Skip to content

fix(plugin-testops): correlate CI uploads with TestOps job runs end-to-end - #906

Merged
epszaw merged 32 commits into
mainfrom
feat/testops-attach-existing-job-run
Sep 4, 2026
Merged

fix(plugin-testops): correlate CI uploads with TestOps job runs end-to-end#906
epszaw merged 32 commits into
mainfrom
feat/testops-attach-existing-job-run

Conversation

@todti

@todti todti commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Reworks plugin-testops to correlate CI-triggered uploads with an existing TestOps job run end-to-end, instead of always creating a new launch:

  • Attaches uploads to an existing TestOps job run when one is provided, or to an existing launch via a launchId option, instead of always creating a new launch
  • Adds CI-descriptor env overrides and an ALLURE_CI_ENV passthrough for pipeline steps that don't share env with the step that ran the plugin
  • Adds an upload retry queue and rate pacer so a TestOps outage defers failed upload batches to finalization instead of dropping them
  • Fixes the Azure CI detector's getRootURL to strip a trailing slash from SYSTEM_COLLECTIONURI
  • Adds a testops-plan CLI command that fetches a job run's test plan from TestOps

Fixes

  • Job runs started directly by TestOps (not through a recognized CI provider) now correctly attach to their launch instead of crashing the run
  • The job's branch and custom-name defaults no longer get overwritten with stale values on repeat runs of the same job
  • Launch creation for local (non-CI) runs no longer fails, by sending only the fields the TestOps API accepts
  • A bulk upload of global errors is no longer rejected outright when one error in the batch has a blank message
  • Long report/launch names no longer fail upload; they're truncated to the accepted length instead
  • Removed a redundant retry layer that could redo already-successful work during a TestOps outage
  • The test-plan command now sees the job run id in pipeline setups where it's passed through an encoded environment bundle instead of a plain variable
  • Stale queued upload entries no longer linger after a later successful retry, which could otherwise cause a duplicate re-upload at finalization

Notes

Split the larger free functions out of client.ts/plugin.ts into utils/ modules for readability, without changing the TestOpsClient/TestOpsPlugin class structure.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Allure Report Summary

Name Duration Stats New Flaky Retry Report
Allure 3 Report (plugin-allure2) 1h 5m Passed tests 10902   Skipped tests 23   Unknown tests 12 741 0 33
Allure 3 Report 1h 5m Passed tests 10902   Skipped tests 23   Unknown tests 12 741 0 33
Allure 3 Report (plugin-classic) 1h 5m Passed tests 10902   Skipped tests 23   Unknown tests 12 741 0 33
My Dashboard 1h 5m Passed tests 10902   Skipped tests 23   Unknown tests 12 741 0 33
Allure Report 0s 0 0 0

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Allure perf metrics

Generated at: 2026-09-03T18:03:57.892Z

Phase Count Total Avg Min Max
restoreState.total 1 4225.2 ms 4225.2 ms 4225.2 ms 4225.2 ms
restoreState.dump 3 4224.9 ms 1408.3 ms 1272.4 ms 1561.8 ms
restoreState.attachments 3 3777.2 ms 1259.1 ms 1119.6 ms 1404.6 ms
restoreState.storeRestore 3 127.7 ms 42.6 ms 39.2 ms 46.5 ms
generate.total 1 18088.5 ms 18088.5 ms 18088.5 ms 18088.5 ms
generate.plugins.done 1 16982.9 ms 16982.9 ms 16982.9 ms 16982.9 ms
publish.upload.total 1 11841.8 ms 11841.8 ms 11841.8 ms 11841.8 ms
summary.generate 1 6.3 ms 6.3 ms 6.3 ms 6.3 ms
generate.plugin.done.agent 1 0.1 ms 0.1 ms 0.1 ms 0.1 ms
generate.plugin.done.allure2 1 4517.9 ms 4517.9 ms 4517.9 ms 4517.9 ms
generate.plugin.done.awesome 1 6259.1 ms 6259.1 ms 6259.1 ms 6259.1 ms
generate.plugin.done.classic 1 5578.0 ms 5578.0 ms 5578.0 ms 5578.0 ms
generate.plugin.done.dashboard 1 623.9 ms 623.9 ms 623.9 ms 623.9 ms
generate.plugin.done.log 1 2.4 ms 2.4 ms 2.4 ms 2.4 ms
generate.plugin.done.testops 1 0.2 ms 0.2 ms 0.2 ms 0.2 ms
publish.upload.plugin.awesome 1 6256.9 ms 6256.9 ms 6256.9 ms 6256.9 ms

