pkg/models/mappings.go
Rama main · réplica de lectura del repositorio original.
package models
import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"go.keploy.io/server/v3/pkg/provenswitch"
"gopkg.in/yaml.v3"
)
const MappingKind = "TestMocksMapping"
// MappingSourceKeployTest is the Mapping.Source of a mappings.yaml keploy test
// wrote: what a replay's passing tests consumed, not what each test recorded.
// Under the proven switch it does not say what a test owns.
const MappingSourceKeployTest = "keploy test"
// MockEntry represents a single mock entry with its name and kind.
type MockEntry struct {
Name string `json:"name" yaml:"name" bson:"name"`
Kind string `json:"kind" yaml:"kind" bson:"kind"`
Timestamp int64 `json:"timestamp,omitempty" yaml:"timestamp,omitempty" bson:"timestamp,omitempty"`
ReqTimestampMock string `json:"reqTimestampMock,omitempty" yaml:"reqTimestampMock,omitempty" bson:"req_timestamp_mock,omitempty"`
ResTimestampMock string `json:"resTimestampMock,omitempty" yaml:"resTimestampMock,omitempty" bson:"res_timestamp_mock,omitempty"`
Start string `json:"start,omitempty" yaml:"start,omitempty" bson:"start,omitempty"`
// Key is, for a start-up: or worker: entry keploy mock record wrote
// under the proven switch, the key of the claim active when it was
// recorded; for a worker: entry moved out of a test's list, the key the
// move names, else that test's, else the entry's own
// (MappingDb.MoveToWorker). Replay serves it only while a test process of
// that key holds the run, a worker: entry in a test's window too
// (Mapping.Worker). Empty means PoolKeyNone.
Key string `json:"key,omitempty" yaml:"key,omitempty" bson:"key,omitempty"`
// Place is, for a mock of the set's worker scope (Mapping.Worker), where
// in the set's run it becomes reachable: after the test case of this ID
// began; empty for the set's start. See Mapping.Worker.
Place string `json:"place,omitempty" yaml:"place,omitempty" bson:"place,omitempty"`
// AtStop says, for a mock of the worker scope, that keploy record
// recorded it once it had begun stopping the application: a call the
// application makes as it shuts down (a deregistration, a final flush).
// A replay requires it only once keploy has stopped the set's
// application, and never waits for it before.
AtStop bool `json:"atStop,omitempty" yaml:"atStop,omitempty" bson:"at_stop,omitempty"`
// Group is, under the proven switch, the concurrency group of a mock
// recorded while several tests' requests were open (Mapping.Groups):
// the test that lists it is the one whose request came first, which
// time alone cannot tell made it. keploy record's full round shares it
// between the group's tests and gives it to the one that made its call
// (dropping its group); every other replay serves it to the test that
// lists it. Empty for a mock recorded while one test's request was
// open, or none.
Group string `json:"group,omitempty" yaml:"group,omitempty" bson:"group,omitempty"`
// Unresolved says, under the proven switch, that keploy record's full
// round could not tell which test of its group made this mock's call,
// and charged the test that lists it now (it placed it there, not time):
// MockEntryUnresolvedAmbiguous, which every replay charges a test that
// lists it with, or MockEntryUnresolvedUnproven.
Unresolved string `json:"unresolved,omitempty" yaml:"unresolved,omitempty" bson:"unresolved,omitempty"`
// Reason says why keploy placed this entry where it is (a routing at
// record, a move by record's judging), in words with its undo; written
// under the proven switch only. Like every field of an
// entry, it is read and kept by each rewrite of mappings.yaml, whatever
// the switch.
Reason string `json:"reason,omitempty" yaml:"reason,omitempty" bson:"reason,omitempty"`
// Owner is, on a worker-scope entry record's judging moved out of a
// test's list (MappingDb.MoveToWorker, under the proven switch), the test
// it was moved from: the test whose window recorded it (the
// compatibility audit's B4). Empty on every other entry. Read and kept by
// each rewrite of mappings.yaml, whatever the switch.
Owner string `json:"owner,omitempty" yaml:"owner,omitempty" bson:"owner,omitempty"`
// Drift says, on a moved entry, that the move rests on its Owner's
// quarantine: the pairing that moved it drifted, which holds the
// recording against its Owner, so the move is kept only while the Owner
// does not run. Judging quarantines such an Owner as
// keploy/unseparable_write (keploy cannot tell the Owner's own write
// from the call), or for a failure of its own; a confirming round that
// does not confirm the move undoes it. record's judging sets Drift on the
// moves it passes MoveToWorker. A replay under the switch that runs the
// Owner (its quarantine no longer applies: its test was edited, or
// verdicts.yaml lost its entry) gives the entry back to the Owner's own
// list: today's rule, failing closed. An exact move (no Drift) keeps its
// Owner running beside it, as judging kept it. Read and kept by each
// rewrite, whatever the switch.
Drift bool `json:"drift,omitempty" yaml:"drift,omitempty" bson:"drift,omitempty"`
// Trails is, on a worker-scope entry record's judging moved out of a
// test's list (MappingDb.MoveToWorker, under the proven switch), the ID
// of the earlier test whose call the entry trails: the application
// made it for that test once it had answered it (a clean-up it runs in
// the background, such as deleting what the test made), so its request
// carries a value that test's own call sent. Judging writes it only on
// proof, and the entry's Reason says which proof held. Under the switch
// keploy mock replay tells the agent (models.ScopeTableReq.Trails), and
// the learn step reads the places that test's own list left the run as
// the call's own (learn.Trail): only that test's, every other test's
// stays outside the call's scope. Empty on every other entry, and an
// entry given back to its Owner drops it. Read and kept by each rewrite,
// whatever the switch.
Trails string `json:"trails,omitempty" yaml:"trails,omitempty" bson:"trails,omitempty"`
// NotRequired says, on an entry of a test's own list, that keploy's
// judging (keploy mock record's, or keploy mock judge's) proved the
// recording served, not required, and why, in keploy's words: the test's
// record made the call, round F (the judging's replay of the tests its
// command runs) did not make it while the test ran, and the test failed
// on nothing else. Under keploy mock record, round F runs the code that
// recorded the call, so whether the app makes it while the test runs
// depends on more than the code (how fast an answer comes, for one);
// keploy mock judge runs the code as it is when it judges. The words may
// also name a race note: the call of the same test (the first sent, when
// several were), made in the round too, that the recording shows was still
// unanswered when this one was sent. The note is information only: a call
// without one is marked all the same. Under the proven switch a replay
// serves the recording, when the app makes the call, by the same matching
// as the test's other recordings, and does not charge that test, and only
// that test, for not making it, unless the store refused the recording,
// which nothing could serve. keploy writes a mark only from that judging
// (a pull of a published recording gives back the mark its publish
// carried); a replay honours any mark an entry of the test's own list
// carries, one edited by hand too. Read and kept by each rewrite of
// mappings.yaml, whatever the switch.
NotRequired string `json:"notRequired,omitempty" yaml:"notRequired,omitempty" bson:"not_required,omitempty"`
}
// RecordedWithS12Routing is the Mapping.RecordedWith mark of a test set keploy
// record recorded under proven routing: only such a set
// is served by the looser background rules (no worker-scope check at the set's end, the
// pool inside tests, background writes answered by kind), and only by a build
// whose judging enables them (replay.RegisterS12Serving), under the proven
// switch. Every other set keeps today's rules (the compatibility audit's B3).
const RecordedWithS12Routing = "s12-routing"
// MappingDoc is mappings.yaml's document, Mapping, by the name the proven-routing
// contract gives it (MappingDoc.RecordedWith).
type MappingDoc = Mapping
// WasRecordedWith reports whether m's set carries feature among the marks
// keploy record wrote it with (Mapping.RecordedWith). A nil m carries none.
func (m *Mapping) WasRecordedWith(feature string) bool {
if m == nil {
return false
}
for _, f := range m.RecordedWith {
if f == feature {
return true
}
}
return false
}
// The MockEntry.Unresolved of a recording keploy record's full round could not
// give to the test of its group that made its call.
const (
// MockEntryUnresolvedAmbiguous: its recordings that keploy cannot tell
// apart got different answers, so a replay that answers a test with it
// may answer with another test's.
MockEntryUnresolvedAmbiguous = "ambiguous"
// MockEntryUnresolvedUnproven: every other case keploy could not prove
// (no call matched it exactly, two pair with it, retries differed, a
// call drifted from it): it stays with a test the round charged for it.
MockEntryUnresolvedUnproven = "unproven"
)
// ConcurrencyGroup is one set of tests whose requests were open together
// while a mock was recorded (Mapping.Groups): the windows that contained the
// mock's place, from the recorded request and answer times alone.
type ConcurrencyGroup struct {
ID string `json:"id" yaml:"id" bson:"id"`
// Tests are the group's tests the recording kept, in window order.
Tests []string `json:"tests" yaml:"tests" bson:"tests"`
// Unkept counts the group's tests the recording did not keep (deleted,
// or lost): one of them may have made the call.
Unkept int `json:"unkept,omitempty" yaml:"unkept,omitempty" bson:"unkept,omitempty"`
}
// RecordedAt is when the entry's request was recorded: its recorded time
// (ReqTimestampMock), else its timestamp in seconds.
func (e MockEntry) RecordedAt() time.Time {
if t, err := ParseMockTimestamp(e.ReqTimestampMock); err == nil && !t.IsZero() {
return t
}
return time.Unix(e.Timestamp, 0)
}
// FormatMockTimestamp stores mock timings as stable RFC3339 strings so
// mappings.yaml remains portable across YAML/JSON/BSON encoders while still
// omitting zero values.
func FormatMockTimestamp(ts time.Time) string {
if ts.IsZero() {
return ""
}
return ts.Format(time.RFC3339Nano)
}
// ParseMockTimestamp is the inverse of FormatMockTimestamp. It accepts both
// RFC3339Nano (the format written by FormatMockTimestamp) and plain RFC3339
// for compatibility with older fixtures. An empty input returns the zero
// time without error.
func ParseMockTimestamp(s string) (time.Time, error) {
if s == "" {
return time.Time{}, nil
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, nil
}
return time.Parse(time.RFC3339, s)
}
type Mapping struct {
Version string `json:"version" yaml:"version" bson:"version"`
Kind string `json:"kind" yaml:"kind" bson:"kind"`
TestSetID string `json:"testSetId" yaml:"test_set_id" bson:"test_set_id"`
TestCases []MappedTestCase `json:"tests" yaml:"tests" bson:"tests"`
// Startup lists mocks that belong to the TEST SET rather than to any one test
// case: traffic the app produced while booting, before the first test request
// fired — driver handshakes, connection-pool warm-up, config/secret fetches.
//
// Until now mappings.yaml had no slot for these. Its only container was
// TestCases, and a mock consumed during app boot is never attributed to a test
// (replay drains it in the pre-loop "initial setup" pass), so it simply went
// unrecorded. That is fine for a local replay, where the tier is rederived from
// timestamps at load time, but not for the CLOUD path, which fetches mocks BY
// NAME from this file: a startup mock absent here is never fetched, and the app
// cannot boot.
//
// Deliberately a flat list, not keyed by test: these are available for the whole
// session and are NOT consumed by one test. Consumers must treat membership here
// as "always needed", exempt from any per-test selection — see the
// StartupMockNames note in pkg/service/replay.
//
// omitempty so an existing mappings.yaml round-trips byte-identically until a
// startup mock is actually recorded; every older reader ignores the key.
Startup []MockEntry `json:"startup,omitempty" yaml:"startup,omitempty" bson:"startup,omitempty"`
Suites []SuiteSpan `json:"suites,omitempty" yaml:"suites,omitempty" bson:"suites,omitempty"`
Boots []BootSpec `json:"boots,omitempty" yaml:"boots,omitempty" bson:"boots,omitempty"`
Runner []MockEntry `json:"runner,omitempty" yaml:"runner,omitempty" bson:"runner,omitempty"`
// Worker is the set's worker scope (design v14, the founder's D1 to
// D4): the data-plane mocks no test case owns, which the application made
// on its own rather than for a test's request (a queue consumer, a timer,
// work a test left running), in recorded order, each with its Place.
//
// Under the proven switch a worker mock answers whether or not a test is
// open, from its place on (never before it: what it answers may be what a
// later test caused), each at most one request, as the test mocks do. No
// test is charged with it: the set is. At the set's end every worker mock
// whose place its run reached must have answered, unless its parser marks
// its answer idle (an empty poll). A request no mock answers is a miss, as
// anywhere.
//
// keploy record writes it under the switch: a test mock recorded while no
// test's window was open (the gap's traffic, the app's start, what came
// after the last test) is the scope's, at the place its recording holds
// it (DeriveWindowMappings). Omitted when empty; a reader that predates it
// ignores the key.
//
// keploy mock (record and replay) keeps it plainer: record lists here
// every call made outside every test's window after the first test began,
// with its claim's key and no place, and a reader may move a test's call
// here. Replay serves each from the set's start, in a test's window or
// between tests, at most once, and requires none of them
// (ScopeTableReq.Worker).
Worker []MockEntry `json:"worker,omitempty" yaml:"worker,omitempty" bson:"worker,omitempty"`
// Source is the keploy command that wrote the file's test entries, when
// it is not keploy record: MappingSourceKeployTest. A writer that names
// none keeps the file's, keploy record's writes included. Empty in a file
// only keploy record wrote, in one keploy test wrote before it said so,
// and in one a cloud store handed back (which keeps no source).
Source string `json:"source,omitempty" yaml:"source,omitempty" bson:"source,omitempty"`
// Groups are, under the proven switch, the concurrency groups the
// entries name (MockEntry.Group): the sets of tests whose requests were
// open together while a mock was recorded. Omitted for a set recorded
// one request at a time; a reader that predates it ignores the key, and
// serves every entry to the test that lists it.
Groups []ConcurrencyGroup `json:"groups,omitempty" yaml:"groups,omitempty" bson:"groups,omitempty"`
// Shared are, under the proven switch, the mocks recorded while several
// tests' requests were open whose first-opened test the recording did
// not keep (deleted, or lost): no kept test lists them, and keploy
// record's full round shares them between the kept tests of their group
// (MockEntry.Group). Every other replay leaves them unowned, as it does
// a mock no test lists.
Shared []MockEntry `json:"shared,omitempty" yaml:"shared,omitempty" bson:"shared,omitempty"`
// RecordedWith names, set-wide, what keploy record recorded the set with
// that changes how it is served: RecordedWithS12Routing for a set a
// proven-routing record wrote (routed at record, its background served by the background rules).
// keploy record writes it under the proven switch. Read and kept by each
// rewrite of mappings.yaml, whatever the switch, and by a writer that
// names none; omitted when empty, so a file without it is written as
// before. Only its use is under the switch.
RecordedWith []string `json:"recordedWith,omitempty" yaml:"recordedWith,omitempty" bson:"recorded_with,omitempty"`
}
// Group is the concurrency group id names, nil when there is none.
func (m *Mapping) Group(id string) *ConcurrencyGroup {
if m == nil || id == "" {
return nil
}
for i := range m.Groups {
if m.Groups[i].ID == id {
return &m.Groups[i]
}
}
return nil
}
type BootSpec struct {
Dir string `json:"dir" yaml:"dir" bson:"dir"`
Key string `json:"key" yaml:"key" bson:"key"`
Program string `json:"program" yaml:"program" bson:"program"`
Place string `json:"place" yaml:"place" bson:"place"`
N int `json:"n" yaml:"n" bson:"n"`
Ready bool `json:"ready" yaml:"ready" bson:"ready"`
Mocks []MockEntry `json:"mocks" yaml:"mocks" bson:"mocks"`
}
// MockPlaces says where in the mapping each mock name is named, as a reader of
// mappings.yaml finds it: "test <id>" for a test's own list, and "startup:",
// "runner:", "worker:", "shared:" or "boots:" for the set's sections. A name
// named in several places has each once: its tests in the file's order,
// then the sections in that order.
func (m *Mapping) MockPlaces() map[string][]string {
out := map[string][]string{}
if m == nil {
return out
}
add := func(name, place string) {
if name != "" && !slices.Contains(out[name], place) {
out[name] = append(out[name], place)
}
}
for _, tc := range m.TestCases {
for _, e := range tc.Mocks {
add(e.Name, "test "+tc.ID)
}
}
for _, s := range []struct {
place string
entries []MockEntry
}{{"startup:", m.Startup}, {"runner:", m.Runner}, {"worker:", m.Worker}, {"shared:", m.Shared}} {
for _, e := range s.entries {
add(e.Name, s.place)
}
}
for _, b := range m.Boots {
for _, e := range b.Mocks {
add(e.Name, "boots:")
}
}
return out
}
// StartupMockNames returns the names of the test-set-scoped startup mocks. Callers
// building a "which mocks does this run need?" set MUST include these regardless of
// which test cases were selected: unlike per-test entries they are not tied to a
// test, so a subset run (--test-sets / --tests) that filtered them out would leave
// the app unable to boot. Returns nil when the mapping has no startup section, so
// callers need no version check.
func (m *Mapping) StartupMockNames() []string {
if m == nil || len(m.Startup) == 0 {
return nil
}
names := make([]string, 0, len(m.Startup))
for _, e := range m.Startup {
if e.Name != "" {
names = append(names, e.Name)
}
}
return names
}
// BackgroundMockNames returns the names of the set's background recordings,
// each once: its worker: mocks, then its startup: mocks, the calls the
// application made on its own rather than for a test's request
// (MockFilterParams.BackgroundMocks). nil when it has neither.
func (m *Mapping) BackgroundMockNames() []string {
if m == nil {
return nil
}
var out []string
seen := map[string]bool{}
for _, list := range [][]MockEntry{m.Worker, m.Startup} {
for _, e := range list {
if e.Name != "" && !seen[e.Name] {
seen[e.Name] = true
out = append(out, e.Name)
}
}
}
return out
}
type MappedTestCase struct {
ID string `json:"id" yaml:"id" bson:"id"`
// Mocks is written and read as mock_entries (MarshalYAML, MarshalJSON);
// mocks is the key of the older shapes only, and under the proven switch
// an entry with both is refused (errBothMockLists).
Mocks []MockEntry `json:"mocks" yaml:"mocks" bson:"mocks"`
// Cases names the test cases this test produced when its record captured incoming requests.
Cases []string `json:"cases,omitempty" yaml:"cases,omitempty" bson:"cases,omitempty"`
CaseMocks map[string][]string `json:"case_mocks,omitempty" yaml:"case_mocks,omitempty" bson:"case_mocks,omitempty"`
CaseSteps map[string]string `json:"case_steps,omitempty" yaml:"case_steps,omitempty" bson:"case_steps,omitempty"`
Dir string `json:"dir,omitempty" yaml:"dir,omitempty" bson:"dir,omitempty"`
Starts int `json:"starts,omitempty" yaml:"starts,omitempty" bson:"starts,omitempty"`
// At is when the test began in its recording: the clock its replay runs
// on while it is open (design v14 §3.4). keploy mock record writes it
// under the proven switch only, and a replay acts on it only under the
// switch; zero in recordings made before keploy kept it, and in those
// made without the switch. Read and kept by each rewrite of
// mappings.yaml, whatever the switch, as Key.
At time.Time `json:"at,omitzero" yaml:"at,omitempty" bson:"at,omitempty"`
// Real is the mock.real destinations the test used while it was open in
// its recording (design v14 §4): its replay runs on the real clock, and
// the verdict fails when CI never reaches one. Written, and acted on by
// a replay, under the proven switch only; empty in recordings made
// without it. Read and kept by each rewrite, whatever the switch, as Key.
Real []string `json:"real,omitempty" yaml:"real,omitempty" bson:"real,omitempty"`
// Given is the values the test gave while it was open in its recording
// (POST /agent/scope/given), in the order it gave them: each replay
// binds the value the test gives then with the recorded one (design
// given-api §3). Written, and acted on by a replay, under the proven
// switch only; empty in recordings made without it. Read and kept by
// each rewrite, whatever the switch, as Key.
Given []GivenValue `json:"given,omitempty" yaml:"given,omitempty" bson:"given,omitempty"`
// Key is the key of the claim the test ran under in its recording: the
// test process's (POST /agent/instance). A reader who moves one of the
// test's calls to the set's worker: list gives the moved entry this key,
// so a replay serves it while that process holds the run (MockEntry.Key).
// keploy mock record writes it under the proven switch only; empty in
// recordings made before keploy kept it, in those made without the
// switch, and for a test run under no claim. Read and kept by each
// rewrite of mappings.yaml, whatever the switch: mapdb keeps every
// field keploy writes on a test entry whatever the switch, and what
// acts on it checks the switch itself. A rewrite without the switch
// that dropped the key would leave a later move of the test's call no
// key to give.
Key string `json:"key,omitempty" yaml:"key,omitempty" bson:"key,omitempty"`
// Seed is the seed the test's browser drew its random numbers from in
// its recording (models.ScopeWindow.Seed): each replay's begin is
// answered it, so the replay's pages draw what the recording's drew. It
// is the entry's, not derived from the test's name: an entry renamed
// keeps it, and a record of the test draws a new one. Written, and
// answered by a replay, under the proven switch only; empty in
// recordings made before keploy kept it, in those made with seeding
// off, and for a test whose harness drives no browser. Read and kept by
// each rewrite, whatever the switch, as Key.
Seed string `json:"seed,omitempty" yaml:"seed,omitempty" bson:"seed,omitempty"`
}
type persistedTestFormat struct {
ID string `json:"id" yaml:"id"`
MockEntries []MockEntry `json:"mock_entries,omitempty" yaml:"mock_entries,omitempty"`
Cases []string `json:"cases,omitempty" yaml:"cases,omitempty"`
CaseMocks map[string][]string `json:"case_mocks,omitempty" yaml:"case_mocks,omitempty"`
CaseSteps map[string]string `json:"case_steps,omitempty" yaml:"case_steps,omitempty"`
Dir string `json:"dir,omitempty" yaml:"dir,omitempty"`
Starts int `json:"starts,omitempty" yaml:"starts,omitempty"`
}
// writtenTestFormat is how a test entry is written: the persisted format and
// the test's recorded begin, last and omitted when zero, so an entry without
// one is written as it was before keploy kept it. The begin stays out of
// persistedTestFormat, which also picks the shape an entry is read in.
type writtenTestFormat struct {
persistedTestFormat `yaml:",inline"`
At time.Time `json:"at,omitzero" yaml:"at,omitempty"`
Real []string `json:"real,omitempty" yaml:"real,omitempty"`
Given []GivenValue `json:"given,omitempty" yaml:"given,omitempty"`
Key string `json:"key,omitempty" yaml:"key,omitempty"`
Seed string `json:"seed,omitempty" yaml:"seed,omitempty"`
}
type structuredTestFormat struct {
ID string `json:"id" yaml:"id"`
Mocks []MockEntry `json:"mocks" yaml:"mocks"`
Cases []string `json:"cases,omitempty" yaml:"cases,omitempty"`
}
type stringSliceTestFormat struct {
ID string `json:"id" yaml:"id"`
Mocks []string `json:"mocks" yaml:"mocks"`
}
func (tc MappedTestCase) MarshalYAML() (interface{}, error) {
return writtenTestFormat{persistedTestFormat: tc.persistedFormat(), At: tc.At, Real: tc.Real, Given: tc.Given, Key: tc.Key, Seed: tc.Seed}, nil
}
func (tc MappedTestCase) MarshalJSON() ([]byte, error) {
return json.Marshal(writtenTestFormat{persistedTestFormat: tc.persistedFormat(), At: tc.At, Real: tc.Real, Given: tc.Given, Key: tc.Key, Seed: tc.Seed})
}
// errRecordedBegin is a test entry whose at: is not a time.
const errRecordedBegin = "a test's recorded begin (at) is not a time: %w"
// recordedBeginYAML is a test entry's at:, whatever the proven switch: a
// writer that rewrites the file with the switch off keeps it, as it keeps the
// entry's mocks (MappedTestCase.At). One that is not a time is an error, never
// a reason to read the entry in another shape and lose its mocks.
func recordedBeginYAML(node *yaml.Node) (time.Time, error) {
if !nodeHasField(node, "at") {
return time.Time{}, nil
}
var at struct {
At time.Time `yaml:"at"`
}
if err := node.Decode(&at); err != nil {
return time.Time{}, fmt.Errorf(errRecordedBegin, err)
}
return at.At, nil
}
// errRecordedReal is a test entry whose real: is not a list of destinations.
const errRecordedReal = "a test's mock.real destinations (real) are not a list of strings: %w"
// recordedRealYAML is a test entry's real:, whatever the proven switch, as
// recordedBeginYAML. One that is not a list of strings is an error, never a
// reason to read the entry without it: its replay would run frozen and its
// verdict would not ask for the destinations.
func recordedRealYAML(node *yaml.Node) ([]string, error) {
if !nodeHasField(node, "real") {
return nil, nil
}
var r struct {
Real []string `yaml:"real"`
}
if err := node.Decode(&r); err != nil {
return nil, fmt.Errorf(errRecordedReal, err)
}
return r.Real, nil
}
// recordedRealJSON is recordedRealYAML for a JSON entry.
func recordedRealJSON(data []byte) ([]string, error) {
if !jsonContainsField(data, "real") {
return nil, nil
}
var r struct {
Real []string `json:"real"`
}
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf(errRecordedReal, err)
}
return r.Real, nil
}
// errRecordedGiven is a test entry whose given: is not a list of givens.
const errRecordedGiven = "a test's given values (given) are not a list of name, value and source: %w"
// recordedGivenYAML is a test entry's given:, whatever the proven switch, as
// recordedBeginYAML. One that is not a list of givens is an error, never a
// reason to read the entry without it: its replay would bind each given with
// no recorded value, and check none.
func recordedGivenYAML(node *yaml.Node) ([]GivenValue, error) {
if !nodeHasField(node, "given") {
return nil, nil
}
var g struct {
Given []GivenValue `yaml:"given"`
}
if err := node.Decode(&g); err != nil {
return nil, fmt.Errorf(errRecordedGiven, err)
}
return g.Given, nil
}
// recordedGivenJSON is recordedGivenYAML for a JSON entry.
func recordedGivenJSON(data []byte) ([]GivenValue, error) {
if !jsonContainsField(data, "given") {
return nil, nil
}
var g struct {
Given []GivenValue `json:"given"`
}
if err := json.Unmarshal(data, &g); err != nil {
return nil, fmt.Errorf(errRecordedGiven, err)
}
return g.Given, nil
}
// errRecordedKey is a test entry whose key: is not a string.
const errRecordedKey = "a test's claim key (key) is not a string: %w"
// recordedKeyYAML is a test entry's key:, whatever the proven switch: a
// writer that rewrites the file with the switch off keeps it, as it keeps
// the entry's mocks. One that is not a string is an error, never a reason to
// read the entry without it: an edit that moves the test's call to worker:
// would lose the key its replay serves it under.
func recordedKeyYAML(node *yaml.Node) (string, error) {
if !nodeHasField(node, "key") {
return "", nil
}
var k struct {
Key string `yaml:"key"`
}
if err := node.Decode(&k); err != nil {
return "", fmt.Errorf(errRecordedKey, err)
}
return k.Key, nil
}
// recordedKeyJSON is recordedKeyYAML for a JSON entry.
func recordedKeyJSON(data []byte) (string, error) {
if !jsonContainsField(data, "key") {
return "", nil
}
var k struct {
Key string `json:"key"`
}
if err := json.Unmarshal(data, &k); err != nil {
return "", fmt.Errorf(errRecordedKey, err)
}
return k.Key, nil
}
// errRecordedSeed is a test entry whose seed: is not a string.
const errRecordedSeed = "a test's browser seed (seed) is not a string: %w"
// recordedSeedYAML is a test entry's seed:, whatever the proven switch, as
// recordedBeginYAML. One that is not a string is an error, never a reason to
// read the entry without it: its replay would draw other numbers than its
// recording.
func recordedSeedYAML(node *yaml.Node) (string, error) {
if !nodeHasField(node, "seed") {
return "", nil
}
var k struct {
Seed string `yaml:"seed"`
}
if err := node.Decode(&k); err != nil {
return "", fmt.Errorf(errRecordedSeed, err)
}
return k.Seed, nil
}
// recordedSeedJSON is recordedSeedYAML for a JSON entry.
func recordedSeedJSON(data []byte) (string, error) {
if !jsonContainsField(data, "seed") {
return "", nil
}
var k struct {
Seed string `json:"seed"`
}
if err := json.Unmarshal(data, &k); err != nil {
return "", fmt.Errorf(errRecordedSeed, err)
}
return k.Seed, nil
}
// recordedBeginJSON is recordedBeginYAML for a JSON entry.
func recordedBeginJSON(data []byte) (time.Time, error) {
if !jsonContainsField(data, "at") {
return time.Time{}, nil
}
var at struct {
At time.Time `json:"at"`
}
if err := json.Unmarshal(data, &at); err != nil {
return time.Time{}, fmt.Errorf(errRecordedBegin, err)
}
return at.At, nil
}
// UnmarshalYAML provides backward compatibility for the old comma-separated
// string format while supporting the structured formats used by newer builds.
//
// Old format:
//
// tests:
// - id: test-1
// mocks: "mock-0,mock-1,mock-2"
//
// Structured format:
//
// tests:
// - id: test-1
// mocks:
// - name: mock-0
// kind: HTTP
// - name: mock-1
// kind: Redis
func (tc *MappedTestCase) UnmarshalYAML(node *yaml.Node) error {
at, err := recordedBeginYAML(node)
if err != nil {
return err
}
rl, err := recordedRealYAML(node)
if err != nil {
return err
}
gv, err := recordedGivenYAML(node)
if err != nil {
return err
}
key, err := recordedKeyYAML(node)
if err != nil {
return err
}
seed, err := recordedSeedYAML(node)
if err != nil {
return err
}
if err := tc.unmarshalYAMLEntry(node); err != nil {
return err
}
tc.At, tc.Real, tc.Given, tc.Key, tc.Seed = at, rl, gv, key, seed
return nil
}
// errBothMockLists is a test entry with a mock_entries list and a mocks list
// beside it, under the proven switch: keploy reads mock_entries only, so the
// mocks list (a hand edit, or an undo followed to the letter) would be dropped
// without a word, and the next rewrite of the file would erase it. With the
// switch off the entry reads as it did before: mock_entries alone.
const errBothMockLists = "test %q in mappings has both `mocks` and `mock_entries`, and keploy reads only `mock_entries`: move the entries under `mocks` into `mock_entries`, then delete `mocks`"
// BothMockListsError is the refusal of a test entry that holds both mock
// lists (errBothMockLists), with the entry's id (Test): a reader that checks
// a set (enterprise's keploy mock check) names the entry from it, as a
// replay refuses the set with it.
type BothMockListsError struct{ Test string }
func (e *BothMockListsError) Error() string { return fmt.Sprintf(errBothMockLists, e.Test) }
// unmarshalYAMLEntry reads a test entry's id, mocks and the persisted
// format's other fields, in whichever shape the entry has.
func (tc *MappedTestCase) unmarshalYAMLEntry(node *yaml.Node) error {
if provenswitch.On() && mergedField(node, "mock_entries") != nil && nodeHasEntries(mergedField(node, "mocks"), "mocks") {
var e struct {
ID string `yaml:"id"`
}
_ = node.Decode(&e)
return &BothMockListsError{Test: e.ID}
}
// Prefer the backward-compatible persisted format first.
var persisted persistedTestFormat
perr := node.Decode(&persisted)
// A field a merge key (<<) brings counts as the entry's own, as the
// decode above reads it and as the both-lists check sees it.
has := func(field string) bool { return mergedField(node, field) != nil }
if perr == nil && (has("mock_entries") || ((has("dir") || has("starts") || has("cases")) && !has("mocks"))) {
tc.applyDecodedMocks(persisted.ID, persisted.MockEntries, "", persisted.Cases)
tc.extras(persisted)
return nil
}
if perr != nil {
persisted = persistedTestFormat{}
}
type legacyTestFormat struct {
ID string `yaml:"id"`
Mocks string `yaml:"mocks"`
Cases []string `yaml:"cases"`
}
var legacy legacyTestFormat
if err := node.Decode(&legacy); err == nil {
tc.applyDecodedMocks(legacy.ID, nil, legacy.Mocks, legacy.Cases)
tc.extras(persisted)
return nil
}
var structured structuredTestFormat
if err := node.Decode(&structured); err == nil {
tc.applyDecodedMocks(structured.ID, structured.Mocks, "", structured.Cases)
tc.extras(persisted)
return nil
}
// Accept a string slice too so intermediate/custom fixtures remain readable.
var stringSlice stringSliceTestFormat
if err := node.Decode(&stringSlice); err == nil {
tc.applyDecodedMocks(stringSlice.ID, mockEntriesFromNames(stringSlice.Mocks), "", nil)
return nil
}
return fmt.Errorf("failed to unmarshal mapped test case from YAML")
}
// UnmarshalJSON provides backward compatibility for the old comma-separated
// string format while supporting the structured formats used by newer builds.
func (tc *MappedTestCase) UnmarshalJSON(data []byte) error {
at, err := recordedBeginJSON(data)
if err != nil {
return err
}
rl, err := recordedRealJSON(data)
if err != nil {
return err
}
gv, err := recordedGivenJSON(data)
if err != nil {
return err
}
key, err := recordedKeyJSON(data)
if err != nil {
return err
}
seed, err := recordedSeedJSON(data)
if err != nil {
return err
}
if err := tc.unmarshalJSONEntry(data); err != nil {
return err
}
tc.At, tc.Real, tc.Given, tc.Key, tc.Seed = at, rl, gv, key, seed
return nil
}
// unmarshalJSONEntry is unmarshalYAMLEntry for a JSON entry.
func (tc *MappedTestCase) unmarshalJSONEntry(data []byte) error {
if provenswitch.On() && jsonContainsField(data, "mock_entries") && jsonHasEntries(data, "mocks") {
var e struct {
ID string `json:"id"`
}
_ = json.Unmarshal(data, &e)
return &BothMockListsError{Test: e.ID}
}
var persisted persistedTestFormat
perr := json.Unmarshal(data, &persisted)
if perr == nil && (jsonContainsField(data, "mock_entries") || ((jsonContainsField(data, "dir") || jsonContainsField(data, "starts") || jsonContainsField(data, "cases")) && !jsonContainsField(data, "mocks"))) {
tc.applyDecodedMocks(persisted.ID, persisted.MockEntries, "", persisted.Cases)
tc.extras(persisted)
return nil
}
if perr != nil {
persisted = persistedTestFormat{}
}
type legacyTestFormat struct {
ID string `json:"id"`
Mocks string `json:"mocks"`
Cases []string `json:"cases"`
}
var legacy legacyTestFormat
if err := json.Unmarshal(data, &legacy); err == nil {
tc.applyDecodedMocks(legacy.ID, nil, legacy.Mocks, legacy.Cases)
tc.extras(persisted)
return nil
}
var structured structuredTestFormat
if err := json.Unmarshal(data, &structured); err == nil {
tc.applyDecodedMocks(structured.ID, structured.Mocks, "", structured.Cases)
tc.extras(persisted)
return nil
}
var stringSlice stringSliceTestFormat
if err := json.Unmarshal(data, &stringSlice); err == nil {
tc.applyDecodedMocks(stringSlice.ID, mockEntriesFromNames(stringSlice.Mocks), "", nil)
return nil
}
return fmt.Errorf("failed to unmarshal mapped test case from JSON")
}
// MockNames returns the names of all mocks as a string slice (for backward compatibility).
func (tc *MappedTestCase) MockNames() []string {
names := make([]string, len(tc.Mocks))
for i, m := range tc.Mocks {
names[i] = m.Name
}
return names
}
func (tc MappedTestCase) persistedFormat() persistedTestFormat {
return persistedTestFormat{
ID: tc.ID,
MockEntries: append([]MockEntry(nil), tc.Mocks...),
Cases: append([]string(nil), tc.Cases...),
CaseMocks: tc.CaseMocks,
CaseSteps: tc.CaseSteps,
Dir: tc.Dir,
Starts: tc.Starts,
}
}
func (tc *MappedTestCase) extras(p persistedTestFormat) {
tc.CaseMocks, tc.CaseSteps, tc.Dir, tc.Starts = p.CaseMocks, p.CaseSteps, p.Dir, p.Starts
}
func (tc *MappedTestCase) applyDecodedMocks(id string, mockEntries []MockEntry, legacyMocks string, cases []string) {
tc.ID = id
tc.Cases = append([]string(nil), cases...)
tc.CaseMocks, tc.CaseSteps, tc.Dir, tc.Starts = nil, nil, "", 0
tc.Mocks = nil
if len(mockEntries) > 0 {
tc.Mocks = append([]MockEntry(nil), mockEntries...)
return
}
tc.Mocks = parseLegacyMocks(legacyMocks)
}
func parseLegacyMocks(mocks string) []MockEntry {
if mocks == "" {
return nil
}
var entries []MockEntry
for _, name := range strings.Split(mocks, ",") {
name = strings.TrimSpace(name)
if name != "" {
entries = append(entries, MockEntry{Name: name})
}
}
return entries
}
func mockEntriesFromNames(names []string) []MockEntry {
if len(names) == 0 {
return nil
}
entries := make([]MockEntry, 0, len(names))
for _, name := range names {
name = strings.TrimSpace(name)
if name != "" {
entries = append(entries, MockEntry{Name: name})
}
}
return entries
}
func nodeHasField(node *yaml.Node, field string) bool {
if node == nil || node.Kind != yaml.MappingNode {
return false
}
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i].Value == field {
return true
}
}
return false
}
// nodeHasEntries reports whether the mapping node holds field with at least
// one entry: a list with an item, or the earlier format's comma-separated
// string of names with one. An empty or null value loses nothing when it is
// not read.
func nodeHasEntries(node *yaml.Node, field string) bool {
if node == nil || node.Kind != yaml.MappingNode {
return false
}
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i].Value == field {
v := node.Content[i+1]
for v.Kind == yaml.AliasNode && v.Alias != nil {
v = v.Alias
}
switch v.Kind {
case yaml.SequenceNode, yaml.MappingNode:
return len(v.Content) > 0
case yaml.ScalarNode:
return v.Tag != "!!null" && strings.Trim(v.Value, " ,") != ""
}
return true
}
}
return false
}
// mergedField is the mapping that gives node field: node itself, else the
// first mapping its merge keys (<<) name that has it, as YAML resolves them;
// nil when none does.
func mergedField(node *yaml.Node, field string) *yaml.Node {
if nodeHasField(node, field) {
return node
}
if node == nil || node.Kind != yaml.MappingNode {
return nil
}
var merged []*yaml.Node
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i].Value == "<<" {
merged = append(merged, node.Content[i+1])
}
}
for _, m := range MergedMappings(merged) {
if nodeHasField(m, field) {
return m
}
}
return nil
}
// jsonHasEntries is nodeHasEntries for a JSON object.
func jsonHasEntries(data []byte, field string) bool {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return false
}
var list []json.RawMessage
if json.Unmarshal(raw[field], &list) == nil {
return len(list) > 0
}
var names string
if json.Unmarshal(raw[field], &names) == nil {
return strings.Trim(names, " ,") != ""
}
var object map[string]json.RawMessage
if json.Unmarshal(raw[field], &object) == nil {
return len(object) > 0
}
return len(raw[field]) > 0 && string(raw[field]) != "null"
}
func jsonContainsField(data []byte, field string) bool {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return false
}
_, ok := raw[field]
return ok
}
// MergeStartupMockNames returns the per-test mock names followed by any startup
// names not already present.
//
// Lives here rather than in a replay package because BOTH replay paths need it:
// pkg/service/replay (the `keploy test` flow) and pkg/service/runner (the flow
// enterprise's sandbox drives). Duplicating it once already meant the startup
// section reached one path and not the other.
//
// Order is per-test first, then startup, each in its original order — stable
// across runs so the agent sees a deterministic list. Deduped because a session-
// or connection-tier mock legitimately appears in both (consumed at boot AND
// kept in the per-test list by the recorder's always-keep carve-out).
//
// Fast path when there is no startup section — the overwhelmingly common case —
// skips the dedupe map entirely, since this runs once per test case.
func MergeStartupMockNames(perTest []MockEntry, startup []string) []string {
// A test with NO per-test entry must return an empty list, even when a
// startup section exists.
//
// The agent engages mapping mode on `UseMappingBased && len(MockMapping) > 0`
// (service/agent/agent.go). An empty list therefore falls through to the
// strict-window / lax-all loaders, i.e. the whole recorded pool time-filtered
// — the graceful degradation such a test has always relied on. Returning
// startup names here instead would flip it into mapping mode with ONLY those
// names, shrinking its pool to the boot mocks and failing it with no_mocks.
//
// This is reachable: a set can have hasMeaningfulMappings == true because one
// test has mocks while another's entry is empty (the recorder dropping a
// batch — see mapdb/db.go). Turning that test's graceful degradation into a
// hard failure is a strictly worse trade than leaving it on the old path,
// where boot mocks are loaded by LoadBefore anyway.
if len(perTest) == 0 {
return nil
}
if len(startup) == 0 {
out := make([]string, 0, len(perTest))
for _, m := range perTest {
if m.Name != "" {
out = append(out, m.Name)
}
}
return out
}
out := make([]string, 0, len(perTest)+len(startup))
seen := make(map[string]struct{}, len(perTest)+len(startup))
add := func(name string) {
if name == "" {
return
}
if _, dup := seen[name]; dup {
return
}
seen[name] = struct{}{}
out = append(out, name)
}
for _, m := range perTest {
add(m.Name)
}
for _, n := range startup {
add(n)
}
return out
}
type SuiteSpan struct {
Dir string `json:"dir" yaml:"dir" bson:"dir"`
Start time.Time `json:"start" yaml:"start" bson:"start"`
End time.Time `json:"end" yaml:"end" bson:"end"`
}