Skip to content

Fix CSSWatcher live reload: drop stale bindings + extract m2 designer jar#4929

Merged
shai-almog merged 3 commits into
masterfrom
fix/csswatcher-live-reload-regression
May 12, 2026
Merged

Fix CSSWatcher live reload: drop stale bindings + extract m2 designer jar#4929
shai-almog merged 3 commits into
masterfrom
fix/csswatcher-live-reload-regression

Conversation

@shai-almog
Copy link
Copy Markdown
Collaborator

Summary

CSSWatcher's live reload stopped applying CSS edits in two distinct ways after the recent CSS/localization push. This PR fixes both, adds regression tests, and wires the maven/javase test module into CI so similar regressions are caught next time.

1. addThemeProps was stomping user edits with stale @cn1-bind entries

PR #4884 added applyThemeBindings() inside UIManager.buildTheme so a single addThemeProps({"@accent-color": ...}) override could retune every var()-bound theme key. CSSWatcher reloads the recompiled theme.res through the same code path — but addThemeProps never clears themeConstants.

When the user replaced a var() CSS rule with a literal, the recompiled theme.res no longer emitted the matching @cn1-bind:<key> entry, but the previous binding was still sitting in themeConstants from the initial load. applyThemeBindings happily re-overlaid the user's fresh literal value with the stale binding's resolved value, so the visible change disappeared on every reload.

Fix: in buildTheme, before iterating the incoming Hashtable, detect any binding whose subject style key the new load is re-setting without re-asserting the binding alongside, and drop those bindings before the overlay pass. Pure @accent-color-only overrides keep working because they don't carry style keys, so no bindings are considered stale.

2. MavenUtils.findDesignerJarInM2 returned the unrunnable wrapper zip

PR #4852 added an m2 fallback for the CSSWatcher's designer-jar lookup, used whenever -Dcodename1.designer.jar isn't passed in (e.g. simulator launched from the IDE rather than mvn cn1:run). The helper returned codenameone-designer-<v>-jar-with-dependencies.jar directly from m2 — but that artifact is a zip wrapper containing a single inner designer_1.jar (see maven/designer/pom.xml's antrun step), with no top-level Main-Class manifest. java -jar wrapper.zip fails with "no main manifest attribute", the CSS subprocess never starts, and the watcher silently waits for ::refresh:: lines that never come.

Fix: mirror AbstractCN1Mojo.getDesignerJar's pattern — unzip the wrapper to an <artifact>.jar-extracted/ sibling on demand and return the inner designer_1.jar so java -jar actually launches.

