DEV Community

Simon Raynaud
Simon Raynaud

Posted on Originally published at pathlyhq.com

Test a login flow without writing Playwright

Pathly watches the path. Ping if it's up. Flow if a user can walk it. Chain if the services behind it hold. Login is a Flow. A Ping on /login only proves the URL answered. This article is the steps a real Chrome run already understands, including the login template that ships in the product. You can try a journey without writing a script from try Flow.

A Flow opens Chromium, follows a list of at most 50 steps, and stops. The default run cap is two minutes. A step timeout you do not set falls back to two seconds for a selector wait. Two clicks in a row wait two seconds between them unless you set clickDelayMs to something else, including 0. The viewport you do not set is desktop 1280 by 720. Locale defaults to fr-FR and the timezone to Europe/Paris. Set those when the page changes language or the price depends on the clock.

Why a green Ping is not a login

/login returns 200 when the button does nothing. A third-party script fails to load. An identity provider changes its redirect. A release renames the field. The document is fine. The sign-in is not. The check that matches a visitor opens the page, types an email and a password, clicks, and asserts a text that exists only after sign-in. The account name, or the words "Log out", are the usual asserts. An assert on the word "Login" passes on the form you failed to leave.

Record that in the product by signing in as usual. Each click and each fill becomes a step. If a selector changes later, edit that step. You do not have to record the whole journey again. A failed run keeps a screenshot of the failing step. Open the run and read the step name before you change a selector from memory.

The login template, step by step

The built-in template id is login. Its steps are the ones below. The host example.com is a placeholder. applyTemplateStartUrl rewrites each goto onto the origin you pass, and keeps the template path.

