# Pushing suites

> For the complete documentation index, see [llms.txt](/llms.txt)

Tests reach a box in one direction only: **you push them**. A box holds no
deploy key and no token to anybody's repository and never pulls, so
nothing is on it that was not pushed to it.

```sh
npx @unotest/web bundle push --box https://tests.example.com --token unob_…
```

`UNOTEST_BOX_URL` and `UNOTEST_BOX_TOKEN` supply the two values instead of
flags — a CI job usually takes them from its secret store.

## What a bundle is

`bundle push` packs the `unotest/` directory — the suite package: its
scenarios, helpers, collections, `unotest.config`, `package.json` and
lockfile — into one `tar.gz` and uploads it. The product around it never
travels, and neither do its dependencies: the box runs `npm ci` over the
suite's own manifest.

- **In a git project, git decides what belongs.** The file list is
  `git ls-files --cached --others --exclude-standard`, so whatever your
  `.gitignore` keeps out stays out. Content is read from the working tree,
  so uncommitted changes travel — the manifest records `dirty: true` and
  the viewer labels the bundle `wip`.
- **Secrets never travel.** `unotest/.env`, `.env.<name>`, `.secrets`,
  `.secrets.<name>` — and any `<name>.env` / `<name>.secrets` — are excluded
  even when committed. A file that looks like one and still reaches the
  packer stops the push. Environment values live on the box; see
  [Environments & values](/box/environments-and-values/).
- **Also excluded:** `node_modules`, `.git`, run and queue directories,
  `.unotest/`, `.tmp`, `.debug`, the viewer lock file.
- **Install hooks are stripped** from the packed `package.json`
  (`postinstall`, `prepare`, …): a box installs the suite, it does not run
  your project's hooks. The push reports what it removed.
- **Your dependencies' install scripts run on the box**, so a native
  module builds or fetches its binary as usual. They run in a container
  of their own — the bundle tree is its only writable mount, and none of
  the box's state is in reach — which is what makes running somebody's
  `postinstall` on a shared machine affordable.
- **The id is the content.** `bundleId` is a hash of the packed files, so
  re-pushing an unchanged suite is an instant no-op (`already had this
  exact bundle`), and two people packing the same tree get the same id.
- **Names of externals travel, values do not.** The bundle carries the
  names your `.env` / `.secrets` files (or their `.example` twins) declare,
  so the box can answer the push with what an environment cannot supply.

### Refused before upload

What would fail on the box is refused on your machine, with the file, the
line and the fix:

- a scenario that reads a file outside `unotest/` (`MODULE_NOT_FOUND` on
  the box, hours later);
- no lockfile, or no `@unotest/web` in the suite's dependencies;
- a symlink;
- a secrets file inside `unotest/`;
- a suite over 256 MB;
- a schedules registry that cannot be read — the box reads it to know
  what runs on its own.

`--dry-run` packs and checks without uploading; `--out <file>` keeps the
archive (`tar -tzf` shows what is inside); `--json` prints
`{ bundleId, status }`.

### Refused by the box

The box verifies the archive before writing a single file and answers with
a typed refusal; the CLI prints it with what to do next:

| Refusal | Meaning |
|---|---|
| `unauthorized` | The token is unknown, revoked, or for another box. |
| `min-version` | The bundle pins an `@unotest/web` older than the box runs. Bump it in `unotest/package.json`, commit the lockfile, push again. |
| `scope-mismatch` | This repository belongs to another project on the box than the token's. |
| `too-large` | Over the box's upload limit (128 MB by default). |
| `malformed` / `id-mismatch` | The upload did not arrive intact — retry; if it repeats, a proxy is rewriting the body. |

The token alone decides which project a bundle lands in. Nothing in the
request can name one.

After a successful push the box may add **notes**: environments that hold
no value for a name the suite declares, with the command that sets it.

## Running after a push

```sh
npx @unotest/web bundle push --run --env test --collection smoke
```

`--run` orders a run of the bundle just pushed, on the same token:

- **`--env <name>` is required** — which environments a box has is the
  operator's decision.
- **`--collection <name>`** names what to run; repeat for several. Without
  it the box runs what that environment's schedules name.
- **`--pr <number>`** ties the push to a pull request. The queue shows it
  (`PR #412@1a2b3c4`), and a newer push of the same pull request withdraws
  its older runs that are still **waiting**; a run that already started is
  never killed.