Tests

  • UIManagerThemeBindingsTest gains three regression cases:
    • cssReloadDropsStaleBindingWhenRuleBecomesLiteral — the actual reproducer; fails before the fix
    • cssReloadKeepsBindingWhenStillEmittedTogether — guard against an over-eager fix that drops bindings on every reload
    • overrideOnlyReloadKeepsBindings — repeated @accent-color retunes still work
  • MavenUtilsTest (new) covers the wrapper-vs-inner-jar resolution with five cases: happy path, re-use of extracted jar when the wrapper hasn't changed, re-extract when the wrapper mtime advances, null when the core jar isn't in an m2 layout, and null when the designer artifact is missing.
  • To make those tests actually executable, the javase pom pins maven-surefire-plugin to 3.2.5 (the parent's 2.21.0 doesn't auto-discover JUnit Jupiter — that's why the pre-existing CSSWatcherTest etc. compiled but never ran).
  • pr.yml gets a new "Run JavaSE port unit tests" step. Without it, the CSSWatcher/MavenUtils/JavaSEPort helper regressions would continue to slip through — which is the original gap behind this issue.

Test plan

  • mvn -pl core-unittests test — all binding tests pass, including the new reproducer; pre-existing BorderAndPlafTest.testRoundBorderShadowSpreadAndPaintingCaches flake reproduces on master (cross-test pollution, unrelated to this PR)
  • mvn -pl javase test -Plocal-dev-javase — 15 tests pass (CSSWatcherTest × 2, LocationSimulationTest × 3, JavaSEPortFontMappingTest × 5, MavenUtilsTest × 5)
  • CI matrix (Java 8/17/21) reproduces locally-green results
  • Manual smoke: edit theme.css in a generated initializr project, confirm the simulator's running form picks up both literal and var() changes without restart

🤖 Generated with Claude Code

… jar

Two recent CSS/localization changes regressed the simulator's live CSS
reload, in different ways.

1. addThemeProps stomped user edits with stale @cn1-bind entries.
   PR #4884 added applyThemeBindings() inside UIManager.buildTheme so a
   single addThemeProps({"@accent-color": ...}) override could retune
   every var()-bound theme key. But CSSWatcher reloads the theme through
   the same code path -- and addThemeProps never clears themeConstants.
   When the user replaced a `var()` rule with a literal in their CSS,
   the recompiled theme.res no longer emitted the matching
   `@cn1-bind:<key>` entry, but the previous binding was still sitting
   in themeConstants. applyThemeBindings happily re-overlaid the
   user's fresh literal value with the stale binding's resolved value,
   so the visible change disappeared on every reload.

   Fix: in buildTheme, before iterating the incoming Hashtable, detect
   any binding whose subject style key the new load is re-setting
   without re-asserting the binding alongside, and drop those bindings
   before the overlay pass runs. Pure `@accent-color` overrides keep
   working because they don't carry style keys, so no bindings are
   considered stale.

2. MavenUtils.findDesignerJarInM2 returned the unrunnable wrapper zip.
   PR #4852 added an m2 fallback for the CSSWatcher's designer-jar
   lookup, used whenever -Dcodename1.designer.jar isn't passed in (e.g.
   simulator launched from the IDE rather than `mvn cn1:run`). The
   helper returned `codenameone-designer-<v>-jar-with-dependencies.jar`
   directly from m2 -- but that artifact is a zip wrapper containing a
   single inner designer_1.jar (see maven/designer/pom.xml's antrun
   step), with no top-level Main-Class manifest. `java -jar wrapper.zip`
   fails with "no main manifest attribute", the CSS subprocess never
   starts, and the watcher silently waits for ::refresh:: lines that
   never come.

   Fix: mirror AbstractCN1Mojo.getDesignerJar's pattern -- unzip the
   wrapper to an `<artifact>.jar-extracted/` sibling on demand and
   return the inner designer_1.jar so `java -jar` actually launches.

Tests:

- UIManagerThemeBindingsTest gains three regression cases:
  cssReloadDropsStaleBindingWhenRuleBecomesLiteral (the actual
  reproducer), cssReloadKeepsBindingWhenStillEmittedTogether (guard
  against an over-eager fix), and overrideOnlyReloadKeepsBindings
  (repeated `@accent-color` retunes still work). The first fails
  before the UIManager fix; all three pass after.

