Skip to content

Commit 14191cb

Browse files
committed
feat(provider-tck): fail a run that did not execute the canonical set
The existing accounting proves the report has an entry for every scenario. It does not prove the run produced those entries: a scenario is registered when it is defined, so one the runner declines to run carries a placeholder failure and satisfies the accounting anyway. A partial run therefore goes green -- a `-t` filter, a `testPathIgnorePatterns` entry, a mistake in the extension wiring -- while its report supports nothing. The recorder now separates a scenario it has *recorded* from one the run has *settled*, and the suite fails unless every canonical scenario reached a decision. A capability skip is a decision and passes: declaring fewer capabilities narrows what the suite asks, says so in the declaration and in each skipped result's reason, and is a claim the provider is making rather than a hole in the run. Extension scenarios are excluded -- which of their own an adopter runs is the adopter's business. The limit is Jest's: a suite whose tests are all filtered out, or whose file is excluded, never reaches `afterAll`, so nothing runs to complain. That case produces no report at all rather than a green partial one. Working on one scenario with `-t` now ends in a failed suite. That is the cost of not being able to mistake a partial run for a complete one. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
1 parent bd76f51 commit 14191cb

4 files changed

Lines changed: 145 additions & 9 deletions

File tree

libs/shared/provider-tck/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,18 @@ is reported as skipped and *never* as passed — checkable by a consumer rather
288288
runner's summary being trustworthy. The harness checks the accounting itself at the end of every
289289
run, report or no report, and fails the suite if a scenario is missing or recorded twice.
290290

291+
**The canonical scenarios must also have actually run.** The accounting above proves the report has
292+
an entry per scenario; it does not prove the run produced those entries, because a scenario is
293+
registered when it is *defined* and one the runner declines to run carries a placeholder failure
294+
instead. So a filtered run — `jest -t`, a `testPathIgnorePatterns` entry, a mistake in the extension
295+
wiring — satisfies the accounting and goes green while its report supports nothing. The harness
296+
therefore fails the suite unless every canonical scenario reached a decision. A capability skip is a
297+
decision and passes the check; extension scenarios are excluded from it, because which of their own
298+
scenarios an adopter runs is the adopter's business.
299+
300+
Working on one scenario with `-t` therefore ends in a failed suite. That is the intended cost: the
301+
alternative is a green run that cannot be told apart from a complete one.
302+
291303
### Reading the stream
292304

293305
| Question | Where the answer is |
@@ -326,7 +338,8 @@ hand-rolled and no id is invented.
326338

327339
A scenario is registered when it is *defined*, so one Jest never finished — a timeout, or a `-t`
328340
filter — still appears, as a failure that says so. **A report from a filtered run is partial by
329-
construction; do not publish one.**
341+
construction**, and the canonical-coverage check above fails the suite rather than leaving that to
342+
be noticed.
330343

331344
**One `TestStep` per test case, not one per Gherkin step.** jest-cucumber runs a whole scenario as a
332345
single Jest test and reports one outcome for it; it never says which step failed. A step per Gherkin

libs/shared/provider-tck/src/lib/report.spec.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
REPORT_DIR_ENV,
1313
coverageProblems,
1414
reportBaseName,
15+
unexecutedScenarios,
1516
writeConformanceReport,
1617
} from './report';
1718
import { loadTckFeatures } from './runProviderTck';
@@ -179,15 +180,19 @@ describe('scenario accounting', () => {
179180
]);
180181
});
181182

