Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions libs/providers/flagd/src/e2e/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const config: Config = {
preset: 'ts-jest',
moduleNameMapper: {
'@openfeature/flagd-core': ['<rootDir>/../../../../shared/flagd-core/src'],
'@openfeature/provider-tck': ['<rootDir>/../../../../shared/provider-tck/src'],
'(.+)\\.js$': '$1',
},
verbose: true,
Expand Down
42 changes: 42 additions & 0 deletions libs/providers/flagd/src/e2e/tests/tck-in-process.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Capability } from '@openfeature/provider-tck';
import { runFlagdTck } from './tckSuite';

/**
* The OpenFeature Provider Conformance Suite, run against the flagd provider's in-process resolver.
*
* One `runProviderTck` call per file — see the note in `tckSuite.ts`.
*/
runFlagdTck({
name: 'flagd-in-process',
resolverType: 'in-process',

/*
* The same five capabilities as the RPC suite, and that identity is the finding rather than a
* copy-paste: in Go the two resolvers differ over PROVIDER_STALE (go-sdk-contrib#939), here they
* cannot, because both report a lost connection through the same `disconnectCallback` seam —
* src/lib/service/in-process/grpc/grpc-fetch.ts:197 here, src/lib/service/grpc/grpc-service.ts:274
* for RPC — and the single handler behind it, src/lib/flagd-provider.ts:130-148, emits
* PROVIDER_STALE (flagd-provider.ts:136) before escalating to PROVIDER_ERROR
* (flagd-provider.ts:144).
*
* The omissions are the same and have the same reasons: StrictNumericTyping because JavaScript has
* no integer type, so the scenario is unsatisfiable by construction (see the TCK README), and
* Targeting and Caching because no scenario carries their tags yet.
*
* One in-process detail worth recording: the file/offline fetcher —
* src/lib/service/in-process/file/file-fetch.ts — never calls `disconnectCallback` and so emits
* neither PROVIDER_STALE nor a reconnect PROVIDER_READY. That mode is not under test here; a suite
* covering `offlineFlagSourcePath` would have to leave Stale undeclared.
*/
capabilities: [
Capability.Events,
Capability.Stale,
Capability.ConfigurationChange,
Capability.Object,
Capability.UnavailableInit,
],

// In-process syncs the whole ruleset before reporting ready, so it needs longer than RPC.
readyTimeoutMs: 60_000,
retryGracePeriod: 30,
});
46 changes: 46 additions & 0 deletions libs/providers/flagd/src/e2e/tests/tck-rpc.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Capability } from '@openfeature/provider-tck';
import { runFlagdTck } from './tckSuite';

/**
* The OpenFeature Provider Conformance Suite, run against the flagd provider's RPC resolver.
*
* One `runProviderTck` call per file — see the note in `tckSuite.ts`.
*/
runFlagdTck({
name: 'flagd-rpc',
resolverType: 'rpc',

/*
* Five capabilities, and every omission is derived from the provider's source rather than assumed:
*
* - Stale IS declared, and that is worth stating plainly because the Go provider is different. In
* Go, the RPC resolver never emits PROVIDER_STALE while in-process does
* (go-sdk-contrib#939). Here there is no such asymmetry: both resolvers report a lost connection
* through the same `disconnectCallback` seam — src/lib/service/grpc/grpc-service.ts:274 for RPC,
* src/lib/service/in-process/grpc/grpc-fetch.ts:197 for in-process — and the single handler
* behind it, src/lib/flagd-provider.ts:130-148, emits PROVIDER_STALE immediately
* (flagd-provider.ts:136) and escalates to PROVIDER_ERROR only once `retryGracePeriod` expires
* (flagd-provider.ts:144). The staleness contract is implemented once, in the provider, so it
* cannot differ between resolvers.
* - UnavailableInit is declared: an unreachable backend rejects out of
* `waitForReady` (grpc-service.ts:194-202), which rejects `connect` and therefore `initialize`.
* - ConfigurationChange is declared, and the event carries the changed keys —
* grpc-service.ts:243 derives them from the flagd change message and flagd-provider.ts:151
* puts them in the payload as `flagsChanged`, which the suite asserts on.
* - StrictNumericTyping is omitted for the reason every JavaScript provider omits it: the language
* has no integer type, so the scenario is unsatisfiable by construction rather than by defect.
* See "The one place JavaScript cannot answer the shared question" in the TCK README.
* - Targeting and Caching are omitted because no scenario carries their tags yet.
*/
capabilities: [
Capability.Events,
Capability.Stale,
Capability.ConfigurationChange,
Capability.Object,
Capability.UnavailableInit,
],

// The RPC resolver asks flagd to resolve each flag, so it is ready as soon as the stream is up.
readyTimeoutMs: 30_000,
retryGracePeriod: 30,
});
132 changes: 132 additions & 0 deletions libs/providers/flagd/src/e2e/tests/tckSuite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { Capability } from '@openfeature/provider-tck';
import { HttpControl, runProviderTck } from '@openfeature/provider-tck';
import type { ResolverType } from '../../lib/configuration';
import { FlagdProvider } from '../../lib/flagd-provider';
import { FlagdComposeContainer } from './flagdComposeContainer';

/**
* The shared body of the two flagd conformance suites.
*
* flagd resolves flags two quite different ways — RPC evaluates remotely over gRPC, in-process syncs
* the ruleset and evaluates locally — and they are separate suites because they are separately
* conformant. Any difference between the two results is a difference an application would see when
* it switches resolver, which is exactly the kind of thing the suite exists to surface.
*
* They are separate **files** for a mechanical reason on top of that: jest-cucumber accumulates step
* definitions in module state, so two `runProviderTck` calls in one file would register the
* vocabulary twice and every step would report as ambiguous.
*
* The existing e2e suites in this directory are untouched, and so is flagd-testbed. The TCK drives
* the testbed's launchpad through the standardised control API, which the launchpad already
* implements.
*/
export interface FlagdTckSuite {
/** Identifies the suite in test output and scopes its OpenFeature domain. */
name: string;

/** Which resolver is under test. Also selects the container port the provider connects to. */
resolverType: ResolverType;

/** What the resolver supports, and — more importantly — what it does not. */
capabilities: readonly Capability[];

/** How long the provider may take to reach READY. */
readyTimeoutMs: number;

/**
* Seconds the provider stays STALE before escalating to ERROR.
*
* It has to outlast the outage in the `@stale` scenario, which lasts as long as the suite takes to
* assert the stale event and then call `/start` — bounded by {@link EVENT_TIMEOUT_MS}. Too short a
* value would turn a scenario about staleness into one about failure.
*/
retryGracePeriod: number;
}

/**
* How long to wait for a provider event.
*
* flagd streams, so it sees an outage or a configuration change in well under a second; this is
* headroom for a loaded CI machine rather than an expected latency.
*/
const EVENT_TIMEOUT_MS = 15_000;

/** Bounds a single scenario, which may register a provider and then await several events. */
const SCENARIO_TIMEOUT_MS = 120_000;

/** Bounds bringing the Compose stack up, which on a cold machine includes pulling the image. */
const STACK_TIMEOUT_MS = 180_000;

export function runFlagdTck(suite: FlagdTckSuite): void {
jest.setTimeout(SCENARIO_TIMEOUT_MS);

// Deliberately no jest.retryTimes, unlike the neighbouring flagd e2e suites: a conformance result
// that only holds on the third attempt is not a conformance result. If a scenario is flaky here,
// the timings above are the knob, or the flakiness is the finding.

// The stack is started once for the whole suite and never restarted. Scenario isolation comes from
// the control API instead — see the no-container-restart invariant in the control API
// specification. Registered at the file's root scope, so it runs before the `beforeEach` that
// runProviderTck installs inside its own describe block.
const container = FlagdComposeContainer.build();

beforeAll(async () => {
await container.start();
}, STACK_TIMEOUT_MS);

afterAll(async () => {
// Guarded because stop() throws on a stack that never came up, which would bury the startup
// failure that is the actual finding under a second, misleading one.
if (container.isStarted()) {
await container.stop();
}
}, STACK_TIMEOUT_MS);

// The launchpad's host port is mapped dynamically and does not exist until the stack is up, while
// runProviderTck must be called at module load — hence the thunk. getLaunchpadUrl() returns
// "host:port" with no scheme, and the control API address needs one.
const control = new HttpControl({ baseUrl: () => `http://${container.getLaunchpadUrl()}` });

runProviderTck({
name: suite.name,
control,

// Read inside the factory, not above it: the testbed maps host ports dynamically, so they do not
// exist until the stack is up. They stay valid for the whole suite because nothing restarts a
// container.
//
// The timings other than retryGracePeriod are the ones the neighbouring flagd e2e suites already
// use, where they are described as optimised for test speed and stability.
newProvider: () =>
new FlagdProvider({
resolverType: suite.resolverType,
host: 'localhost',
port: container.getPort(suite.resolverType),
deadlineMs: 15000,
keepAliveTime: 200,
retryBackoffMs: 100,
retryBackoffMaxMs: 500,
retryGracePeriod: suite.retryGracePeriod,
}),

// Pointed at a closed port on localhost, never at the backend under test — that has to stay up,
// and simulated outages belong to the control API. The deadlines are deliberately short: the
// scenario asserts that failure is reported promptly, so a provider that took 30 seconds to give
// up would pass a test about eventual failure and fail the one that matters. A one-second grace
// period is what turns the initial STALE into the ERROR the scenario waits for.
newUnavailableProvider: () =>
new FlagdProvider({
resolverType: suite.resolverType,
host: 'localhost',
port: 9999,
deadlineMs: 500,
retryBackoffMs: 100,
retryBackoffMaxMs: 500,
retryGracePeriod: 1,
}),

capabilities: suite.capabilities,
readyTimeoutMs: suite.readyTimeoutMs,
eventTimeoutMs: EVENT_TIMEOUT_MS,
});
}
23 changes: 21 additions & 2 deletions libs/shared/provider-tck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,26 @@ the normative contract for those providers, and it is what makes a conformance c
another language's TCK drives the same endpoints against the same stack and must get the same
answers.

Two of its requirements are easy to get wrong:
`HttpControl` is the client for it, and it implements both `BackendControl` and `ConnectionControl`
over the global `fetch`, so it adds no dependency. It takes a thunk for the base URL because the
control service's host port is mapped dynamically and does not exist until the stack is up, whereas
`runProviderTck` has to be called at module load:

```ts
const container = MyComposeStack.build();
beforeAll(() => container.start());
afterAll(() => container.stop());

const control = new HttpControl({ baseUrl: () => `http://${container.getControlUrl()}` });
```

It prefers `POST /reset` for scenario isolation and falls back to `POST /start?config=default` on a
`404` or `501`, caching that decision once per suite. The fallback is the normal path rather than an
edge case — flagd-testbed's launchpad, the reference implementation, serves only `/start`,
`/restart`, `/stop` and `/change`. After a disconnect it always uses `/start`, because `/reset`
restores flag state and is not specified to start a stopped backend.

Two of the API's requirements are easy to get wrong:

- **Containers are never stopped or restarted mid-suite.** Unavailability is simulated *inside* the
running stack. Container orchestrators assign host ports dynamically and cannot reliably preserve
Expand Down Expand Up @@ -176,6 +195,7 @@ the same reason as the other two: there is no backend for initialisation to reac
| `inMemory.spec.ts` | the SDK's `InMemoryProvider` | reference adoption for a backend-less provider, and the Docker-free canary |
| `multiProvider.spec.ts` | `MultiProvider` wrapping one child | delegation must be transparent |
| `inProcessControl.spec.ts` | `InProcessControl` | pins what the Gherkin cannot assert about itself |
| `httpControl.spec.ts` | `HttpControl` | pins the control-API request sequence, without a container |

`multiProvider.spec.ts` wraps exactly one child deliberately. That is the interesting configuration
rather than a degenerate one: the correct answer is precisely what the in-memory suite already
Expand Down Expand Up @@ -220,7 +240,6 @@ by every language's TCK.

## Known gaps

- **No HTTP control client yet** — it arrives with the first containerised adopter.
- **Evaluation context passthrough is unverifiable** without an echo operation on the control API.
- Caching, hooks and flag metadata are not covered.

Expand Down
2 changes: 2 additions & 0 deletions libs/shared/provider-tck/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export { asConnectionControl, unsupportedControl } from './lib/control';
export type { BackendControl, ConnectionControl } from './lib/control';
export { CHANGING_BASELINE, CHANGING_CHANGED, CHANGING_FLAG_KEY, canonicalFlagSet } from './lib/flags';
export type { FlagConfiguration } from './lib/flags';
export { DEFAULT_CONFIGURATION, DEFAULT_CONTROL_TIMEOUT_MS, HttpControl } from './lib/httpControl';
export type { HttpControlOptions } from './lib/httpControl';
export { InProcessControl } from './lib/inProcessControl';
export { DEFAULT_EVENT_TIMEOUT_MS, DEFAULT_READY_TIMEOUT_MS, domainFor } from './lib/options';
export type { ProviderFactory, TckOptions } from './lib/options';
Expand Down
Loading
Loading