The box answers **202** as soon as the runs are queued, with their run
ids — a suite takes minutes, and a request held open that long is a
timeout, not a result. The ids are real from that moment; `--json` carries
them (`runs: [{ runId, collection }]`, plus `superseded` when older runs
were withdrawn), so a CI log can print links into the viewer at once.

Run requests are rate-limited (60 per minute per box, and per token).

Refusals: `unknown-environment`, `scope-mismatch` (the environment belongs
to another project), `unknown-bundle` (the box no longer holds it — push
again), `no-collections` (nothing named and no schedule for that
environment), `malformed` (the box is older than this CLI — push without
`--run`).

### A minimal CI job

```yaml
jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npx @unotest/web bundle push --run --env test --collection smoke --json
        env:
          UNOTEST_BOX_URL: ${{ secrets.UNOTEST_BOX_URL }}
          UNOTEST_BOX_TOKEN: ${{ secrets.UNOTEST_BOX_TOKEN }}
```

Exit codes: `0` pushed (and queued), `1` the box refused or was
unreachable, `2` the suite was refused on your machine — nothing left it.
With `--json`, a failed run order after a successful upload still reports
the bundle, so the job does not push it again for nothing.

## From a GitHub webhook

With the box's GitHub App installed (see
[Administration](/box/administration/#continuous-integration)), the box
also reacts to your repository's events. Every mapping lives on the box,
never in the repository:

- a `deployment` event's environment name is mapped to one of the box's
  environments; an unmapped name is logged and dropped, never guessed;
- pull requests run in a named environment, or each in its own preview
  environment (below), running the collections the project's CI
  configuration names or the environment's schedules;
- the bundle is matched **by commit sha** against what has been pushed. A
  commit no bundle was pushed for waits up to 15 minutes for your CI job to
  upload, then the check fails saying so;
- **fork pull requests are not run.** Their head is somebody else's branch,
  and running it would hand it the environment's secrets. A maintainer
  re-requesting the check in GitHub is the opt-in.

The box reports one check per collection (`unotest / <collection>`):
queued when the event arrives, in progress when the run takes its slot,
completed with a summary and a link into the viewer.

## Pull-request environments

A project with preview deployments can give each pull request an
environment of its own. unotest configures a template with you:

- the target URL shape, with `{pr}` where the number goes
  (`https://pr-{pr}.preview.acme.dev`) — the box substitutes, it never
  deploys;
- variables every preview run gets;
- how many may exist at once (5 by default) and how long one survives
  after its last run (72 hours by default).

The environment is named `pr-<n>`, created when the pull request is first
seen and removed when it closes or goes unused past the TTL. Preview
environments learn the suite's schedules but never arm them: what CI asks
for runs, the clock does not. Secrets are set per environment by an
administrator; a preview environment that needs one is not one that should
be created automatically.

## Bundles in the viewer

On a box the viewer shows what it is running:

- **Bundle badge** in the status bar — `main@1a2b3c4`, or `wip` for a
  bundle from an uncommitted tree, with the author, the pinned
  `@unotest/web` and who switched the environment last.
- **Switching.** Clicking the badge lists the bundles pushed to this
  project and switches the environment to one. The switch is a maintenance
  ticket: a running suite finishes, then the code changes, then the next
  run starts on the new bundle. An environment with `allowSwitch` off
  refuses (*follows its own branch — it cannot be switched by hand*).
- **Following the branch.** An environment follows the newest bundle of
  its `defaultBranch` until somebody selects one by hand; that selection
  survives restarts and later pushes until the next person switches.
  Schedules always run the newest bundle of `defaultBranch`, regardless of
  what anyone selected — a tester's switch never redirects tonight's
  suite.
- **Runs remember their bundle.** The runs list marks runs that ran on
  another bundle, and a **this bundle only** filter narrows the history to
  the bundle in front of you.
- **Project / environment switcher** in the tree header, where a local
  viewer shows the checkout directory. Each environment is its own viewer
  process, so switching is a full page load.

## Retention

Each push leaves an archive and, once installed, a `node_modules`. The box
keeps the newest 5 bundles per branch regardless of age and removes older
ones after 30 days (both configurable) — never a bundle an environment
currently points at or a waiting run has asked for.
