Conformance harness¶
forgetest.RunProviderConformance asserts the half of the provider contract the
compiler cannot see: which sentinel to return, that a nil result and a nil error
never come back together, that a size bound you accepted is actually enforced.
import forgetest "gitlab.com/phpboyscout/go/forge/test"
func TestConformance(t *testing.T) {
forgetest.RunProviderConformance(t, forgetest.ConformanceConfig{
NewProvider: func() forge.Provider { return newTestProvider(t) },
Capabilities: forgetest.Capabilities{
GetReleaseByTag: true,
ListReleases: false, // platform has no listing
},
Owner: "acme", Repo: "tool", Tag: "v1.0.0",
})
}
For the narrative version (why it exists, what to do about each finding) see author a provider. This page is the field list.
What the harness runs¶
Seventeen checks, in this order. Each is wrapped so a panic from the provider is reported as a violation rather than ending the test run. The first provider to panic would otherwise take every later check down with it.
| Check | Covers |
|---|---|
GetLatestRelease |
never (nil, nil) |
GetReleaseByTag |
declared support behaves and never returns (nil, nil); undeclared returns ErrNotSupported |
ListReleases |
declared support behaves; undeclared returns ErrNotSupported |
DownloadReleaseAsset |
the redirect contract: a body or a redirect or an error, never a nil reader with an empty redirect and no error |
| size bounds | DownloadChecksumManifest and DownloadSignature honour the caller's maxBytes |
ListRepositories |
containment in the namespace, non-empty and unique Path, fail-closed visibility decoding |
ListRepositories early stop |
yield is not called again after it returns false, and stopping early is not an error |
GetFile |
reads what exists, ErrNotFound for what does not, maxBytes enforced |
GetSite |
ErrNotSupported or ErrNotFound where each belongs |
SearchIssues |
Text is honoured rather than ignored; early stop; issue shape |
GetIssue |
shape, and ErrNotFound rather than a bespoke error |
ListComments |
shape and early stop |
CreateIssue |
sanitisation by default, the Unsanitised opt-out, and at-most-once under a repeated IdempotencyKey |
CreateSnippet |
shape, that the effective visibility is never weaker than requested, a create/get/list round trip, and cleanup |
PullRequests |
Find returns an open one for the branch asked for, FindLastMerged returns a merged one with a non-zero MergedAt, and ResolveMergedCommit returns the commit on the target branch rather than the forge's recorded head, or ErrNotFound rather than a guess |
GetReleaseByTag missing tag |
a tag that does not resolve returns ErrReleaseNotFound, not a bespoke error |
| refusals | each status a forge can answer with is named by the right sentinel, including a 403 carrying rate-limit evidence, which is a rate limit rather than a permission failure |
| logging | no ERROR-level record for a condition also returned as an error |
Capabilities: declare honestly¶
The harness checks both directions: an undeclared capability must return
ErrNotSupported, and a declared one must not. Over-declaring is caught because
it would otherwise skip every check for that method while implying they ran.
| Field | Declare true when |
|---|---|
GetReleaseByTag |
the platform has an addressable tag concept |
ListReleases |
the platform exposes a release listing |
Checksums |
the provider implements forge.ChecksumProvider and is configured to serve a manifest |
Signatures |
the provider implements forge.SignatureProvider and is configured to serve one |
Repositories |
the provider implements forge.Repositories and can enumerate Namespace |
Contents |
the provider implements forge.Contents and can read FilePath |
Sites |
the provider implements forge.Sites and its forge publishes sites |
Issues |
the provider implements forge.Issues and can read IssueNumber |
IssueFiler |
the provider implements forge.IssueFiler. See the warning below |
Snippets |
the provider implements forge.Snippets and its forge still offers the feature. It writes, see below |
PullRequests |
the provider implements forge.PullRequests and ConformanceConfig.PullRequests names fixtures it can serve. Read-only: the harness never opens one |
ReleasePublisher |
the provider implements forge.ReleasePublisher. Its three refusal checks write NOTHING; the one write is gated separately on ReleaseFixture.PublishTag |
IssueFiler: true files real issues
Its writes are not undone. There is no delete in the contract, and most forges do not offer one.
Point NewProvider at a fixture. A provider aimed at a real tracker will
leave real issues in it, visible to everyone watching the project, and the
harness cannot tell the difference.
Snippets: true writes too, but cleans up
The check creates a snippet, reads it back, lists it and deletes it. The
contract carries a delete, so unlike IssueFiler this is safe to run
repeatedly.
A failure part-way through can still leave one behind, so point it at a
scope where a stray snippet is acceptable. The harness reports it under
DeleteSnippet when cleanup fails, rather than failing silently.
PullRequests needs an armed fixture or it proves nothing
The check that matters is ResolveMergedCommit, and it can only catch a
provider that returns the forge's recorded head if the fixture's
recorded head differs from the commit that landed.
Set PullRequestFixture.StaleHead to the head the forge reports and
MergedCommit to the commit actually on the target branch. If the two are
equal the harness reports the fixture, because a check that passes
whatever the provider does is worse than no check: it reads as coverage.
Leaving StaleHead empty is allowed, and the check is then unarmed rather
than passed. Point it at a pull request the forge auto-rebased.
Most providers declare Sites: false, and that is the capability pattern
working rather than a gap: two of the four first-party forges have no site
feature at all.
ConformanceConfig: required, optional, and what a blank field costs¶
An omitted optional field means the corresponding check is skipped, not assumed satisfied. That is deliberate, because a guessed fixture produces a verdict nobody can trust. It does mean a sparse config runs a much weaker suite than it looks like it is running.
| Field | Required when | Blank means |
|---|---|---|
NewProvider |
always | t.Fatal, and the harness will not run |
Capabilities |
always in practice | every capability false, so only the opt-out protocol is checked |
Owner, Repo |
always in practice | passed through as empty strings to every method |
Tag |
Capabilities.GetReleaseByTag |
the tag lookup has nothing to find |
NewProviderWithLogger |
never | the ERROR-level logging rule is unverified rather than satisfied |
MissingTag |
never | the ErrReleaseNotFound check on GetReleaseByTag is skipped, and the logging check has one fewer failing lookup to drive |
NewProviderAt |
never | every refusal check is skipped. See below |
Namespace |
Capabilities.Repositories |
enumeration has no namespace to walk |
FilePath, FileRef |
Capabilities.Contents |
the file read has nothing to fetch |
MissingFilePath |
never | the ErrNotFound check on GetFile is skipped |
SiteRepo, MissingSiteRepo |
never | consulted only when Capabilities.Sites is true |
UnknownVisibilityPath |
never | the fail-closed visibility check is skipped, and it is the only mechanical check on that rule |
IssueNumber |
Capabilities.Issues |
GetIssue has nothing to fetch |
MissingIssueNumber |
never | the ErrNotFound check on GetIssue is skipped |
IssueSearchText |
never | the search runs unfiltered |
EchoesCreatedBody |
never | the sanitisation comparisons are skipped |
Four of those are worth supplying even though nothing forces you to.
UnknownVisibilityPath names a repository your fixture reports without a
privacy field, so the harness can prove the provider decodes fail-closed rather
than letting an absent field become false and therefore public. Providers whose
SDK models privacy as a plain bool are exactly the ones that need it.
EchoesCreatedBody declares that your fixture returns the body it was sent
rather than a canned one. The sanitisation checks compare what came back against
what went in, so a canned body would make the redaction check pass without
redacting anything.
NewProviderWithLogger is the only way the harness can inject a logger, because
NewProvider hands back an already-built provider. Without it, the rule that a
provider never logs at ERROR for a condition it also returns goes unchecked.
NewProviderAt builds your provider against a URL the harness supplies,
rather than against your fixture:
It exists because three of the refusals cannot be provoked from a fixture.
Nothing makes a healthy forge rate-limit a test, so the harness stands up its
own server, answers with one status and set of headers, and checks what your
provider made of it. If your NewProvider already closes over a server URL,
supplying this is usually a two-line refactor.
Without it the refusal mapping is unverified rather than assumed satisfied, which is the whole point: an unsentinelled refusal is the defect this check exists for, and it is invisible until a caller needs to branch on it.
What the refusal checks will not decide for you
Which not-found sentinel a 404 carries is yours to choose. The harness
accepts either ErrNotFound or ErrReleaseNotFound, because what is missing
depends on what your provider addressed. What it will not accept is a 404
carrying neither.
A server fault is checked with a 501, not a 500. Every SDK in this family retries a 500 with backoff, and a fixture answering 500 to every request pays that in full (twelve seconds, in the case that prompted the change) before the check can look at the error. 501 makes the same point at no cost.
Capturing logs in your own tests¶
forgetest.CaptureLogger returns a logger and a function yielding the records it
collected. It wraps exactly as forge.NormaliseLogger does, so what you assert
on is what a consumer would see, redaction included.
Related¶
- Author a provider: the contract these checks enforce
- Test against a forge:
forgetest.Sourceand the published mocks - Errors: the sentinels the harness insists on