From 4520d5cf9385a3a7c18037e0c3dba95ad1874b35 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 18 Jul 2026 23:26:47 +0300 Subject: [PATCH] feat(web): pluggable alternative-view registry for repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repository can now expose specialized, read-only "views" chosen by the shape of its tables, while the generic table browser stays available as a fallback. A View fingerprints the tables (Applies), builds a model from the browse layer (Build), and renders its own template; views register at init time via RegisterView and are dispatched by slug at /~user/db/view/{slug}. The overview shows a tab per applicable view. The template loader parses each registered view's template with the shared chrome, so a new view plugs in by adding two files (its .go with init()+RegisterView and its .html) plus nothing else — no edits to the registry, router, loader, or handlers. Build receives the request query values so a view can offer sub-modes (e.g. a detail pane). No SQL engine is involved; views read through the existing BrowseSession surface. --- web/handlers_repo.go | 11 ++- web/handlers_view.go | 95 +++++++++++++++++++ web/router.go | 33 +++++-- web/templates.go | 44 +++++++++ web/templates/overview.html | 4 + web/templates/partials.html | 24 +++++ web/views.go | 63 +++++++++++++ web/views_test.go | 179 ++++++++++++++++++++++++++++++++++++ web/web_test.go | 27 ++++-- 9 files changed, 466 insertions(+), 14 deletions(-) create mode 100644 web/handlers_view.go create mode 100644 web/views.go create mode 100644 web/views_test.go diff --git a/web/handlers_repo.go b/web/handlers_repo.go index 201853870d3d077f8ac5eed1ed817f7f947b1a38..d27197b1ff452b76d46f783585e4847fcab107e4 100644 --- a/web/handlers_repo.go +++ b/web/handlers_repo.go @@ -5,8 +5,8 @@ import ( "net/http" "strings" - "sourcecraft.dev/bigbes/sr-ht-core/config" "github.com/go-chi/chi/v5" + "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" "sourcecraft.dev/bigbes/sr-ht-dolt/core" @@ -177,6 +177,7 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) { branches []browse.Branch defBr string commits []browse.CommitInfo + views []View browseErr string ) if sess, err := a.cfg.Browse.Open(r.Context(), repo.Path); err == nil { @@ -190,6 +191,12 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) { } else { browseErr = err.Error() } + // Fingerprint the tables at the default branch to compute the + // optional alternative-view tabs. A browse failure here must not + // break the overview: on error we simply yield no view tabs. + if tables, err := sess.Tables(r.Context(), defBr); err == nil { + views = applicableViews(a.views, tables) + } } } else { browseErr = err.Error() @@ -204,6 +211,7 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) { Branches []browse.Branch DefaultBranch string Commits []browse.CommitInfo + Views []View CloneURL string BrowseError string }{ @@ -212,6 +220,7 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) { Branches: branches, DefaultBranch: defBr, Commits: commits, + Views: views, CloneURL: a.cloneURL(r, repo), BrowseError: browseErr, } diff --git a/web/handlers_view.go b/web/handlers_view.go new file mode 100644 index 0000000000000000000000000000000000000000..edf8f000e0712ea37ee580db1aeba316e0897ed4 --- /dev/null +++ b/web/handlers_view.go @@ -0,0 +1,95 @@ +package web + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" + "sourcecraft.dev/bigbes/sr-ht-dolt/core" +) + +// handleView renders a specialized alternative view of a repository. The view +// is selected by the {view} URL slug and must both exist in a.views and Apply +// to the tables at the resolved ref; otherwise the request is a 404 (the view +// simply does not exist for this repo). The generic table browser remains +// reachable via the tree/table routes regardless. +// +// The ref is taken from ?ref= (default: the repo's default branch). The view's +// own Template() is rendered with a fixed envelope: the chrome, the repo, the +// ref and branch list, the applicable view tabs, and the opaque value the view +// produced from Build (as .Data). +func (a *app) handleView(w http.ResponseWriter, r *http.Request) { + repo, _, _, ok := a.loadRepoForBrowse(w, r) + if !ok { + return + } + sess, ok := a.openBrowse(w, r, repo) + if !ok { + return + } + defer sess.Close() + + ref := r.URL.Query().Get("ref") + if ref == "" { + branches, err := sess.Branches(r.Context()) + if err != nil { + http.Error(w, "failed to list branches", http.StatusInternalServerError) + return + } + ref = browse.DefaultBranch(branches) + } + + tables, err := sess.Tables(r.Context(), ref) + if err != nil { + if errors.Is(err, browse.ErrRefNotFound) { + a.notFound(w, r) + return + } + http.Error(w, "failed to read tables", http.StatusInternalServerError) + return + } + + // Find the requested view; an unknown slug or a view that does not fingerprint + // these tables is reported as not found — the view does not exist here. + slug := chi.URLParam(r, "view") + var view View + for _, v := range a.views { + if v.Name() == slug { + view = v + break + } + } + if view == nil || !view.Applies(tables) { + a.notFound(w, r) + return + } + + data, err := view.Build(r.Context(), sess, repo, ref, r.URL.Query()) + if err != nil { + http.Error(w, "failed to build view", http.StatusInternalServerError) + return + } + + // Re-list branches for the chrome/tab bar. Cheap and keeps the ref selector + // consistent whether or not ?ref= was supplied. + branches, _ := sess.Branches(r.Context()) + + page := struct { + basePage + Repo *core.Repo + Ref string + Branches []browse.Branch + Views []View + Data any + }{ + basePage: a.newBasePage(r, view.Label()+" — "+repo.OwnerName+"/"+repo.Name), + Repo: repo, + Ref: ref, + Branches: branches, + Views: applicableViews(a.views, tables), + Data: data, + } + a.render(w, http.StatusOK, view.Template(), page) +} diff --git a/web/router.go b/web/router.go index fd23ed006837e418e30546dfeb106c1569a76613..3e7833148d5d928e3414ba764670329b7c16285a 100644 --- a/web/router.go +++ b/web/router.go @@ -16,6 +16,9 @@ type app struct { cfg Config templates templateSet styleHref string + // views is a snapshot of the global registeredViews taken at Register time. + // Handlers read this (never the global) so tests can inject their own set. + views []View } // Register mounts every dolt.sr.ht web route onto r. The caller (the Phase-3 @@ -26,22 +29,41 @@ type app struct { // so a broken template fails startup loudly rather than a request later. A // parse failure returns an error the caller must surface. func Register(r chi.Router, cfg Config) error { + a, err := newApp(cfg) + if err != nil { + return err + } + a.mount(r) + return nil +} + +// newApp validates cfg, parses templates and snapshots the view registry into a +// ready *app. Register uses it; tests build an *app directly so they can inspect +// and override its fields (e.g. app.views) before mounting. +func newApp(cfg Config) (*app, error) { if cfg.Repos == nil || cfg.Stores == nil || cfg.Browse == nil || cfg.Users == nil || cfg.RepoDiskPath == nil { - return fmt.Errorf("web: Register requires Repos, Stores, Browse, Users and RepoDiskPath") + return nil, fmt.Errorf("web: Register requires Repos, Stores, Browse, Users and RepoDiskPath") } templates, err := loadTemplates() if err != nil { - return err + return nil, err } - a := &app{ + return &app{ cfg: cfg, templates: templates, styleHref: discoverStyleHref(cfg.StaticDir), - } + // Snapshot the registry so all handlers see a stable set and tests can + // override it per-app without mutating the global. + views: append([]View{}, registeredViews...), + }, nil +} +// mount installs every dolt.sr.ht web route onto r. Split from Register so tests +// can mount an *app they retain a handle to. +func (a *app) mount(r chi.Router) { r.Get("/", a.handleIndex) r.Get("/create", a.handleCreateForm) r.Post("/create", a.handleCreate) @@ -55,12 +77,11 @@ func Register(r chi.Router, cfg Config) error { r.Get("/~{user}/{db}/commit/{hash}", a.handleCommit) r.Get("/~{user}/{db}/tree/{ref}", a.handleTree) r.Get("/~{user}/{db}/table/{ref}/{table}", a.handleTable) + r.Get("/~{user}/{db}/view/{view}", a.handleView) r.Get("/~{user}/{db}/settings", a.handleSettings) r.Post("/~{user}/{db}/settings", a.handleSettingsPost) r.Handle("/static/*", httpStaticHandler(a.cfg.StaticDir)) - - return nil } // --- shared response helpers ------------------------------------------------- diff --git a/web/templates.go b/web/templates.go index 8c0780b246529bbf73fe1aabb5f9f31e140450b1..12c254456adae7a0c52ab0736a5f6f258601b178 100644 --- a/web/templates.go +++ b/web/templates.go @@ -64,6 +64,27 @@ func loadTemplates() (templateSet, error) { } set[page] = t } + + // Parse the page template of every registered view the same way, so a + // concrete view (e.g. the future "beads" view) becomes renderable by adding + // only its own web/beads.go + templates/beads.html — the embed.FS glob picks + // the new file up at compile time and this loop parses it, with no edit to + // this loader. The registry is fully populated by the init()s that ran before + // Register called us. A view whose Template() is already a known page (which + // a test may deliberately reuse, e.g. "tree.html") is skipped rather than + // re-parsed, so registration never clobbers a page template. + for _, v := range registeredViews { + name := v.Template() + if _, ok := set[name]; ok { + continue + } + t := template.New("layout").Funcs(funcs) + files := append(append([]string{}, sharedTemplates...), "templates/"+name) + if _, err := t.ParseFS(templateFS, files...); err != nil { + return nil, fmt.Errorf("web: parse view template %s: %w", name, err) + } + set[name] = t + } return set, nil } @@ -113,7 +134,30 @@ func templateFuncs(icons map[string]template.HTML) template.FuncMap { // doltHost derives the host:port a `dolt login --auth-endpoint` expects // from our origin URL (defaulting to :443 for https). "doltHost": doltHost, + // dict builds a map from alternating key/value args, so a partial that + // needs several fields (e.g. the "viewtabs" tab bar) can be invoked with + // an inline context: {{template "viewtabs" (dict "Repo" .Repo ...)}}. + "dict": dict, + } +} + +// dict builds a map[string]any from alternating key/value arguments. It powers +// multi-field partial invocations from templates, which otherwise can pass only +// a single pipeline value. An odd argument count or a non-string key is a +// template authoring error and surfaces as a render error. +func dict(kv ...any) (map[string]any, error) { + if len(kv)%2 != 0 { + return nil, fmt.Errorf("dict: expected an even number of arguments, got %d", len(kv)) + } + m := make(map[string]any, len(kv)/2) + for i := 0; i < len(kv); i += 2 { + k, ok := kv[i].(string) + if !ok { + return nil, fmt.Errorf("dict: key %d is not a string", i) + } + m[k] = kv[i+1] } + return m, nil } // doltHost renders the host:port for `dolt login --auth-endpoint` from an origin diff --git a/web/templates/overview.html b/web/templates/overview.html index 50798ead4ef8835b9d4367b71ac7db7f678810ce..741da4f4d4fdafc6022c13f48f0ef46814dff1d0 100644 --- a/web/templates/overview.html +++ b/web/templates/overview.html @@ -8,6 +8,10 @@

{{.Repo.Description}}

{{end}} +{{if .Views}} +{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "" "Ref" .DefaultBranch)}} +{{end}} +

Clone

With a meta.sr.ht personal access token (Basic auth):

diff --git a/web/templates/partials.html b/web/templates/partials.html index 4620caad29d7d1097625ac600516298225a1fe97..b47dc14d5e922c879cbc0f3e3fa0543755fbbd87 100644 --- a/web/templates/partials.html +++ b/web/templates/partials.html @@ -8,6 +8,30 @@ {{- end}} {{- end}} +{{/* + viewtabs renders the alternative-view tab bar for a repo: one tab per + applicable View plus a "Tables" tab for the always-available generic browser. + Invoke it with a dict context: + {{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "" "Ref" .DefaultBranch)}} + .Current is the active view's slug ("" selects the Tables tab); .Ref is the + branch/ref the Tables tab links to (falls back to the overview when empty). +*/}} +{{define "viewtabs" -}} +{{$repo := .Repo}}{{$current := .Current}}{{$ref := .Ref}} + +{{- end}} + {{define "repoList" -}} {{if .}}
diff --git a/web/views.go b/web/views.go new file mode 100644 index 0000000000000000000000000000000000000000..bbce981210da037905e46bc296be570598e8610f --- /dev/null +++ b/web/views.go @@ -0,0 +1,63 @@ +package web + +import ( + "context" + "net/url" + + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" + "sourcecraft.dev/bigbes/sr-ht-dolt/core" +) + +// View is a specialized, read-only rendering of a repository whose table shape +// it recognizes. Views are registered at init time via RegisterView and chosen +// per-repo by Applies. The generic table browser is always available too, so a +// View never has to be exhaustive. +// +// A View pulls all of its data through the BrowseSession surface only (Tables, +// Rows, Branches, Log, ...); there is no SQL engine behind a bare store. Build +// returns an opaque value handed to the view's Template as its .Data field. +type View interface { + Name() string // URL slug, e.g. "beads"; must be a valid path segment, unique + Label() string // human tab label, e.g. "Beads" + Template() string // page template filename in templates/, e.g. "beads.html" + Applies(tables []browse.TableInfo) bool // fingerprint by table names/columns + // Build produces the opaque .Data value for the view's Template. query is the + // request URL's query string, so a view can offer sub-modes (e.g. beads' + // ?issue= detail pane) without a new route. + Build(ctx context.Context, sess BrowseSession, repo *core.Repo, ref string, query url.Values) (any, error) +} + +// registeredViews is the package-global registry populated at init time by +// RegisterView. It is read once per process: loadTemplates parses each view's +// Template() at startup, and Register snapshots it into app.views so handlers +// (and tests, which set app.views directly) never touch the global at request +// time. +var registeredViews []View + +// RegisterView adds v to the global registry. It is meant to be called from an +// init() in the file that defines a concrete view, so the registry is fully +// populated before loadTemplates runs and the first request arrives. If a view +// with the same Name() is already registered it is replaced, so double +// registration (e.g. from a duplicated init) is safe. +func RegisterView(v View) { + for i, existing := range registeredViews { + if existing.Name() == v.Name() { + registeredViews[i] = v + return + } + } + registeredViews = append(registeredViews, v) +} + +// applicableViews returns the subset of views whose Applies reports true for +// tables, preserving registration order. It is pure: handlers pass their own +// snapshot (app.views) so the result is deterministic and testable. +func applicableViews(views []View, tables []browse.TableInfo) []View { + out := make([]View, 0, len(views)) + for _, v := range views { + if v.Applies(tables) { + out = append(out, v) + } + } + return out +} diff --git a/web/views_test.go b/web/views_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f2dafbdc7ce48c8b1c1c4d79063a7e55292908af --- /dev/null +++ b/web/views_test.go @@ -0,0 +1,179 @@ +package web + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" + "sourcecraft.dev/bigbes/sr-ht-dolt/core" +) + +// dummyView is a test-only View that fingerprints repos containing a table +// named "issues". It renders through an EXISTING page template ("404.html") so +// the test needs no newly embedded file. That template references only the +// shared chrome (basePage), so it renders cleanly with the fixed view envelope +// (which does not carry a .Tables field the way tree.html would demand). It +// records whether Build ran. +type dummyView struct { + built bool +} + +func (d *dummyView) Name() string { return "issues" } +func (d *dummyView) Label() string { return "Issues" } +func (d *dummyView) Template() string { return "404.html" } + +func (d *dummyView) Applies(tables []browse.TableInfo) bool { + for _, t := range tables { + if t.Name == "issues" { + return true + } + } + return false +} + +func (d *dummyView) Build(_ context.Context, sess BrowseSession, _ *core.Repo, ref string, _ url.Values) (any, error) { + d.built = true + // Pull data through the BrowseSession surface only, mirroring how a real + // view works, and return the tables so tree.html renders something. + tables, err := sess.Tables(context.Background(), ref) + if err != nil { + return nil, err + } + return tables, nil +} + +func issueTables() []browse.TableInfo { + return []browse.TableInfo{ + {Name: "issues", Columns: []browse.ColumnInfo{{Name: "id", PrimaryKey: true}}, RowCount: 2}, + {Name: "labels", RowCount: 5}, + } +} + +func TestApplicableViewsFilters(t *testing.T) { + v := &dummyView{} + views := []View{v} + + got := applicableViews(views, issueTables()) + if len(got) != 1 || got[0] != v { + t.Fatalf("applicableViews should select the matching view; got %v", got) + } + + // No "issues" table → no applicable views. + none := applicableViews(views, []browse.TableInfo{{Name: "widgets"}}) + if len(none) != 0 { + t.Fatalf("applicableViews should filter out non-matching views; got %v", none) + } +} + +func TestApplicableViewsPreservesOrder(t *testing.T) { + a := &dummyView{} + // A second always-applies view to check registration order is preserved. + b := alwaysView{} + got := applicableViews([]View{b, a}, issueTables()) + if len(got) != 2 || got[0] != View(b) || got[1] != View(a) { + t.Fatalf("applicableViews should preserve order; got %v", got) + } +} + +// alwaysView is a trivial always-applicable view used to check ordering. +type alwaysView struct{} + +func (alwaysView) Name() string { return "always" } +func (alwaysView) Label() string { return "Always" } +func (alwaysView) Template() string { return "404.html" } +func (alwaysView) Applies([]browse.TableInfo) bool { return true } +func (alwaysView) Build(context.Context, BrowseSession, *core.Repo, string, url.Values) (any, error) { + return nil, nil +} + +func TestHandleViewSelectedAndBuilt(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = &fakeSession{ + branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, + tables: issueTables(), + } + + dv := &dummyView{} + // Inject the view directly on the app, without polluting the global registry. + setViews(t, h, dv) + + rec := h.do("GET", "/~alice/db/view/issues", nil, nil) + // A 200 (rather than the 404 an unknown/non-applicable view returns) proves + // the view was selected and its Template rendered; dv.built proves Build ran. + if rec.Code != http.StatusOK { + t.Fatalf("view page: got %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !dv.built { + t.Fatalf("view.Build was not called") + } + // The view's Label flows into the page via the envelope's basePage, + // so its presence confirms this view (not a fallback) produced the response. + if !strings.Contains(rec.Body.String(), "Issues") { + t.Fatalf("view page missing view label in chrome; body=%s", rec.Body.String()) + } +} + +func TestHandleViewUnknownSlugIs404(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = &fakeSession{ + branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, + tables: issueTables(), + } + setViews(t, h, &dummyView{}) + + rec := h.do("GET", "/~alice/db/view/nope", nil, nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("unknown view slug: got %d, want 404", rec.Code) + } +} + +func TestHandleViewNotApplicableIs404(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + // Tables lack "issues", so dummyView.Applies is false even though the slug matches. + h.browse.sess = &fakeSession{ + branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, + tables: []browse.TableInfo{{Name: "widgets"}}, + } + setViews(t, h, &dummyView{}) + + rec := h.do("GET", "/~alice/db/view/issues", nil, nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("non-applicable view: got %d, want 404", rec.Code) + } +} + +func TestOverviewShowsViewTabs(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = &fakeSession{ + branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, + tables: issueTables(), + } + setViews(t, h, &dummyView{}) + + rec := h.do("GET", "/~alice/db", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("overview: got %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "/~alice/db/view/issues") { + t.Fatalf("overview missing view tab link; body=%s", body) + } + if !strings.Contains(body, "nav-tabs") { + t.Fatalf("overview missing tab bar; body=%s", body) + } +} + +// setViews overrides the app's view snapshot without touching the global +// registry, so view routing/overview tabs can be exercised with a controlled +// set. The harness retains the *app the router is mounted on. +func setViews(t *testing.T, h *harness, vs ...View) { + t.Helper() + h.app.views = vs +} diff --git a/web/web_test.go b/web/web_test.go index bc4e1d0376945737740dc8435ba6642d8b67ac0c..0d1d9ea5b8b9e35f8ae36e74af5565b5a33277ed 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -12,10 +12,10 @@ import ( "testing" "time" - "sourcecraft.dev/bigbes/sr-ht-core/auth" "github.com/dolthub/dolt/go/libraries/doltcore/creds" "github.com/go-chi/chi/v5" "github.com/vaughan0/go-ini" + "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-dolt/authn" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" @@ -244,8 +244,12 @@ type fakeSession struct { commits []browse.CommitInfo tables []browse.TableInfo rows *browse.RowPage - summary *browse.CommitDiff - closed bool + // rowsByTable, when set, lets a test return a distinct page per table name + // (as the beads view needs). A named miss falls back to rows. A table absent + // from a non-nil map is reported as ErrTableNotFound, mirroring the store. + rowsByTable map[string]*browse.RowPage + summary *browse.CommitDiff + closed bool } func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil } @@ -255,7 +259,13 @@ func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.Commi func (s *fakeSession) Tables(_ context.Context, _ string) ([]browse.TableInfo, error) { return s.tables, nil } -func (s *fakeSession) Rows(_ context.Context, _, _ string, _, _ int) (*browse.RowPage, error) { +func (s *fakeSession) Rows(_ context.Context, _, table string, _, _ int) (*browse.RowPage, error) { + if s.rowsByTable != nil { + if p, ok := s.rowsByTable[table]; ok { + return p, nil + } + return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table) + } return s.rows, nil } func (s *fakeSession) CommitSummary(_ context.Context, _ string) (*browse.CommitDiff, error) { @@ -288,6 +298,7 @@ func (u *fakeUsers) LookupUser(_ context.Context, username string) (*core.Caller type harness struct { router chi.Router + app *app store *fakeStore stores *fakeStoreManager browse *fakeBrowse @@ -314,10 +325,12 @@ func newHarness(t *testing.T) *harness { }, } r := chi.NewRouter() - if err := Register(r, cfg); err != nil { - t.Fatalf("Register: %v", err) + a, err := newApp(cfg) + if err != nil { + t.Fatalf("newApp: %v", err) } - return &harness{router: r, store: store, stores: stores, browse: fb, users: users} + a.mount(r) + return &harness{router: r, app: a, store: store, stores: stores, browse: fb, users: users} } // do issues a request through the router, optionally with an authenticated