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
- Variables
- func AssertAccessible(t testing.TB, html string, extra ...A11yRule)
- func AssertNotFound(t testing.TB, app *loom.App, path string, opts ...Option)
- func AssertPreviewsAccessible(t *testing.T, tmpl render.Template, extra ...A11yRule)
- func Count(html, selector string) (int, error)
- func DB(t testing.TB, opts ...DBOption) *data.DB
- func LimitClock(t testing.TB, app *loom.App) *jobs.FakeClock
- func Manifest(t testing.TB, app *loom.App)
- func Name(e *Element) string
- func Render[A any](t testing.TB, c render.Component[A], assigns A) string
- func RenderTree[A any](t testing.TB, c render.Component[A], assigns A) (r *render.Rendered)
- func Snapshot(t testing.TB, got string)
- func Validate(t testing.TB, app *loom.App)
- func ValidateTree(r *render.Rendered) error
- type A11yProblem
- type A11yRule
- type Crowd
- type DBOption
- type Element
- type FlowTest
- type JobQueue
- func (q *JobQueue) Advance(d time.Duration)
- func (q *JobQueue) All() []*jobs.Record
- func (q *JobQueue) AssertBroadcast(t testing.TB, topic, event string)
- func (q *JobQueue) AssertCompleted(t testing.TB, job jobs.Job)
- func (q *JobQueue) AssertEnqueued(t testing.TB, job jobs.Job)
- func (q *JobQueue) AssertEvent(t testing.TB, name string, attrs ...any) trace.Event
- func (q *JobQueue) AssertState(t testing.TB, job jobs.Job, state jobs.State)
- func (q *JobQueue) Attach(app *loom.App)
- func (q *JobQueue) Broadcasts() []pubsub.Message
- func (q *JobQueue) Clock() *jobs.FakeClock
- func (q *JobQueue) Cron(expr string, job jobs.Job)
- func (q *JobQueue) DB() *data.DB
- func (q *JobQueue) Drain(t testing.TB) int
- func (q *JobQueue) Enqueue(job jobs.Job, opts ...jobs.Option) int64
- func (q *JobQueue) Events() []trace.Event
- func (q *JobQueue) Jobs(js ...jobs.Job)
- func (q *JobQueue) Matching(job jobs.Job) []*jobs.Record
- func (q *JobQueue) PubSub() pubsub.PubSub
- func (q *JobQueue) RefuteEnqueued(t testing.TB, job jobs.Job)
- func (q *JobQueue) Runner() *jobs.Runner
- func (q *JobQueue) Store() jobs.Store
- type JobsOption
- type Load
- type Mailbox
- type Option
- type Page
- func (p *Page) Advance(d time.Duration)
- func (p *Page) AssertAccessible(extra ...A11yRule)
- func (p *Page) AssertDiff(want string)
- func (p *Page) AssertEvent(name string, attrs ...any) trace.Event
- func (p *Page) AssertHTML(selector string, n int)
- func (p *Page) AssertLimited(name string, payload loom.Payload) time.Duration
- func (p *Page) AssertRefused(name string, payload loom.Payload)
- func (p *Page) AssertText(selector, text string)
- func (p *Page) Change(selector, value string)
- func (p *Page) Click(selector string)
- func (p *Page) Diff() string
- func (p *Page) Document() string
- func (p *Page) Emit(selector, name string, data map[string]any)
- func (p *Page) Event(name string, payload loom.Payload)
- func (p *Page) Events() []trace.Event
- func (p *Page) Follow(selector string)
- func (p *Page) HTML() string
- func (p *Page) Info(msg loom.Message)
- func (p *Page) Input(selector, value string)
- func (p *Page) Listen(ps pubsub.PubSub)
- func (p *Page) Meta() loom.Meta
- func (p *Page) Navigate(path string) *Page
- func (p *Page) Navigated() string
- func (p *Page) Patch(url string)
- func (p *Page) RefuteEvent(name string)
- func (p *Page) Remount() *Page
- func (p *Page) Submit(selector string, fields loom.Payload)
- func (p *Page) TableChanged(table string)
- func (p *Page) Timers() map[string]time.Duration
- func (p *Page) Title() string
- func (p *Page) Trace() string
- func (p *Page) URL() string
- func (p *Page) Version() int
Constants ¶
const ManifestFile = "loom.manifest"
ManifestFile is the file Manifest keeps: the app's manifest, committed next to the test that checks it.
Variables ¶
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.
var JobsEpoch = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
JobsEpoch is the fake clock's starting time in loomtest.Jobs.
var LimitEpoch = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
LimitEpoch is where LimitClock's fake clock starts.
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 ¶
AssertAccessible fails the test with every accessibility problem in html.
func AssertNotFound ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
RenderTree renders a component and returns its render tree, checked for consistency: every block has one more static part than dynamics.
func Snapshot ¶
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 ¶
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 ¶
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 ¶
MountCrowd mounts n pages at path, each as Mount does, with opts.
func (*Crowd) Info ¶
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 ¶
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 ¶
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 ¶
Select returns the elements under e (not e itself) that match a selector, for rules that look inside an element. A bad selector panics.
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 ¶
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 ¶
Checklist returns the flow as a checklist: ✓ passed, ✗ failed, and ○ for the steps of the committed file the test didn't reach.
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 ¶
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) AssertBroadcast ¶
AssertBroadcast fails the test unless a job published event on topic.
func (*JobQueue) AssertCompleted ¶
AssertCompleted fails the test unless a matching job completed.
func (*JobQueue) AssertEnqueued ¶
AssertEnqueued fails the test unless a matching job is waiting to run: scheduled, available or retryable.
func (*JobQueue) AssertEvent ¶
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 ¶
AssertState fails the test unless a matching job is in state.
func (*JobQueue) Attach ¶
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 ¶
Broadcasts returns the messages jobs have published, oldest first, leaving out the runner's own announcements on jobs.Topic.
func (*JobQueue) 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 ¶
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) Events ¶
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) PubSub ¶
PubSub carries the jobs' Broadcasts. Connect pages to it with Page.Listen.
func (*JobQueue) RefuteEnqueued ¶
RefuteEnqueued fails the test if a matching job is waiting to run.
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 ¶
Load is what one change cost a crowd: the statements every page ran re-rendering for it, their time, and the time in all.
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 (*Mailbox) Link ¶
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.
type Option ¶
type Option func(*options)
Option changes how Mount mounts a page.
func Cookie ¶
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.
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 ¶
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 ¶
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 ¶
AssertAccessible fails the test with every accessibility problem in the page's current HTML.
func (*Page) AssertDiff ¶
AssertDiff fails the test unless the last event sent exactly this diff. Use "" to assert that it changed nothing.
func (*Page) AssertEvent ¶
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 ¶
AssertHTML fails the test unless exactly n elements match the selector. See Count for the supported selectors.
func (*Page) AssertLimited ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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) Navigate ¶
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 ¶
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 ¶
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 ¶
RefuteEvent fails the test if the page's latest action emitted an event named name.
func (*Page) Remount ¶
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 ¶
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 ¶
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 ¶
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) Trace ¶
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 ¶
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(...) }