Skip to content

Conformance harness

Package gitlab.com/phpboyscout/go/chat-platform/test, conventionally imported as chatplatformtest or cptest. It checks the behaviour a consumer relies on that the compiler cannot see.

For how to wire it into a provider's test suite, see Run the conformance harness. This page is the list of what it actually asserts.

func RunProviderConformance(t *testing.T, cfg ConformanceConfig)

Every check runs without a network and sends no token. It reports through t.Errorf, so a failing provider reports every breach in one run rather than stopping at the first.

ConformanceConfig

type ConformanceConfig struct {
    NewClient      func(chatplatform.ClientConfig) (chatplatform.Client, error)
    Needs          []chatplatform.Need
    Refuses        []chatplatform.Need
    Capabilities   Capabilities
    Space          chatplatform.ID
    AllowedChannel chatplatform.ID
    SecondSpace    chatplatform.ID
}

type Capabilities struct {
    Moderator         bool
    MemberInspector   bool
    Interactive       bool
    Commands          bool
    Author            bool
    VoiceReceiver     bool
    VoiceSender       bool
    VoiceParticipants bool
    Indicator         bool
    ReactionObserver  bool
}
Field Required Notes
NewClient yes; t.Fatal if nil called once per check, so each gets an independent transport
Needs no declared to the client; leave empty and the harness declares all of them, less Refuses, since capability checks are about what a provider implements and discovery is gated on declared needs. A need in both Needs and Refuses stops the run
Refuses no the needs the transport can never serve, so a client asked for one must not be built; a transport that carries audio only names NeedMessages. A declaration the harness trusts, as Capabilities is
Capabilities no the zero value declares none, which is a legitimate provider
Space in practice the space every scope is minted on
AllowedChannel in practice becomes a one-element allowlist via WithAllowedChannels
SecondSpace yes; reported as ConformanceConfig if empty or equal to Space a different space, never dialled; the sibling-scope checks need it, and Participants uses it as a channel outside the allowlist

The harness mints several scopes from one client, because the failures it checks for cannot happen with only one. A client that refuses a scope on a second space, hands the same Provider back for two of them, lets a scope's Close tear down the transport, or (the other way round) grants two live scopes on one space, is broken for a multi-tenant consumer and indistinguishable from a correct one otherwise. The last is checked sequentially and then under fifty concurrent calls, because a guard that checks and acts without holding its lock passes every sequential test and hands out duplicates only under load.

Voice is two fields because it is two interfaces. A provider may implement the receiving half without the sending half, so declaring one does not declare the other.

If a provider's voice support depends on a build tag (the Discord provider's does, because end-to-end encryption needs a CGO dependency), declare these from the same build-tagged file that decides it. A declaration written once by hand drifts from reality in precisely the build nobody runs the harness in, and the symptom is a conformance failure on a machine that is configured differently from the one that last passed.

Space, SecondSpace and AllowedChannel must be syntactically valid identifiers for your platform. They are never dialled; the harness only needs values your constructor will accept. Discord's provider is exercised with real-looking snowflakes for exactly this reason.

SecondSpace is required, and it is the one people expect to be expensive. It is not: nothing connects, so it needs only to be an identifier your constructor accepts, and fabricating one is fine. No second real space has to exist anywhere.

It has to be there because scopes on one space and scopes on different spaces are different rules, and only the second catches a guard keyed on the client rather than the space, the mistake an author implementing the singleton in a hurry makes, which passes every same-space check and breaks every multi-tenant consumer. Leaving it unset fails the run rather than skipping those checks, because a harness that quietly drops a check when it is under-configured tells you your provider is sound when nothing looked at it.

What the harness builds is:

// once per check, so each gets an independent transport; no token
chatplatform.ClientConfig{Needs: needs} // cfg.Needs, or every need less Refuses when empty

// then scopes on it
c.Provider(ctx, cfg.Space, chatplatform.WithAllowedChannels(cfg.AllowedChannel))

with WithReadOnly added only for the read-only check. Note that no token is sent: ClientConfig.Token requires a constructor to accept an empty one and leave its validity to Connect, so a provider that needs a real credential to construct cannot be conformance-tested at all, and the same applies to one that dials on NewClient rather than lazily. A transport that never authenticates is built exactly as honestly as one that does.

Every check the harness runs

Construction is checked first and everything else is skipped if it fails, so a provider that cannot be built reports one clear error rather than a cascade of nil-pointer symptoms.

Reported as Fails when
Refuses a need is in both Needs and Refuses; nothing else runs
NewClient asked for a need in Refuses alone, the factory builds a client, errors with anything but ErrUnsupported, or errors without naming the need
NewClient the factory errors on a valid client config
NewClient the factory returns a nil client and a nil error
NewClient a scope cannot be minted for a valid space
NewClient minting returns a nil provider and a nil error
Name Provider.Name is empty
Reader Provider.Reader is nil
Actor Provider.Actor is nil for a writable config
Messages Reader.Messages() returns a nil channel
ReadOnly the factory errors on a read-only config
ReadOnly the factory returns a nil provider for a read-only config
ConformanceConfig SecondSpace is empty, or the same as Space; the sibling checks did not run
Provider a second scope on the same space is granted, refused with anything but ErrAlreadyScoped, refused without naming the space, or refused alongside a non-nil Provider
Provider closing a scope does not release its space for a new one
Provider under fifty concurrent calls on one space, more or fewer than one is granted, or a losing call returns anything but a nil Provider and ErrAlreadyScoped
Provider a scope cannot be minted on a second space, or comes back nil with a nil error
Provider the same Provider comes back for two spaces
Provider closing one scope stops the client minting others
Provider the declared needs are not carried onto the minted scope
Provider a need is carried that the client was never asked for
Participants a channel outside the allowlist is not refused with ErrChannelDenied
Participants an empty channel id is not refused with ErrInvalidArgument
Participants a call before Connect does not return ErrNotConnected
Indicator IndicatorLimits().MaxLength is not positive
Indicator Show accepts empty text, or text one rune over the limit
Indicator Show refuses text exactly at the limit in two-byte runes, so the limit is counted in bytes
Indicator Show or Clear before Connect does not return ErrNotConnected
ReadOnly Actor is non-nil despite WithReadOnly
ReadOnly Reader is nil for a read-only config
ReadOnly VoiceParticipants or ReactionObserver is declared but not discoverable on a read-only scope; it was put on the Actor
Reactions ReactionObserver.Reactions() returns a nil channel
Close Reader.Close() panics unconnected, or on the second call
VoiceReceiver.Capabilities Format is not Valid, Format.MaxPayloadBytes is negative, or the call panics on a provider that never connected
Moderator etc. the capability is declared but the Actor does not implement it
Moderator etc. the Actor implements it but Capabilities does not declare it

A nil Reader stops the shape check there. A provider that cannot observe is not usable, so the remaining checks would only restate it.

What the isolation checks do not cover. Except through VoiceParticipants.Participants, which takes a bare channel id and must refuse one outside the allowlist before touching the platform, so the harness does exercise that refusal where the capability is declared. Otherwise the harness cannot observe what a provider's allowlist admits. That is internal to the provider, and inventing a second channel identifier to probe it would be rejected by anything that parses them. So it checks the structural half a provider cannot get right by accident: that a second scope can be minted, that it is a distinct Provider, and that closing one does not stop the client minting others. The allowlist rule itself is the contract's, and each provider tests it against its own internals.

Why capabilities are checked in both directions

Both mistakes are silent:

  • Declared but missing makes the harness look for something impossible, and makes a consumer's AsModerator return false where the author thought it would not.
  • Implemented but not declared is worse in a quiet way: the capability works, and nobody ever discovers it. A capability nobody declares is a capability nobody uses.

Why Close is called twice

Shutdown runs after failures, and it runs from defer blocks that may already have run once. A Close that panics on the second call buries whatever error caused the shutdown, so the harness calls it twice on a provider that never connected, which is the state shutdown most often happens in.

What the harness does not check

Everything here is real contract behaviour that a passing provider may still get wrong. None of it can be checked offline.

  • That the allowlist filters anything. AllowedChannel is passed to the constructor and never exercised. A provider that ignores the scope's allowlist entirely passes conformance.
  • Anything a Reaction carries. Which event maps to which change, the own-reaction drop, the member on an Added, the variant, the emoji round-trip against React: all need a platform event to produce, so they are provider conformance, with synthesised gateway events.
  • That ErrNotConnected is returned, except from VoiceParticipants.Participants and Indicator.Show and Clear, which the harness does call before Connect where the capability is declared. The only other method called is VoiceReceiver.Capabilities, which the contract promises is answerable before joining. Nothing else is called, so a provider that panics elsewhere instead of returning the sentinel passes.
  • Anything about a voice session. Join is never called, so the format a session reports, attribution, sequence numbers and interruptions are all provider conformance. Capabilities is checked for coherence, not against any platform's numbers: a harness expecting 48000 Hz would be a Discord harness.
  • That Connect works, or fails cleanly. Connect is never called.
  • That Messages() or Reactions() ever delivers anything, or that either closes on Close. Only that the channel is non-nil.
  • That capabilities behave. Only that the type assertion succeeds. A Moderator whose DeleteMessage always fails passes.
  • That ConnState is ever populated, or that LastReconnectLostEvents can fire. This is the single most valuable behaviour in the contract and the hardest to test, because it needs a real connection cut peer-side. See Testing it.
  • Concurrency, beyond the singleton. The scope-singleton check is the one thing the harness runs concurrently, and it is a detector rather than a proof: a guard with a very narrow window between its check and its write can pass fifty contended calls by luck, and the race detector sees nothing because every access is still under a lock. Nothing else runs in parallel, and nothing runs under -race from inside the harness. Run your own tests with -race.

Those belong in the provider's own integration tests, gated so they do not run on every merge. Passing conformance means the contract a consumer depends on before a single message arrives is honoured, not that the provider works.

Reading a failure

Failures are reported as method: message, where method is the column above:

--- FAIL: TestConformance
    conformance_test.go:18: ReadOnly: exposed an Actor despite WithReadOnly; an
        observe-only deployment must have nothing to post or delete through

Treat one as a finding about your provider before you treat it as one about the harness. On the Discord provider's first run the harness reported that the provider could not be constructed at all, and that an implemented capability was not declared. Both were real.