> ## Documentation Index
> Fetch the complete documentation index at: https://docs.peepsai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Send results from your lab

> File results from tests that never run in your CI, such as device tests on lab hardware, as your repository's CI runs, and have the lab verify the fixes Peeps makes.

Some pytest tests never run in your GitHub Actions or GitLab CI. A camera test, for example, runs on hardware that a lab service reserves. Your lab can send each run's JUnit file to Peeps with an [API key](https://docs.peepsai.com/api-reference/api-keys.md). Peeps files it as a CI run of your repository, at the commit and branch the lab tested, and analyzes the failures as it does for any CI run.

The lab's results can also settle a fix: when the lab can't finish inside a heal, Peeps opens the fix and lets the lab's next upload decide it. See [Verify fixes on the lab](#verify-fixes-on-the-lab).

> [!NOTE]
>
> This page is for pytest tests in a project backed by a repository. To send report files that aren't tied to a commit, see [Send results from your harness](https://docs.peepsai.com/results-api/send-results.md).

## Before you start

* **A project backed by a repository.** See [Connect a repository](https://docs.peepsai.com/repository/connect.md).
* **An API key that names the project.** [Create a key](https://docs.peepsai.com/api-reference/api-keys.md#create-a-key), choose **Only these projects**, and pick this project. A key created with **All web projects, including ones created later** can't send lab results: Peeps answers `404 not_found_or_no_access`, as if the project didn't exist.
* **Keep the key in your lab's or CI system's secret store.** The examples below read it from `PEEPS_API_KEY`.

## Send a lab run

Send one `POST` request per lab run to `/api/v1/projects/{project}/ci-results`, with a JSON body. `{project}` is the project's acronym, in any case, or its ID.

| Field | Required | What it is |
| - | - | - |
| `commitSha` | Yes | The full 40-character commit the tests were built from. |
| `gitRef` | Yes | The branch that commit is on. It must be a branch Peeps follows, or the branch of a fix waiting for the lab (see [below](#verify-fixes-on-the-lab)). |
| `junit` | Yes | The JUnit XML that pytest's `--junitxml` option wrote, as text. |
| `rootDir` | No | pytest's rootdir, relative to the repository root. Leave it out, or send `.`, when pytest runs from the repository root. When it runs from a subdirectory, its JUnit names tests from there, so send that subdirectory, such as `tests/device`. |
| `jobUrl` | No | A link to the lab run. |
| `context` | No | What the lab knows about the run: any of `cameraModel`, `firmwareVersion`, `machine`, `serialNumber` and `suite`, each 1 to 200 characters. Any other field is refused. |

```bash theme={null}
jq -n \
  --arg sha "$COMMIT_SHA" \
  --arg ref "$BRANCH" \
  --arg job "$LAB_RUN_URL" \
  --rawfile junit junit.xml \
  '{commitSha: $sha, gitRef: $ref, rootDir: ".", jobUrl: $job,
    context: {cameraModel: "CD62", firmwareVersion: "v2026.10.27.2"},
    junit: $junit}' > lab-results.json

curl --fail-with-body -sS -X POST --data-binary @lab-results.json \
  -H "Authorization: Bearer $PEEPS_API_KEY" \
  -H "Content-Type: application/json" \
  "https://app.peepsai.com/api/v1/projects/LAB/ci-results"
```

Replace `LAB` with your project's acronym. `--data-binary` sends the body with its size, which Peeps requires. Peeps answers `201` once the run is filed:

```json theme={null}
{
  "batchNumber": 42,
  "filed": 18,
  "unmatched": [
    { "path": "tests/device/test_new_mode.py", "titlePath": "test_night_mode" }
  ]
}
```

`filed` counts the tests Peeps filed. `unmatched` lists tests in the JUnit that the project has no test case for: an upload never creates a test case, so these aren't filed.

See [Send lab results](https://docs.peepsai.com/api-reference/results/send-lab-results.md) in the API reference for every field, response and error.

### What Peeps checks

A CI job proves which repository and commit it ran with its identity token. A key can't, so Peeps checks the upload against your repository instead:

* **The branch.** `gitRef` must be the headline branch or a branch listed under [Tracked branches](https://docs.peepsai.com/repository/how-tests-sync.md#tracked-branches). Otherwise Peeps answers `422 branch_not_followed`.
* **The commit.** `commitSha` must be on that branch: its latest commit or an earlier one. Otherwise Peeps answers `422 unknown_commit`.
* **The repository.** The project must still be connected to its repository. Otherwise Peeps answers `422 not_connected`.

### What Peeps files

* **One batch per upload**, named after the camera model, the firmware and the commit, such as **Lab CD62 · v2026.10.27.2 @ 1a2b3c4**. Its runs count as CI runs, and failure analysis follows as it does for a run your CI reports.
* **One run per test function and browser.** Each parametrization, such as a camera profile, is a result of that one test case. A parameter that pytest retried counts by its last attempt.
* **The lab's context comes first** on each result, so you can see which camera, firmware and machine it ran on.
* **Sending again files a second batch.** Peeps doesn't recognize a resend, so send each lab run once.
* **A lab run never starts an automatic fix.** A result sent with a key doesn't send Peeps into your repository on its own. You can still click **Heal in your repository** on a failed run. See [Fix a failing test](https://docs.peepsai.com/repository/fix-a-failing-test.md).

### Limits

| Limit | Value |
| - | - |
| Body size | 50 MiB per upload |
| Time to send the body | 120 seconds |
| Tests per upload | 2,000 |
| Sends per key | 30 a minute, shared with report files |
| Sends per organization, across all its keys | 60 a minute, shared with report files |

## Verify fixes on the lab

When Peeps fixes a pytest test, it runs the test through your [`.peeps/run-tests` hook](https://docs.peepsai.com/repository/fix-a-failing-test#run-the-test-through-your-own-tooling) if your repository has one. A lab run often takes longer than a fix can wait: reserving a device alone can take half an hour. So the hook can start the lab run and return without a result. Peeps then opens the fix, and the lab's upload for the fix decides it.

Lab-verified fixes work for pytest tests, and need the latest version of the Peeps action.

### Return from the hook without a result

To say the lab will report later, the hook must:

* exit with `0`
* write no JUnit file
* print, as its last line, exactly `peeps:pending`, optionally followed by a space and a note, such as a link to the lab run

```text theme={null}
peeps:pending https://lab.example.com/runs/42
```

A JUnit file always wins over the marker, and a non-zero exit ignores it. Print the marker last: Peeps reads only the end of the hook's output.

Once the hook says the lab will report later, Peeps doesn't run the test again during that fix: it works from the failing run's evidence and your code. It pushes the fix to its `peeps/…` branch and reads your repository's workflows at that commit, to see whether one will run the lab on that push.

A fix into another pull request's branch can't wait for the lab, so when the hook prints the marker, Peeps opens no pull request for it.

### When a workflow runs the lab on the fix's branch

A workflow under `.github/workflows/` counts when, at the fix's commit:

* **it runs on a push to the fix's branch.** Peeps follows GitHub's `branches`, `branches-ignore`, `tags`, `paths` and `paths-ignore` filters, matching `paths` against the files the fix changed. It doesn't read a job's own `if:`.
* **its file contains `/ci-results`**, the path of the lab results endpoint.

The pull request then opens as a draft, whatever **Healed fixes arrive as** says. Its description names the workflow, and the branch and commit the lab must report, and shows the hook's note as **Lab run:**. Its receipt reads **Pending: the repository's lab reports this test's result after the heal.**

When the lab uploads results for that branch at that commit, Peeps settles the draft and comments on it:

* **A pass** marks the pull request ready for review: "The lab's results for `peeps/fix-…` at `1a2b3c4` report `test_capture` passed, so Peeps marked this pull request ready for review."
* **A failure** closes it without merging: "The lab's results for `peeps/fix-…` at `1a2b3c4` report `test_capture` failed, so Peeps closed this pull request without merging." The comment quotes the failure's first line.
* **Skips alone** settle nothing, and the draft keeps waiting.

Each parametrization of the test, such as a camera profile or a browser, counts: if any of them failed, the fix fails; otherwise, if any passed, it passes. The comment ends with the upload's `jobUrl` as **Lab run:**. Peeps masks your project's secrets in the lab's text before it posts it.

Peeps accepts this branch although it doesn't follow it, but only at the exact commit it pushed, and it files only the healed test:

* An upload that doesn't report the healed test is refused with `422 test_not_reported` and files nothing.
* An upload of another commit on that branch is refused with `422 branch_not_followed`, and the message names the commit to send.
* A later upload of the same commit, such as a run on a second camera model, is filed, and leaves the pull request as it is.
* If someone already closed or merged the pull request, or pushed to its branch, Peeps leaves it alone.

A draft whose results never arrive waits until you close it.

### When no workflow runs the lab

When no workflow runs the lab on the fix's branch, or Peeps can't read your workflows, a draft would wait for a run nothing starts. So the pull request opens as **Healed fixes arrive as** says, and its description starts with **Not yet run on the lab.** It says which of the two it was.

* **Before it's merged**, an upload for its branch at its commit still counts: a failure closes the pull request, and a pass comments that the fix is verified on the lab and marks a draft ready for review.
* **After it's merged**, the lab's first upload of a followed branch at a commit that includes the merge comments on the merged pull request whether the fix held, for example "The lab's results for `main` at `9f8e7d6` on `CD62 · v2026.10.27.2`, which includes this merged fix, report `test_capture` failed: the merged fix did not hold."

The same happens when someone merges a draft before the lab has run it. Only uploads to this endpoint count: a [report file](https://docs.peepsai.com/results-api/send-results.md) carries no commit, so it never settles a fix.

> [!NOTE]
>
> On GitLab, Peeps doesn't read your CI configuration yet, so a fix whose hook printed `peeps:pending` always opens as a draft that waits for the lab's results.

### Example workflow

The hook runs before Peeps commits, so it can't know the fix's branch or commit. Your own CI can: run the lab on pushes to `peeps/…` branches. The fix's commit names the test to run in two trailers:

* **`Peeps-Lab-Test`**: the pytest node id of the test function, from the repository root, such as `tests/device/test_capture.py::test_capture`. It selects every parametrization of the test.
* **`Peeps-Lab-Browser`**: for a pytest-playwright test, the browser to pass to `pytest --browser`.

Other `peeps/…` branches, such as new tests Peeps writes, carry no trailer, so the workflow below skips them.

```yaml .github/workflows/lab-verify.yml theme={null}
name: Lab verification
on:
  push:
    branches: ["peeps/**"]
permissions:
  contents: read
jobs:
  lab:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Read the test to run
        id: test
        run: |
          echo "node=$(git log -1 --format='%(trailers:key=Peeps-Lab-Test,valueonly)')" >> "$GITHUB_OUTPUT"
          echo "browser=$(git log -1 --format='%(trailers:key=Peeps-Lab-Browser,valueonly)')" >> "$GITHUB_OUTPUT"
      - name: Run it on the lab
        if: steps.test.outputs.node != ''
        env:
          NODE_ID: ${{ steps.test.outputs.node }}
        # Your lab tooling: run pytest with "$NODE_ID" from the repository
        # root on a device, and copy its --junitxml file back as junit.xml.
        run: ./lab/run.sh "$NODE_ID" junit.xml
      - name: Send the results to Peeps
        if: always() && steps.test.outputs.node != '' && hashFiles('junit.xml') != ''
        env:
          PEEPS_API_KEY: ${{ secrets.PEEPS_API_KEY }}
          JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        run: |
          jq -n --arg sha "$GITHUB_SHA" --arg ref "$GITHUB_REF_NAME" \
            --arg job "$JOB_URL" --rawfile junit junit.xml \
            '{commitSha: $sha, gitRef: $ref, rootDir: ".", jobUrl: $job, junit: $junit}' \
            > lab-results.json
          curl --fail-with-body -sS -X POST --data-binary @lab-results.json \
            -H "Authorization: Bearer $PEEPS_API_KEY" \
            -H "Content-Type: application/json" \
            "https://app.peepsai.com/api/v1/projects/LAB/ci-results"
```

`GITHUB_REF_NAME` and `GITHUB_SHA` are the fix's branch and commit, which is what Peeps waits for. If pytest's rootdir isn't the repository root, send it as `rootDir`, or Peeps can't find the healed test and answers `422 test_not_reported`. Add `context` to say which camera and firmware the lab used.

The same workflow file can also run the lab on your headline branch. Then the lab's runs after a merge tell you whether each fix held.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.