Sample tests
Bugmole records every journey it discovers as a readable flow file. The flow is the source of truth: it is what Bugmole runs across browsers and devices, and what you review and edit. This page shows one journey — creating a project — as a flow, then the same journey written by hand in Playwright, Cypress, and Selenium for comparison.
Bugmole writes flows, not Playwright, Cypress, or Selenium code, and it does not export flows to those frameworks. Bugmole's own runner plays each flow in a real browser, using Playwright under the hood.
The flow
Flows you run from the CLI live in your repository. bugmole --mode suite looks in spec/flows/ first, then flows/, then .maestro/. Flows created in the dashboard, such as the ones exploration writes, are kept in the project's Bugmole storage. Each flow has a header that says where it starts, followed by the steps a real user takes.
url: https://staging.example.com/projects
name: create-project
---
- launchApp
- tapOn: "New project"
- tapOn: "Project name"
- inputText: "My Project"
- tapOn: "Create"
- assertVisible: "Project created"
Steps find elements the way a person does: by a button's name, a field's label or placeholder, or visible text. There are no CSS selectors to keep up to date, so a restyled page does not break the test.
| Step | What it does |
|---|---|
launchApp |
Opens the flow's URL |
openLink: "/settings" |
Navigates to a path or URL |
tapOn: "Save" |
Clicks the button, link, or field with that name |
tapOn: { text: "Delete", within: "API_TOKEN" } |
Clicks the "Delete" in the row or card that also shows "API_TOKEN", not the first "Delete" on the page |
inputText: "hello" |
Types into the focused field |
eraseText |
Clears the focused field |
selectOption: { field: "Plan", option: "Business" } |
Picks an option in the drop-down with that label, by the option's text (or value). Add index when several drop-downs share a label |
pressKey: "Enter" |
Presses a key |
assertVisible: "Saved" |
Waits for text to appear |
assertNotVisible: "Error" |
Checks text is absent |
assertClickable: "Continue" |
Checks a control can be used |
scrollUntilVisible: { element: "Footer" } |
Scrolls until the element shows |
extendedWaitUntil: { visible: "Ready", timeout: 20000 } |
Waits longer for slow screens |
assertLayout |
Checks the page looks right at this screen size: it doesn't scroll sideways, and no text spills out of its box or is cut off without an ellipsis |
assertLinks |
Checks every link to your own site on the page answers. assertLinks: { external: true } checks links to other sites too: a 404, 410 or server error fails, and sites that refuse automated visitors aren't counted |
takeScreenshot: "after-save" |
Saves a named screenshot |
requestTempEmail / openEmailLink |
Tests sign-up and email links with a real disposable inbox (see below) |
Confirm dialogs
When a step opens a browser dialog (confirm, alert or prompt), the flow answers it the way a person clicking OK would: a confirm-guarded Delete really deletes, and a prompt gets its default value. Each answer is written to the run's log, for example Dialog "Sure?" accepted. To test the Cancel path instead, add dialogs: dismiss to the flow's header:
url: https://staging.example.com/projects
dialogs: dismiss
---
- launchApp
- tapOn: "Delete project"
- assertVisible: "My Project"
Email: sign-up and confirmation links
requestTempEmail creates a real inbox and lets later steps type its address as {{tempEmail}}. openEmailLink waits for a message at that inbox and opens the first link in it:
- requestTempEmail
- tapOn: "Email"
- inputText: "{{tempEmail}}"
- tapOn: "Sign up"
- openEmailLink: { subject: "Confirm", timeout: 60000 }
- assertVisible: "Email confirmed"
- Naming the inbox.
requestTempEmail: "buyer"names it, so the flow types{{buyer}}and opensopenEmailLink: { as: "buyer" }.{{name}}works in any text or URL a step takes. - Choosing the message and link.
openEmailLinktakesfromandsubjectto pick the message,matchto pick the first link whose address or text contains it, andtimeoutin milliseconds. - Needs the internet. The inbox is created on mail.tm, a third-party disposable-mail service, so the machine running the flow must be able to reach it. The inbox is deleted when the run ends. These steps don't run on real devices yet.
Passwords and secrets
Type secrets by reference, never literally:
- tapOn: "Password"
- inputText: "{{secret.APP_PASSWORD}}"
Set BUGMOLE_FLOW_SECRET_APP_PASSWORD on the machine or CI job that runs the flow. A flow can only read variables with the BUGMOLE_FLOW_SECRET_ prefix, and only inputText can use them. Everything typed is masked in logs and results.
When a run targets an environment, the flow's URL is moved onto that environment, so the same flow runs against local, preview, staging, or production. See Configuration.
Run it
bugmole --mode suite --flows spec/flows --base-url https://staging.example.com \
--browsers chromium,firefox,webkit,msedge --devices "iPhone 15,Pixel 7"
Every browser and device runs in parallel, and each one keeps its own screenshots, video, and log.
For comparison: the same journey written by hand
This is what the flow above would look like if you wrote it yourself in each framework. Bugmole does not generate or run these files; they are here so you can see how a flow maps to code you may already know.
Playwright
import { test, expect } from '@playwright/test';
test('user can create a new project', async ({ page }) => {
await page.goto('/projects');
await page.getByRole('button', { name: 'New project' }).click();
await page.getByLabel('Project name').fill('My Project');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Project created')).toBeVisible();
});
Cypress
describe('projects', () => {
it('user can create a new project', () => {
cy.visit('/projects');
cy.contains('button', 'New project').click();
cy.get('input[name="projectName"]').type('My Project');
cy.contains('button', 'Create').click();
cy.contains('Project created').should('be.visible');
});
});
Selenium (JavaScript)
import { Builder, By, until } from 'selenium-webdriver';
import assert from 'node:assert/strict';
const baseUrl = process.env.BASE_URL ?? 'https://staging.example.com';
describe('projects', function () {
let driver;
before(async () => { driver = await new Builder().forBrowser('chrome').build(); });
after(async () => { await driver.quit(); });
it('user can create a new project', async () => {
await driver.get(`${baseUrl}/projects`);
await driver.findElement(By.xpath("//button[normalize-space()='New project']")).click();
await driver.findElement(By.name('projectName')).sendKeys('My Project');
await driver.findElement(By.xpath("//button[normalize-space()='Create']")).click();
const toast = await driver.wait(until.elementLocated(By.xpath("//*[normalize-space()='Project created']")), 10000);
assert.ok(await toast.isDisplayed());
});
});