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:
| Verb | Template | Example |
|---|---|---|
click | Click {controlType} "{name}" | Click button "Export" |
fill | Type "{detail}" into {controlType} "{name}" | Type "mi-llavero" into field "Filename" |
choose | Choose "{detail}" in {controlType} "{name}" | Choose "3MF" in select "Format" |
select | Select option "{detail}" in {controlType} "{name}" | Select option "Standard" in radio group "Quality" |
check | Check {controlType} "{name}" | Check checkbox "Include supports" |
attach | Attach "{detail}" to {controlType} "{name}" | Attach "model.stl" to file input "Model file" |
hover | Hover over {controlType} "{name}" | Hover over button "Export" |
drag | Drag {controlType} "{name}" onto {detail} | Drag file input "Model" onto button "Upload zone" |
assert-text | Assert {controlType} "{name}" has text "{expected}" | Assert label "Export status" has text "Exported mi-llavero.3mf (Standard)" |
assert-visible | Assert {controlType} "{name}" is visible | Assert 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:
| Field | Type | Meaning |
|---|---|---|
action | string | Grammar verb: click, fill, choose, assert-text, ... |
controlType | string | button, field, select, radio group, ... |
name | string | The control's human name ("Export", "Filename") |
sentence | string | The sentence exactly as shown in the Playwright report |
detail | string? | Action payload (typed text, chosen option, attached file name) |
expected | string? | Assertions only: what was expected |
actual | string? | Failed assertions only: what was actually found in the DOM |
selector | string | Stable, readable selector ([data-testid="..."], role=button[name="..."]) |
status | 'passed' | 'failed' | Outcome of the step |
error | string? | 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 |
domCandidates | string[]? | Failed, locator-not-found steps only: same-role elements on the page, for the analyzer |
timestamp | string | ISO 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.