refactor(engine): allow scripts to register experiments (#860)

See https://github.com/ooni/probe/issues/2216
This commit is contained in:
Simone Basso 2022-08-17 10:57:03 +02:00 committed by GitHub
parent 69602abe8a
commit 6a0ae5c70b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1438 additions and 1200 deletions

View File

@ -16,7 +16,7 @@ func (n DNSCheck) lookupURLs(ctl *Controller) ([]string, error) {
// not needed because we have default static input in the engine
},
ExperimentName: "dnscheck",
InputPolicy: engine.InputOrStaticDefault,
InputPolicy: model.InputOrStaticDefault,
Session: ctl.Session,
SourceFiles: ctl.InputFiles,
StaticInputs: ctl.Inputs,

View File

@ -123,7 +123,7 @@ func (c *Controller) SetNettestIndex(i, n int) {
//
// This function will continue to run in most cases but will
// immediately halt if something's wrong with the file system.
func (c *Controller) Run(builder engine.ExperimentBuilder, inputs []string) error {
func (c *Controller) Run(builder model.ExperimentBuilder, inputs []string) error {
// This will configure the controller as handler for the callbacks
// called by ooni/probe-engine/experiment.Experiment.
builder.SetCallbacks(model.ExperimentCallbacks(c))

View File

@ -16,7 +16,7 @@ func (n STUNReachability) lookupURLs(ctl *Controller) ([]string, error) {
// not needed because we have default static input in the engine
},
ExperimentName: "stunreachability",
InputPolicy: engine.InputOrStaticDefault,
InputPolicy: model.InputOrStaticDefault,
Session: ctl.Session,
SourceFiles: ctl.InputFiles,
StaticInputs: ctl.Inputs,

View File

@ -22,7 +22,7 @@ func (n WebConnectivity) lookupURLs(ctl *Controller, categories []string) ([]str
},
},
ExperimentName: "web_connectivity",
InputPolicy: engine.InputOrQueryBackend,
InputPolicy: model.InputOrQueryBackend,
Session: ctl.Session,
SourceFiles: ctl.InputFiles,
StaticInputs: ctl.Inputs,

View File

@ -1,378 +1,17 @@
package engine
//
// List of all implemented experiments
// List of all implemented experiments.
//
// Note: if you're looking for a way to register a new experiment, we
// now use the internal/registry package for this purpose.
//
// (This comment will eventually autodestruct.)
//
import (
"time"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/dash"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/dnscheck"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/dnsping"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/example"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/fbmessenger"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/hhfm"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/hirl"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/httphostheader"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/ndt7"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/psiphon"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/quicping"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/riseupvpn"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/run"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/signal"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/simplequicping"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/sniblocking"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/stunreachability"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/tcpping"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/telegram"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/tlsping"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/tlstool"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/tor"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/torsf"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/vanillator"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/whatsapp"
)
var experimentsByName = map[string]func(*Session) *experimentBuilder{
"dash": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, dash.NewExperimentMeasurer(
*config.(*dash.Config),
))
},
config: &dash.Config{},
interruptible: true,
inputPolicy: InputNone,
}
},
"dnscheck": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, dnscheck.NewExperimentMeasurer(
*config.(*dnscheck.Config),
))
},
config: &dnscheck.Config{},
inputPolicy: InputOrStaticDefault,
}
},
"dnsping": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, dnsping.NewExperimentMeasurer(
*config.(*dnsping.Config),
))
},
config: &dnsping.Config{},
inputPolicy: InputOrStaticDefault,
}
},
"example": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, example.NewExperimentMeasurer(
*config.(*example.Config), "example",
))
},
config: &example.Config{
Message: "Good day from the example experiment!",
SleepTime: int64(time.Second),
},
interruptible: true,
inputPolicy: InputNone,
}
},
"facebook_messenger": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, fbmessenger.NewExperimentMeasurer(
*config.(*fbmessenger.Config),
))
},
config: &fbmessenger.Config{},
inputPolicy: InputNone,
}
},
"http_header_field_manipulation": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, hhfm.NewExperimentMeasurer(
*config.(*hhfm.Config),
))
},
config: &hhfm.Config{},
inputPolicy: InputNone,
}
},
"http_host_header": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, httphostheader.NewExperimentMeasurer(
*config.(*httphostheader.Config),
))
},
config: &httphostheader.Config{},
inputPolicy: InputOrQueryBackend,
}
},
"http_invalid_request_line": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, hirl.NewExperimentMeasurer(
*config.(*hirl.Config),
))
},
config: &hirl.Config{},
inputPolicy: InputNone,
}
},
"ndt": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, ndt7.NewExperimentMeasurer(
*config.(*ndt7.Config),
))
},
config: &ndt7.Config{},
interruptible: true,
inputPolicy: InputNone,
}
},
"psiphon": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, psiphon.NewExperimentMeasurer(
*config.(*psiphon.Config),
))
},
config: &psiphon.Config{},
inputPolicy: InputOptional,
}
},
"quicping": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, quicping.NewExperimentMeasurer(
*config.(*quicping.Config),
))
},
config: &quicping.Config{},
inputPolicy: InputStrictlyRequired,
}
},
"riseupvpn": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, riseupvpn.NewExperimentMeasurer(
*config.(*riseupvpn.Config),
))
},
config: &riseupvpn.Config{},
inputPolicy: InputNone,
}
},
"run": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, run.NewExperimentMeasurer(
*config.(*run.Config),
))
},
config: &run.Config{},
inputPolicy: InputStrictlyRequired,
}
},
"simplequicping": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, simplequicping.NewExperimentMeasurer(
*config.(*simplequicping.Config),
))
},
config: &simplequicping.Config{},
inputPolicy: InputStrictlyRequired,
}
},
"signal": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, signal.NewExperimentMeasurer(
*config.(*signal.Config),
))
},
config: &signal.Config{},
inputPolicy: InputNone,
}
},
"sni_blocking": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, sniblocking.NewExperimentMeasurer(
*config.(*sniblocking.Config),
))
},
config: &sniblocking.Config{},
inputPolicy: InputOrQueryBackend,
}
},
"stunreachability": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, stunreachability.NewExperimentMeasurer(
*config.(*stunreachability.Config),
))
},
config: &stunreachability.Config{},
inputPolicy: InputOrStaticDefault,
}
},
"tcpping": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, tcpping.NewExperimentMeasurer(
*config.(*tcpping.Config),
))
},
config: &tcpping.Config{},
inputPolicy: InputStrictlyRequired,
}
},
"tlsping": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, tlsping.NewExperimentMeasurer(
*config.(*tlsping.Config),
))
},
config: &tlsping.Config{},
inputPolicy: InputStrictlyRequired,
}
},
"telegram": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, telegram.NewExperimentMeasurer(
*config.(*telegram.Config),
))
},
config: &telegram.Config{},
inputPolicy: InputNone,
}
},
"tlstool": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, tlstool.NewExperimentMeasurer(
*config.(*tlstool.Config),
))
},
config: &tlstool.Config{},
inputPolicy: InputOrQueryBackend,
}
},
"tor": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, tor.NewExperimentMeasurer(
*config.(*tor.Config),
))
},
config: &tor.Config{},
inputPolicy: InputNone,
}
},
"torsf": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, torsf.NewExperimentMeasurer(
*config.(*torsf.Config),
))
},
config: &torsf.Config{},
inputPolicy: InputNone,
}
},
"urlgetter": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, urlgetter.NewExperimentMeasurer(
*config.(*urlgetter.Config),
))
},
config: &urlgetter.Config{},
inputPolicy: InputStrictlyRequired,
}
},
"vanilla_tor": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, vanillator.NewExperimentMeasurer(
*config.(*vanillator.Config),
))
},
config: &vanillator.Config{},
inputPolicy: InputNone,
}
},
"web_connectivity": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, webconnectivity.NewExperimentMeasurer(
*config.(*webconnectivity.Config),
))
},
config: &webconnectivity.Config{},
inputPolicy: InputOrQueryBackend,
}
},
"whatsapp": func(session *Session) *experimentBuilder {
return &experimentBuilder{
build: func(config interface{}) *experiment {
return newExperiment(session, whatsapp.NewExperimentMeasurer(
*config.(*whatsapp.Config),
))
},
config: &whatsapp.Config{},
inputPolicy: InputNone,
}
},
}
import "github.com/ooni/probe-cli/v3/internal/registry"
// AllExperiments returns the name of all experiments
func AllExperiments() []string {
var names []string
for key := range experimentsByName {
names = append(names, key)
}
return names
return registry.ExperimentNames()
}

View File

@ -26,76 +26,6 @@ func formatTimeNowUTC() string {
return time.Now().UTC().Format(dateFormat)
}
// Experiment is an experiment instance.
type Experiment interface {
// KibiBytesReceived accounts for the KibiBytes received by the experiment.
KibiBytesReceived() float64
// KibiBytesSent is like KibiBytesReceived but for the bytes sent.
KibiBytesSent() float64
// Name returns the experiment name.
Name() string
// GetSummaryKeys returns a data structure containing a
// summary of the test keys for ooniprobe.
GetSummaryKeys(m *model.Measurement) (any, error)
// ReportID returns the open report's ID, if we have opened a report
// successfully before, or an empty string, otherwise.
//
// Deprecated: new code should use a Submitter.
ReportID() string
// MeasureAsync runs an async measurement. This operation could post
// one or more measurements onto the returned channel. We'll close the
// channel when we've emitted all the measurements.
//
// Arguments:
//
// - ctx is the context for deadline/cancellation/timeout;
//
// - input is the input (typically a URL but it could also be
// just an endpoint or an empty string for input-less experiments
// such as, e.g., ndt7 and dash).
//
// Return value:
//
// - on success, channel where to post measurements (the channel
// will be closed when done) and nil error;
//
// - on failure, nil channel and non-nil error.
MeasureAsync(ctx context.Context, input string) (<-chan *model.Measurement, error)
// MeasureWithContext performs a synchronous measurement.
//
// Return value: strictly either a non-nil measurement and
// a nil error or a nil measurement and a non-nil error.
//
// CAVEAT: while this API is perfectly fine for experiments that
// return a single measurement, it will only return the first measurement
// when used with an asynchronous experiment.
MeasureWithContext(ctx context.Context, input string) (measurement *model.Measurement, err error)
// SaveMeasurement saves a measurement on the specified file path.
//
// Deprecated: new code should use a Saver.
SaveMeasurement(measurement *model.Measurement, filePath string) error
// SubmitAndUpdateMeasurementContext submits a measurement and updates the
// fields whose value has changed as part of the submission.
//
// Deprecated: new code should use a Submitter.
SubmitAndUpdateMeasurementContext(
ctx context.Context, measurement *model.Measurement) error
// OpenReportContext will open a report using the given context
// to possibly limit the lifetime of this operation.
//
// Deprecated: new code should use a Submitter.
OpenReportContext(ctx context.Context) error
}
// experiment implements Experiment.
type experiment struct {
byteCounter *bytecounter.Counter

View File

@ -12,7 +12,6 @@ import (
"strings"
"testing"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/example"
"github.com/ooni/probe-cli/v3/internal/model"
)
@ -142,7 +141,7 @@ func TestNeedsInput(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if builder.InputPolicy() != InputOrQueryBackend {
if builder.InputPolicy() != model.InputOrQueryBackend {
t.Fatal("web_connectivity certainly needs input")
}
}
@ -218,88 +217,6 @@ func TestMeasurementFailure(t *testing.T) {
}
}
func TestUseOptions(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
sess := newSessionForTesting(t)
defer sess.Close()
builder, err := sess.NewExperimentBuilder("example")
if err != nil {
t.Fatal(err)
}
options, err := builder.Options()
if err != nil {
t.Fatal(err)
}
var (
returnError bool
message bool
sleepTime bool
other int64
)
for name, option := range options {
if name == "ReturnError" {
returnError = true
if option.Type != "bool" {
t.Fatal("ReturnError is not a bool")
}
if option.Doc != "Toogle to return a mocked error" {
t.Fatal("ReturnError doc is wrong")
}
} else if name == "Message" {
message = true
if option.Type != "string" {
t.Fatal("Message is not a string")
}
if option.Doc != "Message to emit at test completion" {
t.Fatal("Message doc is wrong")
}
} else if name == "SleepTime" {
sleepTime = true
if option.Type != "int64" {
t.Fatal("SleepTime is not an int64")
}
if option.Doc != "Amount of time to sleep for" {
t.Fatal("SleepTime doc is wrong")
}
} else {
other++
}
}
if other != 0 {
t.Fatal("found unexpected option")
}
if !returnError {
t.Fatal("did not find ReturnError option")
}
if !message {
t.Fatal("did not find Message option")
}
if !sleepTime {
t.Fatal("did not find SleepTime option")
}
if err := builder.SetOptionAny("ReturnError", true); err != nil {
t.Fatal("cannot set ReturnError field")
}
if err := builder.SetOptionAny("SleepTime", 10); err != nil {
t.Fatal("cannot set SleepTime field")
}
if err := builder.SetOptionAny("Message", "antani"); err != nil {
t.Fatal("cannot set Message field")
}
config := builder.(*experimentBuilder).config.(*example.Config)
if config.ReturnError != true {
t.Fatal("config.ReturnError was not changed")
}
if config.SleepTime != 10 {
t.Fatal("config.SleepTime was not changed")
}
if config.Message != "antani" {
t.Fatal("config.Message was not changed")
}
}
func TestRunHHFM(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
@ -313,7 +230,7 @@ func TestRunHHFM(t *testing.T) {
runexperimentflow(t, builder.NewExperiment(), "")
}
func runexperimentflow(t *testing.T, experiment Experiment, input string) {
func runexperimentflow(t *testing.T, experiment model.Experiment, input string) {
ctx := context.Background()
err := experiment.OpenReportContext(ctx)
if err != nil {

View File

@ -5,243 +5,46 @@ package engine
//
import (
"errors"
"fmt"
"reflect"
"strconv"
"github.com/iancoleman/strcase"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/registry"
)
// InputPolicy describes the experiment policy with respect to input. That is
// whether it requires input, optionally accepts input, does not want input.
type InputPolicy string
const (
// InputOrQueryBackend indicates that the experiment requires
// external input to run and that this kind of input is URLs
// from the citizenlab/test-lists repository. If this input
// not provided to the experiment, then the code that runs the
// experiment is supposed to fetch from URLs from OONI's backends.
InputOrQueryBackend = InputPolicy("or_query_backend")
// InputStrictlyRequired indicates that the experiment
// requires input and we currently don't have an API for
// fetching such input. Therefore, either the user specifies
// input or the experiment will fail for the lack of input.
InputStrictlyRequired = InputPolicy("strictly_required")
// InputOptional indicates that the experiment handles input,
// if any; otherwise it fetchs input/uses a default.
InputOptional = InputPolicy("optional")
// InputNone indicates that the experiment does not want any
// input and ignores the input if provided with it.
InputNone = InputPolicy("none")
// We gather input from StaticInput and SourceFiles. If there is
// input, we return it. Otherwise, we return an internal static
// list of inputs to be used with this experiment.
InputOrStaticDefault = InputPolicy("or_static_default")
)
// ExperimentBuilder builds an experiment.
type ExperimentBuilder interface {
// Interruptible tells you whether this is an interruptible experiment. This kind
// of experiments (e.g. ndt7) may be interrupted mid way.
Interruptible() bool
// InputPolicy returns the experiment input policy.
InputPolicy() InputPolicy
// Options returns information about the experiment's options.
Options() (map[string]OptionInfo, error)
// SetOptionAny sets an option whose value is an any value. We will use reasonable
// heuristics to convert the any value to the proper type of the field whose name is
// contained by the key variable. If we cannot convert the provided any value to
// the proper type, then this function returns an error.
SetOptionAny(key string, value any) error
// SetOptionsAny sets options from a map[string]any. See the documentation of
// the SetOptionAny method for more information.
SetOptionsAny(options map[string]any) error
// SetCallbacks sets the experiment's interactive callbacks.
SetCallbacks(callbacks model.ExperimentCallbacks)
// NewExperiment creates the experiment instance.
NewExperiment() Experiment
}
// experimentBuilder implements ExperimentBuilder.
//
// This type is now just a tiny wrapper around registry.Factory.
type experimentBuilder struct {
// build is the constructor that build an experiment with the given config.
build func(config interface{}) *experiment
factory *registry.Factory
// callbacks contains callbacks for the new experiment.
callbacks model.ExperimentCallbacks
// config contains the experiment's config.
config interface{}
// inputPolicy contains the experiment's InputPolicy.
inputPolicy InputPolicy
// interruptible indicates whether the experiment is interruptible.
interruptible bool
// session is the session
session *Session
}
// Interruptible implements ExperimentBuilder.Interruptible.
func (b *experimentBuilder) Interruptible() bool {
return b.interruptible
return b.factory.Interruptible()
}
// InputPolicy implements ExperimentBuilder.InputPolicy.
func (b *experimentBuilder) InputPolicy() InputPolicy {
return b.inputPolicy
func (b *experimentBuilder) InputPolicy() model.InputPolicy {
return b.factory.InputPolicy()
}
// OptionInfo contains info about an option.
type OptionInfo struct {
// Doc contains the documentation.
Doc string
// Type contains the type.
Type string
}
var (
// ErrConfigIsNotAStructPointer indicates we expected a pointer to struct.
ErrConfigIsNotAStructPointer = errors.New("config is not a struct pointer")
// ErrNoSuchField indicates there's no field with the given name.
ErrNoSuchField = errors.New("no such field")
// ErrCannotSetIntegerOption means SetOptionAny couldn't set an integer option.
ErrCannotSetIntegerOption = errors.New("cannot set integer option")
// ErrInvalidStringRepresentationOfBool indicates the string you passed
// to SetOptionaAny is not a valid string representation of a bool.
ErrInvalidStringRepresentationOfBool = errors.New("invalid string representation of bool")
// ErrCannotSetBoolOption means SetOptionAny couldn't set a bool option.
ErrCannotSetBoolOption = errors.New("cannot set bool option")
// ErrCannotSetStringOption means SetOptionAny couldn't set a string option.
ErrCannotSetStringOption = errors.New("cannot set string option")
// ErrUnsupportedOptionType means we don't support the type passed to
// the SetOptionAny method as an opaque any type.
ErrUnsupportedOptionType = errors.New("unsupported option type")
)
// Options implements ExperimentBuilder.Options.
func (b *experimentBuilder) Options() (map[string]OptionInfo, error) {
result := make(map[string]OptionInfo)
ptrinfo := reflect.ValueOf(b.config)
if ptrinfo.Kind() != reflect.Ptr {
return nil, ErrConfigIsNotAStructPointer
}
structinfo := ptrinfo.Elem().Type()
if structinfo.Kind() != reflect.Struct {
return nil, ErrConfigIsNotAStructPointer
}
for i := 0; i < structinfo.NumField(); i++ {
field := structinfo.Field(i)
result[field.Name] = OptionInfo{
Doc: field.Tag.Get("ooni"),
Type: field.Type.String(),
}
}
return result, nil
}
// setOptionBool sets a bool option.
func (b *experimentBuilder) setOptionBool(field reflect.Value, value any) error {
switch v := value.(type) {
case bool:
field.SetBool(v)
return nil
case string:
if v != "true" && v != "false" {
return fmt.Errorf("%w: %s", ErrInvalidStringRepresentationOfBool, v)
}
field.SetBool(v == "true")
return nil
default:
return fmt.Errorf("%w from a value of type %T", ErrCannotSetBoolOption, value)
}
}
// setOptionInt sets an int option
func (b *experimentBuilder) setOptionInt(field reflect.Value, value any) error {
switch v := value.(type) {
case int64:
field.SetInt(v)
return nil
case int32:
field.SetInt(int64(v))
return nil
case int16:
field.SetInt(int64(v))
return nil
case int8:
field.SetInt(int64(v))
return nil
case int:
field.SetInt(int64(v))
return nil
case string:
number, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("%w: %s", ErrCannotSetIntegerOption, err.Error())
}
field.SetInt(number)
return nil
default:
return fmt.Errorf("%w from a value of type %T", ErrCannotSetIntegerOption, value)
}
}
// setOptionString sets a string option
func (b *experimentBuilder) setOptionString(field reflect.Value, value any) error {
switch v := value.(type) {
case string:
field.SetString(v)
return nil
default:
return fmt.Errorf("%w from a value of type %T", ErrCannotSetStringOption, value)
}
func (b *experimentBuilder) Options() (map[string]model.ExperimentOptionInfo, error) {
return b.factory.Options()
}
// SetOptionAny implements ExperimentBuilder.SetOptionAny.
func (b *experimentBuilder) SetOptionAny(key string, value any) error {
field, err := b.fieldbyname(b.config, key)
if err != nil {
return err
}
switch field.Kind() {
case reflect.Int64:
return b.setOptionInt(field, value)
case reflect.Bool:
return b.setOptionBool(field, value)
case reflect.String:
return b.setOptionString(field, value)
default:
return fmt.Errorf("%w: %T", ErrUnsupportedOptionType, value)
}
return b.factory.SetOptionAny(key, value)
}
// SetOptionsAny implements ExperimentBuilder.SetOptionsAny.
func (b *experimentBuilder) SetOptionsAny(options map[string]any) error {
for key, value := range options {
if err := b.SetOptionAny(key, value); err != nil {
return err
}
}
return nil
return b.factory.SetOptionsAny(options)
}
// SetCallbacks implements ExperimentBuilder.SetCallbacks.
@ -249,57 +52,24 @@ func (b *experimentBuilder) SetCallbacks(callbacks model.ExperimentCallbacks) {
b.callbacks = callbacks
}
// fieldbyname return v's field whose name is equal to the given key.
func (b *experimentBuilder) fieldbyname(v interface{}, key string) (reflect.Value, error) {
// See https://stackoverflow.com/a/6396678/4354461
ptrinfo := reflect.ValueOf(v)
if ptrinfo.Kind() != reflect.Ptr {
return reflect.Value{}, fmt.Errorf("%w but a %T", ErrConfigIsNotAStructPointer, v)
}
structinfo := ptrinfo.Elem()
if structinfo.Kind() != reflect.Struct {
return reflect.Value{}, fmt.Errorf("%w but a %T", ErrConfigIsNotAStructPointer, v)
}
field := structinfo.FieldByName(key)
if !field.IsValid() || !field.CanSet() {
return reflect.Value{}, fmt.Errorf("%w: %s", ErrNoSuchField, key)
}
return field, nil
}
// NewExperiment creates the experiment
func (b *experimentBuilder) NewExperiment() Experiment {
experiment := b.build(b.config)
func (b *experimentBuilder) NewExperiment() model.Experiment {
measurer := b.factory.NewExperimentMeasurer()
experiment := newExperiment(b.session, measurer)
experiment.callbacks = b.callbacks
return experiment
}
// canonicalizeExperimentName allows code to provide experiment names
// in a more flexible way, where we have aliases.
//
// Because we allow for uppercase experiment names for backwards
// compatibility with MK, we need to add some exceptions here when
// mapping (e.g., DNSCheck => dnscheck).
func canonicalizeExperimentName(name string) string {
switch name = strcase.ToSnake(name); name {
case "ndt_7":
name = "ndt" // since 2020-03-18, we use ndt7 to implement ndt by default
case "dns_check":
name = "dnscheck"
case "stun_reachability":
name = "stunreachability"
default:
}
return name
}
// newExperimentBuilder creates a new experimentBuilder instance.
func newExperimentBuilder(session *Session, name string) (*experimentBuilder, error) {
factory := experimentsByName[canonicalizeExperimentName(name)]
if factory == nil {
return nil, fmt.Errorf("no such experiment: %s", name)
factory, err := registry.NewFactory(name)
if err != nil {
return nil, err
}
builder := &experimentBuilder{
factory: factory,
callbacks: model.NewPrinterCallbacks(session.Logger()),
session: session,
}
builder := factory(session)
builder.callbacks = model.NewPrinterCallbacks(session.Logger())
return builder, nil
}

View File

@ -1,347 +1 @@
package engine
import (
"errors"
"testing"
"github.com/google/go-cmp/cmp"
)
type fakeExperimentConfig struct {
Chan chan any `ooni:"we cannot set this"`
String string `ooni:"a string"`
Truth bool `ooni:"something that no-one knows"`
Value int64 `ooni:"a number"`
}
func TestExperimentBuilderOptions(t *testing.T) {
t.Run("when config is not a pointer", func(t *testing.T) {
b := &experimentBuilder{
config: 17,
}
options, err := b.Options()
if !errors.Is(err, ErrConfigIsNotAStructPointer) {
t.Fatal("expected an error here")
}
if options != nil {
t.Fatal("expected nil here")
}
})
t.Run("when config is not a struct", func(t *testing.T) {
number := 17
b := &experimentBuilder{
config: &number,
}
options, err := b.Options()
if !errors.Is(err, ErrConfigIsNotAStructPointer) {
t.Fatal("expected an error here")
}
if options != nil {
t.Fatal("expected nil here")
}
})
t.Run("when config is a pointer to struct", func(t *testing.T) {
config := &fakeExperimentConfig{}
b := &experimentBuilder{
config: config,
}
options, err := b.Options()
if err != nil {
t.Fatal(err)
}
for name, value := range options {
switch name {
case "Chan":
if value.Doc != "we cannot set this" {
t.Fatal("invalid doc")
}
if value.Type != "chan interface {}" {
t.Fatal("invalid type", value.Type)
}
case "String":
if value.Doc != "a string" {
t.Fatal("invalid doc")
}
if value.Type != "string" {
t.Fatal("invalid type", value.Type)
}
case "Truth":
if value.Doc != "something that no-one knows" {
t.Fatal("invalid doc")
}
if value.Type != "bool" {
t.Fatal("invalid type", value.Type)
}
case "Value":
if value.Doc != "a number" {
t.Fatal("invalid doc")
}
if value.Type != "int64" {
t.Fatal("invalid type", value.Type)
}
default:
t.Fatal("unknown name", name)
}
}
})
}
func TestExperimentBuilderSetOptionAny(t *testing.T) {
var inputs = []struct {
TestCaseName string
InitialConfig any
FieldName string
FieldValue any
ExpectErr error
ExpectConfig any
}{{
TestCaseName: "config is not a pointer",
InitialConfig: fakeExperimentConfig{},
FieldName: "Antani",
FieldValue: true,
ExpectErr: ErrConfigIsNotAStructPointer,
ExpectConfig: fakeExperimentConfig{},
}, {
TestCaseName: "config is not a pointer to struct",
InitialConfig: func() *int {
v := 17
return &v
}(),
FieldName: "Antani",
FieldValue: true,
ExpectErr: ErrConfigIsNotAStructPointer,
ExpectConfig: func() *int {
v := 17
return &v
}(),
}, {
TestCaseName: "for missing field",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Antani",
FieldValue: true,
ExpectErr: ErrNoSuchField,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "[bool] for true value represented as string",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Truth",
FieldValue: "true",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Truth: true,
},
}, {
TestCaseName: "[bool] for false value represented as string",
InitialConfig: &fakeExperimentConfig{
Truth: true,
},
FieldName: "Truth",
FieldValue: "false",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Truth: false, // must have been flipped
},
}, {
TestCaseName: "[bool] for true value",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Truth",
FieldValue: true,
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Truth: true,
},
}, {
TestCaseName: "[bool] for false value",
InitialConfig: &fakeExperimentConfig{
Truth: true,
},
FieldName: "Truth",
FieldValue: false,
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Truth: false, // must have been flipped
},
}, {
TestCaseName: "[bool] for invalid string representation of bool",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Truth",
FieldValue: "xxx",
ExpectErr: ErrInvalidStringRepresentationOfBool,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "[bool] for value we don't know how to convert to bool",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Truth",
FieldValue: make(chan any),
ExpectErr: ErrCannotSetBoolOption,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "[int] for int",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: 17,
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for int64",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: int64(17),
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for int32",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: int32(17),
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for int16",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: int16(17),
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for int8",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: int8(17),
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for string representation of int",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: "17",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for invalid string representation of int",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: "xx",
ExpectErr: ErrCannotSetIntegerOption,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "[int] for type we don't know how to convert to int",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: make(chan any),
ExpectErr: ErrCannotSetIntegerOption,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "[string] for serialized bool value while setting a string value",
InitialConfig: &fakeExperimentConfig{},
FieldName: "String",
FieldValue: "true",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
String: "true",
},
}, {
TestCaseName: "[string] for serialized int value while setting a string value",
InitialConfig: &fakeExperimentConfig{},
FieldName: "String",
FieldValue: "155",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
String: "155",
},
}, {
TestCaseName: "[string] for any other string",
InitialConfig: &fakeExperimentConfig{},
FieldName: "String",
FieldValue: "xxx",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
String: "xxx",
},
}, {
TestCaseName: "[string] for type we don't know how to convert to string",
InitialConfig: &fakeExperimentConfig{},
FieldName: "String",
FieldValue: make(chan any),
ExpectErr: ErrCannotSetStringOption,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "for a field that we don't know how to set",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Chan",
FieldValue: make(chan any),
ExpectErr: ErrUnsupportedOptionType,
ExpectConfig: &fakeExperimentConfig{},
}}
for _, input := range inputs {
t.Run(input.TestCaseName, func(t *testing.T) {
ec := input.InitialConfig
b := &experimentBuilder{config: ec}
err := b.SetOptionAny(input.FieldName, input.FieldValue)
if !errors.Is(err, input.ExpectErr) {
t.Fatal(err)
}
if diff := cmp.Diff(input.ExpectConfig, ec); diff != "" {
t.Fatal(diff)
}
})
}
}
func TestExperimentBuilderSetOptionsAny(t *testing.T) {
b := &experimentBuilder{config: &fakeExperimentConfig{}}
t.Run("we correctly handle an empty map", func(t *testing.T) {
if err := b.SetOptionsAny(nil); err != nil {
t.Fatal(err)
}
})
t.Run("we correctly handle a map containing options", func(t *testing.T) {
f := &fakeExperimentConfig{}
privateb := &experimentBuilder{config: f}
opts := map[string]any{
"String": "yoloyolo",
"Value": "174",
"Truth": "true",
}
if err := privateb.SetOptionsAny(opts); err != nil {
t.Fatal(err)
}
if f.String != "yoloyolo" {
t.Fatal("cannot set string value")
}
if f.Value != 174 {
t.Fatal("cannot set integer value")
}
if f.Truth != true {
t.Fatal("cannot set bool value")
}
})
t.Run("we handle mistakes in a map containing string options", func(t *testing.T) {
opts := map[string]any{
"String": "yoloyolo",
"Value": "xx",
"Truth": "true",
}
if err := b.SetOptionsAny(opts); !errors.Is(err, ErrCannotSetIntegerOption) {
t.Fatal("unexpected err", err)
}
})
}

View File

@ -11,6 +11,7 @@ import (
"github.com/apex/log"
"github.com/ooni/probe-cli/v3/internal/fsx"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/registry"
"github.com/ooni/probe-cli/v3/internal/stuninput"
)
@ -86,7 +87,7 @@ type InputLoader struct {
// current experiment. We will not load any input if
// the policy says we should not. You MUST fill in
// this field.
InputPolicy InputPolicy
InputPolicy model.InputPolicy
// Logger is the optional logger that the InputLoader
// should be using. If not set, we will use the default
@ -112,13 +113,13 @@ type InputLoader struct {
// return a list of URLs because this is the only input we support.
func (il *InputLoader) Load(ctx context.Context) ([]model.OOAPIURLInfo, error) {
switch il.InputPolicy {
case InputOptional:
case model.InputOptional:
return il.loadOptional()
case InputOrQueryBackend:
case model.InputOrQueryBackend:
return il.loadOrQueryBackend(ctx)
case InputStrictlyRequired:
case model.InputStrictlyRequired:
return il.loadStrictlyRequired(ctx)
case InputOrStaticDefault:
case model.InputOrStaticDefault:
return il.loadOrStaticDefault(ctx)
default:
return il.loadNone()
@ -299,7 +300,7 @@ func StaticBareInputForExperiment(name string) ([]string, error) {
// Implementation note: we may be called from pkg/oonimkall
// with a non-canonical experiment name, so we need to convert
// the experiment name to be canonical before proceeding.
switch canonicalizeExperimentName(name) {
switch registry.CanonicalizeExperimentName(name) {
case "dnscheck":
return dnsCheckDefaultInput, nil
case "stunreachability":

View File

@ -30,7 +30,7 @@ func TestInputLoaderInputOrQueryBackendWithNoInput(t *testing.T) {
}
defer sess.Close()
il := &engine.InputLoader{
InputPolicy: engine.InputOrQueryBackend,
InputPolicy: model.InputOrQueryBackend,
Session: sess,
}
ctx := context.Background()

View File

@ -19,7 +19,7 @@ import (
func TestInputLoaderInputNoneWithStaticInputs(t *testing.T) {
il := &InputLoader{
StaticInputs: []string{"https://www.google.com/"},
InputPolicy: InputNone,
InputPolicy: model.InputNone,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -37,7 +37,7 @@ func TestInputLoaderInputNoneWithFilesInputs(t *testing.T) {
"testdata/inputloader1.txt",
"testdata/inputloader2.txt",
},
InputPolicy: InputNone,
InputPolicy: model.InputNone,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -56,7 +56,7 @@ func TestInputLoaderInputNoneWithBothInputs(t *testing.T) {
"testdata/inputloader1.txt",
"testdata/inputloader2.txt",
},
InputPolicy: InputNone,
InputPolicy: model.InputNone,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -70,7 +70,7 @@ func TestInputLoaderInputNoneWithBothInputs(t *testing.T) {
func TestInputLoaderInputNoneWithNoInput(t *testing.T) {
il := &InputLoader{
InputPolicy: InputNone,
InputPolicy: model.InputNone,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -84,7 +84,7 @@ func TestInputLoaderInputNoneWithNoInput(t *testing.T) {
func TestInputLoaderInputOptionalWithNoInput(t *testing.T) {
il := &InputLoader{
InputPolicy: InputOptional,
InputPolicy: model.InputOptional,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -103,7 +103,7 @@ func TestInputLoaderInputOptionalWithInput(t *testing.T) {
"testdata/inputloader1.txt",
"testdata/inputloader2.txt",
},
InputPolicy: InputOptional,
InputPolicy: model.InputOptional,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -133,7 +133,7 @@ func TestInputLoaderInputOptionalNonexistentFile(t *testing.T) {
"/nonexistent",
"testdata/inputloader2.txt",
},
InputPolicy: InputOptional,
InputPolicy: model.InputOptional,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -152,7 +152,7 @@ func TestInputLoaderInputStrictlyRequiredWithInput(t *testing.T) {
"testdata/inputloader1.txt",
"testdata/inputloader2.txt",
},
InputPolicy: InputStrictlyRequired,
InputPolicy: model.InputStrictlyRequired,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -176,7 +176,7 @@ func TestInputLoaderInputStrictlyRequiredWithInput(t *testing.T) {
func TestInputLoaderInputStrictlyRequiredWithoutInput(t *testing.T) {
il := &InputLoader{
InputPolicy: InputStrictlyRequired,
InputPolicy: model.InputStrictlyRequired,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -190,7 +190,7 @@ func TestInputLoaderInputStrictlyRequiredWithoutInput(t *testing.T) {
func TestInputLoaderInputStrictlyRequiredWithEmptyFile(t *testing.T) {
il := &InputLoader{
InputPolicy: InputStrictlyRequired,
InputPolicy: model.InputStrictlyRequired,
SourceFiles: []string{
"testdata/inputloader1.txt",
"testdata/inputloader3.txt", // we want it before inputloader2.txt
@ -215,7 +215,7 @@ func TestInputLoaderInputOrStaticDefaultWithInput(t *testing.T) {
"testdata/inputloader1.txt",
"testdata/inputloader2.txt",
},
InputPolicy: InputOrStaticDefault,
InputPolicy: model.InputOrStaticDefault,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -240,7 +240,7 @@ func TestInputLoaderInputOrStaticDefaultWithInput(t *testing.T) {
func TestInputLoaderInputOrStaticDefaultWithEmptyFile(t *testing.T) {
il := &InputLoader{
ExperimentName: "dnscheck",
InputPolicy: InputOrStaticDefault,
InputPolicy: model.InputOrStaticDefault,
SourceFiles: []string{
"testdata/inputloader1.txt",
"testdata/inputloader3.txt", // we want it before inputloader2.txt
@ -260,7 +260,7 @@ func TestInputLoaderInputOrStaticDefaultWithEmptyFile(t *testing.T) {
func TestInputLoaderInputOrStaticDefaultWithoutInputDNSCheck(t *testing.T) {
il := &InputLoader{
ExperimentName: "dnscheck",
InputPolicy: InputOrStaticDefault,
InputPolicy: model.InputOrStaticDefault,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -287,7 +287,7 @@ func TestInputLoaderInputOrStaticDefaultWithoutInputDNSCheck(t *testing.T) {
func TestInputLoaderInputOrStaticDefaultWithoutInputStunReachability(t *testing.T) {
il := &InputLoader{
ExperimentName: "stunreachability",
InputPolicy: InputOrStaticDefault,
InputPolicy: model.InputOrStaticDefault,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -323,7 +323,7 @@ func TestStaticBareInputForExperimentWorksWithNonCanonicalNames(t *testing.T) {
func TestInputLoaderInputOrStaticDefaultWithoutInputOtherName(t *testing.T) {
il := &InputLoader{
ExperimentName: "xx",
InputPolicy: InputOrStaticDefault,
InputPolicy: model.InputOrStaticDefault,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -342,7 +342,7 @@ func TestInputLoaderInputOrQueryBackendWithInput(t *testing.T) {
"testdata/inputloader1.txt",
"testdata/inputloader2.txt",
},
InputPolicy: InputOrQueryBackend,
InputPolicy: model.InputOrQueryBackend,
}
ctx := context.Background()
out, err := il.Load(ctx)
@ -377,7 +377,7 @@ func TestInputLoaderInputOrQueryBackendWithNoInputAndCancelledContext(t *testing
}
defer sess.Close()
il := &InputLoader{
InputPolicy: InputOrQueryBackend,
InputPolicy: model.InputOrQueryBackend,
Session: sess,
}
ctx, cancel := context.WithCancel(context.Background())
@ -393,7 +393,7 @@ func TestInputLoaderInputOrQueryBackendWithNoInputAndCancelledContext(t *testing
func TestInputLoaderInputOrQueryBackendWithEmptyFile(t *testing.T) {
il := &InputLoader{
InputPolicy: InputOrQueryBackend,
InputPolicy: model.InputOrQueryBackend,
SourceFiles: []string{
"testdata/inputloader1.txt",
"testdata/inputloader3.txt", // we want it before inputloader2.txt

View File

@ -390,7 +390,7 @@ var ErrAlreadyUsingProxy = errors.New(
// NewExperimentBuilder returns a new experiment builder
// for the experiment with the given name, or an error if
// there's no such experiment with the given name
func (s *Session) NewExperimentBuilder(name string) (ExperimentBuilder, error) {
func (s *Session) NewExperimentBuilder(name string) (model.ExperimentBuilder, error) {
eb, err := newExperimentBuilder(s, name)
if err != nil {
return nil, err

View File

@ -141,3 +141,143 @@ type ExperimentMeasurer interface {
// GetSummaryKeys returns summary keys expected by ooni/probe-cli.
GetSummaryKeys(*Measurement) (interface{}, error)
}
// Experiment is an experiment instance.
type Experiment interface {
// KibiBytesReceived accounts for the KibiBytes received by the experiment.
KibiBytesReceived() float64
// KibiBytesSent is like KibiBytesReceived but for the bytes sent.
KibiBytesSent() float64
// Name returns the experiment name.
Name() string
// GetSummaryKeys returns a data structure containing a
// summary of the test keys for ooniprobe.
GetSummaryKeys(m *Measurement) (any, error)
// ReportID returns the open report's ID, if we have opened a report
// successfully before, or an empty string, otherwise.
//
// Deprecated: new code should use a Submitter.
ReportID() string
// MeasureAsync runs an async measurement. This operation could post
// one or more measurements onto the returned channel. We'll close the
// channel when we've emitted all the measurements.
//
// Arguments:
//
// - ctx is the context for deadline/cancellation/timeout;
//
// - input is the input (typically a URL but it could also be
// just an endpoint or an empty string for input-less experiments
// such as, e.g., ndt7 and dash).
//
// Return value:
//
// - on success, channel where to post measurements (the channel
// will be closed when done) and nil error;
//
// - on failure, nil channel and non-nil error.
MeasureAsync(ctx context.Context, input string) (<-chan *Measurement, error)
// MeasureWithContext performs a synchronous measurement.
//
// Return value: strictly either a non-nil measurement and
// a nil error or a nil measurement and a non-nil error.
//
// CAVEAT: while this API is perfectly fine for experiments that
// return a single measurement, it will only return the first measurement
// when used with an asynchronous experiment.
MeasureWithContext(ctx context.Context, input string) (measurement *Measurement, err error)
// SaveMeasurement saves a measurement on the specified file path.
//
// Deprecated: new code should use a Saver.
SaveMeasurement(measurement *Measurement, filePath string) error
// SubmitAndUpdateMeasurementContext submits a measurement and updates the
// fields whose value has changed as part of the submission.
//
// Deprecated: new code should use a Submitter.
SubmitAndUpdateMeasurementContext(
ctx context.Context, measurement *Measurement) error
// OpenReportContext will open a report using the given context
// to possibly limit the lifetime of this operation.
//
// Deprecated: new code should use a Submitter.
OpenReportContext(ctx context.Context) error
}
// InputPolicy describes the experiment policy with respect to input. That is
// whether it requires input, optionally accepts input, does not want input.
type InputPolicy string
const (
// InputOrQueryBackend indicates that the experiment requires
// external input to run and that this kind of input is URLs
// from the citizenlab/test-lists repository. If this input
// not provided to the experiment, then the code that runs the
// experiment is supposed to fetch from URLs from OONI's backends.
InputOrQueryBackend = InputPolicy("or_query_backend")
// InputStrictlyRequired indicates that the experiment
// requires input and we currently don't have an API for
// fetching such input. Therefore, either the user specifies
// input or the experiment will fail for the lack of input.
InputStrictlyRequired = InputPolicy("strictly_required")
// InputOptional indicates that the experiment handles input,
// if any; otherwise it fetchs input/uses a default.
InputOptional = InputPolicy("optional")
// InputNone indicates that the experiment does not want any
// input and ignores the input if provided with it.
InputNone = InputPolicy("none")
// We gather input from StaticInput and SourceFiles. If there is
// input, we return it. Otherwise, we return an internal static
// list of inputs to be used with this experiment.
InputOrStaticDefault = InputPolicy("or_static_default")
)
// ExperimentBuilder builds an experiment.
type ExperimentBuilder interface {
// Interruptible tells you whether this is an interruptible experiment. This kind
// of experiments (e.g. ndt7) may be interrupted mid way.
Interruptible() bool
// InputPolicy returns the experiment input policy.
InputPolicy() InputPolicy
// Options returns information about the experiment's options.
Options() (map[string]ExperimentOptionInfo, error)
// SetOptionAny sets an option whose value is an any value. We will use reasonable
// heuristics to convert the any value to the proper type of the field whose name is
// contained by the key variable. If we cannot convert the provided any value to
// the proper type, then this function returns an error.
SetOptionAny(key string, value any) error
// SetOptionsAny sets options from a map[string]any. See the documentation of
// the SetOptionAny method for more information.
SetOptionsAny(options map[string]any) error
// SetCallbacks sets the experiment's interactive callbacks.
SetCallbacks(callbacks ExperimentCallbacks)
// NewExperiment creates the experiment instance.
NewExperiment() Experiment
}
// ExperimentOptionInfo contains info about an experiment option.
type ExperimentOptionInfo struct {
// Doc contains the documentation.
Doc string
// Type contains the type.
Type string
}

View File

@ -117,7 +117,7 @@ type inputProcessor interface {
}
// newInputProcessor creates a new inputProcessor instance.
func (ed *Experiment) newInputProcessor(experiment engine.Experiment,
func (ed *Experiment) newInputProcessor(experiment model.Experiment,
inputList []model.OOAPIURLInfo, saver engine.Saver, submitter engine.Submitter) inputProcessor {
return &engine.InputProcessor{
Annotations: ed.Annotations,
@ -138,7 +138,7 @@ func (ed *Experiment) newInputProcessor(experiment engine.Experiment,
}
// newSaver creates a new engine.Saver instance.
func (ed *Experiment) newSaver(experiment engine.Experiment) (engine.Saver, error) {
func (ed *Experiment) newSaver(experiment model.Experiment) (engine.Saver, error) {
return engine.NewSaver(engine.SaverConfig{
Enabled: !ed.NoJSON,
Experiment: experiment,
@ -157,7 +157,7 @@ func (ed *Experiment) newSubmitter(ctx context.Context) (engine.Submitter, error
}
// newExperimentBuilder creates a new engine.ExperimentBuilder for the given experimentName.
func (ed *Experiment) newExperimentBuilder(experimentName string) (engine.ExperimentBuilder, error) {
func (ed *Experiment) newExperimentBuilder(experimentName string) (model.ExperimentBuilder, error) {
return ed.Session.NewExperimentBuilder(ed.Name)
}
@ -167,7 +167,7 @@ type inputLoader interface {
}
// newInputLoader creates a new inputLoader.
func (ed *Experiment) newInputLoader(inputPolicy engine.InputPolicy) inputLoader {
func (ed *Experiment) newInputLoader(inputPolicy model.InputPolicy) inputLoader {
return &engine.InputLoader{
CheckInConfig: &model.OOAPICheckInConfig{
RunType: model.RunTypeManual,

View File

@ -29,5 +29,5 @@ type Session interface {
Logger() model.Logger
// NewExperimentBuilder creates a new engine.ExperimentBuilder.
NewExperimentBuilder(name string) (engine.ExperimentBuilder, error)
NewExperimentBuilder(name string) (model.ExperimentBuilder, error)
}

View File

@ -0,0 +1,12 @@
package registry
// Where we register all the available experiments.
var allexperiments = map[string]*Factory{}
// ExperimentNames returns the name of all experiments
func ExperimentNames() (names []string) {
for key := range allexperiments {
names = append(names, key)
}
return
}

23
internal/registry/dash.go Normal file
View File

@ -0,0 +1,23 @@
package registry
//
// Registers the `dash' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/dash"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["dash"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return dash.NewExperimentMeasurer(
*config.(*dash.Config),
)
},
config: &dash.Config{},
interruptible: true,
inputPolicy: model.InputNone,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `dnscheck' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/dnscheck"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["dnscheck"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return dnscheck.NewExperimentMeasurer(
*config.(*dnscheck.Config),
)
},
config: &dnscheck.Config{},
inputPolicy: model.InputOrStaticDefault,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `dnsping' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/dnsping"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["dnsping"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return dnsping.NewExperimentMeasurer(
*config.(*dnsping.Config),
)
},
config: &dnsping.Config{},
inputPolicy: model.InputOrStaticDefault,
}
}

2
internal/registry/doc.go Normal file
View File

@ -0,0 +1,2 @@
// Package registry contains a registry of all the available experiments.
package registry

View File

@ -0,0 +1,28 @@
package registry
//
// Registers the `example' experiment.
//
import (
"time"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/example"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["example"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return example.NewExperimentMeasurer(
*config.(*example.Config), "example",
)
},
config: &example.Config{
Message: "Good day from the example experiment!",
SleepTime: int64(time.Second),
},
interruptible: true,
inputPolicy: model.InputNone,
}
}

View File

@ -0,0 +1,223 @@
package registry
//
// Factory for constructing experiments.
//
import (
"errors"
"fmt"
"reflect"
"strconv"
"github.com/iancoleman/strcase"
"github.com/ooni/probe-cli/v3/internal/model"
)
// Factory allows to construct an experiment measurer.
type Factory struct {
// build is the constructor that build an experiment with the given config.
build func(config interface{}) model.ExperimentMeasurer
// config contains the experiment's config.
config any
// inputPolicy contains the experiment's InputPolicy.
inputPolicy model.InputPolicy
// interruptible indicates whether the experiment is interruptible.
interruptible bool
}
// Interruptible returns whether the experiment is interruptible.
func (b *Factory) Interruptible() bool {
return b.interruptible
}
// InputPolicy returns the experiment's InputPolicy.
func (b *Factory) InputPolicy() model.InputPolicy {
return b.inputPolicy
}
var (
// ErrConfigIsNotAStructPointer indicates we expected a pointer to struct.
ErrConfigIsNotAStructPointer = errors.New("config is not a struct pointer")
// ErrNoSuchField indicates there's no field with the given name.
ErrNoSuchField = errors.New("no such field")
// ErrCannotSetIntegerOption means SetOptionAny couldn't set an integer option.
ErrCannotSetIntegerOption = errors.New("cannot set integer option")
// ErrInvalidStringRepresentationOfBool indicates the string you passed
// to SetOptionaAny is not a valid string representation of a bool.
ErrInvalidStringRepresentationOfBool = errors.New("invalid string representation of bool")
// ErrCannotSetBoolOption means SetOptionAny couldn't set a bool option.
ErrCannotSetBoolOption = errors.New("cannot set bool option")
// ErrCannotSetStringOption means SetOptionAny couldn't set a string option.
ErrCannotSetStringOption = errors.New("cannot set string option")
// ErrUnsupportedOptionType means we don't support the type passed to
// the SetOptionAny method as an opaque any type.
ErrUnsupportedOptionType = errors.New("unsupported option type")
)
// Options returns the options exposed by this experiment.
func (b *Factory) Options() (map[string]model.ExperimentOptionInfo, error) {
result := make(map[string]model.ExperimentOptionInfo)
ptrinfo := reflect.ValueOf(b.config)
if ptrinfo.Kind() != reflect.Ptr {
return nil, ErrConfigIsNotAStructPointer
}
structinfo := ptrinfo.Elem().Type()
if structinfo.Kind() != reflect.Struct {
return nil, ErrConfigIsNotAStructPointer
}
for i := 0; i < structinfo.NumField(); i++ {
field := structinfo.Field(i)
result[field.Name] = model.ExperimentOptionInfo{
Doc: field.Tag.Get("ooni"),
Type: field.Type.String(),
}
}
return result, nil
}
// setOptionBool sets a bool option.
func (b *Factory) setOptionBool(field reflect.Value, value any) error {
switch v := value.(type) {
case bool:
field.SetBool(v)
return nil
case string:
if v != "true" && v != "false" {
return fmt.Errorf("%w: %s", ErrInvalidStringRepresentationOfBool, v)
}
field.SetBool(v == "true")
return nil
default:
return fmt.Errorf("%w from a value of type %T", ErrCannotSetBoolOption, value)
}
}
// setOptionInt sets an int option
func (b *Factory) setOptionInt(field reflect.Value, value any) error {
switch v := value.(type) {
case int64:
field.SetInt(v)
return nil
case int32:
field.SetInt(int64(v))
return nil
case int16:
field.SetInt(int64(v))
return nil
case int8:
field.SetInt(int64(v))
return nil
case int:
field.SetInt(int64(v))
return nil
case string:
number, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("%w: %s", ErrCannotSetIntegerOption, err.Error())
}
field.SetInt(number)
return nil
default:
return fmt.Errorf("%w from a value of type %T", ErrCannotSetIntegerOption, value)
}
}
// setOptionString sets a string option
func (b *Factory) setOptionString(field reflect.Value, value any) error {
switch v := value.(type) {
case string:
field.SetString(v)
return nil
default:
return fmt.Errorf("%w from a value of type %T", ErrCannotSetStringOption, value)
}
}
// SetOptionAny sets an option given any value.
func (b *Factory) SetOptionAny(key string, value any) error {
field, err := b.fieldbyname(b.config, key)
if err != nil {
return err
}
switch field.Kind() {
case reflect.Int64:
return b.setOptionInt(field, value)
case reflect.Bool:
return b.setOptionBool(field, value)
case reflect.String:
return b.setOptionString(field, value)
default:
return fmt.Errorf("%w: %T", ErrUnsupportedOptionType, value)
}
}
// SetOptionsAny calls SetOptionAny for each entry inside [options].
func (b *Factory) SetOptionsAny(options map[string]any) error {
for key, value := range options {
if err := b.SetOptionAny(key, value); err != nil {
return err
}
}
return nil
}
// fieldbyname return v's field whose name is equal to the given key.
func (b *Factory) fieldbyname(v interface{}, key string) (reflect.Value, error) {
// See https://stackoverflow.com/a/6396678/4354461
ptrinfo := reflect.ValueOf(v)
if ptrinfo.Kind() != reflect.Ptr {
return reflect.Value{}, fmt.Errorf("%w but a %T", ErrConfigIsNotAStructPointer, v)
}
structinfo := ptrinfo.Elem()
if structinfo.Kind() != reflect.Struct {
return reflect.Value{}, fmt.Errorf("%w but a %T", ErrConfigIsNotAStructPointer, v)
}
field := structinfo.FieldByName(key)
if !field.IsValid() || !field.CanSet() {
return reflect.Value{}, fmt.Errorf("%w: %s", ErrNoSuchField, key)
}
return field, nil
}
// NewExperimentMeasurer creates the experiment
func (b *Factory) NewExperimentMeasurer() model.ExperimentMeasurer {
return b.build(b.config)
}
// CanonicalizeExperimentName allows code to provide experiment names
// in a more flexible way, where we have aliases.
//
// Because we allow for uppercase experiment names for backwards
// compatibility with MK, we need to add some exceptions here when
// mapping (e.g., DNSCheck => dnscheck).
func CanonicalizeExperimentName(name string) string {
switch name = strcase.ToSnake(name); name {
case "ndt_7":
name = "ndt" // since 2020-03-18, we use ndt7 to implement ndt by default
case "dns_check":
name = "dnscheck"
case "stun_reachability":
name = "stunreachability"
default:
}
return name
}
// NewFactory creates a new Factory instance.
func NewFactory(name string) (*Factory, error) {
factory := allexperiments[CanonicalizeExperimentName(name)]
if factory == nil {
return nil, fmt.Errorf("no such experiment: %s", name)
}
return factory, nil
}

View File

@ -0,0 +1,347 @@
package registry
import (
"errors"
"testing"
"github.com/google/go-cmp/cmp"
)
type fakeExperimentConfig struct {
Chan chan any `ooni:"we cannot set this"`
String string `ooni:"a string"`
Truth bool `ooni:"something that no-one knows"`
Value int64 `ooni:"a number"`
}
func TestExperimentBuilderOptions(t *testing.T) {
t.Run("when config is not a pointer", func(t *testing.T) {
b := &Factory{
config: 17,
}
options, err := b.Options()
if !errors.Is(err, ErrConfigIsNotAStructPointer) {
t.Fatal("expected an error here")
}
if options != nil {
t.Fatal("expected nil here")
}
})
t.Run("when config is not a struct", func(t *testing.T) {
number := 17
b := &Factory{
config: &number,
}
options, err := b.Options()
if !errors.Is(err, ErrConfigIsNotAStructPointer) {
t.Fatal("expected an error here")
}
if options != nil {
t.Fatal("expected nil here")
}
})
t.Run("when config is a pointer to struct", func(t *testing.T) {
config := &fakeExperimentConfig{}
b := &Factory{
config: config,
}
options, err := b.Options()
if err != nil {
t.Fatal(err)
}
for name, value := range options {
switch name {
case "Chan":
if value.Doc != "we cannot set this" {
t.Fatal("invalid doc")
}
if value.Type != "chan interface {}" {
t.Fatal("invalid type", value.Type)
}
case "String":
if value.Doc != "a string" {
t.Fatal("invalid doc")
}
if value.Type != "string" {
t.Fatal("invalid type", value.Type)
}
case "Truth":
if value.Doc != "something that no-one knows" {
t.Fatal("invalid doc")
}
if value.Type != "bool" {
t.Fatal("invalid type", value.Type)
}
case "Value":
if value.Doc != "a number" {
t.Fatal("invalid doc")
}
if value.Type != "int64" {
t.Fatal("invalid type", value.Type)
}
default:
t.Fatal("unknown name", name)
}
}
})
}
func TestExperimentBuilderSetOptionAny(t *testing.T) {
var inputs = []struct {
TestCaseName string
InitialConfig any
FieldName string
FieldValue any
ExpectErr error
ExpectConfig any
}{{
TestCaseName: "config is not a pointer",
InitialConfig: fakeExperimentConfig{},
FieldName: "Antani",
FieldValue: true,
ExpectErr: ErrConfigIsNotAStructPointer,
ExpectConfig: fakeExperimentConfig{},
}, {
TestCaseName: "config is not a pointer to struct",
InitialConfig: func() *int {
v := 17
return &v
}(),
FieldName: "Antani",
FieldValue: true,
ExpectErr: ErrConfigIsNotAStructPointer,
ExpectConfig: func() *int {
v := 17
return &v
}(),
}, {
TestCaseName: "for missing field",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Antani",
FieldValue: true,
ExpectErr: ErrNoSuchField,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "[bool] for true value represented as string",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Truth",
FieldValue: "true",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Truth: true,
},
}, {
TestCaseName: "[bool] for false value represented as string",
InitialConfig: &fakeExperimentConfig{
Truth: true,
},
FieldName: "Truth",
FieldValue: "false",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Truth: false, // must have been flipped
},
}, {
TestCaseName: "[bool] for true value",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Truth",
FieldValue: true,
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Truth: true,
},
}, {
TestCaseName: "[bool] for false value",
InitialConfig: &fakeExperimentConfig{
Truth: true,
},
FieldName: "Truth",
FieldValue: false,
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Truth: false, // must have been flipped
},
}, {
TestCaseName: "[bool] for invalid string representation of bool",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Truth",
FieldValue: "xxx",
ExpectErr: ErrInvalidStringRepresentationOfBool,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "[bool] for value we don't know how to convert to bool",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Truth",
FieldValue: make(chan any),
ExpectErr: ErrCannotSetBoolOption,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "[int] for int",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: 17,
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for int64",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: int64(17),
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for int32",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: int32(17),
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for int16",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: int16(17),
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for int8",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: int8(17),
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for string representation of int",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: "17",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
Value: 17,
},
}, {
TestCaseName: "[int] for invalid string representation of int",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: "xx",
ExpectErr: ErrCannotSetIntegerOption,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "[int] for type we don't know how to convert to int",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Value",
FieldValue: make(chan any),
ExpectErr: ErrCannotSetIntegerOption,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "[string] for serialized bool value while setting a string value",
InitialConfig: &fakeExperimentConfig{},
FieldName: "String",
FieldValue: "true",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
String: "true",
},
}, {
TestCaseName: "[string] for serialized int value while setting a string value",
InitialConfig: &fakeExperimentConfig{},
FieldName: "String",
FieldValue: "155",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
String: "155",
},
}, {
TestCaseName: "[string] for any other string",
InitialConfig: &fakeExperimentConfig{},
FieldName: "String",
FieldValue: "xxx",
ExpectErr: nil,
ExpectConfig: &fakeExperimentConfig{
String: "xxx",
},
}, {
TestCaseName: "[string] for type we don't know how to convert to string",
InitialConfig: &fakeExperimentConfig{},
FieldName: "String",
FieldValue: make(chan any),
ExpectErr: ErrCannotSetStringOption,
ExpectConfig: &fakeExperimentConfig{},
}, {
TestCaseName: "for a field that we don't know how to set",
InitialConfig: &fakeExperimentConfig{},
FieldName: "Chan",
FieldValue: make(chan any),
ExpectErr: ErrUnsupportedOptionType,
ExpectConfig: &fakeExperimentConfig{},
}}
for _, input := range inputs {
t.Run(input.TestCaseName, func(t *testing.T) {
ec := input.InitialConfig
b := &Factory{config: ec}
err := b.SetOptionAny(input.FieldName, input.FieldValue)
if !errors.Is(err, input.ExpectErr) {
t.Fatal(err)
}
if diff := cmp.Diff(input.ExpectConfig, ec); diff != "" {
t.Fatal(diff)
}
})
}
}
func TestExperimentBuilderSetOptionsAny(t *testing.T) {
b := &Factory{config: &fakeExperimentConfig{}}
t.Run("we correctly handle an empty map", func(t *testing.T) {
if err := b.SetOptionsAny(nil); err != nil {
t.Fatal(err)
}
})
t.Run("we correctly handle a map containing options", func(t *testing.T) {
f := &fakeExperimentConfig{}
privateb := &Factory{config: f}
opts := map[string]any{
"String": "yoloyolo",
"Value": "174",
"Truth": "true",
}
if err := privateb.SetOptionsAny(opts); err != nil {
t.Fatal(err)
}
if f.String != "yoloyolo" {
t.Fatal("cannot set string value")
}
if f.Value != 174 {
t.Fatal("cannot set integer value")
}
if f.Truth != true {
t.Fatal("cannot set bool value")
}
})
t.Run("we handle mistakes in a map containing string options", func(t *testing.T) {
opts := map[string]any{
"String": "yoloyolo",
"Value": "xx",
"Truth": "true",
}
if err := b.SetOptionsAny(opts); !errors.Is(err, ErrCannotSetIntegerOption) {
t.Fatal("unexpected err", err)
}
})
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `fbmessenger' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/fbmessenger"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["facebook_messenger"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return fbmessenger.NewExperimentMeasurer(
*config.(*fbmessenger.Config),
)
},
config: &fbmessenger.Config{},
inputPolicy: model.InputNone,
}
}

22
internal/registry/hhfm.go Normal file
View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `hhfm' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/hhfm"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["http_header_field_manipulation"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return hhfm.NewExperimentMeasurer(
*config.(*hhfm.Config),
)
},
config: &hhfm.Config{},
inputPolicy: model.InputNone,
}
}

22
internal/registry/hirl.go Normal file
View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `hirl' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/hirl"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["http_invalid_request_line"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return hirl.NewExperimentMeasurer(
*config.(*hirl.Config),
)
},
config: &hirl.Config{},
inputPolicy: model.InputNone,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `httphostheader' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/httphostheader"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["http_host_header"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return httphostheader.NewExperimentMeasurer(
*config.(*httphostheader.Config),
)
},
config: &httphostheader.Config{},
inputPolicy: model.InputOrQueryBackend,
}
}

23
internal/registry/ndt.go Normal file
View File

@ -0,0 +1,23 @@
package registry
//
// Registers the `ndt' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/ndt7"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["ndt"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return ndt7.NewExperimentMeasurer(
*config.(*ndt7.Config),
)
},
config: &ndt7.Config{},
interruptible: true,
inputPolicy: model.InputNone,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `psiphon' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/psiphon"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["psiphon"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return psiphon.NewExperimentMeasurer(
*config.(*psiphon.Config),
)
},
config: &psiphon.Config{},
inputPolicy: model.InputOptional,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `quicping' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/quicping"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["quicping"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return quicping.NewExperimentMeasurer(
*config.(*quicping.Config),
)
},
config: &quicping.Config{},
inputPolicy: model.InputStrictlyRequired,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `riseupvpn' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/riseupvpn"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["riseupvpn"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return riseupvpn.NewExperimentMeasurer(
*config.(*riseupvpn.Config),
)
},
config: &riseupvpn.Config{},
inputPolicy: model.InputNone,
}
}

22
internal/registry/run.go Normal file
View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `run' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/run"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["run"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return run.NewExperimentMeasurer(
*config.(*run.Config),
)
},
config: &run.Config{},
inputPolicy: model.InputStrictlyRequired,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `signal' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/signal"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["signal"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return signal.NewExperimentMeasurer(
*config.(*signal.Config),
)
},
config: &signal.Config{},
inputPolicy: model.InputNone,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `simplequicping' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/simplequicping"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["simplequicping"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return simplequicping.NewExperimentMeasurer(
*config.(*simplequicping.Config),
)
},
config: &simplequicping.Config{},
inputPolicy: model.InputStrictlyRequired,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `sniblocking' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/sniblocking"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["sni_blocking"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return sniblocking.NewExperimentMeasurer(
*config.(*sniblocking.Config),
)
},
config: &sniblocking.Config{},
inputPolicy: model.InputOrQueryBackend,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `stunreachability' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/stunreachability"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["stunreachability"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return stunreachability.NewExperimentMeasurer(
*config.(*stunreachability.Config),
)
},
config: &stunreachability.Config{},
inputPolicy: model.InputOrStaticDefault,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `tcpping' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/tcpping"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["tcpping"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return tcpping.NewExperimentMeasurer(
*config.(*tcpping.Config),
)
},
config: &tcpping.Config{},
inputPolicy: model.InputStrictlyRequired,
}
}

View File

@ -0,0 +1,23 @@
package registry
//
// Registers the `telegram' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/telegram"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["telegram"] = &Factory{
build: func(config any) model.ExperimentMeasurer {
return telegram.NewExperimentMeasurer(
config.(telegram.Config),
)
},
config: &telegram.Config{},
interruptible: false,
inputPolicy: model.InputNone,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `tlsping' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/tlsping"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["tlsping"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return tlsping.NewExperimentMeasurer(
*config.(*tlsping.Config),
)
},
config: &tlsping.Config{},
inputPolicy: model.InputStrictlyRequired,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `tlstool' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/tlstool"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["tlstool"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return tlstool.NewExperimentMeasurer(
*config.(*tlstool.Config),
)
},
config: &tlstool.Config{},
inputPolicy: model.InputOrQueryBackend,
}
}

22
internal/registry/tor.go Normal file
View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `tor' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/tor"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["tor"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return tor.NewExperimentMeasurer(
*config.(*tor.Config),
)
},
config: &tor.Config{},
inputPolicy: model.InputNone,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `torsf' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/torsf"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["torsf"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return torsf.NewExperimentMeasurer(
*config.(*torsf.Config),
)
},
config: &torsf.Config{},
inputPolicy: model.InputNone,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `urlgetter' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["urlgetter"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return urlgetter.NewExperimentMeasurer(
*config.(*urlgetter.Config),
)
},
config: &urlgetter.Config{},
inputPolicy: model.InputStrictlyRequired,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `vanilla_tor' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/vanillator"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["vanilla_tor"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return vanillator.NewExperimentMeasurer(
*config.(*vanillator.Config),
)
},
config: &vanillator.Config{},
inputPolicy: model.InputNone,
}
}

View File

@ -0,0 +1,23 @@
package registry
//
// Registers the `web_connectivity' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/webconnectivity"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["web_connectivity"] = &Factory{
build: func(config any) model.ExperimentMeasurer {
return webconnectivity.NewExperimentMeasurer(
config.(webconnectivity.Config),
)
},
config: &webconnectivity.Config{},
interruptible: false,
inputPolicy: model.InputOrQueryBackend,
}
}

View File

@ -0,0 +1,22 @@
package registry
//
// Registers the `whatsapp' experiment.
//
import (
"github.com/ooni/probe-cli/v3/internal/engine/experiment/whatsapp"
"github.com/ooni/probe-cli/v3/internal/model"
)
func init() {
allexperiments["whatsapp"] = &Factory{
build: func(config interface{}) model.ExperimentMeasurer {
return whatsapp.NewExperimentMeasurer(
*config.(*whatsapp.Config),
)
},
config: &whatsapp.Config{},
inputPolicy: model.InputNone,
}
}

View File

@ -3,7 +3,6 @@ package oonimkall
import (
"context"
"github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/model"
)
@ -67,7 +66,7 @@ type experimentBuilder interface {
// experimentBuilderWrapper wraps *ExperimentBuilder
type experimentBuilderWrapper struct {
eb engine.ExperimentBuilder
eb model.ExperimentBuilder
}
// newExperiment implements experimentBuilder.newExperiment

View File

@ -87,7 +87,7 @@ type MockableTaskRunnerDependencies struct {
// taskExperimentBuilder:
MockableSetCallbacks func(callbacks model.ExperimentCallbacks)
MockableInputPolicy func() engine.InputPolicy
MockableInputPolicy func() model.InputPolicy
MockableNewExperimentInstance func() taskExperiment
MockableInterruptible func() bool
@ -169,7 +169,7 @@ func (dep *MockableTaskRunnerDependencies) SetCallbacks(callbacks model.Experime
dep.MockableSetCallbacks(callbacks)
}
func (dep *MockableTaskRunnerDependencies) InputPolicy() engine.InputPolicy {
func (dep *MockableTaskRunnerDependencies) InputPolicy() model.InputPolicy {
return dep.MockableInputPolicy()
}

View File

@ -227,7 +227,7 @@ type taskExperimentBuilder interface {
SetCallbacks(callbacks model.ExperimentCallbacks)
// InputPolicy returns the experiment's input policy.
InputPolicy() engine.InputPolicy
InputPolicy() model.InputPolicy
// NewExperiment creates the new experiment.
NewExperimentInstance() taskExperiment

View File

@ -184,12 +184,12 @@ func (r *runnerForTask) Run(rootCtx context.Context) {
// In fact, our current app assumes that it's its
// responsibility to load the inputs, not oonimkall's.
switch builder.InputPolicy() {
case engine.InputOrQueryBackend, engine.InputStrictlyRequired:
case model.InputOrQueryBackend, model.InputStrictlyRequired:
if len(r.settings.Inputs) <= 0 {
r.emitter.EmitFailureStartup("no input provided")
return
}
case engine.InputOrStaticDefault:
case model.InputOrStaticDefault:
if len(r.settings.Inputs) <= 0 {
inputs, err := engine.StaticBareInputForExperiment(r.settings.Name)
if err != nil {
@ -198,7 +198,7 @@ func (r *runnerForTask) Run(rootCtx context.Context) {
}
r.settings.Inputs = inputs
}
case engine.InputOptional:
case model.InputOptional:
if len(r.settings.Inputs) <= 0 {
r.settings.Inputs = append(r.settings.Inputs, "")
}
@ -240,7 +240,7 @@ func (r *runnerForTask) Run(rootCtx context.Context) {
// this policy in the future, but for now this covers in a
// reasonable way web connectivity, so we should be ok.
switch builder.InputPolicy() {
case engine.InputOrQueryBackend, engine.InputStrictlyRequired:
case model.InputOrQueryBackend, model.InputStrictlyRequired:
var (
cancelMeas context.CancelFunc
cancelSubmit context.CancelFunc

View File

@ -204,8 +204,8 @@ func TestTaskRunnerRun(t *testing.T) {
},
MockableSetCallbacks: func(callbacks model.ExperimentCallbacks) {
},
MockableInputPolicy: func() engine.InputPolicy {
return engine.InputNone
MockableInputPolicy: func() model.InputPolicy {
return model.InputNone
},
MockableInterruptible: func() bool {
return false
@ -310,8 +310,8 @@ func TestTaskRunnerRun(t *testing.T) {
t.Run("with missing input and InputOrQueryBackend policy", func(t *testing.T) {
runner, emitter := newRunnerForTesting()
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputOrQueryBackend
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputOrQueryBackend
}
runner.sessionBuilder = fake
events := runAndCollect(runner, emitter)
@ -331,8 +331,8 @@ func TestTaskRunnerRun(t *testing.T) {
t.Run("with missing input and InputStrictlyRequired policy", func(t *testing.T) {
runner, emitter := newRunnerForTesting()
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputStrictlyRequired
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputStrictlyRequired
}
runner.sessionBuilder = fake
events := runAndCollect(runner, emitter)
@ -355,8 +355,8 @@ func TestTaskRunnerRun(t *testing.T) {
runner, emitter := newRunnerForTesting()
runner.settings.Name = "Antani" // no input for this experiment
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputOrStaticDefault
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputOrStaticDefault
}
runner.sessionBuilder = fake
events := runAndCollect(runner, emitter)
@ -377,8 +377,8 @@ func TestTaskRunnerRun(t *testing.T) {
runner, emitter := newRunnerForTesting()
runner.settings.Inputs = append(runner.settings.Inputs, "https://x.org/")
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputNone
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputNone
}
runner.sessionBuilder = fake
events := runAndCollect(runner, emitter)
@ -419,8 +419,8 @@ func TestTaskRunnerRun(t *testing.T) {
t.Run("with success and InputNone policy", func(t *testing.T) {
runner, emitter := newRunnerForTesting()
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputNone
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputNone
}
runner.sessionBuilder = fake
events := runAndCollect(runner, emitter)
@ -445,8 +445,8 @@ func TestTaskRunnerRun(t *testing.T) {
t.Run("with measurement failure and InputNone policy", func(t *testing.T) {
runner, emitter := newRunnerForTesting()
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputNone
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputNone
}
fake.MockableMeasureWithContext = func(ctx context.Context, input string) (measurement *model.Measurement, err error) {
return nil, errors.New("preconditions error")
@ -475,8 +475,8 @@ func TestTaskRunnerRun(t *testing.T) {
// which is what was happening in the above referenced issue.
runner, emitter := newRunnerForTesting()
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputNone
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputNone
}
fake.MockableMeasureWithContext = func(ctx context.Context, input string) (measurement *model.Measurement, err error) {
return nil, errors.New("preconditions error")
@ -506,8 +506,8 @@ func TestTaskRunnerRun(t *testing.T) {
runner, emitter := newRunnerForTesting()
runner.settings.Inputs = []string{"a", "b", "c", "d"}
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputStrictlyRequired
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputStrictlyRequired
}
runner.sessionBuilder = fake
events := runAndCollect(runner, emitter)
@ -554,8 +554,8 @@ func TestTaskRunnerRun(t *testing.T) {
runner, emitter := newRunnerForTesting()
runner.settings.Inputs = []string{"a", "b", "c", "d"}
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputOptional
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputOptional
}
runner.sessionBuilder = fake
events := runAndCollect(runner, emitter)
@ -601,8 +601,8 @@ func TestTaskRunnerRun(t *testing.T) {
t.Run("with success and InputOptional and no input", func(t *testing.T) {
runner, emitter := newRunnerForTesting()
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputOptional
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputOptional
}
runner.sessionBuilder = fake
events := runAndCollect(runner, emitter)
@ -631,8 +631,8 @@ func TestTaskRunnerRun(t *testing.T) {
runner, emitter := newRunnerForTesting()
runner.settings.Name = experimentName
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputOrStaticDefault
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputOrStaticDefault
}
runner.sessionBuilder = fake
events := runAndCollect(runner, emitter)
@ -667,8 +667,8 @@ func TestTaskRunnerRun(t *testing.T) {
runner.settings.Inputs = []string{"a", "b", "c", "d"}
runner.settings.Options.MaxRuntime = 2
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputStrictlyRequired
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputStrictlyRequired
}
fake.MockableMeasureWithContext = func(ctx context.Context, input string) (measurement *model.Measurement, err error) {
time.Sleep(1 * time.Second)
@ -708,8 +708,8 @@ func TestTaskRunnerRun(t *testing.T) {
runner.settings.Inputs = []string{"a", "b", "c", "d"}
runner.settings.Options.MaxRuntime = 2
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputStrictlyRequired
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputStrictlyRequired
}
fake.MockableInterruptible = func() bool {
return true
@ -743,8 +743,8 @@ func TestTaskRunnerRun(t *testing.T) {
runner, emitter := newRunnerForTesting()
runner.settings.Inputs = []string{"a"}
fake := fakeSuccessfulRun()
fake.MockableInputPolicy = func() engine.InputPolicy {
return engine.InputStrictlyRequired
fake.MockableInputPolicy = func() model.InputPolicy {
return model.InputStrictlyRequired
}
fake.MockableSubmitAndUpdateMeasurementContext = func(ctx context.Context, measurement *model.Measurement) error {
return errors.New("cannot submit")

View File

@ -59,7 +59,7 @@ func (sess *taskSessionEngine) NewExperimentBuilderByName(
// taskExperimentBuilderEngine wraps ./internal/engine's
// ExperimentBuilder type.
type taskExperimentBuilderEngine struct {
engine.ExperimentBuilder
model.ExperimentBuilder
}
var _ taskExperimentBuilder = &taskExperimentBuilderEngine{}
@ -72,7 +72,7 @@ func (b *taskExperimentBuilderEngine) NewExperimentInstance() taskExperiment {
// taskExperimentEngine wraps ./internal/engine's Experiment.
type taskExperimentEngine struct {
engine.Experiment
model.Experiment
}
var _ taskExperiment = &taskExperimentEngine{}