Artifacts: allure-perf-metrics

Comment thread packages/plugin-testops/src/client.ts Outdated
Comment thread packages/plugin-testops/src/plugin.ts
Comment thread packages/plugin-testops/test/plugin.test.ts Outdated
todti and others added 9 commits September 2, 2026 15:51
…o-end

Results uploaded from CI never connected back to the job run TestOps
itself tracks, breaking rerun, test-plan-scoped runs, and two-way
pipeline triggering. Uploads from Azure DevOps also produced a job
with a corrupted name and no working integration, making it
impossible to trigger the pipeline back from TestOps.

CI descriptor and job binding:
- Send ci.type + ci.endpoint (derived from the job URL's origin) so
  TestOps can attach the right build-server integration to the job;
  previously neither was sent, so triggering a job from TestOps always
  failed with "build server is not configured for job".
- Send job.name, job.url, jobRun.name, jobRun.url, and the job's
  numeric id when already known, instead of leaving the job named
  after its raw uid.
- Send Branch (and, on Bitbucket, CustomName) as job/job-run
  parameters, and restate the job's already-configured parameters on
  every upload — job.parameters is a full replacement server-side, so
  omitting them would silently wipe out manual configuration.
- Support ALLURE_JOB_UID/URL/NAME and ALLURE_JOB_RUN_UID/URL/NAME/BRANCH
  env vars to override individual CI-detected fields when a detector
  gets it wrong or the CI is exotic, and ALLURE_CI_ENV to carry
  ALLURE_* context across pipeline steps/processes that don't share
  environment.

Job run attachment:
- Read ALLURE_JOB_RUN_ID: when TestOps itself started the pipeline
  (e.g. its "Run" button), attach to that job run instead of creating
  a disconnected launch, and bind the upload session to it
  (test_result.job_run_id is what rerun and test-plan matching key
  off). Also read jobRunId from /api/upload/start's response
  unconditionally, since the server always resolves one.
- Propagate jobRunId onto launch-scoped attachment/error uploads so
  they're attributed to the run that produced them, not just the
  launch.
- Add a reopenClosedLaunch option to recover from uploading into an
  already-closed launch instead of failing outright.
- Add a launchId option to attach to an existing launch by id.
- Skip job/job-run creation entirely for local (non-CI) runs instead
  of synthesizing a fake job; tag the launch itself as an allure3
  report in that case so TestOps has some signal about its origin.

Reliability:
- Classify upload errors (transient/recoverable/terminal) and retry
  transient ones with backoff across all upload call sites, replacing
  a single untargeted retry path.
- Add a leaky-bucket rate pacer (requests/files/bytes per window) so
  large uploads don't overrun TestOps's own limits.
- Defer upload batches that exhaust their retry budget to a
  finalization-time queue instead of dropping them, so a sustained
  TestOps outage doesn't lose results outright.
- Retry the launch-creation prerequisites (/api/upload/start, the
  job-parameters lookup, the closed-launch check) that previously had
  no retry at all, unlike every per-chunk upload call.
- Validate that TestOps actually returns a launch id when attaching to
  a job run, and only treat a 404 (not any failure) as "job doesn't
  exist yet" when looking up existing job parameters, so a transient
  error can't silently wipe real configuration.

Test plan:
- New `allure testops-plan` CLI command: when ALLURE_JOB_RUN_ID is
  set, fetches the job run's selected test cases from TestOps and
  writes testplan.json in the format allure-js-commons already reads
  via ALLURE_TESTPLAN_PATH. No-ops when the variable isn't set, and
  continues without a plan (rather than hard-failing the pipeline) if
  the fetch itself fails.
…t success

run() never removed a name's pending entry when a subsequent direct
call for that same name succeeded outright (not suspended, not
deferred) - only ever replaced it on another failure. The stale task
closure (captured from the earlier failed attempt) would still sit in
the queue and get re-run by flush() at finalization, re-uploading data
a later successful call already sent. Added a test that catches this
by asserting pendingNames is empty after the later success.
… plugin.ts

No behavior change - moved code that was already free functions (not
methods needing class state) into their own modules, since client.ts
and plugin.ts had grown to ~900 lines each:

- utils/httpRetry.ts: retryRequest and its error-classification helpers
- utils/jobParameters.ts: externalParameters, ciEndpoint, TESTOPS_CI_TYPE
- utils/launchCategories.ts: the category enrichment/sync helpers,
  converted from private plugin.ts methods to functions taking the
  client/store explicitly instead of implicit `this`

TestOpsClient and TestOpsPlugin themselves stay as single classes -
splitting their actual methods across files would need composition/
mixins, a real architecture change, not just a move.
…aunch

TestOps' LaunchCreateDto has no such fields and Jackson's default
fail-on-unknown-properties would reject the request, breaking launch
creation for every local (non-CI) run.
…in plugin.ts

The onRetry/onDeferred wiring and the isTestOpsClientError/Error/else
logging branch were copy-pasted identically at all 4 upload call sites,
differing only by a label string.
Three defects found by comparing what the plugin uploads against what the
TestOps API accepts. They are independent of each other.

Global errors come from the results files test framework adapters write, and
their message is optional in our model - nothing validates it on the way in.
TestOps requires a non-blank message and rejects the entire bulk request when
one item lacks it, so a single untitled error dropped every valid error
uploaded alongside it. Send a placeholder instead, keeping the trace.

TestOps accepts a launch name of at most 255 characters and fails the request
otherwise, so a longer report title killed the upload before it started. Trim
the name to the accepted length.

The upload API has no `environment` property on a test result, so the value
was dropped on arrival; the binding it was meant to carry travels in
`namedEnv`, which is sent next to it. Remove the dead field from the payload
and from the type that mirrors the API.
…ected CI

startUpload checked isLocalCiDescriptor(ci) before jobRunId, so a job run
TestOps started itself (its "Run" button) on an agent packages/ci doesn't
recognize as any known CI provider took the local-run shortcut instead of
attaching to the job run - #launch was never set, and the next
createSession() call crashed with "Launch isn't created!".

Also fixes externalParameters() sending a job's stale saved Branch/CustomName
back to TestOps: the loop that restates existing job parameters ran after the
fresh values were set, overwriting them.

Drops the inner retry wrapper around the test-result chunk POST, since the
outer withUploadRetry in uploadTestResults already retries the whole
operation - the two layers disagreed on what's retryable and could redo a
successful createNamedEnvs call for nothing.

Moves applyAllureCiEnv from plugin-testops into @allurereport/ci so the new
testops-plan CLI command can decode ALLURE_CI_ENV too - it previously read
ALLURE_JOB_RUN_ID directly, which meant it saw nothing in the matrix-job/
separate-container scenario ALLURE_CI_ENV exists to handle.
The three-way rateLimit resolution (unset -> default, false -> disabled,
object -> itself) was one dense ternary; split into a named helper with
each case spelled out.
@epszaw
epszaw force-pushed the feat/testops-attach-existing-job-run branch from 3491add to f8e8c6d Compare September 2, 2026 13:53
…ps omits it from the response

startUpload only bound the session to a job run when /api/upload/start's
response echoed jobRunId back, but that field is optional on the response
type. If it's missing, createSession silently falls back to a manual
session, losing the job-run binding even though we told TestOps which job
run to attach to.
…uploads

done() logged an error when some upload streams never made it through
finalization but continued straight to stopUpload() and, if autoclose was
on, polled and closed the launch anyway - reporting an incomplete launch
as finished. Skip the close (leave the launch open) when anything is still
pending; stopUpload's own CI-status report is unaffected.
…_JOB_RUN_ID on an undetected CI

isManuallyEnabled only checked ALLURE_TESTOPS_ENABLED/CI, so a job run
TestOps started itself on an environment with no recognized CI still hit
the local-CI disable guard in the constructor and never got a chance to
attach - the client-side fix for this exact scenario was unreachable.
On a reused workspace (a self-hosted runner or cached container that
doesn't wipe its working directory between runs), a testplan.json left
over from an earlier successful run would otherwise still be there for
the current run to pick up and execute, even though this run's fetch
failed and TestOps has no scoped plan for it.
…adPacer

Clamping cost to the window's limit meant a single batch far bigger than
the configured budget only ever reserved one window's delay, letting it
blow through maxRequestsPerWindow/maxFilesPerWindow/maxBytesPerWindow by
an arbitrary amount. Charging the full cost makes the next call wait
proportionally longer instead.
…ready-acknowledged test result chunks

uploadTestResults() chunks a batch and retries each chunk internally, but
the plugin wrapped the whole call in a second, outer retry (UploadQueue).
If a later chunk failed after an earlier one had already been accepted by
TestOps, the outer retry re-invoked the same closure with the full,
unfiltered batch - resending the already-accepted chunk.

TestOps can't treat that resend as a no-op: a chunk's test results are
upserted onto pre-existing "expected" rows only while those rows are
still unfilled (status is null); once the first successful upload fills
them in, the same uuids no longer match anything "expected" and the
retried chunk creates brand new test_result rows instead - duplicating
every test result from the chunk that already succeeded.

uploadTestResults now reports each chunk's uploaded ids back via
onChunkUploaded as it goes, and the plugin uses that to filter the batch
passed into the retried closure down to what's still actually pending,
so a retry (hot or at finalization) only ever resends what TestOps
hasn't accepted yet.
…te budget

attachmentByteLength only recognized Buffer and Blob, returning 0 for
everything else - but resolved attachments flow through as a Readable
stream in the normal reporting path, so maxBytesPerWindow never actually
throttled real uploads, only the Buffer-backed ones exercised in tests.

ResultFile already exposes getContentLength(), so capture it once when
resolving the attachment and have attachmentByteLength prefer that over
inspecting the content value.
todti and others added 14 commits September 2, 2026 15:49
…uest

classifyError treated a canceled axios request (ERR_CANCELED/CanceledError)
the same as a network failure with no response - ServiceTransient, i.e.
retryable. httpRetry.ts's retryRequest already excludes cancellation from
its own retry check; classifyError/withUploadRetry disagreed and would
retry a request that was intentionally aborted.
…s a valid launchId, drop remaining comments

- attachToLaunch's launchId check used a falsy test, so launchId: 0 would
  silently fall through to creating a new launch instead of attaching.
- withUploadRetry's terminal check called isTerminalUploadError, whose
  isPermanentUploadError half is provably always false at that call site
  since shouldRetryUpload already returned true - simplified to the
  attempt-count check it actually reduces to.
- #uploadAttachmentsForResult and the fixtures/attachments catch in
  #uploadChunkAttachmentsAndFixtures had the same three-way error-message
  formatting copy-pasted; extracted into #logResultUploadFailure.
- Stripped the remaining explanatory comments and single-letter loop/
  parameter names left in this branch's test files.
Dropping the inner retryRequest around the test-result chunk POST (to
stop it double-retrying under the outer UploadQueue wrapper) also
dropped Retry-After compliance for that call, since withUploadRetry's
own backoff never read the header - a 429 with Retry-After: 30 would
get hammered again after the default 500ms/1000ms/2000ms backoff instead
of waiting the 30s TestOps asked for.

withUploadRetry now reuses httpRetry.ts's retryAfterMs, which already
unwraps KnownError/UnknownError via their captured axios .cause to read
the header - not just raw AxiosErrors.
…wait

Three gaps found reviewing the previous round of fixes:

- Global attachments build their own AttachmentForUpload inline in
  plugin.ts, so they never got the contentLength the per-result resolver
  now carries - their byte pacing was still counting zero.
- withUploadRetry honored Retry-After unbounded, so a server (or a
  misconfigured proxy) answering Retry-After: 3600 would stall the run
  for an hour per attempt. Capped at a minute, applied inside
  retryAfterMs so retryRequest gets the same bound.
- ALLURE_LAUNCH_ID was accepted as anything that parses as a non-NaN
  number, so -5 and 1.5 reached attachToLaunch, while its sibling
  ALLURE_JOB_RUN_ID required a positive integer. Both now share
  toPositiveInteger, and it validates a launchId passed as an option
  too, not just the env fallback.
…ipped the upload

createSession's POST is the one call in the upload path with no retry
around it, and #upload() bails out of the whole cycle when it throws -
without going through UploadQueue, so nothing gets deferred. At
finalization that meant the entire final batch could be dropped by a
single transient failure while stillPending stayed empty, so the
autoclose guard didn't fire and the launch was closed and reported as
finished with its results missing.
…of swallowing it

syncLaunchCategories caught everything and returned silently, so results
uploaded without their categories with nothing in the log to explain it.
It now hands the failure back for the plugin to log, and returns early
on a missing launch id instead of asserting one with a non-null
assertion that only holds because of an invariant in another file.
…them on retry

getNamedEnvFor scanned the whole named-env map comparing externalId,
but the map is already keyed by exactly that - and it runs once per test
result and once per fixture.

The per-chunk retry closure also computed the "envs to create" set
outside itself, so a retried chunk re-posted the named envs the failed
attempt had already created.
…URE_ variable

The decoded bundle deliberately overwrites ALLURE_ variables present in
the current environment - the orchestrating step's snapshot is the
authority. Nothing covered that, so the precedence was easy to flip
while "fixing" what looked like an accidental clobber.
Two more flaky cases from packages/reader's xcresult suite, same as the
existing entries in this rule - pass locally, flake in CI.
TestOpsClient#namedEnvs was never read anywhere; the class itself is
not part of the package's public API, so it was unreachable from
outside too.
resolutions.rules[].testCaseId matches against testResult.testCase.id,
not the test result's own id (the report URL's hash). Fetched the
result's JSON from the report and the two hashes I'd added turned out to
be the same test: ddad4e5c... is the result id, d41ec9ab... is its
actual testCase.id. Keeping only the one that matches.
allurerc.mjs's resolutions only gate the aggregated Allure report's
quality gate (the Build report job) - they never touch vitest's own
exit code, which is what actually reddens the macos-latest CI check.
This test calls Apple's xcresulttool against a fixture bundle and its
output format drifts across Xcode versions on the runner; it passes
reliably locally, so skip it under CI only rather than always.
…one test

CI reran with the previous single-test skip and a different test in the
same suite failed the same way (empty result from xcresulttool), which
means the flakiness isn't localized to one test - xcresulttool itself is
intermittently returning nothing on this runner. Skipping individual
tests one at a time as they come up isn't going to converge, and
skipping the whole ~90-test suite in CI throws away real coverage for a
transient issue. Retrying the whole suite up to twice under CI keeps
that coverage and survives the flake; local runs are unaffected (retry
0).
@epszaw
epszaw merged commit c2d5a6d into main Sep 4, 2026
14 checks passed
@epszaw
epszaw deleted the feat/testops-attach-existing-job-run branch September 4, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants