These fixtures verify the current gateway SSE contract and browser state application. Editing cases
add local drafts to that live-state contract; viewing does not require edit controls. Native watch
support has recovery tests of its own (packages/krm-stream/test/native.test.ts); these SSE fixtures
do not prove it.
One YAML file describes one scenario end to end: the Kubernetes watch input, the gateway's wire output, and the client's resulting state after applying events and local edits. The Go and TypeScript suites load the same files, so a contract change is checked on both sides in the same commit.
conformance/
bodies/ real KRM objects, in YAML — the nouns
fixtures/ scenarios, in YAML — the verbs
gen/ generated JSON (both suites read this — do not hand-edit)
generate.sh bodies+fixtures YAML -> gen/bodies.json + gen/fixtures.json
Why YAML in, JSON out. YAML is what a human reasons about — a bodies/*.yaml file is just a
Kubernetes object, the thing you'd kubectl apply. JSON is what two languages parse with zero
dependencies (encoding/json, JSON.parse). So the YAML is the source of truth, task fixtures
builds the JSON, and CI fails if the JSON is stale.
The gateway and the client cannot run the same assertions — one produces the wire, the other
consumes it. So a fixture has three sections, and each suite reads the parts that apply to it. They
meet in the middle, at events.
watch: ──▶ [ GATEWAY ] ──▶ events: ──▶ [ CLIENT ] ──▶ client.expect:
(input) Go (the shared TS (draft, dirty,
contract) conflicts, patch)
| section | who runs it | meaning |
|---|---|---|
watch |
gateway | a scripted fake Kubernetes watch. The gateway's input. No cluster needed |
events |
both | the exact wire. The gateway must emit this; the client is fed this. This is the contract |
client |
client | local edits applied at points in the stream, and what the store must then hold |
A fixture may omit client (a pure stream/framing test) or omit watch (a pure merge test, fed
straight from events). suites: says which suites must run it.
id: edit-vs-unrelated-change # unique; matches the filename
title: A server change to a key you are NOT editing must not disturb the key you ARE.
why: R-THREEWAY — the base is the PREVIOUS server object, not the draft.
suites: [gateway, client]
scope: { target: demo, version: v1, resource: configmaps, namespace: app }
projection: krm-full/v1
watch: # see "The watch ops" below
- { op: list, bodies: [cm-app.v1] }
- { op: modified, body: cm-app.v2 }
events: # `body:` is a reference into bodies/ — resolved by the loader
- { type: reset }
- { type: added, body: cm-app.v1 }
- { type: synced }
- { type: modified, body: cm-app.v2 }
client:
edits:
- { after: 2, op: set, uid: cm-app-0001, path: [data, log-level], value: debug }
expect:
dirty: [[data, log-level]] # exact set of dirty paths
conflicts: [] # exact set
draftSubset: { data: { log-level: debug, replicas: "5" } } # deep-subset of the draft
patch: { data: { log-level: debug } } # exact RFC 7386 merge patchafter: N applies the edit once event index N (0-based) has been processed — so an edit can be
placed before the server change that will collide with it. That ordering is the whole point of a
three-way merge, and a fixture format that couldn't express it would be useless.
uid: — bodies use readable uids (cm-app-0001) rather than real UUIDs. A Kubernetes uid is an
opaque string; making it legible costs nothing and makes a delete-and-recreate fixture (§uid changes,
name does not) obvious at a glance.
watch: models API-server events, browser disconnects and client-go cache tombstones.
The API reference notes distinguish claims from the
Kubernetes API concepts page, client-go implementation details and recorded cluster observations.
| op | means | the gateway must |
|---|---|---|
list |
the objects currently in scope, then the bookmark that ends the initial events | open a cycle: reset, one added each, synced |
added / modified |
an upsert | forward it (subject to monotonicity) |
deleted |
the object left scope | emit deleted with its identity |
relist |
upstream continuity lost (410 Gone / cache reset) — the SSE connection is fine | announce RESYNC_REQUIRED, then a fresh cycle on the same connection |
disconnect |
the browser's connection dropped | nothing — the next connection is a new stream |
bookmark |
a routine BOOKMARK, which may arrive on a watch that requested bookmarks. Its object can include type metadata alongside metadata.resourceVersion |
absorb it. Never forward it; never mistake it for synced |
partial |
a metadata-only object (PartialObjectMetadata) delivered as an upsert. It has a uid; it has no spec and no status |
refuse it and resnapshot. Forwarding it blanks the consumer's object |
tombstone |
a DELETED whose object lost its identity (client-go's DeletedFinalStateUnknown) |
not guess. Begin a new cycle and let synced prune |
The final three operations cover gateway safety rules that ordinary list and watch traffic cannot express. They ensure routine bookmarks, metadata-only objects, and ambiguous tombstones remain covered by the shared corpus.
Assertions. dirty and conflicts are compared as exact sets (order-insensitive). patch is
compared exactly — it is what gets sent to the API server, so "close enough" is not a thing.
draftSubset is a deep-subset check: it lets a fixture assert the two fields it cares about without
restating a whole Deployment.
Every fixture names the rule it defends, in why:. The ones that catch real bugs:
| fixture | defends |
|---|---|
snapshot-then-deltas |
the basic cycle: reset … added … synced, then live deltas |
named-object-absent |
a named scope that does not exist is still framed reset, synced — not silence. Skip this and a delete-while-disconnected leaves a ghost forever |
reconnect-prune |
pruning is gated on synced; a reconnect removes what vanished while away |
partial-cycle-no-prune |
a cycle that never reaches synced prunes nothing |
delete-recreate-uid |
identity is uid, never name; no state bleeds across a recreate |
adopt-created-dedup |
I-IDEMPOTENT — an optimistic adoptSaved and the watch echo of the create are one object by uid, not two |
resync-midstream |
upstream continuity can be lost without the SSE connection dropping → a fresh cycle mid-stream |
nested-field-removed |
added/modified replace; a deep-merge would resurrect a field the server deleted (a ghost) |
status-follow-live |
status is read-only under the full projection: it follows the server live, and never becomes dirty, never conflicts, never enters a patch |
status-only-churn |
a final status-only write under spec projection is suppressed; visible content converges with an older held RV and an intact spec edit |
final-redaction-rotation |
a final hidden-value change emits a redaction revision even when projected content excluding RV is unchanged |
final-bookkeeping-only |
a final ignored bookkeeping write is suppressed; visible content converges with an older held RV |
edit-vs-unrelated-change |
R-THREEWAY — the base is the previous server object |
conflict-and-converge |
a conflict clears when the server's value arrives at what you typed |
dotted-label-keys |
R-ID — app.kubernetes.io/name is ONE path segment. Dot-joining it is silently wrong |
array-atomic-on-change |
unannotated arrays merge atomically; a positional merge mis-aligns |
secret-redaction |
a redacted value is never displayed, never dirty, and can never be written back over the real one |
bookmark-absorbed |
a routine BOOKMARK is absorbed. Forward its object and you replace a live resource with a husk that has only a resourceVersion |
partial-object-refused |
a PartialObjectMetadata has a uid — so "has a uid" was never a sufficient test for "is a complete object", and ours was wrong |
tombstone-without-uid |
identity is never reconstructed. A guessed uid deletes the wrong object out of somebody's browser |
resourceversion-bignum |
a resourceVersion is an arbitrary-bitsize decimal (Kubernetes' own example is 40 digits). strconv.ParseInt overflows it, and the symptom is silently dropped live updates |
resourceversion-unorderable |
an aggregated API may serve a non-decimal resourceVersion. Strict ordering refuses it, loudly, naming the escape hatch — it never degrades in silence |
Those two use wardle.example.com/v1alpha1 Flunder — Kubernetes' own
sample-apiserver — and not a ConfigMap, and the
reason is the whole point of grounding fixtures in real behaviour:
- A conformant cluster cannot produce either value on a built-in. Since 1.35, orderability is a
Certified Kubernetes requirement for base objects and custom resources. An unorderable
resourceVersionon a ConfigMap is a scenario that cannot happen, and a fixture defending against it would be defending against nothing. - kube-apiserver's
resourceVersionis an etcd revision — an int64, 19 digits. You will never meet a 40-digit one there either. A different backing store is where such a value actually comes from. - An aggregated / extension API server is the one server the docs still carve out, because it is a third-party implementation the conformance test does not cover. So it is the right home for both.
A fixture that teaches a real rule with an impossible example is worse than no fixture: it makes the reader trust a mental model that will mislead them the next time.
- Add or reuse a body in
bodies/(it is a plain Kubernetes object — write it as you would apply it). - Add
fixtures/<id>.yaml. Say inwhy:which rule it defends; if it doesn't defend one, ask whether it earns its keep. task fixtures(rebuildsgen/), thentask test. Commit the YAML and the generated JSON.
The normative invariant and
suppression decision share one comparison.
Final bookkeeping and
final spec-only status churn run through the
gateway conformance suite and the client object and SSE
suites. The convergence test compares the held
content with the final upstream body under that projection and asserts the older held RV explicitly.
The gateway suppression test also checks the suppression observation
and original delivered object under all three built-in projections.
The symmetric final Secret rotation must emit its changed
redaction revision; that same client test checks the held records at each delivered upsert.
Existing snapshot, pruning, ordering and redaction tests remain part of task test.