Skip to content

Commit 6910a83

Browse files
committed
fix(provider-tck): give a failed capability a reason, and stop claiming untested ones
Two defects in the capability rollup, both found by the Java implementation reviewing this one. A failed capability was emitted as {"state": "failed"} with no reason. The schema now requires a reason for any outcome other than passed, so that entry does not validate -- and it would have appeared only when a provider was actually failing, which is precisely when the report matters. It now says how many of how many scenarios carrying the tag failed, and points at the per-scenario results for which and why. No test caught it because every self-test suite passes, so nothing that runs end to end ever reaches that branch. The new internal test drives the report builder directly with synthetic records, which is the only way to exercise a failure without breaking a provider on purpose. A declared capability that no scenario carries was reported as passed. @targeting is reserved -- it exists in the vocabulary but nothing tests it, because asserting that an evaluation context reached the backend needs an echo operation the control API does not have -- so a provider declaring it got a green result for a claim nothing had examined. That is the vacuous pass the capability vocabulary was introduced to eliminate, arriving through the report rather than through the suite. Such a capability is now omitted. The suite asked no question, so it has no answer to report, and a consumer sees the tag is absent rather than a pass it cannot rely on. Omitting is preferred to inventing a fifth outcome: the four in the schema are about what the provider did, and "the suite does not test this" is a fact about the suite. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
1 parent d165af1 commit 6910a83

3 files changed

Lines changed: 143 additions & 13 deletions

File tree

tools/provider-tck/pkg/tck/report.go

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,15 @@ func (r *runner) buildReport() Report {
131131
})
132132

133133
scenarios := make([]ReportScenario, 0, len(records))
134-
// failedCapabilities tracks which declared capabilities had a scenario fail,
135-
// so a capability is only reported as passed when everything gating on it
136-
// actually passed.
137-
failedCapabilities := map[Capability]bool{}
134+
// failed counts, per capability, the scenarios gating on it that failed, so a
135+
// capability is reported as passed only when everything gating on it passed and
136+
// a failure can say how much failed.
137+
failed := map[Capability]int{}
138+
// exercised counts the scenarios gating on each capability at all. A capability
139+
// no scenario carries cannot have been demonstrated, and reporting it as passed
140+
// would claim conformance the suite never tested -- which is the same vacuous
141+
// green the capability vocabulary exists to prevent.
142+
exercised := map[Capability]int{}
138143

139144
for _, rec := range records {
140145
scenarios = append(scenarios, ReportScenario{
@@ -145,12 +150,14 @@ func (r *runner) buildReport() Report {
145150
Reason: rec.reason,
146151
DurationMs: float64(rec.duration.Microseconds()) / 1000.0,
147152
})
148-
if rec.outcome != OutcomeFailed {
149-
continue
150-
}
151153
for _, tag := range rec.tags {
152-
if capability, gates := CapabilityForTag(tag); gates {
153-
failedCapabilities[capability] = true
154+
capability, gates := CapabilityForTag(tag)
155+
if !gates {
156+
continue
157+
}
158+
exercised[capability]++
159+
if rec.outcome == OutcomeFailed {
160+
failed[capability]++
154161
}
155162
}
156163
}
@@ -165,8 +172,18 @@ func (r *runner) buildReport() Report {
165172
"not declared by this provider's configuration; the %s scenarios were skipped and did not contribute to this result",
166173
capability.Tag()),
167174
}
168-
case failedCapabilities[capability]:
169-
capabilities[capability.Tag()] = ReportCapability{State: OutcomeFailed}
175+
case exercised[capability] == 0:
176+
// Declared, but no scenario in the suite gates on it. Saying nothing is
177+
// the only honest answer: the suite asked no question, so it has none
178+
// to report. Claiming passed would be a green result for an untested
179+
// claim, which is precisely what this suite exists to make impossible.
180+
case failed[capability] > 0:
181+
capabilities[capability.Tag()] = ReportCapability{
182+
State: OutcomeFailed,
183+
Reason: fmt.Sprintf(
184+
"%d of %d scenarios carrying %s failed; the per-scenario results say which, and why",
185+
failed[capability], exercised[capability], capability.Tag()),
186+
}
170187
default:
171188
capabilities[capability.Tag()] = ReportCapability{State: OutcomePassed}
172189
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package tck
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// TestFailedCapabilityCarriesAReason exercises the branch no passing suite can.
9+
//
10+
// The schema requires a reason whenever an outcome is not "passed", and the
11+
// self-test suites all pass, so nothing that runs end to end ever builds a failed
12+
// capability entry. That branch was schema-invalid for a while and no test
13+
// noticed, because the only way to reach it is to fail a scenario on purpose --
14+
// which is what this does, by handing the report builder the records directly
15+
// rather than by breaking a provider.
16+
func TestFailedCapabilityCarriesAReason(t *testing.T) {
17+
caps, err := newCapabilitySet([]Capability{Object, Events})
18+
if err != nil {
19+
t.Fatalf("building the capability set: %v", err)
20+
}
21+
22+
r := &runner{cfg: Config{Name: "synthetic", Control: stubControl{}}, caps: caps}
23+
r.records = []scenarioRecord{
24+
{feature: "errors", name: "a structured flag fails", tags: []string{"@object"},
25+
outcome: OutcomeFailed, reason: "resolved to nil, expected an object"},
26+
{feature: "errors", name: "a structured flag succeeds", tags: []string{"@object"},
27+
outcome: OutcomePassed},
28+
{feature: "events", name: "ready fires", tags: []string{"@events"},
29+
outcome: OutcomePassed},
30+
}
31+
32+
report := r.buildReport()
33+
34+
object, present := report.Capabilities[Object.Tag()]
35+
if !present {
36+
t.Fatalf("%s is missing from the report", Object.Tag())
37+
}
38+
if object.State != OutcomeFailed {
39+
t.Errorf("%s reported as %q, want %q", Object.Tag(), object.State, OutcomeFailed)
40+
}
41+
if object.Reason == "" {
42+
t.Fatalf("%s failed but carries no reason; the schema rejects a non-passed outcome "+
43+
"without one, so a report built this way would not validate", Object.Tag())
44+
}
45+
// The reason has to be usable, not merely present: a consumer reading a
46+
// comparison page wants to know how much failed before opening the detail.
47+
if !strings.Contains(object.Reason, "1 of 2") {
48+
t.Errorf("%s reason %q does not say how many of how many failed", Object.Tag(), object.Reason)
49+
}
50+
51+
events, present := report.Capabilities[Events.Tag()]
52+
if !present {
53+
t.Fatalf("%s is missing from the report", Events.Tag())
54+
}
55+
if events.State != OutcomePassed {
56+
t.Errorf("%s reported as %q, want %q; one capability failing must not drag down another",
57+
Events.Tag(), events.State, OutcomePassed)
58+
}
59+
60+
// Every capability the report does mention, other than a pass, must carry a
61+
// reason -- the same rule the schema enforces, checked here so a change to the
62+
// builder fails in this package rather than in a validator downstream.
63+
for tag, result := range report.Capabilities {
64+
if result.State != OutcomePassed && result.Reason == "" {
65+
t.Errorf("capability %s is %q with no reason", tag, result.State)
66+
}
67+
}
68+
}

tools/provider-tck/pkg/tck/report_test.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ func TestReportRecordsUndeclaredCapabilities(t *testing.T) {
113113
for _, capability := range tck.AllCapabilities() {
114114
result, ok := report.Capabilities[capability.Tag()]
115115
if !ok {
116-
t.Errorf("capability %s is missing from the report; every capability is reported, "+
117-
"because an absent one is indistinguishable from one that was forgotten",
116+
t.Errorf("capability %s is missing from the report; a capability is omitted only when "+
117+
"it is declared and no scenario exercises it, which is not the case here",
118118
capability.Tag())
119119
continue
120120
}
@@ -193,3 +193,48 @@ func readReport(t *testing.T, path string) tck.Report {
193193
}
194194
return report
195195
}
196+
197+
// TestReservedCapabilityIsNotReportedAsPassed covers the capability a provider
198+
// declares and the suite never tests.
199+
//
200+
// @targeting is reserved: it exists in the vocabulary but no scenario carries
201+
// it, because asserting that an evaluation context reached the backend needs an
202+
// echo operation the control API does not have yet. Reporting it as passed would
203+
// be a green result for a claim nothing tested -- the same vacuous pass the
204+
// capability vocabulary was introduced to eliminate, arriving through the report
205+
// instead of through the suite.
206+
//
207+
// Omitting it is the honest answer: the suite asked no question, so it has none
208+
// to report. A consumer sees the tag is absent rather than a pass it cannot rely
209+
// on.
210+
func TestReservedCapabilityIsNotReportedAsPassed(t *testing.T) {
211+
dir := t.TempDir()
212+
t.Setenv(tck.ReportDirEnv, dir)
213+
214+
tck.Run(t, tck.Config{
215+
Name: "reserved-capability",
216+
Control: plainMemoryControl{},
217+
NewProvider: func(context.Context) (openfeature.FeatureProvider, error) {
218+
return memprovider.NewInMemoryProvider(tck.CanonicalFlagSet()), nil
219+
},
220+
// Targeting is declared and no scenario carries it. Object is declared so
221+
// the suite still does something.
222+
Capabilities: []tck.Capability{tck.Object, tck.Targeting},
223+
})
224+
225+
report := readReport(t, filepath.Join(dir, "reserved-capability.json"))
226+
227+
if result, present := report.Capabilities[tck.Targeting.Tag()]; present {
228+
t.Errorf("%s was declared and no scenario exercises it, but the report states %q; "+
229+
"a capability the suite never tested must not be reported as a result",
230+
tck.Targeting.Tag(), result.State)
231+
}
232+
233+
// The declared capability that is exercised must still be reported, so the
234+
// omission above is specific rather than a general failure to report.
235+
if result, present := report.Capabilities[tck.Object.Tag()]; !present {
236+
t.Errorf("%s was declared and exercised but is missing from the report", tck.Object.Tag())
237+
} else if result.State != tck.OutcomePassed {
238+
t.Errorf("%s reported as %q, want %q", tck.Object.Tag(), result.State, tck.OutcomePassed)
239+
}
240+
}

0 commit comments

Comments
 (0)