The artifact

Why an artifact for AI

A bare locator timed out tells you nothing about what the test was trying to do. razo's controls know their business name and their type, so every action and assertion can narrate itself — and that narration doubles as structured data. When a test fails, an AI reading the artifact doesn't have to reconstruct intent from a stack trace: it reads which business action failed, what was expected, and what actually happened.

The sentence grammar

Every verb maps to exactly one fixed template, defined in a single place (SENTENCES in Control.ts). That's what keeps the narration predictable and parseable — the same action always produces the same shape of sentence:

VerbTemplateExample
clickClick {controlType} "{name}"Click button "Export"
fillType "{detail}" into {controlType} "{name}"Type "mi-llavero" into field "Filename"
chooseChoose "{detail}" in {controlType} "{name}"Choose "3MF" in select "Format"
selectSelect option "{detail}" in {controlType} "{name}"Select option "Standard" in radio group "Quality"
checkCheck {controlType} "{name}"Check checkbox "Include supports"
attachAttach "{detail}" to {controlType} "{name}"Attach "model.stl" to file input "Model file"
hoverHover over {controlType} "{name}"Hover over button "Export"
dragDrag {controlType} "{name}" onto {detail}Drag file input "Model" onto button "Upload zone"
assert-textAssert {controlType} "{name}" has text "{expected}"Assert label "Export status" has text "Exported mi-llavero.3mf (Standard)"
assert-visibleAssert {controlType} "{name}" is visibleAssert button "Export" is visible

A sentence can be overridden per call with { as: '...' } for business-level narration (exportButton.click({ as: 'Confirm the export' })) — the StepEvent still records the real control underneath.

The StepEvent field reference

Every step in razo-steps.json is a StepEvent:

FieldTypeMeaning
actionstringGrammar verb: click, fill, choose, assert-text, ...
controlTypestringbutton, field, select, radio group, ...
namestringThe control's human name ("Export", "Filename")
sentencestringThe sentence exactly as shown in the Playwright report
detailstring?Action payload (typed text, chosen option, attached file name)
expectedstring?Assertions only: what was expected
actualstring?Failed assertions only: what was actually found in the DOM
selectorstringStable, readable selector ([data-testid="..."], role=button[name="..."])
status'passed' | 'failed'Outcome of the step
errorstring?Error message when status is 'failed' (ANSI codes stripped)
healed{ from: string; to: string }?Set when the primary locator stopped resolving and a fallback found the element
domCandidatesstring[]?Failed, locator-not-found steps only: same-role elements on the page, for the analyzer
timestampstringISO 8601

The top-level report wrapping the steps also carries test, file, status, durationMs, and a test-level error (a failure outside any narrated step).

Where files land

The reporter writes one file per test: test-results/<test>/razo-steps.json. The directory name is derived from the spec file and the test title; a retried test gets a -retry1, -retry2, ... suffix so no artifact is overwritten. The output root is configurable:

reporter: [['html'], ['@razohq/razo/reporter', { outputDir: 'test-results' }]],

A full example artifact

razo's demo suite includes a deliberately failing test — an export where the quality is never chosen. This is the real, unedited razo-steps.json it produces:

{
  "test": "export a model without choosing quality",
  "file": "tests/demo.spec.ts",
  "status": "failed",
  "durationMs": 3510,
  "error": "Error: expect(locator).toHaveText(expected) failed\n\nLocator:  getByTestId('status')\nExpected: \"Exported mi-llavero.3mf (Standard)\"\nReceived: \"Error: missing fields\"\nTimeout:  3000ms\n...",
  "steps": [
    {
      "action": "fill",
      "controlType": "field",
      "name": "Filename",
      "sentence": "Type \"mi-llavero\" into field \"Filename\"",
      "detail": "mi-llavero",
      "selector": "[data-testid=\"filename\"]",
      "status": "passed",
      "timestamp": "2026-07-17T02:43:15.126Z"
    },
    {
      "action": "choose",
      "controlType": "select",
      "name": "Format",
      "sentence": "Choose \"3MF\" in select \"Format\"",
      "detail": "3MF",
      "selector": "[data-testid=\"format\"]",
      "status": "passed",
      "timestamp": "2026-07-17T02:43:15.140Z"
    },
    {
      "action": "click",
      "controlType": "button",
      "name": "Export",
      "sentence": "Click button \"Export\"",
      "selector": "[data-testid=\"export\"]",
      "status": "passed",
      "timestamp": "2026-07-17T02:43:15.183Z"
    },
    {
      "action": "assert-text",
      "controlType": "label",
      "name": "Export status",
      "sentence": "Assert label \"Export status\" has text \"Exported mi-llavero.3mf (Standard)\"",
      "expected": "Exported mi-llavero.3mf (Standard)",
      "actual": "Error: missing fields",
      "selector": "[data-testid=\"status\"]",
      "status": "failed",
      "error": "expect(locator).toHaveText(expected) failed\n\nLocator:  getByTestId('status')\nExpected: \"Exported mi-llavero.3mf (Standard)\"\nReceived: \"Error: missing fields\"\nTimeout:  3000ms\n...",
      "timestamp": "2026-07-17T02:43:18.212Z"
    }
  ]
}

The story is complete without a screenshot: a filename was typed, a format was chosen, Export was clicked — and nobody ever selected a quality, so the app answered "Error: missing fields" instead of confirming the export.

How razo-analyze consumes it

razo-analyzer reads every failed test's razo-steps.json, feeds it to Claude, and returns a business-level analysis: which action failed, a root-cause hypothesis (app bug, outdated expectation, missing precondition, flake), and a suggested fix. Because the artifact is structured — sentence, control name, expected vs. actual — the model reads the story instead of guessing from a stack trace. Against the example above, razo-analyze correctly flags the missing quality.select('Standard') step as the root cause, not the assertion that merely surfaced it.