{
  "steps": [
    { "op": "goto", "url": "https://example.com/login" },
    {
      "op": "wait_for",
      "selector": "input[type='email'], input[name='email']",
      "timeoutMs": 2000
    },
    {
      "op": "fill",
      "selector": "input[type='email'], input[name='email']",
      "value": "user@example.com"
    },
    {
      "op": "fill",
      "selector": "input[type='password']",
      "value": "ChangeMe123!"
    },
    {
      "op": "click",
      "selector": "button[type='submit'], button:has-text('Connexion'), button:has-text('Sign in')"
    },
    { "op": "assert_text", "text": "Mon compte" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

goto navigates with domcontentloaded, then waits up to five seconds for network idle and continues if that wait misses. After the navigation the runner tries known cookie-accept buttons for up to five seconds. That pass is a convenience. It is not a guarantee that your banner uses one of those buttons. Add your own step when it does not.

wait_for requires the selector to show. timeoutMs here is 2000, which is also the default when you omit it. The maximum you can set on this op is 60000. A login form that appears after a slow identity widget needs a higher timeout. A timeout of two seconds on a widget that takes four will fail on every run, and you will blame the password.

fill types into the selector. The value is part of the scenario. Replace user@example.com and ChangeMe123! before you save this against a real site. The template password is a placeholder in source control, not an account.

click tries the selector, then can fall back to visible text or an href when you recorded those. The template uses a CSS list: a submit button, or a button whose text is "Connexion" or "Sign in". A redesign that leaves the text and changes the element still matches the text fallback if you recorded text. Add retryTimes between 0 and 5 when the control mounts late. Retries are not a substitute for wait_for.

assert_text requires the string to be visible. If you also pass selector, the text must sit in that element, not in a hidden node of the document. ignoreCase and regex are optional. "Mon compte" is French because the template was written that way. Change it to the string your signed-in page actually shows. An assert that matches the login page is a green failure.

Creating this as a browser scenario is POST /v1/scenarios with type: "browser" and the steps under scenario. scenarios:write is the scope. enabled: false saves it without alerting until you turn it on. The same call takes Idempotency-Key, 8 to 128 characters from [A-Za-z0-9._:-], remembered for 24 hours.

{
  "type": "browser",
  "name": "Sign in",
  "intervalSec": 300,
  "severity": "critical",
  "tags": ["prod", "login"],
  "scenario": {
    "steps": [
      { "op": "goto", "url": "https://shop.example.com/login" },
      {
        "op": "wait_for",
        "selector": "input[type='email']",
        "timeoutMs": 8000
      },
      {
        "op": "fill",
        "selector": "input[type='email']",
        "value": "monitor@shop.example.com"
      },
      {
        "op": "fill",
        "selector": "input[type='password']",
        "value": "replace-with-the-test-password"
      },
      { "op": "click", "selector": "button[type='submit']" },
      { "op": "assert_text", "text": "Log out" }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

A read of that scenario does not return the steps. The list leaves the steps out because they carry login credentials. You get scenarioFingerprint instead. It changes when the steps change. That is enough to notice drift. It is not enough to recover the password. Keep the steps in your own private source if you create them through the API. Do not commit the test password next to production passwords.

A test account, never a real customer

The typed password is stored with the scenario. Create an account that exists only for this run. No admin rights. No customer data. A password used nowhere else. If the scenario leaks, that account opens nothing that matters. Do not record your own staff login because it was convenient on a Thursday.

Rotate that password on the site and in the scenario together. A fingerprint change on the next read tells you the steps moved. It does not tell you whether the site still accepts them. Run once after the rotation and read the screenshot if it fails.

basicAuth on the scenario is a different field. It is HTTP basic credentials for the browser context, not the form fill. Use it only when the site gates the whole host with basic auth. The form fill remains the login you are testing.

The cookie banner

Three mechanisms exist. Use the one that matches the banner. Do not stack all three and then wonder which click won.

After every goto, the runner calls a dismiss helper. It walks a list of known accept selectors for at most five seconds, clicks the first that is visible, and waits 200 milliseconds. If your banner is OneTrust-shaped, this often clears it before your steps run. If your banner is custom, it does nothing, and your next step must handle it.

The conditional step is if_visible. The runner looks for the selector for up to 2.5 seconds. If it is visible, the next step runs. If it is not visible, the next step is marked ok with the detail "skip (si…)" and is not executed. A missing banner must not fail the login.

{
  "steps": [
    { "op": "goto", "url": "https://shop.example.com/login" },
    {
      "op": "if_visible",
      "selector": "button:has-text('Accept all')"
    },
    {
      "op": "click",
      "selector": "button:has-text('Accept all')"
    },
    { "op": "wait_for", "selector": "input[type='email']", "timeoutMs": 8000 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The click immediately after if_visible is the step that gets skipped. Do not put the email fill in that position. The fill would be skipped on a day the banner is absent, and the click on "Sign in" would land on an empty form.

There is also a template id cmp that waits 1500 milliseconds, clicks a list of accept buttons, and runs assert_no_cmp. That assert fails while a known overlay is still visible. Use it when the journey is "the banner goes away", not when the journey is "sign in". A login Flow can end with assert_no_cmp if a leftover overlay would hide the account menu. It should not replace assert_text of the signed-in page. An overlay can be gone on an error page.

if_text works the same way as if_visible, matching text in a selector or in the body, and skips the next step when the text is absent. Use it when the banner has no stable button selector and does have a stable sentence.

Captcha and one-time codes

A captcha or a one-time code exists to stop robots. This runner is a robot. Do not solve the captcha, do not read the mailbox, and do not replay a stolen cookie from a real user. Exempt the test account on your side, or recognise the monitoring traffic with a header you choose.

Headers live on the scenario, not on a single step. At most ten. Each name matches ^[A-Za-z0-9-]+$, is at most 64 characters, and must not be host, content-length, connection or transfer-encoding. The value is at most 500 characters. The runner copies them to Chromium's extra HTTP headers for every request of that run. You pick the name. There is no reserved Pathly header you can assume your site already trusts.

{
  "steps": [
    { "op": "goto", "url": "https://shop.example.com/login" }
  ],
  "headers": {
    "X-Shop-Monitor": "login-flow"
  }
}
Enter fullscreen mode Exit fullscreen mode

On the site, skip the captcha when that header has the value you configured, and only for the test account. A header anyone can send is not an admin backdoor. Pair it with the test account, or with a network rule you already use for other monitors. If you cannot exempt the account, this Flow cannot pass the captcha, and it should not. Monitor the page up to the captcha with an assert that the widget rendered, and test the API behind it with a Chain from a network that your API allows.

Two-factor on the test account has the same rule. Disable it for that account, or use a method your policy allows a monitor to complete without a human. Do not store a colleague's phone number in a step.

Asserts that mean you are in

assert_text was the template's last step. Add assert_url when the signed-in page has a stable path.

{
  "op": "assert_url",
  "path": "/account",
  "ignoreQuery": true,
  "ignoreHash": true
}
Enter fullscreen mode Exit fullscreen mode

includes matches a substring. regex is a pattern of at most 200 characters. Ignoring the query and the hash avoids a failure on a tracking parameter. Do not ignore the path. A redirect to /login?error=1 must fail.

assert_visible checks a selector and can retry. assert_amount checks a number in a selector against min, max or equals, with an optional currency. Use it on a cart, not on a login. wait_for_response waits for a URL substring and an optional status, which is how you tie the click to POST /session returning 200. wait_for_networkidle waits for the network to settle, up to the timeout you set, capped at 60 seconds.

{
  "op": "wait_for_response",
  "urlIncludes": "/session",
  "status": 200,
  "timeoutMs": 10000
}
Enter fullscreen mode Exit fullscreen mode

Put that after the submit click, before the text assert. A 200 from /session plus the text "Log out" is the pair. The status alone can be a 200 error payload. The text alone can be left over in a cached layout. Together they are the sign-in.

press sends Enter, Escape, Tab, ArrowDown, ArrowUp or Space, optionally on a selector. Use Enter when the form submits from the password field and the button selector is unstable. You still want assert_text after it.

A click can set newTab when the control opens a popup. The runner switches to that page. switch_tab with urlIncludes comes back to a tab whose URL contains the string. Identity providers that bounce through a second window need this. A Ping never will.

Viewport, locale and timezone belong on the scenario next to steps when the mobile menu or the translated button is what you are proving.

{
  "viewport": "mobile",
  "locale": "en-US",
  "timezone": "Europe/Paris",
  "steps": []
}
Enter fullscreen mode Exit fullscreen mode

mobile is a real viewport id. Desktop is the default, 1280 by 720. Record the size your customers use for that form. A banner that covers the button on a phone and not on a desktop will stay green if you only record the desktop.

When login is an API, use Chain

Flow sees the button. Chain sees the token. A front end that calls an auth API can break in either place. Chain does not open Chrome. One hop extracts a JSON field into a name. The next call uses it only where you write {{token}}, in the URL, the body, or an Authorization header. It is cheaper and it does not prove the button.

Hops are one to ten. Methods are the usual HTTP set. extractJson.path is at most 200 characters. extractJson.as is a name of at most 64 characters, starting with a letter or underscore.

import { PathlyClient } from "@pathly/sdk";

const pathly = new PathlyClient({ token: process.env.PATHLY_API_TOKEN! });

await pathly.createChain({
  name: "Login then /me",
  intervalSec: 300,
  severity: "critical",
  hops: [
    {
      name: "Login",
      method: "POST",
      url: "https://api.example.com/login",
      body: JSON.stringify({
        email: process.env.API_USER,
        password: process.env.API_PASSWORD,
      }),
      assertStatus: 200,
      extractJson: { path: "token", as: "token" },
    },
    {
      name: "Me",
      method: "GET",
      url: "https://api.example.com/me",
      headers: { Authorization: "Bearer {{token}}" },
      assertStatus: 200,
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

extractCookie on a hop stores a cookie name instead of a JSON path, when the session is a cookie rather than a bearer token. waitMs pauses up to 30 seconds before the next hop. expectText fails the hop when the body does not contain the string. Put the test password in the CI secret, as API_PASSWORD above, not in a screenshot of this article.

Keep the Flow for the button and the Chain for the token when both are on the critical path. A green Chain with a broken submit button is the outage this article started from. A green Flow with a token your mobile app cannot refresh is the outage the Chain is for.

POST /v1/scenarios/{id}/run with scope runs:trigger queues a run and returns jobId. Poll GET /v1/runs?scenarioId= with runs:read. Finished statuses are ok, fail and error. The browser cap is two minutes unless that account was given a higher one, up to ten minutes. A poll that waits longer than the cap will only see the timeout.

The GitHub Action pathlyhq/pathly-action@v1 can run-scenario or assert-scenario on the id you saved. Assert reads the last status and does not start a browser. Use assert in a pull request when you only need to know the scheduled Flow is currently green. Use run after a deploy when you need a fresh sign-in. The token scopes are scenarios:read and runs:read, plus runs:trigger for the run operation.

What not to put in the steps

Do not put a production admin password in fill. Do not put a card number in a checkout Flow. Stop the journey before payment, on a text that proves the payment page rendered, unless you have a payment provider test card that cannot move money. Do not add a step that clicks "delete account". The runner will click it on schedule.

Fifty steps is the maximum. A journey that long is two journeys. The default click delay of two seconds times a long click list will also hit the two-minute cap. Shorten the path to the assert that matters.

Selectors are at most 500 characters. Prefer an id or a name. A selector that is a copy of the entire form will break on the next class hash. text and href on a click are the fallbacks for pages that have neither.

When the run fails, the step result carries the op, the duration, the page URL, and the screenshot of that step. Fix the selector in the list. Run again. A Ping on the same host can stay green the whole time. That is not a contradiction. They are different instruments.

Viewports you can name

viewport is one of desktop, tablet, mobile, iphone_se, iphone_14, pixel_7, galaxy_s21, ipad_mini. desktop is 1280 by 720 and it is the default when the field is absent. tablet is 768 by 1024. Pick mobile or a named phone when the login form collapses into a menu. Record and replay the same id. A journey recorded on desktop and replayed on iphone_14 will miss buttons that moved. Set the id in the scenario, do not rely on the recorder window being maximised.

Locale and timezone are strings on the same object. locale is at most 16 characters. timezone is at most 64. fr-FR and Europe/Paris are what you get if you omit them. An English submit button will not match button:has-text('Connexion') on an en-US locale. Align the assert text, the click text, and the locale. A mismatch here looks like a product outage and is a configuration error.

A scheduled run versus a run you start

intervalSec: 300 checks the login every five minutes and spends browser time on each pass. A login that only needs to be proven after a deploy can stay on a long interval, and the pipeline calls POST /v1/scenarios/{id}/run when the deploy finishes. Plans do not share the same Flow quota. Do not copy a five-minute interval from this example onto every journey because the JSON looked tidy. Browser minutes are the meter. A Ping on the same URL is the cheap signal that the host answers. The Flow is the expensive signal that a person can sign in.

The GitHub Action below starts one fresh run and fails the job if it does not finish ok. The id is a variable, not a secret. The token is a secret.

- name: Pathly sign-in
  uses: pathlyhq/pathly-action@v1
  with:
    api_token: ${{ secrets.PATHLY_API_TOKEN }}
    operation: run-scenario
    scenario_id: ${{ vars.PATHLY_LOGIN_SCENARIO_ID }}
    timeout_sec: "120"
Enter fullscreen mode Exit fullscreen mode

timeout_sec of 120 matches the default browser cap. Raising the action timeout above the scenario cap does not give the browser more time. assert-scenario is the other operation. It fails the job when the last status is not ok, and it does not start a browser. Use it on pull requests. Use run-scenario after the deploy.

Reading a failure without guessing

The step list in the run is the order the runner executed, including a skipped step after if_visible. A skipped step is ok and its detail says it was skipped. Do not "fix" a skip by deleting if_visible. The skip is the banner being absent, which is the success case for that pair.

A failed fill usually means the selector matched nothing, or matched an element that cannot be typed into. A failed click after a successful fill often means a banner appeared late and covered the button. Put if_visible and the accept click after goto and before wait_for on the email field, not after the fill. A failed assert_text with a screenshot of the login form means the click did not navigate. Look at wait_for_response for /session. A 401 or a timeout there is the password or the exempt rule, not the CSS.

assert_no_cmp fails while a known overlay is still visible. That is a banner the dismiss list did not click. Add an explicit click. Do not lengthen the goto and hope.

Console messages and a short network list can be attached to the step when the runner captured them. They explain a script that did not load. They are not a second monitoring product. The screenshot is still the first thing to open.

What the API will not echo back

Create the scenario once, store the id, and stop sending the password on every deploy. A later GET does not include the steps. scenarioFingerprint changes when they change. If you need to rotate the test password, patch the scenario from a secret store, then run once. Do not print the patch body in the job log. The fingerprint moving, plus a green run, is the confirmation.

enabled: false on create lets you save the steps and read a fingerprint before any alert goes out. Patch enabled to true after the first manual run is green. An enabled scenario with a wrong assert will page you every interval. That is the correct behaviour, and it is a bad first hour.

Headers you set for the captcha exemption are part of the scenario object too. Rotate the header value the same way you rotate the password, on the site and in the scenario together. A header that has been in a ticket and a screenshot is not a secret. Use a fresh value per environment.

This is the whole login check. A test account, a goto, a banner step that is allowed to skip, two fills, a click, a response wait, and a text that only exists once signed in. Ping stays on the URL. Chain stays on the token. Flow is the walk between them.

Top comments (0)