diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e4221b7..1617e636 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -204,6 +204,46 @@ jobs: run: | dotnet test --configuration Debug --no-build --logger:"console;verbosity=detailed" api-validator-dotnet + + - name: Create TestRun + run: | + testit testrun create --token ${{ env.TMS_PRIVATE_TOKEN }} --output ${{ env.TEMP_FILE }} + echo "TMS_TEST_RUN_ID=$(<${{ env.TEMP_FILE }})" >> $GITHUB_ENV + + - name: Test importRealtime=true + run: | + export TMS_IMPORT_REALTIME=true + cd java-examples/${{ matrix.project_name }} + + SYNC_STORAGE_BIN=".caches/syncstorage-linux-amd64" + pkill -f "syncstorage.*--port 49152" || true + pkill -f "syncstorage-linux-amd64" || true + sleep 1 + + nohup "$SYNC_STORAGE_BIN" --testRunId ${{ env.TMS_TEST_RUN_ID }} --port 49152 \ + --baseURL ${{ env.TMS_URL }} --privateToken ${{ env.TMS_PRIVATE_TOKEN }} > service.log 2>&1 & + + sleep 1 + + curl -v http://127.0.0.1:49152/health || true + + chmod +x ./gradlew + ./gradlew test --no-daemon -DtmsUrl=${{ env.TMS_URL }} -DtmsPrivateToken=${{ env.TMS_PRIVATE_TOKEN }} \ + -DtmsProjectId=${{ env.TMS_PROJECT_ID }} -DtmsConfigurationId=${{ env.TMS_CONFIGURATION_ID }} \ + -DtmsAdapterMode=${{ env.TMS_ADAPTER_MODE }} -DtmsTestRunId=${{ env.TMS_TEST_RUN_ID }} \ + -DtmsCertValidation=${{ env.TMS_CERT_VALIDATION }} || true # ignore error code + + sleep 1 + + curl -v http://127.0.0.1:49152/wait-completion?testRunId=${{ env.TMS_TEST_RUN_ID }} --max-time 100 || true + + cat service.log + + - name: Validate importRealtime=true + run: | + dotnet test --configuration Debug --no-build --logger:"console;verbosity=detailed" api-validator-dotnet + + - name: Test Adapter Mode 2 run: | export TMS_ADAPTER_MODE=2 diff --git a/docs/README.md b/docs/README.md index 796ccb9e..1717079b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ Supplementary documentation for behaviour and performance changes that are not f | Document | Topic | |----------|--------| +| [improvements-2026-05-19.md](./improvements-2026-05-19.md) | **2026-05-19** changelog spec: `importRealtime`, CI, bulk lifecycle (Cucumber/JBehave), Selenide, Serenity | | [bulk-import-autotest-tms.md](./bulk-import-autotest-tms.md) | `importRealtime=false`: bulk autotest create/update, `sendTestResults` batching, parallelism, dedupe, skipping unchanged autotests | | [cucumber-bulk-import-lifecycle.md](./cucumber-bulk-import-lifecycle.md) | Cucumber: one `stopMainContainer` per run, class container retention, deduped class list | | [jbehave-meta-external-id-and-parameters.md](./jbehave-meta-external-id-and-parameters.md) | JBehave: Meta key shapes (`@Key`, `Key=value`), Examples substitution, `ExternalId` | diff --git a/docs/improvements-2026-06-16.md b/docs/improvements-2026-06-16.md new file mode 100644 index 00000000..729e2535 --- /dev/null +++ b/docs/improvements-2026-06-16.md @@ -0,0 +1,264 @@ +# Improvements specification — 2026-06-16 + +Technical specification of adapter, CI, and bulk-import improvements delivered or prepared on **2026-05-19**. +Audience: maintainers and reviewers integrating changes across branches. + +--- + +## 1. Summary + +| Area | Goal | Status | +|------|------|--------| +| `importRealtime` defaults & realtime safety | Align config with docs; avoid NPE on fixture updates | Merged (`feat/import-realtime-improvements`) | +| E2E CI: `importRealtime=true` pass | Cover realtime path in addition to bulk default | Merged | +| Bulk diagnostics: `mainContainerUuid` | Scope tree-vs-storage warnings per main container | Merged | +| Cucumber bulk lifecycle | One `writeTests` per run; retain class `children` | In codebase (see linked doc) | +| JBehave bulk lifecycle | One main container per embedder run | Local change on `BaseJbehaveListener` | +| Selenide / WebDriver message stability | Reduce flaky `api-validator-dotnet` failures in CI | Branch `fix/TMS-38717-selenide-serenity` | +| Serenity Scenario Outline `externalId` | Unique autotest id per Examples row | Branch `fix/TMS-38717-selenide-serenity` + `java-examples` PR | + +--- + +## 2. `importRealtime` configuration and realtime writer + +### 2.1 Problem + +- `AppProperties.validateProperties` defaulted missing/invalid `importRealtime` to **`true`**, while README, `ClientConfiguration`, and E2E workflow used **`false`** (bulk mode). +- With `importRealtime=true`, `HttpWriter.updateTestResults` could call TMS APIs with a **null** `testResultId` when setup/teardown updates ran before the result id was registered → **NPE**. + +### 2.2 Changes + +**`AppProperties.java`** + +- Invalid or missing `importRealtime` now defaults to **`false`** (bulk import), consistent with documentation and `TMS_IMPORT_REALTIME: false` in `.github/workflows/test.yml`. + +**`HttpWriter.java`** + +- Before `apiClient.getTestResult(testResultId)`, guard: + - if `testResultId == null` → log warning with `testUuid` and `externalId`, **skip** setup/teardown update (no NPE). + +### 2.3 Acceptance criteria + +- Adapter starts with no `importRealtime` property → bulk mode. +- Realtime run with a missing result id logs a warning and continues instead of failing the worker. + +--- + +## 3. E2E CI: second pass with `importRealtime=true` + +### 3.1 Problem + +Only the bulk path (`importRealtime=false`) was validated in CI. Realtime regressions were undetected. + +### 3.2 Changes (`.github/workflows/test.yml`) + +After the default bulk run and `api-validator-dotnet` validation: + +1. **Create a new test run** (`testit testrun create`). +2. **`Test importRealtime=true`** + - `export TMS_IMPORT_REALTIME=true` + - Reuse sync-storage binary from `.caches/syncstorage-linux-amd64` (no second `wget`). + - `pkill` previous sync-storage process; restart with the new `TMS_TEST_RUN_ID`. + - Run `./gradlew test` with the same TMS properties. +3. **`Validate importRealtime=true`** — run `api-validator-dotnet` again. + +Default workflow env remains `TMS_IMPORT_REALTIME: false` for the first pass. + +### 3.3 Acceptance criteria + +- Matrix projects pass validator for both bulk and realtime modes. +- Sync-storage is restarted cleanly between passes (no port/process leak). + +--- + +## 4. Bulk import diagnostics — `mainContainerUuid` + +### 4.1 Problem + +`HttpWriter.logBulkImportTreeVsStorageDiagnostics` compared the class-container tree to **all** test results in storage. With parallel classes or multiple mains, warnings were **false positives**. + +### 4.2 Changes + +**`TestResult`** + +- Field `mainContainerUuid` (already present) is now populated by listeners when scheduling tests. + +**Listeners** — `.setMainContainerUuid(launcherUUID.get())` (or run-level uuid for JBehave) on `TestResult`: + +- `BaseCucumber4Listener` … `BaseCucumber7Listener` +- `BaseJunit4Listener` +- `BaseJbehaveListener` (run-level `runMainUuid`) + +**`HttpWriter.storedTestUuidsForBulkDiagnostics`** + +- Prefer `storage.getTestResultUuidsForMainContainer(container.getUuid())`. +- Fall back to global storage only when no scoped results exist (legacy adapters). + +### 4.3 Notes + +- **Does not affect TMS export** — diagnostics only. +- Reduces noise when debugging “tests not linked under class tree” bulk skips. + +--- + +## 5. Cucumber — bulk container lifecycle + +Documented in detail: [cucumber-bulk-import-lifecycle.md](./cucumber-bulk-import-lifecycle.md). + +### 5.1 Before + +- `stopMainContainer` after **every scenario** → `writeTests` called N times (quadratic bulk work). +- `startClassContainer` replaced storage entry each scenario → **lost** accumulated `children`. +- Duplicate class UUIDs in `MainContainer.children` → duplicate bulk processing. + +### 5.2 After (Cucumber 4–7) + +| Event | Action | +|-------|--------| +| `TestRunStarted` | Clear `MAIN_UUIDS_PENDING_FINALIZE` | +| `TestCaseStarted` | `startMainContainer` (idempotent per thread uuid), `startClassContainer`, schedule test | +| `TestCaseFinished` | `stopTestCase`, `stopClassContainer` — **no** `stopMainContainer` | +| `TestRunFinished` | `stopMainContainer` once per registered main uuid (parallel-safe set) | + +**`AdapterContainerHelper`** + +- Reuse existing class container instance (refresh `start` only). +- Append class uuid to main **once** (dedupe). + +**`HttpWriter`** + +- Walk unique class ids (`LinkedHashSet`) during bulk import. + +--- + +## 6. JBehave — bulk container lifecycle (new) + +### 6.1 Problem + +`BaseJbehaveListener` previously created and finalized **main + class containers per scenario** — same bulk cost as pre-fix Cucumber (one `writeTests` per scenario). + +### 6.2 Target behaviour (aligned with Cucumber) + +| Event | Action | +|-------|--------| +| `beforeStory` (first call → `startAdapterLaunch`) | Reset run state; one shared `runMainUuid`; new `ClassContainer` per story file | +| `beforeScenario` / `example` | Schedule/start test; `updateClassContainer` only | +| `afterScenario` | `stopTestCase` only | +| `afterStory` | `stopClassContainer` for that story | +| last `afterStory` (`storyClassUuids` empty) | `finalizeRunMainContainers()` → single `writeTests` | + +### 6.3 Thread safety + +- `afterScenario` may run on a **different thread** than `beforeScenario` (documented JBehave behaviour). +- Container uuids stored in `ConcurrentHashMap` keyed by `story.getPath()`. +- `scenarioClassUuid` set in `ThreadLocal` during `beforeScenario` for `example` / `afterScenario`. + +### 6.4 Example impact + +`multiStory.story` (3 scenarios): **3× `writeTests`** → **1× `writeTests`** + **3× `writeClass`** (one class stop per story file). + +### 6.5 File + +- `testit-adapter-jbehave/src/main/java/ru/testit/listener/BaseJbehaveListener.java` + +--- + +## 7. Selenide / WebDriver — stable failure messages (CI) + +Branch: **`fix/TMS-38717-selenide-serenity`** (`41fafa1`). + +### 7.1 Problem + +`selenide-gradle-junit5` E2E failed intermittently in `api-validator-dotnet` because Chrome session startup errors differed between CI runs: + +- Host name / IP in “Host info” +- Varying “session not created” reason text +- Different `java.version` / `os.version` in stack traces + +### 7.2 Changes + +**`Utils.normalizeWebDriverMessage(String)`** + +- Applied only when message contains `Could not start a new session`. +- Strips host info; normalizes session-not-created line; masks Java/OS version strings. + +**`Converter`** + +- Normalizes `throwable.getMessage()` and stack trace before HTML escape and TMS export. + +**`NormalizeWebDriverMessageTest`** + +- Unit tests for normalization rules. + +**`.github/workflows/test.yml` (selenide matrix row)** + +- `needs_chrome: true` → `browser-actions/setup-chrome@v1`. +- Headless Selenide opts with isolated `--user-data-dir` per run (`run1` / `run2` for adapter mode 2). +- `pkill` chromedriver/chrome and cleanup between test steps. + +### 7.3 Follow-up + +- Update expected artifacts in `api-validator-dotnet` for selenide after stable Chrome messages. +- Optional: `closeWebDriver()` in `java-examples` selenide project. + +--- + +## 8. Serenity / Cucumber Scenario Outline — unique `externalId` + +Branch: **`fix/TMS-38717-selenide-serenity`**. + +### 8.1 Problem + +Scenario Outline “Summing” used `@ExternalId={result}` — **same** `externalId` for all Examples rows → multiple test results shared one `autoTestId` → **non-deterministic order** in validator. + +**Constraint:** do **not** change `TagParser` / `externalId` derivation logic in commons. + +### 8.2 Solution + +Explicit per-row tag in feature files: + +```gherkin +@ExternalId=Summing_{left}_{right}_{result} +Scenario Outline: Summing +``` + +**In-repo example updated (branch):** + +- `testit-adapter-cucumber7/src/test/resources/features/parameterized.feature` + +**Required in `java-examples`:** + +- `serenity2-gradle-junit4` and `serenity3-gradle-junit4` feature files (separate PR). + +### 8.3 Cucumber listener hardening (already in codebase) + +- `testFinished`: `updateTestCase` with `setThrowable` when scenario fails. +- Hook failures: propagate throwable via `updateTestCase` (not only `Adapter.addMessage`). + +--- + +## 9. Related existing documentation + +| Document | Topic | +|----------|--------| +| [bulk-import-autotest-tms.md](./bulk-import-autotest-tms.md) | Bulk autotest create/update, batching, dedupe | +| [cucumber-bulk-import-lifecycle.md](./cucumber-bulk-import-lifecycle.md) | Cucumber main/class lifecycle | +| [jbehave-meta-external-id-and-parameters.md](./jbehave-meta-external-id-and-parameters.md) | JBehave Meta / `ExternalId` syntax | + +--- + +## 10. Integration checklist + +- [ ] Merge `feat/import-realtime-improvements` (importRealtime default, CI realtime pass, `mainContainerUuid`). +- [ ] Merge `fix/TMS-38717-selenide-serenity` (WebDriver normalization, CI Chrome, parameterized `externalId`). +- [ ] Merge JBehave `BaseJbehaveListener` bulk lifecycle. +- [ ] PR to `java-examples`: Serenity `@ExternalId=Summing_{left}_{right}_{result}`. +- [ ] Refresh `api-validator-dotnet` expected data for selenide if needed after Chrome stabilization. + +--- + +## 11. Out of scope (not changed here) + +- Python `@testit.step("write: {text}")` parameter substitution (`adapters-python`). +- Sync Storage hybrid import path beyond existing worker lifecycle. +- Changing global `externalId` hashing in `TagParser` for Scenario Outlines. diff --git a/gradle.properties b/gradle.properties index 4f45861c..fcb83353 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=3.1.0 +version=3.1.1 org.gradle.daemon=true org.gradle.parallel=true diff --git a/testit-adapter-cucumber4/src/main/java/ru/testit/listener/BaseCucumber4Listener.java b/testit-adapter-cucumber4/src/main/java/ru/testit/listener/BaseCucumber4Listener.java index 03bfd9e7..f77b22a9 100644 --- a/testit-adapter-cucumber4/src/main/java/ru/testit/listener/BaseCucumber4Listener.java +++ b/testit-adapter-cucumber4/src/main/java/ru/testit/listener/BaseCucumber4Listener.java @@ -125,6 +125,7 @@ public void testStarted(final TestCaseStarted event) { final TestResult result = new TestResult() .setUuid(uuid) + .setMainContainerUuid(launcherUUID.get()) .setExternalId(tagParser.getExternalIdValue()) .setName(tagParser.getDisplayNameValue()) .setTitle(tagParser.getTitleValue()) diff --git a/testit-adapter-cucumber5/src/main/java/ru/testit/listener/BaseCucumber5Listener.java b/testit-adapter-cucumber5/src/main/java/ru/testit/listener/BaseCucumber5Listener.java index de579d97..b895bb3f 100644 --- a/testit-adapter-cucumber5/src/main/java/ru/testit/listener/BaseCucumber5Listener.java +++ b/testit-adapter-cucumber5/src/main/java/ru/testit/listener/BaseCucumber5Listener.java @@ -125,6 +125,7 @@ public void testStarted(final TestCaseStarted event) { final TestResult result = new TestResult() .setUuid(uuid) + .setMainContainerUuid(launcherUUID.get()) .setExternalId(tagParser.getExternalIdValue()) .setName(tagParser.getDisplayNameValue()) .setTitle(tagParser.getTitleValue()) diff --git a/testit-adapter-cucumber6/src/main/java/ru/testit/listener/BaseCucumber6Listener.java b/testit-adapter-cucumber6/src/main/java/ru/testit/listener/BaseCucumber6Listener.java index 10d2db77..b8c524e0 100644 --- a/testit-adapter-cucumber6/src/main/java/ru/testit/listener/BaseCucumber6Listener.java +++ b/testit-adapter-cucumber6/src/main/java/ru/testit/listener/BaseCucumber6Listener.java @@ -128,6 +128,7 @@ public void testStarted(final TestCaseStarted event) { final TestResult result = new TestResult() .setUuid(uuid) + .setMainContainerUuid(launcherUUID.get()) .setExternalId(tagParser.getExternalIdValue()) .setName(tagParser.getDisplayNameValue()) .setTitle(tagParser.getTitleValue()) diff --git a/testit-adapter-cucumber7/src/main/java/ru/testit/listener/BaseCucumber7Listener.java b/testit-adapter-cucumber7/src/main/java/ru/testit/listener/BaseCucumber7Listener.java index fe3a368d..4613bfd8 100644 --- a/testit-adapter-cucumber7/src/main/java/ru/testit/listener/BaseCucumber7Listener.java +++ b/testit-adapter-cucumber7/src/main/java/ru/testit/listener/BaseCucumber7Listener.java @@ -133,6 +133,7 @@ public void testStarted(final TestCaseStarted event) { final TestResult result = new TestResult() .setUuid(uuid) + .setMainContainerUuid(launcherUUID.get()) .setExternalId(tagParser.getExternalIdValue()) .setName(tagParser.getDisplayNameValue()) .setTitle(tagParser.getTitleValue()) diff --git a/testit-adapter-jbehave/src/main/java/ru/testit/listener/BaseJbehaveListener.java b/testit-adapter-jbehave/src/main/java/ru/testit/listener/BaseJbehaveListener.java index 8a354070..a267b729 100644 --- a/testit-adapter-jbehave/src/main/java/ru/testit/listener/BaseJbehaveListener.java +++ b/testit-adapter-jbehave/src/main/java/ru/testit/listener/BaseJbehaveListener.java @@ -11,19 +11,25 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; import static java.util.Objects.nonNull; public class BaseJbehaveListener extends NullStoryReporter { + private static final Set MAIN_UUIDS_PENDING_FINALIZE = ConcurrentHashMap.newKeySet(); + private final AdapterManager adapterManager; private final ThreadLocal executableTest = ThreadLocal.withInitial(ExecutableTest::new); private final ThreadLocal executableStory = new InheritableThreadLocal<>(); private final ThreadLocal executableScenario = new InheritableThreadLocal<>(); + /** Set in {@link #beforeScenario} — {@code afterScenario} may run on another thread. */ + private final ThreadLocal scenarioClassUuid = new ThreadLocal<>(); private final List exampleUuids = new ArrayList<>(); - /** Must be {@link ThreadLocal#set} in {@link #beforeScenario} — {@code withInitial} breaks when afterScenario runs on another thread. */ - private final ThreadLocal launcherUUID = new ThreadLocal<>(); - private final ThreadLocal classUUID = new ThreadLocal<>(); + private final ConcurrentHashMap storyClassUuids = new ConcurrentHashMap<>(); + private final AtomicReference runMainUuid = new AtomicReference<>(); private boolean adapterLaunchIsStarted = false; public BaseJbehaveListener() { @@ -31,11 +37,22 @@ public BaseJbehaveListener() { } private void startAdapterLaunch() { + MAIN_UUIDS_PENDING_FINALIZE.clear(); + runMainUuid.set(null); + storyClassUuids.clear(); adapterManager.startTests(); - adapterLaunchIsStarted = true; } + private void finalizeRunMainContainers() { + for (String uuid : new ArrayList<>(MAIN_UUIDS_PENDING_FINALIZE)) { + adapterManager.stopMainContainer(uuid); + } + MAIN_UUIDS_PENDING_FINALIZE.clear(); + runMainUuid.set(null); + storyClassUuids.clear(); + } + @Override public void beforeStory(final Story story, final boolean givenStory) { if (!adapterLaunchIsStarted) { @@ -44,31 +61,40 @@ public void beforeStory(final Story story, final boolean givenStory) { if (!givenStory) { executableStory.set(story); + + final String mainUuid = runMainUuid.updateAndGet( + existing -> existing != null ? existing : UUID.randomUUID().toString()); + if (MAIN_UUIDS_PENDING_FINALIZE.add(mainUuid)) { + adapterManager.startMainContainer(new MainContainer().setUuid(mainUuid)); + } + + final String classUuid = UUID.randomUUID().toString(); + storyClassUuids.put(story.getPath(), classUuid); + adapterManager.startClassContainer(mainUuid, new ClassContainer().setUuid(classUuid)); } } @Override public void afterStory(final boolean givenStory) { if (!givenStory) { + final Story story = executableStory.get(); + if (story != null) { + final String classUuid = storyClassUuids.remove(story.getPath()); + if (classUuid != null) { + adapterManager.stopClassContainer(classUuid); + } + if (storyClassUuids.isEmpty()) { + finalizeRunMainContainers(); + } + } executableStory.remove(); } } @Override public void beforeScenario(final Scenario scenario) { - final String mainUuid = UUID.randomUUID().toString(); - final String classU = UUID.randomUUID().toString(); - launcherUUID.set(mainUuid); - classUUID.set(classU); - - final MainContainer mainContainer = new MainContainer() - .setUuid(mainUuid); - final ClassContainer classContainer = new ClassContainer() - .setUuid(classU); - - adapterManager.startMainContainer(mainContainer); - adapterManager.startClassContainer(mainUuid, classContainer); - + final String classUuid = storyClassUuids.get(executableStory.get().getPath()); + scenarioClassUuid.set(classUuid); executableScenario.set(scenario); if (notParameterised(scenario)) { @@ -82,10 +108,10 @@ public void beforeScenario(final Scenario scenario) { final String uuid = test.getUuid(); - adapterManager.updateClassContainer(classUUID.get(), + adapterManager.updateClassContainer(classUuid, container -> container.getChildren().add(uuid)); - startTestCase(scenario, uuid, null, mainUuid); + startTestCase(scenario, uuid, null, runMainUuid.get()); } } @@ -124,15 +150,16 @@ public void example(final Map tableRow, final int exampleIndex) test.setTestStatus(); final String uuid = test.getUuid(); + final String classUuid = scenarioClassUuid.get(); - adapterManager.updateClassContainer(classUUID.get(), + adapterManager.updateClassContainer(classUuid, container -> container.getChildren().add(uuid)); exampleUuids.add(uuid); startTestCase( executableScenario.get(), uuid, tableRow, - launcherUUID.get()); + runMainUuid.get()); } @Override @@ -150,16 +177,7 @@ public void afterScenario() { exampleUuids.clear(); } - final String classU = classUUID.get(); - final String mainUuid = launcherUUID.get(); - if (classU != null) { - adapterManager.stopClassContainer(classU); - } - if (mainUuid != null) { - adapterManager.stopMainContainer(mainUuid); - } - classUUID.remove(); - launcherUUID.remove(); + scenarioClassUuid.remove(); executableTest.remove(); } diff --git a/testit-adapter-junit4/src/main/java/ru/testit/listener/BaseJunit4Listener.java b/testit-adapter-junit4/src/main/java/ru/testit/listener/BaseJunit4Listener.java index 3596c321..a523b46b 100644 --- a/testit-adapter-junit4/src/main/java/ru/testit/listener/BaseJunit4Listener.java +++ b/testit-adapter-junit4/src/main/java/ru/testit/listener/BaseJunit4Listener.java @@ -263,6 +263,7 @@ protected void startTestCase(Description method, final String uuid) { final TestResult result = new TestResult() .setUuid(uuid) + .setMainContainerUuid(launcherUUID.get()) .setLabels(Utils.extractLabels(method)) .setTags(Utils.extractTags(method)) .setExternalId(Utils.extractExternalID(method)) diff --git a/testit-java-commons/src/main/java/ru/testit/properties/AppProperties.java b/testit-java-commons/src/main/java/ru/testit/properties/AppProperties.java index 14f496bf..c34af9ae 100644 --- a/testit-java-commons/src/main/java/ru/testit/properties/AppProperties.java +++ b/testit-java-commons/src/main/java/ru/testit/properties/AppProperties.java @@ -353,8 +353,8 @@ private static Properties validateProperties(Properties properties) { String tmsImportRealtime = properties.getProperty(TMS_IMPORT_REALTIME); if (!Objects.equals(tmsImportRealtime, "false") && !Objects.equals(tmsImportRealtime, "true")) { - log.warn("Invalid tmsImportRealtime: {}. Use default value instead: true", tmsImportRealtime); - properties.setProperty(TMS_IMPORT_REALTIME, "true"); + log.warn("Invalid tmsImportRealtime: {}. Use default value instead: false", tmsImportRealtime); + properties.setProperty(TMS_IMPORT_REALTIME, "false"); } String errors = errorsBuilder.toString(); diff --git a/testit-java-commons/src/main/java/ru/testit/syncstorage/ClientWrapper.java b/testit-java-commons/src/main/java/ru/testit/syncstorage/ClientWrapper.java index 9532a47b..f3bac86c 100644 --- a/testit-java-commons/src/main/java/ru/testit/syncstorage/ClientWrapper.java +++ b/testit-java-commons/src/main/java/ru/testit/syncstorage/ClientWrapper.java @@ -11,6 +11,10 @@ import ru.testit.syncstorage.model.SetWorkerStatusRequest; import ru.testit.syncstorage.model.TestResultCutApiModel; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; @@ -22,6 +26,7 @@ public class ClientWrapper { private static final Logger LOGGER = LoggerFactory.getLogger(ClientWrapper.class); private static final int API_CONNECT_TIMEOUT_MS = 10000; private static final int API_READ_TIMEOUT_MS = 10000; + private static final int KEEP_ALIVE_TIMEOUT_MS = 5000; private static final int RETRY_MAX_ATTEMPTS = 5; private static final long RETRY_DELAY_MS = 1000L; @@ -63,6 +68,49 @@ public boolean setWorkerStatus( return Boolean.TRUE.equals(result); } + /** + * Single non-retried ping; failures are logged and ignored (see sync-storage KEEP_ALIVE.md). + */ + public boolean sendKeepAlive(String url, String pid, String testRunId) { + if (pid == null || pid.isEmpty() || testRunId == null || testRunId.isEmpty()) { + return false; + } + try { + HttpURLConnection connection = (HttpURLConnection) new URL(url + "/keep_alive").openConnection(); + connection.setRequestMethod("POST"); + connection.setConnectTimeout(KEEP_ALIVE_TIMEOUT_MS); + connection.setReadTimeout(KEEP_ALIVE_TIMEOUT_MS); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + + String body = "{\"pid\":\"" + jsonEscape(pid) + "\",\"testRunId\":\"" + jsonEscape(testRunId) + "\"}"; + try (OutputStream out = connection.getOutputStream()) { + out.write(body.getBytes(StandardCharsets.UTF_8)); + } + + int responseCode = connection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + LOGGER.trace("keep_alive accepted for pid={}", pid); + return true; + } + if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) { + LOGGER.warn("keep_alive: worker not found (pid={})", pid); + } else { + LOGGER.debug("keep_alive failed: HTTP {} (pid={})", responseCode, pid); + } + return false; + } catch (Exception e) { + LOGGER.debug("keep_alive failed for pid={}: {}", pid, e.getMessage()); + return false; + } + } + + private static String jsonEscape(String value) { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\""); + } + public boolean sendTestResultToSyncStorage( String url, String testRunId, diff --git a/testit-java-commons/src/main/java/ru/testit/syncstorage/SyncStorageKeepAlive.java b/testit-java-commons/src/main/java/ru/testit/syncstorage/SyncStorageKeepAlive.java new file mode 100644 index 00000000..1aeb9fe4 --- /dev/null +++ b/testit-java-commons/src/main/java/ru/testit/syncstorage/SyncStorageKeepAlive.java @@ -0,0 +1,78 @@ +package ru.testit.syncstorage; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Background keep-alive pings for sync-storage while the worker stays {@code in_progress}. + * Resets stuck-detection timers without changing worker status. + */ +final class SyncStorageKeepAlive { + + private static final Logger LOGGER = LoggerFactory.getLogger(SyncStorageKeepAlive.class); + static final int DEFAULT_INTERVAL_SEC = 25; + + private final ClientWrapper clientWrapper; + private final String url; + private final String pid; + private final String testRunId; + private final int intervalSec; + + private volatile boolean running; + private Thread thread; + + SyncStorageKeepAlive( + ClientWrapper clientWrapper, + String url, + String pid, + String testRunId + ) { + this(clientWrapper, url, pid, testRunId, DEFAULT_INTERVAL_SEC); + } + + SyncStorageKeepAlive( + ClientWrapper clientWrapper, + String url, + String pid, + String testRunId, + int intervalSec + ) { + this.clientWrapper = clientWrapper; + this.url = url; + this.pid = pid; + this.testRunId = testRunId; + this.intervalSec = intervalSec > 0 ? intervalSec : DEFAULT_INTERVAL_SEC; + } + + synchronized void start() { + if (running) { + return; + } + running = true; + thread = new Thread(this::loop, "sync-storage-keep-alive"); + thread.setDaemon(true); + thread.start(); + LOGGER.debug("SyncStorage keep-alive started (interval {}s, pid={})", intervalSec, pid); + } + + synchronized void stop() { + running = false; + if (thread != null) { + thread.interrupt(); + thread = null; + } + LOGGER.debug("SyncStorage keep-alive stopped (pid={})", pid); + } + + private void loop() { + while (running) { + clientWrapper.sendKeepAlive(url, pid, testRunId); + try { + Thread.sleep(intervalSec * 1000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } +} diff --git a/testit-java-commons/src/main/java/ru/testit/syncstorage/SyncStorageRunner.java b/testit-java-commons/src/main/java/ru/testit/syncstorage/SyncStorageRunner.java index b574ee08..7ae1d366 100644 --- a/testit-java-commons/src/main/java/ru/testit/syncstorage/SyncStorageRunner.java +++ b/testit-java-commons/src/main/java/ru/testit/syncstorage/SyncStorageRunner.java @@ -44,12 +44,13 @@ public class SyncStorageRunner { private boolean isExternal = false; - private static final String SYNC_STORAGE_VERSION = "v0.3.2"; + private static final String SYNC_STORAGE_VERSION = "v0.3.7-tms-5.7"; private static final String SYNC_STORAGE_REPO_URL ="https://github.com/testit-tms/sync-storage-public/releases/download/"; private static final String AMD64 = "amd64"; private static final String ARM64 = "arm64"; private final ClientWrapper clientWrapper = new ClientWrapper(); + private SyncStorageKeepAlive keepAlive; public SyncStorageRunner( String testRunId, @@ -489,9 +490,28 @@ private boolean registerWorker() { LOGGER.debug("Worker registered successfully, PID: {}", workerPid); } + startKeepAlive(); return true; } + private void startKeepAlive() { + if (workerPid == null || workerPid.isEmpty()) { + return; + } + if (keepAlive != null) { + keepAlive.stop(); + } + keepAlive = new SyncStorageKeepAlive(clientWrapper, getUrl(), workerPid, testRunId); + keepAlive.start(); + } + + void stopKeepAlive() { + if (keepAlive != null) { + keepAlive.stop(); + keepAlive = null; + } + } + /** * Get worker PID */ diff --git a/testit-java-commons/src/main/java/ru/testit/syncstorage/SyncStorageService.java b/testit-java-commons/src/main/java/ru/testit/syncstorage/SyncStorageService.java index ee0a7899..5b61a2ae 100644 --- a/testit-java-commons/src/main/java/ru/testit/syncstorage/SyncStorageService.java +++ b/testit-java-commons/src/main/java/ru/testit/syncstorage/SyncStorageService.java @@ -115,6 +115,9 @@ public void setWorkerStatus(String pid, String status) { status, pid ); + if ("completed".equalsIgnoreCase(status)) { + syncStorageRunner.stopKeepAlive(); + } } else { LOGGER.warn( "Failed to set status {} for worker with PID: {}, continue without sync-storage", diff --git a/testit-java-commons/src/main/java/ru/testit/writers/HttpWriter.java b/testit-java-commons/src/main/java/ru/testit/writers/HttpWriter.java index 1d359878..1468d76d 100644 --- a/testit-java-commons/src/main/java/ru/testit/writers/HttpWriter.java +++ b/testit-java-commons/src/main/java/ru/testit/writers/HttpWriter.java @@ -236,6 +236,15 @@ private void updateTestResults(MainContainer container) { autoTestResultsForTestRunModel.setTeardownResults(afterResultFinish); UUID testResultId = testResults.get(test.getUuid()); + if (testResultId == null) { + LOGGER.warn( + "Realtime import: no test result id for testUuid={}, externalId={}; " + + "skip setup/teardown result update", + test.getUuid(), + test.getExternalId() + ); + return; + } TestResultResponse resultModel = apiClient.getTestResult(testResultId); TestResultUpdateV2Request model = Converter.testResultToTestResultUpdateModel(resultModel); diff --git a/testit-java-commons/src/test/java/ru/testit/syncstorage/SyncStorageKeepAliveTest.java b/testit-java-commons/src/test/java/ru/testit/syncstorage/SyncStorageKeepAliveTest.java new file mode 100644 index 00000000..9464ce4d --- /dev/null +++ b/testit-java-commons/src/test/java/ru/testit/syncstorage/SyncStorageKeepAliveTest.java @@ -0,0 +1,33 @@ +package ru.testit.syncstorage; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SyncStorageKeepAliveTest { + + @Test + void sendKeepAlive_rejectsBlankPid() { + ClientWrapper client = new ClientWrapper(); + assertFalse(client.sendKeepAlive("http://127.0.0.1:1", "", "run-1")); + assertFalse(client.sendKeepAlive("http://127.0.0.1:1", "w1", "")); + } + + @Test + void keepAlive_stopsOnInterrupt() throws InterruptedException { + ClientWrapper client = new ClientWrapper() { + @Override + public boolean sendKeepAlive(String url, String pid, String testRunId) { + return true; + } + }; + SyncStorageKeepAlive keepAlive = new SyncStorageKeepAlive( + client, "http://127.0.0.1:1", "w1", "run-1", 1 + ); + keepAlive.start(); + Thread.sleep(50); + keepAlive.stop(); + assertTrue(true); + } +}