- MavenUtilsTest is new and covers the wrapper-vs-inner-jar resolution
  with five cases: happy path, re-use of extracted inner jar when the
  wrapper hasn't changed, re-extract when the wrapper mtime advances,
  null when the core jar isn't in an m2 layout, and null when the
  designer artifact is missing. To make these actually executable, the
  javase pom now pins maven-surefire-plugin to 3.2.5 (the parent's
  2.21.0 doesn't auto-discover JUnit Jupiter). The pre-existing
  CSSWatcherTest + LocationSimulationTest + JavaSEPortFontMappingTest
  in the same module also start running as a side effect.

- pr.yml gets a new "Run JavaSE port unit tests" step so this whole
  test class -- which compiled but never executed -- is wired into CI.
  Without it, regressions in CSSWatcher/MavenUtils/JavaSEPort helpers
  would continue to slip through, which was the original gap the user
  flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread Ports/JavaSE/src/com/codename1/impl/javase/util/MavenUtils.java Fixed
@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 12, 2026

Compared 7 screenshots: 7 matched.
✅ JavaSE simulator integration screenshots matched stored baselines.

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 12, 2026

Compared 15 screenshots: 15 matched.
✅ JavaScript-port screenshot tests passed.

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 12, 2026

Compared 105 screenshots: 105 matched.

Native Android coverage

  • 📊 Line coverage: 11.25% (6218/55280 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 8.94% (30802/344403), branch 3.86% (1266/32764), complexity 4.99% (1567/31415), method 8.73% (1283/14698), class 14.77% (294/1991)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

✅ Native Android screenshot tests passed.

Native Android coverage

  • 📊 Line coverage: 11.25% (6218/55280 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 8.94% (30802/344403), branch 3.86% (1266/32764), complexity 4.99% (1567/31415), method 8.73% (1283/14698), class 14.77% (294/1991)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

Benchmark Results

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 869.000 ms
Base64 CN1 encode 196.000 ms
Base64 encode ratio (CN1/native) 0.226x (77.4% faster)
Base64 native decode 788.000 ms
Base64 CN1 decode 309.000 ms
Base64 decode ratio (CN1/native) 0.392x (60.8% faster)
Image encode benchmark status skipped (SIMD unsupported)

- MavenUtils.extractInnerJar no longer derives a File path from
  ZipEntry.getName(). CodeQL flagged the previous loop as a Zip Slip
  risk because a wrapper containing `../../etc/passwd` would have been
  written outside the extraction directory. The wrapper produced by
  maven/designer/pom.xml has a single designer_1.jar entry by design,
  so the extractor now (a) writes only to a single fixed destination
  path under destDir and (b) only matches entries whose literal name
  equals "designer_1.jar". Anything else is skipped; if the canonical
  entry is absent, the method throws. Two new MavenUtilsTest cases:
  refusesPathTraversalEntriesAndDoesNotWriteOutsideExtractDir packs a
  `../../escaped.txt` entry and asserts no escaped file appears in the
  temp root; skipsUnexpectedEntriesAndStillExtractsDesignerJar mixes
  a README and a subdir/other.jar with the real designer_1.jar and
  asserts only the inner jar lands on disk.

- pr.yml's new "Run JavaSE port unit tests" step failed with
  "Could not find artifact com.codenameone:sqlite-jdbc:jar:8.0-SNAPSHOT"
  on all three matrix entries (Java 8/17/21). The earlier "Build
  Codename One" step builds core-unittests with -am, which doesn't
  install sqlite-jdbc into the local repo. Split the new step into
  two mvn invocations: first install javase's transitive deps without
  running their tests, then run javase's tests in isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 12, 2026

✅ Continuous Quality Report

Test & Coverage

Static Analysis

  • SpotBugs [Report archive]
    • ByteCodeTranslator: 0 findings (no issues)
    • android: 0 findings (no issues)
    • codenameone-maven-plugin: 0 findings (no issues)
    • core-unittests: 0 findings (no issues)
    • ios: 0 findings (no issues)
  • PMD: 0 findings (no issues) [Report archive]
  • Checkstyle: 0 findings (no issues) [Report archive]

Generated automatically by the PR CI workflow.

The previous fix ran the stale-binding preprocessing inside buildTheme,
which is also called from the @includeNativeBool layered initial load
(setThemePropsImpl -> buildTheme -> Display.installNativeTheme() ->
buildTheme(native) -> outer buildTheme(userTheme) continues). After the
native theme installs its bindings into themeConstants, the outer call's
preprocessing would drop them whenever the user's app theme.css set a
literal value for the same UIID -- which the existing iOS / Android
screenshot goldens were captured against.

The iOS PR check hit this: the device-runner log shows the suite ran
fine through ChartCubicLineScreenshotTest and then hung in
ChartBarScreenshotTest setup until the 30-minute timeout fired. The
inconsistent themeConstants state left over once the layered native
bindings were dropped manifests as a hang in chart-component initialization
(presumably a Style.derive cycle or similar) rather than as a pixel
diff.

Move the drop pre-pass out of buildTheme and into a new
dropSupersededBindings() called only from addThemeProps. This keeps
the CSSWatcher reload fix (the actual reported regression) and the
companion regression tests passing, while restoring the original
behavior of the layered initial-load path -- bindings declared by the
native theme via @includeNativeBool stay live, user-app literals don't
silently strip them out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 12, 2026

Compared 104 screenshots: 104 matched.
✅ Native iOS Metal screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 360 seconds

Build and Run Timing

Metric Duration
Simulator Boot 92000 ms
Simulator Boot (Run) 1000 ms
App Install 14000 ms
App Launch 7000 ms
Test Execution 283000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 2198.000 ms
Base64 CN1 encode 2359.000 ms
Base64 encode ratio (CN1/native) 1.073x (7.3% slower)
Base64 native decode 1475.000 ms
Base64 CN1 decode 1311.000 ms
Base64 decode ratio (CN1/native) 0.889x (11.1% faster)
Base64 SIMD encode 1056.000 ms
Base64 encode ratio (SIMD/native) 0.480x (52.0% faster)
Base64 encode ratio (SIMD/CN1) 0.448x (55.2% faster)
Base64 SIMD decode 829.000 ms
Base64 decode ratio (SIMD/native) 0.562x (43.8% faster)
Base64 decode ratio (SIMD/CN1) 0.632x (36.8% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 106.000 ms
Image createMask (SIMD on) 18.000 ms
Image createMask ratio (SIMD on/off) 0.170x (83.0% faster)
Image applyMask (SIMD off) 339.000 ms
Image applyMask (SIMD on) 128.000 ms
Image applyMask ratio (SIMD on/off) 0.378x (62.2% faster)
Image modifyAlpha (SIMD off) 214.000 ms
Image modifyAlpha (SIMD on) 99.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.463x (53.7% faster)
Image modifyAlpha removeColor (SIMD off) 369.000 ms
Image modifyAlpha removeColor (SIMD on) 187.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.507x (49.3% faster)
Image PNG encode (SIMD off) 2085.000 ms
Image PNG encode (SIMD on) 1377.000 ms
Image PNG encode ratio (SIMD on/off) 0.660x (34.0% faster)
Image JPEG encode 1163.000 ms

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 12, 2026

Compared 105 screenshots: 105 matched.
✅ Native iOS screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 329 seconds

Build and Run Timing

Metric Duration
Simulator Boot 119000 ms
Simulator Boot (Run) 1000 ms
App Install 21000 ms
App Launch 11000 ms
Test Execution 310000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 1474.000 ms
Base64 CN1 encode 1427.000 ms
Base64 encode ratio (CN1/native) 0.968x (3.2% faster)
Base64 native decode 936.000 ms
Base64 CN1 decode 1049.000 ms
Base64 decode ratio (CN1/native) 1.121x (12.1% slower)
Base64 SIMD encode 600.000 ms
Base64 encode ratio (SIMD/native) 0.407x (59.3% faster)
Base64 encode ratio (SIMD/CN1) 0.420x (58.0% faster)
Base64 SIMD decode 472.000 ms
Base64 decode ratio (SIMD/native) 0.504x (49.6% faster)
Base64 decode ratio (SIMD/CN1) 0.450x (55.0% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 61.000 ms
Image createMask (SIMD on) 44.000 ms
Image createMask ratio (SIMD on/off) 0.721x (27.9% faster)
Image applyMask (SIMD off) 163.000 ms
Image applyMask (SIMD on) 65.000 ms
Image applyMask ratio (SIMD on/off) 0.399x (60.1% faster)
Image modifyAlpha (SIMD off) 128.000 ms
Image modifyAlpha (SIMD on) 92.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.719x (28.1% faster)
Image modifyAlpha removeColor (SIMD off) 205.000 ms
Image modifyAlpha removeColor (SIMD on) 76.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.371x (62.9% faster)
Image PNG encode (SIMD off) 1926.000 ms
Image PNG encode (SIMD on) 1195.000 ms
Image PNG encode ratio (SIMD on/off) 0.620x (38.0% faster)
Image JPEG encode 685.000 ms

@shai-almog shai-almog merged commit c9d078e into master May 12, 2026
19 of 21 checks passed
shai-almog added a commit that referenced this pull request May 16, 2026
PR #4929 (May 12) bumped codenameone-javase's surefire to 3.2.5 so its
JUnit Jupiter tests would run. Surefire 3.x renamed
`failIfNoTests` -> `failIfNoSpecifiedTests`, and designer.yml's reactor
build invokes `-Dtest=SimpleXmlParserTest -DfailIfNoTests=false` to
suppress "no tests matched" on intermediate modules. The flag was
silently ignored by 3.2.5, so the codenameone-javase test phase began
failing on the next run.

designer.yml's path filter excludes `maven/javase/**`, so the bug
didn't surface on master after the surefire bump - it only manifested
on a PR that touches the designer workflow's trigger paths
(maven/css-compiler/**, maven/designer/**, or CodenameOneDesigner/**).
This PR touches css-compiler, so it surfaces here.

Pass both flag names so each surefire version finds the one it
understands. codenameone-javase (3.2.5) reads
`surefire.failIfNoSpecifiedTests`; peer modules still on parent-pom
2.21.0 read `failIfNoTests`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shai-almog added a commit that referenced this pull request May 16, 2026
Three reviewer-driven changes:

1. Replace `fillLinearGradientWithStops` / `fillRadialGradientWithStops` /
   `fillConicGradient` on Graphics with a single
   `Graphics.fillGradient(Gradient, x, y, w, h)` that consumes a value
   object - shaped like the Shape hierarchy. Three concrete subclasses:

     * LinearGradient(angleDegrees, colors, positions)
     * RadialGradient(colors, positions) + shape/extent/center/radius setters
     * ConicGradient(colors, positions) + fromAngle/center setters

   `Gradient` is a `Paint` subclass with shared stops, cycle method
   (NONE / REPEAT / REFLECT) and a `sampleArgb` hook the base impl uses
   for the software-rasterizer fallback. Ports route through their
   native shader API: Java2D LinearGradientPaint / RadialGradientPaint
   on JavaSE (with AffineTransform for elliptical radials), Android
   LinearGradient / RadialGradient / SweepGradient shaders. iOS still
   falls back to the software rasterizer.

   Style.gradientDescriptor / getGradientDescriptor / setGradientDescriptor
   renamed to Style.gradient / getGradient / setGradient. The .res key
   `bgGradientEx` is unchanged on disk; only the in-memory value type
   changed. The deleted `com.codename1.ui.plaf.GradientDescriptor` had
   no callers outside this branch.

2. Pin maven-surefire-plugin to 3.2.5 uniformly in the parent pom
   (instead of per-module in maven/javase/pom.xml as PR #4929 did).
   Revert the dual-flag hack in designer.yml; the single new
   `surefire.failIfNoSpecifiedTests` flag now suffices everywhere.

3. Fix Android instrumentation suite hang at DrawGradientStops. The
   previous AndroidImplementation.fillXxxWithStops fell through to the
   base-impl software rasterizer when invoked on the Bitmap-graphics
   path used by buffered screenshot variants (asyncView=false). The
   conic kernel does per-pixel atan2 and the linear/radial kernels
   allocate full-size ARGB buffers, which together starved the Android
   emulator GC under the 4x repaint pattern in
   AbstractGraphicsScreenshotTest. After the refactor
   AndroidImplementation.fillGradient unconditionally routes to
   AndroidGraphics.fillGradient which always uses the hardware Shader -
   no per-pixel allocations, no software path.

   Also drop the screenshot capture from CssFilterBlurScreenshotTest:
   `filter:blur()` and `backdrop-filter:blur()` round-trip through the
   .res into Style fields, but Component.paint doesn't yet consume the
   radius (that's a follow-up using Graphics.gaussianBlur). The test
   keeps the field assertions and tells Cn1ssDeviceRunner not to
   screenshot via shouldTakeScreenshot()=false. The `backdrop-filter`
   tile rendered as gray on iOS for exactly this reason - only the
   rgba background was being painted.

Verified by full reactor `mvn install -Plocal-dev-javase`, Android
`mvn -pl android -am compile` under JDK17, hellocodenameone common
compile, and `mvn -pl core-unittests test -Dtest=CSSBorderTest` - all
exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants