Help us shape the future of Go! Take the 2026 Go Developer Survey

loomtest

package
v0.0.0-...-765e509 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 36 Imported by: 0

Documentation

Overview

Package loomtest tests Loom components and pages without a browser.

func TestTodoList(t *testing.T) {
	html := loomtest.Render(t, templates.TodoList, templates.TodoListAssigns{Todos: todos})
	loomtest.Snapshot(t, html)
}

Snapshots live in testdata/snapshots, named after the test. Run `go test -update` to create or update them, and review the diff.

loomtest defines the -update flag; test packages that use loomtest should not define their own.

Index

Constants

View Source
const ManifestFile = "loom.manifest"

ManifestFile is the file Manifest keeps: the app's manifest, committed next to the test that checks it.

Variables

View Source
var A11yRules = []A11yRule{
	{
		ID: "image-alt", Selector: "img",
		Fix: `add alt="..." describing the image, or alt="" if it is decoration`,
		Check: func(e *Element) string {
			if _, ok := e.attrs["alt"]; ok || isPresentational(e) {
				return ""
			}
			return "image has no alt text"
		},
	},
	{
		ID: "button-name", Selector: "button",
		Fix:   `give the button text, or aria-label="..." if it shows only an icon`,
		Check: needsName("button"),
	},
	{
		ID: "button-name", Selector: "[role=button]",
		Fix:   `give the button text, or aria-label="..." if it shows only an icon`,
		Check: needsName("button"),
	},
	{
		ID: "link-name", Selector: "a[href]",
		Fix:   `give the link text, or aria-label="..." if it shows only an icon`,
		Check: needsName("link"),
	},
	{
		ID: "label", Selector: "input",
		Fix:   `wrap the control in a <label>, point a <label for="id"> at it, or give it aria-label="..."`,
		Check: needsLabel,
	},
	{
		ID: "label", Selector: "select",
		Fix:   `wrap the control in a <label>, point a <label for="id"> at it, or give it aria-label="..."`,
		Check: needsLabel,
	},
	{
		ID: "label", Selector: "textarea",
		Fix:   `wrap the control in a <label>, point a <label for="id"> at it, or give it aria-label="..."`,
		Check: needsLabel,
	},
	{
		ID: "input-image-alt", Selector: "input[type=image]",
		Fix: `add alt="..." saying what the button does`,
		Check: func(e *Element) string {
			if strings.TrimSpace(e.attrs["alt"]) == "" && Name(e) == "" {
				return "image button has no alt text"
			}
			return ""
		},
	},
	{
		ID: "dialog-name", Selector: "[role=dialog]",
		Fix:   `give the dialog aria-label="..." or aria-labelledby="<id of its heading>"`,
		Check: needsName("dialog"),
	},
	{
		ID: "dialog-name", Selector: "[role=alertdialog]",
		Fix:   `give the dialog aria-label="..." or aria-labelledby="<id of its heading>"`,
		Check: needsName("dialog"),
	},
	{
		ID: "dialog-name", Selector: "dialog",
		Fix:   `give the dialog aria-label="..." or aria-labelledby="<id of its heading>"`,
		Check: needsName("dialog"),
	},
	{
		ID: "aria-roles", Selector: "[role]",
		Fix: "use a role from WAI-ARIA, e.g. button, dialog, listbox, option, tab, tablist, tabpanel, menu, menuitem, status, alert",
		Check: func(e *Element) string {
			for _, r := range strings.Fields(e.attrs["role"]) {
				if !ariaRoles[r] {
					return fmt.Sprintf("role %q isn't an ARIA role", r)
				}
			}
			if strings.TrimSpace(e.attrs["role"]) == "" {
				return "role is empty"
			}
			return ""
		},
	},
	{
		ID: "aria-valid-attr", Selector: "*",
		Fix: "use an aria-* attribute from WAI-ARIA, spelled in full, e.g. aria-label, aria-labelledby, aria-describedby, aria-expanded, aria-selected, aria-hidden",
		Check: func(e *Element) string {
			var bad []string
			for k := range e.attrs {
				if strings.HasPrefix(k, "aria-") && ariaAttrs[k] == "" {
					bad = append(bad, k)
				}
			}
			if len(bad) > 0 {
				sort.Strings(bad)
				return fmt.Sprintf("%s isn't an ARIA attribute", strings.Join(bad, ", "))
			}
			return ""
		},
	},
	{
		ID: "aria-valid-attr-value", Selector: "*",
		Fix: `write true or false (aria-checked and aria-pressed also take "mixed"; aria-current also page, step, location, date, time)`,
		Check: func(e *Element) string {
			var bad []string
			for k, v := range e.attrs {
				var ok bool
				switch ariaAttrs[k] {
				case "bool":
					ok = v == "true" || v == "false"
				case "tristate":
					ok = v == "true" || v == "false" || v == "mixed"
				case "current":
					ok = strings.Contains(" true false page step location date time ", " "+v+" ")
				default:
					ok = true
				}
				if !ok {
					bad = append(bad, fmt.Sprintf("%s=%q", k, v))
				}
			}
			if len(bad) > 0 {
				sort.Strings(bad)
				return "invalid value " + strings.Join(bad, ", ")
			}
			return ""
		},
	},
	{
		ID: "aria-reference", Selector: "*",
		Fix: "point the attribute at the id of an element on the page, or remove it",
		Check: func(e *Element) string {
			var bad []string
			for _, k := range []string{"aria-labelledby", "aria-describedby", "aria-controls", "aria-owns", "aria-activedescendant", "aria-errormessage"} {
				for _, id := range strings.Fields(e.attrs[k]) {
					if e.ByID(id) == nil {
						bad = append(bad, fmt.Sprintf("%s=%q", k, id))
					}
				}
			}
			if e.tag == "label" {
				if id := e.attrs["for"]; id != "" && e.ByID(id) == nil {
					bad = append(bad, fmt.Sprintf("for=%q", id))
				}
			}
			if len(bad) > 0 {
				return "no element has the id in " + strings.Join(bad, ", ")
			}
			return ""
		},
	},
	{
		ID: "aria-required-parent", Selector: "[role]",
		Fix: "put the element inside its container role: option in listbox, tab in tablist, menuitem in menu or menubar, listitem in list, row in table or grid",
		Check: func(e *Element) string {
			parents := requiredParents[e.attrs["role"]]
			if parents == nil || e.Parent() == nil {

				return ""
			}
			for a := e.Parent(); a != nil; a = a.Parent() {
				if r := a.attrs["role"]; r != "" && (parents[r] || r == "group") {
					return ""
				}
				if r := implicitRole(a); parents[r] {
					return ""
				}
			}
			var names []string
			for p := range parents {
				names = append(names, p)
			}
			sort.Strings(names)
			return fmt.Sprintf("role %s must be inside %s", e.attrs["role"], strings.Join(names, " or "))
		},
	},
	{
		ID: "html-lang", Selector: "html",
		Fix: `add lang="en" (or the page's language) to <html>`,
		Check: func(e *Element) string {
			if strings.TrimSpace(e.attrs["lang"]) == "" {
				return "the document has no language"
			}
			return ""
		},
	},
}

A11yRules are the built-in accessibility rules, checked by A11y along with duplicate-id. The IDs follow axe-core's where one matches.

View Source
var JobsEpoch = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

JobsEpoch is the fake clock's starting time in loomtest.Jobs.

View Source
var LimitEpoch = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

LimitEpoch is where LimitClock's fake clock starts.

View Source
var TimersEpoch = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)

TimersEpoch is the time a mounted page's fake clock starts at. Page timers (loom.Ctx.SendAfter) run only when Advance moves the clock.

Functions

func AssertAccessible

func AssertAccessible(t testing.TB, html string, extra ...A11yRule)

AssertAccessible fails the test with every accessibility problem in html.

func AssertNotFound

func AssertNotFound(t testing.TB, app *loom.App, path string, opts ...Option)

AssertNotFound fails the test unless the live page app routes at path calls c.NotFound in Mount, so that loading it answers 404.

loomtest.AssertNotFound(t, app, "/tracks/nothing")

func AssertPreviewsAccessible

func AssertPreviewsAccessible(t *testing.T, tmpl render.Template, extra ...A11yRule)

AssertPreviewsAccessible checks every preview variant of a component (see docs/components.md), as a subtest per variant. It fails if the component has no previews.

func Count

func Count(html, selector string) (int, error)

Count returns the number of elements in html matching a CSS selector. Supported: *, tag, #id, .class, [attr], [attr=value], and descendant combinators (spaces). Other syntax is an error.

func DB

func DB(t testing.TB, opts ...DBOption) *data.DB

DB returns a migrated Postgres database for a test.

db := loomtest.DB(t)
app := loom.New()
app.UseDB(db)

By default everything the test does runs inside one transaction, rolled back when it ends, so tests are fast and leave nothing behind; db.Tx makes a savepoint, and AfterCommit hooks run when it is released. Jobs enqueued in it go to the in-memory store, so loomtest.Jobs works (pass the DB to it with UseDB). Use the DB from one goroutine at a time; tests that need real commits or concurrency use Committed.

Postgres is found through LOOM_TEST_DATABASE_URL, whose user must be able to create databases; without it the test is skipped. Databases are built once from the migrations (named loomtest_<hash of the migrations>) and cloned from that template.

func LimitClock

func LimitClock(t testing.TB, app *loom.App) *jobs.FakeClock

LimitClock gives the app's rate limits (loom.RateLimit) a fake clock, starting at LimitEpoch, and returns it. Nothing moves it but Advance, so a test can use up a limit and then step past its window:

clock := loomtest.LimitClock(t, app)
p := loomtest.Mount(t, app, "/sign-in")
for range 5 {
	p.Event("sign_in", loom.Payload{"email": "ada@example.com", "password": "wrong"})
}
p.AssertLimited("sign_in", loom.Payload{"email": "ada@example.com", "password": "wrong"})
clock.Advance(time.Minute)
p.Event("sign_in", loom.Payload{"email": "ada@example.com", "password": "right"})

The app's limits count by real time when the test ends.

func Manifest

func Manifest(t testing.TB, app *loom.App)

Manifest fails the test if the app's behaviour no longer matches the committed loom.manifest in the test's package directory (see docs/manifest.md). `go test -update` rewrites the file; commit it with the change, so the pull request shows reviewers what the app now does: a new route, an event, a watched table, a job.

func TestManifest(t *testing.T) {
	app := loom.New()
	todo.Setup(app)
	loomtest.Manifest(t, app)
}

The manifest includes the migrations, from the nearest directory named migrations, found as DB finds it.

func Name

func Name(e *Element) string

Name returns an element's accessible name, simplified from the ARIA algorithm: aria-labelledby, then aria-label, then for a form control its <label>, then for a button, link or similar its text and images' alt text, then title.

func Render

func Render[A any](t testing.TB, c render.Component[A], assigns A) string

Render renders a component with the given assigns and returns its HTML. It fails the test if rendering panics or produces a malformed tree.

func RenderTree

func RenderTree[A any](t testing.TB, c render.Component[A], assigns A) (r *render.Rendered)

RenderTree renders a component and returns its render tree, checked for consistency: every block has one more static part than dynamics.

func Snapshot

func Snapshot(t testing.TB, got string)

Snapshot compares got with the test's snapshot file, or writes it when run with -update. A test may take several snapshots; they are numbered.

func Validate

func Validate(t testing.TB, app *loom.App)

Validate fails the test where the app would fail to start or serve stale code: it runs app.Validate, the checks app.Run makes before serving (jobs queues with no workers, watched tables that aren't tracked), and checks the generated *_hx.go files of every compiled template are what `loom build` writes now (RT421). Mount runs it, so most tests never call it; call it in a test of an app's setup:

func TestSetup(t *testing.T) {
	app := loom.New()
	todo.Setup(app)
	loomtest.Validate(t, app)
}

func ValidateTree

func ValidateTree(r *render.Rendered) error

ValidateTree checks that a render tree is well formed.

Types

type A11yProblem

type A11yProblem struct {
	Rule    string
	Element string // the element's start tag
	Message string
	Fix     string
}

A11yProblem is a rule broken by an element.

func A11y

func A11y(html string, extra ...A11yRule) ([]A11yProblem, error)

A11y checks html against the built-in rules and any extra ones, and returns the problems found, in document order per rule.

func (A11yProblem) String

func (p A11yProblem) String() string

type A11yRule

type A11yRule struct {
	ID       string // stable, e.g. "button-name"
	Selector string // which elements it checks (see Count for the syntax)
	Fix      string // how to fix a problem, shown with it
	Check    func(e *Element) string
}

A11yRule is an accessibility rule: Check runs on every element that matches Selector and returns a problem, or "" if there is none.

type Crowd

type Crowd struct {
	Pages []*Page
	// contains filtered or unexported fields
}

Crowd is many pages open on one route, as many browsers would hold them: the load a watched table or a broadcast puts on the app grows with it, and one browser in dev never shows that. A change re-renders every page, so a query in Assigns runs once per page; Crowd measures that, so a test can hold a route to a budget before production finds the cost.

c := loomtest.MountCrowd(t, app, "/", 50)
if load := c.TableChanged("tune_files"); load.Queries > 1 {
	t.Errorf("a tune_files change ran %d queries for %d pages; share the read (one per change, not per page)", load.Queries, load.Pages)
}

func MountCrowd

func MountCrowd(t testing.TB, app *loom.App, path string, n int, opts ...Option) *Crowd

MountCrowd mounts n pages at path, each as Mount does, with opts.

func (*Crowd) Info

func (c *Crowd) Info(msg loom.Message) Load

Info delivers a message to every page, as a broadcast on a topic they subscribe to would (Page.Info), and returns what that cost.

func (*Crowd) TableChanged

func (c *Crowd) TableChanged(table string) Load

TableChanged re-renders every page as the runtime does when a table the route watches changes (Page.TableChanged), and returns what that cost.

type DBOption

type DBOption func(*dbOptions)

DBOption changes how DB sets up a database.

func Committed

func Committed() DBOption

Committed gives the test a database of its own, where transactions really commit: for AfterCommit hooks, LISTEN/NOTIFY and the Postgres job store. It is a fresh copy of the migrated template, dropped when the test ends.

func Migrations

func Migrations(dir string) DBOption

Migrations names the directory of migrations the database is built from. The default is the nearest directory named migrations in the test's package directory or above it, up to the module root; with none the database starts empty.

type Element

type Element struct {
	// contains filtered or unexported fields
}

Element is an element of parsed HTML, as accessibility rules see it.

func Select

func Select(e *Element, selector string) []*Element

Select returns the elements under e (not e itself) that match a selector, for rules that look inside an element. A bad selector panics.

func (*Element) Attr

func (e *Element) Attr(name string) (string, bool)

Attr returns an attribute's value, unescaped, and whether it is set.

func (*Element) ByID

func (e *Element) ByID(id string) *Element

ByID returns the element in the same document with the given id, or nil.

func (*Element) Children

func (e *Element) Children() []*Element

Children returns the element's child elements (not text).

func (*Element) Parent

func (e *Element) Parent() *Element

Parent returns the element's parent element, or nil at the top.

func (*Element) String

func (e *Element) String() string

String returns the element's start tag, to point at it in messages.

func (*Element) Tag

func (e *Element) Tag() string

Tag returns the element's tag name, lower case.

func (*Element) Text

func (e *Element) Text() string

Text returns the element's text content, with whitespace collapsed.

type FlowTest

type FlowTest struct {
	// contains filtered or unexported fields
}

FlowTest is a test written as a user's flow through the app: steps in plain words, each with the code that does or checks it. Flow keeps the steps in testdata/flows/<test>.md, so they read as acceptance criteria a person can agree before the code exists and review in a pull request without reading Go, and prints them back as a checklist.

func Flow

func Flow(t testing.TB, title string) *FlowTest

Flow starts a flow test titled title:

f := loomtest.Flow(t, "Ann plans her day")
var p *loomtest.Page
f.Step("Ann opens her todo list", func() {
	p = loomtest.Mount(t, app, "/")
})
f.Step(`She adds "Buy milk"`, func() {
	p.Submit("form.new-todo", loom.Payload{"title": "Buy milk"})
})

When the test passes, its title and steps must match testdata/flows/<test>.md (go test -update writes it). When it fails, the checklist shows which steps passed, the one that failed, and the steps of the file it didn't reach, so a flow file written first, and a test growing step by step to match it, shows the progress.

func (*FlowTest) Checklist

func (f *FlowTest) Checklist() string

Checklist returns the flow as a checklist: ✓ passed, ✗ failed, and ○ for the steps of the committed file the test didn't reach.

func (*FlowTest) File

func (f *FlowTest) File() string

File returns the flow as testdata/flows holds it: a heading and the numbered steps.

func (*FlowTest) Step

func (f *FlowTest) Step(text string, do func())

Step runs do as the next step of the flow. The step fails if the test fails during do; a t.Fatal in it ends the flow there.

type JobQueue

type JobQueue struct {
	// contains filtered or unexported fields
}

JobQueue runs background jobs in a test: an in-memory store on a fake clock, with jobs run synchronously and deterministically by Drain.

q := loomtest.Jobs(t)
q.Jobs(SendWelcome{})                      // register job types, as app.Jobs does
signUp(q.DB(), "u1")                       // code that enqueues in q.DB()'s transactions
q.AssertEnqueued(t, SendWelcome{UserID: "u1"})
q.Advance(10 * time.Minute)                // scheduled jobs and cron ticks become due
q.Drain(t)                                 // run every due job
q.AssertCompleted(t, SendWelcome{})

Assertions match a job by type, and by every field of the given value that isn't its zero value: SendWelcome{} matches any SendWelcome.

func Jobs

func Jobs(t testing.TB, opts ...JobsOption) *JobQueue

Jobs returns a JobQueue whose clock starts at JobsEpoch, with an in-memory store unless WithStore gives another. Only cron entries added with Cron run; the built-in prune job doesn't.

func (*JobQueue) Advance

func (q *JobQueue) Advance(d time.Duration)

Advance moves the clock forward by d, enqueueing each cron tick it passes, at that tick's time. Jobs don't run until Drain.

func (*JobQueue) All

func (q *JobQueue) All() []*jobs.Record

All returns every job, newest first.

func (*JobQueue) AssertBroadcast

func (q *JobQueue) AssertBroadcast(t testing.TB, topic, event string)

AssertBroadcast fails the test unless a job published event on topic.

func (*JobQueue) AssertCompleted

func (q *JobQueue) AssertCompleted(t testing.TB, job jobs.Job)

AssertCompleted fails the test unless a matching job completed.

func (*JobQueue) AssertEnqueued

func (q *JobQueue) AssertEnqueued(t testing.TB, job jobs.Job)

AssertEnqueued fails the test unless a matching job is waiting to run: scheduled, available or retryable.

func (*JobQueue) AssertEvent

func (q *JobQueue) AssertEvent(t testing.TB, name string, attrs ...any) trace.Event

AssertEvent fails the test unless the queue's runner emitted a job event named name whose attributes include attrs, compared as text.

q.Drain(t)
q.AssertEvent(t, "job.finish", "type", "todo.Notify", "state", "completed")

func (*JobQueue) AssertState

func (q *JobQueue) AssertState(t testing.TB, job jobs.Job, state jobs.State)

AssertState fails the test unless a matching job is in state.

func (*JobQueue) Attach

func (q *JobQueue) Attach(app *loom.App)

Attach makes the queue app's job runner and PubSub, before the app's routes are added: the jobs the app and its sub-apps register (such as auth's mail job) are then the queue's, enqueues land in it, and Drain runs them.

q := loomtest.Jobs(t, loomtest.UseDB(db))
app := loom.New()
app.UseDB(db)
q.Attach(app)
app.Mount("/auth", auth.New(auth.Config{}))

func (*JobQueue) Broadcasts

func (q *JobQueue) Broadcasts() []pubsub.Message

Broadcasts returns the messages jobs have published, oldest first, leaving out the runner's own announcements on jobs.Topic.

func (*JobQueue) Clock

func (q *JobQueue) Clock() *jobs.FakeClock

Clock is the fake clock. Use Advance to move it.

func (*JobQueue) Cron

func (q *JobQueue) Cron(expr string, job jobs.Job)

Cron adds a cron entry, as app.Cron does. Advance enqueues its ticks.

func (*JobQueue) DB

func (q *JobQueue) DB() *data.DB

DB is the store's database: pass it to code that enqueues jobs, which does so in its transactions (q.DB().Tx(ctx, ...)).

func (*JobQueue) Drain

func (q *JobQueue) Drain(t testing.TB) int

Drain runs every due job, one at a time on the calling goroutine, until none is due, and returns how many attempts ran. Jobs that fail are retried after a backoff of one minute per attempt, so they run again only after Advance. Failures don't fail the test; assert the outcome.

func (*JobQueue) Enqueue

func (q *JobQueue) Enqueue(job jobs.Job, opts ...jobs.Option) int64

Enqueue enqueues a job in a transaction of its own and returns its id.

func (*JobQueue) Events

func (q *JobQueue) Events() []trace.Event

Events returns every event the queue's runner emitted, oldest first: job.start and job.finish for each attempt, cron.tick, and an error for each failure. Attached to an app (Attach), they are the app's events.

func (*JobQueue) Jobs

func (q *JobQueue) Jobs(js ...jobs.Job)

Jobs registers job types, as app.Jobs does, and gives their queues workers.

func (*JobQueue) Matching

func (q *JobQueue) Matching(job jobs.Job) []*jobs.Record

Matching returns the jobs matching job, newest first.

func (*JobQueue) PubSub

func (q *JobQueue) PubSub() pubsub.PubSub

PubSub carries the jobs' Broadcasts. Connect pages to it with Page.Listen.

func (*JobQueue) RefuteEnqueued

func (q *JobQueue) RefuteEnqueued(t testing.TB, job jobs.Job)

RefuteEnqueued fails the test if a matching job is waiting to run.

func (*JobQueue) Runner

func (q *JobQueue) Runner() *jobs.Runner

Runner is the job runner, for its introspection methods.

func (*JobQueue) Store

func (q *JobQueue) Store() jobs.Store

Store is the queue's store: in memory unless WithStore gave another.

type JobsOption

type JobsOption func(*jobsOptions)

JobsOption changes how Jobs sets up a JobQueue.

func UseDB

func UseDB(db *data.DB) JobsOption

UseDB makes the queue's store take jobs enqueued in db's transactions, such as a loomtest.DB(t): jobs.Enqueue(tx, ...) in db.Tx then lands in the queue when the transaction (a savepoint, in the default mode) commits. The default is a local DB of its own (q.DB()).

func WithStore

func WithStore(newStore func(clock jobs.Clock) jobs.Store) JobsOption

WithStore runs the queue on the store newStore makes on the fake clock, such as a Postgres store, instead of in memory. Use it to run the same test on every store. Only the in-memory store announces enqueues on jobs.Topic, since it does so synchronously; changes made by running jobs, retries and other runner actions are announced on any store.

q := loomtest.Jobs(t, loomtest.WithStore(func(c jobs.Clock) jobs.Store {
	return pgjobs.New(db, pgjobs.Options{Clock: c})
}))

type Load

type Load struct {
	Pages   int
	Queries int64
	QueryMS float64
	MS      float64
}

Load is what one change cost a crowd: the statements every page ran re-rendering for it, their time, and the time in all.

func (Load) PerPage

func (l Load) PerPage() float64

PerPage is the queries each page ran on average.

func (Load) String

func (l Load) String() string

type Mailbox

type Mailbox struct {
	// contains filtered or unexported fields
}

Mailbox is a loom.Mailer that keeps the mail sent, for tests of code that sends mail, such as auth's sign-in links:

box := loomtest.Mail(t, app)
p.Submit("form", loom.Payload{"email": "ada@example.com"}) // enqueues the mail job
q.Drain(t)                                                 // which sends it
m := box.Last(t)                                           // m.To, m.Subject, m.Text
p = loomtest.Mount(t, app, box.Link(t))                    // follow its link

func Mail

func Mail(t testing.TB, app *loom.App) *Mailbox

Mail returns a Mailbox and makes it app's mailer.

func (*Mailbox) Last

func (b *Mailbox) Last(t testing.TB) loom.Mail

Last returns the last mail sent, failing the test if there is none.

func (b *Mailbox) Link(t testing.TB) string

Link returns the path, with its query, of the first link in the text of the last mail sent, ready for Mount. It fails the test if there is no mail or the mail has no link.

func (*Mailbox) Mail

func (b *Mailbox) Mail() []loom.Mail

Mail returns the mail sent so far, oldest first.

func (*Mailbox) Send

func (b *Mailbox) Send(ctx context.Context, m loom.Mail) error

Send keeps m.

type Option

type Option func(*options)

Option changes how Mount mounts a page.

func Cookie(name, value string) Option

Cookie sends a cookie with the page's request, as a signed-in browser would: auth checks and Mount see it, and so does every later event.

p := loomtest.Mount(t, app, "/admin/jobs", loomtest.Cookie("user", "Admin"))

func NoLayout

func NoLayout() Option

NoLayout mounts the page alone, without its app layout or root layout, for tests focused on the page itself.

func SignIn

func SignIn(t testing.TB, app *loom.App, userID string) Option

SignIn signs userID in to app, as the browser of someone who signed in would be: Mount sends the session cookie, so loom.SignedIn lets the page in and c.UserID and loom.User see userID.

p := loomtest.Mount(t, app, "/", loomtest.SignIn(t, app, "ana"))

type Page

type Page struct {
	// contains filtered or unexported fields
}

Page is a live page driven without a browser or server: the same Mount, event, render and diff steps the runtime runs, called directly.

p := loomtest.Mount(t, app, "/")
p.Event("add", loom.Payload{"title": "Milk"})
p.AssertHTML("li", 3)
p.AssertDiff(`{"d":{"0":{"o":[["1"],["2"],["3",[...]]]}}}`)

func Mount

func Mount(t testing.TB, app *loom.App, path string, opts ...Option) *Page

Mount mounts the live page that app routes at path, as a GET of path would: a fresh copy of the route's page, mounted and rendered inside its app layout. It fails the test on any error, such as a panic, the wrong assigns type or a path no live route matches, and where the app would fail to start (see Validate, which it runs once per app).

func (*Page) Advance

func (p *Page) Advance(d time.Duration)

Advance moves the page's fake clock forward by d, running each timer set with loom.Ctx.SendAfter that falls due, in order, at its due time, as the runtime would: so a page polling every 5s runs three times in Advance(16*time.Second). Each run re-renders and checks the diff like Event. Nothing runs a page's timers but Advance.

p.Input("input[name=q]", "bug")   // the handler calls c.SendAfter(300*time.Millisecond, "search", nil)
p.AssertHTML(".result", 0)
p.Advance(300 * time.Millisecond)
p.AssertHTML(".result", 2)

func (*Page) AssertAccessible

func (p *Page) AssertAccessible(extra ...A11yRule)

AssertAccessible fails the test with every accessibility problem in the page's current HTML.

func (*Page) AssertDiff

func (p *Page) AssertDiff(want string)

AssertDiff fails the test unless the last event sent exactly this diff. Use "" to assert that it changed nothing.

func (*Page) AssertEvent

func (p *Page) AssertEvent(name string, attrs ...any) trace.Event

AssertEvent fails the test unless the page's latest action emitted an event named name whose attributes include attrs, given as alternating keys and values compared as text. It returns the first such event.

p.Submit("form", loom.Payload{"title": "Milk"})
p.AssertEvent("tx.commit", "statements", 2)
p.AssertEvent("job.enqueue", "type", "todo.Notify")

func (*Page) AssertHTML

func (p *Page) AssertHTML(selector string, n int)

AssertHTML fails the test unless exactly n elements match the selector. See Count for the supported selectors.

func (*Page) AssertLimited

func (p *Page) AssertLimited(name string, payload loom.Payload) time.Duration

AssertLimited sends an event and fails the test unless a rate limit refuses it (RT436) and the page stays as it was. It returns how long until the next attempt would be allowed.

func (*Page) AssertRefused

func (p *Page) AssertRefused(name string, payload loom.Payload)

AssertRefused sends an event that no element on the page binds, as someone posting it by hand would, and fails the test unless the page refuses it (RT438) and stays as it was. Use it to check that a control the page hides (a button behind :if={admin}, a step not reached yet) can't be used anyway.

p.AssertRefused("delete", loom.Payload{"id": "7"})

func (*Page) AssertText

func (p *Page) AssertText(selector, text string)

AssertText fails the test unless an element matching the selector contains text, whitespace collapsed. See Count for the supported selectors.

p.AssertText(".todo-list li", "Buy milk")

func (*Page) Change

func (p *Page) Change(selector, value string)

Change sets the value of the input, select or textarea matching selector and fires its change event, as a browser does when the user leaves a changed field: the nearest on:change binding sends its event with its val-* attributes and the value, or, when that binding is a form, with all the form's fields (this one set to value) and its val-* attributes. For a checkbox, value is "true" or "false", sent as checked.

p.Change("input[name=done]", "true")
p.Change("select[name=priority]", "2")

func (*Page) Click

func (p *Page) Click(selector string)

Click clicks the element matching selector, as a browser would: the nearest element at or above it with an on:click binding sends its event, with that element's val-* attributes (and, for a checkbox, its new checked state). It fails the test unless exactly one element matches and something binds on:click, so a test that clicks the button, rather than calling Event with a name, also checks the name the template sends.

p.Click("li#todo-3 button.delete")

func (*Page) Diff

func (p *Page) Diff() string

Diff returns the wire JSON of the diff sent by the last event (see docs/wire.md), or "" if it changed nothing.

func (*Page) Document

func (p *Page) Document() string

Document returns the page's whole HTML document as a GET would serve it: the root layout around the live tree (just the live root with NoLayout).

func (*Page) Emit

func (p *Page) Emit(selector, name string, data map[string]any)

Emit sends an event as the behavior attached to the element matching selector would by calling emit(name, data) in the browser: the element's on:<name> event, with its val-* attributes and data as the payload. It fails the test unless exactly one element matches, a behavior is attached to it, the behavior declares name in its emits, and the element binds on:<name>.

p.Emit("[role=listbox]", "select", map[string]any{"value": "pear"})

func (*Page) Event

func (p *Page) Event(name string, payload loom.Payload)

Event sends an event to the page, as a browser would with an on:<event> binding. It fails the test on any error, including one a handler reports with c.Fail. It also applies the diff to a copy of the tree, as the browser does, and fails if the result doesn't match a fresh render: a check on Loom itself.

func (*Page) Events

func (p *Page) Events() []trace.Event

Events returns the events of the page's latest action, oldest first: the handler's page.event, its commits, the jobs it enqueued, and what those jobs did once run (q.Drain), which carry the same trace.

p.Click("button.export")
q.Drain(t)
p.AssertEvent("job.finish", "type", "app.Export", "state", "completed")

func (*Page) Follow

func (p *Page) Follow(selector string)

Follow clicks the link matching selector, as navigate.js does: a link to the page's path with another query, on a page with HandleParams, patches the page (see Patch); any other link, including one to another path value of the route, leaves it, and Navigated returns its URL. It fails the test unless exactly one element matches and it is an <a> with an href.

p.Follow(`.tabs a[href="/team/ENG?view=backlog"]`)

func (*Page) HTML

func (p *Page) HTML() string

HTML returns the HTML of the page's live tree: the app layout with the page inside (just the page with NoLayout).

func (*Page) Info

func (p *Page) Info(msg loom.Message)

Info delivers a message as if it were published on a topic the page subscribed to (see loom.Ctx.Subscribe), running its HandleInfo. It fails the test if the page isn't subscribed to the topic or on any error, and checks the diff like Event.

p.Info(loom.Message{Topic: "todos", Event: "pruned", Payload: map[string]string{"count": "2"}})

func (*Page) Input

func (p *Page) Input(selector, value string)

Input is Change for on:input, which fires on every keystroke: it sets the field's value and sends the nearest on:input binding's event.

p.Input("input[type=search]", "bug")

func (*Page) Listen

func (p *Page) Listen(ps pubsub.PubSub)

Listen connects the page to ps: messages published on its topics are delivered with Info as they are published, on the publishing goroutine. Use it with loomtest.Jobs, whose Drain runs jobs (and their Broadcasts) on the test's goroutine:

q := loomtest.Jobs(t)
p.Listen(q.PubSub())
q.Drain(t) // a job's c.Broadcast reaches the page's HandleInfo

func (*Page) Meta

func (p *Page) Meta() loom.Meta

Meta returns the page's metadata, as set with loom.Ctx.Meta.

func (*Page) Navigate

func (p *Page) Navigate(path string) *Page

Navigate leaves the page for the live page app routes at path, as a live navigation does (a link, back or forward, or c.Navigate), and returns the new page. If both pages are in the same live layout, the layout's state moves to the new page, as it does in the browser: what it holds survives, and its Mount doesn't run again. The old page does nothing more. It fails the test like Mount.

tune := loomtest.Mount(t, app, "/c64/monty-on-the-run")
tune.Click("button.play")
next := tune.Navigate("/c64/lykkehjulet")
next.AssertText("#now-title", "Monty on the Run") // still playing

func (*Page) Navigated

func (p *Page) Navigated() string

Navigated returns the URL the page last sent the browser to with loom.Ctx.Navigate, in Mount or an event, or "" if it hasn't. In the browser, Navigate in Mount redirects the page load, and in an event navigates (live, if the URL is a page with the same root layout).

p.Event("create", loom.Payload{"title": "Bug"})
if got := p.Navigated(); got != "/issues/1" { t.Errorf(...) }

func (*Page) Patch

func (p *Page) Patch(url string)

Patch changes the page's URL as the browser does when the user follows a link to the page's path with another query, or goes back or forward to one: it runs the page's HandleParams with the new URL and checks the diff like Event. It fails the test if the URL isn't of the page's path (RT426), the page has no HandleParams (RT427), or on any error.

p := loomtest.Mount(t, app, "/team/ENG")
p.Patch("/team/ENG?view=backlog")
p.AssertText(".tab.selected", "Backlog")

func (*Page) RefuteEvent

func (p *Page) RefuteEvent(name string)

RefuteEvent fails the test if the page's latest action emitted an event named name.

func (*Page) Remount

func (p *Page) Remount() *Page

Remount mounts the page again as the browser does after a server restart, and returns the new page: a fresh copy of the page and its live layout at the page's URL, given what they keep (loom.Keeper) before Mount, so a test shows what a deploy keeps and what it loses. The old page does nothing more. It fails the test like Mount.

p := loomtest.Mount(t, app, "/")
p.Click("button.next")
p = p.Remount()
p.AssertText("#now-title", "Monty on the Run") // still playing

func (*Page) Submit

func (p *Page) Submit(selector string, fields loom.Payload)

Submit submits the form matching selector (or the form around the element matching it) with fields filled in, as a browser would: the form's on:submit sends its event with every named field, those not in fields keeping the value in the markup. It fails the test if a key of fields names no field of the form, which catches a name="titel" that a Payload given to Event would not.

p.Submit("form.new-todo", loom.Payload{"title": "Milk"})

func (*Page) TableChanged

func (p *Page) TableChanged(table string)

TableChanged re-renders the page as the runtime does when a table its route watches changes (loom.Watch): it re-runs Assigns and checks the diff like Event. It fails the test if the route doesn't watch table. Use it after changing the table outside the page, as another user, a job or another node would:

other.Event("add", loom.Payload{"title": "Milk"})
p.TableChanged("todos")
p.AssertText(".todo-list li", "Milk")

func (*Page) Timers

func (p *Page) Timers() map[string]time.Duration

Timers returns the page's pending timers (loom.Ctx.SendAfter), as the time until each runs, by event.

if got := p.Timers()["save"]; got != 2*time.Second { t.Errorf(...) }

func (*Page) Title

func (p *Page) Title() string

Title returns the page's title, as set with loom.Ctx.Title.

func (*Page) Trace

func (p *Page) Trace() string

Trace returns the trace id of the page's latest action: its mount, or the last event, message, timer or re-render (docs/events.md).

func (*Page) URL

func (p *Page) URL() string

URL returns the page's current URL, path and query, as the browser's address bar shows it: the URL it was mounted at, or the last one it patched to with loom.Ctx.Patch or Patch.

p.Click("[role=option][data-value='2']")
if got := p.URL(); got != "/team/ENG?priority=2" { t.Errorf(...) }

func (*Page) Version

func (p *Page) Version() int

Version returns the page's version: 1 after Mount, plus one for each event that changed it or its title.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL