Skip to content
Pages

Writing flows

A flow is a journey a user takes through your app, written as steps. Each step performs an action, waits for what it expects, and becomes a state on the map with its screenshots.

After reading this page, you will know:

  • How a flow and its steps are written.
  • How to find elements so a flow runs in every language.
  • How flows share data, sign in, and handle dialogs.

A flow

flow "core-group-journey",
  title: "Create a group, split an expense, settle up",
  area: "Groups",
  summary: "A visitor starts a group, adds members and an expense, then settles up." do |walk|
  walk.step("First group form", via: "Create your first split", main: true) do |page|
    walk.visit("/groups/new")
    page.locator("#group_name").wait_for(state: "visible")
  end

  walk.step("Group with no transactions", via: "Name it, start splitting") do |page|
    page.locator("#group_name").fill("Kyoto weekend")
    page.get_by_role("button", name: walk.t("groups.create_cta")).click
    walk.see(walk.t("groups.detail.empty_heading"))
  end
end
  • id names the screenshot files and must be unique.
  • title and summary are what the viewer shows.
  • area groups the flow with pages of the same area.
  • signed_in: true passes signed_in: true to the cookies block, which can return a session cookie from the seed.

Flow files in ezu/flows/ load in name order, and run in that order in every slot.

Steps

walk.step(state, via:, main: false) { |page| … } names the state the step lands on and, in via, the action that got there. The block gets a Playwright page. Its job is to act and then wait for proof that it arrived: a visible element, a text, a closed panel. Ezu takes the screenshots only after the block returns and the page has settled.

The state belongs to whichever page the browser is on when the block returns, matched against the app's routes. Its id is that page plus the name: groups#show/Group with no transactions.

main: true marks the state that represents the page. Every mapped page needs exactly one, or the run fails. Other states are variants, or overlays when a <dialog> or popover is open at the time of the screenshot.

A step fails the run when:

  • its block raises, including a Playwright wait timing out (10 seconds);
  • the app answers a document or fetch request with 403, 404 or 5xx (422 is a form with errors, and counts as a state);
  • walk.t is asked for a key the locale doesn't have.

The failure message names the flow, slot and step, and the path of a screenshot taken at that moment.

Finding things in every language

The same flow runs in every locale, so it never looks for English text. Use the app's own translations, or attributes that don't change with language:

page.get_by_role("button", name: walk.t("groups.add_button"), exact: true).click
walk.see(walk.t("groups.members.claim.remembered", name: "Ben"))
page.locator("#transaction-form-panel").wait_for(state: "visible")
page.locator("a[href*='marketing_cta=hero']").click

walk.t(key, **values) returns the text for key in the slot's locale, filling %{name} placeholders. The Rails adapter reads every translation from I18n once per run.

What a walk offers

Method What it does
walk.step(state, via:, main:) Run an action and record the state it lands on.
walk.visit(path) Go to a path, wait for the network to go quiet and Stimulus to connect.
walk.see(text) Wait until a visible element contains the text.
walk.t(key, **values) The app's text for a translation key in this locale.
walk.page The Playwright page, also passed to each step's block.
walk.mobile? True in the mobile slots, for layouts that differ by screen size.
walk.locale The slot's locale, such as "ja".
walk.seed What ezu/seed.rb wrote for this slot, with symbol keys.
walk.memo A hash shared by every flow in the slot, in order.
walk.close_dialogs Press Escape until no <dialog> is open.
walk.dialog_loaded Wait for an open dialog that is no longer aria-busy.
walk.flag(message) Note something wrong on this step without failing the run.

Sharing data between flows

A flow can hand a later one what it made through walk.memo, such as the address of a group it created:

walk.memo[:group_path] = URI(walk.page.url).path
# … in a later flow file
walk.visit(walk.memo.fetch(:group_path))

Data that should exist before any flow runs, such as users and sessions, belongs in the seed.

Screen sizes

Each flow runs at desktop (1440×1000) and mobile (390×844, touch). When the UI differs, branch on walk.mobile?:

if walk.mobile?
  page.get_by_role("button", name: walk.t("groups.detail.mobile_actions_aria")).click
  page.get_by_role("menuitem", name: walk.t("groups.detail.add_cta")).click
else
  page.get_by_role("link", name: walk.t("groups.detail.add_cta")).click
end

Helpers

A flow file is Ruby evaluated in the project, so a lambda or a method defined in config.rb is available to every flow:

add_member = lambda do |page, name, walk|
  page.locator("#participant-name-new").fill(name)
  page.get_by_role("button", name: walk.t("groups.add_button"), exact: true).click
  walk.see(name)
end