182-
it('accounts for every planned scenario exactly once', () => {
183-
const recorder = new ConformanceRecorder({
183+
/** A recorder writing into the stream the canonical features above were registered with. */
184+
const recorderInto = () =>
185+
new ConformanceRecorder({
184186
suiteName: 'unit',
185187
control,
186188
declared: new Set(declared),
187189
notApplicable: new Map(),
188190
messages,
189191
});
190192

193+
it('accounts for every planned scenario exactly once', () => {
194+
const recorder = recorderInto();
195+
191196
for (const feature of plans) {
192197
for (const { name, pickleId, example, missing } of feature.scenarios) {
193198
const identity = { feature: feature.feature, name, pickleId, example };
@@ -233,6 +238,50 @@ describe('scenario accounting', () => {
233238
'[key=string-flag requested=Integer default=1]: expected 1 outcome(s), recorded 2',
234239
]);
235240
});
241+
242+
it('does not call a scenario executed until the runner has decided about it', () => {
243+
// The distinction the canonical guard turns on, and the reason `coverageProblems` alone is not
244+
// enough. A scenario is registered when it is *defined*, so one Jest then declines to run is
245+
// present in the report -- carrying the placeholder failure -- and satisfies the accounting
246+
// above while having proved nothing.
247+
const recorder = recorderInto();
248+
const held = planned.find((scenario) => !scenario.example) as ScenarioIdentity;
249+
250+
const complete = recorder.started(held);
251+
for (const scenario of planned.filter((entry) => entry !== held)) {
252+
recorder.started(scenario)({ durationMs: 0 });
253+
}
254+
255+
expect(unexecutedScenarios(planned, recorder.settled)).toEqual([`${held.feature}.feature: ${held.name}`]);
256+
257+
complete({ durationMs: 1 });
258+
expect(unexecutedScenarios(planned, recorder.settled)).toEqual([]);
259+
});
260+
261+
it('treats a capability skip as a decision rather than as a scenario that did not run', () => {
262+
// Declaring fewer capabilities narrows what the suite asks, and says so in the declaration and
263+
// in every skipped result's reason. It is a claim the provider is making, not a hole in the run,
264+
// so the guard must not fire on it -- otherwise no provider could adopt the suite selectively.
265+
const recorder = recorderInto();
266+
const gated = plans.flatMap((feature) =>
267+
feature.scenarios
268+
.filter((scenario) => scenario.missing.length)
269+
.map(({ name, pickleId, example, missing }) => ({
270+
identity: { feature: feature.feature, name, pickleId, example },
271+
missing,
272+
})),
273+
);
274+
275+
expect(gated.length).toBeGreaterThan(0);
276+
gated.forEach(({ identity, missing }) => recorder.skipped(identity, missing));
277+
278+
expect(
279+
unexecutedScenarios(
280+
gated.map(({ identity }) => identity),
281+
recorder.settled,
282+
),
283+
).toEqual([]);
284+
});
236285
});
237286

238287
describe('writing the report', () => {

libs/shared/provider-tck/src/lib/report.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ const UNREPORTED =
152152
*/
153153
export class ConformanceRecorder {
154154
private readonly recorded: ScenarioIdentity[] = [];
155+
private readonly settledIds = new Set<string>();
155156

156157
constructor(private readonly context: RecorderContext) {}
157158

@@ -164,6 +165,10 @@ export class ConformanceRecorder {
164165
*/
165166
skipped(scenario: ScenarioIdentity, missing: readonly Capability[]): void {
166167
this.register(scenario, { status: TestStepResultStatus.SKIPPED, message: this.skipReason(missing) });
168+
// A gate skip is a settled outcome, not an absent one. The question was put to the suite and
169+
// answered: this provider does not claim the capability. That is exactly what a conformance
170+
// report is for, so it must not read as a scenario the run failed to reach.
171+
this.settledIds.add(scenario.pickleId);
167172
}
168173

169174
/**
@@ -176,7 +181,12 @@ export class ConformanceRecorder {
176181
started(scenario: ScenarioIdentity): (completion: { durationMs: number; error?: unknown }) => void {
177182
const complete = this.register(scenario, { status: TestStepResultStatus.FAILED, message: UNREPORTED });
178183

179-
return ({ durationMs, error }) =>
184+
return ({ durationMs, error }) => {
185+
// Settled only here, when the scenario's body has actually run. Registration happens when the
186+
// scenario is *defined*, which Jest does even for a scenario it then declines to run, so
187+
// registration alone says nothing about execution.
188+
this.settledIds.add(scenario.pickleId);
189+
180190
complete(
181191
error === undefined
182192
? { status: TestStepResultStatus.PASSED, durationMs }
@@ -186,6 +196,7 @@ export class ConformanceRecorder {
186196
message: error instanceof Error ? error.message : String(error),
187197
},
188198
);
199+
};
189200
}
190201

191202
/**
@@ -196,13 +207,27 @@ export class ConformanceRecorder {
196207
*/
197208
skippedUnexpectedly(scenario: ScenarioIdentity, reason: string): void {
198209
this.register(scenario, { status: TestStepResultStatus.FAILED, message: reason });
210+
// Settled: the run reached this scenario and decided about it. The decision was wrong, and it is
211+
// recorded as a failure, which is a different complaint from the one the coverage guard makes.
212+
this.settledIds.add(scenario.pickleId);
199213
}
200214

201215
/** Every scenario recorded so far, in the order it was recorded. */
202216
get results(): readonly ScenarioIdentity[] {
203217
return this.recorded;
204218
}
205219

220+
/**
221+
* The scenarios whose outcome the run actually determined, by pickle id.
222+
*
223+
* Narrower than {@link results}, and the difference is the point: a scenario is *recorded* when it
224+
* is defined and *settled* when the runner has decided about it. Everything recorded but not
225+
* settled is a scenario the report carries a placeholder failure for rather than a result.
226+
*/
227+
get settled(): ReadonlySet<string> {
228+
return this.settledIds;
229+
}
230+
206231
/** How many scenarios ended in each Cucumber status, for a line a human reads. */
207232
get statusCounts(): Record<string, number> {
208233
return this.context.messages.statusCounts;
@@ -343,6 +368,28 @@ export function coverageProblems(
343368
return problems.sort();
344369
}
345370

371+
/**
372+
* The scenarios that were planned but whose outcome the run never determined.
373+
*
374+
* {@link coverageProblems} asks whether the report accounts for every scenario; this asks the
375+
* separate question of whether the run *answered* for them. The two differ because a scenario is
376+
* registered when it is defined, so one Jest declined to run is present in the report -- carrying
377+
* the placeholder failure {@link ConformanceRecorder.started} wrote -- and therefore satisfies the
378+
* accounting while proving nothing.
379+
*
380+
* A capability skip is settled and does not appear here. Declaring fewer capabilities narrows what
381+
* the suite asks; it is visible in the report's declaration and in each skipped result's reason, and
382+
* it is a claim the provider is making rather than a hole in the run.
383+
*
384+
* Give this the canonical scenarios only. An adopter's own scenarios are theirs to filter.
385+
*/
386+
export function unexecutedScenarios(planned: readonly ScenarioIdentity[], settled: ReadonlySet<string>): string[] {
387+
return planned
388+
.filter((scenario) => !settled.has(scenario.pickleId))
389+
.map(scenarioName)
390+
.sort();
391+
}
392+
346393
/**
347394
* A scenario's identity as one printable string, for a diagnostic a person has to act on.
348395
*

libs/shared/provider-tck/src/lib/runProviderTck.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import type { FeatureMessages } from './messages';
1010
import { ConformanceMessages, readFeatureMessages } from './messages';
1111
import type { TckOptions } from './options';
1212
import { resolveCapabilities } from './options';
13-
import { ConformanceRecorder, coverageProblems, writeConformanceReport } from './report';
13+
import type { ScenarioIdentity } from './report';
14+
import { ConformanceRecorder, coverageProblems, unexecutedScenarios, writeConformanceReport } from './report';
15+
import { SPEC_REVISION } from './revision';
1416
import type { FeaturePlan } from './scenarioRunner';
1517
import { planFeature, scenarioRunner } from './scenarioRunner';
1618
import { TckState } from './state';
@@ -311,17 +313,42 @@ export function runProviderTck(options: TckOptions): void {
311313
// skipped and never as passed. That is only checkable if the report accounts for every
312314
// scenario, so the accounting is verified here rather than assumed -- in every run, not only
313315
// when a report is being written.
314-
const planned = plans.flatMap((plan) =>
315-
plan.scenarios.map(({ name, pickleId, example }) => ({ feature: plan.feature, name, pickleId, example })),
316-
);
317-
const problems = coverageProblems(recorder.results, planned);
316+
const plannedIn = (wanted: boolean): ScenarioIdentity[] =>
317+
plans.flatMap((plan, position) =>
318+
features[position].canonical === wanted
319+
? plan.scenarios.map(({ name, pickleId, example }) => ({ feature: plan.feature, name, pickleId, example }))
320+
: [],
321+
);
322+
323+
const canonicalPlanned = plannedIn(true);
324+
const problems = coverageProblems(recorder.results, [...canonicalPlanned, ...plannedIn(false)]);
318325
if (problems.length) {
319326
throw new Error(
320327
`provider-tck [${options.name}]: the conformance report does not account for every ` +
321328
`scenario exactly once, so it cannot be trusted:\n ${problems.join('\n ')}`,
322329
);
323330
}
324331

332+
// The accounting above proves the report has an entry for every scenario. It does not prove
333+
// the run produced those entries: a scenario is registered when it is defined, so one Jest
334+
// then declined to run carries a placeholder failure and satisfies the accounting anyway. A
335+
// partial run therefore goes green today -- a `-t` filter, a `testPathIgnorePatterns` entry,
336+
// or a mistake in the extension wiring -- while its report claims nothing it can support.
337+
//
338+
// The canonical set is the entire content of a conformance claim, so it is checked. Extension
339+
// scenarios are excluded: they are the adopter's, and filtering them is the adopter's business.
340+
const unexecuted = unexecutedScenarios(canonicalPlanned, recorder.settled);
341+
if (unexecuted.length) {
342+
throw new Error(
343+
`provider-tck [${options.name}]: ${unexecuted.length} of ${canonicalPlanned.length} ` +
344+
`canonical scenarios did not run, so this run cannot support a conformance claim and ` +
345+
`its report must not be published. The canonical set is the one in open-feature/spec ` +
346+
`at ${SPEC_REVISION || 'an unknown revision'}; it is fixed, and running less of it is ` +
347+
`not a configuration. If you filtered deliberately -- 'jest -t' while working on a ` +
348+
`single scenario -- this failure is the expected consequence.\n ${unexecuted.join('\n ')}`,
349+
);
350+
}
351+
325352
const written = writeConformanceReport(recorder, options.name);
326353
if (written) {
327354
const counts = Object.entries(recorder.statusCounts)

0 commit comments

Comments
 (0)