fix(loadtesting): back every capability's returns with a typed response schema - #450
Conversation
…se schema
Every capability resolved its 2xx to the shared SuccessEnvelope, whose data is a
typeless object — so returns carried the whole payload contract as a flat list of
names with no types, describeCapability returned an empty schema for all 21, and
the check-contract drift guard could never catch a dropped field. Add a per-
capability <Cap>Data schema and compose each 2xx as allOf[SuccessEnvelope, {data}],
so describeCapability exposes real field types and drift is detectable.
scripts/check-contract.py now reports 0 unbacked for loadtesting (was 21/75).
Also, derivable contract fixes:
- one-line description on all 9 entities (the field listProducts routes on)
- format date-time on the ISO timestamp params + uuid on compare run ids
- minimum/maximum on the limit params from their declared caps
- examples on high-leverage create/search/compare params
- updateLoadTest guidance: ifVersion opt-in vs last-write-wins semantics
- searchLoadTests guidance: hasMore has no cursor (top-N, narrow the query)
Tests updated: the two assertions that documented the missing entity
descriptions now assert they are present and meet the shared quality bar.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Central YAML (base), Organization UI (inherited), Workspace UI (inherited) Review profile: ASSERTIVE Plan: Enterprise Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
…+ seconds The report returns durationSec in whole seconds. Add guidance on getLoadTestRunReport (and a durationSec schema description) telling the agent to render it to the user as minutes + seconds (e.g. 330 -> '5m 30s'), keeping the raw seconds only for calculations. Presentation-only — no BE/unit change.
…tent + missing returns) compareLoadTestRuns is the regression check for runs of the SAME test, but the contract said only 'compare two completed runs' and declared just kpiDeltas/transactionDeltas — while the endpoint also returns baseline, candidate (each with testId), slaVerdictChanges and a warnings[] array. Most importantly the BE's 'runs are of different tests — comparison may be misleading' signal lives in warnings, so the agent never saw it and would present a cross- test delta as valid. - intent: state it compares runs of the SAME test; cross-test is allowed but flagged - returns + CompareLoadTestRunsData schema: add baseline, candidate, slaVerdictChanges, warnings (baseline/candidate carry runId/testId/startedAt) - guidance: prefer same-test runs; always surface warnings before reporting
The BE compare now mirrors the Compare Runs page: runs of different tests are rejected (DIFFERENT_TESTS), both runs must be finished and share test types, and deltas come from the page's own data sources. - intent: two executions of one test; not for different tests; use getLoadTestHistoricalTrends for more than two runs - guidance: pick both runs from listLoadTestRuns (status=terminal), older = baseline, "latest" = latest vs previous finished run; signed pctChange; how the transaction metric is chosen; SLA-changed warning - drop the "check baseline.testId == candidate.testId" advice (testId is a config version; runs across an edit legitimately differ) - groupBy values (transaction/url/label/threadGroup/scenario); corrected metrics / regressedOnly / pctChangeMin descriptions; 404 response - typed kpiDeltas / transactionDeltas / slaVerdictChanges rows - listProjectLoadTestRuns no longer suggests cross-test comparison
… the unservable-metrics warning Follows load-testing-backend#3135 review: the BE no longer offers a raw url axis for transaction deltas (url rows are keyed by request name, which merged distinct URLs), matching the Compare Runs page allowlist. Warnings now also list requested metrics compare cannot serve.
…ent the new warnings Follows load-testing-backend#3135 review round 2: a metric missing on either run now reports direction "unknown" (regressed null) instead of "unchanged"; warnings also name no-data metrics and runs whose SLAs were not evaluated.
sarve-shreyas
left a comment
There was a problem hiding this comment.
Review
Traced how the index is actually consumed (bind.ts, index-loader.ts, register.ts, check-contract.py) and ran the suite plus the contract gate in a throwaway worktree: 737/737 tests pass, and check-contract.py on the loadtesting index reports 0 unbacked. The PR does what it says.
Six inline comments below. Two things that don't anchor to a changed line:
The fix lands one level below the root cause
The PR's stated problem is "data is a typeless object with no properties" — but 20 of the 79 new <Cap>Data properties are themselves typeless containers, including every list endpoint's row array (tests, runs, projects) plus kpis, transactions, slaVerdicts, errorsByCategory, meta, config, plan, vuHours, concurrency, report, metrics, aliases.
So both stated benefits are only partly delivered: describeCapability still says nothing about a row's fields, and check-contract.py still cannot detect drift inside them. searchLoadTests guidance promises "each row carries testId, projectId, name, testType and framework" and listProjectLoadTestRuns promises "each run carries a tags array" — no schema backs either, so the API can stop returning them silently. At minimum, giving the four row arrays an items schema would close most of it.
The reset baseline currently verifies nothing
scripts/contract-baseline/loadtesting.json is inert on this branch:
- this branch's
check-contract.pyhas no--baselinehandling at all —grep -c against_baseline→ 0, the argument is silently ignored and the strictmain()path runs npm run check:contractshort-circuits on tm's failure before reaching the loadtesting invocation anyway
Both confirmed by running them. So nothing in CI verifies the 0-unbacked state this PR establishes, and a future edit that re-breaks it lands unnoticed. The PR body names the short-circuit but not the unimplemented flag. (--baseline has since been implemented on main, so this may resolve itself on rebase.)
One stale comment outside the diff
src/tools/capability-registry/types.ts:198 — the EntityDoc.description doc comment still reads "loadtesting has 0 of 9 today". This PR makes that 9 of 9 and updates the two tests that documented the same gap, but not the comment.
| "description": "ISO-8601 timestamp; only tests updated after it.", | ||
| "example": "2026-08-01T00:00:00Z" | ||
| "example": "2026-08-01T00:00:00Z", | ||
| "format": "date-time" |
There was a problem hiding this comment.
This is enforced, not documentation — and it rejects values the guidance asks for.
format: "date-time" is run through FORMATS["date-time"] in bind.ts:139, whose regex requires a full T/space plus HH:MM:SS. The param is described only as an "ISO-8601 lower bound", and date-only is valid ISO-8601. listLoadTestRuns guidance goes further and says "To find a run by date, pass sinceIso/untilIso".
Concrete failure: an agent asked "which runs ran on Sept 12?" sends sinceIso=2026-09-12 (or 2026-09-12T10:30Z, no seconds) and invokeCapability refuses locally with 'sinceIso' must be a date-time — before any request goes out. That call previously reached the API.
bind.ts's own docstring names this as the failure mode to avoid: "a validator that rejects a correct value is worse than no validator."
Affects 11 params (also 349, 355, 454, 460, 805, 811, 1436, 1442, 1558, 1564), 8 of which carry no example to hint at the required shape. Either widen to accept date-only, or add an example to all 11 plus a guidance line.
There was a problem hiding this comment.
Fixed in 3ccd5a6: dropped the enforced date-time format from all 11 sinceIso/untilIso/updatedSince params — the BE parses them with new Date(), so date-only and no-seconds values are valid there. Each now documents both shapes ("2026-09-12" or "2026-09-12T10:30:00Z") and has an example.
| "type": "string", | ||
| "description": "Optimistic-concurrency guard (ISO timestamp of the version being edited)." | ||
| "description": "Optimistic-concurrency guard (ISO timestamp of the version being edited).", | ||
| "format": "date-time" |
There was a problem hiding this comment.
Question rather than a defect — what does version actually look like?
ifVersion gets an enforced format: "date-time", but the version token a caller must round-trip is declared as a bare {"type": "string"} in CreateLoadTestData (2237), UpdateLoadTestData (2250) and GetLoadTestData (2266) — this PR deliberately declines to assert a format there.
If that token is ever anything other than strict RFC3339 (an epoch, a hash, v3), the optimistic-concurrency path becomes unreachable: the agent reads version from getLoadTest, passes it as ifVersion, and bind rejects it client-side.
Asserting on the consumer side of a value you don't constrain on the producer side is the risk. Either assert the format on version too, or drop it from ifVersion.
There was a problem hiding this comment.
Asserted it on the producer side instead (3ccd5a6): version is always the BE's updated_at via toISOString() (util/agentVersionToken.js), so CreateLoadTestData / UpdateLoadTestData / GetLoadTestData now declare version as format date-time, matching ifVersion.
| }, | ||
| "baseline": { | ||
| "type": "number", | ||
| "description": "Null when either run lacks the value." |
There was a problem hiding this comment.
These declare a non-null type while their own description says the value is null.
11 fields in CompareLoadTestRunsData: kpiDeltas.items.baseline/candidate/absChange/pctChange, transactionDeltas.items.* plus regressed, and slaVerdictChanges.items.from/to.
type: "number" excludes null, so the schema contradicts its own description and any consumer validating a real comparison response fails on the first null delta.
The repo already has the right encoding — nullable: true appears 676 times in tm.capability-index.json, and index-loader.ts:564 calls it out as exactly the absent-vs-explicitly-null distinction a caller cannot otherwise make.
There was a problem hiding this comment.
Fixed in 3ccd5a6: the 11 CompareLoadTestRunsData fields that can be null are now nullable: true (tm's encoding).
| "ListLoadTestsData": { | ||
| "type": "object", | ||
| "properties": { | ||
| "tests": { |
There was a problem hiding this comment.
Typeless container — see the summary comment.
This is one of 20 <Cap>Data properties that declare no shape, which is the PR's own root cause relocated one level down. The row arrays (tests, runs, projects) are the highest-value ones to fix, since guidance already promises specific fields inside them that nothing backs.
There was a problem hiding this comment.
Addressed the row arrays in 3ccd5a6: tests (list + search), projects and runs (per-test, per-project, active) now have item schemas taken from the BE shapers, so the fields the guidance promises are backed. The rest stay open by design for now — kpis / aliases are metric-keyed maps, and report / meta / config / plan are pass-through blocks; happy to type those in a follow-up.
| "values": [ | ||
| "transaction", | ||
| "label", | ||
| "threadGroup", |
There was a problem hiding this comment.
Worth confirming against the backend before shipping — this is a hard enum, not guidance.
values is enforced as a closed allowlist by bind.ts:305. Any axis the backend accepts but these four omit is now refused client-side, with no override.
Two things suggest the set is being narrowed by inference rather than from the API: commit 3d1833a removed url from this list after the fact, and the sibling getLoadTestRunReport.groupBy names a completely different set in prose (url/browser/location/transaction/step) while deliberately declaring no values at all.
If the backend's accepted set isn't known precisely, prose guidance is the safer encoding than an enum.
There was a problem hiding this comment.
Confirmed against the backend: load-testing-backend#3135 accepts exactly transaction / label / threadGroup / scenario for compare (AGENT_TXN_GROUP_BY; anything else is a 400) — url was dropped there because url rows are keyed by request name. The report endpoint's groupBy is a different parameter with its own set (ALLOWED_GROUPBY), hence the prose there. Keeping the enum so the client rejects what the BE would.
| @@ -28,7 +28,20 @@ | |||
| ], | |||
| "responses": { | |||
| "200": { | |||
There was a problem hiding this comment.
Inlining the schema drops the response description, and orphans the named Success.
Replacing {"$response": "Success"} with an inline schema loses "Standard success envelope; operation data under data" across all 21 capabilities — so describeCapability's 200 is now the only status with no description, while 400/401/404/500 keep theirs. That's the one place the envelope gets explained.
It also leaves the named Success response at line 2362 referenced by nothing — the dangling-definition class that check-contract.py's own docstring flags as something a live probe had to find.
tm's inline 200s use {description, schema}. Matching that keeps the prose; then either delete Success or keep referencing it.
There was a problem hiding this comment.
Fixed in 3ccd5a6: all 21 inline 200s carry the success-envelope description again, and the unreferenced named Success response is removed.
… schemas, 200 prose
- sinceIso / untilIso / updatedSince (11 params) drop the enforced
date-time format: bind rejected date-only values ("2026-09-12") and
timestamps without seconds before the request left, while the BE parses
any new Date()-valid ISO-8601 value. Each now documents both shapes and
carries an example.
- version (create/update/get) declares format date-time — the BE emits
updated_at via toISOString() — so the ifVersion format it round-trips
into is asserted on the producer side too.
- CompareLoadTestRunsData delta fields that can be null (baseline,
candidate, absChange, pctChange, regressed, from, to) are marked
nullable: true, the convention tm already uses.
- Row arrays get item schemas from the BE shapers: tests (list + search),
projects, runs (per-test, per-project, active) — backing the fields the
guidance promises (testId/projectId/name/testType/framework, tags, …).
- Inline 200s keep the success-envelope description; the now-unreferenced
named Success response is removed.
- types.ts: EntityDoc comment no longer claims loadtesting authors 0 of 9.
|
Thanks for the thorough review — addressed in 3ccd5a6 (replies inline). On the two summary items: the EntityDoc "0 of 9" comment is updated. On the inert baseline: agreed that nothing in CI on this branch checks the 0-unbacked state — the --baseline handling exists on main, so it resolves once this branch is rebased/merged; I've kept the reset baseline file so it's correct when that lands. |
sarve-shreyas
left a comment
There was a problem hiding this comment.
Approving. Re-verified against 3ccd5a6 rather than the head I originally reviewed.
Verified on this head: 737/737 tests pass, tsc clean, and check-contract.py on the loadtesting index reports 0 capabilities / 0 fields unbacked.
Fixed from the earlier review:
| finding | status |
|---|---|
Enforced date-time rejecting valid date-only inputs |
fixed — enforced params 12 → 1 |
ifVersion asserting a format version never declared |
fixed — format: date-time now asserted on version in all three schemas, with a description naming it as updated_at |
| Non-null types on fields documented as null | fixed — 11 nullable added to CompareLoadTestRunsData |
200 losing its description, Success orphaned |
fixed — 200 is {description, schema}, and Success is deleted rather than left dangling |
Stale types.ts comment |
fixed |
Asserting the format on version as well as ifVersion was the stronger of the two available fixes — producer and consumer now agree, which also makes the one remaining enforced date-time correct rather than a leftover.
Typed row schemas: shapeless containers went 23 → 17, and the half that mattered landed — the list endpoints' row arrays (tests, runs, projects) now carry items, so the fields the guidance promises are finally backed. What remains is concentrated in GetLoadTestRunReportData (kpis, transactions, slaVerdicts, errorsByCategory, meta), GetLoadTestQuotaData and the two metrics trend objects. Worth a follow-up, not a blocker.
Two things deliberately not blocking:
- The
groupByvalue set is the Load Testing team's call to make, not the registry's. Withdrawn. scripts/contract-baseline/loadtesting.jsonis inert on this branch —--baselinehas no handling incheck-contract.pyhere, so nothing in CI yet verifies the 0-unbacked state this PR establishes. It is implemented onmain, so a rebase picks it up.
Context
External contract review of
capability/loadtesting.capability-index.json(build43eba70) found that every capability declared response fields no schema backs — 21 of 21, 75 fields. Root cause: all 21 resolve to the sharedSuccessEnvelope, whosedatais a typelessobjectwith no properties. Soreturnscarried the whole payload contract as a flat list of names — no types, no nesting.Two knock-on effects the reviewer called out, both real:
describeCapabilityreturns an empty schema for all 21 capabilities.scripts/check-contract.pywalksreturns → schema, and with the schema empty it can never catch a field the API stopped returning.Fix (headline)
Per-capability response schemas composed with
allOf, astmdid in its index — a shared field likerunIdis defined once and the capabilities carrying it can't drift apart:<Cap>Dataschema for all 21 capabilities; eachreturnsfield is now a backed property.scripts/check-contract.py capability/loadtesting.capability-index.json→ 0 unbacked (was 21 / 75).*Sec, booleans, arrays,date-time/uuidformats). Three genuinely ambiguous fields (metricsfor trends,kpiDeltas,estimatedVuHoursRange) are backed with a description but no asserted type — deliberately not guessing types.Also (derivable contract fixes from the same review)
listProductsroutes on (was 0/9).format: date-timeon the 11 ISO timestamp params +ifVersion;format: uuidon the compare run-ids.minimum/maximumon all 6limitparams, from their declared caps.exampleon 5 high-leverage create/search/compare params.updateLoadTestguidance:ifVersionis opt-in optimistic concurrency; omitting it is last-write-wins.searchLoadTestsguidance:hasMorehas no cursor (top-N — narrow the query), so it isn't a dead-end page-2 signal.scripts/contract-baseline/loadtesting.jsonto its now-clean state.Deliberately not changed
stopLoadTestRunis path-only,startLoadTestRunis an all-optional re-run,updateLoadTestis a partial update.createLoadTest/estimate/comparealready declare required fields.listActiveLoadTestRunstruncation signal — negligible; active runs are concurrency-capped.Validation
tsc --noEmit: clean ·eslint: clean · 726/726 tests pass ·check-contract.py(loadtesting): exit 0.Note (out of scope)
npm run check:contractrunstm && loadtesting; tm still fails (2 pre-existing empty-schema caps), so the chain short-circuits before loadtesting runs. Our index passes when checked directly — making the CI step gate loadtesting needs tm fixed too, or the&&split so both run independently.