internal/frontend: allow modifying experiments by query param

If the server is serving page stats, let the user manipulate the
active experiments by query param. The query param string

     ?exp=A&exp=!B

will enable experiment A and disable experiment B.

Change-Id: I66ad04f0377cf42380e4c0be3a5513da3f89674a
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/257557
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go
index 7a3f9dc..09f250c 100644
--- a/internal/experiment/experiment.go
+++ b/internal/experiment/experiment.go
@@ -43,7 +43,7 @@
 	return &Set{set: set}
 }
 
-// NewContext stores the provided experiment set in the context.
+// NewContext stores a set constructed from the provided experiment names in the context.
 func NewContext(ctx context.Context, experimentNames ...string) context.Context {
 	return context.WithValue(ctx, contextKey{}, NewSet(experimentNames...))
 }
diff --git a/internal/frontend/details.go b/internal/frontend/details.go
index 8ef059c..cf23898 100644
--- a/internal/frontend/details.go
+++ b/internal/frontend/details.go
@@ -119,6 +119,11 @@
 		}
 	}
 	ctx := r.Context()
+	// If page statistics are enabled, use the "exp" query param to adjust
+	// the active experiments.
+	if s.serveStats {
+		ctx = setExperimentsFromQueryParam(ctx, r)
+	}
 	// Validate the fullPath and requestedVersion that were parsed.
 	if err := validatePathAndVersion(ctx, ds, urlInfo.fullPath, urlInfo.requestedVersion); err != nil {
 		return err
@@ -459,3 +464,43 @@
 		tag.Upsert(keyVersionType, v),
 	}, versionTypeResults.M(1))
 }
+
+func setExperimentsFromQueryParam(ctx context.Context, r *http.Request) context.Context {
+	if err := r.ParseForm(); err != nil {
+		log.Errorf(ctx, "ParseForm: %v", err)
+		return ctx
+	}
+	return newContextFromExps(ctx, r.Form["exp"])
+}
+
+// newContextFromExps adds and removes experiments from the context's experiment
+// set, creates a new set with the changes, and returns a context with the new
+// set. Each string in expMods can be either an experiment name, which means
+// that the experiment should be added, or "!" followed by an experiment name,
+// meaning that it should be removed.
+func newContextFromExps(ctx context.Context, expMods []string) context.Context {
+	var (
+		exps   []string
+		remove = map[string]bool{}
+	)
+	set := experiment.FromContext(ctx)
+	for _, exp := range expMods {
+		if strings.HasPrefix(exp, "!") {
+			exp = exp[1:]
+			if set.IsActive(exp) {
+				remove[exp] = true
+			}
+		} else if !set.IsActive(exp) {
+			exps = append(exps, exp)
+		}
+	}
+	if len(exps) == 0 && len(remove) == 0 {
+		return ctx
+	}
+	for _, a := range set.Active() {
+		if !remove[a] {
+			exps = append(exps, a)
+		}
+	}
+	return experiment.NewContext(ctx, exps...)
+}
diff --git a/internal/frontend/details_test.go b/internal/frontend/details_test.go
index 7256a7c..3dd62bb 100644
--- a/internal/frontend/details_test.go
+++ b/internal/frontend/details_test.go
@@ -8,10 +8,12 @@
 	"context"
 	"net/http"
 	"net/url"
+	"sort"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/experiment"
 	"golang.org/x/pkgsite/internal/stdlib"
 )
 
@@ -235,3 +237,31 @@
 type fakeDataSource struct {
 	internal.DataSource
 }
+
+func TestNewContextFromExps(t *testing.T) {
+	for _, test := range []struct {
+		mods []string
+		want []string
+	}{
+		{
+			mods: []string{"c", "a", "b"},
+			want: []string{"a", "b", "c"},
+		},
+		{
+			mods: []string{"d", "a"},
+			want: []string{"a", "b", "c", "d"},
+		},
+		{
+			mods: []string{"d", "!b", "!a", "c"},
+			want: []string{"c", "d"},
+		},
+	} {
+		ctx := experiment.NewContext(context.Background(), "a", "b", "c")
+		ctx = newContextFromExps(ctx, test.mods)
+		got := experiment.FromContext(ctx).Active()
+		sort.Strings(got)
+		if !cmp.Equal(got, test.want) {
+			t.Errorf("mods=%v:\ngot  %v\nwant %v", test.mods, got, test.want)
+		}
+	}
+}