# Jest steps and attachments

> Record nested steps with probara.step(), attach files with probara.attach(), capture each test's console output, and the limits that apply.

The steps of a test show under its result in Probara, nested as they ran, each with its status,
duration and error. Files attached by the test show on the result, or on the step they were attached
in. Jest has no steps or files of its own (no screenshots, no traces): what a result carries is what
the test recorded with the `probara.*` helpers, plus its console output when you turn that on.

## `probara.step()`

`probara.step(title, body, options)` runs `body`, records it as a step, and returns what the body
returns, so a step can compute a value the test uses (`cart` below):

```js
// tests/checkout.test.js
const { probara } = require('@probara/jest-reporter');

test('checks out', async () => {
  const cart = await probara.step('Open the cart', async () => ({ items: 2 }));
  await probara.step('Pay', async () => {
    probara.step('Enter the card', () => {
      expect(cart.items).toBe(2);
    });
    await probara.step('Confirm', async () => {
      expect(await Promise.resolve('declined')).toBe('paid');
    });
  });
});
```

```json
[
  {
    "status": "failed",
    "steps": [
      { "action": "Open the cart", "status": "passed" },
      {
        "action": "Pay",
        "status": "failed",
        "steps": [
          { "action": "Enter the card", "status": "passed" },
          { "action": "Confirm", "status": "failed" }
        ]
      }
    ]
  }
]
```

| The body                        | The step                                                          | What `probara.step()` does                     |
| ------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------- |
| Returns                         | `passed`                                                          | Returns its value                              |
| Throws                          | `failed`, with the error's message and stack; its parents fail    | Throws the same error: the test fails as usual |
| Returns a promise (`async`)     | Ends when the promise settles: `passed`, or `failed`              | Returns a promise that settles the same way    |
| Returns another thenable        | Ends when the body returns (see [below](#promises-and-thenables)) | Returns it untouched                           |
| None (`probara.step('Seeded')`) | `passed`, with no duration                                        | Returns `undefined`                            |

- **Nesting** follows the calls: a step started inside another step's body is its child, across
  `await` too. Two steps of the same parent run with `Promise.all` are both its children.
- **Duration** is measured with Node's own clock, so Jest's fake timers (`jest.useFakeTimers()`)
  never change it.
- **A step that never ended** (a promise the test did not await, still pending when the test ends)
  is `failed`, with the error `The step had not finished when the test ended`. Await every
  `probara.step()` that has an `async` body.
- **Hooks**: a step in `beforeEach` or `afterEach` is a step of the running test. In `beforeAll`,
  `afterAll` or a `describe` body no single test runs: the body runs, and the step is not recorded.

### Steps of a new case

A result's steps record what ran. A **case's** steps are its specification: an action, the expected
result and the test data. The outermost steps of an attempt, in the order they started, are the
steps of the case the report creates; `expected` and `data` go with them:

```js
// tests/refund.test.js
const { probara } = require('@probara/jest-reporter');

test('refunds an order', () => {
  probara.step('Open the order', () => {}, { expected: 'The order is shown', data: 'order=1042' });
  probara.step(
    'Refund it',
    () => {
      probara.step('Confirm the dialog', () => {});
    },
    { expected: 'The order is refunded' },
  );
});
```

```json
[
  {
    "steps": [
      { "action": "Open the order", "expected": "The order is shown", "data": "order=1042" },
      {
        "action": "Refund it",
        "expected": "The order is refunded",
        "steps": [{ "action": "Confirm the dialog" }]
      }
    ],
    "case": {
      "steps": [
        { "action": "Open the order", "expected": "The order is shown", "data": "order=1042" },
        { "action": "Refund it", "expected": "The order is refunded" }
      ]
    }
  }
]
```

- Nested steps (`Confirm the dialog`) are steps of the result only: how a test works, not what it
  specifies.
- Like every case field, case steps apply only when the report creates the case
  ([created cases only](https://docs.probara.net/en/developers/ci-integrations/jest/metadata/#created-cases-only)). An
  existing case keeps its own steps.
- `expected` and `data` are strings: a step given anything else is recorded with its title only,
  and one warning says so.

### Promises and thenables

A body that returns a promise ends its step when the promise settles. `probara.step()` returns a new
promise that settles the same way, so `await probara.step(...)` behaves as awaiting the body. A
rejection the test never handles is still an unhandled rejection, which fails the test, as it would
without the step.

A body can also return a **thenable** that is not a promise: a `supertest` request, a query builder.
Calling its `then()` would start it (or start it twice), so `probara.step()` returns it untouched,
and the step ends as soon as the body returns, before the request is sent. To time the request
inside the step, await it in an `async` body:

```js
// tests/api.test.js
const { probara } = require('@probara/jest-reporter');

// A stand-in for a supertest request: it runs when awaited.
const request = () => ({ then: (resolve) => resolve({ status: 200 }) });

test('answers the health check', async () => {
  const response = await probara.step('Call /health', async () => await request());
  expect(response.status).toBe(200);
});
```

```json
[{ "status": "passed", "steps": [{ "action": "Call /health", "status": "passed" }] }]
```

## `probara.attach()`

Give it a file on disk or a body. It works from anywhere a test runs: the test body, a `beforeEach`
or `afterEach` hook, a helper or a page object the test calls.

```js
// tests/receipt.test.js
const { writeFileSync } = require('node:fs');
const { tmpdir } = require('node:os');
const { join } = require('node:path');
const { probara } = require('@probara/jest-reporter');

test('prints the receipt', () => {
  const path = join(tmpdir(), 'receipt.txt');
  writeFileSync(path, 'Total: 25.00');
  probara.attach({ name: 'receipt', path });
  probara.attach({
    name: 'order',
    body: JSON.stringify({ id: 1042, total: 25 }),
    contentType: 'application/json',
  });
  probara.attach({ name: 'log', body: 'paid in 1.2 s' });
});
```

The result gets three files:

```text
receipt.txt text/plain
order.json application/json
log.txt text/plain
```

| Field         | With `path`                                                                   | With `body`                                                                                                     |
| ------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `name`        | Required: the name in Probara; the file's extension is added when it has none | Required: the name in Probara; the content type's extension is added when it has none                           |
| `path`        | The file, absolute or relative to the directory `jest` runs in                | —                                                                                                               |
| `body`        | —                                                                             | A string, a `Buffer` or a `Uint8Array`, sent as it is                                                           |
| `contentType` | Optional: guessed from the file name                                          | Optional: guessed from `name`'s extension, else `text/plain` for a string, `application/octet-stream` for bytes |

- **Copied when called.** The file (or the body) is copied at once, so a test can delete or
  overwrite it right after: what Probara gets is what it was at the call. `probara.attach()` returns
  a promise that is already resolved; awaiting it is harmless.
- **A string is sent as it is**, never decoded: pass a `Buffer` for binary content.
- **A file that cannot be attached** (it does not exist, say) is a `[probara]` warning in the
  reporter's log; the test goes on.
- **Each attempt has its own files**: a retried test uploads the files of every attempt, each to its
  own result.

### Files of a step

A file attached while a `probara.step()` runs (from the step's body or anything it calls) goes to
that step, and Probara shows it under the step:

```js
// tests/upload.test.js
const { probara } = require('@probara/jest-reporter');

test('uploads an avatar', async () => {
  await probara.step('Choose the file', async () => {
    probara.attach({ name: 'avatar.png', body: Buffer.from([137, 80, 78, 71]) });
  });
});
```

```text
avatar.png image/png
```

## Console output

With `captureOutput: true` (`PROBARA_CAPTURE_OUTPUT=true`) and the reporter's setup file, each
attempt's console output is attached as `stdout.log` (`console.log`, `console.info`,
`console.debug`) and `stderr.log` (`console.warn`, `console.error`), when there is any, its
`beforeEach` hooks and the `afterEach` hooks of its `describe` blocks included:

```js
// jest.config.js
module.exports = {
  reporters: ['default', ['@probara/jest-reporter', { captureOutput: true }]],
  setupFilesAfterEnv: ['@probara/jest-reporter/setup'],
};
```

```js
// tests/logs.test.js
beforeEach(() => {
  console.info('Seeding the cart');
});

test('prints what it does', () => {
  console.log('Opening the cart');
  console.error('Slow response: 2.1 s');
});
```

```text
stdout.log text/plain
stderr.log text/plain
```

Jest still prints the output as usual. The setup file wraps the console of each test file, in every
worker; without it, the tests run and are reported without their output, with one warning:

```text
[probara] captureOutput needs the setup file: add setupFilesAfterEnv: ['@probara/jest-reporter/setup'] to the Jest config (first seen in tests/checkout.test.js; repeats are logged at debug)
```

| Not captured                                                                                                       | Why                                                                  |
| ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| `console.dir`, `table`, `group`, `count`, `time`, `timeEnd`, `trace`, `assert`                                     | Only the five methods above are wrapped                              |
| `process.stdout.write`, `process.stderr.write`                                                                     | They bypass the console                                              |
| Output of `test.concurrent` tests                                                                                  | Their bodies run outside the `beforeEach`/`afterEach` of each test   |
| Output of a top-level `afterEach` of the test file                                                                 | The setup file's own `afterEach`, which ends the capture, runs first |
| A console method the test mocked (`jest.spyOn(console, 'log').mockImplementation(...)`, `console.log = jest.fn()`) | The mock receives the calls; a spy that calls through is captured    |
| Output in `beforeAll`, `afterAll`, a `describe` body or the module scope                                           | No single test runs there                                            |

Each stream is cut at 32 MiB, the largest file Probara takes, with a last line that says it was cut.
With Jest `projects`, give every project that needs it the setup file
([Jest projects](https://docs.probara.net/en/developers/ci-integrations/jest/sharding/#jest-projects)).

## Limits and what is skipped

| Limit                                      | What happens                                               |
| ------------------------------------------ | ---------------------------------------------------------- |
| 32 MiB per file                            | A larger file is skipped with a warning                    |
| 20 files per result                        | The files after the first 20 are skipped, with one warning |
| Images over 10 MiB or 8192 px wide or tall | Skipped with a warning (Probara converts images to WebP)   |
| Executables and scripts                    | Skipped: Probara refuses their content types               |
| A missing, unreadable or empty file        | Skipped with a warning                                     |
| A result that recorded nothing             | Its files are not uploaded                                 |

Warnings name the file, and the log ends with how many files were attached, skipped and failed.
`uploadAttachments: false` (`PROBARA_UPLOAD_ATTACHMENTS=false`) uploads none. Every result with
files costs two more requests against the organization's
[rate limit](https://docs.probara.net/en/developers/ci-integrations/jest/configuration/#rate-limit).

Files upload after their report is recorded, while the run is still open, then the run is closed.
Until then, the reporter keeps its copies in a folder of the system's temporary directory, removed
when `jest` ends; with a
[results file](https://docs.probara.net/en/developers/ci-integrations/jest/results-and-retries/#results-file), the copies
of what was not sent are kept next to it.

## When steps or files are missing

- **A step is missing.** Only `probara.step()` makes steps: `expect` calls and helper functions do
  not. A step called outside a test (`beforeAll`, a `describe` body) runs its body and records
  nothing, with a warning.
- **Steps are flattened, or fail with `The step had not finished`.** A step started without `await`
  ends after the test: await every `probara.step()` whose body is `async`.
- **The case has no steps.** Only a case the report creates takes steps. An existing case keeps its
  own.
- **A file is missing.** The log names every file it skipped and why. A file attached inside a step
  goes to that step, not to the result
  ([troubleshooting](https://docs.probara.net/en/developers/ci-integrations/jest/troubleshooting/#an-attachment-is-missing)).

Related: [Metadata and links](https://docs.probara.net/en/developers/ci-integrations/jest/metadata/) ·
[Configuration](https://docs.probara.net/en/developers/ci-integrations/jest/configuration/#captureoutput) ·
[Troubleshooting](https://docs.probara.net/en/developers/ci-integrations/jest/troubleshooting/) · Source:
[steps.md](https://github.com/cynch-me/probara-sdk/blob/main/packages/jest-reporter/docs/steps.md),
[attachments.md](https://github.com/cynch-me/probara-sdk/blob/main/packages/jest-reporter/docs/attachments.md)
