@@ -16,6 +16,8 @@
finalrun.app
•
+ Docs
+ •
Blog
•
Cloud Device Waitlist
@@ -49,11 +51,19 @@
## Install
+**macOS / Linux**
+
```sh
curl -fsSL https://raw.githubusercontent.com/final-run/finalrun-agent/main/scripts/install.sh | bash
```
-Sets up Node.js, the CLI, AI coding agent skills, and platform tools. Run `finalrun doctor` to verify host readiness.
+**Windows (PowerShell)**
+
+```powershell
+irm https://raw.githubusercontent.com/final-run/finalrun-agent/main/scripts/install.ps1 | iex
+```
+
+iOS local testing requires macOS (`xcodebuild`). On Windows, use `finalrun cloud` for iOS — Android works locally. CI flags, env overrides, and the artifact list live in the [latest release notes](https://github.com/final-run/finalrun-agent/releases/latest).
## Write and Run Your First Test Using AI Agents
@@ -133,6 +143,8 @@ finalrun suite auth_smoke.yaml --platform android --model google/gemini-3-flash-
## Documentation
+Full docs: **[docs.finalrun.app](https://docs.finalrun.app/)**
+
- [Autotrigger FinalRun tests (AI agents)](docs/autotrigger-finalrun.md) — when coding agents should generate, validate, and run tests after UI work
- [YAML Tests](docs/yaml-tests.md) — test format, fields, suites, and environment placeholders
- [CLI Reference](docs/cli-reference.md) — all commands, flags, and report tools
diff --git a/RELEASING.md b/RELEASING.md
new file mode 100644
index 0000000..20ebd13
--- /dev/null
+++ b/RELEASING.md
@@ -0,0 +1,198 @@
+# Releasing finalrun-agent
+
+This is the runbook for cutting a new release. The normal path uses GitHub Actions and takes one click. There's also a manual fallback for when CI is unavailable or you want to release from your laptop.
+
+## What a release contains
+
+Every release ships **10 download files** plus a checksum file for each (20 files total) on the GitHub Releases page:
+
+- A small `finalrun` program for each of: macOS Apple Silicon, macOS Intel, Linux x86_64, Linux ARM64, Windows x86_64.
+- A "runtime bundle" (`.tar.gz`) for each of those five platforms — this contains the extra files local-test execution needs (driver app builds, gRPC schema, the report-server web UI). The Windows runtime is Android-only; iOS local execution requires macOS.
+
+Users install the `finalrun` program by running:
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/final-run/finalrun-agent/main/scripts/install.sh | bash
+```
+
+The installer downloads the right binary for their machine, and (in interactive mode) the matching runtime bundle.
+
+There is **no npm publication.** The CLI is a binary, not an npm package.
+
+---
+
+## How to cut a release (the normal way)
+
+You do three things. Steps 1 and 2 are a small PR. Step 3 is one click.
+
+### 1. Open a release PR
+
+Make a branch and bump the version:
+
+```sh
+git checkout -b release/vX.Y.Z
+npm version X.Y.Z -w @finalrun/finalrun-agent --no-git-tag-version
+```
+
+Then edit [`CHANGELOG.md`](./CHANGELOG.md) and add a section for your new version under `## [Unreleased]`. Use this format:
+
+```markdown
+## [X.Y.Z] - YYYY-MM-DD
+
+### Added
+- (new things)
+
+### Changed
+- (behavior changes)
+
+### Fixed
+- (bug fixes)
+```
+
+This is the **only** place you write release notes. The release process pulls this section from `CHANGELOG.md` and puts it on the GitHub Releases page automatically — you never edit the GitHub Releases page directly.
+
+Commit and push:
+
+```sh
+git add packages/cli/package.json package.json CHANGELOG.md
+git commit -m "Release vX.Y.Z"
+git push -u origin release/vX.Y.Z
+```
+
+Open a PR from this branch to `main`, get review, merge.
+
+### 2. Trigger the release
+
+After the PR is merged, in your browser:
+
+1. Go to the repo's **Actions** tab on GitHub
+2. Click **Release** in the left sidebar
+3. Click **Run workflow** on the right, pick `main`, click the green **Run workflow** button
+
+Or from your terminal:
+
+```sh
+gh workflow run release.yml -f branch=main
+gh run watch # follow progress live
+```
+
+### 3. Verify it shipped
+
+The workflow takes about 4 minutes. When it's done:
+
+```sh
+gh release view vX.Y.Z # see the release page contents
+```
+
+Or open `https://github.com/final-run/finalrun-agent/releases/tag/vX.Y.Z` in a browser.
+
+You should see:
+
+- 20 downloadable files (10 binaries/tarballs + 10 checksum files)
+- A release body that includes install instructions and your CHANGELOG section
+
+That's it — `finalrun upgrade` on user machines, and fresh `curl ... | bash` runs, will now pull your new version.
+
+---
+
+## What the workflow checks before publishing
+
+The workflow refuses to release if any of these fail. This is your safety net.
+
+- The version in `packages/cli/package.json` must look like a valid version (e.g. `1.2.3` or `0.2.0-rc.1`).
+- A tag named `vX.Y.Z` must not already exist on origin (so you can't accidentally overwrite a previous release).
+- `CHANGELOG.md` must have a `## [X.Y.Z]` section. **No release notes, no release.**
+
+If any of these fail, the workflow exits early with a message telling you exactly what to fix. Nothing ships.
+
+---
+
+## Manual fallback (no CI needed)
+
+Use this when GitHub Actions is down, you don't have access to it, or you want to release straight from your laptop. The result is identical — same files, same release page.
+
+You'll need:
+
+- `bun` installed: `curl -fsSL https://bun.sh/install | bash` (one time)
+- `gh` CLI logged in: `gh auth login` (one time)
+- About 5 minutes
+
+Steps:
+
+```sh
+# 1. Be on the merged release commit on main
+git checkout main && git pull
+
+# 2. Set the version you're releasing
+VERSION=X.Y.Z
+
+# 3. Build all 10 release files
+./scripts/build-binary.sh
+for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64 windows-x64; do
+ npm run build:tarball --workspace=@finalrun/local-runtime -- --target=$t
+done
+
+# 4. Tag the commit and push the tag
+git tag -a "v$VERSION" -m "Release v$VERSION"
+git push origin "v$VERSION"
+
+# 5. Build the release notes (combines the static install instructions with
+# your CHANGELOG section — same logic the workflow uses)
+awk -v marker="## [${VERSION}]" '
+ index($0, marker) == 1 { c=1; print; next }
+ c && /^## \[/ { exit }
+ c { print }
+' CHANGELOG.md > /tmp/version-notes.md
+
+{
+ cat .github/release-notes-template.md
+ echo ""
+ echo "---"
+ echo ""
+ echo "## What's changed in this release"
+ echo ""
+ tail -n +2 /tmp/version-notes.md
+} > /tmp/release-body.md
+
+# 6. Create the release with all artifacts attached
+gh release create "v$VERSION" \
+ --title "FinalRun $VERSION" \
+ --notes-file /tmp/release-body.md \
+ --latest \
+ dist/binaries/finalrun-* \
+ packages/local-runtime/dist/finalrun-runtime-*.tar.gz*
+```
+
+For a pre-release (e.g. `0.2.0-rc.1`), swap `--latest` for `--prerelease` so it doesn't displace the current "latest" pointer.
+
+---
+
+## If the workflow fails partway through
+
+The workflow is designed so the tag isn't created until the build has succeeded. So if it fails before that point, just **fix the issue and re-run** — there's no leftover state to clean up.
+
+If it fails AFTER the tag is created (rare — only happens if the GitHub Releases upload itself flakes), do this cleanup before retrying:
+
+```sh
+git push origin :refs/tags/vX.Y.Z # delete the tag from GitHub
+git tag -d vX.Y.Z # delete it locally too
+gh release delete vX.Y.Z --yes # delete the partial release if any
+```
+
+Then re-trigger the workflow.
+
+---
+
+## Rolling back a release that shipped broken
+
+If a release goes out and turns out to be broken:
+
+```sh
+gh release delete vX.Y.Z --yes # this rolls back the "latest" pointer
+git push origin :refs/tags/vX.Y.Z # delete the tag
+git tag -d vX.Y.Z # locally too
+```
+
+Now fix the issue on a new PR, then cut a fresh release (either re-using `vX.Y.Z` or moving to `vX.Y.Z+1` — your call).
+
+Note: anyone who already installed the broken version still has it on their disk. They'll get the new version when they run `finalrun upgrade` or re-run the curl install command. The public install URL goes through "latest", so deleting the broken release immediately stops new users from getting it.
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
index b42355e..6041397 100644
--- a/docs/cli-reference.md
+++ b/docs/cli-reference.md
@@ -26,12 +26,14 @@ Flags for `test` and `suite`:
| `--model ` | AI model (e.g. `google/gemini-3-flash-preview`). Falls back to `.finalrun/config.yaml`. |
| `--env ` | Environment name (matches `.finalrun/env/.yaml`). Falls back to config. |
| `--app ` | Path to `.apk` or `.app` binary. Overrides the app identity in config. See [configuration.md](configuration.md) for details. |
-| `--api-key ` | Override the provider API key. |
+| `--api-key ` | Override the provider API key. Only valid when a single provider is in use across all features; use env vars when features target multiple providers. |
| `--debug` | Enable debug logging. |
| `--max-iterations ` | Limit AI action iterations per step. |
CLI flags always take precedence over `.finalrun/config.yaml`.
+For workspace-level `model`, `reasoning`, and per-feature `features:` overrides (including mixed-provider setups), see [configuration.md](configuration.md#supported-configurations).
+
### Examples
```sh
diff --git a/docs/codebase-walkthrough.md b/docs/codebase-walkthrough.md
index b849990..009772d 100644
--- a/docs/codebase-walkthrough.md
+++ b/docs/codebase-walkthrough.md
@@ -500,6 +500,8 @@ Standard Grounder (hierarchy-based)
**Why Vercel AI SDK?** It provides a unified interface across providers, so the goal executor doesn't need provider-specific code for each LLM.
+Model and reasoning effort are configurable per feature (planner, grounder, and the specialized grounders) via the `features:` block in `.finalrun/config.yaml`. See [configuration.md](configuration.md) for the YAML shape.
+
---
## 8. The Device Layer (Physical Actions)
diff --git a/docs/configuration.md b/docs/configuration.md
index 0128f28..13a5a60 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -31,9 +31,14 @@ The workspace config defines defaults used by the CLI when flags are omitted.
| `app.bundleId` | iOS bundle identifier (e.g. `com.example.myapp`) |
| `env` | Default environment name (used when `--env` is omitted) |
| `model` | Default AI model in `provider/model` format (used when `--model` is omitted) |
+| `reasoning` | Default reasoning effort for all features: `minimal`, `low`, `medium`, or `high`. `minimal` is OpenAI-only. |
+| `features..model` | Per-feature model override in `provider/model` format. |
+| `features..reasoning` | Per-feature reasoning effort override. |
At least one of `app.packageName` or `app.bundleId` is required.
+Valid feature names: `planner`, `grounder`, `visual-grounder`, `scroll-index-grounder`, `input-focus-grounder`, `launch-app-grounder`, `set-location-grounder`.
+
### Example
```yaml
@@ -43,8 +48,99 @@ app:
bundleId: com.example.myapp
env: dev
model: google/gemini-3-flash-preview
+reasoning: medium
+
+# Optional — unlisted features inherit the default model and reasoning.
+features:
+ planner:
+ model: anthropic/claude-opus-4-7
+ reasoning: high
+ scroll-index-grounder:
+ reasoning: low
+```
+
+### Supported Providers
+
+Use any of these prefixes in the `provider/model` format:
+
+- `openai/` (e.g. `openai/gpt-5.4-mini`)
+- `google/` (e.g. `google/gemini-3-flash-preview`)
+- `anthropic/` (e.g. `anthropic/claude-opus-4-7`)
+
+Model names are passed straight to the provider — consult the provider's docs for which models accept reasoning effort.
+
+### Reasoning Levels by Provider
+
+| Provider | Accepted `reasoning` values |
+|---|---|
+| `openai` | `minimal`, `low`, `medium`, `high` |
+| `google` | `low`, `medium`, `high` |
+| `anthropic` | `low`, `medium`, `high` |
+
+Setting `reasoning: minimal` on a Google- or Anthropic-routed feature fails at run time with a message naming the offending feature.
+
+When neither workspace `reasoning:` nor a per-feature `reasoning:` is set, FinalRun applies built-in fallbacks:
+
+- `planner` → `medium`
+- every grounder (`grounder`, `visual-grounder`, `scroll-index-grounder`, `input-focus-grounder`, `launch-app-grounder`, `set-location-grounder`) → `low`
+
+### Anthropic Model Compatibility
+
+`anthropic/...` models must be Claude 4.5 or later (Sonnet 4.5+, Opus 4.5+, Haiku 4.5+, including Sonnet 4.6, Opus 4.6, and Opus 4.7). FinalRun uses Anthropic's native structured-output API (`output_config.format`) for guaranteed JSON, and only Claude 4.5+ supports it. Older Anthropic models will return HTTP 400 from the API. OpenAI and Google paths have no equivalent restriction.
+
+### Supported Configurations
+
+Three shapes are supported. Pick the simplest one that fits.
+
+**1. One model, one reasoning level (simplest).** Every feature uses the same model and effort:
+
+```yaml
+model: openai/gpt-5.4-mini
+reasoning: low
+```
+
+**2. Same provider, per-feature reasoning tuning.** One API key, one provider, but effort tuned per feature:
+
+```yaml
+model: openai/gpt-5.4-mini
+reasoning: low
+
+features:
+ planner:
+ reasoning: high # planner only — keeps the workspace model
+ scroll-index-grounder:
+ reasoning: minimal # cheap fast grounding
+ # unlisted features inherit model + reasoning from the top
+```
+
+**3. Mixed providers across features.** Different providers for different features:
+
+```yaml
+model: google/gemini-3-flash-preview # default for anything unlisted
+reasoning: medium
+
+features:
+ planner:
+ model: anthropic/claude-opus-4-7
+ reasoning: high
+ grounder:
+ model: openai/gpt-5.4-mini
+ reasoning: minimal
```
+Mixed-provider mode requires **every** referenced provider's env var to be set (`OPENAI_API_KEY`, `GOOGLE_API_KEY`, `ANTHROPIC_API_KEY` — see [environment.md](environment.md)). The `--api-key` CLI flag is rejected in this mode.
+
+### Per-Feature Overrides
+
+The `features:` block lets you tune each LLM call independently. Each feature drives a distinct prompt:
+
+- `planner` — decides the next user action from the current screen.
+- `grounder` — picks the UI element for an action.
+- `visual-grounder` — visual fallback when text grounding fails.
+- `scroll-index-grounder`, `input-focus-grounder`, `launch-app-grounder`, `set-location-grounder` — specialized grounders for their respective actions.
+
+Both `model` and `reasoning` are optional per feature. Any unset field falls back to the workspace-level default (`model:` / `reasoning:`), and any unlisted feature inherits both defaults.
+
## App Identity
FinalRun needs to know which app to launch on the device. The app identity is resolved in this order:
diff --git a/docs/environment.md b/docs/environment.md
index 1d7eabb..24daf8c 100644
--- a/docs/environment.md
+++ b/docs/environment.md
@@ -63,6 +63,8 @@ FinalRun resolves API keys by provider prefix:
Keys are read from `process.env` and from workspace-root `.env` / `.env.`. You can also pass `--api-key` to override.
+If `.finalrun/config.yaml` uses different providers across features (via the `features:` block in [configuration.md](configuration.md)), set the env var for each provider you reference. `--api-key` is only accepted when a single provider is in play.
+
## Git: Keep Secrets Out of the Repo
**Do not commit** `.env` files. Add the following to your app repository's `.gitignore`:
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 9d7b311..a38f810 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -6,6 +6,12 @@ FinalRun looks for `.finalrun/` by walking up from your current directory. Make
**`Error: API key not configured`**
Set the matching environment variable for your model provider. For `google/...`, set `GOOGLE_API_KEY` in your `.env` or shell. See [environment.md](environment.md#ai-provider-api-keys).
+**`Error: --api-key is only valid when a single provider is active`**
+Your `.finalrun/config.yaml` targets multiple providers via the `features:` block (see [configuration.md](configuration.md)). Drop `--api-key` and set each provider's env var instead: `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `ANTHROPIC_API_KEY`.
+
+**`Error: Reasoning level "minimal" is only supported for OpenAI`**
+The `minimal` reasoning level only exists on OpenAI. Change the workspace or feature override to `low`, `medium`, or `high` — or route that feature to an OpenAI model.
+
**`Error: No Android emulator running`**
Start an emulator with `emulator -avd ` or launch one from Android Studio. Run `finalrun doctor --platform android` to verify.
diff --git a/mintlify-docs/.atlas-analysis.json b/mintlify-docs/.atlas-analysis.json
new file mode 100644
index 0000000..f050d12
--- /dev/null
+++ b/mintlify-docs/.atlas-analysis.json
@@ -0,0 +1,84 @@
+{
+ "projectType": "cli-tool",
+ "projectName": "FinalRun",
+ "projectDescription": "An AI-driven CLI that tests Android and iOS apps using natural language YAML specs, executing steps on real devices or emulators with Gemini, GPT, or Claude.",
+ "theme": "luma",
+ "primaryColor": "#3f4fe8",
+ "lightColor": "#d0a7f7",
+ "darkColor": "#6c4cfc",
+ "navigation": {
+ "tabs": [
+ {
+ "tab": "Docs",
+ "groups": [
+ {
+ "group": "Get Started",
+ "pages": [
+ "introduction",
+ "quickstart",
+ "installation"
+ ]
+ },
+ {
+ "group": "Writing Tests",
+ "pages": [
+ "tests/yaml-format",
+ "tests/suites",
+ "tests/placeholders"
+ ]
+ },
+ {
+ "group": "Configuration",
+ "pages": [
+ "configuration/workspace",
+ "configuration/environments",
+ "configuration/ai-providers"
+ ]
+ },
+ {
+ "group": "Running Tests",
+ "pages": [
+ "running/cli-reference",
+ "running/ai-agent-skills",
+ "running/reports"
+ ]
+ },
+ {
+ "group": "Help",
+ "pages": [
+ "troubleshooting",
+ "faq"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "keyFeatures": [
+ "Natural language YAML test specs for Android and iOS",
+ "AI-powered test execution using Gemini, GPT, or Claude",
+ "Three-phase test model: setup, steps, expected_state",
+ "AI agent skills for generating, running, and fixing tests",
+ "Local report viewer with video, screenshots, and device logs",
+ "BYOK (Bring Your Own Key) — use your own AI provider API key",
+ "Multi-environment support with secrets and variable bindings",
+ "One-command install and host readiness check via finalrun doctor"
+ ],
+ "publicApiSurface": [
+ "finalrun test ",
+ "finalrun suite ",
+ "finalrun check [selectors...]",
+ "finalrun doctor",
+ "finalrun runs",
+ "finalrun start-server",
+ "finalrun stop-server",
+ "finalrun server-status",
+ "/finalrun-generate-test skill",
+ "/finalrun-use-cli skill",
+ "/finalrun-test-and-fix skill",
+ ".finalrun/config.yaml",
+ ".finalrun/tests//.yaml",
+ ".finalrun/suites/.yaml",
+ ".finalrun/env/.yaml"
+ ]
+}
diff --git a/mintlify-docs/.mintignore b/mintlify-docs/.mintignore
new file mode 100644
index 0000000..9922f06
--- /dev/null
+++ b/mintlify-docs/.mintignore
@@ -0,0 +1,7 @@
+# Mintlify automatically ignores these files and directories:
+# .git, .github, .claude, .agents, .idea, node_modules,
+# README.md, LICENSE.md, CHANGELOG.md, CONTRIBUTING.md
+
+# Draft content
+drafts/
+*.draft.mdx
diff --git a/mintlify-docs/AGENTS.md b/mintlify-docs/AGENTS.md
new file mode 100644
index 0000000..cebd973
--- /dev/null
+++ b/mintlify-docs/AGENTS.md
@@ -0,0 +1,33 @@
+> **First-time setup**: Customize this file for your project. Prompt the user to customize this file for their project.
+> For Mintlify product knowledge (components, configuration, writing standards),
+> install the Mintlify skill: `npx skills add https://mintlify.com/docs`
+
+# Documentation project instructions
+
+## About this project
+
+- This is a documentation site built on [Mintlify](https://mintlify.com)
+- Pages are MDX files with YAML frontmatter
+- Configuration lives in `docs.json`
+- Run `mint dev` to preview locally
+- Run `mint broken-links` to check links
+
+## Terminology
+
+{/* Add product-specific terms and preferred usage */}
+{/* Example: Use "workspace" not "project", "member" not "user" */}
+
+## Style preferences
+
+{/* Add any project-specific style rules below */}
+
+- Use active voice and second person ("you")
+- Keep sentences concise — one idea per sentence
+- Use sentence case for headings
+- Bold for UI elements: Click **Settings**
+- Code formatting for file names, commands, paths, and code references
+
+## Content boundaries
+
+{/* Define what should and shouldn't be documented */}
+{/* Example: Don't document internal admin features */}
diff --git a/mintlify-docs/CONTRIBUTING.md b/mintlify-docs/CONTRIBUTING.md
new file mode 100644
index 0000000..8863ee4
--- /dev/null
+++ b/mintlify-docs/CONTRIBUTING.md
@@ -0,0 +1,34 @@
+> **Customize this file**: Tailor this template to your project by noting specific contribution types you're looking for, adding a Code of Conduct, or adjusting the writing guidelines to match your style.
+
+# Contribute to the documentation
+
+Thank you for your interest in contributing to our documentation! This guide will help you get started.
+
+## How to contribute
+
+### Option 1: Edit directly on GitHub
+
+1. Navigate to the page you want to edit
+2. Click the "Edit this file" button (the pencil icon)
+3. Make your changes and submit a pull request
+
+### Option 2: Local development
+
+1. Fork and clone this repository
+2. Install the Mintlify CLI: `npm i -g mint`
+3. Create a branch for your changes
+4. Make changes
+5. Navigate to the docs directory and run `mint dev`
+6. Preview your changes at `http://localhost:3000`
+7. Commit your changes and submit a pull request
+
+For more details on local development, see our [development guide](development.mdx).
+
+## Writing guidelines
+
+- **Use active voice**: "Run the command" not "The command should be run"
+- **Address the reader directly**: Use "you" instead of "the user"
+- **Keep sentences concise**: Aim for one idea per sentence
+- **Lead with the goal**: Start instructions with what the user wants to accomplish
+- **Use consistent terminology**: Don't alternate between synonyms for the same concept
+- **Include examples**: Show, don't just tell
diff --git a/mintlify-docs/LICENSE b/mintlify-docs/LICENSE
new file mode 100644
index 0000000..5411374
--- /dev/null
+++ b/mintlify-docs/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Mintlify
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/mintlify-docs/README.md b/mintlify-docs/README.md
new file mode 100644
index 0000000..4552fbc
--- /dev/null
+++ b/mintlify-docs/README.md
@@ -0,0 +1,55 @@
+# Mintlify Starter Kit
+
+Use the starter kit to get your docs deployed and ready to customize.
+
+Click the green **Use this template** button at the top of this repo to copy the Mintlify starter kit. The starter kit contains examples with
+
+- Guide pages
+- Navigation
+- Customizations
+- API reference pages
+- Use of popular components
+
+**[Follow the full quickstart guide](https://starter.mintlify.com/quickstart)**
+
+## AI-assisted writing
+
+Set up your AI coding tool to work with Mintlify:
+
+```bash
+npx skills add https://mintlify.com/docs
+```
+
+This command installs Mintlify's documentation skill for your configured AI tools like Claude Code, Cursor, Windsurf, and others. The skill includes component reference, writing standards, and workflow guidance.
+
+See the [AI tools guides](/ai-tools) for tool-specific setup.
+
+## Development
+
+Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally. To install, use the following command:
+
+```
+npm i -g mint
+```
+
+Run the following command at the root of your documentation, where your `docs.json` is located:
+
+```
+mint dev
+```
+
+View your local preview at `http://localhost:3000`.
+
+## Publishing changes
+
+Install our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch.
+
+## Need help?
+
+### Troubleshooting
+
+- If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI.
+- If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`.
+
+### Resources
+- [Mintlify documentation](https://mintlify.com/docs)
diff --git a/mintlify-docs/configuration/ai-providers.mdx b/mintlify-docs/configuration/ai-providers.mdx
new file mode 100644
index 0000000..de22a23
--- /dev/null
+++ b/mintlify-docs/configuration/ai-providers.mdx
@@ -0,0 +1,92 @@
+---
+title: "Connect FinalRun to Google, OpenAI, or Anthropic AI"
+sidebarTitle: "AI Providers"
+description: "Bring your own API key to FinalRun and configure Google Gemini, OpenAI GPT, or Anthropic Claude as the AI model that drives your Android and iOS test runs."
+---
+
+FinalRun uses a bring-your-own-key (BYOK) model — it does not proxy AI requests through its own infrastructure. When you run a test, the CLI calls your chosen AI provider directly using the API key you supply. This gives you full visibility into token usage and billing, and lets you use whichever model tier your team has access to.
+
+
+ Prefer managed AI without provisioning a provider account? See [Cloud API Key](/configuration/cloud-api-key) for the FinalRun Cloud option.
+
+
+## Supported providers
+
+| Provider prefix | Environment variable | Recommended model family |
+|---|---|---|
+| `google/...` | `GOOGLE_API_KEY` | Gemini 3 family and above |
+| `openai/...` | `OPENAI_API_KEY` | GPT-5 family and above |
+| `anthropic/...` | `ANTHROPIC_API_KEY` | Claude Sonnet 4 / Opus 4 and above |
+
+The provider is inferred from the prefix of the `--model` value or the `model` field in `.finalrun/config.yaml`.
+
+## Setting your API key
+
+You can supply an API key in three ways:
+
+
+
+ Add the key to a `.env` file at your workspace root. This is the recommended approach for local development.
+
+ ```bash
+ echo "GOOGLE_API_KEY=your-key-here" > .env
+ ```
+
+ The file is read automatically on every run. See [Managing environments and secrets](/configuration/environments) for dotenv load order details.
+
+
+ Export the variable in your shell session or CI environment:
+
+ ```bash
+ export GOOGLE_API_KEY=your-key-here
+ ```
+
+ Shell environment variables take the highest priority in FinalRun's load order.
+
+
+ Pass the key directly as a CLI flag for a one-off run without modifying any file:
+
+ ```bash
+ finalrun test smoke.yaml --api-key your-key-here --model google/gemini-3-flash-preview
+ ```
+
+
+
+## Setting a default model
+
+Add a `model` field to `.finalrun/config.yaml` so you don't need to pass `--model` on every command:
+
+```yaml .finalrun/config.yaml
+model: google/gemini-3-flash-preview
+```
+
+The model value must use `provider/model-name` format. Examples: `google/gemini-3-flash-preview`, `anthropic/claude-sonnet-4-6`, `openai/gpt-5`.
+
+
+ Set `model` in `.finalrun/config.yaml` once during workspace setup. After that, commands like `finalrun test smoke.yaml --platform android` work without an explicit `--model` flag.
+
+
+## Provider setup examples
+
+
+
+```bash Google Gemini
+echo "GOOGLE_API_KEY=your-key-here" > .env
+finalrun test smoke.yaml --platform android --model google/gemini-3-flash-preview
+```
+
+```bash OpenAI GPT
+echo "OPENAI_API_KEY=your-key-here" > .env
+finalrun test smoke.yaml --platform android --model openai/gpt-5
+```
+
+```bash Anthropic Claude
+echo "ANTHROPIC_API_KEY=your-key-here" > .env
+finalrun test smoke.yaml --platform ios --model anthropic/claude-sonnet-4-6
+```
+
+
+
+
+ Test runs consume AI provider tokens. Standard API billing from your provider applies — FinalRun does not add any markup or usage fees on top of provider costs.
+
diff --git a/mintlify-docs/configuration/cloud-api-key.mdx b/mintlify-docs/configuration/cloud-api-key.mdx
new file mode 100644
index 0000000..10fde34
--- /dev/null
+++ b/mintlify-docs/configuration/cloud-api-key.mdx
@@ -0,0 +1,67 @@
+---
+title: "Get your FinalRun Cloud API key"
+sidebarTitle: "Cloud API Key"
+description: "Sign up for FinalRun Cloud, generate an API key, and set FINALRUN_API_KEY for the CLI."
+---
+
+FinalRun Cloud runs tests on FinalRun's hosted infrastructure with managed AI access, so you don't need to bring your own provider key. New accounts include $5 of credits.
+
+VIDEO
+
+## 1. Sign up
+
+Create an account at [https://cloud.finalrun.app](https://cloud.finalrun.app).
+
+## 2. Generate an API key
+
+In the dashboard, open the **API keys** section and create a new key. Copy the value — it is shown only once.
+
+## 3. Set `FINALRUN_API_KEY`
+
+The CLI reads `FINALRUN_API_KEY` from the same sources as provider keys.
+
+
+
+ Add the key to a `.env` file at your workspace root:
+
+ ```bash
+ echo "FINALRUN_API_KEY=your-key-here" > .env
+ ```
+
+ See [Managing environments and secrets](/configuration/environments) for dotenv load order details.
+
+
+ Export the variable in your shell session or CI environment:
+
+ ```bash
+ export FINALRUN_API_KEY=your-key-here
+ ```
+
+ Shell variables take the highest priority in FinalRun's load order.
+
+
+ Pass the key directly as a CLI flag:
+
+ ```bash
+ finalrun cloud test smoke.yaml --api-key your-key-here
+ ```
+
+
+
+## Run a cloud test
+
+```bash
+finalrun cloud test smoke.yaml --platform android
+```
+
+
+ Prefer to use your own AI provider account instead? See [AI Providers](/configuration/ai-providers) for the bring-your-own-key setup.
+
diff --git a/mintlify-docs/configuration/environments.mdx b/mintlify-docs/configuration/environments.mdx
new file mode 100644
index 0000000..19caa99
--- /dev/null
+++ b/mintlify-docs/configuration/environments.mdx
@@ -0,0 +1,101 @@
+---
+title: "FinalRun environments: secrets, variables, and overrides"
+sidebarTitle: "Environments"
+description: "Use named environment files and dotenv files to manage variable bindings, secret placeholders, and per-environment app identity overrides in FinalRun."
+---
+
+Environments are named profiles that group variable bindings, secret placeholders, and optional app identity overrides. You define an environment by creating a YAML file at `.finalrun/env/.yaml`, then activate it with `--env ` or by setting `env: ` in `.finalrun/config.yaml`. Separating environments lets you run the same test specs against development, staging, and production configurations without touching the tests themselves.
+
+## Environment file structure
+
+Each environment file lives at `.finalrun/env/.yaml` and can contain three top-level blocks:
+
+
+ Per-environment app identity override. Values here take priority over the workspace default in `config.yaml`. Useful when your staging or debug build uses a different package name or bundle ID.
+
+
+
+ Placeholder bindings for sensitive values. Each value uses `${SHELL_ENV_VAR}` syntax. The CLI resolves each placeholder from the shell environment or from workspace-root dotenv files at runtime. Do not put real secrets in this file.
+
+
+
+ Plain, non-sensitive values such as locale strings, feature flags, or base URLs. Safe to commit.
+
+
+### Full example
+
+```yaml .finalrun/env/dev.yaml
+app:
+ packageName: com.example.myapp.debug
+ bundleId: com.example.myapp.debug
+
+secrets:
+ email: ${TEST_USER_EMAIL}
+ password: ${TEST_USER_PASSWORD}
+
+variables:
+ locale: en-US
+```
+
+In your test specs, you can reference these values as `${secrets.email}` and `${variables.locale}`.
+
+## Dotenv files for secret values
+
+Real secret values — API keys, passwords, tokens — belong in dotenv files at the workspace root, not in the YAML binding file.
+
+| File | Purpose |
+|---|---|
+| `.env` | Shared defaults loaded for all runs |
+| `.env.` | Environment-specific values loaded when `--env ` is active (e.g. `.env.dev` for `--env dev`) |
+
+FinalRun finds the workspace root by walking up from your shell's current directory, so dotenv paths are always anchored to the folder that contains `.finalrun/`, regardless of where you run the CLI from.
+
+### Load order
+
+For an active environment named `N`, the CLI loads values in this order:
+
+
+
+ Environment-specific dotenv file is read first. Keys set here take precedence over the shared file.
+
+
+ Shared dotenv file fills in any keys not already set by `.env.N`.
+
+
+ Shell environment variables win if the same key exists in both a dotenv file and the current shell session.
+
+
+
+This same load order applies to both `secrets` placeholder resolution and AI provider API key resolution.
+
+## Using environments with the CLI
+
+Pass `--env` to activate a named environment for any command:
+
+```bash
+# Run a test against the dev environment on Android
+finalrun test smoke.yaml --env dev --platform android
+
+# Validate workspace configuration for staging
+finalrun check --env staging
+```
+
+When you set `env: dev` in `.finalrun/config.yaml`, `--env` becomes optional and the CLI uses `dev` by default.
+
+## Keeping secrets out of version control
+
+
+ Never commit `.env` files. Add the following to your repository's `.gitignore`:
+
+ ```text .gitignore
+ .env
+ .env.*
+ !.env.example
+ ```
+
+ This ignores `.env`, `.env.dev`, `.env.staging`, and any similar files while keeping `.env.example` tracked.
+
+
+
+ Create a `.env.example` file that lists every required variable with placeholder values. Commit it so that team members know exactly which variables to set in their local `.env` file.
+
diff --git a/mintlify-docs/configuration/workspace.mdx b/mintlify-docs/configuration/workspace.mdx
new file mode 100644
index 0000000..b012304
--- /dev/null
+++ b/mintlify-docs/configuration/workspace.mdx
@@ -0,0 +1,112 @@
+---
+title: "FinalRun workspace config: app identity and defaults"
+sidebarTitle: "Workspace"
+description: "Set up .finalrun/config.yaml with app identity, default environment, and AI model fields to configure your FinalRun workspace for Android and iOS testing."
+---
+
+Every FinalRun project is anchored to a workspace root — the directory that contains the `.finalrun/` folder. The workspace holds your configuration file, test specs, optional suite manifests, and per-environment binding files. Understanding the layout and the fields in `config.yaml` lets the CLI resolve the right app, environment, and AI model without you needing to pass flags on every run.
+
+## Workspace layout
+
+```text
+my-app/ # workspace root
+ .env # optional
+ .env.dev # optional
+ .finalrun/
+ config.yaml # workspace configuration
+ tests/ # YAML test specs (required)
+ smoke.yaml
+ auth/
+ login.yaml
+ suites/ # suite manifests (optional)
+ auth_smoke.yaml
+ env/ # environment bindings (optional)
+ dev.yaml
+```
+
+The `tests/` directory is the only required subdirectory. `suites/` and `env/` are optional and only needed when you run suites or use named environments.
+
+## `.finalrun/config.yaml` fields
+
+The workspace config defines defaults that the CLI uses when flags are omitted. Place this file at `.finalrun/config.yaml` in your workspace root.
+
+
+ Human-readable name for the app. Optional — used only for display purposes.
+
+
+
+ Android package identifier (e.g. `com.example.myapp`). Required if you run Android tests and do not pass `--app`.
+
+
+
+ iOS bundle identifier (e.g. `com.example.myapp`). Required if you run iOS tests and do not pass `--app`.
+
+
+
+ Default environment name. Used when you omit the `--env` flag. Must match a file under `.finalrun/env/.yaml` if one exists.
+
+
+
+ Default AI model in `provider/model` format (e.g. `google/gemini-3-flash-preview`). Used when you omit `--model`.
+
+
+
+ At least one of `app.packageName` or `app.bundleId` is required unless you always pass `--app` on the command line.
+
+
+### Example config
+
+```yaml .finalrun/config.yaml
+app:
+ name: MyApp
+ packageName: com.example.myapp
+ bundleId: com.example.myapp
+env: dev
+model: google/gemini-3-flash-preview
+```
+
+## App identity resolution
+
+FinalRun resolves which app to launch on the device using the following priority order:
+
+
+
+ When you pass `--app `, FinalRun uses that binary directly. It extracts the package name (Android) or bundle ID (iOS) from the binary and ignores any `app` block in config files.
+
+
+ If an active environment file at `.finalrun/env/.yaml` contains an `app` block, those values override the workspace defaults.
+
+
+ If neither of the above applies, FinalRun falls back to the `app` block in `.finalrun/config.yaml`.
+
+
+
+### Using the `--app` flag
+
+Pass a local binary to run a specific build without changing any config file:
+
+```bash
+finalrun test smoke.yaml --platform android --app path/to/your.apk
+finalrun test smoke.yaml --platform ios --app path/to/YourApp.app
+```
+
+The CLI:
+- Extracts the package name (Android) or bundle ID (iOS) from the binary
+- Infers the platform from the file extension (`.apk` → Android, `.app` → iOS)
+- Validates that the binary matches the `--platform` flag if both are provided
+
+
+ CLI flags always override values in `config.yaml`. You can use flags for one-off runs without modifying your workspace config.
+
+
+### Per-environment app overrides
+
+If your app uses different identifiers per environment — for example, a `.staging` suffix — set the override in the corresponding env file instead of changing `config.yaml`:
+
+```yaml .finalrun/env/staging.yaml
+app:
+ packageName: com.example.myapp.staging
+ bundleId: com.example.myapp.staging
+```
+
+Any environment that does not define its own `app` block falls back to the workspace default in `.finalrun/config.yaml`.
diff --git a/mintlify-docs/docs.json b/mintlify-docs/docs.json
new file mode 100644
index 0000000..de91abc
--- /dev/null
+++ b/mintlify-docs/docs.json
@@ -0,0 +1,70 @@
+{
+ "$schema": "https://mintlify.com/docs.json",
+ "name": "FinalRun",
+ "theme": "luma",
+ "colors": {
+ "primary": "#3f4fe8",
+ "light": "#d0a7f7",
+ "dark": "#6c4cfc"
+ },
+ "logo": {
+ "light": "/logo/finalrun-logo.png",
+ "dark": "/logo/finalrun-logo-dark-theme.png"
+ },
+ "favicon": "https://media.brand.dev/31164f48-397c-4036-929b-8153e11d15c1.jpg",
+ "navbar": {
+ "primary": {
+ "type": "github",
+ "href": "https://github.com/final-run/finalrun-agent"
+ }
+ },
+ "navigation": {
+ "tabs": [
+ {
+ "tab": "Docs",
+ "groups": [
+ {
+ "group": "Get Started",
+ "pages": [
+ "index",
+ "installation",
+ "quickstart"
+ ]
+ },
+ {
+ "group": "Writing Tests",
+ "pages": [
+ "tests/yaml-format",
+ "tests/suites",
+ "tests/placeholders"
+ ]
+ },
+ {
+ "group": "Configuration",
+ "pages": [
+ "configuration/workspace",
+ "configuration/environments",
+ "configuration/ai-providers",
+ "configuration/cloud-api-key"
+ ]
+ },
+ {
+ "group": "Running Tests",
+ "pages": [
+ "running/cli-reference",
+ "running/ai-agent-skills",
+ "running/reports"
+ ]
+ },
+ {
+ "group": "Help",
+ "pages": [
+ "troubleshooting",
+ "faq"
+ ]
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/mintlify-docs/faq.mdx b/mintlify-docs/faq.mdx
new file mode 100644
index 0000000..fefcb01
--- /dev/null
+++ b/mintlify-docs/faq.mdx
@@ -0,0 +1,103 @@
+---
+title: "FinalRun FAQ: pricing, AI providers, and CI/CD usage"
+sidebarTitle: "FAQ"
+description: "Answers to common FinalRun questions: pricing, supported AI providers, platform compatibility, CI/CD usage, three-phase test model, and artifact storage."
+---
+
+FinalRun is an open-source, CLI-based tool — no account required, no platform lock-in. The questions below cover the most common things people ask about how FinalRun works, what it costs, and how to get the most out of it.
+
+
+
+ No. FinalRun is open-source and runs entirely from the command line. Install the CLI, set your AI provider API key, and start running tests. There is no account, signup, or FinalRun subscription required.
+
+
+
+ FinalRun supports three AI providers. You bring your own key (BYOK) — costs are billed directly by your provider at standard API rates.
+
+ | Provider | Supported models | Environment variable |
+ |---|---|---|
+ | Google | Gemini 3+ | `GOOGLE_API_KEY` |
+ | OpenAI | GPT-5+ | `OPENAI_API_KEY` |
+ | Anthropic | Claude Sonnet 4 / Opus 4+ | `ANTHROPIC_API_KEY` |
+
+ Set the key in your shell or in a `.env` file at your workspace root. You can also override it for a single run with the `--api-key` flag.
+
+
+
+ Currently FinalRun targets Android emulators (AVDs) and iOS simulators for local runs. Support for cloud devices and physical hardware is on the roadmap.
+
+ If you want early access to cloud device support, [join the waitlist](https://docs.google.com/forms/d/e/1FAIpQLScOTaNWjvxIG8Ywn6THHYJuqBM-b86Y-Fx39YVoBVhHuBDZ2w/viewform?usp=publish-editor).
+
+
+
+ - **Android** — any macOS, Linux, or Windows machine with Android SDK tools (`adb`, `emulator`, `scrcpy`) installed and a running Android Virtual Device.
+ - **iOS** — macOS only. Requires Xcode command line tools with `xcrun simctl`.
+
+ Run `finalrun doctor` to check that all required dependencies are present on your machine before running tests.
+
+
+
+ FinalRun itself is free and open-source. You pay your AI provider — Google, OpenAI, or Anthropic — for the tokens consumed during test execution. The cost depends on the model you choose and how long the test takes to complete. There are no additional charges from FinalRun.
+
+
+
+ Yes. Install the CLI in your CI environment and set the required environment variables — your AI provider API key, `ANDROID_HOME` (for Android), and any secrets your test specs reference. Then call `finalrun test` or `finalrun suite` as a step in your pipeline.
+
+ ```sh
+ finalrun test smoke.yaml --platform android --model google/gemini-3-flash-preview
+ ```
+
+ Run `finalrun check` before your test step to catch workspace configuration errors early, before you consume any API tokens.
+
+
+
+ Every FinalRun test has three phases:
+
+ - **`setup`** — optional actions that prepare a clean state before the test starts (for example, clearing app data).
+ - **`steps`** — the ordered, plain-English instructions the AI executes on your device screen.
+ - **`expected_state`** — the UI conditions the AI verifies once all steps have completed.
+
+ A test passes only when all three phases succeed. If any phase fails, FinalRun stops the run and records the failure with the current screenshot, video, and device log.
+
+
+
+ `finalrun check` validates your entire workspace before you run any tests. It checks:
+
+ - Selector definitions
+ - Suite manifests
+ - Environment bindings (secrets and variables)
+ - App overrides
+
+ Running `finalrun check` before a test run catches configuration errors early, so you are not spending API tokens on a run that will fail at startup.
+
+ ```sh
+ finalrun check --env dev --platform android
+ ```
+
+
+
+ FinalRun ships a set of agent skills that let you generate tests, run them, and fix failures — all from your AI coding agent chat. Install the skills with:
+
+ ```sh
+ npx skills add final-run/finalrun-agent
+ ```
+
+ Once installed, three slash commands are available in your AI coding agent:
+
+ | Command | What it does |
+ |---|---|
+ | `/finalrun-generate-test` | Reads your source code, infers app identity, and generates complete YAML test specs |
+ | `/finalrun-use-cli` | Validates and runs your tests using the CLI |
+ | `/finalrun-test-and-fix` | Runs the full generate → run → diagnose → fix loop until the test is green |
+
+
+
+ Artifacts for each run — including video, screenshots, and device logs — are stored at:
+
+ ```
+ ~/.finalrun/workspaces//artifacts/
+ ```
+
+ Use `finalrun runs` to list all recorded runs for your current workspace, and `finalrun start-server` to open the visual report UI where you can browse results interactively.
+
+
diff --git a/mintlify-docs/index.mdx b/mintlify-docs/index.mdx
new file mode 100644
index 0000000..09d4315
--- /dev/null
+++ b/mintlify-docs/index.mdx
@@ -0,0 +1,42 @@
+---
+title: "FinalRun: AI-powered Android and iOS test automation"
+sidebarTitle: "Introduction"
+description: "FinalRun lets you write plain-English test specs in YAML and run them on Android and iOS using AI models like Gemini, GPT, or Claude."
+---
+
+FinalRun is an AI-powered CLI for testing Android and iOS apps with natural language.
+You write test scenarios in YAML, describing actions the way a person would.
+FinalRun launches your app on a real device or emulator and uses an AI model to see the screen and perform each action — tapping, swiping, typing, and verifying results.
+When the run finishes, you get a pass/fail report complete with video, screenshots, and device logs.
+
+VIDEO
+
+## Get started
+
+
+
+ Prerequisites, install the CLI, and verify host readiness.
+
+
+ Write and run your first test in minutes.
+
+
+ Learn the full test spec format: fields, placeholders, and suites.
+
+
+ Every command, flag, and option available in the finalrun CLI.
+
+
+ Use AI coding agents to generate, run, and fix tests automatically.
+
+
+ Set up your workspace config, app identity, and environments.
+
+
diff --git a/mintlify-docs/installation.mdx b/mintlify-docs/installation.mdx
new file mode 100644
index 0000000..3180709
--- /dev/null
+++ b/mintlify-docs/installation.mdx
@@ -0,0 +1,139 @@
+---
+title: "Install FinalRun: prerequisites, CLI setup, and host verification"
+sidebarTitle: "Installation"
+description: "Set up prerequisites, install the FinalRun CLI on macOS, Linux, or Windows, and verify that your host is ready to run Android or iOS tests."
+---
+
+FinalRun is distributed as a standalone `finalrun` binary — no Node.js or npm required. This page walks you through the prerequisites, the install itself, and how to verify your machine is ready. Once `finalrun doctor` reports a clean bill of health, continue to the [Quickstart](/quickstart) to write and run your first test.
+
+## Prerequisites
+
+Before installing FinalRun, make sure your machine has the following in place.
+
+### AI provider API key
+
+FinalRun is BYOK (bring your own key). Before your first test run, obtain an API key from one of the supported providers:
+
+| Provider | Environment variable |
+|---|---|
+| Google Gemini | `GOOGLE_API_KEY` |
+| OpenAI GPT | `OPENAI_API_KEY` |
+| Anthropic Claude | `ANTHROPIC_API_KEY` |
+
+You'll set the key during the Quickstart. See [AI Providers](/configuration/ai-providers) for details on choosing a model and configuring the key.
+
+### Platform tools
+
+
+
+ Android tests run on emulators via `adb`. You need the Android SDK and a few additional tools on your PATH.
+
+ **Required:**
+
+ | Tool | How to provide |
+ |---|---|
+ | `adb` | Available through `ANDROID_HOME`, `ANDROID_SDK_ROOT`, or `PATH` |
+ | `emulator` | Must be on `PATH`; used to discover and boot Android Virtual Devices |
+ | `scrcpy` | Must be on `PATH`; used for screen recording during local runs |
+ | FinalRun Android driver assets | Installed automatically by the CLI during installation |
+
+ **Install the Android SDK** via [Android Studio](https://developer.android.com/studio) or the standalone command-line tools. Once installed, make sure `platform-tools` and `emulator` directories are on your `PATH`, or set `ANDROID_HOME` to your SDK root.
+
+ **Install scrcpy** using your system package manager:
+
+
+ ```bash macOS
+ brew install scrcpy
+ ```
+
+ ```bash Ubuntu / Debian
+ sudo apt install scrcpy
+ ```
+
+
+
+
+ iOS testing requires macOS. It is not supported on Linux or Windows.
+
+
+ iOS tests run on simulators via Xcode's `xcrun simctl`. You need Xcode command line tools and a few standard utilities.
+
+ **Required:**
+
+ | Tool | Notes |
+ |---|---|
+ | macOS | iOS simulator support is macOS-only |
+ | Xcode command line tools | Provides `xcrun` |
+ | `xcrun simctl` | Used to manage and boot iOS simulators |
+ | `unzip` | Standard macOS utility |
+ | `/bin/bash` | Standard macOS shell |
+ | `plutil` | Standard macOS utility |
+ | FinalRun iOS driver archives | Installed automatically by the CLI during installation |
+
+ Install Xcode command line tools if you haven't already:
+
+ ```bash
+ xcode-select --install
+ ```
+
+ **Optional tools:**
+
+ | Tool | Purpose |
+ |---|---|
+ | `ffmpeg` | Compresses iOS recordings after capture |
+ | `applesimutils` | Enables simulator permission helpers |
+
+ Install optional tools via Homebrew:
+
+ ```bash
+ brew install ffmpeg applesimutils
+ ```
+
+
+
+## Install FinalRun
+
+The installer downloads the standalone `finalrun` binary for your platform, the bundled platform driver assets for Android and iOS, and the FinalRun AI agent skills for your coding agent.
+
+
+```bash macOS / Linux
+curl -fsSL https://raw.githubusercontent.com/final-run/finalrun-agent/main/scripts/install.sh | bash
+```
+
+```powershell Windows
+irm https://raw.githubusercontent.com/final-run/finalrun-agent/main/scripts/install.ps1 | iex
+```
+
+
+To keep the CLI up to date later, run:
+
+```bash
+finalrun upgrade
+```
+
+## Verify the installation
+
+Confirm the CLI is on your PATH:
+
+```bash
+finalrun --help
+```
+
+Then check host readiness for your target platform:
+
+```bash
+finalrun doctor
+```
+
+`finalrun doctor` reports which required tools are present, missing, or misconfigured. You can also check a single platform:
+
+```bash
+finalrun doctor --platform android
+finalrun doctor --platform ios
+```
+
+Fix any issues reported before running tests.
+
+## Next steps
+
+Once `finalrun doctor` reports your host as ready, follow the [Quickstart](/quickstart) guide to write and run your first test.
diff --git a/mintlify-docs/logo/finalrun-logo-dark-theme.png b/mintlify-docs/logo/finalrun-logo-dark-theme.png
new file mode 100644
index 0000000..78525b0
Binary files /dev/null and b/mintlify-docs/logo/finalrun-logo-dark-theme.png differ
diff --git a/mintlify-docs/logo/finalrun-logo.png b/mintlify-docs/logo/finalrun-logo.png
new file mode 100644
index 0000000..ab0a9f5
Binary files /dev/null and b/mintlify-docs/logo/finalrun-logo.png differ
diff --git a/mintlify-docs/quickstart.mdx b/mintlify-docs/quickstart.mdx
new file mode 100644
index 0000000..5c50790
--- /dev/null
+++ b/mintlify-docs/quickstart.mdx
@@ -0,0 +1,140 @@
+---
+title: "Quickstart: run your first FinalRun test"
+sidebarTitle: "Quickstart"
+description: "Configure your AI provider key, create a minimal workspace, write a YAML test spec, and execute it on an Android emulator or iOS simulator."
+---
+
+This guide assumes you've already completed [Installation](/installation) and that `finalrun doctor` reports your host as ready. You'll configure your AI provider key, create a minimal workspace, write a test, and execute it on a connected Android emulator or iOS simulator.
+
+
+
+ FinalRun uses your own AI provider API key. Create a `.env` file at the root of your project with the key for your chosen provider:
+
+
+ ```bash Google Gemini
+ echo "GOOGLE_API_KEY=your-key-here" > .env
+ ```
+
+ ```bash OpenAI
+ echo "OPENAI_API_KEY=your-key-here" > .env
+ ```
+
+ ```bash Anthropic Claude
+ echo "ANTHROPIC_API_KEY=your-key-here" > .env
+ ```
+
+
+ Add `.env` to your `.gitignore` so secrets are never committed.
+
+
+ Each test run makes real AI API calls that consume tokens. Standard billing from your AI provider applies.
+
+
+
+
+ Create the `.finalrun/` directory and a folder for your test specs:
+
+ ```bash
+ mkdir -p .finalrun/tests
+ ```
+
+ Then create `.finalrun/config.yaml` with your app's identity and default settings:
+
+ ```yaml
+ app:
+ name: MyApp
+ packageName: com.example.myapp
+ bundleId: com.example.myapp
+ env: dev
+ model: google/gemini-3-flash-preview
+ ```
+
+ | Field | Description |
+ |---|---|
+ | `app.packageName` | Android package identifier |
+ | `app.bundleId` | iOS bundle identifier |
+ | `env` | Default environment name (can be omitted if not using env files) |
+ | `model` | Default AI model in `provider/model` format |
+
+ At least one of `app.packageName` or `app.bundleId` is required.
+
+
+
+ Create `.finalrun/tests/login_smoke.yaml` with a basic login scenario:
+
+ ```yaml
+ name: login_smoke
+ description: Verify that a user can log in and reach the home screen.
+
+ setup:
+ - Clear app data.
+
+ steps:
+ - Launch the app.
+ - Enter ${secrets.email} on the login screen.
+ - Enter ${secrets.password} on the password screen.
+ - Tap the login button.
+
+ expected_state:
+ - The home screen is visible.
+ - The user's name appears in the header.
+ ```
+
+ The `${secrets.email}` and `${secrets.password}` placeholders are resolved at runtime. To use them, **append** the values to your existing `.env` (use `>>`, not `>`, so you don't overwrite the API key you set in step 1):
+
+ ```bash
+ cat >> .env <<'EOF'
+ TEST_USER_EMAIL=test@example.com
+ TEST_USER_PASSWORD=password123
+ EOF
+ ```
+
+ Then declare the bindings in `.finalrun/env/dev.yaml`:
+
+ ```yaml
+ secrets:
+ email: ${TEST_USER_EMAIL}
+ password: ${TEST_USER_PASSWORD}
+ ```
+
+
+
+ Run your test on Android or iOS. The `--model` flag can be omitted if you set a default in `config.yaml`.
+
+
+ ```bash Android
+ finalrun test login_smoke.yaml --platform android --model google/gemini-3-flash-preview
+ ```
+
+ ```bash iOS
+ finalrun test login_smoke.yaml --platform ios --model google/gemini-3-flash-preview
+ ```
+
+
+ FinalRun will boot the emulator or simulator, install the app, and begin executing the test steps. You'll see live output as the AI works through each action.
+
+
+ Run `finalrun check` from inside your project directory before executing tests. It validates your workspace config, environment bindings, and suite manifests without launching a device.
+
+
+
+
+ List your recent test runs:
+
+ ```bash
+ finalrun runs
+ ```
+
+ Open the local report UI in your browser to see screenshots, video, and device logs:
+
+ ```bash
+ finalrun start-server
+ ```
+
+
+
+## What's next
+
+- [YAML test format](/tests/yaml-format) — learn all test spec fields, suite manifests, and placeholder syntax
+- [CLI reference](/running/cli-reference) — full list of commands and flags
+- [Workspace configuration](/configuration/workspace) — app identity, per-environment overrides, and `--app` flag
diff --git a/mintlify-docs/running/ai-agent-skills.mdx b/mintlify-docs/running/ai-agent-skills.mdx
new file mode 100644
index 0000000..274e890
--- /dev/null
+++ b/mintlify-docs/running/ai-agent-skills.mdx
@@ -0,0 +1,120 @@
+---
+title: "FinalRun AI agent skills: generate, run, and fix tests"
+sidebarTitle: "AI Agent Skills"
+description: "Install and use FinalRun's three AI agent skills — generate-test, use-cli, and test-and-fix — to automate mobile test creation, execution, and debugging."
+---
+
+FinalRun ships three AI coding agent skills that let your AI coding agent generate tests, run them, and fix failures — all from chat. The skills work with any agent that supports the skills protocol, including Claude Code, Cursor, Windsurf, and similar tools.
+
+## Install the skills
+
+Run the following command once in your repository to install all three skills into your coding agent:
+
+```sh
+npx skills add final-run/finalrun-agent
+```
+
+
+ These skills work with any AI coding agent that supports the skills protocol. You do not need to configure them separately for each tool.
+
+
+## Available skills
+
+
+
+ Reads your app's source code, infers the app identity, and generates complete YAML test specs with setup, steps, and expected state — organized by feature folder.
+
+
+ Validates and runs tests once they are generated. Handles flag selection, env binding, and post-run artifact inspection.
+
+
+ Orchestrates the full generate → run → diagnose → fix loop. Keeps iterating until the run is green or a genuine blocker is hit.
+
+
+
+---
+
+## /finalrun-generate-test
+
+The `/finalrun-generate-test` skill reads your app's source code, infers the app identity (package name or bundle ID), and generates complete test specs organized by feature folder under `.finalrun/tests/`.
+
+**Example usage**
+
+```sh
+/finalrun-generate-test Generate tests for the authentication feature — cover login with valid credentials, login with wrong password, and logout
+```
+
+When you invoke this skill, the agent works through the following sequence:
+
+
+
+ Reads relevant application source code to understand the UI, user flows, and infer the app's package name or bundle ID.
+
+
+ Creates or updates `.finalrun/config.yaml` with the app identity and scaffolds environment bindings under `.finalrun/env/`.
+
+
+ Presents the proposed test files, feature folder structure, and environment binding strategy for your approval before writing any files.
+
+
+ After you approve, writes YAML specs under `.finalrun/tests//` and a matching suite manifest under `.finalrun/suites/`.
+
+
+ Runs `finalrun check` to validate the workspace, bindings, and generated specs. Fixes any issues until the check passes.
+
+
+
+
+ The agent will not write test files until you explicitly approve the proposed plan in step 3. Review the proposed paths and binding strategy before confirming.
+
+
+---
+
+## /finalrun-use-cli
+
+The `/finalrun-use-cli` skill validates and runs your tests once they are generated. It handles CLI flag selection, env binding, and post-run artifact inspection — including reading `result.json`, screenshots, and device logs on failure.
+
+**Example usage**
+
+```sh
+/finalrun-use-cli Run the auth tests on Android
+```
+
+The agent validates the workspace with `finalrun check` before executing, explains the exact command it will run, and summarizes the outcome with artifact links when done.
+
+---
+
+## /finalrun-test-and-fix
+
+The `/finalrun-test-and-fix` skill orchestrates the full **generate → run → diagnose → fix** loop. It calls `/finalrun-generate-test` to author tests, runs them via `/finalrun-use-cli`, reads the CLI artifacts on failure, classifies whether the bug is in the app code or the test spec, applies the narrowest possible fix, and re-runs until the run is green.
+
+**Example usage**
+
+```sh
+/finalrun-test-and-fix Verify and fix the checkout feature end-to-end on Android
+```
+
+The agent stops the loop only when the run is green, or when execution is genuinely blocked — for example, no emulator is available, a required secret is missing, or you explicitly opt out. In a blocked state it prints the exact command for you to run locally.
+
+
+ Use `/finalrun-test-and-fix` as your default entry point when you finish a UI feature. It covers the full cycle without needing to invoke the other two skills manually.
+
+
+---
+
+## Auto-triggering FinalRun after feature work
+
+You can configure your AI agent to automatically generate and run FinalRun tests whenever it finishes a UI feature — without you having to ask explicitly. Add the autotrigger content to your `AGENTS.md` file:
+
+
+
+ ```sh
+ npx skills add final-run/finalrun-agent
+ ```
+
+
+ Add a definition-of-done rule to your `AGENTS.md` that instructs the agent to run the `/finalrun-test-and-fix` skill automatically after every UI change. The rule tells the agent not to mark a task as done until FinalRun coverage is updated and the run is green.
+
+
+
+With the autotrigger in place, your agent treats a passing FinalRun run as a hard requirement for every UI task — no separate prompt needed.
diff --git a/mintlify-docs/running/cli-reference.mdx b/mintlify-docs/running/cli-reference.mdx
new file mode 100644
index 0000000..d5b600f
--- /dev/null
+++ b/mintlify-docs/running/cli-reference.mdx
@@ -0,0 +1,150 @@
+---
+title: "FinalRun CLI reference: all commands, flags, and options"
+sidebarTitle: "CLI Reference"
+description: "Complete reference for all FinalRun CLI commands — test, suite, check, doctor, runs, and server commands — with flags and copy-paste usage examples."
+---
+
+The `finalrun` CLI is the main interface for validating workspaces, running tests, and inspecting reports. CLI flags always take precedence over settings in `.finalrun/config.yaml`, so you can override any default at the command line without editing your config file.
+
+## Getting started
+
+Use these commands to verify your environment before running tests.
+
+
+
+ Validates your `.finalrun` workspace, environment bindings, selectors, and suite manifests. Falls back to the `env` set in `.finalrun/config.yaml` when `--env` is omitted.
+
+
+ Checks host readiness for local Android and iOS runs. Use `--platform` to check a single platform.
+
+
+
+```sh
+# Check the full workspace
+finalrun check
+
+# Validate a specific environment and platform
+finalrun check --env dev --platform android
+```
+
+## Running tests
+
+
+
+ Executes one or more YAML specs from `.finalrun/tests/`.
+
+ ```sh
+ finalrun test [flags]
+ ```
+
+ **Examples**
+
+ ```sh
+ # Run a single test
+ finalrun test smoke.yaml --platform android --model google/gemini-3-flash-preview
+
+ # Run with a specific app binary
+ finalrun test smoke.yaml --platform android --app path/to/your.apk
+
+ # Run two specs at once
+ finalrun test auth/login.yaml auth/logout.yaml --platform ios --env staging
+ ```
+
+
+ Executes a suite manifest from `.finalrun/suites/`.
+
+ ```sh
+ finalrun suite [flags]
+ ```
+
+ **Example**
+
+ ```sh
+ finalrun suite auth_smoke.yaml --platform ios --model anthropic/claude-sonnet-4-6
+ ```
+
+
+
+### Common flags
+
+The following flags apply to both `finalrun test` and `finalrun suite`.
+
+
+ Target platform for the test run. Required when the platform cannot be inferred from the `--app` extension (`.apk` implies Android, `.app` implies iOS).
+
+
+
+ AI model to use, in `provider/model` format. For example: `google/gemini-3-flash-preview`, `anthropic/claude-sonnet-4-6`, `openai/gpt-5`. Falls back to the `model` key in `.finalrun/config.yaml`.
+
+
+
+ Environment name. Selects the matching file at `.finalrun/env/.yaml`. Falls back to the `env` key in `.finalrun/config.yaml`.
+
+
+
+ Path to the `.apk` or `.app` binary to install and test. Overrides the app identity defined in `.finalrun/config.yaml`.
+
+
+
+ Override the provider API key for this run. Use `--api-key` for one-off runs; for persistent configuration set the provider environment variable (`GOOGLE_API_KEY`, `OPENAI_API_KEY`, or `ANTHROPIC_API_KEY`).
+
+
+
+ Enable debug logging for the run.
+
+
+
+ Cap the number of AI action iterations per step. The run aborts when the limit is reached.
+
+
+
+ CLI flags always take precedence over `.finalrun/config.yaml`. You can set defaults in config and override them selectively at the command line.
+
+
+## Report commands
+
+After a test run, FinalRun saves artifacts locally. These commands let you list, browse, and manage those reports.
+
+| Command | Description |
+|---|---|
+| `finalrun runs` | Lists local reports from `~/.finalrun/workspaces//artifacts`. |
+| `finalrun start-server` | Starts or reuses the local report UI for the current workspace. |
+| `finalrun server-status` | Shows the current local report server status. |
+| `finalrun stop-server` | Stops the local report server. |
+
+All report commands accept `--workspace ` to target a workspace other than the current directory.
+
+
+
+```sh List runs
+finalrun runs
+
+# Machine-readable output
+finalrun runs --json
+
+# Target a different workspace
+finalrun runs --workspace /path/to/other/app
+```
+
+```sh Report server
+# Start the report UI
+finalrun start-server
+
+# Check server status
+finalrun server-status
+
+# Stop the server
+finalrun stop-server
+
+# Target a different workspace
+finalrun start-server --workspace /path/to/other/app
+```
+
+
+
+## Getting help
+
+```sh
+finalrun --help
+finalrun --help
+```
diff --git a/mintlify-docs/running/reports.mdx b/mintlify-docs/running/reports.mdx
new file mode 100644
index 0000000..f813290
--- /dev/null
+++ b/mintlify-docs/running/reports.mdx
@@ -0,0 +1,101 @@
+---
+title: "FinalRun test reports: browse artifacts, video, and logs"
+sidebarTitle: "Reports"
+description: "Explore FinalRun run artifacts, open the local report UI, read result.json, and inspect failures with video playback and device logs."
+---
+
+After every test run, FinalRun saves a full set of artifacts to disk so you can diagnose failures, replay executions, and track results over time. You can browse these artifacts directly on the filesystem or through the local report UI, which adds video playback, log search, and step-by-step screenshot browsing.
+
+## Artifact layout
+
+Each run writes its artifacts to:
+
+```
+~/.finalrun/workspaces//artifacts/
+```
+
+The `` is derived from your project directory, so each repository gets its own isolated artifacts folder.
+
+| File or folder | Contents |
+|---|---|
+| `result.json` | Test outcome, failure message, and step-level results. |
+| `actions/` | Individual agent action files (JSON) showing the agent's reasoning, the action taken, and whether it succeeded at each step. |
+| `screenshots/` | Per-step screenshots showing the device screen at each action. |
+| `recording.mp4` / `recording.mov` | Screen recording of the full test run. |
+| `device.log` | Device-level logs — logcat on Android, `log stream` on iOS — captured during the run. |
+| `runner.log` | The CLI's own log for the entire run, written at the run directory root. |
+
+## Listing recent runs
+
+Use `finalrun runs` to list local reports for the current workspace:
+
+
+
+```sh Standard output
+finalrun runs
+```
+
+```sh Machine-readable JSON
+finalrun runs --json
+```
+
+```sh Different workspace
+finalrun runs --workspace /path/to/other/app
+```
+
+
+
+## Opening the report UI
+
+The local report UI lets you browse run artifacts with video playback, device log search, and step-by-step screenshots.
+
+```sh
+finalrun start-server
+```
+
+FinalRun starts a local web server (or reuses an existing one) and opens the report UI in your browser. The UI shows all runs for the current workspace.
+
+
+ Run `finalrun start-server` immediately after a test failure to replay the recording, step through screenshots, and search device logs — all in sync. This is the fastest way to understand what the AI agent saw on screen at each step.
+
+
+### Report UI features
+
+
+
+ Watch the full screen recording of any run. Scrub to any moment to see exactly what was on the device.
+
+
+ Browse per-action screenshots alongside the agent's action details for each step.
+
+
+ Search and filter device logs (logcat or log stream) captured during the run. Filter by log level to focus on errors and warnings.
+
+
+
+## Managing the server
+
+```sh
+# Check whether the server is running
+finalrun server-status
+
+# Stop the server
+finalrun stop-server
+```
+
+## Targeting a different workspace
+
+All report commands accept `--workspace ` when you want to inspect runs from a project other than the current directory:
+
+```sh
+finalrun start-server --workspace /path/to/other/app
+finalrun runs --workspace /path/to/other/app
+```
+
+## Reading result.json
+
+`result.json` is the canonical record of a run. It contains the overall test outcome (`pass` or `fail`), a human-readable failure message, and step-level results that map directly to the entries in `actions/` and `screenshots/`. When your AI coding agent reads artifacts after a failure, `result.json` is the first file it reads to identify which step failed and why.
+
+
+ When the `/finalrun-use-cli` or `/finalrun-test-and-fix` skill diagnoses a failure, it reads `result.json`, the matching `actions/` entry, and the screenshot at the failed step before suggesting any fix. You can do the same inspection manually using `finalrun start-server`.
+
diff --git a/mintlify-docs/tests/placeholders.mdx b/mintlify-docs/tests/placeholders.mdx
new file mode 100644
index 0000000..5905582
--- /dev/null
+++ b/mintlify-docs/tests/placeholders.mdx
@@ -0,0 +1,106 @@
+---
+title: "Inject secrets and variables into FinalRun test specs"
+sidebarTitle: "Placeholders"
+description: "Learn how to use ${secrets.*} and ${variables.*} placeholders in FinalRun test specs, how they resolve at run time, and how to configure env files safely."
+---
+
+FinalRun test specs support two types of placeholders that let you inject dynamic values without hardcoding them in YAML files. You reference a placeholder in a step; FinalRun resolves its value at run time from environment variables or a binding file. This keeps credentials and configuration out of your test source.
+
+## Placeholder types
+
+**`${secrets.*}`** — for sensitive values such as credentials and API keys. The logical key (e.g. `secrets.email`) maps to a shell environment variable declared in your binding file. The actual value is never stored in YAML.
+
+**`${variables.*}`** — for non-sensitive values such as locale codes, search terms, and feature flags. Values are declared directly in the binding file as plain strings.
+
+Both types must be declared in `.finalrun/env/.yaml` before you use them in a test.
+
+## Env binding file
+
+Create a file under `.finalrun/env/` for each environment you use (e.g. `dev.yaml`, `staging.yaml`). Declare secrets as `${SHELL_ENV_VAR}` placeholders and variables as plain values:
+
+```yaml
+secrets:
+ email: ${TEST_USER_EMAIL}
+ password: ${TEST_USER_PASSWORD}
+
+variables:
+ locale: en-US
+ search_term: coffee
+```
+
+The `secrets` entries are placeholders only. The CLI resolves them from shell environment variables and `.env` files at run time. **Do not put real credentials in this YAML file.**
+
+## Using placeholders in tests
+
+Reference declared placeholders with `${secrets.}` or `${variables.}` syntax anywhere in `setup`, `steps`, or `expected_state`:
+
+```yaml
+steps:
+ - Enter ${secrets.email} on the login screen.
+ - Enter ${secrets.password} on the password screen.
+ - Type ${variables.search_term} in the search field.
+```
+
+## Load order
+
+When you run FinalRun with an env named `N` (e.g. `--env dev`), the CLI resolves values in this order:
+
+
+
+ The environment-specific dotenv file (e.g. `.env.dev`) is loaded first.
+
+
+ Fills in any keys not already set by the environment-specific file.
+
+
+ Shell environment variables win if the same key appears in both a file and the current shell environment.
+
+
+
+
+ When no env profile is configured, the CLI uses `process.env` and `.env` directly. You do not need a `.env.` file for a simple single-environment workspace.
+
+
+## Selecting an env profile
+
+Pass `--env ` to the CLI to activate a specific env profile. The name must match a file under `.finalrun/env/`:
+
+```bash
+finalrun test auth/login.yaml --platform android --model google/gemini-3-flash-preview --env staging
+```
+
+FinalRun will load `.env.staging`, then `.env`, then `process.env`, and resolve all `${secrets.*}` bindings declared in `.finalrun/env/staging.yaml`.
+
+## Never commit secrets
+
+
+ Do not commit `.env` files to your repository. Add the following lines to your `.gitignore` to exclude all dotenv files while keeping `.env.example` tracked as a template:
+
+
+```gitignore
+.env
+.env.*
+!.env.example
+```
+
+Use `.env.example` to document which shell variables team members need to export, without including real values.
+
+## Never hardcode secrets
+
+Always use placeholder syntax for sensitive values. Hardcoding credentials in a test spec exposes them in version control and in run reports.
+
+
+
+```yaml Good — placeholder syntax
+steps:
+ - Enter ${secrets.email} on the login screen.
+ - Enter ${secrets.password} on the password screen.
+```
+
+```yaml Bad — hardcoded credentials
+steps:
+ - Enter user@example.com on the login screen.
+ - Enter hunter2 on the password screen.
+```
+
+
diff --git a/mintlify-docs/tests/suites.mdx b/mintlify-docs/tests/suites.mdx
new file mode 100644
index 0000000..1b54521
--- /dev/null
+++ b/mintlify-docs/tests/suites.mdx
@@ -0,0 +1,63 @@
+---
+title: "FinalRun suite manifests: run groups of tests together"
+sidebarTitle: "Suites"
+description: "Learn how to create suite manifests that group related test files, organize them by feature, and run an entire suite with a single CLI command."
+---
+
+A suite is a YAML manifest that groups individual test files into a logical collection you can run together. Rather than running tests one at a time, you define a suite for each feature and run every scenario in it with a single command. Suite manifests live under `.finalrun/suites/`.
+
+## Suite fields
+
+
+ A stable identifier for the suite. Use `snake_case`. This name is what you pass to `finalrun suite` when you want to run it.
+
+
+
+ A short, human-readable summary of what the suite covers. One or two sentences is enough.
+
+
+
+ An ordered list of test file paths. Each path is relative to `.finalrun/tests/`. The agent runs the tests in the order listed.
+
+
+## Example suite
+
+```yaml
+name: auth_smoke
+description: Covers the authentication smoke scenarios.
+tests:
+ - auth/login.yaml
+ - auth/logout.yaml
+```
+
+## Organizing suites by feature
+
+The recommended convention is one suite per feature folder, mirroring the structure of `.finalrun/tests//`. A suite named `auth_smoke` covering tests in `.finalrun/tests/auth/` is a clear, predictable mapping that makes it easy to find which suite runs a given test.
+
+
+ Name your suite files after their feature folder. If your tests live in `.finalrun/tests/checkout/`, name the suite file `.finalrun/suites/checkout.yaml` and give it the `name: checkout` identifier. This one-to-one convention keeps suites discoverable as the test library grows.
+
+
+## Running a suite
+
+Pass the suite manifest path to `finalrun suite`. Specify a platform and AI model:
+
+```bash
+finalrun suite auth_smoke.yaml --platform android --model google/gemini-3-flash-preview
+```
+
+You can also target iOS:
+
+```bash
+finalrun suite auth_smoke.yaml --platform ios --model google/gemini-3-flash-preview
+```
+
+## Validating a suite before running
+
+Run `finalrun check` with the `--suite` flag to validate the suite manifest and all referenced test files — confirming that paths resolve, placeholders are declared, and the workspace is configured correctly — before spending time on a full test run:
+
+```bash
+finalrun check --suite auth_smoke.yaml
+```
+
+Fix any errors reported by `finalrun check` before proceeding. The command output is the source of truth for binding correctness and path resolution.
diff --git a/mintlify-docs/tests/yaml-format.mdx b/mintlify-docs/tests/yaml-format.mdx
new file mode 100644
index 0000000..a208232
--- /dev/null
+++ b/mintlify-docs/tests/yaml-format.mdx
@@ -0,0 +1,171 @@
+---
+title: "FinalRun YAML test format: fields, phases, examples"
+sidebarTitle: "YAML Format"
+description: "Learn the FinalRun YAML test format: required fields, the three-phase execution model, allowed actions, and how to write reliable natural-language steps."
+---
+
+FinalRun test specs are plain YAML files stored under `.finalrun/tests/`. Each file defines a single test scenario using natural-language steps that the AI agent executes on a real device or emulator. You describe what a user would do; FinalRun taps, swipes, types, and verifies on your behalf.
+
+## Test fields
+
+Every test file follows a fixed schema. The `name` and `steps` fields are required; all others are optional.
+
+
+ A stable, unique identifier for the test scenario. Use `snake_case`. This value appears in run reports and suite manifests, so keep it descriptive and consistent across renames.
+
+
+
+ A short, human-readable summary of what the test validates. One or two sentences is enough.
+
+
+
+ Actions the agent runs before the main steps to prepare a clean starting state. Every setup block must be idempotent — see [Setup and idempotent cleanup](#setup-and-idempotent-cleanup) below.
+
+
+
+ An ordered list of natural-language steps the agent executes. Each step must use an action from the [allowed action vocabulary](#allowed-action-vocabulary).
+
+
+
+ The expected UI state after all steps are complete. These are boolean conditions the agent checks against the final screen — not actions to perform. If every condition is met, the test passes; if any fail, the test fails.
+
+
+## Three-phase execution model
+
+At runtime, the agent executes every test in three sequential phases:
+
+
+
+ The agent runs any `setup` steps to guarantee a clean starting state, regardless of what a previous run may have left behind.
+
+
+ The agent performs each `steps` entry in order — tapping, typing, swiping, and verifying as instructed.
+
+
+ The agent checks each `expected_state` condition against the final screen. The test succeeds only when all conditions pass.
+
+
+
+## Example: login smoke test
+
+```yaml
+name: login_smoke
+description: Verify that a user can log in and reach the home screen.
+
+setup:
+ - Clear app data.
+
+steps:
+ - Launch the app.
+ - Enter ${secrets.email} on the login screen.
+ - Enter ${secrets.password} on the password screen.
+ - Tap the login button.
+
+expected_state:
+ - The home screen is visible.
+ - The user's name appears in the header.
+```
+
+
+ The `${secrets.email}` and `${secrets.password}` placeholders are resolved at run time from environment variables or `.env` files. See [Placeholders](/tests/placeholders) for details.
+
+
+## Allowed action vocabulary
+
+Every step in `setup` or `steps` must use one of the following verbs. Do not write steps that require actions outside this list.
+
+| Verb to use in steps | What the agent does | Needs a UI target? |
+|---|---|---|
+| **Tap** / Click | Taps the specified element | Yes |
+| **Long press** | Long-presses the specified element | Yes |
+| **Type** / Enter text | Inputs text into the specified field | Yes |
+| **Swipe** / Scroll | Swipes in a direction over the specified area | Yes |
+| **Navigate back** | Presses the device back button | No |
+| **Go to home screen** | Returns to the device home screen | No |
+| **Rotate device** | Rotates the device orientation | No |
+| **Hide keyboard** | Dismisses the on-screen keyboard | No |
+| **Open URL / deeplink** | Opens a URL or deeplink | No |
+| **Set location** | Sets the device GPS location | Yes (coordinates) |
+| **Wait** | Pauses execution | No |
+| **Verify** / Check | Visually inspects the screen for a condition | Yes (what to verify) |
+
+
+ **Verify** is the one step type that is not a device action. Use it in `setup` to confirm cleanup succeeded, and in `steps` to confirm intermediate states before critical actions.
+
+
+## Writing good steps
+
+Good steps are specific and reference actual UI labels — the text or label visible on screen, not internal component names.
+
+- Reference the exact label: `Tap the Login button`, not `Tap the button`.
+- Name the screen when it matters: `Enter the password on the Password screen`.
+- Add inline `Verify` steps before critical actions so failures are caught with a clear message rather than a confusing grounding error:
+
+```yaml
+steps:
+ - Verify the hamburger menu icon is visible in the top-left corner of the toolbar.
+ - Tap the hamburger menu icon in the top-left corner of the toolbar.
+```
+
+- Use `Verify` steps in `steps` to confirm intermediate states during multi-step flows.
+- Reserve `expected_state` for the final screen only. Do not put navigation or interaction instructions there.
+
+### Avoid verifying ephemeral UI
+
+Do not assert on toasts, snackbars, or transient banners in `steps` or `expected_state`. These short-lived messages disappear on their own timer and can race against the agent's verification step. Verify the persistent consequence instead — the updated list, the changed badge count, the screen that appeared.
+
+```yaml
+# Good — verifies a persistent outcome
+expected_state:
+ - The item appears in the shopping cart.
+
+# Bad — toast may have already dismissed
+expected_state:
+ - The "Added to cart" toast is visible.
+```
+
+## Positional strictness
+
+When a step specifies the position of a UI element — `top-left corner`, `in the header`, `first item` — the agent treats that position as a strict assertion. If the element is not found at the described location, the test fails; the agent will not search elsewhere.
+
+Use positional context when the element's location is part of what you are testing. Omit it when you only need to confirm the element exists, so the agent can scroll to find it.
+
+```yaml
+# Position matters — include it
+expected_state:
+ - The navigation drawer is open and visible on the left side of the screen.
+ - The profile avatar is visible at the top of the drawer.
+
+# Position doesn't matter — keep it generic
+expected_state:
+ - The navigation drawer is open.
+ - The profile avatar is visible.
+```
+
+The second `expected_state` block above is too vague — `The navigation drawer is open` could match an unintended element. The first block is spatially precise and will only pass if the layout matches exactly.
+
+## Setup and idempotent cleanup
+
+Every test must be idempotent: assume it has already run and failed. If a previous run added data, enabled a toggle, or navigated to a new screen, your `setup` must reverse that state before the test begins.
+
+| If the test validates... | Setup must... |
+|---|---|
+| **Adding** an item | Check if the item exists and delete it first. |
+| **Deleting** an item | Check if the item exists and add it first if missing. |
+| **Enabling** a toggle | Disable the toggle first if it is already on. |
+| **Moving or reordering** | Reset the list to a known default order first. |
+
+Always add a `Verify` step after each cleanup action to confirm the app is in the expected starting state. If cleanup fails, the test will fail early in setup rather than produce a misleading failure in the main steps.
+
+```yaml
+setup:
+ - Navigate to the Shopping List screen.
+ - If the item 'Milk' is visible, swipe left on it and tap Delete.
+ - Verify that 'Milk' is no longer visible on the Shopping List screen.
+```
+
+## File organization
+
+
+ Group tests by feature under `.finalrun/tests//`. For example, authentication tests belong in `.finalrun/tests/auth/`, and onboarding tests in `.finalrun/tests/onboarding/`. This mirrors the suite structure and makes it easy to run all tests for a given feature at once.
+
diff --git a/mintlify-docs/troubleshooting.mdx b/mintlify-docs/troubleshooting.mdx
new file mode 100644
index 0000000..bd5ddd0
--- /dev/null
+++ b/mintlify-docs/troubleshooting.mdx
@@ -0,0 +1,160 @@
+---
+title: "Troubleshoot FinalRun: common errors and device setup"
+sidebarTitle: "Troubleshooting"
+description: "Diagnose and fix common FinalRun errors — missing workspace, unconfigured API keys, missing device tools — and verify host readiness with finalrun doctor."
+---
+
+When something goes wrong during setup or a test run, the error message usually tells you exactly what FinalRun expected and where to look. The sections below cover the most common errors, their causes, and the steps to resolve them. After fixing a configuration issue, run `finalrun doctor` to confirm your environment is ready before re-running your tests.
+
+## Common errors
+
+
+
+ FinalRun finds your workspace by walking up from your current directory until it finds a folder that contains `.finalrun/`. If you run `finalrun` from outside your app repository — or before creating the workspace — it cannot locate the directory.
+
+ **Fix:** Make sure your shell is inside the app repository where `.finalrun/tests/` exists. You can confirm the structure is in place with:
+
+ ```sh
+ ls .finalrun/tests/
+ ```
+
+ If the directory is missing, follow the workspace setup guide to initialize your `.finalrun/` folder before running any commands.
+
+
+
+ FinalRun reads your AI provider API key from the environment. The key it looks for depends on the provider prefix in your `--model` value or your `.finalrun/config.yaml` default.
+
+ | Model prefix | Required environment variable |
+ |---|---|
+ | `openai/...` | `OPENAI_API_KEY` |
+ | `google/...` | `GOOGLE_API_KEY` |
+ | `anthropic/...` | `ANTHROPIC_API_KEY` |
+
+ **Fix:** Set the correct variable in your shell or in a `.env` file at your workspace root. For example, if you are using a Google model:
+
+ ```sh
+ echo "GOOGLE_API_KEY=your-key-here" >> .env
+ ```
+
+ FinalRun loads `.env` from the workspace root (the folder containing `.finalrun/`). You can also pass the key directly with the `--api-key` flag to override the environment for a single run.
+
+
+ Do not commit `.env` to version control. Add `.env` and `.env.*` to your `.gitignore`, keeping `.env.example` tracked as a template.
+
+
+
+
+ FinalRun requires a running Android Virtual Device (AVD) before it can connect and start a test. If no emulator is active when you run a test, FinalRun cannot proceed.
+
+ **Fix:** Start an emulator before running your test. You can do this from the command line:
+
+ ```sh
+ emulator -avd
+ ```
+
+ Or launch one from the **Device Manager** in Android Studio. Once the emulator has fully booted, verify that FinalRun can detect it:
+
+ ```sh
+ finalrun doctor --platform android
+ ```
+
+
+
+ FinalRun depends on `scrcpy` for Android screen recording and `adb` (Android Debug Bridge) for device communication. If either tool is missing from your `PATH`, Android tests cannot run.
+
+ **Fix:** Install both tools with Homebrew on macOS:
+
+ ```sh
+ brew install scrcpy android-platform-tools
+ ```
+
+ After installation, verify that FinalRun can find all required Android tools:
+
+ ```sh
+ finalrun doctor
+ ```
+
+
+ `android-platform-tools` provides `adb`. Make sure `ANDROID_HOME` or `ANDROID_SDK_ROOT` is set in your shell so FinalRun can locate the full Android SDK.
+
+
+
+
+ When a test spec references a value like `${secrets.email}`, FinalRun looks it up in your environment binding file (`.finalrun/env/.yaml`) and then resolves the underlying variable from your shell environment or `.env` file. If either piece is missing, the placeholder cannot be resolved.
+
+ **Fix:** Check two things:
+
+ 1. The binding is declared in `.finalrun/env/.yaml` using the `${ENV_VAR}` placeholder syntax:
+
+ ```yaml
+ secrets:
+ email: ${TEST_USER_EMAIL}
+ password: ${TEST_USER_PASSWORD}
+ ```
+
+ 2. The actual value is present in your shell environment or in the workspace-root `.env` (or `.env.`) file:
+
+ ```sh
+ echo "TEST_USER_EMAIL=user@example.com" >> .env
+ ```
+
+ The environment name `` must match the `--env` flag you pass (or the `env` value in `.finalrun/config.yaml`).
+
+
+
+ The `--app` flag expects a path to an existing binary that matches the target platform: an `.apk` file for Android or a `.app` directory for iOS. If the path does not exist or the file type does not match the `--platform` value, FinalRun rejects it.
+
+ **Fix:** Verify the path exists and the file matches the platform you are targeting:
+
+ ```sh
+ # Android
+ finalrun test smoke.yaml --platform android --app path/to/your.apk
+
+ # iOS
+ finalrun test smoke.yaml --platform ios --app path/to/YourApp.app
+ ```
+
+ If you omit `--app`, FinalRun uses the app identity defined in `.finalrun/config.yaml`.
+
+
+
+ In earlier versions of FinalRun, running the CLI from within AI coding agent terminals (such as Claude Code or Cursor) could cause TTY-related errors because those environments do not always provide a standard terminal interface.
+
+ This issue has been resolved. Upgrade to the latest version of FinalRun to pick up the fix:
+
+ ```sh
+ curl -fsSL https://raw.githubusercontent.com/final-run/finalrun-agent/main/scripts/install.sh | bash
+ ```
+
+
+
+## Verify host readiness with finalrun doctor
+
+Before running tests, use `finalrun doctor` to check that all required tools and platform dependencies are installed and reachable. The command prints a tick/cross summary for each dependency.
+
+```sh
+# Check both Android and iOS
+finalrun doctor
+
+# Check Android only
+finalrun doctor --platform android
+
+# Check iOS only
+finalrun doctor --platform ios
+```
+
+Fix any items marked with a cross before running tests. For Android, the required tools are `adb`, `emulator`, and `scrcpy`. For iOS (macOS only), FinalRun requires Xcode command line tools with `xcrun simctl`.
+
+## Getting more information
+
+If an error is not covered above or you need more detail during a failing run, use the `--debug` flag to enable verbose logging:
+
+```sh
+finalrun test smoke.yaml --platform android --debug
+```
+
+After a run completes, use `finalrun start-server` to open the visual report UI and inspect screenshots, video, and device logs for the failed run.
+
+If you are still stuck, join the FinalRun community on Slack — the team and other users are active there:
+
+[Join the FinalRun Slack community](https://join.slack.com/t/finalrun-community/shared_invite/zt-38qg6q9fq-9L87nNF8aX4HZ8_pn9KBgw)
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index 748197f..0000000
--- a/package-lock.json
+++ /dev/null
@@ -1,4118 +0,0 @@
-{
- "name": "finalrun-agent-monorepo",
- "version": "0.1.5",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "finalrun-agent-monorepo",
- "version": "0.1.5",
- "workspaces": [
- "packages/common",
- "packages/device-node",
- "packages/goal-executor",
- "packages/cli",
- "packages/report-web"
- ],
- "devDependencies": {
- "@eslint/js": "^10.0.1",
- "eslint": "^10.1.0",
- "eslint-config-prettier": "^10.1.8",
- "globals": "^17.4.0",
- "prettier": "^3.8.1",
- "typescript-eslint": "^8.57.2"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/@ai-sdk/anthropic": {
- "version": "3.0.64",
- "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.64.tgz",
- "integrity": "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g==",
- "license": "Apache-2.0",
- "dependencies": {
- "@ai-sdk/provider": "3.0.8",
- "@ai-sdk/provider-utils": "4.0.21"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "zod": "^3.25.76 || ^4.1.8"
- }
- },
- "node_modules/@ai-sdk/gateway": {
- "version": "3.0.83",
- "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.83.tgz",
- "integrity": "sha512-LvlWujbSdEkTBXBLFtF7GS6riXdHhH0O+DpDrCaNQvXeHmSF2jKsOg7JWXiCgygAHM5cWFAO3JYmZp83DjiuBQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "@ai-sdk/provider": "3.0.8",
- "@ai-sdk/provider-utils": "4.0.21",
- "@vercel/oidc": "3.1.0"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "zod": "^3.25.76 || ^4.1.8"
- }
- },
- "node_modules/@ai-sdk/google": {
- "version": "3.0.54",
- "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.54.tgz",
- "integrity": "sha512-EgYYdA2LpHZefLDU/FIpmeTlL5Hi4WKQZY3nACMh0wVhrS1fAvlfrdwnD1G4ISCOKWMWrMcRZX9ubs3NM/KHfA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@ai-sdk/provider": "3.0.8",
- "@ai-sdk/provider-utils": "4.0.21"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "zod": "^3.25.76 || ^4.1.8"
- }
- },
- "node_modules/@ai-sdk/openai": {
- "version": "3.0.49",
- "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.49.tgz",
- "integrity": "sha512-U2f0pCyNn/jQH3wjgxr8o9VvCkuDFTtXbIhbFFtgXqCzMbed6rBnvzQcAMEK0/Pa44byL9zfcvCOFOflvkRA8w==",
- "license": "Apache-2.0",
- "dependencies": {
- "@ai-sdk/provider": "3.0.8",
- "@ai-sdk/provider-utils": "4.0.21"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "zod": "^3.25.76 || ^4.1.8"
- }
- },
- "node_modules/@ai-sdk/provider": {
- "version": "3.0.8",
- "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
- "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "json-schema": "^0.4.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@ai-sdk/provider-utils": {
- "version": "4.0.21",
- "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.21.tgz",
- "integrity": "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw==",
- "license": "Apache-2.0",
- "dependencies": {
- "@ai-sdk/provider": "3.0.8",
- "@standard-schema/spec": "^1.1.0",
- "eventsource-parser": "^3.0.6"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "zod": "^3.25.76 || ^4.1.8"
- }
- },
- "node_modules/@bufbuild/protobuf": {
- "version": "2.11.0",
- "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
- "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
- "dev": true,
- "license": "(Apache-2.0 AND BSD-3-Clause)"
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
- "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
- "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
- "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
- "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
- "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
- "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
- "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
- "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
- "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
- "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
- "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
- "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
- "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
- "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
- "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
- "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
- "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
- "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
- "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
- "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
- "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
- "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
- "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
- "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
- "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
- "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
- "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@eslint-community/eslint-utils": {
- "version": "4.9.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
- "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-visitor-keys": "^3.4.3"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- },
- "peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
- }
- },
- "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
- "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@eslint-community/regexpp": {
- "version": "4.12.2",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
- "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
- }
- },
- "node_modules/@eslint/config-array": {
- "version": "0.23.3",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
- "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/object-schema": "^3.0.3",
- "debug": "^4.3.1",
- "minimatch": "^10.2.4"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/config-helpers": {
- "version": "0.5.3",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
- "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/core": "^1.1.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/core": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
- "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@types/json-schema": "^7.0.15"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/js": {
- "version": "10.0.1",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
- "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://eslint.org/donate"
- },
- "peerDependencies": {
- "eslint": "^10.0.0"
- },
- "peerDependenciesMeta": {
- "eslint": {
- "optional": true
- }
- }
- },
- "node_modules/@eslint/object-schema": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
- "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@eslint/plugin-kit": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
- "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@eslint/core": "^1.1.1",
- "levn": "^0.4.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- }
- },
- "node_modules/@finalrun/common": {
- "resolved": "packages/common",
- "link": true
- },
- "node_modules/@finalrun/device-node": {
- "resolved": "packages/device-node",
- "link": true
- },
- "node_modules/@finalrun/finalrun-agent": {
- "resolved": "packages/cli",
- "link": true
- },
- "node_modules/@finalrun/goal-executor": {
- "resolved": "packages/goal-executor",
- "link": true
- },
- "node_modules/@finalrun/report-web": {
- "resolved": "packages/report-web",
- "link": true
- },
- "node_modules/@grpc/grpc-js": {
- "version": "1.14.3",
- "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
- "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@grpc/proto-loader": "^0.8.0",
- "@js-sdsl/ordered-map": "^4.4.2"
- },
- "engines": {
- "node": ">=12.10.0"
- }
- },
- "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
- "version": "0.8.0",
- "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
- "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "lodash.camelcase": "^4.3.0",
- "long": "^5.0.0",
- "protobufjs": "^7.5.3",
- "yargs": "^17.7.2"
- },
- "bin": {
- "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/@grpc/proto-loader": {
- "version": "0.7.15",
- "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
- "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "lodash.camelcase": "^4.3.0",
- "long": "^5.0.0",
- "protobufjs": "^7.2.5",
- "yargs": "^17.7.2"
- },
- "bin": {
- "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/@humanfs/core": {
- "version": "0.19.1",
- "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
- "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18.18.0"
- }
- },
- "node_modules/@humanfs/node": {
- "version": "0.16.7",
- "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
- "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@humanfs/core": "^0.19.1",
- "@humanwhocodes/retry": "^0.4.0"
- },
- "engines": {
- "node": ">=18.18.0"
- }
- },
- "node_modules/@humanwhocodes/module-importer": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
- "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.22"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@humanwhocodes/retry": {
- "version": "0.4.3",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
- "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18.18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@img/colour": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
- "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@img/sharp-darwin-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
- "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-darwin-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
- "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
- "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
- "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
- "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
- "cpu": [
- "arm"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
- "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
- "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
- "cpu": [
- "ppc64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-riscv64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
- "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
- "cpu": [
- "riscv64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
- "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
- "cpu": [
- "s390x"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
- "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
- "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
- "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-linux-arm": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
- "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
- "cpu": [
- "arm"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
- "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-ppc64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
- "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
- "cpu": [
- "ppc64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-riscv64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
- "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
- "cpu": [
- "riscv64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-riscv64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-s390x": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
- "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
- "cpu": [
- "s390x"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
- "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
- "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
- "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-wasm32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
- "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
- "cpu": [
- "wasm32"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/runtime": "^1.7.0"
- },
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
- "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-ia32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
- "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
- "cpu": [
- "ia32"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
- "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@isaacs/fs-minipass": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
- "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "minipass": "^7.0.4"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "node_modules/@js-sdsl/ordered-map": {
- "version": "4.4.2",
- "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
- "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
- "license": "MIT",
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/js-sdsl"
- }
- },
- "node_modules/@mapbox/node-pre-gyp": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz",
- "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "consola": "^3.2.3",
- "detect-libc": "^2.0.0",
- "https-proxy-agent": "^7.0.5",
- "node-fetch": "^2.6.7",
- "nopt": "^8.0.0",
- "semver": "^7.5.3",
- "tar": "^7.4.0"
- },
- "bin": {
- "node-pre-gyp": "bin/node-pre-gyp"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@next/env": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz",
- "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==",
- "license": "MIT"
- },
- "node_modules/@next/swc-darwin-arm64": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz",
- "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-darwin-x64": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz",
- "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz",
- "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz",
- "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz",
- "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-x64-musl": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz",
- "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz",
- "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz",
- "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@opentelemetry/api": {
- "version": "1.9.0",
- "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
- "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8.0.0"
- }
- },
- "node_modules/@protobufjs/aspromise": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
- "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/base64": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
- "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/codegen": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
- "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/eventemitter": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
- "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/fetch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
- "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.1",
- "@protobufjs/inquire": "^1.1.0"
- }
- },
- "node_modules/@protobufjs/float": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
- "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/inquire": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
- "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/path": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
- "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/pool": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
- "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/utf8": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
- "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@standard-schema/spec": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
- "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
- "license": "MIT"
- },
- "node_modules/@swc/helpers": {
- "version": "0.5.15",
- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
- "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.8.0"
- }
- },
- "node_modules/@types/esrecurse": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
- "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/estree": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
- "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/json-schema": {
- "version": "7.0.15",
- "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
- "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/node": {
- "version": "25.5.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
- "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
- "license": "MIT",
- "dependencies": {
- "undici-types": "~7.18.0"
- }
- },
- "node_modules/@types/react": {
- "version": "19.2.14",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
- "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "csstype": "^3.2.2"
- }
- },
- "node_modules/@types/react-dom": {
- "version": "19.2.3",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
- "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "^19.2.0"
- }
- },
- "node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
- "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.58.0",
- "@typescript-eslint/type-utils": "8.58.0",
- "@typescript-eslint/utils": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0",
- "ignore": "^7.0.5",
- "natural-compare": "^1.4.0",
- "ts-api-utils": "^2.5.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "@typescript-eslint/parser": "^8.58.0",
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
- "version": "7.0.5",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
- "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/@typescript-eslint/parser": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz",
- "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/scope-manager": "8.58.0",
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0",
- "debug": "^4.4.3"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/@typescript-eslint/project-service": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz",
- "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.58.0",
- "@typescript-eslint/types": "^8.58.0",
- "debug": "^4.4.3"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/@typescript-eslint/scope-manager": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz",
- "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz",
- "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/@typescript-eslint/type-utils": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz",
- "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0",
- "@typescript-eslint/utils": "8.58.0",
- "debug": "^4.4.3",
- "ts-api-utils": "^2.5.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/@typescript-eslint/types": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz",
- "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz",
- "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/project-service": "8.58.0",
- "@typescript-eslint/tsconfig-utils": "8.58.0",
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0",
- "debug": "^4.4.3",
- "minimatch": "^10.2.2",
- "semver": "^7.7.3",
- "tinyglobby": "^0.2.15",
- "ts-api-utils": "^2.5.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/@typescript-eslint/utils": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz",
- "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.58.0",
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz",
- "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "8.58.0",
- "eslint-visitor-keys": "^5.0.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vercel/oidc": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
- "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">= 20"
- }
- },
- "node_modules/abbrev": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
- "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": "^18.17.0 || >=20.5.0"
- }
- },
- "node_modules/acorn": {
- "version": "8.16.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
- "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/acorn-jsx": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
- "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/agent-base": {
- "version": "7.1.4",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
- "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/ai": {
- "version": "6.0.141",
- "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.141.tgz",
- "integrity": "sha512-+GomGQWaId3xN0wcugUW/H7xMMaFkID2PiS7K/Wugj45G3efv0BXhQ3psRZoQVoRbOpdNoUqcK/KTB+FR4h6qg==",
- "license": "Apache-2.0",
- "dependencies": {
- "@ai-sdk/gateway": "3.0.83",
- "@ai-sdk/provider": "3.0.8",
- "@ai-sdk/provider-utils": "4.0.21",
- "@opentelemetry/api": "1.9.0"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "zod": "^3.25.76 || ^4.1.8"
- }
- },
- "node_modules/ajv": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
- "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/ansi-regex": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
- "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/balanced-match": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/baseline-browser-mapping": {
- "version": "2.10.12",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz",
- "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==",
- "license": "Apache-2.0",
- "bin": {
- "baseline-browser-mapping": "dist/cli.cjs"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/brace-expansion": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
- "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^4.0.2"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/caniuse-lite": {
- "version": "1.0.30001782",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz",
- "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "CC-BY-4.0"
- },
- "node_modules/case-anything": {
- "version": "2.1.13",
- "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz",
- "integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.13"
- },
- "funding": {
- "url": "https://github.com/sponsors/mesqueeb"
- }
- },
- "node_modules/chalk": {
- "version": "5.6.2",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
- "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
- "license": "MIT",
- "engines": {
- "node": "^12.17.0 || ^14.13 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/chalk/chalk?sponsor=1"
- }
- },
- "node_modules/chownr": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
- "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/cli-cursor": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
- "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
- "license": "MIT",
- "dependencies": {
- "restore-cursor": "^5.0.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/cli-spinners": {
- "version": "2.9.2",
- "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
- "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/client-only": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
- "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
- "license": "MIT"
- },
- "node_modules/cliui": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
- "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
- "license": "ISC",
- "dependencies": {
- "string-width": "^4.2.0",
- "strip-ansi": "^6.0.1",
- "wrap-ansi": "^7.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/cliui/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/cliui/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "license": "MIT"
- },
- "node_modules/cliui/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/cliui/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "license": "MIT"
- },
- "node_modules/commander": {
- "version": "13.1.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
- "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/consola": {
- "version": "3.4.2",
- "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
- "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^14.18.0 || >=16.10.0"
- }
- },
- "node_modules/cross-spawn": {
- "version": "7.0.6",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
- "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/deep-is": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
- "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "devOptional": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/dotenv": {
- "version": "16.6.1",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
- "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://dotenvx.com"
- }
- },
- "node_modules/dprint-node": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/dprint-node/-/dprint-node-1.0.8.tgz",
- "integrity": "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "detect-libc": "^1.0.3"
- }
- },
- "node_modules/dprint-node/node_modules/detect-libc": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
- "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "detect-libc": "bin/detect-libc.js"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/emoji-regex": {
- "version": "10.6.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
- "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
- "license": "MIT"
- },
- "node_modules/esbuild": {
- "version": "0.27.4",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
- "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.4",
- "@esbuild/android-arm": "0.27.4",
- "@esbuild/android-arm64": "0.27.4",
- "@esbuild/android-x64": "0.27.4",
- "@esbuild/darwin-arm64": "0.27.4",
- "@esbuild/darwin-x64": "0.27.4",
- "@esbuild/freebsd-arm64": "0.27.4",
- "@esbuild/freebsd-x64": "0.27.4",
- "@esbuild/linux-arm": "0.27.4",
- "@esbuild/linux-arm64": "0.27.4",
- "@esbuild/linux-ia32": "0.27.4",
- "@esbuild/linux-loong64": "0.27.4",
- "@esbuild/linux-mips64el": "0.27.4",
- "@esbuild/linux-ppc64": "0.27.4",
- "@esbuild/linux-riscv64": "0.27.4",
- "@esbuild/linux-s390x": "0.27.4",
- "@esbuild/linux-x64": "0.27.4",
- "@esbuild/netbsd-arm64": "0.27.4",
- "@esbuild/netbsd-x64": "0.27.4",
- "@esbuild/openbsd-arm64": "0.27.4",
- "@esbuild/openbsd-x64": "0.27.4",
- "@esbuild/openharmony-arm64": "0.27.4",
- "@esbuild/sunos-x64": "0.27.4",
- "@esbuild/win32-arm64": "0.27.4",
- "@esbuild/win32-ia32": "0.27.4",
- "@esbuild/win32-x64": "0.27.4"
- }
- },
- "node_modules/escalade": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
- "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/escape-string-regexp": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/eslint": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz",
- "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.8.0",
- "@eslint-community/regexpp": "^4.12.2",
- "@eslint/config-array": "^0.23.3",
- "@eslint/config-helpers": "^0.5.3",
- "@eslint/core": "^1.1.1",
- "@eslint/plugin-kit": "^0.6.1",
- "@humanfs/node": "^0.16.6",
- "@humanwhocodes/module-importer": "^1.0.1",
- "@humanwhocodes/retry": "^0.4.2",
- "@types/estree": "^1.0.6",
- "ajv": "^6.14.0",
- "cross-spawn": "^7.0.6",
- "debug": "^4.3.2",
- "escape-string-regexp": "^4.0.0",
- "eslint-scope": "^9.1.2",
- "eslint-visitor-keys": "^5.0.1",
- "espree": "^11.2.0",
- "esquery": "^1.7.0",
- "esutils": "^2.0.2",
- "fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^8.0.0",
- "find-up": "^5.0.0",
- "glob-parent": "^6.0.2",
- "ignore": "^5.2.0",
- "imurmurhash": "^0.1.4",
- "is-glob": "^4.0.0",
- "json-stable-stringify-without-jsonify": "^1.0.1",
- "minimatch": "^10.2.4",
- "natural-compare": "^1.4.0",
- "optionator": "^0.9.3"
- },
- "bin": {
- "eslint": "bin/eslint.js"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://eslint.org/donate"
- },
- "peerDependencies": {
- "jiti": "*"
- },
- "peerDependenciesMeta": {
- "jiti": {
- "optional": true
- }
- }
- },
- "node_modules/eslint-config-prettier": {
- "version": "10.1.8",
- "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
- "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "eslint-config-prettier": "bin/cli.js"
- },
- "funding": {
- "url": "https://opencollective.com/eslint-config-prettier"
- },
- "peerDependencies": {
- "eslint": ">=7.0.0"
- }
- },
- "node_modules/eslint-scope": {
- "version": "9.1.2",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
- "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "@types/esrecurse": "^4.3.1",
- "@types/estree": "^1.0.8",
- "esrecurse": "^4.3.0",
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/eslint-visitor-keys": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
- "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/espree": {
- "version": "11.2.0",
- "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
- "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "acorn": "^8.16.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^5.0.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/esquery": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
- "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "estraverse": "^5.1.0"
- },
- "engines": {
- "node": ">=0.10"
- }
- },
- "node_modules/esrecurse": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
- "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "estraverse": "^5.2.0"
- },
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/estraverse": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
- "node_modules/esutils": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
- "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/eventsource-parser": {
- "version": "3.0.6",
- "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
- "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
- "license": "MIT",
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "node_modules/fast-deep-equal": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-json-stable-stringify": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
- "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fast-levenshtein": {
- "version": "2.0.6",
- "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
- "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/file-entry-cache": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
- "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flat-cache": "^4.0.0"
- },
- "engines": {
- "node": ">=16.0.0"
- }
- },
- "node_modules/find-up": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
- "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "locate-path": "^6.0.0",
- "path-exists": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/flat-cache": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
- "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "flatted": "^3.2.9",
- "keyv": "^4.5.4"
- },
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/flatted": {
- "version": "3.4.2",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
- "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/get-caller-file": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
- "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "license": "ISC",
- "engines": {
- "node": "6.* || 8.* || >= 10.*"
- }
- },
- "node_modules/get-east-asian-width": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
- "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/get-tsconfig": {
- "version": "4.13.7",
- "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
- "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "resolve-pkg-maps": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
- }
- },
- "node_modules/glob-parent": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
- "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.3"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/globals": {
- "version": "17.4.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
- "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/grpc-tools": {
- "version": "1.13.1",
- "resolved": "https://registry.npmjs.org/grpc-tools/-/grpc-tools-1.13.1.tgz",
- "integrity": "sha512-0sttMUxThNIkCTJq5qI0xXMz5zWqV2u3yG1kR3Sj9OokGIoyRBFjoInK9NyW7x5fH7knj48Roh1gq5xbl0VoDQ==",
- "dev": true,
- "hasInstallScript": true,
- "dependencies": {
- "@mapbox/node-pre-gyp": "^2.0.0"
- },
- "bin": {
- "grpc_tools_node_protoc": "bin/protoc.js",
- "grpc_tools_node_protoc_plugin": "bin/protoc_plugin.js"
- }
- },
- "node_modules/https-proxy-agent": {
- "version": "7.0.6",
- "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
- "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "agent-base": "^7.1.2",
- "debug": "4"
- },
- "engines": {
- "node": ">= 14"
- }
- },
- "node_modules/ignore": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
- "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
- "node_modules/imurmurhash": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
- "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.8.19"
- }
- },
- "node_modules/is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-glob": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-extglob": "^2.1.1"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/is-interactive": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
- "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/is-unicode-supported": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
- "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/json-buffer": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-schema": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
- "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
- "license": "(AFL-2.1 OR BSD-3-Clause)"
- },
- "node_modules/json-schema-traverse": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
- "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-stable-stringify-without-jsonify": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
- "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/keyv": {
- "version": "4.5.4",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
- "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "json-buffer": "3.0.1"
- }
- },
- "node_modules/levn": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
- "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1",
- "type-check": "~0.4.0"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/locate-path": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
- "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-locate": "^5.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/lodash.camelcase": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
- "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
- "license": "MIT"
- },
- "node_modules/log-symbols": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
- "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
- "license": "MIT",
- "dependencies": {
- "chalk": "^5.3.0",
- "is-unicode-supported": "^1.3.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/log-symbols/node_modules/is-unicode-supported": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
- "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/long": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
- "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
- "license": "Apache-2.0"
- },
- "node_modules/mimic-function": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
- "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/minimatch": {
- "version": "10.2.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
- "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "brace-expansion": "^5.0.5"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/minipass": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
- "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=16 || 14 >=14.17"
- }
- },
- "node_modules/minizlib": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
- "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "minipass": "^7.1.2"
- },
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/natural-compare": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
- "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/next": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz",
- "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==",
- "license": "MIT",
- "dependencies": {
- "@next/env": "16.2.1",
- "@swc/helpers": "0.5.15",
- "baseline-browser-mapping": "^2.9.19",
- "caniuse-lite": "^1.0.30001579",
- "postcss": "8.4.31",
- "styled-jsx": "5.1.6"
- },
- "bin": {
- "next": "dist/bin/next"
- },
- "engines": {
- "node": ">=20.9.0"
- },
- "optionalDependencies": {
- "@next/swc-darwin-arm64": "16.2.1",
- "@next/swc-darwin-x64": "16.2.1",
- "@next/swc-linux-arm64-gnu": "16.2.1",
- "@next/swc-linux-arm64-musl": "16.2.1",
- "@next/swc-linux-x64-gnu": "16.2.1",
- "@next/swc-linux-x64-musl": "16.2.1",
- "@next/swc-win32-arm64-msvc": "16.2.1",
- "@next/swc-win32-x64-msvc": "16.2.1",
- "sharp": "^0.34.5"
- },
- "peerDependencies": {
- "@opentelemetry/api": "^1.1.0",
- "@playwright/test": "^1.51.1",
- "babel-plugin-react-compiler": "*",
- "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
- "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
- "sass": "^1.3.0"
- },
- "peerDependenciesMeta": {
- "@opentelemetry/api": {
- "optional": true
- },
- "@playwright/test": {
- "optional": true
- },
- "babel-plugin-react-compiler": {
- "optional": true
- },
- "sass": {
- "optional": true
- }
- }
- },
- "node_modules/node-fetch": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
- "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "whatwg-url": "^5.0.0"
- },
- "engines": {
- "node": "4.x || >=6.0.0"
- },
- "peerDependencies": {
- "encoding": "^0.1.0"
- },
- "peerDependenciesMeta": {
- "encoding": {
- "optional": true
- }
- }
- },
- "node_modules/nopt": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
- "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "abbrev": "^3.0.0"
- },
- "bin": {
- "nopt": "bin/nopt.js"
- },
- "engines": {
- "node": "^18.17.0 || >=20.5.0"
- }
- },
- "node_modules/onetime": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
- "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
- "license": "MIT",
- "dependencies": {
- "mimic-function": "^5.0.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/optionator": {
- "version": "0.9.4",
- "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
- "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "deep-is": "^0.1.3",
- "fast-levenshtein": "^2.0.6",
- "levn": "^0.4.1",
- "prelude-ls": "^1.2.1",
- "type-check": "^0.4.0",
- "word-wrap": "^1.2.5"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/ora": {
- "version": "8.2.0",
- "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
- "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
- "license": "MIT",
- "dependencies": {
- "chalk": "^5.3.0",
- "cli-cursor": "^5.0.0",
- "cli-spinners": "^2.9.2",
- "is-interactive": "^2.0.0",
- "is-unicode-supported": "^2.0.0",
- "log-symbols": "^6.0.0",
- "stdin-discarder": "^0.2.2",
- "string-width": "^7.2.0",
- "strip-ansi": "^7.1.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-limit": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
- "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "yocto-queue": "^0.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-locate": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
- "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "p-limit": "^3.0.2"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/path-exists": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
- "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/path-key": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/postcss": {
- "version": "8.4.31",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
- "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.6",
- "picocolors": "^1.0.0",
- "source-map-js": "^1.0.2"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/prelude-ls": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
- "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/prettier": {
- "version": "3.8.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
- "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "prettier": "bin/prettier.cjs"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/prettier/prettier?sponsor=1"
- }
- },
- "node_modules/protobufjs": {
- "version": "7.5.4",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
- "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
- "hasInstallScript": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.2",
- "@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
- "@protobufjs/eventemitter": "^1.1.0",
- "@protobufjs/fetch": "^1.1.0",
- "@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
- "@protobufjs/path": "^1.1.2",
- "@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
- "@types/node": ">=13.7.0",
- "long": "^5.0.0"
- },
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/punycode": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/react": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
- "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/react-dom": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
- "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
- "license": "MIT",
- "dependencies": {
- "scheduler": "^0.27.0"
- },
- "peerDependencies": {
- "react": "^19.2.4"
- }
- },
- "node_modules/require-directory": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
- "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/resolve-pkg-maps": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
- "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
- }
- },
- "node_modules/restore-cursor": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
- "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
- "license": "MIT",
- "dependencies": {
- "onetime": "^7.0.0",
- "signal-exit": "^4.1.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/scheduler": {
- "version": "0.27.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
- "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
- "license": "MIT"
- },
- "node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "devOptional": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/sharp": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
- "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "optional": true,
- "dependencies": {
- "@img/colour": "^1.0.0",
- "detect-libc": "^2.1.2",
- "semver": "^7.7.3"
- },
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.34.5",
- "@img/sharp-darwin-x64": "0.34.5",
- "@img/sharp-libvips-darwin-arm64": "1.2.4",
- "@img/sharp-libvips-darwin-x64": "1.2.4",
- "@img/sharp-libvips-linux-arm": "1.2.4",
- "@img/sharp-libvips-linux-arm64": "1.2.4",
- "@img/sharp-libvips-linux-ppc64": "1.2.4",
- "@img/sharp-libvips-linux-riscv64": "1.2.4",
- "@img/sharp-libvips-linux-s390x": "1.2.4",
- "@img/sharp-libvips-linux-x64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
- "@img/sharp-linux-arm": "0.34.5",
- "@img/sharp-linux-arm64": "0.34.5",
- "@img/sharp-linux-ppc64": "0.34.5",
- "@img/sharp-linux-riscv64": "0.34.5",
- "@img/sharp-linux-s390x": "0.34.5",
- "@img/sharp-linux-x64": "0.34.5",
- "@img/sharp-linuxmusl-arm64": "0.34.5",
- "@img/sharp-linuxmusl-x64": "0.34.5",
- "@img/sharp-wasm32": "0.34.5",
- "@img/sharp-win32-arm64": "0.34.5",
- "@img/sharp-win32-ia32": "0.34.5",
- "@img/sharp-win32-x64": "0.34.5"
- }
- },
- "node_modules/shebang-command": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "shebang-regex": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/shebang-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/signal-exit": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
- "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "license": "ISC",
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/stdin-discarder": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
- "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/string-width": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
- "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^10.3.0",
- "get-east-asian-width": "^1.0.0",
- "strip-ansi": "^7.1.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/strip-ansi": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
- "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^6.2.2"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
- "node_modules/styled-jsx": {
- "version": "5.1.6",
- "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
- "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
- "license": "MIT",
- "dependencies": {
- "client-only": "0.0.1"
- },
- "engines": {
- "node": ">= 12.0.0"
- },
- "peerDependencies": {
- "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
- },
- "peerDependenciesMeta": {
- "@babel/core": {
- "optional": true
- },
- "babel-plugin-macros": {
- "optional": true
- }
- }
- },
- "node_modules/tar": {
- "version": "7.5.13",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
- "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "@isaacs/fs-minipass": "^4.0.0",
- "chownr": "^3.0.0",
- "minipass": "^7.1.2",
- "minizlib": "^3.1.0",
- "yallist": "^5.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.15",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/tr46": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
- "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/ts-api-utils": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
- "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18.12"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4"
- }
- },
- "node_modules/ts-poet": {
- "version": "6.12.0",
- "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz",
- "integrity": "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "dprint-node": "^1.0.8"
- }
- },
- "node_modules/ts-proto": {
- "version": "2.11.6",
- "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.11.6.tgz",
- "integrity": "sha512-2rPkH5W/KeXOyVUC6o06RdRabVK8zSDmQpnRz4XbRiYMHRdI12KqDjAdGW7ebxzzMNE5cw/j+ptA0WMVqZILrQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "@bufbuild/protobuf": "^2.10.2",
- "case-anything": "^2.1.13",
- "ts-poet": "^6.12.0",
- "ts-proto-descriptors": "2.1.0"
- },
- "bin": {
- "protoc-gen-ts_proto": "protoc-gen-ts_proto"
- }
- },
- "node_modules/ts-proto-descriptors": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-2.1.0.tgz",
- "integrity": "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "@bufbuild/protobuf": "^2.0.0"
- }
- },
- "node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD"
- },
- "node_modules/tsx": {
- "version": "4.21.0",
- "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
- "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "esbuild": "~0.27.0",
- "get-tsconfig": "^4.7.5"
- },
- "bin": {
- "tsx": "dist/cli.mjs"
- },
- "engines": {
- "node": ">=18.0.0"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- }
- },
- "node_modules/type-check": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
- "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1"
- },
- "engines": {
- "node": ">= 0.8.0"
- }
- },
- "node_modules/typescript": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
- "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
- "dev": true,
- "license": "Apache-2.0",
- "peer": true,
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/typescript-eslint": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz",
- "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/eslint-plugin": "8.58.0",
- "@typescript-eslint/parser": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0",
- "@typescript-eslint/utils": "8.58.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/undici-types": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
- "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
- "license": "MIT"
- },
- "node_modules/uri-js": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
- "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "punycode": "^2.1.0"
- }
- },
- "node_modules/uuid": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
- "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "license": "MIT",
- "bin": {
- "uuid": "dist/esm/bin/uuid"
- }
- },
- "node_modules/webidl-conversions": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
- "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
- "dev": true,
- "license": "BSD-2-Clause"
- },
- "node_modules/whatwg-url": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
- "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tr46": "~0.0.3",
- "webidl-conversions": "^3.0.0"
- }
- },
- "node_modules/which": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "node-which": "bin/node-which"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/word-wrap": {
- "version": "1.2.5",
- "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
- "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/wrap-ansi": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/wrap-ansi/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/wrap-ansi/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "license": "MIT"
- },
- "node_modules/wrap-ansi/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/wrap-ansi/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/y18n": {
- "version": "5.0.8",
- "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
- "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
- "license": "ISC",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/yallist": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
- "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/yaml": {
- "version": "2.8.3",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
- "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
- "license": "ISC",
- "bin": {
- "yaml": "bin.mjs"
- },
- "engines": {
- "node": ">= 14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/eemeli"
- }
- },
- "node_modules/yargs": {
- "version": "17.7.2",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
- "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
- "license": "MIT",
- "dependencies": {
- "cliui": "^8.0.1",
- "escalade": "^3.1.1",
- "get-caller-file": "^2.0.5",
- "require-directory": "^2.1.1",
- "string-width": "^4.2.3",
- "y18n": "^5.0.5",
- "yargs-parser": "^21.1.1"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/yargs-parser": {
- "version": "21.1.1",
- "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
- "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
- "license": "ISC",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/yargs/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yargs/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "license": "MIT"
- },
- "node_modules/yargs/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yargs/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yocto-queue": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
- "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/zod": {
- "version": "4.3.6",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
- "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
- },
- "packages/cli": {
- "name": "@finalrun/finalrun-agent",
- "version": "0.1.5",
- "bundleDependencies": [
- "@ai-sdk/anthropic",
- "@ai-sdk/google",
- "@ai-sdk/openai",
- "@finalrun/common",
- "@finalrun/device-node",
- "@finalrun/goal-executor",
- "@grpc/proto-loader",
- "@grpc/grpc-js",
- "ai",
- "chalk",
- "commander",
- "dotenv",
- "ora",
- "protobufjs",
- "uuid",
- "yaml",
- "zod"
- ],
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@ai-sdk/anthropic": "^3.0.58",
- "@ai-sdk/google": "^3.0.43",
- "@ai-sdk/openai": "^3.0.47",
- "@finalrun/common": "*",
- "@finalrun/device-node": "*",
- "@finalrun/goal-executor": "*",
- "@grpc/grpc-js": "^1.12.0",
- "@grpc/proto-loader": "^0.7.0",
- "ai": "^6.0.134",
- "chalk": "^5.4.0",
- "commander": "^13.1.0",
- "dotenv": "^16.4.0",
- "ora": "^8.2.0",
- "protobufjs": "^7.5.4",
- "uuid": "^11.1.0",
- "yaml": "^2.8.2",
- "zod": "^4.1.8"
- },
- "bin": {
- "finalrun": "dist/bin/finalrun.js",
- "finalrun-agent": "dist/bin/finalrun.js"
- },
- "devDependencies": {
- "tsx": "^4.19.0",
- "typescript": "^5.7.0"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "packages/cli/node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "packages/common": {
- "name": "@finalrun/common",
- "version": "0.1.5",
- "license": "Apache-2.0",
- "devDependencies": {
- "typescript": "^5.7.0"
- }
- },
- "packages/common/node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "packages/device-node": {
- "name": "@finalrun/device-node",
- "version": "0.1.5",
- "license": "Apache-2.0",
- "dependencies": {
- "@finalrun/common": "*",
- "@grpc/grpc-js": "^1.12.0",
- "@grpc/proto-loader": "^0.7.0"
- },
- "devDependencies": {
- "grpc-tools": "^1.12.4",
- "ts-proto": "^2.6.0",
- "typescript": "^5.7.0"
- }
- },
- "packages/device-node/node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "packages/goal-executor": {
- "name": "@finalrun/goal-executor",
- "version": "0.1.5",
- "license": "Apache-2.0",
- "dependencies": {
- "@ai-sdk/anthropic": "^3.0.58",
- "@ai-sdk/google": "^3.0.43",
- "@ai-sdk/openai": "^3.0.47",
- "@finalrun/common": "*",
- "ai": "^6.0.134",
- "uuid": "^11.1.0"
- },
- "devDependencies": {
- "typescript": "^5.7.0"
- }
- },
- "packages/goal-executor/node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "packages/report-web": {
- "name": "@finalrun/report-web",
- "version": "0.1.5",
- "dependencies": {
- "@finalrun/common": "*",
- "next": "^16.2.1",
- "react": "^19.2.0",
- "react-dom": "^19.2.0"
- },
- "devDependencies": {
- "@types/react": "^19.2.14",
- "@types/react-dom": "^19.2.3",
- "tsx": "^4.19.0",
- "typescript": "^5.9.0"
- }
- },
- "packages/report-web/node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- }
- }
-}
diff --git a/package.json b/package.json
index 89ba0c2..321bc93 100644
--- a/package.json
+++ b/package.json
@@ -1,18 +1,20 @@
{
"name": "finalrun-agent-monorepo",
- "version": "0.1.6",
+ "version": "0.1.10",
"private": true,
"description": "Monorepo for the finalrun-agent CLI",
"workspaces": [
"packages/common",
+ "packages/cloud-core",
"packages/device-node",
"packages/goal-executor",
+ "packages/report-web",
"packages/cli",
- "packages/report-web"
+ "packages/local-runtime"
],
"scripts": {
"bootstrap": "npm ci",
- "build": "npm run build --workspaces",
+ "build": "npm run build --workspaces --if-present",
"build:drivers": "./scripts/build-drivers-android.sh && ./scripts/build-drivers-ios.sh",
"build:drivers:android": "./scripts/build-drivers-android.sh",
"build:drivers:ios": "./scripts/build-drivers-ios.sh",
@@ -20,6 +22,9 @@
"dev:cli": "npm run dev --workspace=packages/cli --",
"dev:report": "npm run dev --workspace=packages/report-web --",
"dev:watch": "tsc --build --watch --preserveWatchOutput packages/common/tsconfig.json packages/device-node/tsconfig.json packages/goal-executor/tsconfig.json packages/cli/tsconfig.json",
+ "changeset": "changeset",
+ "version-packages": "changeset version",
+ "release": "npm run build && changeset publish",
"lint": "eslint packages/*/src packages/cli/bin packages/cli/scripts eslint.config.mjs",
"format": "prettier --write .",
"format:check": "prettier --check .",
@@ -35,10 +40,12 @@
"pregenerate:proto": "node ./scripts/ensure-dev-install.mjs"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=20.19.0"
},
"devDependencies": {
+ "@changesets/cli": "^2.27.0",
"@eslint/js": "^10.0.1",
+ "@types/node": "^24.12.2",
"eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.4.0",
diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/packages/cli/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 510c074..94c7a80 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -5,9 +5,6 @@
-
-
-
@@ -16,6 +13,8 @@
finalrun.app
•
+ Docs
+ •
Blog
•
Cloud Device Waitlist
@@ -53,7 +52,17 @@
curl -fsSL https://raw.githubusercontent.com/final-run/finalrun-agent/main/scripts/install.sh | bash
```
-Sets up Node.js, the CLI, AI coding agent skills, and platform tools. Run `finalrun doctor` to verify host readiness.
+Downloads a self-contained `finalrun` binary into `~/.finalrun/bin/`, adds it to your PATH, downloads the per-platform runtime tarball, prompts for Android/iOS, installs host tools (`scrcpy`, Xcode CLT, `applesimutils`), and offers to install the AI agent skills. No Node.js required.
+
+For CI / non-interactive environments — install only the binary, skip the runtime tarball and prompts:
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/final-run/finalrun-agent/main/scripts/install.sh | bash -s -- --ci
+```
+
+CI environments (`CI=1` set in env) get this behavior automatically even without the flag.
+
+After install, run `finalrun doctor` to verify host readiness.
## Write and Run Your First Test Using AI Agents
@@ -133,6 +142,8 @@ finalrun suite auth_smoke.yaml --platform android --model google/gemini-3-flash-
## Documentation
+Full docs: **[docs.finalrun.app](https://docs.finalrun.app/)**
+
- [Autotrigger FinalRun tests (AI agents)](docs/autotrigger-finalrun.md) — when coding agents should generate, validate, and run tests after UI work
- [YAML Tests](docs/yaml-tests.md) — test format, fields, suites, and environment placeholders
- [CLI Reference](docs/cli-reference.md) — all commands, flags, and report tools
diff --git a/packages/cli/bin/finalrun.ts b/packages/cli/bin/finalrun.ts
index ffe57ea..e2c3ccd 100644
--- a/packages/cli/bin/finalrun.ts
+++ b/packages/cli/bin/finalrun.ts
@@ -5,22 +5,11 @@ import { Command } from 'commander';
import { Logger, LogLevel, type TestResult } from '@finalrun/common';
import { formatResolvedAppSummary } from '../src/appConfig.js';
import { CliEnv, MODEL_FORMAT_EXAMPLE, parseModel } from '../src/env.js';
-import { resolveApiKey } from '../src/apiKey.js';
+import { resolveApiKeys } from '../src/apiKey.js';
import { runCheck, runMultiDeviceCheck, SUITE_SELECTOR_CONFLICT_ERROR } from '../src/checkRunner.js';
-import { runDoctorCommand } from '../src/doctorRunner.js';
-import {
- buildRunReportUrl,
- buildWorkspaceReportUrl,
- getWorkspaceReportServerStatus,
- openReportUrl,
- resolveHealthyWorkspaceReportServer,
- startOrReuseWorkspaceReportServer,
- stopWorkspaceReportServer,
-} from '../src/reportServerManager.js';
import { normalizeTestSelectors, TEST_SELECTION_REQUIRED_ERROR } from '../src/testSelection.js';
-import { PreExecutionFailureError, runTests, type TestRunnerResult } from '../src/testRunner.js';
+import { runCloud, uploadApp } from '../src/cloudRunner.js';
import { formatRunIndexForConsole, loadRunIndex } from '../src/runIndex.js';
-import { serveReportWorkspace } from '../src/reportServer.js';
import { initializeCliRuntimeEnvironment, resolveCliPackageVersion } from '../src/runtimePaths.js';
import {
isMultiDeviceSelector,
@@ -30,8 +19,10 @@ import {
resolveWorkspaceForCommand,
} from '../src/workspace.js';
import { WorkspaceSelectionCancelledError } from '../src/workspacePicker.js';
-import { prepareMultiDeviceSession } from '../src/multiDeviceSessionRunner.js';
-import { AIAgent, MultiDeviceTestExecutor } from '@finalrun/goal-executor';
+import { LocalRuntimeMissingError, resolveLocalRuntime } from '../src/localRuntime.js';
+import { runUpgrade } from '../src/upgradeCommand.js';
+// Type-only imports — erased at runtime, do not pull the heavy module graph.
+import type { TestRunnerResult } from '../src/testRunner.js';
// ============================================================================
// CLI definition
@@ -110,7 +101,8 @@ program
.action(async (options: DoctorCommandOptions) => {
await runCommand(async () => {
Logger.init({ level: LogLevel.WARN, resetSinks: true });
- const result = await runDoctorCommand({
+ const { doctorRunner } = await resolveLocalRuntime();
+ const result = await doctorRunner.runDoctorCommand({
platform: options.platform,
output: process.stdout,
});
@@ -138,13 +130,23 @@ program
}
console.log(formatRunIndexForConsole(index));
if (index.runs.length > 0) {
- const activeServer = await resolveHealthyWorkspaceReportServer(workspace);
- if (activeServer) {
- console.log(`\nReport server: ${buildWorkspaceReportUrl(activeServer.url)}`);
- } else {
- console.log(
- `\nRun \`finalrun start-server --workspace ${JSON.stringify(workspace.rootDir)}\` to browse reports in the local web UI.`,
- );
+ // The report-server URL hint requires the local runtime; if it's not
+ // installed we silently skip it rather than failing the run listing.
+ try {
+ const { reportServerManager } = await resolveLocalRuntime();
+ const activeServer = await reportServerManager.resolveHealthyWorkspaceReportServer(workspace);
+ if (activeServer) {
+ console.log(`\nReport server: ${reportServerManager.buildWorkspaceReportUrl(activeServer.url)}`);
+ } else {
+ console.log(
+ `\nRun \`finalrun start-server --workspace ${JSON.stringify(workspace.rootDir)}\` to browse reports in the local web UI.`,
+ );
+ }
+ } catch (e) {
+ if (!(e instanceof LocalRuntimeMissingError)) {
+ throw e;
+ }
+ // Local runtime missing — listing runs still works; just skip the URL hint.
}
}
});
@@ -197,6 +199,62 @@ program
});
});
+const cloud = program
+ .command('cloud')
+ .description('Run tests on FinalRun cloud devices');
+
+cloud
+ .command('test [selectors...]')
+ .description('Run repo-local FinalRun YAML tests from .finalrun/tests on cloud devices')
+ .option('--env ', 'Environment name (for example dev or staging)')
+ .option('--platform ', 'Target platform (android or ios)')
+ .option('--app ', 'Path to the .apk or .app to install (omit to use the latest uploaded app)')
+ .action(async (selectors: string[] | undefined, options: CloudCommandOptions) => {
+ await runCommand(async () => {
+ Logger.init({ level: LogLevel.INFO, resetSinks: true });
+ const normalizedSelectors = normalizeTestSelectors(selectors);
+ if (normalizedSelectors.length === 0) {
+ throw new Error(TEST_SELECTION_REQUIRED_ERROR);
+ }
+ await runCloud({
+ selectors: normalizedSelectors,
+ envName: options.env,
+ platform: options.platform,
+ appPath: options.app,
+ });
+ });
+ });
+
+cloud
+ .command('suite ')
+ .description('Run a FinalRun suite manifest from .finalrun/suites on cloud devices')
+ .option('--env ', 'Environment name (for example dev or staging)')
+ .option('--platform ', 'Target platform (android or ios)')
+ .option('--app ', 'Path to the .apk or .app to install (omit to use the latest uploaded app)')
+ .action(async (suitePath: string, options: CloudCommandOptions) => {
+ await runCommand(async () => {
+ Logger.init({ level: LogLevel.INFO, resetSinks: true });
+ await runCloud({
+ selectors: [],
+ suitePath: suitePath.trim(),
+ envName: options.env,
+ platform: options.platform,
+ appPath: options.app,
+ });
+ });
+ });
+
+cloud
+ .command('upload')
+ .description('Upload an app binary to FinalRun cloud for use in subsequent test runs')
+ .requiredOption('--app ', 'Path to the .apk or .app to upload')
+ .action(async (options: { app: string }) => {
+ await runCommand(async () => {
+ Logger.init({ level: LogLevel.INFO, resetSinks: true });
+ await uploadApp(options.app);
+ });
+ });
+
program
.command('start-server')
.description('Start or reuse the local FinalRun report server for a workspace')
@@ -233,6 +291,20 @@ program
});
});
+program
+ .command('upgrade')
+ .description('Upgrade the finalrun CLI by re-running the install script')
+ .option('--version ', 'Pin to a specific version (default: latest GitHub release)')
+ .option('--ci', 'Install only the binary (skip runtime tarball + prompts)')
+ .action(async (options: UpgradeCommandOptions) => {
+ await runCommand(async () => {
+ await runUpgrade({
+ version: options.version,
+ ci: options.ci === true,
+ });
+ });
+ });
+
program
.command('internal-report-server', { hidden: true })
.option('--workspace-root ', 'Workspace root', '')
@@ -241,7 +313,8 @@ program
.option('--mode ', 'Internal report server mode', 'production')
.action(async (options: InternalReportServerOptions) => {
await runCommand(async () => {
- const server = await serveReportWorkspace({
+ const { reportServer } = await resolveLocalRuntime();
+ const server = await reportServer.serveReportWorkspace({
workspaceRoot: options.workspaceRoot,
artifactsDir: options.artifactsDir,
port: parsePortOption(options.port, 4173),
@@ -277,6 +350,12 @@ interface CheckCommandOptions extends CommonCommandOptions {
suite?: string;
}
+interface CloudCommandOptions {
+ env?: string;
+ platform?: string;
+ app?: string;
+}
+
interface DoctorCommandOptions {
platform?: string;
}
@@ -310,6 +389,11 @@ interface InternalReportServerOptions {
mode: string;
}
+interface UpgradeCommandOptions {
+ version?: string;
+ ci?: boolean;
+}
+
async function runTestCommand(params: {
invokedCommand: 'test' | 'suite';
selectors?: string[];
@@ -331,6 +415,17 @@ async function runTestCommand(params: {
const workspace = await resolveWorkspace();
const workspaceConfig = await loadWorkspaceConfig(workspace.finalrunDir);
const model = parseModel(params.options.model ?? workspaceConfig.model);
+ const features = workspaceConfig.features;
+ const reasoning = workspaceConfig.reasoning;
+
+ const requiredProviders = new Set([model.provider]);
+ if (features) {
+ for (const override of Object.values(features)) {
+ if (override?.model) {
+ requiredProviders.add(parseModel(override.model).provider);
+ }
+ }
+ }
const debug = params.options.debug === true;
Logger.init({ level: debug ? LogLevel.DEBUG : LogLevel.INFO, resetSinks: true });
@@ -346,30 +441,35 @@ async function runTestCommand(params: {
: resolvedEnvironment.envName,
{ cwd: workspace.rootDir },
);
- const apiKey = resolveApiKey({
+ const apiKeys = resolveApiKeys({
env: runtimeEnv,
- provider: model.provider,
+ providers: requiredProviders,
providedApiKey: params.options.apiKey,
});
- const reportServer = await tryStartReportServer(workspace);
+ const runtime = await resolveLocalRuntime();
+ const reportServerUrl = await tryStartReportServer(workspace, runtime);
- const result = await runTests({
+ const result = await runtime.testRunner.runTests({
envName: resolvedEnvironment.usesEmptyBindings ? undefined : resolvedEnvironment.envName,
selectors: normalizedSelectors,
suitePath: normalizedSuitePath,
platform: params.options.platform,
appPath: params.options.app,
- apiKey,
- provider: model.provider,
- modelName: model.modelName,
+ apiKeys,
+ defaults: {
+ provider: model.provider,
+ modelName: model.modelName,
+ reasoning,
+ },
+ features,
maxIterations: parseInt(params.options.maxIterations, 10) || 110,
debug,
invokedCommand: params.invokedCommand,
});
- const runUrl = reportServer
- ? buildRunReportUrl(reportServer, result.runId)
+ const runUrl = reportServerUrl
+ ? runtime.reportServerManager.buildRunReportUrl(reportServerUrl, result.runId)
: undefined;
if (result.success) {
@@ -379,17 +479,29 @@ async function runTestCommand(params: {
}
if (runUrl) {
- await openUrlBestEffort(runUrl);
+ await openUrlBestEffort(runUrl, runtime);
}
process.exit(result.status === 'aborted' ? 130 : result.success ? 0 : 1);
} catch (error) {
- if (error instanceof PreExecutionFailureError) {
+ // Need the runtime modules to format pre-execution errors properly. If the
+ // runtime isn't installed, the resolver throws LocalRuntimeMissingError
+ // first; otherwise we can safely import the error type here.
+ if (error instanceof LocalRuntimeMissingError) {
await exitWithRawStderr(error.message, error.exitCode);
- } else {
- const message = error instanceof Error ? error.message : String(error);
- await exitWithRawStderr(message, 1);
+ return;
+ }
+ try {
+ const { testRunner } = await resolveLocalRuntime();
+ if (error instanceof testRunner.PreExecutionFailureError) {
+ await exitWithRawStderr(error.message, error.exitCode);
+ return;
+ }
+ } catch {
+ // Runtime not available — fall through to generic error formatting.
}
+ const message = error instanceof Error ? error.message : String(error);
+ await exitWithRawStderr(message, 1);
}
}
@@ -411,28 +523,48 @@ async function runMultiDeviceTestCommand(
throw new Error('No multi-device tests matched the given selectors.');
}
- // 2. Resolve model and API key
+ // 2. Resolve model and API keys (mirrors the single-device test path)
const workspaceConfig = await loadWorkspaceConfig(checked.workspace.finalrunDir);
const model = parseModel(options.model ?? workspaceConfig.model);
+ const features = workspaceConfig.features;
+ const reasoning = workspaceConfig.reasoning;
+
+ const requiredProviders = new Set([model.provider]);
+ if (features) {
+ for (const override of Object.values(features)) {
+ if (override?.model) {
+ requiredProviders.add(parseModel(override.model).provider);
+ }
+ }
+ }
+
const runtimeEnv = new CliEnv();
runtimeEnv.load(
checked.environment.envName === 'none' ? undefined : checked.environment.envName,
{ cwd: checked.workspace.rootDir },
);
- const apiKey = resolveApiKey({
+ const apiKeys = resolveApiKeys({
env: runtimeEnv,
- provider: model.provider,
+ providers: requiredProviders,
providedApiKey: options.apiKey,
});
- // 3. Set up multi-device session (connect 2 devices, launch apps)
+ // 3. Set up multi-device session (connect 2 devices, launch apps).
+ // Heavy modules are dynamic-imported so cloud-only invocations don't pay the cost.
+ const [{ prepareMultiDeviceSession }, { AIAgent, MultiDeviceTestExecutor }] =
+ await Promise.all([
+ import('../src/multiDeviceSessionRunner.js'),
+ import('@finalrun/goal-executor'),
+ ]);
+
const test = checked.tests[0]!;
console.log(`\n\x1b[1mFinalRun Multi-Device Test\x1b[0m`);
console.log('─'.repeat(60));
console.log(`Test: ${test.name}`);
console.log(`Devices: ${test.devices.map((d) => `${d.role} (${d.app})`).join(', ')}`);
console.log(`Steps: ${test.steps.length}`);
- console.log(`Model: ${model.provider}/${model.modelName}`);
+ const reasoningSuffix = reasoning ? ` (${reasoning})` : '';
+ console.log(`Model: ${model.provider}/${model.modelName}${reasoningSuffix}`);
console.log('─'.repeat(60) + '\n');
const session = await prepareMultiDeviceSession({
@@ -444,9 +576,13 @@ async function runMultiDeviceTestCommand(
try {
// 4. Run executor (lockstep per-step, reuses existing single-device executor)
const aiAgent = new AIAgent({
- provider: model.provider,
- modelName: model.modelName,
- apiKey,
+ apiKeys,
+ defaults: {
+ provider: model.provider,
+ modelName: model.modelName,
+ reasoning,
+ },
+ features,
});
const deviceApps = new Map(test.devices.map((d) => [d.role, d.app]));
@@ -506,6 +642,12 @@ async function runCommand(run: () => Promise): Promise {
if (error instanceof WorkspaceSelectionCancelledError) {
process.exit(error.exitCode);
}
+ if (error instanceof LocalRuntimeMissingError) {
+ // Already-formatted user-facing message; render verbatim without the
+ // "Error:" prefix that the generic branch adds.
+ process.stderr.write(`${error.message}\n`);
+ process.exit(error.exitCode);
+ }
const msg = error instanceof Error ? error.message : String(error);
console.error(`\n\x1b[31m✖ Error:\x1b[0m ${msg}\n`);
process.exit(1);
@@ -517,21 +659,23 @@ async function startWorkspaceReportServer(params: {
preferredPort: number;
dev: boolean;
}): Promise {
+ const runtime = await resolveLocalRuntime();
const workspace = await resolveCommandWorkspace(params.workspacePath);
await loadRunIndex(workspace.artifactsDir);
- const server = await startOrReuseWorkspaceReportServer({
+ const server = await runtime.reportServerManager.startOrReuseWorkspaceReportServer({
workspace,
requestedPort: params.preferredPort,
dev: params.dev,
});
- const workspaceUrl = buildWorkspaceReportUrl(server.url);
+ const workspaceUrl = runtime.reportServerManager.buildWorkspaceReportUrl(server.url);
console.log(`${server.reused ? 'Reusing' : 'Started'} FinalRun report server at ${workspaceUrl}`);
- await openUrlBestEffort(workspaceUrl);
+ await openUrlBestEffort(workspaceUrl, runtime);
}
async function stopWorkspaceReportServerCommand(workspacePath?: string): Promise {
+ const runtime = await resolveLocalRuntime();
const workspace = await resolveCommandWorkspace(workspacePath);
- const result = await stopWorkspaceReportServer(workspace);
+ const result = await runtime.reportServerManager.stopWorkspaceReportServer(workspace);
if (!result.stopped) {
console.log(`FinalRun report server is not running for ${workspace.rootDir}`);
return;
@@ -541,8 +685,9 @@ async function stopWorkspaceReportServerCommand(workspacePath?: string): Promise
}
async function printWorkspaceReportServerStatus(workspacePath?: string): Promise {
+ const runtime = await resolveLocalRuntime();
const workspace = await resolveCommandWorkspace(workspacePath);
- const status = await getWorkspaceReportServerStatus(workspace);
+ const status = await runtime.reportServerManager.getWorkspaceReportServerStatus(workspace);
if (!status.running || !status.state) {
console.log(`FinalRun report server is not running for ${workspace.rootDir}`);
return;
@@ -589,14 +734,15 @@ function parsePortOption(value: string, fallback: number): number {
async function tryStartReportServer(
workspace: Awaited>,
+ runtime: Awaited>,
): Promise {
try {
- const server = await startOrReuseWorkspaceReportServer({
+ const server = await runtime.reportServerManager.startOrReuseWorkspaceReportServer({
workspace,
requestedPort: 4173,
dev: false,
});
- console.log(`Report server: ${buildWorkspaceReportUrl(server.url)}`);
+ console.log(`Report server: ${runtime.reportServerManager.buildWorkspaceReportUrl(server.url)}`);
return server.url;
} catch {
return undefined;
@@ -661,9 +807,13 @@ function printTestArtifactPaths(test: TestResult, runDir: string): void {
}
}
-async function openUrlBestEffort(url: string): Promise {
+async function openUrlBestEffort(
+ url: string,
+ runtime?: Awaited>,
+): Promise {
try {
- await openReportUrl(url);
+ const resolved = runtime ?? await resolveLocalRuntime();
+ await resolved.reportServerManager.openReportUrl(url);
} catch {
// Silently ignore — the URL is already printed to the terminal.
}
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 20205f0..30f7471 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,8 +1,8 @@
{
"name": "@finalrun/finalrun-agent",
- "version": "0.1.6",
- "private": false,
- "description": "AI-driven mobile app testing CLI for YAML-defined tests on Android and iOS",
+ "version": "0.1.10",
+ "private": true,
+ "description": "AI-driven mobile app testing CLI for YAML-defined tests on Android and iOS. Distributed as a Bun-compiled binary via curl|sh installer; not published to npm.",
"license": "Apache-2.0",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
@@ -29,52 +29,18 @@
"cli",
"ai"
],
- "files": [
- "dist",
- "proto",
- "install-resources",
- "scripts/installAssets.mjs",
- "!dist/**/*.test.js",
- "!dist/**/*.test.js.map",
- "!dist/**/*.test.d.ts",
- "!dist/**/*.test.d.ts.map"
- ],
- "bundleDependencies": [
- "@ai-sdk/anthropic",
- "@ai-sdk/google",
- "@ai-sdk/openai",
- "@finalrun/common",
- "@finalrun/device-node",
- "@finalrun/goal-executor",
- "@grpc/proto-loader",
- "@grpc/grpc-js",
- "ai",
- "chalk",
- "commander",
- "dotenv",
- "ora",
- "protobufjs",
- "uuid",
- "yaml",
- "zod"
- ],
"scripts": {
- "build": "npm run clean && tsc",
- "clean": "rm -rf dist proto install-resources tsconfig.tsbuildinfo",
+ "build": "npm run clean && tsc && node ./scripts/copyReportApp.mjs && node -e \"require('fs').copyFileSync('package.json','dist/package.json')\"",
+ "clean": "rm -rf dist proto tsconfig.tsbuildinfo",
"start": "tsx bin/finalrun.ts",
"dev": "tsx --tsconfig ../../tsconfig.dev.json bin/finalrun.ts",
- "test": "node --test \"dist/**/*.test.js\"",
- "postinstall": "node ./scripts/installAssets.mjs",
- "prepack": "cd ../.. && npm run build --workspace=@finalrun/common && npm run build --workspace=@finalrun/device-node && npm run build --workspace=@finalrun/goal-executor && npm run build --workspace=@finalrun/finalrun-agent && node packages/cli/scripts/preparePackage.mjs",
- "postpack": "node ./scripts/cleanupPackage.mjs"
- },
- "publishConfig": {
- "access": "public"
+ "test": "node ./scripts/runTests.mjs"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/google": "^3.0.43",
"@ai-sdk/openai": "^3.0.47",
+ "@finalrun/cloud-core": "*",
"@finalrun/common": "*",
"@finalrun/device-node": "*",
"@finalrun/goal-executor": "*",
@@ -91,10 +57,10 @@
"zod": "^4.1.8"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=20.19.0"
},
"devDependencies": {
"tsx": "^4.19.0",
- "typescript": "^5.7.0"
+ "typescript": "^6.0.3"
}
}
diff --git a/packages/cli/scripts/cleanupPackage.mjs b/packages/cli/scripts/cleanupPackage.mjs
deleted file mode 100644
index a810c4f..0000000
--- a/packages/cli/scripts/cleanupPackage.mjs
+++ /dev/null
@@ -1,17 +0,0 @@
-import { readFileSync, rmSync } from 'node:fs';
-import { dirname, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const currentDir = dirname(fileURLToPath(import.meta.url));
-const cliDir = resolve(currentDir, '..');
-const cliPackageJson = JSON.parse(readFileSync(resolve(cliDir, 'package.json'), 'utf8'));
-const directExternalDependencies = Object.keys(cliPackageJson.dependencies ?? {})
- .filter((packageName) => !packageName.startsWith('@finalrun/'));
-
-rmSync(resolve(cliDir, 'proto'), { recursive: true, force: true });
-rmSync(resolve(cliDir, 'node_modules/@finalrun'), { recursive: true, force: true });
-for (const packageName of directExternalDependencies) {
- rmSync(resolve(cliDir, 'node_modules', packageName), { recursive: true, force: true });
-}
-rmSync(resolve(cliDir, 'install-resources'), { recursive: true, force: true });
-rmSync(resolve(cliDir, 'LICENSE'), { force: true });
diff --git a/packages/cli/scripts/copyReportApp.mjs b/packages/cli/scripts/copyReportApp.mjs
new file mode 100644
index 0000000..8bcc22b
--- /dev/null
+++ b/packages/cli/scripts/copyReportApp.mjs
@@ -0,0 +1,33 @@
+// Copies the Vite-built report SPA from packages/report-web/dist/app/** into
+// packages/cli/dist/report-app/. reportServer.ts resolves SPA_DIR relative
+// to dist/src/ as '../report-app', so these two paths must stay aligned.
+//
+// If report-web's dist/app is missing we bail with a clear error — the caller
+// is expected to run `npm run build --workspace=@finalrun/report-web` first.
+
+import { cp, mkdir, rm, stat } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const cliRoot = path.resolve(here, '..');
+const source = path.resolve(cliRoot, '..', 'report-web', 'dist', 'app');
+const destination = path.resolve(cliRoot, 'dist', 'report-app');
+
+try {
+ const stats = await stat(source);
+ if (!stats.isDirectory()) {
+ throw new Error(`${source} is not a directory`);
+ }
+} catch (error) {
+ console.error(
+ `[cli:copyReportApp] Source not found: ${source}\n` +
+ `Build the report-web SPA first: npm run build:app --workspace=@finalrun/report-web`,
+ );
+ throw error;
+}
+
+await rm(destination, { recursive: true, force: true });
+await mkdir(destination, { recursive: true });
+await cp(source, destination, { recursive: true });
+console.log(`[cli:copyReportApp] Copied ${path.relative(cliRoot, source)} -> ${path.relative(cliRoot, destination)}`);
diff --git a/packages/cli/scripts/installAssets.mjs b/packages/cli/scripts/installAssets.mjs
deleted file mode 100644
index 31268c8..0000000
--- a/packages/cli/scripts/installAssets.mjs
+++ /dev/null
@@ -1,145 +0,0 @@
-import {
- cpSync,
- existsSync,
- mkdirSync,
- readFileSync,
-} from 'node:fs';
-import os from 'node:os';
-import { spawnSync } from 'node:child_process';
-import { dirname, join, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-function readPackageVersion(packageRoot) {
- const packageJsonPath = resolve(packageRoot, 'package.json');
- const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
- return packageJson.version ?? '0.0.0';
-}
-
-export function resolveInstallResourceRoot(packageRoot) {
- return resolve(packageRoot, 'install-resources');
-}
-
-export function resolveUserAssetRoot(packageRoot, env = process.env) {
- const version = readPackageVersion(packageRoot);
- const overrideRoot = env['FINALRUN_CACHE_DIR'];
- if (overrideRoot && overrideRoot.trim()) {
- return resolve(overrideRoot, version);
- }
- return join(os.homedir(), '.finalrun', 'assets', version);
-}
-
-function copyFileIfPresent(sourcePath, targetPath) {
- if (!existsSync(sourcePath)) {
- return false;
- }
-
- mkdirSync(dirname(targetPath), { recursive: true });
- cpSync(sourcePath, targetPath);
- return true;
-}
-
-function extractIOSArchive(zipPath, targetDir) {
- const unzip = spawnSync('unzip', ['-o', zipPath, '-d', targetDir], {
- stdio: 'pipe',
- encoding: 'utf-8',
- });
-
- if (unzip.status !== 0) {
- const stderr = unzip.stderr?.trim();
- return {
- success: false,
- message: stderr || `unzip exited with status ${unzip.status ?? 'unknown'}`,
- };
- }
-
- return { success: true };
-}
-
-export function installBundledAssets(options = {}) {
- const packageRoot = options.packageRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), '..');
- const log = options.log ?? console;
- const platform = options.platform ?? process.platform;
- const extractIOSArchiveFn = options.extractIOSArchive ?? extractIOSArchive;
- const resourceRoot = resolveInstallResourceRoot(packageRoot);
- if (!existsSync(resourceRoot)) {
- return {
- installed: false,
- targetRoot: resolveUserAssetRoot(packageRoot, options.env),
- reason: 'missing-install-resources',
- };
- }
-
- const targetRoot = resolveUserAssetRoot(packageRoot, options.env);
- const copied = [];
-
- const androidAssets = [
- 'android/app-debug.apk',
- 'android/app-debug-androidTest.apk',
- ];
- for (const relativeAssetPath of androidAssets) {
- const copiedAsset = copyFileIfPresent(
- resolve(resourceRoot, relativeAssetPath),
- resolve(targetRoot, relativeAssetPath),
- );
- if (copiedAsset) {
- copied.push(relativeAssetPath);
- }
- }
-
- const iosAssets = [
- 'ios/finalrun-ios.zip',
- 'ios/finalrun-ios-test-Runner.zip',
- ];
- const copiedIOSZipPaths = [];
- for (const relativeAssetPath of iosAssets) {
- const sourcePath = resolve(resourceRoot, relativeAssetPath);
- const targetPath = resolve(targetRoot, relativeAssetPath);
- const copiedAsset = copyFileIfPresent(sourcePath, targetPath);
- if (copiedAsset) {
- copied.push(relativeAssetPath);
- copiedIOSZipPaths.push(targetPath);
- }
- }
-
- let extractedIOS = false;
- if (copiedIOSZipPaths.length > 0 && platform === 'darwin') {
- const targetDir = resolve(targetRoot, 'ios', 'Debug-iphonesimulator');
- mkdirSync(targetDir, { recursive: true });
-
- for (const zipPath of copiedIOSZipPaths) {
- const extracted = extractIOSArchiveFn(zipPath, targetDir);
- if (!extracted.success) {
- log.warn(
- `[finalrun] Failed to extract bundled iOS archive ${zipPath}: ${extracted.message}`,
- );
- return {
- installed: copied.length > 0,
- targetRoot,
- copied,
- extractedIOS,
- reason: 'ios-extract-failed',
- };
- }
- }
-
- extractedIOS = true;
- }
-
- if (copied.length > 0) {
- log.log(`[finalrun] Installed native driver assets to ${targetRoot}`);
- }
-
- return {
- installed: copied.length > 0,
- targetRoot,
- copied,
- extractedIOS,
- };
-}
-
-const currentFilePath = fileURLToPath(import.meta.url);
-const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null;
-
-if (invokedPath && currentFilePath === invokedPath) {
- installBundledAssets();
-}
diff --git a/packages/cli/scripts/installAssets.test.mjs b/packages/cli/scripts/installAssets.test.mjs
deleted file mode 100644
index 39be68e..0000000
--- a/packages/cli/scripts/installAssets.test.mjs
+++ /dev/null
@@ -1,107 +0,0 @@
-import assert from 'node:assert/strict';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import test from 'node:test';
-import { installBundledAssets, resolveUserAssetRoot } from './installAssets.mjs';
-
-function createPackageRoot(version = '9.9.9') {
- const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'finalrun-install-assets-'));
- fs.writeFileSync(
- path.join(packageRoot, 'package.json'),
- JSON.stringify({ name: '@finalrun/finalrun-agent', version }, null, 2),
- 'utf-8',
- );
- fs.mkdirSync(path.join(packageRoot, 'install-resources', 'android'), { recursive: true });
- fs.mkdirSync(path.join(packageRoot, 'install-resources', 'ios'), { recursive: true });
- fs.writeFileSync(
- path.join(packageRoot, 'install-resources', 'android', 'app-debug.apk'),
- 'apk',
- );
- fs.writeFileSync(
- path.join(packageRoot, 'install-resources', 'android', 'app-debug-androidTest.apk'),
- 'apk-test',
- );
- fs.writeFileSync(
- path.join(packageRoot, 'install-resources', 'ios', 'finalrun-ios.zip'),
- 'ios-app-zip',
- );
- fs.writeFileSync(
- path.join(packageRoot, 'install-resources', 'ios', 'finalrun-ios-test-Runner.zip'),
- 'ios-runner-zip',
- );
- return packageRoot;
-}
-
-test('installBundledAssets copies Android assets and extracts iOS apps into the user asset root', () => {
- const packageRoot = createPackageRoot('1.2.3');
- const cacheBase = fs.mkdtempSync(path.join(os.tmpdir(), 'finalrun-install-cache-'));
- const extracted = [];
-
- try {
- const result = installBundledAssets({
- packageRoot,
- env: { ...process.env, FINALRUN_CACHE_DIR: cacheBase },
- platform: 'darwin',
- log: {
- log() {},
- warn() {},
- },
- extractIOSArchive(zipPath, targetDir) {
- extracted.push(path.basename(zipPath));
- if (zipPath.endsWith('finalrun-ios.zip')) {
- fs.mkdirSync(path.join(targetDir, 'finalrun-ios.app'), { recursive: true });
- }
- if (zipPath.endsWith('finalrun-ios-test-Runner.zip')) {
- fs.mkdirSync(path.join(targetDir, 'finalrun-ios-test-Runner.app'), {
- recursive: true,
- });
- }
- return { success: true };
- },
- });
-
- const targetRoot = resolveUserAssetRoot(packageRoot, { FINALRUN_CACHE_DIR: cacheBase });
- assert.equal(result.installed, true);
- assert.equal(result.targetRoot, targetRoot);
- assert.equal(
- fs.readFileSync(path.join(targetRoot, 'android', 'app-debug.apk'), 'utf-8'),
- 'apk',
- );
- assert.deepEqual(extracted, ['finalrun-ios.zip', 'finalrun-ios-test-Runner.zip']);
- assert.equal(
- fs.existsSync(
- path.join(targetRoot, 'ios', 'Debug-iphonesimulator', 'finalrun-ios-test-Runner.app'),
- ),
- true,
- );
- } finally {
- fs.rmSync(packageRoot, { recursive: true, force: true });
- fs.rmSync(cacheBase, { recursive: true, force: true });
- }
-});
-
-test('installBundledAssets no-ops when the packaged install resources are absent', () => {
- const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'finalrun-install-assets-empty-'));
-
- try {
- fs.writeFileSync(
- path.join(packageRoot, 'package.json'),
- JSON.stringify({ name: '@finalrun/finalrun-agent', version: '0.1.1' }, null, 2),
- 'utf-8',
- );
-
- const result = installBundledAssets({
- packageRoot,
- log: {
- log() {},
- warn() {},
- },
- });
-
- assert.equal(result.installed, false);
- assert.equal(result.reason, 'missing-install-resources');
- } finally {
- fs.rmSync(packageRoot, { recursive: true, force: true });
- }
-});
diff --git a/packages/cli/scripts/preparePackage.mjs b/packages/cli/scripts/preparePackage.mjs
deleted file mode 100644
index 4fdeb58..0000000
--- a/packages/cli/scripts/preparePackage.mjs
+++ /dev/null
@@ -1,150 +0,0 @@
-import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
-import { basename, dirname, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const currentDir = dirname(fileURLToPath(import.meta.url));
-const repoRoot = resolve(currentDir, '../../..');
-const cliDir = resolve(repoRoot, 'packages/cli');
-const vendorRoot = resolve(cliDir, 'node_modules/@finalrun');
-const cliPackageJsonPath = resolve(cliDir, 'package.json');
-const cliPackageJson = JSON.parse(readFileSync(cliPackageJsonPath, 'utf8'));
-
-const bundledPackages = [
- {
- name: '@finalrun/common',
- sourceDir: resolve(repoRoot, 'packages/common'),
- copyEntries: ['dist', 'package.json'],
- },
- {
- name: '@finalrun/device-node',
- sourceDir: resolve(repoRoot, 'packages/device-node'),
- copyEntries: ['dist', 'package.json'],
- },
- {
- name: '@finalrun/goal-executor',
- sourceDir: resolve(repoRoot, 'packages/goal-executor'),
- copyEntries: ['dist', 'package.json', 'src/prompts'],
- },
-];
-const bundledExternalPackages = Object.keys(cliPackageJson.dependencies ?? {})
- .filter((packageName) => !packageName.startsWith('@finalrun/'));
-
-function resolveInstalledPackagePath(packageName) {
- return resolve(repoRoot, 'node_modules', packageName);
-}
-
-function readInstalledPackageJson(packageName) {
- const packageRoot = resolveInstalledPackagePath(packageName);
- const packageJsonPath = resolve(packageRoot, 'package.json');
- if (!existsSync(packageJsonPath)) {
- throw new Error(`Missing installed package.json for ${packageName}: ${packageJsonPath}`);
- }
- return JSON.parse(readFileSync(packageJsonPath, 'utf8'));
-}
-
-function collectDependencyNames(packageJson, ancestry) {
- const dependencyNames = new Set([
- ...Object.keys(packageJson.dependencies ?? {}),
- ...Object.keys(packageJson.optionalDependencies ?? {}),
- ]);
-
- for (const peerDependencyName of Object.keys(packageJson.peerDependencies ?? {})) {
- if (ancestry.has(peerDependencyName)) {
- continue;
- }
- if (existsSync(resolveInstalledPackagePath(peerDependencyName))) {
- dependencyNames.add(peerDependencyName);
- }
- }
-
- return [...dependencyNames].filter((packageName) => !packageName.startsWith('@finalrun/'));
-}
-
-function vendorExternalPackage(packageName, targetNodeModulesDir, ancestry = new Set()) {
- if (ancestry.has(packageName)) {
- return;
- }
-
- const sourcePath = resolveInstalledPackagePath(packageName);
- const targetPath = resolve(targetNodeModulesDir, packageName);
- if (!existsSync(sourcePath)) {
- throw new Error(`Missing bundled external package: ${sourcePath}`);
- }
-
- rmSync(targetPath, { recursive: true, force: true });
- mkdirSync(dirname(targetPath), { recursive: true });
- cpSync(sourcePath, targetPath, { recursive: true });
-
- const nextAncestry = new Set(ancestry);
- nextAncestry.add(packageName);
-
- const childNodeModulesDir = resolve(targetPath, 'node_modules');
- rmSync(childNodeModulesDir, { recursive: true, force: true });
-
- const packageJson = readInstalledPackageJson(packageName);
- for (const dependencyName of collectDependencyNames(packageJson, nextAncestry)) {
- vendorExternalPackage(dependencyName, childNodeModulesDir, nextAncestry);
- }
-}
-
-rmSync(vendorRoot, { recursive: true, force: true });
-
-for (const bundledPackage of bundledPackages) {
- const targetDir = resolve(cliDir, 'node_modules', bundledPackage.name);
- mkdirSync(targetDir, { recursive: true });
-
- for (const entry of bundledPackage.copyEntries) {
- const sourcePath = resolve(bundledPackage.sourceDir, entry);
- const targetPath = resolve(targetDir, entry);
-
- if (!existsSync(sourcePath)) {
- throw new Error(`Missing bundled package entry: ${sourcePath}`);
- }
-
- if (entry === 'package.json') {
- const packageJson = JSON.parse(readFileSync(sourcePath, 'utf8'));
- delete packageJson.devDependencies;
- delete packageJson.scripts;
- delete packageJson.private;
- writeFileSync(targetPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8');
- continue;
- }
-
- cpSync(sourcePath, targetPath, {
- recursive: true,
- filter: (source) => !basename(source).includes('.test.'),
- });
- }
-}
-
-for (const packageName of bundledExternalPackages) {
- vendorExternalPackage(packageName, resolve(cliDir, 'node_modules'));
-}
-
-const protoSourcePath = resolve(repoRoot, 'proto/finalrun/driver.proto');
-const protoTargetPath = resolve(cliDir, 'proto/finalrun/driver.proto');
-mkdirSync(dirname(protoTargetPath), { recursive: true });
-cpSync(protoSourcePath, protoTargetPath);
-
-const installAssets = [
- 'android/app-debug.apk',
- 'android/app-debug-androidTest.apk',
- 'ios/finalrun-ios.zip',
- 'ios/finalrun-ios-test-Runner.zip',
-];
-const installResourcesRoot = resolve(cliDir, 'install-resources');
-rmSync(installResourcesRoot, { recursive: true, force: true });
-
-for (const relativeAssetPath of installAssets) {
- const sourcePath = resolve(repoRoot, 'resources', relativeAssetPath);
- const targetPath = resolve(installResourcesRoot, relativeAssetPath);
-
- if (!existsSync(sourcePath)) {
- throw new Error(`Missing install asset: ${sourcePath}`);
- }
-
- mkdirSync(dirname(targetPath), { recursive: true });
- cpSync(sourcePath, targetPath);
-}
-
-cpSync(resolve(repoRoot, 'LICENSE'), resolve(cliDir, 'LICENSE'));
diff --git a/packages/cli/scripts/runTests.mjs b/packages/cli/scripts/runTests.mjs
new file mode 100644
index 0000000..09f0850
--- /dev/null
+++ b/packages/cli/scripts/runTests.mjs
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+// Run node --test against every dist/**/*.test.js, portably across Node 20.x.
+//
+// We can't rely on `node --test "dist/**/*.test.js"` because native glob
+// expansion in `node --test` arrived in Node 21 and we declare
+// engines.node >= 20.19.
+
+import { spawnSync } from 'node:child_process';
+import { readdirSync, statSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = fileURLToPath(new URL('.', import.meta.url));
+const distDir = resolve(here, '..', 'dist');
+
+function findTestFiles(dir) {
+ const out = [];
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ const s = statSync(full);
+ if (s.isDirectory()) {
+ out.push(...findTestFiles(full));
+ } else if (entry.endsWith('.test.js')) {
+ out.push(full);
+ }
+ }
+ return out;
+}
+
+let testFiles;
+try {
+ testFiles = findTestFiles(distDir);
+} catch (e) {
+ if (e.code === 'ENOENT') {
+ console.error(`[runTests] dist/ not found at ${distDir} — did you forget \`npm run build\`?`);
+ process.exit(1);
+ }
+ throw e;
+}
+
+if (testFiles.length === 0) {
+ console.error('[runTests] No *.test.js files found under dist/.');
+ process.exit(1);
+}
+
+const result = spawnSync(process.execPath, ['--test', ...testFiles], {
+ stdio: 'inherit',
+ cwd: resolve(here, '..'),
+});
+
+// Surface spawn errors instead of dropping them as a bare exit 1.
+if (result.error) {
+ console.error(`[runTests] Failed to spawn ${process.execPath}: ${result.error.message}`);
+ process.exit(1);
+}
+
+// If node --test was killed by a signal (e.g. SIGINT, SIGKILL, OOM),
+// status is null and signal carries the name. Propagate the conventional
+// 128 + signo exit code so CI logs surface the real cause.
+if (result.signal) {
+ // The constants module exposes named signals; fall back to "1" if missing.
+ const signo = (await import('node:os')).constants.signals[result.signal] ?? 1;
+ process.exit(128 + signo);
+}
+
+process.exit(result.status ?? 1);
diff --git a/packages/cli/src/apiKey.test.ts b/packages/cli/src/apiKey.test.ts
index 3bcadc6..6d00a5d 100644
--- a/packages/cli/src/apiKey.test.ts
+++ b/packages/cli/src/apiKey.test.ts
@@ -5,7 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { CliEnv } from './env.js';
-import { resolveApiKey } from './apiKey.js';
+import { resolveApiKey, resolveApiKeys } from './apiKey.js';
function createEnv(values: Record) {
return {
@@ -114,3 +114,64 @@ test('resolveApiKey reports the provider-matched env var in its error message',
/Provide via --api-key or GOOGLE_API_KEY/,
);
});
+
+test('resolveApiKeys returns a per-provider map from env vars', () => {
+ const env = createEnv({
+ OPENAI_API_KEY: 'openai-key',
+ GOOGLE_API_KEY: 'google-key',
+ ANTHROPIC_API_KEY: 'anthropic-key',
+ });
+
+ const keys = resolveApiKeys({
+ env,
+ providers: new Set(['openai', 'google']),
+ });
+
+ assert.deepEqual(keys, {
+ openai: 'openai-key',
+ google: 'google-key',
+ });
+});
+
+test('resolveApiKeys routes --api-key to the single active provider', () => {
+ const keys = resolveApiKeys({
+ env: createEnv({}),
+ providers: ['openai'],
+ providedApiKey: 'flag-key',
+ });
+
+ assert.deepEqual(keys, { openai: 'flag-key' });
+});
+
+test('resolveApiKeys treats empty --api-key as unset and falls through to env vars', () => {
+ const keys = resolveApiKeys({
+ env: createEnv({ OPENAI_API_KEY: 'env-key' }),
+ providers: ['openai'],
+ providedApiKey: '',
+ });
+
+ assert.deepEqual(keys, { openai: 'env-key' });
+});
+
+test('resolveApiKeys rejects --api-key when multiple providers are configured', () => {
+ assert.throws(
+ () =>
+ resolveApiKeys({
+ env: createEnv({}),
+ providers: ['openai', 'anthropic'],
+ providedApiKey: 'flag-key',
+ }),
+ /--api-key is only valid when a single provider is active/,
+ );
+});
+
+test('resolveApiKeys aggregates missing provider errors into one message', () => {
+ assert.throws(
+ () =>
+ resolveApiKeys({
+ env: createEnv({ OPENAI_API_KEY: 'openai-key' }),
+ providers: ['openai', 'google', 'anthropic'],
+ }),
+ /google \(GOOGLE_API_KEY\), anthropic \(ANTHROPIC_API_KEY\)/,
+ );
+});
diff --git a/packages/cli/src/apiKey.ts b/packages/cli/src/apiKey.ts
index 16b2749..0ea93df 100644
--- a/packages/cli/src/apiKey.ts
+++ b/packages/cli/src/apiKey.ts
@@ -20,6 +20,57 @@ export function resolveApiKey(params: {
return apiKey;
}
+/**
+ * Resolve API keys for every provider referenced by the current run.
+ *
+ * --api-key is accepted only when a single provider is in play; mixing
+ * providers across features requires env vars per provider (documented in
+ * docs/environment.md) so we can't silently pair one key with multiple
+ * providers.
+ */
+export function resolveApiKeys(params: {
+ env: Pick;
+ providers: Iterable;
+ providedApiKey?: string;
+}): Record {
+ const providers = Array.from(new Set(params.providers));
+ if (providers.length === 0) {
+ throw new Error('At least one provider must be specified when resolving API keys.');
+ }
+
+ // Match `resolveApiKey` semantics: an empty/whitespace --api-key value
+ // falls through to env-var lookup rather than being treated as "this is
+ // the key." Keeps the two resolvers consistent.
+ if (params.providedApiKey) {
+ if (providers.length > 1) {
+ throw new Error(
+ `--api-key is only valid when a single provider is active. This run uses multiple providers (${providers.join(', ')}). Provide the per-provider env vars instead: ${providers
+ .map((p) => PROVIDER_ENV_VARS[p as keyof typeof PROVIDER_ENV_VARS] ?? `<${p}>`)
+ .join(', ')}.`,
+ );
+ }
+ return { [providers[0]!]: params.providedApiKey };
+ }
+
+ const resolved: Record = {};
+ const missing: Array<{ provider: string; envVar?: string }> = [];
+ for (const provider of providers) {
+ const providerEnvVar = PROVIDER_ENV_VARS[provider as keyof typeof PROVIDER_ENV_VARS];
+ const apiKey = providerEnvVar ? params.env.get(providerEnvVar) : undefined;
+ if (!apiKey) {
+ missing.push({ provider, envVar: providerEnvVar });
+ continue;
+ }
+ resolved[provider] = apiKey;
+ }
+
+ if (missing.length > 0) {
+ throw new Error(buildMissingApiKeysError(missing));
+ }
+
+ return resolved;
+}
+
function buildMissingApiKeyError(
provider: string,
providerEnvVar?: string,
@@ -30,3 +81,16 @@ function buildMissingApiKeyError(
return `API key is required for provider "${provider}". Provide via --api-key.`;
}
+
+function buildMissingApiKeysError(
+ missing: Array<{ provider: string; envVar?: string }>,
+): string {
+ if (missing.length === 1) {
+ const entry = missing[0]!;
+ return buildMissingApiKeyError(entry.provider, entry.envVar);
+ }
+ const detail = missing
+ .map(({ provider, envVar }) => (envVar ? `${provider} (${envVar})` : provider))
+ .join(', ');
+ return `API keys are required for multiple providers. Set the following env vars: ${detail}.`;
+}
diff --git a/packages/cli/src/cloudRunner.ts b/packages/cli/src/cloudRunner.ts
new file mode 100644
index 0000000..3f93194
--- /dev/null
+++ b/packages/cli/src/cloudRunner.ts
@@ -0,0 +1,116 @@
+// Thin CLI orchestrator: runs the local check pipeline, then delegates to
+// @finalrun/cloud-core for the actual zip + HTTP submission. The pure submit
+// and upload logic lives in cloud-core so the slim cloud-only binary can use
+// it without pulling the local-runtime dependency graph.
+
+import { runCheck } from './checkRunner.js';
+import {
+ submitRun,
+ uploadApp as uploadAppCore,
+ type SubmitRunResult,
+ type UploadAppResult,
+} from '@finalrun/cloud-core';
+
+const DEFAULT_CLOUD_URL = 'https://cloud.finalrun.app';
+
+function resolveCloudUrl(): string {
+ const override = process.env['FINALRUN_CLOUD_URL'];
+ return override && override.trim() ? override.trim() : DEFAULT_CLOUD_URL;
+}
+
+function requireApiKey(): string {
+ const key = process.env['FINALRUN_API_KEY'] ?? '';
+ if (!key) {
+ throw new Error(
+ 'FINALRUN_API_KEY is not set. Get your API key from the FinalRun Cloud dashboard and set it:\n' +
+ ' export FINALRUN_API_KEY=fr_your_key_here',
+ );
+ }
+ return key;
+}
+
+export interface CloudRunnerOptions {
+ selectors: string[];
+ suitePath?: string;
+ envName?: string;
+ platform?: string;
+ appPath?: string;
+}
+
+export async function runCloud(options: CloudRunnerOptions): Promise {
+ const apiKey = requireApiKey();
+ const cloudUrl = resolveCloudUrl();
+
+ // 1. Validate specs locally (fast fail before upload). runCheck resolves
+ // the effective env from --env if passed, else from .finalrun/config.yaml's
+ // `env:` field — same logic the server-side runCheck will run when it
+ // unpacks the zip.
+ const checked = await runCheck({
+ selectors: options.selectors,
+ suitePath: options.suitePath,
+ envName: options.envName,
+ platform: options.platform,
+ requireSelection: true,
+ });
+
+ // 2. Capture the CLI invocation for the run record. shell-quote each user
+ // arg so something like `--name "My Test"` round-trips correctly when an
+ // operator copy-pastes the recorded command. process.argv =
+ // [node, finalrun(.ts), ...userArgs].
+ const command = ['finalrun', ...process.argv.slice(2).map(shellQuote)].join(' ');
+
+ // 3. Pass the *resolved* env name (not the raw --env flag) to submit so the
+ // zip includes the right env file. If config.yaml declares `env: dev`,
+ // the resolution above already promoted that to checked.environment.envName
+ // even when --env wasn't passed; the previous behavior of forwarding the
+ // raw flag value left config-default users with no env file in the zip
+ // and a 500 from the server.
+ const effectiveEnvName = checked.environment.envPath
+ ? checked.environment.envName
+ : undefined;
+
+ // 4. Delegate to cloud-core for zip + submit
+ return submitRun({
+ checked: {
+ tests: checked.tests.map((spec) => ({
+ sourcePath: spec.sourcePath,
+ relativePath: spec.relativePath,
+ name: spec.name,
+ })),
+ suite: checked.suite
+ ? {
+ sourcePath: checked.suite.sourcePath,
+ relativePath: checked.suite.relativePath,
+ name: checked.suite.name,
+ }
+ : undefined,
+ },
+ workspaceRoot: checked.workspace.rootDir,
+ selectors: options.selectors,
+ suitePath: options.suitePath,
+ envName: effectiveEnvName,
+ platform: options.platform,
+ appPath: options.appPath,
+ command,
+ cloudUrl,
+ apiKey,
+ });
+}
+
+// POSIX shell single-quote escaping. Wraps the value in single quotes and
+// escapes any embedded single quote as `'\''` (close-quote, escaped quote,
+// reopen-quote). Returns the value bare when it's safe (alphanum + a few
+// punctuation chars) so common args stay readable.
+function shellQuote(value: string): string {
+ if (/^[A-Za-z0-9_./@%+,:=-]+$/.test(value)) return value;
+ return `'${value.replaceAll("'", "'\\''")}'`;
+}
+
+export async function uploadApp(appPath: string): Promise {
+ const apiKey = requireApiKey();
+ return uploadAppCore({
+ appPath,
+ cloudUrl: resolveCloudUrl(),
+ apiKey,
+ });
+}
diff --git a/packages/report-web/src/contentTypes.ts b/packages/cli/src/contentTypes.ts
similarity index 100%
rename from packages/report-web/src/contentTypes.ts
rename to packages/cli/src/contentTypes.ts
diff --git a/packages/cli/src/env.test.ts b/packages/cli/src/env.test.ts
index b11c8bd..af4952b 100644
--- a/packages/cli/src/env.test.ts
+++ b/packages/cli/src/env.test.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { parseModel } from './env.js';
+import { parseModel, parseReasoningLevel } from './env.js';
test('parseModel requires an explicit model value', () => {
assert.throws(
@@ -43,3 +43,53 @@ test('parseModel rejects unsupported providers', () => {
/Unsupported AI provider: "bedrock"\. Supported providers: openai, google, anthropic\./,
);
});
+
+test('parseModel prefixes errors with the provided label for context', () => {
+ // Trailing whitespace after the slash collapses under the outer trim, so
+ // the echoed value is "openai/" (empty model half) and the label prefix
+ // points the user at the exact config entry that tripped validation.
+ assert.throws(
+ () => parseModel('openai/ ', 'features.planner.model'),
+ /features\.planner\.model has invalid model format: "openai\/"\./,
+ );
+ assert.throws(
+ () => parseModel('bedrock/claude', 'features.planner.model'),
+ /features\.planner\.model has unsupported AI provider: "bedrock"\./,
+ );
+ // Sanity: omitting the label keeps the pre-existing CLI-style error text
+ // that other tests (and --model users) depend on.
+ assert.throws(
+ () => parseModel(undefined),
+ /--model is required\./,
+ );
+});
+
+test('parseReasoningLevel returns undefined when unset', () => {
+ assert.equal(parseReasoningLevel(undefined, 'reasoning'), undefined);
+ assert.equal(parseReasoningLevel(null, 'reasoning'), undefined);
+ assert.equal(parseReasoningLevel('', 'reasoning'), undefined);
+});
+
+test('parseReasoningLevel accepts minimal, low, medium, high', () => {
+ for (const value of ['minimal', 'low', 'medium', 'high']) {
+ assert.equal(parseReasoningLevel(value, 'reasoning'), value);
+ }
+});
+
+test('parseReasoningLevel trims surrounding whitespace', () => {
+ assert.equal(parseReasoningLevel(' high ', 'reasoning'), 'high');
+});
+
+test('parseReasoningLevel rejects non-string values with a labeled error', () => {
+ assert.throws(
+ () => parseReasoningLevel(42, 'config.yaml reasoning'),
+ /config\.yaml reasoning must be a string\. Allowed values: minimal, low, medium, high\./,
+ );
+});
+
+test('parseReasoningLevel rejects unknown values with a labeled error', () => {
+ assert.throws(
+ () => parseReasoningLevel('extreme', 'config.yaml reasoning'),
+ /config\.yaml reasoning has invalid value "extreme"\. Allowed values: minimal, low, medium, high\./,
+ );
+});
diff --git a/packages/cli/src/env.ts b/packages/cli/src/env.ts
index ee38ef9..4bf6963 100644
--- a/packages/cli/src/env.ts
+++ b/packages/cli/src/env.ts
@@ -4,6 +4,16 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
import * as fs from 'fs';
+import { REASONING_LEVELS, type ReasoningLevel } from '@finalrun/common';
+export {
+ MODEL_FORMAT_EXAMPLE,
+ PROVIDER_ENV_VARS,
+ SUPPORTED_AI_PROVIDERS,
+ SUPPORTED_AI_PROVIDERS_LABEL,
+ parseModel,
+ type ParsedModel,
+ type SupportedProvider,
+} from '@finalrun/common';
/**
* Environment configuration for the CLI.
@@ -80,51 +90,23 @@ export class CliEnv {
}
}
-export interface ParsedModel {
- provider: string;
- modelName: string;
-}
-
-export const SUPPORTED_AI_PROVIDERS = ['openai', 'google', 'anthropic'] as const;
-export const SUPPORTED_AI_PROVIDERS_LABEL = SUPPORTED_AI_PROVIDERS.join(', ');
-export const MODEL_FORMAT_EXAMPLE = 'google/gemini-3-flash-preview';
-export const PROVIDER_ENV_VARS: Record<(typeof SUPPORTED_AI_PROVIDERS)[number], string> = {
- openai: 'OPENAI_API_KEY',
- google: 'GOOGLE_API_KEY',
- anthropic: 'ANTHROPIC_API_KEY',
-};
+export const REASONING_LEVELS_LABEL = REASONING_LEVELS.join(', ');
-export function parseModel(modelStr: string | undefined): ParsedModel {
- const normalizedModel = modelStr?.trim();
- if (!normalizedModel) {
- throw new Error(
- `--model is required. Use provider/model, for example ${MODEL_FORMAT_EXAMPLE}. Supported providers: ${SUPPORTED_AI_PROVIDERS_LABEL}.`,
- );
+export function parseReasoningLevel(value: unknown, label: string): ReasoningLevel | undefined {
+ if (value === undefined || value === null) {
+ return undefined;
}
-
- const segments = normalizedModel.split('/');
- if (
- segments.length !== 2 ||
- segments[0] === undefined ||
- segments[1] === undefined ||
- segments[0].trim() === '' ||
- segments[1].trim() === ''
- ) {
- throw new Error(
- `Invalid model format: "${normalizedModel}". Expected provider/model with non-empty provider and model name. Supported providers: ${SUPPORTED_AI_PROVIDERS_LABEL}.`,
- );
+ if (typeof value !== 'string') {
+ throw new Error(`${label} must be a string. Allowed values: ${REASONING_LEVELS_LABEL}.`);
}
-
- const provider = segments[0].trim();
- const modelName = segments[1].trim();
- if (!SUPPORTED_AI_PROVIDERS.includes(provider as (typeof SUPPORTED_AI_PROVIDERS)[number])) {
+ const trimmed = value.trim();
+ if (trimmed === '') {
+ return undefined;
+ }
+ if (!REASONING_LEVELS.includes(trimmed as ReasoningLevel)) {
throw new Error(
- `Unsupported AI provider: "${provider}". Supported providers: ${SUPPORTED_AI_PROVIDERS_LABEL}.`,
+ `${label} has invalid value "${trimmed}". Allowed values: ${REASONING_LEVELS_LABEL}.`,
);
}
-
- return {
- provider,
- modelName,
- };
+ return trimmed as ReasoningLevel;
}
diff --git a/packages/cli/src/goalRunner.test.ts b/packages/cli/src/goalRunner.test.ts
index 5f915ab..a2f9029 100644
--- a/packages/cli/src/goalRunner.test.ts
+++ b/packages/cli/src/goalRunner.test.ts
@@ -284,9 +284,8 @@ test('runGoal starts and stops Android recording when recording is configured',
const result = await runGoal(
{
goal: 'Log in',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-4.1',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-4.1' },
platform: PLATFORM_ANDROID,
recording: {
runId: 'run-1',
@@ -340,9 +339,8 @@ test('executeTestOnSession forwards explicit recording output paths and preserve
session,
{
goal: 'Test 1',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-4.1',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-4.1' },
recording: {
runId: 'run-1',
testId: 'case-1',
@@ -662,9 +660,8 @@ test('executeTestOnSession reuses one prepared session while keeping recording s
session,
{
goal: 'Test 1',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-4.1',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-4.1' },
recording: {
runId: 'run-1',
testId: 'case-1',
@@ -676,9 +673,8 @@ test('executeTestOnSession reuses one prepared session while keeping recording s
session,
{
goal: 'Test 2',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-4.1',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-4.1' },
recording: {
runId: 'run-1',
testId: 'case-2',
@@ -744,9 +740,8 @@ test('executeTestOnSession forwards the prelaunch summary and app identifier to
session,
{
goal: 'Test 1',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-4.1',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-4.1' },
},
dependencies,
);
@@ -776,9 +771,8 @@ test('runGoal still performs isolated setup and cleanup for single-test executio
const result = await runGoal(
{
goal: 'Log in',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-4.1',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-4.1' },
platform: PLATFORM_ANDROID,
},
dependencies,
@@ -808,9 +802,8 @@ test('runGoal fails before execution if required Android recording cannot start'
const result = await runGoal(
{
goal: 'Log in',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-4.1',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-4.1' },
platform: PLATFORM_ANDROID,
recording: {
runId: 'run-1',
@@ -850,9 +843,8 @@ test('runGoal marks the Android test as failed if recording stops without a vide
const result = await runGoal(
{
goal: 'Log in',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-4.1',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-4.1' },
platform: PLATFORM_ANDROID,
recording: {
runId: 'run-1',
diff --git a/packages/cli/src/localRuntime.ts b/packages/cli/src/localRuntime.ts
new file mode 100644
index 0000000..c97a5ee
--- /dev/null
+++ b/packages/cli/src/localRuntime.ts
@@ -0,0 +1,125 @@
+// Resolver for the "local runtime" — the heavyweight modules (test runner,
+// device drivers, doctor, report server) that local commands need but cloud
+// commands do not.
+//
+// In dev / npm installs the heavy modules ship inside packages/cli/, so a
+// dynamic import resolves them from the local node_modules. In the
+// Bun-compiled binary distribution they're bundled into the binary itself,
+// but the binary still needs the on-disk runtime tarball (driver APKs, iOS
+// zips, gRPC proto, Vite SPA dist) at ~/.finalrun/runtime//. We
+// gate local-command execution on that tarball being present and surface
+// LocalRuntimeMissingError with recovery instructions if it isn't.
+//
+// Cloud commands never call into this file.
+
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { resolveCliPackageVersion } from './runtimePaths.js';
+
+// Set to "true" by `bun build --define` in scripts/build-binary.sh. Undefined
+// in dev and tsc-compiled builds. We use this rather than process.versions.bun
+// because contributors who run `bun run` for dev iteration would otherwise
+// hit the standalone-binary code path even when the runtime tarball isn't
+// installed.
+declare const FINALRUN_IS_STANDALONE_BINARY: string | undefined;
+const isStandaloneBinary: boolean =
+ typeof FINALRUN_IS_STANDALONE_BINARY !== 'undefined' &&
+ FINALRUN_IS_STANDALONE_BINARY === 'true';
+
+const INSTALL_URL =
+ 'https://raw.githubusercontent.com/final-run/finalrun-agent/main/scripts/install.sh';
+
+export class LocalRuntimeMissingError extends Error {
+ readonly exitCode = 1;
+ readonly cliVersion: string;
+ readonly runtimeRoot: string;
+
+ constructor(cliVersion: string, runtimeRoot: string) {
+ super(buildMessage(cliVersion, runtimeRoot));
+ this.name = 'LocalRuntimeMissingError';
+ this.cliVersion = cliVersion;
+ this.runtimeRoot = runtimeRoot;
+ }
+}
+
+function buildMessage(cliVersion: string, runtimeRoot: string): string {
+ return [
+ '',
+ '\x1b[31m✖ Local runtime not installed.\x1b[0m',
+ '',
+ ' This command needs the local test runtime (driver bundles, AI SDKs,',
+ ' device control, report server). Install it by re-running:',
+ '',
+ ` curl -fsSL ${INSTALL_URL} | bash`,
+ '',
+ ' Or run in cloud instead:',
+ '',
+ ' finalrun cloud test --app ',
+ '',
+ ` (Looked for runtime ${cliVersion} at ${runtimeRoot})`,
+ '',
+ ].join('\n');
+}
+
+export interface LocalRuntime {
+ testRunner: typeof import('./testRunner.js');
+ doctorRunner: typeof import('./doctorRunner.js');
+ reportServer: typeof import('./reportServer.js');
+ reportServerManager: typeof import('./reportServerManager.js');
+}
+
+export function resolveLocalRuntimeRoot(): string {
+ // Explicit override wins.
+ const override = process.env['FINALRUN_RUNTIME_ROOT'];
+ if (override && override.trim()) {
+ return path.resolve(override.trim());
+ }
+ // Honor the same FINALRUN_DIR convention the install script uses, so a
+ // user who installed via `FINALRUN_DIR=/opt/finalrun ... bash` can find
+ // their runtime at /opt/finalrun/runtime//.
+ const finalrunDir = process.env['FINALRUN_DIR']?.trim() || path.join(os.homedir(), '.finalrun');
+ return path.join(finalrunDir, 'runtime', resolveCliPackageVersion());
+}
+
+/**
+ * Lazy-load the local-runtime modules. Cloud commands never call this;
+ * local commands await it before running their handler.
+ *
+ * In a Bun-compiled standalone binary the heavy JS is bundled into the
+ * executable but the on-disk assets (driver bundles, gRPC proto, SPA dist)
+ * live in the runtime tarball — we require it to be installed and throw
+ * LocalRuntimeMissingError with recovery instructions otherwise.
+ *
+ * In dev / tsc / npm installs the resolver always succeeds because the
+ * heavy modules ship in packages/cli's node_modules tree.
+ */
+export async function resolveLocalRuntime(): Promise {
+ const runtimeRoot = resolveLocalRuntimeRoot();
+
+ if (isStandaloneBinary) {
+ if (!fs.existsSync(path.join(runtimeRoot, 'manifest.json'))) {
+ throw new LocalRuntimeMissingError(resolveCliPackageVersion(), runtimeRoot);
+ }
+ }
+
+ const [testRunner, doctorRunner, reportServer, reportServerManager] = await Promise.all([
+ import('./testRunner.js'),
+ import('./doctorRunner.js'),
+ import('./reportServer.js'),
+ import('./reportServerManager.js'),
+ ]);
+
+ return { testRunner, doctorRunner, reportServer, reportServerManager };
+}
+
+/**
+ * Heuristic for whether the current process can prompt the user. Used by
+ * any code path that wants to ask before doing something heavy (e.g.
+ * downloading the runtime tarball). False in CI and any non-TTY context.
+ */
+export function isInteractive(): boolean {
+ if (process.env['CI']) return false;
+ if (process.env['FINALRUN_NON_INTERACTIVE']) return false;
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
+}
diff --git a/packages/cli/src/reportArtifactStream.ts b/packages/cli/src/reportArtifactStream.ts
new file mode 100644
index 0000000..ce48a84
--- /dev/null
+++ b/packages/cli/src/reportArtifactStream.ts
@@ -0,0 +1,260 @@
+// HTTP artifact streaming for the local report server. Handles byte-range
+// requests (for video scrubbing in the browser), directory-traversal guards,
+// and content-type mapping. Ported from packages/report-web/src/artifacts.ts
+// so the CLI owns the server-side path.
+import * as fs from 'node:fs';
+import * as fsp from 'node:fs/promises';
+import * as path from 'node:path';
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import { REPORT_CONTENT_TYPES } from './contentTypes.js';
+
+export class ArtifactRangeNotSatisfiableError extends Error {
+ readonly size: number;
+
+ constructor(size: number) {
+ super('Requested artifact byte range is not satisfiable.');
+ this.name = 'ArtifactRangeNotSatisfiableError';
+ this.size = size;
+ }
+}
+
+export function resolveArtifactPath(
+ artifactsDir: string,
+ relativePath: string,
+): string {
+ const normalizedRelativePath = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
+ const resolvedPath = path.resolve(artifactsDir, normalizedRelativePath);
+ const relativeToArtifacts = path.relative(artifactsDir, resolvedPath);
+ if (relativeToArtifacts.startsWith('..') || path.isAbsolute(relativeToArtifacts)) {
+ throw new Error('Artifact paths must stay within the workspace artifacts directory.');
+ }
+ return resolvedPath;
+}
+
+export function decodeArtifactPath(rawRelativePath: string): string {
+ return rawRelativePath
+ .split('/')
+ .filter((segment) => segment.length > 0)
+ .map((segment) => decodeURIComponent(segment))
+ .join('/');
+}
+
+export async function serveArtifactHttp(params: {
+ artifactsDir: string;
+ relativePath: string;
+ request: IncomingMessage;
+ response: ServerResponse;
+}): Promise {
+ const { artifactsDir, relativePath, request, response } = params;
+ const method = request.method ?? 'GET';
+ const headOnly = method === 'HEAD';
+
+ let resolvedPath: string;
+ try {
+ resolvedPath = resolveArtifactPath(artifactsDir, relativePath);
+ } catch (error) {
+ writeErrorHtml(response, 404, 'Artifact Not Found', (error as Error).message);
+ return;
+ }
+
+ let stats;
+ try {
+ const artifactsRoot = await fsp.realpath(artifactsDir);
+ const realResolvedPath = await fsp.realpath(resolvedPath);
+ const relativeToRoot = path.relative(artifactsRoot, realResolvedPath);
+ if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
+ throw new Error('Artifact paths must stay within the workspace artifacts directory.');
+ }
+ resolvedPath = realResolvedPath;
+ stats = await fsp.stat(resolvedPath);
+ } catch (error) {
+ writeErrorHtml(
+ response,
+ 404,
+ 'Artifact Not Found',
+ error instanceof Error ? error.message : String(error),
+ );
+ return;
+ }
+
+ if (!stats.isFile()) {
+ writeErrorHtml(response, 404, 'Artifact Not Found', `Not a file: ${resolvedPath}`);
+ return;
+ }
+
+ const rangeHeader = Array.isArray(request.headers.range)
+ ? request.headers.range[0]
+ : request.headers.range;
+
+ let byteRange: { start: number; end: number } | undefined;
+ try {
+ byteRange = parseByteRange(rangeHeader, stats.size);
+ } catch (error) {
+ if (error instanceof ArtifactRangeNotSatisfiableError) {
+ response.writeHead(416, {
+ 'Content-Range': `bytes */${error.size}`,
+ 'Content-Type': 'text/plain; charset=utf-8',
+ 'Cache-Control': 'no-store',
+ });
+ response.end(headOnly ? undefined : 'Requested range is not satisfiable.');
+ return;
+ }
+ throw error;
+ }
+
+ const contentType =
+ REPORT_CONTENT_TYPES[path.extname(resolvedPath).toLowerCase()] ??
+ 'application/octet-stream';
+
+ if (byteRange) {
+ const length = byteRange.end - byteRange.start + 1;
+ response.writeHead(206, {
+ 'Accept-Ranges': 'bytes',
+ 'Content-Length': String(length),
+ 'Content-Range': `bytes ${byteRange.start}-${byteRange.end}/${stats.size}`,
+ 'Content-Type': contentType,
+ 'Cache-Control': 'no-store',
+ });
+ if (headOnly) {
+ response.end();
+ return;
+ }
+ pipeFile(response, resolvedPath, { start: byteRange.start, end: byteRange.end });
+ return;
+ }
+
+ response.writeHead(200, {
+ 'Accept-Ranges': 'bytes',
+ 'Content-Length': String(stats.size),
+ 'Content-Type': contentType,
+ 'Cache-Control': 'no-store',
+ });
+ if (headOnly) {
+ response.end();
+ return;
+ }
+ pipeFile(response, resolvedPath);
+}
+
+function pipeFile(
+ response: ServerResponse,
+ resolvedPath: string,
+ options?: { start: number; end: number },
+): void {
+ const stream = fs.createReadStream(resolvedPath, options);
+ stream.on('error', (error) => {
+ response.destroy(error);
+ });
+ stream.pipe(response);
+}
+
+function parseByteRange(
+ rangeHeader: string | undefined,
+ totalSize: number,
+): { start: number; end: number } | undefined {
+ if (!rangeHeader) return undefined;
+
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
+ if (!match) return undefined;
+
+ const [, startValue, endValue] = match;
+ if (startValue === '' && endValue === '') return undefined;
+
+ if (totalSize === 0) {
+ throw new ArtifactRangeNotSatisfiableError(totalSize);
+ }
+
+ if (startValue === '') {
+ const suffixLength = parseInt(endValue, 10);
+ if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
+ throw new ArtifactRangeNotSatisfiableError(totalSize);
+ }
+ const start = Math.max(0, totalSize - suffixLength);
+ return { start, end: totalSize - 1 };
+ }
+
+ const start = parseInt(startValue, 10);
+ const requestedEnd = endValue === '' ? totalSize - 1 : parseInt(endValue, 10);
+ if (!Number.isFinite(start) || !Number.isFinite(requestedEnd)) {
+ throw new ArtifactRangeNotSatisfiableError(totalSize);
+ }
+ if (start < 0 || start >= totalSize) {
+ throw new ArtifactRangeNotSatisfiableError(totalSize);
+ }
+
+ const end = Math.min(requestedEnd, totalSize - 1);
+ if (end < start) {
+ throw new ArtifactRangeNotSatisfiableError(totalSize);
+ }
+ return { start, end };
+}
+
+export function renderHtmlErrorPage(params: { title: string; message: string }): string {
+ return `
+
+
+
+
+ ${escapeHtml(params.title)}
+
+
+
+
+
+ ${escapeHtml(params.title)}
+ ${escapeHtml(params.message)}
+
+
+
+`;
+}
+
+function writeErrorHtml(
+ response: ServerResponse,
+ status: number,
+ title: string,
+ message: string,
+): void {
+ response.writeHead(status, {
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Cache-Control': 'no-store',
+ });
+ response.end(renderHtmlErrorPage({ title, message }));
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/packages/cli/src/reportIndexTemplate.ts b/packages/cli/src/reportIndexTemplate.ts
deleted file mode 100644
index bded266..0000000
--- a/packages/cli/src/reportIndexTemplate.ts
+++ /dev/null
@@ -1,526 +0,0 @@
-import type { RunIndexEntry } from '@finalrun/common';
-
-type RunOutcomeStatus = 'success' | 'failure' | 'aborted';
-
-function svgDataUri(svg: string): string {
- return `data:image/svg+xml,${encodeURIComponent(svg)}`;
-}
-
-const TEST_ICON_SRC = svgDataUri(
- ' ',
-);
-
-const TEST_SUITE_ICON_SRC = svgDataUri(
- ' ',
-);
-
-const LOCAL_ICON_SRC = svgDataUri(
- ' ',
-);
-
-export interface ReportIndexRunRecord extends RunIndexEntry {
- displayName: string;
- displayKind: 'suite' | 'single_test' | 'multi_test' | 'fallback';
- triggeredFrom: 'Suite' | 'Direct';
- selectedTestCount: number;
-}
-
-export interface ReportIndexViewModel {
- generatedAt: string;
- summary: {
- totalRuns: number;
- totalSuccessRate: number;
- totalDurationMs: number;
- };
- runs: ReportIndexRunRecord[];
-}
-
-export function renderRunIndexHtml(index: ReportIndexViewModel): string {
- return `
-
-
-
-
- FinalRun Reports
- ${renderFontLinks()}
-
-
-
-
-
-
-
- ${renderSummaryCard('Total Runs', String(index.summary.totalRuns), 'accent', renderPlayCircleIconSvg())}
- ${renderSummaryCard('Test Success Rate', `${index.summary.totalSuccessRate.toFixed(1)}%`, successRateTone(index.summary.totalSuccessRate), renderCheckCircleIconSvg())}
- ${renderSummaryCard('Total time saved', formatLongDuration(index.summary.totalDurationMs), 'neutral', renderTimerIconSvg())}
-
-
-
-
- ${index.runs.length === 0
- ? 'No FinalRun reports found.
'
- : `
-
-
-
- TEST NAME
- APPS
- DURATION
- STATUS
- RESULT
- RAN ON
- Triggered From
-
-
-
- ${index.runs.map((run) => renderRunIndexRow(run)).join('')}
-
-
- `}
-
-
-
-`;
-}
-
-function renderRunIndexRow(run: ReportIndexRunRecord): string {
- const resultLabel = run.passedCount + run.failedCount === 0
- ? 'NA'
- : `${run.passedCount} / ${run.selectedTestCount}`;
- const href = buildRunRoute(run.runId);
-
- return `
-
-
-
- ${renderTintedPngIcon(run.displayKind === 'suite' ? TEST_SUITE_ICON_SRC : TEST_ICON_SRC)}
-
-
-
- ${escapeHtml(run.appLabel)}
- ${run.durationMs > 0 ? escapeHtml(formatLongDuration(run.durationMs)) : 'NA'}
- ${renderStatusPill(resolveRunStatus(run))}
- ${escapeHtml(resultLabel)}
-
-
-
- Local
-
-
- ${escapeHtml(run.triggeredFrom)}
-
- `;
-}
-
-function buildRunRoute(runId: string): string {
- return `/runs/${encodeURIComponent(runId)}`;
-}
-
-function renderTintedPngIcon(src: string): string {
- return ` `;
-}
-
-function resolveRunStatus(
- run: Pick,
-): RunOutcomeStatus {
- return run.status === 'aborted' ? 'aborted' : run.success ? 'success' : 'failure';
-}
-
-function renderStatusPill(status: RunOutcomeStatus): string {
- const label = status === 'success' ? 'Passed' : status === 'aborted' ? 'Aborted' : 'Failed';
- return `${escapeHtml(label)} `;
-}
-
-function renderSummaryCard(
- label: string,
- value: string,
- tone: 'accent' | 'success' | 'warning' | 'danger' | 'neutral',
- iconSvg: string,
-): string {
- const iconStyle = tone === 'accent'
- ? 'color: var(--accent); background: rgba(67, 24, 255, 0.1);'
- : tone === 'success'
- ? 'color: var(--success); background: rgba(5, 205, 153, 0.12);'
- : tone === 'warning'
- ? 'color: var(--warning); background: rgba(255, 146, 12, 0.12);'
- : tone === 'danger'
- ? 'color: var(--failure); background: rgba(238, 93, 80, 0.12);'
- : 'color: var(--text); background: var(--panel-alt);';
- return `
-
-
${iconSvg}
-
- ${escapeHtml(label)}
- ${escapeHtml(value)}
-
-
- `;
-}
-
-function formatLongDuration(durationMs: number | undefined): string {
- const ms = Number(durationMs || 0);
- if (ms <= 0) {
- return '0s';
- }
-
- const duration = Math.round(ms / 1000);
- const hours = Math.floor(duration / 3600);
- const minutes = Math.floor((duration % 3600) / 60);
- const seconds = duration % 60;
-
- if (hours > 0) {
- return `${hours}h ${minutes}m`;
- }
- if (minutes > 0) {
- return `${minutes}m ${seconds}s`;
- }
- return `${seconds}s`;
-}
-
-function escapeHtml(value: unknown): string {
- return String(value)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-}
-
-function escapeJs(value: string): string {
- return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
-}
-
-function successRateTone(rate: number): 'success' | 'warning' | 'danger' {
- if (rate >= 80) {
- return 'success';
- }
- if (rate >= 50) {
- return 'warning';
- }
- return 'danger';
-}
-
-function renderFontLinks(): string {
- return `
-
-
-
- `;
-}
-
-function renderSharedCss(): string {
- return `
- :root {
- --bg: #F4F7FE;
- --panel: #FFFFFF;
- --panel-alt: #F4F7FE;
- --text: #2B3674;
- --muted: #707EAE;
- --icon: #8E9AB9;
- --accent: #4318FF;
- --success: #05CD99;
- --aborted: #475569;
- --warning: #FF920C;
- --failure: #EE5D50;
- --border: #E0E5F2;
- --border-light: #E9EDF7;
- --selected: #F0F2F7;
- --shadow: 0 18px 40px rgba(112, 126, 174, 0.12);
- }
-
- * { box-sizing: border-box; }
-
- html, body {
- margin: 0;
- padding: 0;
- background: var(--bg);
- color: var(--text);
- font-family: "DM Sans", "Helvetica Neue", Arial, sans-serif;
- }
-
- body {
- background:
- radial-gradient(circle at top right, rgba(67, 24, 255, 0.08), transparent 32%),
- linear-gradient(180deg, #fbfcff 0%, var(--bg) 100%);
- }
-
- a {
- color: var(--accent);
- text-decoration: none;
- }
-
- a:hover {
- text-decoration: underline;
- }
-
- .page {
- max-width: 1360px;
- margin: 0 auto;
- padding: 28px;
- }
-
- .status-pill {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-height: 38px;
- padding: 8px 14px;
- border-radius: 999px;
- font-size: 13px;
- font-weight: 700;
- white-space: nowrap;
- }
-
- .status-pill.success {
- background: rgba(5, 205, 153, 0.14);
- color: var(--success);
- }
-
- .status-pill.aborted {
- background: rgba(71, 85, 105, 0.14);
- color: var(--aborted);
- }
-
- .status-pill.failure {
- background: rgba(238, 93, 80, 0.14);
- color: var(--failure);
- }
-
- @media (max-width: 900px) {
- .page {
- padding: 20px;
- }
- }
- `;
-}
-
-function renderPlayCircleIconSvg(): string {
- return ' ';
-}
-
-function renderCheckCircleIconSvg(): string {
- return ' ';
-}
-
-function renderTimerIconSvg(): string {
- return ' ';
-}
diff --git a/packages/cli/src/reportServer.ts b/packages/cli/src/reportServer.ts
index 880f72b..5a2086e 100644
--- a/packages/cli/src/reportServer.ts
+++ b/packages/cli/src/reportServer.ts
@@ -1,99 +1,105 @@
+// Local report HTTP server. Spawned by reportServerManager as a detached
+// child process via `finalrun internal-report-server`.
+//
+// Routes (in order):
+// GET /health -> JSON health probe
+// GET /api/report/index -> ReportIndexViewModel JSON
+// GET /api/report/runs/:runId -> ReportRunManifest JSON
+// GET /artifacts/<...> -> streamed artifact file (Range-aware)
+// HEAD /artifacts/<...> -> same, headers only (video scrubbing)
+// GET /* -> Vite SPA static bundle (index.html fallback for deep links)
import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as path from 'node:path';
-import { createServer } from 'node:http';
-import type {
- RunIndexEntry,
- RunIndex,
- RunManifest,
- TestDefinition,
- TestResult,
-} from '@finalrun/common';
-import { loadRunIndex } from './runIndex.js';
+import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import {
- renderRunIndexHtml,
- type ReportIndexRunRecord,
- type ReportIndexViewModel,
-} from './reportIndexTemplate.js';
-import {
- renderHtmlReport,
- type ReportManifestSelectedTestRecord,
- type ReportManifestTestRecord,
- type ReportRunManifest,
-} from './reportTemplate.js';
-
-const CONTENT_TYPES: Record = {
- '.html': 'text/html; charset=utf-8',
- '.json': 'application/json; charset=utf-8',
- '.log': 'text/plain; charset=utf-8',
- '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg',
- '.png': 'image/png',
- '.mov': 'video/quicktime',
- '.mp4': 'video/mp4',
- '.yaml': 'text/yaml; charset=utf-8',
- '.yml': 'text/yaml; charset=utf-8',
-};
+ loadReportIndexViewModel,
+ loadReportRunManifestViewModel,
+ type ReportWorkspaceContext,
+} from './reportViewModel.js';
+import { decodeArtifactPath, serveArtifactHttp } from './reportArtifactStream.js';
+import { REPORT_CONTENT_TYPES } from './contentTypes.js';
+
+// SPA dir resolution priority:
+// 1. FINALRUN_REPORT_APP_DIR — set by initializeCliRuntimeEnvironment when
+// the local-runtime tarball is installed (Bun-compiled binary path).
+// 2. ../report-app relative to __dirname — dev / tsc-compiled path, where
+// the Vite SPA dist is copied next to dist/src/ by copyReportApp.mjs at
+// build time. (tsc with Node16 emits CJS for this package — there's no
+// "type": "module" — so __dirname is available.)
+//
+// The value is normalized via path.resolve so the path-traversal guard at
+// the asset-serving call site (startsWith(SPA_DIR + path.sep)) compares
+// against a canonical, segment-collapsed prefix.
+const SPA_DIR = path.resolve(
+ process.env['FINALRUN_REPORT_APP_DIR']?.trim() ||
+ path.resolve(__dirname, '..', 'report-app'),
+);
export async function serveReportWorkspace(params: {
workspaceRoot: string;
artifactsDir: string;
port: number;
}): Promise<{ url: string; close(): Promise }> {
- const rootDir = path.resolve(params.artifactsDir);
+ const artifactsDir = path.resolve(params.artifactsDir);
const workspaceRoot = path.resolve(params.workspaceRoot);
+ const context: ReportWorkspaceContext = { workspaceRoot, artifactsDir };
const server = createServer(async (request, response) => {
try {
+ const method = request.method ?? 'GET';
const requestPath = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;
if (requestPath === '/health') {
- response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
- response.end(JSON.stringify({
+ writeJson(response, 200, {
status: 'ok',
workspaceRoot,
- artifactsDir: rootDir,
+ artifactsDir,
pid: process.pid,
- }));
+ });
return;
}
- if (requestPath === '/') {
- const index = await loadRunIndex(rootDir);
- response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
- response.end(renderRunIndexHtml(await buildReportIndexViewModel(index, rootDir)));
+ if (requestPath === '/api/report/index') {
+ writeJson(response, 200, await loadReportIndexViewModel(context));
return;
}
- const runMatch = /^\/runs\/([^/]+)$/.exec(requestPath);
- if (runMatch) {
- const runId = decodeURIComponent(runMatch[1] ?? '');
- const manifest = await loadRunManifest(rootDir, runId);
- if (!manifest) {
- response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
- response.end(`Run not found: ${runId}`);
- return;
+ const runApiMatch = /^\/api\/report\/runs\/([^/]+)$/.exec(requestPath);
+ if (runApiMatch) {
+ const runId = decodeURIComponent(runApiMatch[1] ?? '');
+ try {
+ writeJson(response, 200, await loadReportRunManifestViewModel(runId, context));
+ } catch (error) {
+ writeJson(response, 404, {
+ status: 'error',
+ message: error instanceof Error ? error.message : String(error),
+ });
}
-
- response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
- response.end(renderHtmlReport(await buildReportRunManifestViewModel(manifest, rootDir)));
return;
}
- if (requestPath.startsWith('/artifacts/')) {
- await serveArtifactFile({
- artifactsDir: rootDir,
+ if (requestPath.startsWith('/artifacts/') && (method === 'GET' || method === 'HEAD')) {
+ await serveArtifactHttp({
+ artifactsDir,
relativePath: decodeArtifactPath(requestPath.slice('/artifacts/'.length)),
- rangeHeader: request.headers.range,
+ request,
response,
});
return;
}
- response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
- response.end('Not found');
+ if (method === 'GET' || method === 'HEAD') {
+ await serveSpaAsset(requestPath, request, response);
+ return;
+ }
+
+ response.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
+ response.end('Method Not Allowed');
} catch (error) {
- response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
+ if (!response.headersSent) {
+ response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
+ }
response.end(error instanceof Error ? error.message : String(error));
}
});
@@ -127,400 +133,103 @@ export async function serveReportWorkspace(params: {
};
}
-async function loadRunManifest(
- artifactsDir: string,
- runId: string,
-): Promise {
- try {
- const raw = await fsp.readFile(path.join(artifactsDir, runId, 'run.json'), 'utf-8');
- const parsed = JSON.parse(raw) as RunManifest;
- if (parsed.schemaVersion !== 2 && parsed.schemaVersion !== 3) {
- return undefined;
- }
- return parsed;
- } catch {
- return undefined;
- }
-}
+async function serveSpaAsset(
+ requestPath: string,
+ request: IncomingMessage,
+ response: ServerResponse,
+): Promise {
+ const headOnly = request.method === 'HEAD';
+ const assetPath = requestPath === '/' ? '/index.html' : requestPath;
+ const resolved = path.resolve(SPA_DIR, '.' + assetPath);
-async function serveArtifactFile(params: {
- artifactsDir: string;
- relativePath: string;
- rangeHeader?: string | string[];
- response: NodeJS.WritableStream & {
- writeHead(statusCode: number, headers: Record): void;
- end(chunk?: string): void;
- };
-}): Promise {
- const resolvedPath = path.resolve(params.artifactsDir, params.relativePath);
- if (!resolvedPath.startsWith(params.artifactsDir)) {
- params.response.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
- params.response.end('Forbidden');
+ if (!resolved.startsWith(SPA_DIR + path.sep) && resolved !== SPA_DIR) {
+ response.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
+ response.end(headOnly ? undefined : 'Forbidden');
return;
}
+ let served = await tryServeFile(resolved, response, headOnly, requestPath);
+ if (served) return;
+
+ // SPA fallback: any unknown path serves index.html so client-side routes
+ // resolve on deep-link reload.
+ served = await tryServeFile(path.join(SPA_DIR, 'index.html'), response, headOnly, '/index.html');
+ if (served) return;
+
+ response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
+ response.end(headOnly ? undefined : 'Report UI bundle not found. Rebuild the CLI.');
+}
+
+async function tryServeFile(
+ filePath: string,
+ response: ServerResponse,
+ headOnly: boolean,
+ logicalPath: string,
+): Promise {
let stats;
try {
- stats = await fsp.stat(resolvedPath);
+ stats = await fsp.stat(filePath);
} catch {
- params.response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
- params.response.end('Not found');
- return;
- }
-
- if (!stats.isFile()) {
- params.response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
- params.response.end('Not found');
- return;
+ return false;
}
+ if (!stats.isFile()) return false;
- const rangeHeader = Array.isArray(params.rangeHeader)
- ? params.rangeHeader[0]
- : params.rangeHeader;
- const byteRange = parseByteRange(rangeHeader, stats.size);
const contentType =
- CONTENT_TYPES[path.extname(resolvedPath).toLowerCase()] ?? 'application/octet-stream';
+ REPORT_CONTENT_TYPES[path.extname(filePath).toLowerCase()] ??
+ guessSpaContentType(filePath) ??
+ 'application/octet-stream';
- if (byteRange) {
- params.response.writeHead(206, {
- 'Accept-Ranges': 'bytes',
- 'Content-Length': String(byteRange.end - byteRange.start + 1),
- 'Content-Range': `bytes ${byteRange.start}-${byteRange.end}/${stats.size}`,
- 'Content-Type': contentType,
- });
- fs.createReadStream(resolvedPath, {
- start: byteRange.start,
- end: byteRange.end,
- }).pipe(params.response);
- return;
- }
+ const cacheControl = isImmutableAsset(logicalPath)
+ ? 'public, max-age=31536000, immutable'
+ : 'no-store';
- params.response.writeHead(200, {
- 'Accept-Ranges': 'bytes',
- 'Content-Length': String(stats.size),
+ response.writeHead(200, {
'Content-Type': contentType,
+ 'Content-Length': String(stats.size),
+ 'Cache-Control': cacheControl,
});
- fs.createReadStream(resolvedPath).pipe(params.response);
-}
-
-function parseByteRange(
- rangeHeader: string | undefined,
- totalSize: number,
-): { start: number; end: number } | undefined {
- if (!rangeHeader) {
- return undefined;
- }
-
- const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
- if (!match) {
- return undefined;
- }
-
- const [, startValue, endValue] = match;
- if (startValue === '' && endValue === '') {
- return undefined;
- }
-
- if (startValue === '') {
- const suffixLength = parseInt(endValue, 10);
- if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
+ if (headOnly) {
+ response.end();
+ return true;
+ }
+ fs.createReadStream(filePath).pipe(response);
+ return true;
+}
+
+function isImmutableAsset(logicalPath: string): boolean {
+ return logicalPath.startsWith('/assets/');
+}
+
+function guessSpaContentType(filePath: string): string | undefined {
+ const ext = path.extname(filePath).toLowerCase();
+ switch (ext) {
+ case '.js':
+ case '.mjs':
+ return 'application/javascript; charset=utf-8';
+ case '.css':
+ return 'text/css; charset=utf-8';
+ case '.svg':
+ return 'image/svg+xml';
+ case '.ico':
+ return 'image/x-icon';
+ case '.webmanifest':
+ case '.map':
+ return 'application/json; charset=utf-8';
+ case '.woff':
+ return 'font/woff';
+ case '.woff2':
+ return 'font/woff2';
+ case '.ttf':
+ return 'font/ttf';
+ default:
return undefined;
- }
- const start = Math.max(0, totalSize - suffixLength);
- return { start, end: totalSize - 1 };
- }
-
- const start = parseInt(startValue, 10);
- const requestedEnd = endValue === '' ? totalSize - 1 : parseInt(endValue, 10);
- if (!Number.isFinite(start) || !Number.isFinite(requestedEnd)) {
- return undefined;
- }
- if (start < 0 || start >= totalSize) {
- return undefined;
}
-
- const end = Math.min(requestedEnd, totalSize - 1);
- if (end < start) {
- return undefined;
- }
-
- return { start, end };
}
-function decodeArtifactPath(rawRelativePath: string): string {
- return rawRelativePath
- .split('/')
- .filter((segment) => segment.length > 0)
- .map((segment) => decodeURIComponent(segment))
- .join('/');
-}
-
-function buildRunRoute(runId: string): string {
- return `/runs/${encodeURIComponent(runId)}`;
-}
-
-function buildArtifactRoute(relativePath: string): string {
- return `/artifacts/${relativePath
- .replace(/\\/g, '/')
- .replace(/^\/+/, '')
- .split('/')
- .map((segment) => encodeURIComponent(segment))
- .join('/')}`;
-}
-
-export async function buildReportIndexViewModel(
- index: RunIndex,
- artifactsDir: string,
-): Promise {
- const runs = await Promise.all(
- index.runs.map(async (run) => await enrichRunIndexEntry(run, artifactsDir)),
- );
- const passedRuns = runs.filter((run) => run.success).length;
-
- return {
- generatedAt: index.generatedAt,
- summary: {
- totalRuns: runs.length,
- totalSuccessRate: runs.length === 0 ? 0 : (passedRuns / runs.length) * 100,
- totalDurationMs: runs.reduce((total, run) => total + Number(run.durationMs || 0), 0),
- },
- runs,
- };
-}
-
-export async function buildReportRunManifestViewModel(
- manifest: RunManifest,
- artifactsDir: string,
-): Promise {
- const runId = manifest.run.runId;
- const snapshotCache = new Map>();
- const readSnapshotYamlText = async (snapshotYamlPath: string): Promise => {
- let cached = snapshotCache.get(snapshotYamlPath);
- if (!cached) {
- cached = readRunArtifactText(artifactsDir, runId, snapshotYamlPath);
- snapshotCache.set(snapshotYamlPath, cached);
- }
- return await cached;
- };
- const readDeviceLogTail = async (deviceLogPath: string): Promise => {
- const content = await readRunArtifactText(artifactsDir, runId, deviceLogPath);
- if (!content) {
- return undefined;
- }
- const lines = content.split('\n');
- const maxLines = 500;
- if (lines.length > maxLines) {
- return `[… ${lines.length - maxLines} lines truncated]\n${lines.slice(-maxLines).join('\n')}`;
- }
- return content;
- };
-
- const { tests: _tests, input: _input, ...rest } = manifest;
- const { tests: _inputTests, ...inputRest } = manifest.input;
- return {
- ...rest,
- input: {
- ...inputRest,
- suite: manifest.input.suite
- ? {
- ...manifest.input.suite,
- snapshotYamlPath: manifest.input.suite.snapshotYamlPath
- ? buildRunScopedArtifactPath(runId, manifest.input.suite.snapshotYamlPath)
- : undefined,
- snapshotJsonPath: manifest.input.suite.snapshotJsonPath
- ? buildRunScopedArtifactPath(runId, manifest.input.suite.snapshotJsonPath)
- : undefined,
- }
- : undefined,
- tests: await Promise.all(
- manifest.input.tests.map(async (test) => await toSelectedTestViewModel(runId, test, readSnapshotYamlText)),
- ),
- },
- tests: await Promise.all(
- manifest.tests.map(async (test) => await toTestViewModel(runId, test, readSnapshotYamlText, readDeviceLogTail)),
- ),
- paths: {
- ...manifest.paths,
- runJson: buildRunScopedArtifactPath(runId, manifest.paths.runJson),
- summaryJson: buildRunScopedArtifactPath(runId, manifest.paths.summaryJson),
- log: buildRunScopedArtifactPath(runId, manifest.paths.log),
- runContextJson: manifest.paths.runContextJson
- ? buildRunScopedArtifactPath(runId, manifest.paths.runContextJson)
- : undefined,
- },
- };
-}
-
-async function toSelectedTestViewModel(
- runId: string,
- test: TestDefinition,
- readSnapshotYamlText: (snapshotYamlPath: string) => Promise,
-): Promise {
- return {
- ...test,
- snapshotYamlPath: test.snapshotYamlPath
- ? buildRunScopedArtifactPath(runId, test.snapshotYamlPath)
- : undefined,
- snapshotJsonPath: test.snapshotJsonPath
- ? buildRunScopedArtifactPath(runId, test.snapshotJsonPath)
- : undefined,
- snapshotYamlText: test.snapshotYamlPath
- ? await readSnapshotYamlText(test.snapshotYamlPath)
- : undefined,
- };
-}
-
-async function toTestViewModel(
- runId: string,
- test: TestResult,
- readSnapshotYamlText: (snapshotYamlPath: string) => Promise,
- readDeviceLogTail: (deviceLogPath: string) => Promise,
-): Promise {
- return {
- ...test,
- snapshotYamlPath: test.snapshotYamlPath
- ? buildRunScopedArtifactPath(runId, test.snapshotYamlPath)
- : undefined,
- snapshotJsonPath: test.snapshotJsonPath
- ? buildRunScopedArtifactPath(runId, test.snapshotJsonPath)
- : undefined,
- snapshotYamlText: test.snapshotYamlPath
- ? await readSnapshotYamlText(test.snapshotYamlPath)
- : undefined,
- deviceLogFile: test.deviceLogFile
- ? buildRunScopedArtifactPath(runId, test.deviceLogFile)
- : undefined,
- deviceLogTailText: test.deviceLogFile
- ? await readDeviceLogTail(test.deviceLogFile)
- : undefined,
- previewScreenshotPath: test.previewScreenshotPath
- ? buildRunScopedArtifactPath(runId, test.previewScreenshotPath)
- : undefined,
- resultJsonPath: test.resultJsonPath
- ? buildRunScopedArtifactPath(runId, test.resultJsonPath)
- : undefined,
- recordingFile: test.recordingFile
- ? buildRunScopedArtifactPath(runId, test.recordingFile)
- : undefined,
- steps: test.steps.map((step) => ({
- ...step,
- screenshotFile: step.screenshotFile
- ? buildRunScopedArtifactPath(runId, step.screenshotFile)
- : undefined,
- stepJsonFile: step.stepJsonFile
- ? buildRunScopedArtifactPath(runId, step.stepJsonFile)
- : undefined,
- })),
- firstFailure: test.firstFailure
- ? {
- ...test.firstFailure,
- screenshotPath: test.firstFailure.screenshotPath
- ? buildRunScopedArtifactPath(runId, test.firstFailure.screenshotPath)
- : undefined,
- stepJsonPath: test.firstFailure.stepJsonPath
- ? buildRunScopedArtifactPath(runId, test.firstFailure.stepJsonPath)
- : undefined,
- }
- : undefined,
- };
-}
-
-function buildRunScopedArtifactPath(runId: string, relativePath: string): string {
- return buildArtifactRoute(`${runId}/${relativePath}`);
-}
-
-async function readRunArtifactText(
- artifactsDir: string,
- runId: string,
- artifactPath: string,
-): Promise {
- const normalizedPath = normalizeRunArtifactPath(runId, artifactPath);
- if (!normalizedPath) {
- return undefined;
- }
-
- try {
- return await fsp.readFile(path.join(artifactsDir, runId, normalizedPath), 'utf-8');
- } catch {
- return undefined;
- }
-}
-
-function normalizeRunArtifactPath(runId: string, artifactPath: string): string | undefined {
- const normalized = artifactPath.replace(/\\/g, '/').replace(/^\/+/, '');
- if (normalized.length === 0) {
- return undefined;
- }
-
- if (!normalized.startsWith('artifacts/')) {
- return normalized;
- }
-
- const withoutArtifactsPrefix = normalized.slice('artifacts/'.length);
- if (withoutArtifactsPrefix.startsWith(`${runId}/`)) {
- return withoutArtifactsPrefix.slice(runId.length + 1);
- }
-
- return undefined;
-}
-
-async function enrichRunIndexEntry(
- run: RunIndexEntry,
- artifactsDir: string,
-): Promise {
- const manifest = await loadRunManifest(artifactsDir, run.runId);
- const selectedTests = manifest?.input.tests ?? [];
-
- return {
- ...run,
- displayName: deriveRunDisplayName(run, manifest),
- displayKind: deriveRunDisplayKind(run, manifest),
- triggeredFrom: run.target?.type === 'suite' ? 'Suite' : 'Direct',
- selectedTestCount: selectedTests.length > 0 ? selectedTests.length : run.testCount,
- paths: {
- ...run.paths,
- log: buildArtifactRoute(run.paths.log),
- runJson: buildArtifactRoute(run.paths.runJson),
- },
- };
-}
-
-function deriveRunDisplayName(
- run: RunIndexEntry,
- manifest: RunManifest | undefined,
-): string {
- if (run.target?.type === 'suite' && run.target.suiteName) {
- return run.target.suiteName;
- }
-
- const selectedTests = manifest?.input.tests ?? [];
- if (selectedTests.length === 1) {
- return selectedTests[0]?.name || selectedTests[0]?.relativePath || run.runId;
- }
- if (selectedTests.length > 1) {
- const firstLabel =
- selectedTests[0]?.name || selectedTests[0]?.relativePath || 'Selected tests';
- return `${firstLabel} +${selectedTests.length - 1} more`;
- }
-
- return run.runId;
-}
-
-function deriveRunDisplayKind(
- run: RunIndexEntry,
- manifest: RunManifest | undefined,
-): ReportIndexRunRecord['displayKind'] {
- if (run.target?.type === 'suite') {
- return 'suite';
- }
-
- const selectedCount = manifest?.input.tests?.length ?? run.testCount;
- if (selectedCount === 1) {
- return 'single_test';
- }
- if (selectedCount > 1) {
- return 'multi_test';
- }
-
- return 'fallback';
+function writeJson(response: ServerResponse, status: number, body: unknown): void {
+ response.writeHead(status, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store',
+ });
+ response.end(JSON.stringify(body));
}
diff --git a/packages/cli/src/reportTemplate.ts b/packages/cli/src/reportTemplate.ts
deleted file mode 100644
index 6b969f5..0000000
--- a/packages/cli/src/reportTemplate.ts
+++ /dev/null
@@ -1,2485 +0,0 @@
-import type {
- RunManifest as SharedRunManifest,
- TestDefinition,
- TestResult,
- AgentAction,
- RunTarget,
-} from '@finalrun/common';
-
-function svgDataUri(svg: string): string {
- return `data:image/svg+xml,${encodeURIComponent(svg)}`;
-}
-
-const TEST_ICON_SRC = svgDataUri(
- ' ',
-);
-
-type TestOutcomeStatus = 'success' | 'failure' | 'error' | 'aborted' | 'not_executed';
-type RunOutcomeStatus = 'success' | 'failure' | 'aborted';
-
-export interface ReportManifestSelectedTestRecord extends TestDefinition {
- snapshotYamlText?: string;
-}
-
-export interface ReportManifestTestRecord extends TestResult {
- snapshotYamlText?: string;
- deviceLogTailText?: string;
-}
-
-export interface ReportRunManifest extends Omit {
- input: Omit & {
- tests: ReportManifestSelectedTestRecord[];
- };
- tests: ReportManifestTestRecord[];
-}
-
-interface ReportTestListItem {
- input: ReportManifestSelectedTestRecord;
- executed?: ReportManifestTestRecord;
- status: TestOutcomeStatus;
- durationLabel: string;
-}
-
-interface OutcomeSummary {
- total: number;
- success: number;
- aborted: number;
- failure: number;
- error: number;
- notExecuted: number;
-}
-
-export function renderHtmlReport(manifest: ReportRunManifest): string {
- const run = manifest.run;
- const testItems = buildTestListItems(manifest);
- const isSingleTest = testItems.length <= 1;
- const outcomeSummary = summarizeTestItems(testItems);
- const initialTest = testItems[0];
- const reportTitle = deriveReportTitle(manifest);
- const reportPayload = JSON.stringify(stripSnapshotYamlText(manifest)).replace(/
-
-
-
-
- ${escapeHtml(reportTitle)}
- ${renderFontLinks()}
-
-
-
-
-
-
- ${isSingleTest
- ? renderSingleSpecPage(manifest, initialTest)
- : renderSuiteRunPage(manifest, testItems, outcomeSummary)}
-
-
-
-
-
-`;
-}
-
-function renderSingleSpecPage(
- manifest: ReportRunManifest,
- item: ReportTestListItem | undefined,
-): string {
- if (!item) {
- return `
-
-
-
No test details were recorded for this run.
-
-
- `;
- }
-
- return renderSpecDetailSection(item, true, undefined, manifest);
-}
-
-function renderSuiteRunPage(
- manifest: ReportRunManifest,
- items: ReportTestListItem[],
- summary: OutcomeSummary,
-): string {
- const suiteLabel = deriveReportTitle(manifest);
- return `
-
-
-
-
Run summary
-
Completed suite-level view based on the locally captured report artifacts.
-
-
- ${renderSummarySegments(summary)}
-
-
-
-
${summary.success}/${summary.total}
-
Tests passed
-
-
-
${formatLongDuration(manifest.run.durationMs)}
-
Run duration
-
-
-
-
-
- ${renderRunContextPanel(manifest)}
-
- Executed tests
- Select a test to inspect the detailed step-by-step report.
-
-
-
- TEST NAME
- APPS
- DURATION
- STATUS
-
-
-
- ${items.map((item) => renderSuiteRow(item, manifest.run.app.label)).join('')}
-
-
-
-
- ${items.map((item) => renderSpecDetailSection(item, false, suiteLabel, manifest)).join('')}
- `;
-}
-
-function renderRunContextPanel(manifest: ReportRunManifest): string {
- return `
-
-
- ${renderRunContextContent(manifest, 'overview-title', 'overview-subtitle')}
-
-
- `;
-}
-
-function renderRunContextContent(
- manifest: ReportRunManifest,
- titleClass: string,
- subtitleClass: string,
-): string {
- return `
- Run Context
- Inputs and environment captured for this report.
-
- ${renderRunContextSummary(manifest)}
-
- `;
-}
-
-function renderRunContextSummary(manifest: ReportRunManifest): string {
- return [
- renderContextSummaryItem('Environment', manifest.input.environment.envName),
- renderContextSummaryItem('Platform', manifest.run.platform),
- renderContextSummaryItem('Model', manifest.run.model.label),
- renderContextSummaryItem('App', manifest.run.app.label),
- ].join('');
-}
-
-function renderContextSummaryItem(label: string, value: string): string {
- return `
-
-
${escapeHtml(label)}
-
${escapeHtml(value)}
-
- `;
-}
-
-function renderSummarySegments(summary: OutcomeSummary): string {
- const segments = [
- { label: 'Success', className: 'success', count: summary.success },
- { label: 'Aborted', className: 'aborted', count: summary.aborted },
- { label: 'Failure', className: 'failure', count: summary.failure },
- { label: 'Error', className: 'error', count: summary.error },
- { label: 'Not Executed', className: 'not-executed', count: summary.notExecuted },
- ];
-
- return `
-
- ${segments
- .filter((segment) => segment.count > 0)
- .map((segment) => {
- const width = summary.total === 0 ? 0 : (segment.count / summary.total) * 100;
- return `
`;
- })
- .join('')}
-
-
- ${segments.map((segment) => {
- const percent = summary.total === 0 ? 0 : Math.round((segment.count / summary.total) * 100);
- return `
-
-
- ${segment.label} - ${percent}%
-
- `;
- }).join('')}
-
- `;
-}
-
-function renderSuiteRow(item: ReportTestListItem, appLabel: string): string {
- return `
-
-
-
- ${renderTintedPngIcon(TEST_ICON_SRC)}
-
-
${escapeHtml(item.input.name)}
-
${escapeHtml(item.input.relativePath)}
-
-
-
- ${escapeHtml(appLabel)}
- ${escapeHtml(item.durationLabel)}
- ${renderStatusPill(item.status)}
-
- `;
-}
-
-function renderSpecDetailSection(
- item: ReportTestListItem,
- visible: boolean,
- parentLabel?: string,
- manifest?: ReportRunManifest,
-): string {
- const detailClass = visible ? 'detail-shell is-visible' : 'detail-shell';
- const detailSubtitle = parentLabel
- ? `${parentLabel} · ${item.input.relativePath}`
- : item.input.relativePath;
- const test = item.executed;
- const initialStep = test?.steps[0];
- const statusText = item.status === 'error'
- ? 'Error'
- : item.status === 'aborted'
- ? 'Aborted'
- : item.status === 'failure'
- ? 'Failed'
- : item.status === 'not_executed'
- ? 'Not executed'
- : 'Passed';
- const analysisText = test
- ? test.analysis || test.message || 'No overall analysis recorded.'
- : 'This test was selected for the run, but it never started. The batch ended before this test could execute.';
- const snapshotYamlText = test?.snapshotYamlText ?? item.input.snapshotYamlText;
- const snapshotYamlPath = test?.snapshotYamlPath ?? item.input.snapshotYamlPath;
- const stepCount = test?.steps.length ?? 0;
- const recordingSpeedId = `recording-speed-${item.input.testId!}`;
-
- return `
-
-
-
-
-
- ${renderSpecTestSection(snapshotYamlPath, snapshotYamlText)}
- ${manifest ? renderRunContextSection(manifest) : ''}
- ${renderSpecAnalysisSection(item.status, analysisText)}
-
-
-
-
-
-
- ${renderPlayIconSvg()}
- ${formatVideoTimestamp(initialStep?.videoOffsetMs)}
-
- --:--
- Playback speed
-
- 1x
- 2x
- 4x
- 8x
-
-
-
-
-
-
-
- Actions
- ${test?.deviceLogFile
- ? 'Device Logs '
- : ''}
-
-
- ${test?.deviceLogFile
- ? `
-
-
-
${renderDeviceLogLines(test.deviceLogTailText ?? '', test.recordingStartedAt)}
-
Download full log
-
-
`
- : ''}
-
-
-
- `;
-}
-
-function renderSpecTestSection(
- snapshotYamlPath: string | undefined,
- snapshotYamlText: string | undefined,
-): string {
- const content = snapshotYamlText
- ? `${escapeHtml(snapshotYamlText)} `
- : 'Snapshot YAML was not available for this report.
';
- const action = snapshotYamlPath
- ? `Open raw YAML `
- : '';
- return renderDetailSectionCard({
- title: 'Test',
- subtitle: 'Captured YAML snapshot for this test.',
- action,
- content,
- });
-}
-
-function renderRunContextSection(manifest: ReportRunManifest): string {
- return renderDetailSectionCard({
- title: 'Run Context',
- subtitle: 'Inputs and environment captured for this report.',
- content: `${renderRunContextSummary(manifest)}
`,
- });
-}
-
-function renderSpecAnalysisSection(status: TestOutcomeStatus, analysisText: string): string {
- return renderDetailSectionCard({
- title: 'Analysis',
- subtitle: 'Overall result commentary captured for this test.',
- action: renderStatusPill(status),
- cardClass: `analysis-card ${status}`,
- content: `${escapeHtml(analysisText)}
`,
- });
-}
-
-function renderDetailSectionCard(params: {
- title: string;
- subtitle: string;
- content: string;
- action?: string;
- cardClass?: string;
-}): string {
- return `
-
-
-
- ${params.content}
-
-
- `;
-}
-
-function renderStepButton(testId: string, step: AgentAction, index: number): string {
- const statusClass = step.success ? 'success' : step.actionType === 'run_failure' ? 'error' : 'failure';
- const reasoningText = resolveStepReasoning(step);
- return `
-
-
-
${statusClass === 'success' ? '✓' : '!'}
-
-
${escapeHtml(step.naturalLanguageAction || step.actionType)}
-
-
${escapeHtml(formatStepDuration(step.durationMs || step.trace?.totalMs || 0))}
-
- ${reasoningText
- ? `${escapeHtml(reasoningText)}
`
- : ''}
-
- `;
-}
-
-function resolveStepReasoning(step: AgentAction): string | undefined {
- const title = normalizeStepText(step.naturalLanguageAction || step.actionType);
- for (const candidate of [step.thought?.think, step.thought?.plan, step.reason]) {
- const normalized = normalizeStepText(candidate);
- if (!normalized || normalized === title) {
- continue;
- }
- return normalized;
- }
- return undefined;
-}
-
-function normalizeStepText(value: string | undefined): string | undefined {
- const normalized = value?.trim();
- return normalized ? normalized : undefined;
-}
-
-function buildTestListItems(manifest: ReportRunManifest): ReportTestListItem[] {
- const executedById = new Map(manifest.tests.map((test) => [test.testId, test]));
- const selectedTests = manifest.input.tests;
- if (selectedTests.length === 0) {
- return manifest.tests.map((test) => ({
- input: {
- testId: test.testId,
- name: test.testName,
- setup: [],
- steps: [],
- expected_state: [],
- relativePath: test.relativePath,
- workspaceSourcePath: test.workspaceSourcePath,
- snapshotYamlPath: test.snapshotYamlPath,
- snapshotJsonPath: test.snapshotJsonPath,
- snapshotYamlText: test.snapshotYamlText,
- bindingReferences: test.bindingReferences,
- },
- executed: test,
- status: classifyTestStatus(test),
- durationLabel: formatLongDuration(test.durationMs),
- }));
- }
-
- return selectedTests.map((selected) => {
- const executed = executedById.get(selected.testId!);
- return {
- input: selected,
- executed,
- status: executed ? classifyTestStatus(executed) : 'not_executed',
- durationLabel: executed ? formatLongDuration(executed.durationMs) : 'NA',
- };
- });
-}
-
-function summarizeTestItems(items: ReportTestListItem[]): OutcomeSummary {
- return items.reduce(
- (summary, item) => {
- summary.total += 1;
- if (item.status === 'success') {
- summary.success += 1;
- } else if (item.status === 'aborted') {
- summary.aborted += 1;
- } else if (item.status === 'failure') {
- summary.failure += 1;
- } else if (item.status === 'error') {
- summary.error += 1;
- } else {
- summary.notExecuted += 1;
- }
- return summary;
- },
- {
- total: 0,
- success: 0,
- aborted: 0,
- failure: 0,
- error: 0,
- notExecuted: 0,
- },
- );
-}
-
-function classifyTestStatus(test: ReportManifestTestRecord): TestOutcomeStatus {
- if (test.status === 'aborted') {
- return 'aborted';
- }
- if (test.status === 'error') {
- return 'error';
- }
- if (test.status === 'success') {
- return 'success';
- }
- if (test.status === 'failure') {
- return 'failure';
- }
- if (test.success) {
- return 'success';
- }
- if (test.steps[0]?.actionType === 'run_failure') {
- return 'error';
- }
- return 'failure';
-}
-
-function deriveReportTitle(manifest: ReportRunManifest): string {
- const target = resolveRunTarget(manifest);
- if (target.type === 'suite' && target.suiteName) {
- return target.suiteName;
- }
-
- if (manifest.input.tests.length === 1) {
- return manifest.input.tests[0]?.name || manifest.run.runId;
- }
-
- if (manifest.input.tests.length > 1) {
- const first = manifest.input.tests[0];
- return `${first?.name || 'Selected tests'} +${manifest.input.tests.length - 1} more`;
- }
-
- return manifest.run.runId;
-}
-
-function resolveRunStatus(
- run: Pick,
-): RunOutcomeStatus {
- return run.status === 'aborted' ? 'aborted' : run.success ? 'success' : 'failure';
-}
-
-function renderStatusPill(status: TestOutcomeStatus | RunOutcomeStatus): string {
- const label = status === 'success'
- ? 'Passed'
- : status === 'aborted'
- ? 'Aborted'
- : status === 'failure'
- ? 'Failed'
- : status === 'error'
- ? 'Error'
- : 'Not Executed';
- return `${escapeHtml(label)} `;
-}
-
-function resolveRunTarget(manifest: ReportRunManifest): RunTarget {
- return manifest.run.target ?? { type: 'direct' };
-}
-
-function stripSnapshotYamlText(manifest: ReportRunManifest): ReportRunManifest {
- return {
- ...manifest,
- input: {
- ...manifest.input,
- tests: manifest.input.tests.map(({ snapshotYamlText: _snapshotYamlText, ...test }) => test),
- },
- tests: manifest.tests.map(({ snapshotYamlText: _snapshotYamlText, deviceLogTailText: _deviceLogTailText, ...test }) => test),
- };
-}
-
-function formatLongDuration(durationMs: number | undefined): string {
- const ms = Number(durationMs || 0);
- if (ms <= 0) {
- return '0s';
- }
-
- const duration = Math.round(ms / 1000);
- const hours = Math.floor(duration / 3600);
- const minutes = Math.floor((duration % 3600) / 60);
- const seconds = duration % 60;
-
- if (hours > 0) {
- return `${hours}h ${minutes}m`;
- }
- if (minutes > 0) {
- return `${minutes}m ${seconds}s`;
- }
- return `${seconds}s`;
-}
-
-function formatStepDuration(durationMs: number | undefined): string {
- const seconds = Number(durationMs || 0) / 1000;
- return seconds >= 10 ? `${seconds.toFixed(0)}s` : `${seconds.toFixed(1)}s`;
-}
-
-function formatRelativeTime(timestamp: string): string {
- const deltaMs = Math.max(0, Date.now() - new Date(timestamp).getTime());
- const totalMinutes = Math.floor(deltaMs / 60000);
- if (totalMinutes < 1) {
- return 'just now';
- }
- if (totalMinutes < 60) {
- return `${totalMinutes}m`;
- }
- const totalHours = Math.floor(totalMinutes / 60);
- if (totalHours < 24) {
- return `${totalHours}h`;
- }
- const totalDays = Math.floor(totalHours / 24);
- if (totalDays < 7) {
- return `${totalDays}d`;
- }
- const totalWeeks = Math.floor(totalDays / 7);
- return `${totalWeeks}w`;
-}
-
-function parseLogTimestamp(line: string, referenceDate?: string): string | undefined {
- // Android logcat threadtime: "MM-DD HH:MM:SS.mmm ..."
- const androidMatch = /^(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\.(\d{3})/.exec(line);
- if (androidMatch) {
- const [, month, day, hour, min, sec, ms] = androidMatch;
- const year = referenceDate ? new Date(referenceDate).getFullYear() : new Date().getFullYear();
- return new Date(year, parseInt(month, 10) - 1, parseInt(day, 10),
- parseInt(hour, 10), parseInt(min, 10), parseInt(sec, 10), parseInt(ms, 10)).toISOString();
- }
-
- // iOS compact log: "YYYY-MM-DD HH:MM:SS.mmm ..."
- const iosMatch = /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}\.\d{3})/.exec(line);
- if (iosMatch) {
- return new Date(`${iosMatch[1]}T${iosMatch[2]}`).toISOString();
- }
-
- return undefined;
-}
-
-function parseLogLevel(line: string): string {
- // iOS compact log: timestamp then level code like "E ", "Ef"
- const iosMatch = /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s+(E|Ef)\s/.exec(line);
- if (iosMatch) return 'error';
- const iosWarnMatch = /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s+(W|Wf)\s/.exec(line);
- if (iosWarnMatch) return 'warn';
-
- // Android logcat threadtime: "MM-DD HH:MM:SS.mmm PID TID LEVEL TAG: ..."
- const androidMatch = /^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s+\d+\s+\d+\s+([EWDIV])\s/.exec(line);
- if (androidMatch) {
- const level = androidMatch[1];
- if (level === 'E') return 'error';
- if (level === 'W') return 'warn';
- }
-
- return 'info';
-}
-
-function renderDeviceLogLines(logText: string, recordingStartedAt?: string): string {
- if (!logText) {
- return 'No log content available.
';
- }
- const recStartMs = recordingStartedAt ? new Date(recordingStartedAt).getTime() : undefined;
- return logText.split('\n')
- .filter(line => {
- if (line.length === 0) return false;
- if (recStartMs === undefined) return true;
- const ts = parseLogTimestamp(line, recordingStartedAt);
- if (!ts) return true;
- const tsMs = new Date(ts).getTime();
- return !Number.isFinite(tsMs) || tsMs >= recStartMs;
- })
- .map(line => {
- const ts = parseLogTimestamp(line, recordingStartedAt);
- const level = parseLogLevel(line);
- return `${escapeHtml(line)}
`;
- })
- .join('');
-}
-
-function formatVideoTimestamp(videoOffsetMs: number | undefined): string {
- if (videoOffsetMs === undefined) {
- return '00:00';
- }
- const wholeSeconds = Math.floor(Math.max(0, videoOffsetMs / 1000));
- const minutesPart = Math.floor(wholeSeconds / 60);
- const secondsPart = wholeSeconds % 60;
- return `${String(minutesPart).padStart(2, '0')}:${String(secondsPart).padStart(2, '0')}`;
-}
-
-function escapeHtml(value: unknown): string {
- return String(value)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-}
-
-function escapeJs(value: string): string {
- return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
-}
-
-function renderFontLinks(): string {
- return `
-
-
-
- `;
-}
-
-function renderSharedCss(): string {
- return `
- :root {
- --bg: #F4F7FE;
- --panel: #FFFFFF;
- --panel-alt: #F4F7FE;
- --text: #2B3674;
- --muted: #707EAE;
- --icon: #8E9AB9;
- --accent: #4318FF;
- --success: #05CD99;
- --aborted: #475569;
- --warning: #FF920C;
- --failure: #EE5D50;
- --border: #E0E5F2;
- --border-light: #E9EDF7;
- --selected: #F0F2F7;
- --shadow: 0 18px 40px rgba(112, 126, 174, 0.12);
- }
-
- * { box-sizing: border-box; }
-
- html, body {
- margin: 0;
- padding: 0;
- background: var(--bg);
- color: var(--text);
- font-family: "DM Sans", "Helvetica Neue", Arial, sans-serif;
- }
-
- body {
- background:
- radial-gradient(circle at top right, rgba(67, 24, 255, 0.08), transparent 32%),
- linear-gradient(180deg, #fbfcff 0%, var(--bg) 100%);
- }
-
- a {
- color: var(--accent);
- text-decoration: none;
- }
-
- a:hover {
- text-decoration: underline;
- }
-
- .page {
- max-width: 1360px;
- margin: 0 auto;
- padding: 28px;
- }
-
- .status-pill {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-height: 38px;
- padding: 8px 14px;
- border-radius: 999px;
- font-size: 13px;
- font-weight: 700;
- white-space: nowrap;
- }
-
- .status-pill.success {
- background: rgba(5, 205, 153, 0.14);
- color: var(--success);
- }
-
- .status-pill.aborted {
- background: rgba(71, 85, 105, 0.14);
- color: var(--aborted);
- }
-
- .status-pill.failure {
- background: rgba(238, 93, 80, 0.14);
- color: var(--failure);
- }
-
- .status-pill.error {
- background: rgba(255, 146, 12, 0.14);
- color: var(--warning);
- }
-
- .status-pill.not_executed {
- background: rgba(112, 126, 174, 0.14);
- color: var(--muted);
- }
-
- .run-name-cell {
- display: flex;
- align-items: center;
- gap: 12px;
- min-width: 260px;
- }
-
- .png-icon {
- width: 18px;
- height: 18px;
- object-fit: contain;
- flex: 0 0 auto;
- }
-
- .tinted-png-icon {
- width: 18px;
- height: 18px;
- flex: 0 0 auto;
- display: inline-block;
- background-color: #707EAE;
- -webkit-mask-image: var(--icon-mask);
- mask-image: var(--icon-mask);
- -webkit-mask-repeat: no-repeat;
- mask-repeat: no-repeat;
- -webkit-mask-position: center;
- mask-position: center;
- -webkit-mask-size: contain;
- mask-size: contain;
- }
-
- .run-name-copy {
- min-width: 0;
- }
-
- .run-name-link {
- color: var(--text);
- font-weight: 700;
- text-decoration: none;
- }
-
- .run-secondary {
- margin-top: 3px;
- color: var(--muted);
- font-size: 12px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- @media (max-width: 900px) {
- .page {
- padding: 20px;
- }
- }
- `;
-}
-
-function renderBackArrowIconSvg(): string {
- return ' ';
-}
-
-function renderPlayIconSvg(): string {
- return ' ';
-}
-
-function renderPauseIconSvg(): string {
- return ' ';
-}
-
-function renderFullscreenIconSvg(): string {
- return ' ';
-}
-
-function renderTintedPngIcon(src: string): string {
- return ` `;
-}
diff --git a/packages/cli/src/reportTemplates.test.ts b/packages/cli/src/reportTemplates.test.ts
deleted file mode 100644
index 47a60da..0000000
--- a/packages/cli/src/reportTemplates.test.ts
+++ /dev/null
@@ -1,909 +0,0 @@
-import assert from 'node:assert/strict';
-import fs from 'node:fs';
-import fsp from 'node:fs/promises';
-import os from 'node:os';
-import path from 'node:path';
-import test from 'node:test';
-import type { RunIndex, RunManifest } from '@finalrun/common';
-import { renderRunIndexHtml, type ReportIndexViewModel } from './reportIndexTemplate.js';
-import { buildReportIndexViewModel, buildReportRunManifestViewModel } from './reportServer.js';
-import { renderHtmlReport, type ReportRunManifest } from './reportTemplate.js';
-
-function createRunIndexViewModel(): ReportIndexViewModel {
- return {
- generatedAt: '2026-03-24T18:00:00.000Z',
- summary: {
- totalRuns: 2,
- totalSuccessRate: 50,
- totalDurationMs: 22000,
- },
- runs: [
- {
- runId: '2026-03-24T18-00-00.000Z-dev-android',
- success: false,
- status: 'failure',
- startedAt: '2026-03-24T18:00:00.000Z',
- completedAt: '2026-03-24T18:00:10.000Z',
- durationMs: 10000,
- envName: 'dev',
- platform: 'android',
- modelLabel: 'openai/gpt-5.4-mini',
- appLabel: 'repo app',
- target: {
- type: 'suite',
- suiteId: 'login_suite',
- suiteName: 'login suite',
- suitePath: 'login_suite.yaml',
- },
- testCount: 2,
- passedCount: 1,
- failedCount: 1,
- stepCount: 4,
- firstFailure: {
- testId: 'login',
- testName: 'login',
- message: 'button not found',
- },
- paths: {
- runJson: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/run.json',
- log: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/runner.log',
- },
- displayName: 'login suite',
- displayKind: 'suite',
- triggeredFrom: 'Suite',
- selectedTestCount: 2,
- },
- {
- runId: '2026-03-24T19-00-00.000Z-dev-android',
- success: true,
- status: 'success',
- startedAt: '2026-03-24T19:00:00.000Z',
- completedAt: '2026-03-24T19:00:12.000Z',
- durationMs: 12000,
- envName: 'dev',
- platform: 'android',
- modelLabel: 'openai/gpt-5.4-mini',
- appLabel: 'repo app',
- target: {
- type: 'direct',
- },
- testCount: 3,
- passedCount: 3,
- failedCount: 0,
- stepCount: 6,
- paths: {
- runJson: '/artifacts/2026-03-24T19-00-00.000Z-dev-android/run.json',
- log: '/artifacts/2026-03-24T19-00-00.000Z-dev-android/runner.log',
- },
- displayName: 'valid login +2 more',
- displayKind: 'multi_test',
- triggeredFrom: 'Direct',
- selectedTestCount: 3,
- },
- ],
- };
-}
-
-function createSuiteRunManifest(): ReportRunManifest {
- return withSnapshotYamlText({
- schemaVersion: 2,
- run: {
- runId: '2026-03-24T18-00-00.000Z-dev-android',
- success: false,
- status: 'failure',
- failurePhase: 'execution',
- startedAt: '2026-03-24T18:00:00.000Z',
- completedAt: '2026-03-24T18:00:10.000Z',
- durationMs: 10000,
- envName: 'dev',
- platform: 'android',
- model: {
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
- label: 'openai/gpt-5.4-mini',
- },
- app: {
- source: 'repo',
- label: 'repo app',
- },
- selectors: [],
- target: {
- type: 'suite',
- suiteId: 'login_suite',
- suiteName: 'login suite',
- suitePath: 'login_suite.yaml',
- },
- counts: {
- tests: {
- total: 2,
- passed: 0,
- failed: 1,
- },
- steps: {
- total: 1,
- passed: 0,
- failed: 1,
- },
- },
- firstFailure: {
- testId: 'login',
- testName: 'login',
- message: 'button not found',
- screenshotPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/tests/login/screenshots/001.jpg',
- },
- },
- input: {
- environment: {
- envName: 'dev',
- variables: {
- locale: 'en-US',
- },
- secretReferences: [
- {
- key: 'email',
- envVar: 'FINALRUN_TEST_EMAIL',
- },
- ],
- },
- suite: {
- suiteId: 'login_suite',
- name: 'login suite',
- workspaceSourcePath: '.finalrun/suites/login_suite.yaml',
- snapshotYamlPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/input/suite.snapshot.yaml',
- snapshotJsonPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/input/suite.json',
- tests: ['login/valid_login.yaml', 'checkout/guest_checkout.yaml'],
- resolvedTestIds: ['login', 'checkout'],
- },
- tests: [
- {
- testId: 'login',
- name: 'valid login',
- setup: [],
- steps: [],
- expected_state: [],
- relativePath: 'login/valid_login.yaml',
- workspaceSourcePath: '.finalrun/tests/login/valid_login.yaml',
- snapshotYamlPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/input/tests/login.yaml',
- snapshotJsonPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/input/tests/login.json',
- bindingReferences: {
- variables: [],
- secrets: ['email'],
- },
- },
- {
- testId: 'checkout',
- name: 'guest checkout',
- setup: [],
- steps: [],
- expected_state: [],
- relativePath: 'checkout/guest_checkout.yaml',
- workspaceSourcePath: '.finalrun/tests/checkout/guest_checkout.yaml',
- snapshotYamlPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/input/tests/checkout.yaml',
- snapshotJsonPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/input/tests/checkout.json',
- bindingReferences: {
- variables: [],
- secrets: [],
- },
- },
- ],
- cli: {
- command: 'finalrun suite login_suite.yaml',
- selectors: [],
- suitePath: 'login_suite.yaml',
- debug: false,
- },
- },
- tests: [
- {
- testId: 'login',
- testName: 'valid login',
- sourcePath: '/repo/.finalrun/tests/login/valid_login.yaml',
- relativePath: 'login/valid_login.yaml',
- success: false,
- status: 'failure',
- message: 'button not found',
- analysis: 'button not found',
- platform: 'android',
- startedAt: '2026-03-24T18:00:00.000Z',
- completedAt: '2026-03-24T18:00:10.000Z',
- durationMs: 10000,
- recordingFile: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/tests/login/recording.mp4',
- steps: [
- {
- stepNumber: 1,
- iteration: 1,
- actionType: 'tap',
- naturalLanguageAction: 'Tap login',
- reason: 'Open the login form.',
- success: false,
- status: 'failure',
- errorMessage: 'button not found',
- durationMs: 1000,
- timestamp: '2026-03-24T18:00:05.000Z',
- screenshotFile: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/tests/login/screenshots/001.jpg',
- stepJsonFile: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/tests/login/steps/001.json',
- videoOffsetMs: 3200,
- analysis: 'The login button was not visible.',
- thought: {
- plan: 'Open the login form.',
- think: 'The login CTA is the fastest way to reach the authenticated screen.',
- },
- actionPayload: {
- direction: 'down',
- repeat: 1,
- },
- trace: {
- step: 1,
- action: 'tap',
- status: 'failure',
- totalMs: 1000,
- spans: [
- {
- name: 'locate_element',
- durationMs: 420,
- startMs: 0,
- status: 'failure',
- },
- ],
- },
- },
- ],
- workspaceSourcePath: '/repo/.finalrun/tests/login/valid_login.yaml',
- snapshotYamlPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/input/tests/login.yaml',
- snapshotJsonPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/input/tests/login.json',
- bindingReferences: {
- variables: [],
- secrets: ['email'],
- },
- authored: {
- name: 'valid login',
- setup: [],
- steps: ['Tap login'],
- expected_state: ['Dashboard is visible'],
- },
- effectiveGoal: 'Tap login',
- counts: {
- executionStepsTotal: 1,
- executionStepsPassed: 0,
- executionStepsFailed: 1,
- },
- firstFailure: {
- testId: 'login',
- testName: 'valid login',
- stepNumber: 1,
- actionType: 'tap',
- message: 'button not found',
- screenshotPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/tests/login/screenshots/001.jpg',
- stepJsonPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/tests/login/steps/001.json',
- },
- previewScreenshotPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/tests/login/screenshots/001.jpg',
- resultJsonPath: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/tests/login/result.json',
- },
- ],
- paths: {
- runJson: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/run.json',
- summaryJson: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/summary.json',
- log: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/runner.log',
- runContextJson: '/artifacts/2026-03-24T18-00-00.000Z-dev-android/input/run-context.json',
- },
- });
-}
-
-function createSingleTestManifest(): ReportRunManifest {
- const suiteManifest = createSuiteRunManifest();
- return withSnapshotYamlText({
- ...suiteManifest,
- run: {
- ...suiteManifest.run,
- runId: '2026-03-24T20-00-00.000Z-dev-android',
- success: true,
- status: 'success',
- target: {
- type: 'direct',
- },
- counts: {
- tests: {
- total: 1,
- passed: 1,
- failed: 0,
- },
- steps: {
- total: 1,
- passed: 1,
- failed: 0,
- },
- },
- firstFailure: undefined,
- },
- input: {
- ...suiteManifest.input,
- suite: undefined,
- tests: [suiteManifest.input.tests[0]],
- cli: {
- command: 'finalrun test login/valid_login.yaml',
- selectors: ['login/valid_login.yaml'],
- debug: false,
- },
- },
- tests: [
- {
- ...suiteManifest.tests[0],
- success: true,
- message: 'All assertions passed',
- analysis: 'Goal completed successfully.',
- durationMs: 4500,
- recordingFile: '/artifacts/2026-03-24T20-00-00.000Z-dev-android/tests/login/recording.mp4',
- resultJsonPath: '/artifacts/2026-03-24T20-00-00.000Z-dev-android/tests/login/result.json',
- snapshotYamlPath: '/artifacts/2026-03-24T20-00-00.000Z-dev-android/input/tests/login.yaml',
- snapshotJsonPath: '/artifacts/2026-03-24T20-00-00.000Z-dev-android/input/tests/login.json',
- steps: [
- {
- ...suiteManifest.tests[0].steps[0],
- success: true,
- status: 'success',
- errorMessage: undefined,
- screenshotFile: '/artifacts/2026-03-24T20-00-00.000Z-dev-android/tests/login/screenshots/001.jpg',
- stepJsonFile: '/artifacts/2026-03-24T20-00-00.000Z-dev-android/tests/login/steps/001.json',
- },
- ],
- },
- ],
- paths: {
- ...suiteManifest.paths,
- runJson: '/artifacts/2026-03-24T20-00-00.000Z-dev-android/run.json',
- summaryJson: '/artifacts/2026-03-24T20-00-00.000Z-dev-android/summary.json',
- log: '/artifacts/2026-03-24T20-00-00.000Z-dev-android/runner.log',
- runContextJson: '/artifacts/2026-03-24T20-00-00.000Z-dev-android/input/run-context.json',
- },
- });
-}
-
-function withSnapshotYamlText(manifest: ReportRunManifest): ReportRunManifest {
- const snapshotByTestId = new Map([
- ['login', [
- 'name: valid login',
- 'steps:',
- ' - Tap login',
- 'expected_state:',
- ' - Dashboard is visible',
- ].join('\n')],
- ['checkout', [
- 'name: guest checkout',
- 'steps:',
- ' - Open checkout',
- 'expected_state:',
- ' - Checkout page is visible',
- ].join('\n')],
- ]);
-
- for (const t of manifest.input.tests) {
- (t as { snapshotYamlText?: string }).snapshotYamlText = snapshotByTestId.get(t.testId!);
- }
- for (const t of manifest.tests) {
- (t as { snapshotYamlText?: string }).snapshotYamlText = snapshotByTestId.get(t.testId);
- }
-
- return manifest;
-}
-
-function extractTestDetailPanel(html: string, testId: string): string {
- const marker = `data-test-panel="${testId}"`;
- const start = html.indexOf(marker);
- assert.notEqual(start, -1, `Expected test detail panel for ${testId}.`);
- const sectionStart = html.lastIndexOf('', start);
- assert.notEqual(sectionStart, -1);
- assert.notEqual(sectionEnd, -1);
- return html.slice(sectionStart, sectionEnd + ' '.length);
-}
-
-function assertTestDetailSectionOrder(html: string, testId: string): void {
- const panel = extractTestDetailPanel(html, testId);
- const testIndex = panel.indexOf('>Test<');
- const runContextIndex = panel.indexOf('>Run Context<');
- const analysisIndex = panel.indexOf('>Analysis<');
- const actionsIndex = panel.indexOf('Agent Actions');
- const recordingIndex = panel.indexOf('Session Recording');
-
- assert.ok(testIndex >= 0);
- assert.ok(runContextIndex > testIndex);
- assert.ok(analysisIndex > runContextIndex);
- assert.ok(actionsIndex > analysisIndex);
- assert.ok(recordingIndex > actionsIndex);
-}
-
-function assertSimplifiedTestDetailHtml(html: string): void {
- assert.match(html, />Test<\/h3>/);
- assert.match(html, /Open raw YAML/);
- assert.match(html, />Run Context<\/h3>/);
- assert.match(html, />Analysis<\/h3>/);
- assert.match(html, /Agent Actions/);
- assert.match(html, /Session Recording/);
- assert.match(html, /function selectNearestStepForTime/);
- assert.match(html, /function findNearestStepIndex/);
- assert.match(html, /selectNearestStepForTime\(testId, nextTime\)/);
- assert.doesNotMatch(html, /Selected Step/);
- assert.doesNotMatch(html, /Action<\/h4>/);
- assert.doesNotMatch(html, /Reasoning<\/h4>/);
- assert.doesNotMatch(html, /Planner Thought<\/h4>/);
- assert.doesNotMatch(html, /Analysis<\/h4>/);
- assert.doesNotMatch(html, /Trace<\/h4>/);
- assert.doesNotMatch(html, /Meta<\/h4>/);
- assert.doesNotMatch(html, /Raw Artifact Links/);
- assert.doesNotMatch(html, /data-role="screenshot"/);
- assert.doesNotMatch(html, /Back to suite list/);
- assert.doesNotMatch(html, /onclick="clearTestSelection\(\)"/);
- assert.doesNotMatch(html, />Goal<\/strong>/);
-}
-
-function assertAgentActionListHtml(html: string): void {
- assert.match(html, /class="timeline-scroll"/);
- assert.match(html, /\.timeline-scroll\s*\{/);
- assert.match(html, /class="step-title">Tap login<\/div>/);
- assert.match(html, /\.step-button\.is-selected \.step-expanded\s*\{/);
- assert.match(html, /class="step-reasoning-copy">The login CTA is the fastest way to reach the authenticated screen\.<\/div>/);
- assert.doesNotMatch(html, /class="step-reason"/);
- assert.doesNotMatch(html, /class="step-meta"/);
- assert.doesNotMatch(html, />Grounding<\/div>/);
-}
-
-function assertCompactRunContextHtml(html: string): void {
- assert.match(html, /class="run-context-summary"/);
- assert.match(html, /class="context-summary-label">Environment<\/span>/);
- assert.match(html, /class="context-summary-label">Platform<\/span>/);
- assert.match(html, /class="context-summary-label">Model<\/span>/);
- assert.match(html, /class="context-summary-label">App<\/span>/);
- assert.doesNotMatch(html, /class="run-context-grid"/);
- assert.doesNotMatch(html, /class="context-card"/);
- assert.doesNotMatch(html, /Run Target<\/strong>/);
- assert.doesNotMatch(html, /Suite<\/strong>/);
- assert.doesNotMatch(html, /Selectors<\/strong>/);
- assert.doesNotMatch(html, /Variables<\/strong>/);
- assert.doesNotMatch(html, /Secrets<\/strong>/);
- assert.doesNotMatch(html, /Artifacts<\/strong>/);
- assert.doesNotMatch(html, />run\.json<\/a>/);
- assert.doesNotMatch(html, />summary\.json<\/a>/);
- assert.doesNotMatch(html, />runner\.log<\/a>/);
- assert.doesNotMatch(html, />run-context\.json<\/a>/);
-}
-
-test('renderRunIndexHtml renders the Flutter-style history table on the live CLI server path', () => {
- const html = renderRunIndexHtml(createRunIndexViewModel());
-
- assert.match(html, /Test Runs<\/h1>/);
- assert.match(html, /Run history/);
- assert.match(html, /Test Success Rate/);
- assert.match(html, /Triggered From/);
- assert.match(html, /login suite/);
- assert.match(html, /valid login \+2 more/);
- assert.match(html, /Local/);
- assert.match(html, /Suite/);
- assert.match(html, /Direct/);
- assert.match(html, /class="tinted-png-icon"/);
- assert.match(html, /background-color: #707EAE/);
- assert.match(html, / login suite<\/h1>/);
- assert.match(html, /id="suite-overview"/);
- assert.match(html, /Run summary/);
- assert.match(html, /Run Context/);
- assert.match(html, /Executed tests/);
- assert.match(html, /Tests passed/);
- assert.match(html, /Not Executed/);
- assert.match(html, /selectTest\('checkout'\)/);
- assert.match(html, /data-test-panel="login"/);
- assert.match(html, /data-test-panel="checkout"/);
- assert.match(html, /id="primary-back-button"/);
- assert.match(html, /handlePrimaryBack\(event\)/);
- assert.match(html, /data-role="recording-seekbar"/);
- assert.match(html, /data-role="recording-playpause"/);
- assert.doesNotMatch(html, /data-role="recording-fullscreen"/);
- assert.match(html, /dev<\/div>/);
- assert.match(html, /class="context-summary-value">android<\/div>/);
- assert.match(html, /class="context-summary-value">openai\/gpt-5.4-mini<\/div>/);
- assert.match(html, /class="context-summary-value">repo app<\/div>/);
- assert.match(html, /name: valid login/);
- assert.match(html, /name: guest checkout/);
- assertTestDetailSectionOrder(html, 'login');
- assertTestDetailSectionOrder(html, 'checkout');
- assertCompactRunContextHtml(html);
- assert.match(html, /class="tinted-png-icon"/);
- assertSimplifiedTestDetailHtml(html);
- assertAgentActionListHtml(html);
-});
-
-test('renderHtmlReport opens directly into the single-test layout for one-test direct runs', () => {
- const html = renderHtmlReport(createSingleTestManifest());
-
- assert.match(html, /valid login<\/h1>/);
- assert.doesNotMatch(html, /id="suite-overview"/);
- assert.doesNotMatch(html, /Executed tests/);
- assert.doesNotMatch(html, /class="overview-grid"/);
- assert.match(html, /name: valid login/);
- assert.match(html, /id="report-back-button"/);
- assert.match(html, /\/artifacts\/2026-03-24T20-00-00\.000Z-dev-android\/tests\/login\/recording\.mp4/);
- assertTestDetailSectionOrder(html, 'login');
- assert.equal((html.match(/Run history/g) || []).length, 1);
- assertCompactRunContextHtml(html);
- assertSimplifiedTestDetailHtml(html);
- assertAgentActionListHtml(html);
-});
-
-test('renderHtmlReport renders compact recording empty states without reintroducing debug panels', () => {
- const noRecordingManifest = createSingleTestManifest();
- noRecordingManifest.tests[0] = {
- ...noRecordingManifest.tests[0],
- recordingFile: undefined,
- };
-
- const noRecordingHtml = renderHtmlReport(noRecordingManifest);
- assert.match(noRecordingHtml, /No session recording was captured for this test\./);
- assertSimplifiedTestDetailHtml(noRecordingHtml);
-
- const noActionsManifest = createSingleTestManifest();
- noActionsManifest.tests[0] = {
- ...noActionsManifest.tests[0],
- steps: [],
- };
-
- const noActionsHtml = renderHtmlReport(noActionsManifest);
- assert.match(noActionsHtml, /No steps were recorded for this test\./);
- assert.match(noActionsHtml, /No recorded actions are available for this test\./);
- assertSimplifiedTestDetailHtml(noActionsHtml);
-});
-
-test('renderHtmlReport surfaces the no-synced-timestamp caption when steps lack video offsets', () => {
- const manifest = createSingleTestManifest();
- manifest.tests[0] = {
- ...manifest.tests[0],
- steps: manifest.tests[0].steps.map((step) => ({
- ...step,
- videoOffsetMs: undefined,
- })),
- };
-
- const html = renderHtmlReport(manifest);
- assert.match(html, /No synced recording timestamp is available for the selected step\./);
- assertSimplifiedTestDetailHtml(html);
-});
-
-test('report templates render aborted runs and tests with explicit aborted badges', () => {
- const historyViewModel = createRunIndexViewModel();
- historyViewModel.runs[0] = {
- ...historyViewModel.runs[0],
- success: false,
- status: 'aborted',
- };
- const historyHtml = renderRunIndexHtml(historyViewModel);
- assert.match(historyHtml, /class="status-pill aborted">Aborted<\/span>/);
-
- const manifest = createSuiteRunManifest();
- manifest.run.status = 'aborted';
- manifest.tests[0] = {
- ...manifest.tests[0],
- status: 'aborted',
- message: 'Goal execution was aborted',
- analysis: 'The user aborted the run from the CLI.',
- };
-
- const html = renderHtmlReport(manifest);
- assert.match(html, /class="status-pill aborted">Aborted<\/span>/);
- assert.match(html, /The user aborted the run from the CLI\./);
-});
-
-test('buildReportIndexViewModel derives display metadata for the actual CLI-served history page', async () => {
- const artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'finalrun-cli-report-'));
-
- try {
- const index: RunIndex = {
- schemaVersion: 1,
- generatedAt: '2026-03-24T18:00:00.000Z',
- runs: [
- {
- runId: 'suite-run',
- success: false,
- status: 'failure',
- startedAt: '2026-03-24T18:00:00.000Z',
- completedAt: '2026-03-24T18:00:10.000Z',
- durationMs: 10000,
- envName: 'dev',
- platform: 'android',
- modelLabel: 'openai/gpt-5.4-mini',
- appLabel: 'repo app',
- target: {
- type: 'suite',
- suiteId: 'smoke',
- suiteName: 'Smoke Suite',
- suitePath: 'smoke.yaml',
- },
- testCount: 2,
- passedCount: 1,
- failedCount: 1,
- stepCount: 4,
- paths: {
- runJson: 'suite-run/run.json',
- log: 'suite-run/runner.log',
- },
- },
- {
- runId: 'direct-run',
- success: true,
- status: 'success',
- startedAt: '2026-03-24T19:00:00.000Z',
- completedAt: '2026-03-24T19:00:12.000Z',
- durationMs: 12000,
- envName: 'dev',
- platform: 'android',
- modelLabel: 'openai/gpt-5.4-mini',
- appLabel: 'repo app',
- target: {
- type: 'direct',
- },
- testCount: 3,
- passedCount: 3,
- failedCount: 0,
- stepCount: 6,
- paths: {
- runJson: 'direct-run/run.json',
- log: 'direct-run/runner.log',
- },
- },
- {
- runId: 'early-failure-run',
- success: false,
- status: 'failure',
- startedAt: '2026-03-24T20:00:00.000Z',
- completedAt: '2026-03-24T20:00:02.000Z',
- durationMs: 2000,
- envName: 'dev',
- platform: 'android',
- modelLabel: 'openai/gpt-5.4-mini',
- appLabel: 'repo app',
- target: {
- type: 'direct',
- },
- testCount: 0,
- passedCount: 0,
- failedCount: 0,
- stepCount: 0,
- paths: {
- runJson: 'early-failure-run/run.json',
- log: 'early-failure-run/runner.log',
- },
- },
- ],
- };
-
- await writeRunManifest(artifactsDir, {
- runId: 'suite-run',
- target: {
- type: 'suite',
- suiteId: 'smoke',
- suiteName: 'Smoke Suite',
- suitePath: 'smoke.yaml',
- },
- selectedTests: [
- { testId: 'login', testName: 'Valid login', relativePath: 'login/valid_login.yaml' },
- { testId: 'checkout', testName: 'Guest checkout', relativePath: 'checkout/guest_checkout.yaml' },
- ],
- suite: {
- suiteId: 'smoke',
- name: 'Smoke Suite',
- workspaceSourcePath: '.finalrun/suites/smoke.yaml',
- snapshotYamlPath: 'input/suite.snapshot.yaml',
- snapshotJsonPath: 'input/suite.json',
- tests: ['login/valid_login.yaml', 'checkout/guest_checkout.yaml'],
- resolvedTestIds: ['login', 'checkout'],
- },
- });
-
- await writeRunManifest(artifactsDir, {
- runId: 'direct-run',
- target: {
- type: 'direct',
- },
- selectedTests: [
- { testId: 'login', testName: 'Valid login', relativePath: 'login/valid_login.yaml' },
- { testId: 'signup', testName: 'Valid signup', relativePath: 'auth/valid_signup.yaml' },
- { testId: 'logout', testName: 'Logout', relativePath: 'auth/logout.yaml' },
- ],
- });
-
- const viewModel = await buildReportIndexViewModel(index, artifactsDir);
-
- assert.equal(viewModel.summary.totalRuns, 3);
- assert.equal(viewModel.summary.totalDurationMs, 24000);
- assert.ok(Math.abs(viewModel.summary.totalSuccessRate - 100 / 3) < 1e-9);
-
- assert.deepEqual(
- viewModel.runs.map((run) => ({
- runId: run.runId,
- displayName: run.displayName,
- displayKind: run.displayKind,
- triggeredFrom: run.triggeredFrom,
- selectedTestCount: run.selectedTestCount,
- runJson: run.paths.runJson,
- })),
- [
- {
- runId: 'suite-run',
- displayName: 'Smoke Suite',
- displayKind: 'suite',
- triggeredFrom: 'Suite',
- selectedTestCount: 2,
- runJson: '/artifacts/suite-run/run.json',
- },
- {
- runId: 'direct-run',
- displayName: 'Valid login +2 more',
- displayKind: 'multi_test',
- triggeredFrom: 'Direct',
- selectedTestCount: 3,
- runJson: '/artifacts/direct-run/run.json',
- },
- {
- runId: 'early-failure-run',
- displayName: 'early-failure-run',
- displayKind: 'fallback',
- triggeredFrom: 'Direct',
- selectedTestCount: 0,
- runJson: '/artifacts/early-failure-run/run.json',
- },
- ],
- );
- } finally {
- await fsp.rm(artifactsDir, { recursive: true, force: true });
- }
-});
-
-test('buildReportRunManifestViewModel inlines snapshot YAML text and scopes snapshot artifact paths', async () => {
- const artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'finalrun-cli-report-run-'));
-
- try {
- await writeRunManifest(artifactsDir, {
- runId: 'yaml-run',
- target: {
- type: 'direct',
- },
- selectedTests: [
- { testId: 'login', testName: 'Valid login', relativePath: 'login/valid_login.yaml' },
- ],
- });
- await fsp.mkdir(path.join(artifactsDir, 'yaml-run', 'input', 'tests'), { recursive: true });
- await fsp.writeFile(
- path.join(artifactsDir, 'yaml-run', 'input', 'tests', 'login.yaml'),
- ['name: valid login', 'steps:', ' - Tap login'].join('\n'),
- 'utf-8',
- );
-
- const rawManifest = JSON.parse(
- await fsp.readFile(path.join(artifactsDir, 'yaml-run', 'run.json'), 'utf-8'),
- ) as RunManifest;
- const viewModel = await buildReportRunManifestViewModel(rawManifest, artifactsDir);
-
- assert.equal(viewModel.input.tests[0]?.snapshotYamlPath, '/artifacts/yaml-run/input/tests/login.yaml');
- assert.equal(viewModel.input.tests[0]?.snapshotYamlText, ['name: valid login', 'steps:', ' - Tap login'].join('\n'));
- assert.equal(viewModel.paths.runJson, '/artifacts/yaml-run/run.json');
- } finally {
- await fsp.rm(artifactsDir, { recursive: true, force: true });
- }
-});
-
-
-async function writeRunManifest(
- artifactsDir: string,
- params: {
- runId: string;
- target: {
- type: 'direct' | 'suite';
- suiteId?: string;
- suiteName?: string;
- suitePath?: string;
- };
- selectedTests: Array<{
- testId: string;
- testName: string;
- relativePath: string;
- }>;
- suite?: {
- suiteId: string;
- name: string;
- workspaceSourcePath: string;
- snapshotYamlPath: string;
- snapshotJsonPath: string;
- tests: string[];
- resolvedTestIds: string[];
- };
- },
-): Promise {
- await fsp.mkdir(path.join(artifactsDir, params.runId), { recursive: true });
- await fsp.writeFile(
- path.join(artifactsDir, params.runId, 'run.json'),
- JSON.stringify({
- schemaVersion: 2,
- run: {
- runId: params.runId,
- success: params.target.type === 'direct',
- status: params.target.type === 'direct' ? 'success' : 'failure',
- startedAt: '2026-03-24T18:00:00.000Z',
- completedAt: '2026-03-24T18:00:10.000Z',
- durationMs: 10000,
- envName: 'dev',
- platform: 'android',
- model: {
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
- label: 'openai/gpt-5.4-mini',
- },
- app: {
- source: 'repo',
- label: 'repo app',
- },
- selectors: params.target.type === 'direct'
- ? params.selectedTests.map((t) => t.relativePath)
- : [],
- target: params.target,
- counts: {
- tests: {
- total: params.selectedTests.length,
- passed: params.target.type === 'direct' ? params.selectedTests.length : 0,
- failed: params.target.type === 'direct' ? 0 : 1,
- },
- steps: {
- total: 0,
- passed: 0,
- failed: 0,
- },
- },
- },
- input: {
- environment: {
- envName: 'dev',
- variables: {},
- secretReferences: [],
- },
- suite: params.suite,
- tests: params.selectedTests.map((t) => ({
- ...t,
- name: t.testName,
- workspaceSourcePath: `.finalrun/tests/${t.relativePath}`,
- snapshotYamlPath: `input/tests/${t.testId}.yaml`,
- snapshotJsonPath: `input/tests/${t.testId}.json`,
- bindingReferences: {
- variables: [],
- secrets: [],
- },
- })),
- cli: {
- command: params.target.type === 'suite'
- ? `finalrun suite ${params.target.suitePath || 'suite.yaml'}`
- : `finalrun test ${params.selectedTests.map((t) => t.relativePath).join(' ')}`,
- selectors: params.target.type === 'direct'
- ? params.selectedTests.map((t) => t.relativePath)
- : [],
- suitePath: params.target.type === 'suite' ? params.target.suitePath : undefined,
- debug: false,
- },
- },
- tests: [],
- paths: {
- runJson: 'run.json',
- summaryJson: 'summary.json',
- log: 'runner.log',
- },
- }, null, 2),
- 'utf-8',
- );
-}
diff --git a/packages/cli/src/reportViewModel.ts b/packages/cli/src/reportViewModel.ts
new file mode 100644
index 0000000..ce2e842
--- /dev/null
+++ b/packages/cli/src/reportViewModel.ts
@@ -0,0 +1,234 @@
+// View-model loaders for the local report.
+//
+// Returns objects whose artifact paths stay workspace-relative (NOT rewritten
+// to HTTP URLs). The React components in @finalrun/report-web/ui do URL
+// rewriting via their own buildArtifactRoute() helper, so the JSON served on
+// /api/report/* matches exactly what the components expect.
+//
+// Type shapes mirror packages/report-web/src/artifacts.ts. Keep them aligned
+// if either side changes.
+import * as fsp from 'node:fs/promises';
+import * as path from 'node:path';
+import type {
+ RunIndex,
+ RunIndexEntry,
+ RunManifest,
+ TestDefinition,
+ TestResult,
+} from '@finalrun/common';
+import { loadRunIndex } from './runIndex.js';
+
+const MISSING_WORKSPACE_CONFIG_ERROR =
+ 'The FinalRun report server is missing workspace configuration. Start it with `finalrun start-server`.';
+
+export interface ReportWorkspaceContext {
+ workspaceRoot: string;
+ artifactsDir: string;
+}
+
+export interface ReportIndexRunRecord extends RunIndexEntry {
+ displayName: string;
+ displayKind: 'suite' | 'single_test' | 'multi_test' | 'fallback';
+ triggeredFrom: 'Suite' | 'Direct';
+ selectedTestCount: number;
+}
+
+export interface ReportIndexViewModel {
+ generatedAt: string;
+ summary: {
+ totalRuns: number;
+ totalSuccessRate: number;
+ totalDurationMs: number;
+ };
+ runs: ReportIndexRunRecord[];
+}
+
+export interface ReportManifestSelectedTestRecord extends TestDefinition {
+ snapshotYamlText?: string;
+}
+
+export interface ReportManifestTestRecord extends TestResult {
+ snapshotYamlText?: string;
+ deviceLogTailText?: string;
+}
+
+export interface ReportRunManifest extends Omit {
+ input: Omit & {
+ tests: ReportManifestSelectedTestRecord[];
+ };
+ tests: ReportManifestTestRecord[];
+}
+
+export function resolveReportWorkspaceContext(): ReportWorkspaceContext {
+ const workspaceRoot = process.env.FINALRUN_REPORT_WORKSPACE_ROOT;
+ const artifactsDir = process.env.FINALRUN_REPORT_ARTIFACTS_DIR;
+ if (!workspaceRoot || !artifactsDir) {
+ throw new Error(MISSING_WORKSPACE_CONFIG_ERROR);
+ }
+ return { workspaceRoot, artifactsDir };
+}
+
+export async function loadReportIndexViewModel(
+ context: ReportWorkspaceContext = resolveReportWorkspaceContext(),
+): Promise {
+ const index: RunIndex = await loadRunIndex(context.artifactsDir);
+ const runs = await Promise.all(
+ index.runs.map((run) => enrichRunIndexEntry(run, context)),
+ );
+ const passedRuns = runs.filter((run) => run.success).length;
+
+ return {
+ generatedAt: index.generatedAt,
+ summary: {
+ totalRuns: runs.length,
+ totalSuccessRate: runs.length === 0 ? 0 : (passedRuns / runs.length) * 100,
+ totalDurationMs: runs.reduce((total, run) => total + Number(run.durationMs || 0), 0),
+ },
+ runs,
+ };
+}
+
+export async function loadRunManifestRecord(
+ runId: string,
+ context: ReportWorkspaceContext = resolveReportWorkspaceContext(),
+): Promise {
+ const runJsonPath = path.join(context.artifactsDir, runId, 'run.json');
+ const raw = await fsp.readFile(runJsonPath, 'utf-8');
+ const parsed = JSON.parse(raw) as RunManifest;
+ if (parsed.schemaVersion !== 2 && parsed.schemaVersion !== 3) {
+ throw new Error(`Unsupported schema version: ${parsed.schemaVersion}`);
+ }
+ return parsed;
+}
+
+export async function loadReportRunManifestViewModel(
+ runId: string,
+ context: ReportWorkspaceContext = resolveReportWorkspaceContext(),
+): Promise {
+ return enrichRunManifestRecord(await loadRunManifestRecord(runId, context), context);
+}
+
+async function enrichRunManifestRecord(
+ manifest: RunManifest,
+ context: ReportWorkspaceContext,
+): Promise {
+ const runId = manifest.run.runId;
+ const snapshotCache = new Map>();
+ const readSnapshotYamlText = async (snapshotYamlPath: string | undefined): Promise => {
+ if (!snapshotYamlPath) return undefined;
+ let cached = snapshotCache.get(snapshotYamlPath);
+ if (!cached) {
+ cached = readRunArtifactText(context, runId, snapshotYamlPath);
+ snapshotCache.set(snapshotYamlPath, cached);
+ }
+ return cached;
+ };
+
+ const readDeviceLogTail = async (deviceLogPath: string | undefined): Promise => {
+ if (!deviceLogPath) return undefined;
+ const content = await readRunArtifactText(context, runId, deviceLogPath);
+ if (!content) return undefined;
+ const lines = content.split('\n');
+ const maxLines = 500;
+ if (lines.length > maxLines) {
+ return `[… ${lines.length - maxLines} lines truncated]\n${lines.slice(-maxLines).join('\n')}`;
+ }
+ return content;
+ };
+
+ return {
+ ...manifest,
+ input: {
+ ...manifest.input,
+ tests: await Promise.all(
+ manifest.input.tests.map(async (t) => ({
+ ...t,
+ snapshotYamlText: await readSnapshotYamlText(t.snapshotYamlPath),
+ })),
+ ),
+ },
+ tests: await Promise.all(
+ manifest.tests.map(async (t) => ({
+ ...t,
+ snapshotYamlText: await readSnapshotYamlText(t.snapshotYamlPath),
+ deviceLogTailText: await readDeviceLogTail(t.deviceLogFile),
+ })),
+ ),
+ };
+}
+
+async function readRunArtifactText(
+ context: ReportWorkspaceContext,
+ runId: string,
+ artifactPath: string,
+): Promise {
+ const normalizedPath = normalizeRunArtifactPath(runId, artifactPath);
+ if (!normalizedPath) return undefined;
+
+ try {
+ return await fsp.readFile(path.join(context.artifactsDir, runId, normalizedPath), 'utf-8');
+ } catch {
+ return undefined;
+ }
+}
+
+function normalizeRunArtifactPath(runId: string, artifactPath: string): string | undefined {
+ const normalized = artifactPath.replace(/\\/g, '/').replace(/^\/+/, '');
+ if (normalized.length === 0) return undefined;
+ if (!normalized.startsWith('artifacts/')) return normalized;
+
+ const withoutArtifactsPrefix = normalized.slice('artifacts/'.length);
+ if (withoutArtifactsPrefix.startsWith(`${runId}/`)) {
+ return withoutArtifactsPrefix.slice(runId.length + 1);
+ }
+ return undefined;
+}
+
+async function enrichRunIndexEntry(
+ run: RunIndexEntry,
+ context: ReportWorkspaceContext,
+): Promise {
+ const manifest = await loadRunManifestRecord(run.runId, context).catch(() => null);
+ const selectedTests = manifest?.input.tests ?? [];
+
+ return {
+ ...run,
+ displayName: deriveRunDisplayName(run, manifest),
+ displayKind: deriveRunDisplayKind(run, manifest),
+ triggeredFrom: run.target?.type === 'suite' ? 'Suite' : 'Direct',
+ selectedTestCount: selectedTests.length > 0 ? selectedTests.length : run.testCount,
+ };
+}
+
+function deriveRunDisplayName(
+ run: RunIndexEntry,
+ manifest: RunManifest | null,
+): string {
+ if (run.target?.type === 'suite' && run.target.suiteName) {
+ return run.target.suiteName;
+ }
+
+ const selectedTests = manifest?.input.tests ?? [];
+ if (selectedTests.length === 1) {
+ return selectedTests[0]?.name || selectedTests[0]?.relativePath || run.runId;
+ }
+ if (selectedTests.length > 1) {
+ const firstLabel =
+ selectedTests[0]?.name || selectedTests[0]?.relativePath || 'Selected tests';
+ return `${firstLabel} +${selectedTests.length - 1} more`;
+ }
+
+ return run.runId;
+}
+
+function deriveRunDisplayKind(
+ run: RunIndexEntry,
+ manifest: RunManifest | null,
+): ReportIndexRunRecord['displayKind'] {
+ if (run.target?.type === 'suite') return 'suite';
+
+ const selectedCount = manifest?.input.tests?.length ?? run.testCount;
+ if (selectedCount === 1) return 'single_test';
+ if (selectedCount > 1) return 'multi_test';
+ return 'fallback';
+}
diff --git a/packages/cli/src/reportWriter.ts b/packages/cli/src/reportWriter.ts
index e019cbb..80d386f 100644
--- a/packages/cli/src/reportWriter.ts
+++ b/packages/cli/src/reportWriter.ts
@@ -3,24 +3,26 @@ import * as fsp from 'node:fs/promises';
import * as path from 'node:path';
import YAML from 'yaml';
import {
+ type AgentAction,
type BindingReference,
+ type EnvironmentRecord,
type FailurePhase,
- Logger,
- type TestDefinition,
- type SuiteDefinition,
+ type FeatureOverrides,
+ type FirstFailure,
type LogEntry,
+ Logger,
type LoggerSink,
- type RunManifestAppRecord,
- type EnvironmentRecord,
- type FirstFailure,
+ type ReasoningLevel,
type RunManifest,
- type TestResult,
- type AgentAction,
+ type RunManifestAppRecord,
type RunStatus,
- type TestStatus,
- type RunTarget,
type RunSummary,
+ type RunTarget,
type RuntimeBindings,
+ type SuiteDefinition,
+ type TestDefinition,
+ type TestResult,
+ type TestStatus,
redactResolvedValue,
} from '@finalrun/common';
import type { TestExecutionResult, AgentActionResult } from '@finalrun/goal-executor';
@@ -160,6 +162,8 @@ export class ReportWriter {
target: RunTarget;
cli: { command: string; selectors: string[]; debug: boolean; [key: string]: unknown };
model: { provider: string; modelName: string; label: string };
+ reasoning?: ReasoningLevel;
+ features?: FeatureOverrides;
app: RunManifestAppRecord;
}): Promise {
const inputDir = path.join(this._runDir, 'input');
@@ -179,6 +183,8 @@ export class ReportWriter {
{
cli: params.cli,
model: params.model,
+ ...(params.reasoning !== undefined ? { reasoning: params.reasoning } : {}),
+ ...(params.features !== undefined ? { features: params.features } : {}),
app: params.app,
target: params.target,
},
diff --git a/packages/cli/src/runtimePaths.ts b/packages/cli/src/runtimePaths.ts
index f14ddd5..4023ffc 100644
--- a/packages/cli/src/runtimePaths.ts
+++ b/packages/cli/src/runtimePaths.ts
@@ -2,6 +2,17 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
+// Read the CLI version from the package.json that sits next to this source
+// file. Under tsc-Node16 the file compiles to CJS so `require` is the global
+// loader; under Bun's compile pipeline the JSON is bundled into the
+// executable. Either way the version is available without walking the
+// build-machine __dirname at runtime (which doesn't exist on the deploy
+// machine for Bun-compiled binaries).
+//
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const cliPackageJson: { version?: string } = require('../package.json');
+const BUNDLED_CLI_VERSION: string = cliPackageJson.version ?? '0.0.0';
+
interface FinalRunPackageJson {
name?: string;
version?: string;
@@ -43,10 +54,12 @@ export function resolveCliPackageRoot(startDir: string = __dirname): string {
return findCliPackageRoot(startDir);
}
-export function resolveCliPackageVersion(startDir: string = __dirname): string {
- const packageJsonPath = path.join(resolveCliPackageRoot(startDir), 'package.json');
- const packageJson = readJsonFile(packageJsonPath);
- return packageJson?.version ?? '0.0.0';
+export function resolveCliPackageVersion(_startDir: string = __dirname): string {
+ // Always return the version inlined at build time. We previously walked up
+ // from __dirname looking for a package.json, but in a Bun-compiled binary
+ // __dirname is the source path on the build machine and doesn't exist on
+ // the deploy machine, causing a fatal startup error.
+ return BUNDLED_CLI_VERSION;
}
export function resolveFinalRunRootDir(): string {
@@ -97,17 +110,64 @@ export function resolveCliLaunchArgs(
}
export function initializeCliRuntimeEnvironment(startDir: string = __dirname): void {
+ // Look for the local-runtime tarball install location first. When the CLI
+ // is running as a Bun-compiled binary, all on-disk assets (driver APKs,
+ // gRPC proto, Vite SPA dist) live there rather than next to the binary.
+ const runtimeRoot = resolveLocalRuntimeRoot();
+
if (!process.env['FINALRUN_DRIVER_PROTO_PATH']) {
- const packageRoot = resolveCliPackageRoot(startDir);
- const candidates = [
- path.join(packageRoot, 'proto', 'finalrun', 'driver.proto'),
- path.resolve(packageRoot, '../../proto/finalrun/driver.proto'),
- path.resolve(packageRoot, '../proto/finalrun/driver.proto'),
- ];
+ const candidates: string[] = [];
+ if (runtimeRoot) {
+ candidates.push(path.join(runtimeRoot, 'proto', 'finalrun', 'driver.proto'));
+ }
+ try {
+ const packageRoot = resolveCliPackageRoot(startDir);
+ candidates.push(
+ path.join(packageRoot, 'proto', 'finalrun', 'driver.proto'),
+ path.resolve(packageRoot, '../../proto/finalrun/driver.proto'),
+ path.resolve(packageRoot, '../proto/finalrun/driver.proto'),
+ );
+ } catch {
+ // No CLI package root resolvable — happens inside Bun-compiled binaries.
+ // Fall back to runtime-tarball location only.
+ }
const resolvedProtoPath = candidates.find((candidate) => fs.existsSync(candidate));
if (resolvedProtoPath) {
process.env['FINALRUN_DRIVER_PROTO_PATH'] = resolvedProtoPath;
}
}
+
+ if (!process.env['FINALRUN_ASSET_DIR'] && runtimeRoot) {
+ const installResources = path.join(runtimeRoot, 'install-resources');
+ if (fs.existsSync(installResources)) {
+ process.env['FINALRUN_ASSET_DIR'] = installResources;
+ }
+ }
+
+ if (!process.env['FINALRUN_REPORT_APP_DIR'] && runtimeRoot) {
+ const reportApp = path.join(runtimeRoot, 'report-app');
+ if (fs.existsSync(reportApp)) {
+ process.env['FINALRUN_REPORT_APP_DIR'] = reportApp;
+ }
+ }
+}
+
+function resolveLocalRuntimeRoot(): string | undefined {
+ const override = process.env['FINALRUN_RUNTIME_ROOT'];
+ if (override && override.trim()) {
+ const candidate = path.resolve(override.trim());
+ if (fs.existsSync(path.join(candidate, 'manifest.json'))) {
+ return candidate;
+ }
+ }
+ // Honor FINALRUN_DIR so the binary finds the runtime when the user
+ // installed via `FINALRUN_DIR=... bash install.sh` to a custom location.
+ const finalrunDir =
+ process.env['FINALRUN_DIR']?.trim() || path.join(os.homedir(), '.finalrun');
+ const versioned = path.join(finalrunDir, 'runtime', resolveCliPackageVersion());
+ if (fs.existsSync(path.join(versioned, 'manifest.json'))) {
+ return versioned;
+ }
+ return undefined;
}
diff --git a/packages/cli/src/sessionRunner.ts b/packages/cli/src/sessionRunner.ts
index e4c3fef..a9a29fb 100644
--- a/packages/cli/src/sessionRunner.ts
+++ b/packages/cli/src/sessionRunner.ts
@@ -11,6 +11,8 @@ import {
RecordingRequest,
type DeviceInventoryDiagnostic,
type DeviceInventoryEntry,
+ type FeatureOverrides,
+ type ModelDefaults,
type RuntimeBindings,
} from '@finalrun/common';
import { DeviceNode } from '@finalrun/device-node';
@@ -56,9 +58,9 @@ type GoalRunnerExecutor = Pick<
export interface TestSessionConfig {
goal: string;
- apiKey: string;
- provider: string; // 'openai' | 'google' | 'anthropic'
- modelName: string; // e.g., 'gpt-5.4-mini', 'gemini-2.0-flash'
+ apiKeys: Record;
+ defaults: ModelDefaults;
+ features?: FeatureOverrides;
maxIterations?: number;
debug?: boolean;
platform?: string;
@@ -307,9 +309,9 @@ export async function executeTestOnSession(
try {
const aiAgent = dependencies.createAiAgent({
- provider: config.provider,
- modelName: config.modelName,
- apiKey: config.apiKey,
+ apiKeys: config.apiKeys,
+ defaults: config.defaults,
+ features: config.features,
});
const executor = dependencies.createExecutor({
@@ -345,7 +347,7 @@ export async function executeTestOnSession(
new RecordingRequest({
runId: config.recording.runId,
testId: config.recording.testId,
- apiKey: config.apiKey,
+ apiKey: config.apiKeys[config.defaults.provider] ?? '',
outputFilePath: config.recording.outputFilePath,
}),
);
@@ -693,7 +695,22 @@ function printRunBanner(config: TestSessionConfig): void {
console.log('\n\x1b[1mFinalRun CLI\x1b[0m');
console.log('─'.repeat(50));
console.log(`Goal: ${config.goal}`);
- console.log(`Model: ${config.provider}/${config.modelName}`);
+ const defaultReasoning = config.defaults.reasoning ? ` (${config.defaults.reasoning})` : '';
+ console.log(`Model: ${config.defaults.provider}/${config.defaults.modelName}${defaultReasoning}`);
+ if (config.features) {
+ const overrides = Object.entries(config.features)
+ .filter(([, override]) => override && (override.model || override.reasoning))
+ .map(([feature, override]) => {
+ const parts: string[] = [];
+ if (override!.model) parts.push(override!.model);
+ if (override!.reasoning) parts.push(override!.reasoning);
+ return ` ${feature}: ${parts.join(' ')}`;
+ });
+ if (overrides.length > 0) {
+ console.log('Feature overrides:');
+ for (const line of overrides) console.log(line);
+ }
+ }
console.log('─'.repeat(50) + '\n');
}
diff --git a/packages/cli/src/testRunner.test.ts b/packages/cli/src/testRunner.test.ts
index 279b8de..795797d 100644
--- a/packages/cli/src/testRunner.test.ts
+++ b/packages/cli/src/testRunner.test.ts
@@ -631,9 +631,8 @@ test('runTests finalizes top-level artifacts when shared-session execution throw
envName: 'dev',
cwd: rootDir,
selectors: ['login.yaml'],
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
});
assert.equal(result.success, false);
@@ -719,9 +718,8 @@ test('runTests succeeds without env config when the repo is env-free', async ()
const result = await runTests({
cwd: rootDir,
selectors: ['smoke.yaml'],
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
});
assert.equal(result.success, true);
@@ -779,9 +777,8 @@ test('runTests records the suite subcommand in run metadata when invoked via fin
const result = await runTests({
cwd: rootDir,
suitePath: 'smoke.yaml',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
invokedCommand: 'suite',
});
@@ -846,9 +843,8 @@ test('runTests prepares one shared session for multiple tests and cleans it up o
envName: 'dev',
cwd: rootDir,
selectors: ['login.yaml', 'search.yaml'],
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
});
assert.equal(result.success, true);
@@ -908,9 +904,8 @@ test('runTests uses mov artifact recording output paths for iOS tests', async ()
cwd: rootDir,
selectors: ['login.yaml'],
platform: 'ios',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
});
assert.equal(result.success, true);
@@ -977,9 +972,8 @@ test('runTests stops the batch after a shared-session failure and cleans up once
envName: 'dev',
cwd: rootDir,
selectors: ['first.yaml', 'second.yaml', 'third.yaml'],
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
});
assert.equal(result.success, false);
@@ -1060,9 +1054,8 @@ test('runTests stops remaining tests after a terminal AI provider failure', asyn
envName: 'dev',
cwd: rootDir,
selectors: ['first.yaml', 'second.yaml', 'third.yaml'],
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
});
assert.equal(result.success, false);
@@ -1176,9 +1169,8 @@ test('runTests aborts the batch after SIGINT and marks the active run as aborted
envName: 'dev',
cwd: rootDir,
selectors: ['first.yaml', 'second.yaml', 'third.yaml'],
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
});
assert.equal(result.success, false);
@@ -1268,9 +1260,8 @@ test('runTests requests a forced exit after a second SIGINT', async () => {
envName: 'dev',
cwd: rootDir,
selectors: ['first.yaml'],
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
});
assert.equal(forcedExitCode, 130);
@@ -1310,9 +1301,8 @@ test('runTests requires base app config even when the env file contains an app o
envName: 'dev',
cwd: rootDir,
selectors: ['login.yaml'],
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
}),
(error: unknown) => {
assert.ok(error instanceof PreExecutionFailureError);
@@ -1350,9 +1340,8 @@ test('runTests rejects validation failures before creating run artifacts', async
runTests({
envName: 'dev',
cwd: rootDir,
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
}),
(error: unknown) => {
assert.ok(error instanceof PreExecutionFailureError);
@@ -1409,9 +1398,8 @@ test('runTests surfaces device setup diagnostics before execution without creati
envName: 'dev',
cwd: rootDir,
selectors: ['login.yaml'],
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
}),
(error: unknown) => {
assert.ok(error instanceof PreExecutionFailureError);
@@ -1472,9 +1460,8 @@ test('runTests fails before prepareGoalSession when Android host preflight is bl
cwd: rootDir,
selectors: ['login.yaml'],
platform: 'android',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
}),
(error: unknown) => {
assert.ok(error instanceof PreExecutionFailureError);
@@ -1535,9 +1522,8 @@ test('runTests fails before prepareGoalSession when iOS host preflight is blocke
cwd: rootDir,
selectors: ['login.yaml'],
platform: 'ios',
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
}),
(error: unknown) => {
assert.ok(error instanceof PreExecutionFailureError);
@@ -1609,9 +1595,8 @@ test('runTests continues when one platform is healthy and the other is blocked',
envName: 'dev',
cwd: rootDir,
selectors: ['login.yaml'],
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
});
assert.equal(result.success, true);
@@ -1674,9 +1659,8 @@ test('runTests requires --platform when both Android and iOS apps are configured
envName: 'dev',
cwd: rootDir,
selectors: ['login.yaml'],
- apiKey: 'test-key',
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
+ apiKeys: { openai: 'test-key' },
+ defaults: { provider: 'openai', modelName: 'gpt-5.4-mini' },
}),
(error: unknown) => {
assert.ok(error instanceof PreExecutionFailureError);
diff --git a/packages/cli/src/testRunner.ts b/packages/cli/src/testRunner.ts
index 1e79d6d..bec54c0 100644
--- a/packages/cli/src/testRunner.ts
+++ b/packages/cli/src/testRunner.ts
@@ -4,7 +4,9 @@ import {
LogLevel,
type DeviceInfo,
type DeviceInventoryDiagnostic,
+ type FeatureOverrides,
type LogEntry,
+ type ModelDefaults,
type RunTarget,
type RuntimeBindings,
type TestResult,
@@ -40,9 +42,9 @@ import {
} from './workspace.js';
export interface TestRunnerOptions extends CheckRunnerOptions {
- apiKey: string;
- provider: string;
- modelName: string;
+ apiKeys: Record;
+ defaults: ModelDefaults;
+ features?: FeatureOverrides;
maxIterations?: number;
debug?: boolean;
invokedCommand?: 'test' | 'suite';
@@ -261,7 +263,11 @@ export async function runTests(options: TestRunnerOptions): Promise {
+ // Mode detection: if the user explicitly passed --ci, honor it. Otherwise,
+ // mirror the user's previous install footprint — if they don't have the
+ // runtime tarball installed today, they probably want a binary-only
+ // upgrade too. If they DO have the runtime tarball, we want the installer
+ // to refresh it (default, no --ci flag).
+ let useCiFlag = options.ci === true;
+ if (!useCiFlag && !hasInstalledRuntime()) {
+ useCiFlag = true;
+ }
+
+ const targetLabel = options.version ? `v${options.version}` : 'latest';
+ const modeLabel = useCiFlag ? 'binary-only (--ci)' : 'full setup';
+ console.log(`Upgrading finalrun to ${targetLabel} (${modeLabel})...`);
+ console.log('');
+
+ // Strip FINALRUN_* env vars from the inherited environment before spawning
+ // the installer. The current process may have been started with debugging
+ // overrides (FINALRUN_RUNTIME_ROOT, FINALRUN_ASSET_DIR, FINALRUN_CLOUD_URL,
+ // FINALRUN_CACHE_DIR, etc.) — those are runtime concerns for THIS binary
+ // and shouldn't influence where the installer puts the next version.
+ // FINALRUN_DIR is the one knob users intentionally pin install location
+ // with, so we preserve it. FINALRUN_VERSION we set explicitly when --version
+ // was passed; otherwise we drop it so the installer resolves "latest".
+ const preservedDir = process.env['FINALRUN_DIR'];
+ const env: NodeJS.ProcessEnv = {};
+ for (const [key, value] of Object.entries(process.env)) {
+ if (!key.startsWith('FINALRUN_')) {
+ env[key] = value;
+ }
+ }
+ if (preservedDir) env['FINALRUN_DIR'] = preservedDir;
+ if (options.version) env['FINALRUN_VERSION'] = options.version;
+
+ let shell: string;
+ let shellArgs: string[];
+
+ if (process.platform === 'win32') {
+ // PowerShell's `irm | iex` reads the script as an in-memory string and
+ // can't forward arguments to it (no equivalent of bash's `-s --`). The
+ // CI flag travels via env var instead — install.ps1 honors FINALRUN_-
+ // NON_INTERACTIVE the same way install.sh does.
+ if (useCiFlag) env['FINALRUN_NON_INTERACTIVE'] = '1';
+ shell = 'powershell.exe';
+ shellArgs = [
+ '-NoProfile',
+ '-NoLogo',
+ '-Command',
+ `irm ${INSTALL_PS1_URL} | iex`,
+ ];
+ } else {
+ // curl -fsSL | bash [-s -- --ci]
+ // Implemented as `bash -c` so the pipe stays correctly inside one shell.
+ const flagPart = useCiFlag ? ' -s -- --ci' : '';
+ shell = 'bash';
+ shellArgs = ['-c', `curl -fsSL ${INSTALL_SH_URL} | bash${flagPart}`];
+ }
+
+ await new Promise((resolve, reject) => {
+ const child = spawn(shell, shellArgs, {
+ stdio: 'inherit',
+ env,
+ });
+ child.on('error', (e) => reject(e));
+ child.on('exit', (code, signal) => {
+ if (signal) {
+ reject(new Error(`Installer terminated by signal ${signal}`));
+ return;
+ }
+ if (code === 0) {
+ resolve();
+ return;
+ }
+ reject(new Error(`Installer exited with code ${code}`));
+ });
+ });
+}
+
+function hasInstalledRuntime(): boolean {
+ const version = resolveCliPackageVersion();
+ const explicit = process.env['FINALRUN_RUNTIME_ROOT']?.trim();
+ if (explicit) {
+ return fs.existsSync(path.join(explicit, 'manifest.json'));
+ }
+ const finalrunDir = process.env['FINALRUN_DIR']?.trim() || path.join(os.homedir(), '.finalrun');
+ return fs.existsSync(path.join(finalrunDir, 'runtime', version, 'manifest.json'));
+}
diff --git a/packages/cli/src/workspace.test.ts b/packages/cli/src/workspace.test.ts
index 5398e34..821af65 100644
--- a/packages/cli/src/workspace.test.ts
+++ b/packages/cli/src/workspace.test.ts
@@ -581,7 +581,7 @@ test('runCheck rejects unknown keys in .finalrun/config.yaml', async () => {
try {
await assert.rejects(
() => runCheck({ cwd: rootDir }),
- /config\.yaml contains unsupported key "region"\. Supported keys: env, model, app\./,
+ /config\.yaml contains unsupported key "region"\. Supported keys: env, model, reasoning, features, app\./,
);
} finally {
await fsp.rm(rootDir, { recursive: true, force: true });
@@ -633,6 +633,87 @@ test('runCheck rejects empty env values in .finalrun/config.yaml', async () => {
}
});
+test('runCheck rejects invalid reasoning level in .finalrun/config.yaml', async () => {
+ const rootDir = createTempWorkspace({
+ configYaml: 'reasoning: extreme\n',
+ });
+
+ try {
+ await assert.rejects(
+ () => runCheck({ cwd: rootDir }),
+ /config\.yaml reasoning has invalid value "extreme"\. Allowed values: minimal, low, medium, high\./,
+ );
+ } finally {
+ await fsp.rm(rootDir, { recursive: true, force: true });
+ }
+});
+
+test('runCheck rejects unknown feature names in .finalrun/config.yaml', async () => {
+ const rootDir = createTempWorkspace({
+ configYaml: ['features:', ' plannerX:', ' reasoning: high'].join('\n'),
+ });
+
+ try {
+ await assert.rejects(
+ () => runCheck({ cwd: rootDir }),
+ /features contains unsupported key "plannerX"\. Supported keys: planner, grounder, visual-grounder, scroll-index-grounder, input-focus-grounder, launch-app-grounder, set-location-grounder\./,
+ );
+ } finally {
+ await fsp.rm(rootDir, { recursive: true, force: true });
+ }
+});
+
+test('runCheck rejects unknown inner keys in a features override', async () => {
+ const rootDir = createTempWorkspace({
+ configYaml: ['features:', ' planner:', ' temperature: 0.2'].join('\n'),
+ });
+
+ try {
+ await assert.rejects(
+ () => runCheck({ cwd: rootDir }),
+ /features\.planner contains unsupported key "temperature"\. Supported keys: model, reasoning\./,
+ );
+ } finally {
+ await fsp.rm(rootDir, { recursive: true, force: true });
+ }
+});
+
+test('runCheck rejects invalid reasoning in a features override', async () => {
+ const rootDir = createTempWorkspace({
+ configYaml: ['features:', ' planner:', ' reasoning: extreme'].join('\n'),
+ });
+
+ try {
+ await assert.rejects(
+ () => runCheck({ cwd: rootDir }),
+ /features\.planner\.reasoning has invalid value "extreme"\./,
+ );
+ } finally {
+ await fsp.rm(rootDir, { recursive: true, force: true });
+ }
+});
+
+test('runCheck accepts a valid features block and preserves unset features', async () => {
+ const rootDir = createTempWorkspace({
+ configYaml: [
+ 'model: openai/gpt-5.4-mini',
+ 'reasoning: medium',
+ 'features:',
+ ' planner:',
+ ' model: anthropic/claude-opus-4-7',
+ ' reasoning: high',
+ ' scroll-index-grounder:',
+ ' reasoning: low',
+ ].join('\n'),
+ });
+
+ try {
+ await assert.doesNotReject(() => runCheck({ cwd: rootDir }));
+ } finally {
+ await fsp.rm(rootDir, { recursive: true, force: true });
+ }
+});
+
test('runCheck accepts empty-string secret environment values when the variable is present', async () => {
const secretEnvVar = 'FINALRUN_EMPTY_SECRET';
const previousSecret = process.env[secretEnvVar];
diff --git a/packages/cli/src/workspace.ts b/packages/cli/src/workspace.ts
index 0860580..72125fd 100644
--- a/packages/cli/src/workspace.ts
+++ b/packages/cli/src/workspace.ts
@@ -3,12 +3,18 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import {
+ ALL_FEATURES,
PLATFORM_ANDROID,
PLATFORM_IOS,
type AppConfig,
+ type FeatureName,
+ type FeatureOverride,
+ type FeatureOverrides,
+ type ReasoningLevel,
} from '@finalrun/common';
import YAML from 'yaml';
import { readAppConfig } from './appConfig.js';
+import { parseReasoningLevel } from './env.js';
import { resolveFinalRunRootDir } from './runtimePaths.js';
import { promptForWorkspaceSelection, type WorkspaceSelectionIO } from './workspacePicker.js';
@@ -31,6 +37,8 @@ export interface AppOverrideValidationResult {
export interface WorkspaceConfig {
env?: string;
model?: string;
+ reasoning?: ReasoningLevel;
+ features?: FeatureOverrides;
app?: AppConfig;
}
@@ -58,7 +66,15 @@ export interface RegisteredWorkspaceEntry {
metadataPath: string;
}
-const WORKSPACE_CONFIG_TOP_LEVEL_KEYS = new Set(['env', 'model', 'app']);
+const WORKSPACE_CONFIG_TOP_LEVEL_KEYS = new Set([
+ 'env',
+ 'model',
+ 'reasoning',
+ 'features',
+ 'app',
+]);
+const FEATURE_OVERRIDE_KEYS = new Set(['model', 'reasoning']);
+const ALL_FEATURES_SET = new Set(ALL_FEATURES);
const WORKSPACE_HASH_LENGTH = 16;
export async function resolveWorkspace(
@@ -425,10 +441,47 @@ export async function loadWorkspaceConfig(finalrunDir: string): Promise 0 ? overrides : undefined;
+}
+
export async function resolveConfiguredEnvironmentFile(
workspace: FinalRunWorkspace,
requestedEnvName?: string,
diff --git a/packages/cloud-core/package.json b/packages/cloud-core/package.json
new file mode 100644
index 0000000..4bee0a0
--- /dev/null
+++ b/packages/cloud-core/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@finalrun/cloud-core",
+ "version": "0.1.7",
+ "private": true,
+ "description": "Pure cloud-submission logic for the FinalRun CLI. Internal workspace package; bundled into the Bun-compiled CLI binary, not published to npm.",
+ "license": "Apache-2.0",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "scripts": {
+ "build": "npm run clean && tsc",
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
+ "test": "node --test \"dist/**/*.test.js\""
+ },
+ "dependencies": {
+ "@finalrun/common": "*",
+ "adm-zip": "^0.5.17"
+ },
+ "devDependencies": {
+ "@types/adm-zip": "^0.5.8",
+ "typescript": "^6.0.3"
+ }
+}
diff --git a/packages/cloud-core/src/index.ts b/packages/cloud-core/src/index.ts
new file mode 100644
index 0000000..5ea49af
--- /dev/null
+++ b/packages/cloud-core/src/index.ts
@@ -0,0 +1,14 @@
+// Barrel export for @finalrun/cloud-core
+
+export {
+ submitRun,
+ formatBytes,
+ type CheckedSpecs,
+ type SubmitRunInput,
+ type SubmitRunResult,
+} from './submit.js';
+export {
+ uploadApp,
+ type UploadAppInput,
+ type UploadAppResult,
+} from './upload.js';
diff --git a/packages/cloud-core/src/submit.ts b/packages/cloud-core/src/submit.ts
new file mode 100644
index 0000000..6781382
--- /dev/null
+++ b/packages/cloud-core/src/submit.ts
@@ -0,0 +1,292 @@
+import * as fs from 'node:fs';
+import { openAsBlob } from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import AdmZip from 'adm-zip';
+import { Logger } from '@finalrun/common';
+
+// Minimal projection of the CLI's CheckRunnerResult needed by submission.
+// Cloud-core does not depend on the CLI's check pipeline; the orchestrator
+// (CLI bin) is responsible for producing this shape.
+export interface CheckedSpecs {
+ tests: Array<{
+ sourcePath?: string;
+ relativePath?: string;
+ name?: string;
+ }>;
+ suite?: {
+ sourcePath?: string;
+ relativePath?: string;
+ name?: string;
+ };
+}
+
+export interface SubmitRunInput {
+ /** Pre-validated tests + (optional) suite produced by the CLI's checkRunner. */
+ checked: CheckedSpecs;
+ /** Workspace root containing .finalrun/config.yaml and .finalrun/env/. */
+ workspaceRoot: string;
+ /** Original positional selectors from the CLI invocation (for display + form data). */
+ selectors: string[];
+ suitePath?: string;
+ envName?: string;
+ platform?: string;
+ appPath?: string;
+ /** Verbatim CLI invocation string for the run record (e.g. "finalrun cloud test ..."). */
+ command: string;
+ /** Cloud service base URL. */
+ cloudUrl: string;
+ /** API key sent in the Authorization header. */
+ apiKey: string;
+}
+
+export interface SubmitRunResult {
+ runId: string;
+ statusUrl: string;
+ appFilename?: string;
+}
+
+// Generous timeout to accommodate large APK/IPA uploads on slow uplinks while
+// still catching genuinely stalled connections. Override with
+// FINALRUN_SUBMIT_TIMEOUT_MS for ultra-large uploads or low-bandwidth tests.
+const SUBMIT_TIMEOUT_MS = parseSubmitTimeoutMs(30 * 60 * 1000);
+
+function parseSubmitTimeoutMs(defaultMs: number): number {
+ const raw = process.env['FINALRUN_SUBMIT_TIMEOUT_MS'];
+ if (raw === undefined || raw === '') return defaultMs;
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ throw new Error(
+ `Invalid FINALRUN_SUBMIT_TIMEOUT_MS=${JSON.stringify(raw)}: must be a positive integer (milliseconds).`,
+ );
+ }
+ return parsed;
+}
+
+export async function submitRun(input: SubmitRunInput): Promise {
+ Logger.i('Preparing cloud run...');
+
+ // Resolve app — either from --app flag or let the server auto-pick
+ // the latest app_upload for this org + platform at submit time.
+ // Client-side inspection was intentionally removed: the server validates
+ // the binary (platform, simulator-compatibility, packageName) authoritatively
+ // after upload, and dropping the inspection step keeps the slim binary lean.
+ let appMode: { type: 'file'; path: string } | { type: 'server-default' };
+
+ if (input.appPath) {
+ if (!fs.existsSync(input.appPath)) {
+ throw new Error(`App file not found: ${input.appPath}`);
+ }
+ appMode = { type: 'file', path: input.appPath };
+ } else {
+ const platformLabel = input.platform?.trim() || 'the run target';
+ console.log(`\n No --app provided; server will use the latest app uploaded for ${platformLabel}.\n`);
+ appMode = { type: 'server-default' };
+ }
+
+ // Collect resolved file paths
+ const filesToZip: Array<{ absolutePath: string; relativePath: string }> = [];
+
+ if (input.checked.suite?.sourcePath && input.checked.suite.relativePath) {
+ filesToZip.push({
+ absolutePath: input.checked.suite.sourcePath,
+ relativePath: path.join('suites', input.checked.suite.relativePath),
+ });
+ }
+
+ for (const spec of input.checked.tests) {
+ if (!spec.sourcePath || !spec.relativePath) continue;
+ filesToZip.push({
+ absolutePath: spec.sourcePath,
+ relativePath: path.join('tests', spec.relativePath),
+ });
+ }
+
+ const configPath = path.join(input.workspaceRoot, '.finalrun', 'config.yaml');
+ if (fs.existsSync(configPath)) {
+ filesToZip.push({
+ absolutePath: configPath,
+ relativePath: 'config.yaml',
+ });
+ }
+
+ // Ship the env file matching the *resolved* env name the caller computed
+ // (--env if passed, else config.yaml's `env:` field, else nothing). The
+ // CLI orchestrator passes the resolved value here, not the raw flag, so
+ // a workspace with `env: dev` in config.yaml gets env/dev.yaml shipped
+ // even when the user didn't repeat --env=dev on the command line.
+ // Uploading just the one in-use env file (instead of every YAML under
+ // .finalrun/env/) avoids leaking other environments' bindings to the
+ // cloud submission.
+ if (input.envName) {
+ const envDir = path.join(input.workspaceRoot, '.finalrun', 'env');
+ const candidates = [`${input.envName}.yaml`, `${input.envName}.yml`];
+ for (const candidate of candidates) {
+ const envPath = path.join(envDir, candidate);
+ if (fs.existsSync(envPath)) {
+ filesToZip.push({
+ absolutePath: envPath,
+ relativePath: path.join('env', candidate),
+ });
+ break;
+ }
+ }
+ }
+
+ // Create zip with only selected files
+ Logger.i(`Zipping ${filesToZip.length} file(s)...`);
+ const zip = new AdmZip();
+ for (const file of filesToZip) {
+ const dir = path.dirname(file.relativePath);
+ zip.addLocalFile(file.absolutePath, dir);
+ }
+
+ const zipPath = path.join(os.tmpdir(), `finalrun-cloud-${Date.now()}.zip`);
+ zip.writeZip(zipPath);
+
+ try {
+ // Display name: suite name for suite runs, test name for single-test runs,
+ // " + N more" for multi-test runs, null otherwise.
+ let runName: string | null = null;
+ if (input.suitePath) {
+ runName = input.checked.suite?.name ?? path.basename(input.suitePath, path.extname(input.suitePath));
+ } else if (input.checked.tests.length === 1) {
+ runName = input.checked.tests[0]?.name ?? null;
+ } else if (input.checked.tests.length > 1) {
+ const first = input.checked.tests[0]?.name ?? path.basename(input.checked.tests[0]?.relativePath ?? '');
+ const remaining = input.checked.tests.length - 1;
+ runName = `${first} + ${remaining} more`;
+ }
+
+ // Run type classification. The server falls back to its own classification
+ // if this field is omitted.
+ const runType: 'single_test' | 'multi_test' | 'suite' = input.suitePath
+ ? 'suite'
+ : input.checked.tests.length === 1
+ ? 'single_test'
+ : 'multi_test';
+
+ const formData = new FormData();
+ const zipBuffer = fs.readFileSync(zipPath);
+ formData.append('file', new Blob([zipBuffer]), 'specs.zip');
+ formData.append('command', input.command);
+ formData.append('selectors', JSON.stringify(input.selectors));
+ formData.append('runType', runType);
+ if (runName) {
+ formData.append('name', runName);
+ }
+ if (input.suitePath) {
+ formData.append('suitePath', input.suitePath);
+ }
+ if (input.envName) {
+ formData.append('envName', input.envName);
+ }
+ if (input.platform) {
+ formData.append('platform', input.platform);
+ }
+
+ let spinnerMessage: string;
+ const submissionLabel = input.suitePath
+ ? `suite ${path.basename(input.suitePath)} (${input.checked.tests.length} test(s))`
+ : `${input.checked.tests.length} test(s)`;
+
+ if (appMode.type === 'file') {
+ // Stream the file into the multipart body so a large APK/IPA isn't
+ // pulled into memory just to wrap as a Blob.
+ const appFileName = path.basename(appMode.path);
+ const appSize = fs.statSync(appMode.path).size;
+ const appBlob = await openAsBlob(appMode.path);
+ formData.append('appFile', appBlob, appFileName);
+ formData.append('appFilename', appFileName);
+
+ spinnerMessage = `Uploading ${appFileName} (${formatBytes(appSize)}) and submitting ${submissionLabel}...`;
+ } else {
+ // server-default: no app fields on the request; server picks latest
+ spinnerMessage = `Submitting ${submissionLabel} (using latest uploaded app)...`;
+ }
+
+ const uploadStart = Date.now();
+ const { default: ora } = await import('ora');
+ const spinner = ora(spinnerMessage).start();
+
+ const url = `${input.cloudUrl}/api/v1/execute`;
+ let response: Response;
+ try {
+ response = await fetch(url, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${input.apiKey}` },
+ body: formData,
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
+ });
+ } catch (e) {
+ const elapsed = ((Date.now() - uploadStart) / 1000).toFixed(1);
+ const isTimeout = e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError');
+ spinner.fail(
+ isTimeout
+ ? `Upload timed out after ${elapsed}s — connection stalled.`
+ : `Upload failed after ${elapsed}s`,
+ );
+ throw e;
+ }
+
+ const elapsed = ((Date.now() - uploadStart) / 1000).toFixed(1);
+ if (response.status !== 201) {
+ spinner.fail(`Submission failed after ${elapsed}s (HTTP ${response.status})`);
+ const body = await response.text();
+ throw new Error(`Cloud service returned ${response.status}: ${body}`);
+ }
+
+ // Validate response shape before declaring success on the spinner. Wrap
+ // the JSON parse so a malformed/empty body (proxy injecting HTML,
+ // truncated response) fails the spinner instead of leaving it hung.
+ let result: { success: boolean; runId?: string; error?: string };
+ try {
+ result = await response.json() as typeof result;
+ } catch (e) {
+ spinner.fail(`Submission succeeded but server returned an unparseable body`);
+ throw e;
+ }
+ if (!result.success || !result.runId) {
+ spinner.fail(`Submission rejected by server`);
+ throw new Error(
+ `Cloud submission failed: ${result.error ?? JSON.stringify(result)}`,
+ );
+ }
+
+ if (appMode.type === 'file') {
+ const appSize = fs.statSync(appMode.path).size;
+ spinner.succeed(`Uploaded ${formatBytes(appSize)} in ${elapsed}s`);
+ } else {
+ spinner.succeed(`Submitted in ${elapsed}s`);
+ }
+
+ // Fire-and-forget: print the polling URL and return.
+ const statusUrl = `${input.cloudUrl}/runs/${result.runId}`;
+ console.log(`\n\x1b[32m✓ Run submitted\x1b[0m`);
+ console.log(` Run ID: ${result.runId}`);
+ console.log(` Status URL: ${statusUrl}`);
+ console.log(`\n The run is now queued. Use the status URL above to track progress.`);
+
+ let appFilename: string | undefined;
+ if (appMode.type === 'file') {
+ appFilename = path.basename(appMode.path);
+ console.log(`\n \x1b[33mTip:\x1b[0m You don't need to upload the app every time. Without --app,`);
+ console.log(` FinalRun uses your latest uploaded app (${appFilename}).`);
+ }
+
+ return { runId: result.runId, statusUrl, appFilename };
+ } finally {
+ try {
+ fs.unlinkSync(zipPath);
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+}
+
+export function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
+ return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
+}
diff --git a/packages/cloud-core/src/upload.ts b/packages/cloud-core/src/upload.ts
new file mode 100644
index 0000000..6fae15f
--- /dev/null
+++ b/packages/cloud-core/src/upload.ts
@@ -0,0 +1,125 @@
+import * as fs from 'node:fs';
+import { openAsBlob } from 'node:fs';
+import * as path from 'node:path';
+import { formatBytes } from './submit.js';
+
+export interface UploadAppInput {
+ appPath: string;
+ cloudUrl: string;
+ apiKey: string;
+ /** Optional explicit platform; if omitted, inferred from filename extension. */
+ platform?: 'android' | 'ios';
+}
+
+export interface UploadAppResult {
+ appUploadId: string;
+ filename: string;
+ size: number;
+}
+
+// Generous timeout to accommodate large APK/IPA uploads on slow uplinks while
+// still catching genuinely stalled connections. Override with
+// FINALRUN_UPLOAD_TIMEOUT_MS for ultra-large uploads or low-bandwidth tests.
+const UPLOAD_TIMEOUT_MS = parseTimeoutMs('FINALRUN_UPLOAD_TIMEOUT_MS', 30 * 60 * 1000);
+
+function parseTimeoutMs(envVar: string, defaultMs: number): number {
+ const raw = process.env[envVar];
+ if (raw === undefined || raw === '') return defaultMs;
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ throw new Error(
+ `Invalid ${envVar}=${JSON.stringify(raw)}: must be a positive integer (milliseconds).`,
+ );
+ }
+ return parsed;
+}
+
+function inferPlatformFromFilename(appPath: string): 'android' | 'ios' {
+ const lower = appPath.toLowerCase();
+ if (lower.endsWith('.apk')) return 'android';
+ if (lower.endsWith('.ipa') || lower.endsWith('.app.zip') || lower.endsWith('.zip')) return 'ios';
+ throw new Error(
+ `Cannot infer platform from filename: ${appPath}. ` +
+ `Expected .apk for Android or .ipa/.zip for iOS, or pass --platform.`,
+ );
+}
+
+export async function uploadApp(input: UploadAppInput): Promise {
+ if (!fs.existsSync(input.appPath)) {
+ throw new Error(`App file not found: ${input.appPath}`);
+ }
+
+ // Server validates the binary authoritatively after upload (platform,
+ // simulator-compatibility, packageName). We only send a platform hint
+ // — inferred from extension if not provided.
+ const platform = input.platform ?? inferPlatformFromFilename(input.appPath);
+
+ const appFileName = path.basename(input.appPath);
+ const appSize = fs.statSync(input.appPath).size;
+
+ const { default: ora } = await import('ora');
+ const spinner = ora(`Uploading ${appFileName} (${formatBytes(appSize)})...`).start();
+ const uploadStart = Date.now();
+
+ // Stream the file via openAsBlob (Node ≥20.16 / Bun) so a large APK/IPA
+ // doesn't get loaded into a single Buffer just to wrap as a Blob.
+ const appBlob = await openAsBlob(input.appPath);
+
+ const formData = new FormData();
+ formData.append('appFile', appBlob, appFileName);
+ formData.append('platform', platform);
+
+ let response: Response;
+ try {
+ response = await fetch(`${input.cloudUrl}/api/v1/app_uploads`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${input.apiKey}` },
+ body: formData,
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
+ });
+ } catch (e) {
+ const elapsed = ((Date.now() - uploadStart) / 1000).toFixed(1);
+ const isTimeout = e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError');
+ spinner.fail(
+ isTimeout
+ ? `Upload timed out after ${elapsed}s — connection stalled.`
+ : `Upload failed after ${elapsed}s`,
+ );
+ throw e;
+ }
+
+ const elapsed = ((Date.now() - uploadStart) / 1000).toFixed(1);
+ if (response.status !== 201) {
+ spinner.fail(`Upload failed after ${elapsed}s (HTTP ${response.status})`);
+ const body = await response.text();
+ throw new Error(`Cloud service returned ${response.status}: ${body}`);
+ }
+
+ // Validate response shape before declaring success on the spinner. Wrap the
+ // JSON parse so a malformed/empty body (proxy injecting HTML, truncated
+ // response) fails the spinner instead of leaving it hung.
+ let result: { success: boolean; appUpload?: { id: string }; error?: string };
+ try {
+ result = await response.json() as typeof result;
+ } catch (e) {
+ spinner.fail(`Upload succeeded but server returned an unparseable body`);
+ throw e;
+ }
+ if (!result.success || !result.appUpload) {
+ spinner.fail(`Upload rejected by server`);
+ throw new Error(`Upload failed: ${result.error ?? JSON.stringify(result)}`);
+ }
+
+ spinner.succeed(`Uploaded ${appFileName} (${formatBytes(appSize)}) in ${elapsed}s`);
+
+ console.log(`\n \x1b[32m✓ App uploaded\x1b[0m`);
+ console.log(` App ID: ${result.appUpload.id}`);
+ console.log(` Filename: ${appFileName}`);
+ console.log(`\n This app will be used automatically when you run tests without --app.`);
+
+ return {
+ appUploadId: result.appUpload.id,
+ filename: appFileName,
+ size: appSize,
+ };
+}
diff --git a/packages/cloud-core/tsconfig.json b/packages/cloud-core/tsconfig.json
new file mode 100644
index 0000000..9c5db86
--- /dev/null
+++ b/packages/cloud-core/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "composite": true
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/common/package.json b/packages/common/package.json
index d97df66..c4016a4 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -1,7 +1,6 @@
{
"name": "@finalrun/common",
- "version": "0.1.6",
- "private": true,
+ "version": "0.1.7",
"description": "Shared interfaces, models, and constants for the FinalRun CLI",
"license": "Apache-2.0",
"main": "./dist/index.js",
@@ -9,12 +8,15 @@
"files": [
"dist"
],
+ "publishConfig": {
+ "access": "public"
+ },
"scripts": {
"build": "npm run clean && tsc",
"clean": "rm -rf dist tsconfig.tsbuildinfo",
"test": "node --test \"dist/**/*.test.js\""
},
"devDependencies": {
- "typescript": "^5.7.0"
+ "typescript": "^6.0.3"
}
}
diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts
index a5f0d9b..c112152 100644
--- a/packages/common/src/constants.ts
+++ b/packages/common/src/constants.ts
@@ -52,6 +52,110 @@ export const FEATURE_INPUT_FOCUS_GROUNDER = 'input-focus-grounder';
export const FEATURE_LAUNCH_APP_GROUNDER = 'launch-app-grounder';
export const FEATURE_SET_LOCATION_GROUNDER = 'set-location-grounder';
+export const ALL_FEATURES = [
+ FEATURE_PLANNER,
+ FEATURE_GROUNDER,
+ FEATURE_VISUAL_GROUNDER,
+ FEATURE_SCROLL_INDEX_GROUNDER,
+ FEATURE_INPUT_FOCUS_GROUNDER,
+ FEATURE_LAUNCH_APP_GROUNDER,
+ FEATURE_SET_LOCATION_GROUNDER,
+] as const;
+export type FeatureName = (typeof ALL_FEATURES)[number];
+
+// ============================================================================
+// Reasoning effort — unified level mapped per-provider inside AIAgent.
+// 'minimal' is OpenAI-only; Google/Anthropic reject it at call time.
+// ============================================================================
+export const REASONING_LEVELS = ['minimal', 'low', 'medium', 'high'] as const;
+export type ReasoningLevel = (typeof REASONING_LEVELS)[number];
+
+// ============================================================================
+// AI provider identifiers and shared model-string parsing. Lives here so
+// both the CLI (workspace config, --model flag) and the goal-executor
+// (per-feature overrides) can validate model strings with identical errors.
+// ============================================================================
+export const SUPPORTED_AI_PROVIDERS = ['openai', 'google', 'anthropic'] as const;
+export type SupportedProvider = (typeof SUPPORTED_AI_PROVIDERS)[number];
+export const SUPPORTED_AI_PROVIDERS_LABEL = SUPPORTED_AI_PROVIDERS.join(', ');
+export const MODEL_FORMAT_EXAMPLE = 'google/gemini-3-flash-preview';
+export const PROVIDER_ENV_VARS: Record = {
+ openai: 'OPENAI_API_KEY',
+ google: 'GOOGLE_API_KEY',
+ anthropic: 'ANTHROPIC_API_KEY',
+};
+
+export interface ParsedModel {
+ provider: SupportedProvider;
+ modelName: string;
+}
+
+/**
+ * Parse a `provider/model` string (e.g. `openai/gpt-5.4-mini`) into its
+ * provider and model name. Validates that both halves are non-empty after
+ * trimming and that the provider is one of `SUPPORTED_AI_PROVIDERS`.
+ *
+ * @param modelStr the raw string from YAML or the CLI `--model` flag
+ * @param label optional context prefix for errors (e.g. `features.planner.model`).
+ * When omitted, errors read as CLI-style (`--model is required...`).
+ */
+export function parseModel(modelStr: string | undefined, label?: string): ParsedModel {
+ const normalizedModel = modelStr?.trim();
+ if (!normalizedModel) {
+ throw new Error(
+ label
+ ? `${label} is required. Use provider/model, for example ${MODEL_FORMAT_EXAMPLE}. Supported providers: ${SUPPORTED_AI_PROVIDERS_LABEL}.`
+ : `--model is required. Use provider/model, for example ${MODEL_FORMAT_EXAMPLE}. Supported providers: ${SUPPORTED_AI_PROVIDERS_LABEL}.`,
+ );
+ }
+
+ const segments = normalizedModel.split('/');
+ if (
+ segments.length !== 2 ||
+ segments[0] === undefined ||
+ segments[1] === undefined ||
+ segments[0].trim() === '' ||
+ segments[1].trim() === ''
+ ) {
+ const detail = `Expected provider/model with non-empty provider and model name. Supported providers: ${SUPPORTED_AI_PROVIDERS_LABEL}.`;
+ throw new Error(
+ label
+ ? `${label} has invalid model format: "${normalizedModel}". ${detail}`
+ : `Invalid model format: "${normalizedModel}". ${detail}`,
+ );
+ }
+
+ const provider = segments[0].trim();
+ const modelName = segments[1].trim();
+ if (!SUPPORTED_AI_PROVIDERS.includes(provider as SupportedProvider)) {
+ throw new Error(
+ label
+ ? `${label} has unsupported AI provider: "${provider}". Supported providers: ${SUPPORTED_AI_PROVIDERS_LABEL}.`
+ : `Unsupported AI provider: "${provider}". Supported providers: ${SUPPORTED_AI_PROVIDERS_LABEL}.`,
+ );
+ }
+
+ return { provider: provider as SupportedProvider, modelName };
+}
+
+/**
+ * Per-feature override resolved from `features:` in .finalrun/config.yaml.
+ * Each field is optional; unset fields inherit workspace-level defaults.
+ * `model` is a "provider/modelName" string (validated via parseModel at use site).
+ */
+export interface FeatureOverride {
+ model?: string;
+ reasoning?: ReasoningLevel;
+}
+
+export type FeatureOverrides = Partial>;
+
+export interface ModelDefaults {
+ provider: string;
+ modelName: string;
+ reasoning?: ReasoningLevel;
+}
+
// ============================================================================
// Defaults
// ============================================================================
diff --git a/packages/device-node/package.json b/packages/device-node/package.json
index c004303..094a672 100644
--- a/packages/device-node/package.json
+++ b/packages/device-node/package.json
@@ -1,6 +1,6 @@
{
"name": "@finalrun/device-node",
- "version": "0.1.6",
+ "version": "0.1.7",
"private": true,
"description": "Device detection, gRPC communication, and driver management",
"license": "Apache-2.0",
@@ -22,7 +22,7 @@
},
"devDependencies": {
"ts-proto": "^2.6.0",
- "typescript": "^5.7.0",
+ "typescript": "^6.0.3",
"grpc-tools": "^1.12.4"
}
}
diff --git a/packages/goal-executor/package.json b/packages/goal-executor/package.json
index 4bb75d3..df3ab29 100644
--- a/packages/goal-executor/package.json
+++ b/packages/goal-executor/package.json
@@ -1,6 +1,6 @@
{
"name": "@finalrun/goal-executor",
- "version": "0.1.6",
+ "version": "0.1.7",
"private": true,
"description": "AI-driven goal execution engine using Vercel AI SDK",
"license": "Apache-2.0",
@@ -21,9 +21,10 @@
"@ai-sdk/openai": "^3.0.47",
"@ai-sdk/google": "^3.0.43",
"@ai-sdk/anthropic": "^3.0.58",
- "uuid": "^11.1.0"
+ "uuid": "^11.1.0",
+ "zod": "^4.1.8"
},
"devDependencies": {
- "typescript": "^5.7.0"
+ "typescript": "^6.0.3"
}
}
diff --git a/packages/goal-executor/src/ActionExecutor.ts b/packages/goal-executor/src/ActionExecutor.ts
index 5328c26..d1157ca 100644
--- a/packages/goal-executor/src/ActionExecutor.ts
+++ b/packages/goal-executor/src/ActionExecutor.ts
@@ -40,6 +40,7 @@ import {
PLANNER_ACTION_SET_LOCATION,
PLANNER_ACTION_WAIT,
PLANNER_ACTION_DEEPLINK,
+ type FeatureName,
type RuntimeBindings,
redactResolvedValue,
resolveRuntimePlaceholders,
@@ -58,6 +59,7 @@ import {
roundDuration,
startTracePhase,
type LLMTrace,
+ type LLMCallTrace,
type SpanTiming,
type TimingMetadata,
type TraceStatus,
@@ -89,6 +91,8 @@ export interface ActionOutput {
error?: string;
trace?: TimingMetadata;
terminalFailure?: TerminalFailureSignal;
+ /** Raw LLM calls made during this action (grounder + visual grounder). Forwarded to observability. */
+ llmCalls?: LLMCallTrace[];
}
interface GroundToPointResult {
@@ -150,49 +154,66 @@ export class ActionExecutor {
* Routes to the correct handler based on action type.
*/
async executeAction(input: ActionInput): Promise {
+ // Per-invocation accumulator — passed into helpers so concurrent
+ // executeAction() calls on the same executor do not share state.
+ const llmCalls: LLMCallTrace[] = [];
+ let output: ActionOutput;
try {
switch (input.action) {
case PLANNER_ACTION_TAP:
- return await this._executeTap(input);
+ output = await this._executeTap(input, llmCalls);
+ break;
case PLANNER_ACTION_LONG_PRESS:
- return await this._executeLongPress(input);
+ output = await this._executeLongPress(input, llmCalls);
+ break;
case PLANNER_ACTION_TYPE:
- return await this._executeType(input);
+ output = await this._executeType(input, llmCalls);
+ break;
case PLANNER_ACTION_SCROLL:
- return await this._executeScroll(input);
+ output = await this._executeScroll(input, llmCalls);
+ break;
case PLANNER_ACTION_BACK:
- return await this._executeSimpleAction(input, new BackAction());
+ output = await this._executeSimpleAction(input, new BackAction());
+ break;
case PLANNER_ACTION_HOME:
- return await this._executeSimpleAction(input, new HomeAction());
+ output = await this._executeSimpleAction(input, new HomeAction());
+ break;
case PLANNER_ACTION_ROTATE:
- return await this._executeSingleDevicePhase(input, new RotateAction());
+ output = await this._executeSingleDevicePhase(input, new RotateAction());
+ break;
case PLANNER_ACTION_HIDE_KEYBOARD:
- return await this._executeSimpleAction(input, new HideKeyboardAction());
+ output = await this._executeSimpleAction(input, new HideKeyboardAction());
+ break;
case PLANNER_ACTION_PRESS_ENTER:
- return await this._executePressEnter(input);
+ output = await this._executePressEnter(input);
+ break;
case PLANNER_ACTION_LAUNCH_APP:
- return await this._executeLaunchApp(input);
+ output = await this._executeLaunchApp(input, llmCalls);
+ break;
case PLANNER_ACTION_SET_LOCATION:
- return await this._executeSetLocation(input);
+ output = await this._executeSetLocation(input, llmCalls);
+ break;
case PLANNER_ACTION_WAIT:
- return await this._executeWait(input);
+ output = await this._executeWait(input);
+ break;
case PLANNER_ACTION_DEEPLINK:
- return await this._executeDeeplink(input);
+ output = await this._executeDeeplink(input);
+ break;
default:
- return { success: false, error: `Unknown action: ${input.action}` };
+ output = { success: false, error: `Unknown action: ${input.action}` };
}
} catch (error) {
const terminalFailure = terminalFailureFromError(error);
@@ -201,11 +222,19 @@ export class ActionExecutor {
} else {
Logger.e(`Action ${input.action} failed:`, error);
}
- return this._failure([], error);
+ output = this._failure([], error);
}
+
+ if (llmCalls.length > 0) {
+ output = { ...output, llmCalls };
+ }
+ return output;
}
- private async _executeTap(input: ActionInput): Promise {
+ private async _executeTap(
+ input: ActionInput,
+ llmCalls: LLMCallTrace[],
+ ): Promise {
const spans: SpanTiming[] = [];
let groundOutcome: GroundToPointResult;
@@ -214,6 +243,7 @@ export class ActionExecutor {
input,
FEATURE_GROUNDER,
'action.ground',
+ llmCalls,
);
} catch (error) {
return this._failure(spans, error);
@@ -222,7 +252,7 @@ export class ActionExecutor {
this._pushGroundSpan(spans, 'action.ground', groundOutcome);
if (!groundOutcome.result.success || !groundOutcome.result.data) {
if (groundOutcome.result.error === 'needsVisualGrounding') {
- const fallbackResult = await this._executeVisualGroundingFallback(input, 'tap');
+ const fallbackResult = await this._executeVisualGroundingFallback(input, 'tap', llmCalls);
this._mergeTrace(spans, fallbackResult.trace);
if (!fallbackResult.success) {
return {
@@ -276,7 +306,10 @@ export class ActionExecutor {
}
}
- private async _executeLongPress(input: ActionInput): Promise {
+ private async _executeLongPress(
+ input: ActionInput,
+ llmCalls: LLMCallTrace[],
+ ): Promise {
const spans: SpanTiming[] = [];
let groundOutcome: GroundToPointResult;
@@ -285,6 +318,7 @@ export class ActionExecutor {
input,
FEATURE_GROUNDER,
'action.ground',
+ llmCalls,
);
} catch (error) {
return this._failure(spans, error);
@@ -296,6 +330,7 @@ export class ActionExecutor {
const fallbackResult = await this._executeVisualGroundingFallback(
input,
'longPress',
+ llmCalls,
);
this._mergeTrace(spans, fallbackResult.trace);
if (!fallbackResult.success) {
@@ -339,7 +374,10 @@ export class ActionExecutor {
}
}
- private async _executeType(input: ActionInput): Promise {
+ private async _executeType(
+ input: ActionInput,
+ llmCalls: LLMCallTrace[],
+ ): Promise {
const spans: SpanTiming[] = [];
let textToType = '';
@@ -373,6 +411,7 @@ export class ActionExecutor {
input,
FEATURE_INPUT_FOCUS_GROUNDER,
'action.ground',
+ llmCalls,
);
} catch (error) {
return this._failure(spans, error);
@@ -422,7 +461,10 @@ export class ActionExecutor {
}
}
- private async _executeScroll(input: ActionInput): Promise {
+ private async _executeScroll(
+ input: ActionInput,
+ llmCalls: LLMCallTrace[],
+ ): Promise {
const spans: SpanTiming[] = [];
const act =
input.reason.trim() ||
@@ -430,13 +472,17 @@ export class ActionExecutor {
let grounderResponse;
try {
- grounderResponse = await this._callGrounder(input, {
- feature: FEATURE_SCROLL_INDEX_GROUNDER,
- act,
- hierarchy: input.hierarchy,
- screenshot: input.screenshot,
- platform: this._platform,
- });
+ grounderResponse = await this._callGrounder(
+ input,
+ {
+ feature: FEATURE_SCROLL_INDEX_GROUNDER,
+ act,
+ hierarchy: input.hierarchy,
+ screenshot: input.screenshot,
+ platform: this._platform,
+ },
+ llmCalls,
+ );
} catch (error) {
return this._failure(spans, error);
}
@@ -490,7 +536,10 @@ export class ActionExecutor {
return await this._executeSingleDevicePhase(input, action);
}
- private async _executeLaunchApp(input: ActionInput): Promise {
+ private async _executeLaunchApp(
+ input: ActionInput,
+ llmCalls: LLMCallTrace[],
+ ): Promise {
const spans: SpanTiming[] = [];
let apps: Array<{ packageName: string; name: string }> = [];
@@ -526,12 +575,16 @@ export class ActionExecutor {
let grounderResponse;
try {
- grounderResponse = await this._callGrounder(input, {
- feature: FEATURE_LAUNCH_APP_GROUNDER,
- act: input.reason,
- platform: this._platform,
- availableApps: apps,
- });
+ grounderResponse = await this._callGrounder(
+ input,
+ {
+ feature: FEATURE_LAUNCH_APP_GROUNDER,
+ act: input.reason,
+ platform: this._platform,
+ availableApps: apps,
+ },
+ llmCalls,
+ );
} catch (error) {
return this._failure(spans, error);
}
@@ -594,15 +647,22 @@ export class ActionExecutor {
}
}
- private async _executeSetLocation(input: ActionInput): Promise {
+ private async _executeSetLocation(
+ input: ActionInput,
+ llmCalls: LLMCallTrace[],
+ ): Promise {
const spans: SpanTiming[] = [];
let grounderResponse;
try {
- grounderResponse = await this._callGrounder(input, {
- feature: FEATURE_SET_LOCATION_GROUNDER,
- act: input.reason,
- });
+ grounderResponse = await this._callGrounder(
+ input,
+ {
+ feature: FEATURE_SET_LOCATION_GROUNDER,
+ act: input.reason,
+ },
+ llmCalls,
+ );
} catch (error) {
return this._failure(spans, error);
}
@@ -773,17 +833,22 @@ export class ActionExecutor {
private async _groundToPoint(
input: ActionInput,
- feature: string,
+ feature: FeatureName,
tracePhase: string,
+ llmCalls: LLMCallTrace[],
): Promise {
- const grounderResponse = await this._callGrounder(input, {
- feature,
- act: input.reason,
- hierarchy: input.hierarchy,
- screenshot: input.screenshot,
- platform: this._platform,
- tracePhase,
- });
+ const grounderResponse = await this._callGrounder(
+ input,
+ {
+ feature,
+ act: input.reason,
+ hierarchy: input.hierarchy,
+ screenshot: input.screenshot,
+ platform: this._platform,
+ tracePhase,
+ },
+ llmCalls,
+ );
return {
result: GrounderResponseConverter.extractPoint({
@@ -806,6 +871,7 @@ export class ActionExecutor {
private async _executeVisualGroundingFallback(
input: ActionInput,
actionType: 'tap' | 'longPress',
+ llmCalls: LLMCallTrace[],
): Promise {
const spans: SpanTiming[] = [];
@@ -833,6 +899,9 @@ export class ActionExecutor {
traceStep: input.traceStep,
logContext: this._logContext,
});
+ if (result.llmCall) {
+ llmCalls.push(result.llmCall);
+ }
} catch (error) {
const message = this._redactRuntimeString(
error instanceof Error ? error.message : String(error),
@@ -937,7 +1006,7 @@ export class ActionExecutor {
private async _callGrounder(
input: ActionInput,
request: {
- feature: string;
+ feature: FeatureName;
act: string;
hierarchy?: Hierarchy;
screenshot?: string;
@@ -945,6 +1014,7 @@ export class ActionExecutor {
availableApps?: Array<{ packageName: string; name: string }>;
tracePhase?: string;
},
+ llmCalls: LLMCallTrace[],
) {
const startedAt = nowMs();
@@ -956,6 +1026,10 @@ export class ActionExecutor {
logContext: this._logContext,
});
+ if (response.llmCall) {
+ llmCalls.push(response.llmCall);
+ }
+
return {
...response,
trace:
@@ -1094,7 +1168,7 @@ export class ActionExecutor {
private _groundTraceDetail(
trace: LLMTrace | undefined,
- feature: string,
+ feature: FeatureName,
reason?: string,
): string {
const detail = `feature=${feature}${reason ? ` reason=${reason}` : ''}`;
diff --git a/packages/goal-executor/src/TestExecutor.ts b/packages/goal-executor/src/TestExecutor.ts
index eac1104..e89e60d 100644
--- a/packages/goal-executor/src/TestExecutor.ts
+++ b/packages/goal-executor/src/TestExecutor.ts
@@ -27,6 +27,7 @@ import {
type SpanTiming,
type StepTrace,
type TimingMetadata,
+ type LLMCallTrace,
} from './trace.js';
// ============================================================================
@@ -89,6 +90,13 @@ export interface AgentActionResult {
durationMs?: number;
timing?: TimingMetadata;
trace?: StepTrace;
+ /**
+ * Raw LLM call traces that happened during this step (planner + any
+ * grounder / visual grounder calls). Consumers can forward these to
+ * observability backends (e.g., Langfuse). Empty for steps with no
+ * LLM activity.
+ */
+ llmCalls?: LLMCallTrace[];
}
export interface TestRecordingResult {
@@ -622,6 +630,16 @@ export class TestExecutor {
);
}
+ // Aggregate LLM calls for this step: planner call + any grounder/visual-grounder
+ // calls made by ActionExecutor. Order: planner first, then action calls.
+ const stepLLMCalls: LLMCallTrace[] = [];
+ if (plannerResponse.llmCall) {
+ stepLLMCalls.push(plannerResponse.llmCall);
+ }
+ if (actionResult.llmCalls && actionResult.llmCalls.length > 0) {
+ stepLLMCalls.push(...actionResult.llmCalls);
+ }
+
const stepResult: AgentActionResult = {
iteration,
action,
@@ -637,6 +655,7 @@ export class TestExecutor {
screenHeight: postActionCapture.screenHeight ?? deviceState.screenHeight,
timestamp: new Date().toISOString(),
timing: actionResult.trace,
+ ...(stepLLMCalls.length > 0 ? { llmCalls: stepLLMCalls } : {}),
};
if (!actionResult.success && actionResult.error) {
diff --git a/packages/goal-executor/src/ai/AIAgent.test.ts b/packages/goal-executor/src/ai/AIAgent.test.ts
index fcd476c..4a10095 100644
--- a/packages/goal-executor/src/ai/AIAgent.test.ts
+++ b/packages/goal-executor/src/ai/AIAgent.test.ts
@@ -2,21 +2,44 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
FEATURE_GROUNDER,
+ FEATURE_PLANNER,
+ FEATURE_SCROLL_INDEX_GROUNDER,
PLANNER_ACTION_ROTATE,
PLANNER_ACTION_TAP,
+ type FeatureName,
+ type FeatureOverrides,
+ type ModelDefaults,
} from '@finalrun/common';
import { AIAgent, GrounderResponse, PlannerResponse } from './AIAgent.js';
import { FatalProviderError } from './providerFailure.js';
type LLMPhase = 'planner' | 'grounder';
-function parsePlannerResponse(output: unknown, rawText = ''): PlannerResponse {
- const agent = new AIAgent({
- provider: 'google',
- modelName: 'gemini-test',
- apiKey: 'test-key',
+function makeAgent(overrides?: {
+ defaults?: Partial;
+ features?: FeatureOverrides;
+ apiKeys?: Record;
+}): AIAgent {
+ const defaults: ModelDefaults = {
+ provider: overrides?.defaults?.provider ?? 'google',
+ modelName: overrides?.defaults?.modelName ?? 'gemini-test',
+ ...(overrides?.defaults?.reasoning !== undefined
+ ? { reasoning: overrides.defaults.reasoning }
+ : {}),
+ };
+ return new AIAgent({
+ apiKeys: overrides?.apiKeys ?? {
+ google: 'test-key',
+ openai: 'test-key',
+ anthropic: 'test-key',
+ },
+ defaults,
+ ...(overrides?.features !== undefined ? { features: overrides.features } : {}),
});
+}
+function parsePlannerResponse(output: unknown, rawText = ''): PlannerResponse {
+ const agent = makeAgent();
return (
agent as unknown as {
_parsePlannerResponse: (output: unknown, rawText: string) => PlannerResponse;
@@ -25,12 +48,7 @@ function parsePlannerResponse(output: unknown, rawText = ''): PlannerResponse {
}
function parseGrounderResponse(output: unknown, rawText = ''): GrounderResponse {
- const agent = new AIAgent({
- provider: 'google',
- modelName: 'gemini-test',
- apiKey: 'test-key',
- });
-
+ const agent = makeAgent();
return (
agent as unknown as {
_parseGrounderResponse: (output: unknown, rawText: string) => GrounderResponse;
@@ -41,26 +59,44 @@ function parseGrounderResponse(output: unknown, rawText = ''): GrounderResponse
function getProviderOptions(params: {
provider: string;
modelName: string;
- phase: LLMPhase;
+ feature: FeatureName;
+ defaultReasoning?: ModelDefaults['reasoning'];
+ features?: FeatureOverrides;
}): Record | undefined {
- const agent = new AIAgent({
- provider: params.provider,
- modelName: params.modelName,
- apiKey: 'test-key',
+ const agent = makeAgent({
+ defaults: {
+ provider: params.provider,
+ modelName: params.modelName,
+ ...(params.defaultReasoning !== undefined ? { reasoning: params.defaultReasoning } : {}),
+ },
+ ...(params.features !== undefined ? { features: params.features } : {}),
});
+ const resolved = (
+ agent as unknown as {
+ _resolveFeatureConfig: (feature: FeatureName) => {
+ provider: string;
+ modelName: string;
+ reasoning: string;
+ };
+ }
+ )._resolveFeatureConfig(params.feature);
+
return (
agent as unknown as {
- _getProviderOptions: (phase: LLMPhase) => Record | undefined;
+ _getProviderOptions: (
+ resolved: { provider: string; modelName: string; reasoning: string },
+ feature: FeatureName,
+ ) => Record | undefined;
}
- )._getProviderOptions(params.phase);
+ )._getProviderOptions(resolved, params.feature);
}
-test('AIAgent uses medium Gemini 3 reasoning defaults for planner calls', () => {
+test('AIAgent uses medium Google reasoning defaults for planner feature', () => {
const providerOptions = getProviderOptions({
provider: 'google',
modelName: 'gemini-3.1-pro-preview',
- phase: 'planner',
+ feature: FEATURE_PLANNER,
});
assert.deepEqual(providerOptions, {
@@ -73,17 +109,17 @@ test('AIAgent uses medium Gemini 3 reasoning defaults for planner calls', () =>
});
});
-test('AIAgent uses minimal Gemini 3 reasoning defaults for grounder calls', () => {
+test('AIAgent uses low Google reasoning defaults for grounder feature', () => {
const providerOptions = getProviderOptions({
provider: 'google',
modelName: 'gemini-3.1-pro-preview',
- phase: 'grounder',
+ feature: FEATURE_GROUNDER,
});
assert.deepEqual(providerOptions, {
google: {
thinkingConfig: {
- thinkingLevel: 'minimal',
+ thinkingLevel: 'low',
includeThoughts: false,
},
},
@@ -94,7 +130,7 @@ test('AIAgent applies Google reasoning defaults without model-family gating', ()
const providerOptions = getProviderOptions({
provider: 'google',
modelName: 'gemini-2.0-flash',
- phase: 'planner',
+ feature: FEATURE_PLANNER,
});
assert.deepEqual(providerOptions, {
@@ -107,11 +143,11 @@ test('AIAgent applies Google reasoning defaults without model-family gating', ()
});
});
-test('AIAgent uses medium GPT-5 reasoning defaults for planner calls', () => {
+test('AIAgent uses medium OpenAI reasoning defaults for planner feature', () => {
const providerOptions = getProviderOptions({
provider: 'openai',
modelName: 'gpt-5',
- phase: 'planner',
+ feature: FEATURE_PLANNER,
});
assert.deepEqual(providerOptions, {
@@ -121,11 +157,11 @@ test('AIAgent uses medium GPT-5 reasoning defaults for planner calls', () => {
});
});
-test('AIAgent uses low GPT-5 reasoning defaults for grounder calls', () => {
+test('AIAgent uses low OpenAI reasoning defaults for grounder feature', () => {
const providerOptions = getProviderOptions({
provider: 'openai',
modelName: 'gpt-5',
- phase: 'grounder',
+ feature: FEATURE_GROUNDER,
});
assert.deepEqual(providerOptions, {
@@ -139,7 +175,7 @@ test('AIAgent applies OpenAI reasoning defaults without model-family gating', ()
const providerOptions = getProviderOptions({
provider: 'openai',
modelName: 'gpt-5.4-mini',
- phase: 'planner',
+ feature: FEATURE_PLANNER,
});
assert.deepEqual(providerOptions, {
@@ -149,30 +185,32 @@ test('AIAgent applies OpenAI reasoning defaults without model-family gating', ()
});
});
-test('AIAgent uses medium Anthropic effort defaults for planner calls', () => {
+test('AIAgent uses medium Anthropic effort defaults for planner feature', () => {
const providerOptions = getProviderOptions({
provider: 'anthropic',
modelName: 'claude-sonnet-4-6',
- phase: 'planner',
+ feature: FEATURE_PLANNER,
});
assert.deepEqual(providerOptions, {
anthropic: {
effort: 'medium',
+ structuredOutputMode: 'outputFormat',
},
});
});
-test('AIAgent uses low Anthropic effort defaults for grounder calls', () => {
+test('AIAgent uses low Anthropic effort defaults for grounder feature', () => {
const providerOptions = getProviderOptions({
provider: 'anthropic',
modelName: 'claude-sonnet-4-6',
- phase: 'grounder',
+ feature: FEATURE_GROUNDER,
});
assert.deepEqual(providerOptions, {
anthropic: {
effort: 'low',
+ structuredOutputMode: 'outputFormat',
},
});
});
@@ -181,12 +219,95 @@ test('AIAgent applies Anthropic effort defaults without model-family gating', ()
const providerOptions = getProviderOptions({
provider: 'anthropic',
modelName: 'claude-3-7-sonnet-latest',
- phase: 'planner',
+ feature: FEATURE_PLANNER,
});
assert.deepEqual(providerOptions, {
anthropic: {
effort: 'medium',
+ structuredOutputMode: 'outputFormat',
+ },
+ });
+});
+
+test('AIAgent respects workspace-wide reasoning default across features', () => {
+ const providerOptions = getProviderOptions({
+ provider: 'openai',
+ modelName: 'gpt-5.4-mini',
+ feature: FEATURE_GROUNDER,
+ defaultReasoning: 'high',
+ });
+
+ assert.deepEqual(providerOptions, {
+ openai: {
+ reasoningEffort: 'high',
+ },
+ });
+});
+
+test('AIAgent per-feature reasoning override beats workspace default', () => {
+ const providerOptions = getProviderOptions({
+ provider: 'openai',
+ modelName: 'gpt-5.4-mini',
+ feature: FEATURE_PLANNER,
+ defaultReasoning: 'low',
+ features: { planner: { reasoning: 'high' } },
+ });
+
+ assert.deepEqual(providerOptions, {
+ openai: {
+ reasoningEffort: 'high',
+ },
+ });
+});
+
+test('AIAgent per-feature model override re-routes to the named provider', () => {
+ const providerOptions = getProviderOptions({
+ provider: 'openai',
+ modelName: 'gpt-5.4-mini',
+ feature: FEATURE_SCROLL_INDEX_GROUNDER,
+ features: {
+ 'scroll-index-grounder': {
+ model: 'google/gemini-2.0-flash',
+ reasoning: 'medium',
+ },
+ },
+ });
+
+ assert.deepEqual(providerOptions, {
+ google: {
+ thinkingConfig: {
+ thinkingLevel: 'medium',
+ includeThoughts: false,
+ },
+ },
+ });
+});
+
+test('AIAgent rejects minimal reasoning on non-OpenAI provider', () => {
+ assert.throws(
+ () =>
+ getProviderOptions({
+ provider: 'google',
+ modelName: 'gemini-3.1-pro-preview',
+ feature: FEATURE_GROUNDER,
+ defaultReasoning: 'minimal',
+ }),
+ /Reasoning level "minimal" is only supported for OpenAI/,
+ );
+});
+
+test('AIAgent accepts minimal reasoning on OpenAI', () => {
+ const providerOptions = getProviderOptions({
+ provider: 'openai',
+ modelName: 'gpt-5.4-mini',
+ feature: FEATURE_GROUNDER,
+ defaultReasoning: 'minimal',
+ });
+
+ assert.deepEqual(providerOptions, {
+ openai: {
+ reasoningEffort: 'minimal',
},
});
});
@@ -371,14 +492,6 @@ test('AIAgent rejects grounder responses that are not JSON objects', () => {
type MockLLMResult = { output: unknown; text: string };
-function makeAgent(): AIAgent {
- return new AIAgent({
- provider: 'google',
- modelName: 'gemini-test',
- apiKey: 'test-key',
- });
-}
-
function installMockCallLLM(
agent: AIAgent,
results: Array,
@@ -389,7 +502,7 @@ function installMockCallLLM(
_callLLM: (
systemPrompt: string,
userParts: unknown[],
- phase: LLMPhase,
+ feature: FeatureName,
) => Promise;
}
)._callLLM = async () => {
diff --git a/packages/goal-executor/src/ai/AIAgent.ts b/packages/goal-executor/src/ai/AIAgent.ts
index 5963d39..6a72dab 100644
--- a/packages/goal-executor/src/ai/AIAgent.ts
+++ b/packages/goal-executor/src/ai/AIAgent.ts
@@ -43,6 +43,11 @@ import {
PLANNER_ACTION_COMPLETED,
PLANNER_ACTION_FAILED,
PLANNER_ACTION_DEEPLINK,
+ parseModel,
+ type FeatureName,
+ type FeatureOverrides,
+ type ModelDefaults,
+ type ReasoningLevel,
} from '@finalrun/common';
import {
describeLLMTrace,
@@ -53,8 +58,10 @@ import {
roundDuration,
startTracePhase,
type LLMTrace,
+ type LLMCallTrace,
} from '../trace.js';
import { classifyFatalProviderError, FatalProviderError } from './providerFailure.js';
+import { schemaForFeature } from './schemas.js';
// ============================================================================
// Types
@@ -99,10 +106,12 @@ export interface PlannerResponse {
act?: string;
};
trace?: LLMTrace;
+ /** Raw LLM call trace captured during planning — forwarded to observability. */
+ llmCall?: LLMCallTrace;
}
export interface GrounderRequest {
- feature: string;
+ feature: FeatureName;
act: string;
hierarchy?: Hierarchy;
screenshot?: string; // base64
@@ -120,6 +129,8 @@ export interface GrounderResponse {
output: Record;
raw: string; // Raw LLM response for debugging
trace?: LLMTrace;
+ /** Raw LLM call trace captured during grounding — forwarded to observability. */
+ llmCall?: LLMCallTrace;
}
type JsonRecord = Record;
@@ -130,6 +141,23 @@ type AIAgentProviderOptions = {
anthropic?: AnthropicLanguageModelOptions;
};
+interface ResolvedFeatureConfig {
+ provider: string;
+ modelName: string;
+ reasoning: ReasoningLevel;
+}
+
+/** Fallback reasoning levels used when neither feature override nor workspace default is set. */
+const DEFAULT_REASONING_BY_PHASE: Record = {
+ planner: 'medium',
+ grounder: 'low',
+};
+
+/** Map a feature to its phase (controls token budget + default reasoning). */
+function phaseForFeature(feature: FeatureName): LLMPhase {
+ return feature === FEATURE_PLANNER ? 'planner' : 'grounder';
+}
+
const MAX_LLM_ATTEMPTS = 2;
// ============================================================================
@@ -143,17 +171,24 @@ const MAX_LLM_ATTEMPTS = 2;
* Dart equivalent: FinalRunAgent in goal_executor/lib/src/FinalRunAgent.dart
*/
export class AIAgent {
- private _provider: string; // e.g., 'openai', 'google', 'anthropic'
- private _modelName: string; // e.g., 'gpt-5.4-mini', 'gemini-2.0-flash'
- private _apiKey: string;
+ private _apiKeys: Record;
+ private _defaults: ModelDefaults;
+ private _features: FeatureOverrides;
// Cached prompt contents
private _promptCache: Map = new Map();
-
- constructor(params: { provider: string; modelName: string; apiKey: string }) {
- this._provider = params.provider;
- this._modelName = params.modelName;
- this._apiKey = params.apiKey;
+ // Cached Vercel AI SDK clients, keyed by provider
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ private _clientCache: Map = new Map();
+
+ constructor(params: {
+ apiKeys: Record;
+ defaults: ModelDefaults;
+ features?: FeatureOverrides;
+ }) {
+ this._apiKeys = params.apiKeys;
+ this._defaults = params.defaults;
+ this._features = params.features ?? {};
}
/**
@@ -217,21 +252,24 @@ export class AIAgent {
let parsedResponse: PlannerResponse | undefined;
let llmMs = 0;
let parseMs = 0;
+ let lastLLMCall: LLMCallTrace | undefined;
+ const plannerResolved = this._resolveFeatureConfig(FEATURE_PLANNER);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const llmPhase = startTracePhase(
request.traceStep,
'planning.llm',
- `provider=${this._provider} model=${this._modelName} attempt=${attempt}/${maxAttempts}`,
+ `provider=${plannerResolved.provider} model=${plannerResolved.modelName} attempt=${attempt}/${maxAttempts}`,
);
const llmStartedAt = performance.now();
let rawOutput: unknown;
let rawText: string;
try {
- const llmResult = await this._callLLM(systemPrompt, userParts, 'planner');
+ const llmResult = await this._callLLM(systemPrompt, userParts, FEATURE_PLANNER);
rawOutput = llmResult.output;
rawText = llmResult.text;
+ lastLLMCall = llmResult.llmCall;
} catch (error) {
finishTracePhase(
llmPhase,
@@ -311,6 +349,7 @@ export class AIAgent {
llmMs,
parseMs,
},
+ ...(lastLLMCall ? { llmCall: lastLLMCall } : {}),
};
}
@@ -367,6 +406,7 @@ export class AIAgent {
let parsed: GrounderResponse | undefined;
let llmMs = 0;
let parseMs = 0;
+ let lastLLMCall: LLMCallTrace | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const phase = startTracePhase(
@@ -379,9 +419,14 @@ export class AIAgent {
let rawOutput: unknown;
let rawText: string;
try {
- const llmResult = await this._callLLM(systemPrompt, userParts, 'grounder');
+ const llmResult = await this._callLLM(
+ systemPrompt,
+ userParts,
+ request.feature,
+ );
rawOutput = llmResult.output;
rawText = llmResult.text;
+ lastLLMCall = llmResult.llmCall;
} catch (error) {
finishTracePhase(
phase,
@@ -465,6 +510,7 @@ export class AIAgent {
llmMs,
parseMs,
},
+ ...(lastLLMCall ? { llmCall: lastLLMCall } : {}),
};
}
@@ -478,10 +524,12 @@ export class AIAgent {
private async _callLLM(
systemPrompt: string,
userParts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string }>,
- phase: LLMPhase,
- ): Promise<{ output: unknown; text: string }> {
- const model = this._getModel();
- const providerOptions = this._getProviderOptions(phase);
+ feature: FeatureName,
+ ): Promise<{ output: unknown; text: string; llmCall: LLMCallTrace }> {
+ const resolved = this._resolveFeatureConfig(feature);
+ const model = this._getModel(resolved);
+ const providerOptions = this._getProviderOptions(resolved, feature);
+ const phase = phaseForFeature(feature);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const userContent: any[] = userParts.map((part) => {
@@ -491,9 +539,22 @@ export class AIAgent {
return { type: 'text' as const, text: part.text };
});
+ // Persist the exact messages we send so we can forward them verbatim to
+ // observability backends (Langfuse stores these for debugging).
+ const messages = [
+ { role: 'system' as const, content: systemPrompt },
+ { role: 'user' as const, content: userContent },
+ ];
+
+ const startedAt = new Date().toISOString();
+ const startPerfMs = performance.now();
+
let output: unknown;
- let text: string;
+ let text = '';
let reasoningText: string | undefined;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let usage: any;
+ let thrownError: unknown;
try {
const result = await generateText({
model,
@@ -501,78 +562,172 @@ export class AIAgent {
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
],
- output: Output.json(),
+ // Anthropic has no schema-less JSON mode — the @ai-sdk/anthropic
+ // adapter drops responseFormat silently without a schema, letting
+ // Claude free-write multiple candidate JSONs. Passing a schema routes
+ // the call through Anthropic's tool-use API for enforced structured
+ // output. OpenAI and Google keep their working schema-less paths.
+ output:
+ resolved.provider === 'anthropic'
+ ? Output.object({ schema: schemaForFeature(feature) })
+ : Output.json(),
maxOutputTokens: phase === 'planner' ? 8192 : 4096,
providerOptions,
});
output = result.output;
text = result.text;
reasoningText = result.reasoningText;
+ usage = result.usage;
} catch (error) {
+ thrownError = error;
+ }
+
+ const completedAt = new Date().toISOString();
+ const durationMs = roundDuration(performance.now() - startPerfMs);
+
+ const llmCall: LLMCallTrace = {
+ provider: resolved.provider,
+ model: resolved.modelName,
+ feature: feature ?? phase,
+ prompt: messages,
+ completion: text,
+ usage: normalizeUsage(usage),
+ startedAt,
+ completedAt,
+ durationMs,
+ ...(thrownError
+ ? { statusMessage: thrownError instanceof Error ? thrownError.message : String(thrownError) }
+ : {}),
+ };
+
+ if (thrownError) {
throw (
- classifyFatalProviderError(error, {
- provider: this._provider,
- modelName: this._modelName,
- }) ?? error
+ classifyFatalProviderError(thrownError, {
+ provider: resolved.provider,
+ modelName: resolved.modelName,
+ }) ?? thrownError
);
}
if (reasoningText) {
Logger.d(
- `LLM reasoning [${phase}] (${this._provider}/${this._modelName}):\n${reasoningText}`,
+ `LLM reasoning [${feature}] (${resolved.provider}/${resolved.modelName}):\n${reasoningText}`,
);
}
Logger.d(
- `LLM response [${phase}] (${this._provider}/${this._modelName}):\n${text || ''}`,
+ `LLM response [${feature}] (${resolved.provider}/${resolved.modelName}):\n${text || ''}`,
);
- return { output, text };
+ return { output, text, llmCall };
+ }
+
+
+ /**
+ * Resolve the effective provider / model / reasoning for a feature by
+ * merging the optional per-feature override on top of workspace defaults.
+ */
+ private _resolveFeatureConfig(feature: FeatureName): ResolvedFeatureConfig {
+ const override = this._features[feature];
+ let provider = this._defaults.provider;
+ let modelName = this._defaults.modelName;
+ if (override?.model) {
+ // Reuse the shared parser so per-feature overrides fail with the same
+ // validation errors (empty provider/model, unsupported provider) as
+ // workspace-level `model:` and the `--model` CLI flag.
+ const parsed = parseModel(override.model, `features.${feature}.model`);
+ provider = parsed.provider;
+ modelName = parsed.modelName;
+ }
+ const reasoning: ReasoningLevel =
+ override?.reasoning ?? this._defaults.reasoning ?? DEFAULT_REASONING_BY_PHASE[phaseForFeature(feature)];
+ return { provider, modelName, reasoning };
}
/**
- * Create the appropriate Vercel AI SDK model instance.
+ * Create (or reuse a cached) Vercel AI SDK model instance for the
+ * resolved provider/modelName.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- private _getModel(): any {
- switch (this._provider) {
+ private _getModel(resolved: ResolvedFeatureConfig): any {
+ const cacheKey = `${resolved.provider}/${resolved.modelName}`;
+ const cached = this._clientCache.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+ const apiKey = this._apiKeys[resolved.provider];
+ if (!apiKey) {
+ throw new Error(
+ `Missing API key for provider "${resolved.provider}". Set the corresponding env var (e.g. OPENAI_API_KEY, GOOGLE_API_KEY, ANTHROPIC_API_KEY).`,
+ );
+ }
+ let client: unknown;
+ switch (resolved.provider) {
case 'openai': {
- const openai = createOpenAI({ apiKey: this._apiKey });
- return openai(this._modelName);
+ const openai = createOpenAI({ apiKey });
+ // Use the Responses API (not Chat Completions) so that
+ // `providerOptions.openai.reasoningEffort` is honored by reasoning
+ // models like gpt-5.4-mini. `openai(modelId)` defaults to Chat
+ // Completions and silently ignores reasoning effort.
+ client = openai.responses(resolved.modelName);
+ break;
}
case 'google': {
- const google = createGoogleGenerativeAI({ apiKey: this._apiKey });
- return google(this._modelName);
+ const google = createGoogleGenerativeAI({ apiKey });
+ client = google(resolved.modelName);
+ break;
}
case 'anthropic': {
- const anthropic = createAnthropic({ apiKey: this._apiKey });
- return anthropic(this._modelName);
+ const anthropic = createAnthropic({ apiKey });
+ client = anthropic(resolved.modelName);
+ break;
}
default:
- throw new Error(`Unsupported AI provider: ${this._provider}`);
+ throw new Error(`Unsupported AI provider: ${resolved.provider}`);
}
+ this._clientCache.set(cacheKey, client);
+ return client;
}
- private _getProviderOptions(phase: LLMPhase): AIAgentProviderOptions | undefined {
- switch (this._provider) {
- case 'google':
+ private _getProviderOptions(
+ resolved: ResolvedFeatureConfig,
+ feature: FeatureName,
+ ): AIAgentProviderOptions | undefined {
+ const { provider, reasoning } = resolved;
+ if (reasoning === 'minimal' && provider !== 'openai') {
+ throw new Error(
+ `Reasoning level "minimal" is only supported for OpenAI. Feature "${feature}" is configured for provider "${provider}".`,
+ );
+ }
+ switch (provider) {
+ case 'google': {
return {
google: {
thinkingConfig: {
- thinkingLevel: phase === 'planner' ? 'high' : 'medium',
+ thinkingLevel: reasoning as 'low' | 'medium' | 'high',
includeThoughts: false,
},
} satisfies GoogleLanguageModelOptions,
};
+ }
case 'openai':
return {
openai: {
- reasoningEffort: phase === 'planner' ? 'medium' : 'low',
+ reasoningEffort: reasoning,
} satisfies OpenAILanguageModelResponsesOptions,
};
case 'anthropic':
return {
anthropic: {
- effort: phase === 'planner' ? 'medium' : 'low',
+ effort: reasoning as 'low' | 'medium' | 'high',
+ // Force Anthropic's native structured-output API
+ // (`output_config.format`). The SDK's `auto` mode falls back to a
+ // `json` tool wrapper when its hardcoded model-capability table
+ // doesn't recognize the model — but that table lags behind new
+ // releases (e.g. Opus 4.7 isn't listed even though it supports
+ // structured output). Pinning `outputFormat` makes us forward-
+ // compatible with every Claude 4.5+ model without any
+ // model-version checks on our side.
+ structuredOutputMode: 'outputFormat',
} satisfies AnthropicLanguageModelOptions,
};
default:
@@ -676,7 +831,8 @@ export class AIAgent {
private _summarizePlannerRequest(req: PlannerRequest): string {
const parts: string[] = ['[AI plan]'];
parts.push(this._formatLogContext(req.logContext, req.traceStep));
- parts.push(`provider=${this._provider}/${this._modelName}`);
+ const plannerResolved = this._resolveFeatureConfig(FEATURE_PLANNER);
+ parts.push(`provider=${plannerResolved.provider}/${plannerResolved.modelName}`);
parts.push(this._screenshotMetric('screenshot', req.preActionScreenshot));
if (req.postActionScreenshot) {
parts.push(this._screenshotMetric('postScreenshot', req.postActionScreenshot));
@@ -696,7 +852,8 @@ export class AIAgent {
private _summarizeGrounderRequest(req: GrounderRequest): string {
const parts: string[] = ['[AI ground]'];
parts.push(this._formatLogContext(req.logContext, req.traceStep));
- parts.push(`provider=${this._provider}/${this._modelName}`);
+ const grounderResolved = this._resolveFeatureConfig(req.feature);
+ parts.push(`provider=${grounderResolved.provider}/${grounderResolved.modelName}`);
parts.push(`feature=${req.feature}`);
parts.push(this._screenshotMetric('screenshot', req.screenshot));
const hierarchyCount = req.hierarchy
@@ -952,3 +1109,38 @@ function normalizeBoolean(value: unknown): boolean | undefined {
function firstNonEmpty(...values: Array): string | undefined {
return values.find((value) => typeof value === 'string' && value.trim().length > 0);
}
+
+/**
+ * Convert the Vercel AI SDK's `LanguageModelUsage` (inputTokens/outputTokens
+ * with nested *TokenDetails) into the Langfuse canonical shape
+ * (input/output/total, optional input_cached_tokens only if > 0).
+ * Fields default to 0 when the provider omits them.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function normalizeUsage(usage: any): { input: number; output: number; total: number; input_cached_tokens?: number } {
+ const input =
+ typeof usage?.inputTokens === 'number'
+ ? usage.inputTokens
+ : typeof usage?.promptTokens === 'number'
+ ? usage.promptTokens
+ : 0;
+ const output =
+ typeof usage?.outputTokens === 'number'
+ ? usage.outputTokens
+ : typeof usage?.completionTokens === 'number'
+ ? usage.completionTokens
+ : 0;
+ const total =
+ typeof usage?.totalTokens === 'number'
+ ? usage.totalTokens
+ : input + output;
+
+ const cacheRead =
+ typeof usage?.inputTokenDetails?.cacheReadTokens === 'number'
+ ? usage.inputTokenDetails.cacheReadTokens
+ : undefined;
+
+ return cacheRead !== undefined && cacheRead > 0
+ ? { input, output, total, input_cached_tokens: cacheRead }
+ : { input, output, total };
+}
diff --git a/packages/goal-executor/src/ai/VisualGrounder.ts b/packages/goal-executor/src/ai/VisualGrounder.ts
index 52e3901..d8d823d 100644
--- a/packages/goal-executor/src/ai/VisualGrounder.ts
+++ b/packages/goal-executor/src/ai/VisualGrounder.ts
@@ -5,7 +5,7 @@
import { Logger } from '@finalrun/common';
import type { AIAgent } from './AIAgent.js';
import { FEATURE_VISUAL_GROUNDER } from '@finalrun/common';
-import type { LLMTrace } from '../trace.js';
+import type { LLMTrace, LLMCallTrace } from '../trace.js';
import { FatalProviderError } from './providerFailure.js';
export interface VisualGroundingResult {
@@ -14,6 +14,8 @@ export interface VisualGroundingResult {
y?: number;
reason?: string;
trace?: LLMTrace;
+ /** LLM call trace from the visual grounding attempt. */
+ llmCall?: LLMCallTrace;
}
/**
@@ -68,17 +70,28 @@ export class VisualGrounder {
y: output['y'] as number,
reason: output['reason'] as string,
trace: response.trace,
+ ...(response.llmCall ? { llmCall: response.llmCall } : {}),
};
}
// Check for error
if (output['isError']) {
Logger.w(`Visual grounding failed: ${output['reason']}`);
- return { success: false, reason: output['reason'] as string, trace: response.trace };
+ return {
+ success: false,
+ reason: output['reason'] as string,
+ trace: response.trace,
+ ...(response.llmCall ? { llmCall: response.llmCall } : {}),
+ };
}
Logger.w('Visual grounding returned unexpected format');
- return { success: false, reason: 'Unexpected response format', trace: response.trace };
+ return {
+ success: false,
+ reason: 'Unexpected response format',
+ trace: response.trace,
+ ...(response.llmCall ? { llmCall: response.llmCall } : {}),
+ };
} catch (error) {
if (FatalProviderError.isInstance(error)) {
throw error;
diff --git a/packages/goal-executor/src/ai/schemas.test.ts b/packages/goal-executor/src/ai/schemas.test.ts
new file mode 100644
index 0000000..3d19128
--- /dev/null
+++ b/packages/goal-executor/src/ai/schemas.test.ts
@@ -0,0 +1,221 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ ALL_FEATURES,
+ FEATURE_GROUNDER,
+ FEATURE_INPUT_FOCUS_GROUNDER,
+ FEATURE_LAUNCH_APP_GROUNDER,
+ FEATURE_PLANNER,
+ FEATURE_SCROLL_INDEX_GROUNDER,
+ FEATURE_SET_LOCATION_GROUNDER,
+ FEATURE_VISUAL_GROUNDER,
+} from '@finalrun/common';
+import { PLANNER_SCHEMA, schemaForFeature } from './schemas.js';
+
+// Schemas describe the inner shape directly — no outer `output` wrapper.
+// See schemas.ts for why.
+
+// ----------------------------------------------------------------------------
+// Planner
+// ----------------------------------------------------------------------------
+
+test('planner schema accepts the canonical wait example from planner.md', () => {
+ const payload = {
+ thought: {
+ plan: '[→ Wait for app to load]',
+ think: 'App is on splash screen; need to wait.',
+ act: 'Wait 5 seconds for the app to load.',
+ },
+ action: { action_type: 'wait', duration: 5 },
+ remember: [],
+ };
+ assert.equal(PLANNER_SCHEMA.safeParse(payload).success, true);
+});
+
+test('planner schema accepts each documented action_type', () => {
+ const types = [
+ 'tap',
+ 'long_press',
+ 'input_text',
+ 'swipe',
+ 'navigate_back',
+ 'navigate_home',
+ 'rotate',
+ 'hide_keyboard',
+ 'keyboard_enter',
+ 'wait',
+ 'deep_link',
+ 'set_location',
+ 'launch_app',
+ 'status',
+ ];
+ for (const t of types) {
+ const payload = { action: { action_type: t } };
+ assert.equal(
+ PLANNER_SCHEMA.safeParse(payload).success,
+ true,
+ `expected ${t} to be accepted`,
+ );
+ }
+});
+
+test('planner schema accepts passthrough action fields (repeat, delay_between_tap)', () => {
+ const payload = {
+ action: {
+ action_type: 'tap',
+ repeat: 3,
+ delay_between_tap: 1000,
+ },
+ remember: [],
+ };
+ assert.equal(PLANNER_SCHEMA.safeParse(payload).success, true);
+});
+
+test('planner schema rejects an unknown action_type', () => {
+ const payload = { action: { action_type: 'click' } };
+ const result = PLANNER_SCHEMA.safeParse(payload);
+ assert.equal(result.success, false);
+});
+
+test('planner schema rejects a payload with an outer output wrapper', () => {
+ // Guard against reintroducing the double-wrapping bug.
+ const payload = {
+ output: {
+ action: { action_type: 'tap' },
+ remember: [],
+ },
+ };
+ assert.equal(PLANNER_SCHEMA.safeParse(payload).success, false);
+});
+
+// ----------------------------------------------------------------------------
+// Grounder — each feature's success and error shapes
+// ----------------------------------------------------------------------------
+
+test('grounder schema accepts index match, needsVisualGrounding, and error variants', () => {
+ const schema = schemaForFeature(FEATURE_GROUNDER);
+ assert.equal(schema.safeParse({ index: 5, reason: 'match' }).success, true);
+ assert.equal(
+ schema.safeParse({ needsVisualGrounding: true, reason: 'not in list' })
+ .success,
+ true,
+ );
+ assert.equal(
+ schema.safeParse({ isError: true, reason: 'not visible' }).success,
+ true,
+ );
+});
+
+test('input-focus grounder schema accepts index, x/y, null-index, and error', () => {
+ const schema = schemaForFeature(FEATURE_INPUT_FOCUS_GROUNDER);
+ assert.equal(
+ schema.safeParse({ index: 42, reason: 'match' }).success,
+ true,
+ );
+ assert.equal(
+ schema.safeParse({ index: null, reason: 'already focused' }).success,
+ true,
+ );
+ assert.equal(
+ schema.safeParse({ x: 100, y: 200, reason: 'derived' }).success,
+ true,
+ );
+ assert.equal(
+ schema.safeParse({ isError: true, reason: 'not found' }).success,
+ true,
+ );
+});
+
+test('visual grounder schema accepts coordinates and error', () => {
+ const schema = schemaForFeature(FEATURE_VISUAL_GROUNDER);
+ assert.equal(
+ schema.safeParse({ x: 540, y: 1200, reason: 'center of label' }).success,
+ true,
+ );
+ assert.equal(
+ schema.safeParse({ isError: true, reason: 'not visible' }).success,
+ true,
+ );
+});
+
+test('scroll-index grounder schema accepts swipe vector and error', () => {
+ const schema = schemaForFeature(FEATURE_SCROLL_INDEX_GROUNDER);
+ assert.equal(
+ schema.safeParse({
+ start_x: 540,
+ start_y: 1800,
+ end_x: 540,
+ end_y: 400,
+ durationMs: 600,
+ reason: 'swipe up',
+ }).success,
+ true,
+ );
+ assert.equal(
+ schema.safeParse({ isError: true, reason: 'no container' }).success,
+ true,
+ );
+});
+
+test('launch-app grounder schema accepts minimal and full payloads', () => {
+ const schema = schemaForFeature(FEATURE_LAUNCH_APP_GROUNDER);
+ assert.equal(
+ schema.safeParse({ packageName: 'com.whatsapp', reason: 'exact match' })
+ .success,
+ true,
+ );
+ assert.equal(
+ schema.safeParse({
+ packageName: 'com.example.myapp',
+ clearState: true,
+ allowAllPermissions: false,
+ permissions: { camera: 'allow', photos: 'allow' },
+ reason: 'full config',
+ }).success,
+ true,
+ );
+ assert.equal(
+ schema.safeParse({ isError: true, reason: 'not found' }).success,
+ true,
+ );
+});
+
+test('set-location grounder schema accepts string coords and error', () => {
+ const schema = schemaForFeature(FEATURE_SET_LOCATION_GROUNDER);
+ assert.equal(
+ schema.safeParse({ lat: '37.7749', long: '-122.4194', reason: 'SF' })
+ .success,
+ true,
+ );
+ assert.equal(
+ schema.safeParse({ isError: true, reason: 'unresolved' }).success,
+ true,
+ );
+});
+
+test('set-location grounder schema rejects numeric lat/long (spec requires strings)', () => {
+ const schema = schemaForFeature(FEATURE_SET_LOCATION_GROUNDER);
+ assert.equal(
+ schema.safeParse({ lat: 37.7749, long: -122.4194, reason: 'numeric' })
+ .success,
+ false,
+ );
+});
+
+// ----------------------------------------------------------------------------
+// Lookup
+// ----------------------------------------------------------------------------
+
+test('schemaForFeature returns a schema for every known feature', () => {
+ for (const feature of ALL_FEATURES) {
+ assert.ok(schemaForFeature(feature), `missing schema for ${feature}`);
+ }
+ // Keep individual imports live so a future rename lands a compile error here.
+ void FEATURE_PLANNER;
+ void FEATURE_GROUNDER;
+ void FEATURE_VISUAL_GROUNDER;
+ void FEATURE_SCROLL_INDEX_GROUNDER;
+ void FEATURE_INPUT_FOCUS_GROUNDER;
+ void FEATURE_LAUNCH_APP_GROUNDER;
+ void FEATURE_SET_LOCATION_GROUNDER;
+});
diff --git a/packages/goal-executor/src/ai/schemas.ts b/packages/goal-executor/src/ai/schemas.ts
new file mode 100644
index 0000000..b0435bb
--- /dev/null
+++ b/packages/goal-executor/src/ai/schemas.ts
@@ -0,0 +1,211 @@
+// Zod schemas for LLM structured output on the Anthropic path.
+//
+// The Vercel AI SDK's Anthropic adapter (`@ai-sdk/anthropic`) cannot enforce
+// JSON output without a schema — Anthropic has no schema-less JSON mode. When
+// a schema is supplied, the adapter routes through Anthropic's structured-
+// output APIs (`output_format` or a `json` tool, depending on
+// `structuredOutputMode`) so Claude emits exactly one well-formed JSON object.
+//
+// OpenAI (`response_format: json_object`) and Google
+// (`response_mime_type: application/json`) work schema-less today, so this
+// file is only consumed on the Anthropic call path in `AIAgent._callLLM`.
+//
+// IMPORTANT — no outer `output` wrapper.
+// The prompts tell the model to emit `{"output": {...}}` because OpenAI and
+// Google are in text-JSON mode and the parsers look for that convention.
+// On the Anthropic structured-output path, the schema IS the shape of the
+// tool call arguments (or the `output_format` payload) — adding an `output`
+// wrapper here causes Claude to nest twice: `{"output":{"output":{...}}}`.
+// Schemas below describe the inner shape directly; `_parsePlannerResponse`
+// and `_parseGrounderResponse` already accept both wrapped and unwrapped
+// shapes via their fallback branches.
+//
+// Each schema mirrors the corresponding prompt in `src/prompts/*.md`. When
+// a prompt changes, update the matching schema here.
+
+import { z } from 'zod';
+import {
+ FEATURE_GROUNDER,
+ FEATURE_INPUT_FOCUS_GROUNDER,
+ FEATURE_LAUNCH_APP_GROUNDER,
+ FEATURE_PLANNER,
+ FEATURE_SCROLL_INDEX_GROUNDER,
+ FEATURE_SET_LOCATION_GROUNDER,
+ FEATURE_VISUAL_GROUNDER,
+ type FeatureName,
+} from '@finalrun/common';
+
+// ----------------------------------------------------------------------------
+// Planner — canonical shape from `prompts/planner.md`
+// ----------------------------------------------------------------------------
+
+const PLANNER_ACTION_TYPES = [
+ 'tap',
+ 'long_press',
+ 'input_text',
+ 'swipe',
+ 'navigate_back',
+ 'navigate_home',
+ 'rotate',
+ 'hide_keyboard',
+ 'keyboard_enter',
+ 'wait',
+ 'deep_link',
+ 'set_location',
+ 'launch_app',
+ 'status',
+] as const;
+
+const plannerActionSchema = z
+ .object({
+ action_type: z.enum(PLANNER_ACTION_TYPES),
+ })
+ .passthrough();
+
+const plannerThoughtSchema = z
+ .object({
+ plan: z.string().optional(),
+ think: z.string().optional(),
+ act: z.string().optional(),
+ })
+ .passthrough();
+
+export const PLANNER_SCHEMA = z.object({
+ thought: plannerThoughtSchema.optional(),
+ action: plannerActionSchema,
+ remember: z.array(z.string()).optional(),
+});
+
+// ----------------------------------------------------------------------------
+// Grounder — per-feature shapes from the grounder prompt files
+// ----------------------------------------------------------------------------
+
+// Numeric fields use plain z.number() — Anthropic's tool-schema validator
+// rejects `minimum`/`maximum` keywords on the `integer` type, and zod v4's
+// .int() emits those bounds by default. Downstream parsers already coerce
+// to integers where needed (ActionExecutor + GrounderResponseConverter).
+
+const errorShape = z.object({
+ isError: z.literal(true),
+ reason: z.string(),
+});
+
+// `FEATURE_GROUNDER` — `prompts/grounder.md`
+// Three success variants: visual-fallback, index match, or error.
+const grounderSchema = z.union([
+ errorShape,
+ z
+ .object({
+ needsVisualGrounding: z.literal(true),
+ reason: z.string(),
+ })
+ .passthrough(),
+ z
+ .object({
+ index: z.number(),
+ reason: z.string().optional(),
+ })
+ .passthrough(),
+]);
+
+// `FEATURE_INPUT_FOCUS_GROUNDER` — `prompts/input-focus-grounder.md`
+// Variants: index match, null index (already focused), x/y coords, or error.
+const inputFocusGrounderSchema = z.union([
+ errorShape,
+ z
+ .object({
+ index: z.number().nullable(),
+ reason: z.string().optional(),
+ })
+ .passthrough(),
+ z
+ .object({
+ x: z.number(),
+ y: z.number(),
+ reason: z.string().optional(),
+ })
+ .passthrough(),
+]);
+
+// `FEATURE_VISUAL_GROUNDER` — `prompts/visual-grounder.md`
+const visualGrounderSchema = z.union([
+ errorShape,
+ z
+ .object({
+ x: z.number(),
+ y: z.number(),
+ reason: z.string().optional(),
+ })
+ .passthrough(),
+]);
+
+// `FEATURE_SCROLL_INDEX_GROUNDER` — `prompts/scroll-grounder.md`
+const scrollIndexGrounderSchema = z.union([
+ errorShape,
+ z
+ .object({
+ start_x: z.number(),
+ start_y: z.number(),
+ end_x: z.number(),
+ end_y: z.number(),
+ durationMs: z.number(),
+ reason: z.string().optional(),
+ })
+ .passthrough(),
+]);
+
+// `FEATURE_LAUNCH_APP_GROUNDER` — `prompts/launch-app-grounder.md`
+// Keep permissions and arguments as permissive records; the prompt documents
+// free-form values.
+const launchAppGrounderSchema = z.union([
+ errorShape,
+ z
+ .object({
+ packageName: z.string(),
+ reason: z.string().optional(),
+ clearState: z.boolean().optional(),
+ allowAllPermissions: z.boolean().optional(),
+ stopAppBeforeLaunch: z.boolean().optional(),
+ shouldUninstallBeforeLaunch: z.boolean().optional(),
+ permissions: z.record(z.string(), z.string()).optional(),
+ arguments: z.record(z.string(), z.string()).optional(),
+ })
+ .passthrough(),
+]);
+
+// `FEATURE_SET_LOCATION_GROUNDER` — `prompts/set-location-grounder.md`
+// lat/long are strings by spec (4-6 decimal places).
+const setLocationGrounderSchema = z.union([
+ errorShape,
+ z
+ .object({
+ lat: z.string(),
+ long: z.string(),
+ reason: z.string().optional(),
+ })
+ .passthrough(),
+]);
+
+// ----------------------------------------------------------------------------
+// Lookup
+// ----------------------------------------------------------------------------
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const FEATURE_SCHEMAS: Record> = {
+ [FEATURE_PLANNER]: PLANNER_SCHEMA,
+ [FEATURE_GROUNDER]: grounderSchema,
+ [FEATURE_INPUT_FOCUS_GROUNDER]: inputFocusGrounderSchema,
+ [FEATURE_VISUAL_GROUNDER]: visualGrounderSchema,
+ [FEATURE_SCROLL_INDEX_GROUNDER]: scrollIndexGrounderSchema,
+ [FEATURE_LAUNCH_APP_GROUNDER]: launchAppGrounderSchema,
+ [FEATURE_SET_LOCATION_GROUNDER]: setLocationGrounderSchema,
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function schemaForFeature(feature: FeatureName): z.ZodType {
+ const schema = FEATURE_SCHEMAS[feature];
+ if (!schema) {
+ throw new Error(`No schema registered for feature "${feature}".`);
+ }
+ return schema;
+}
diff --git a/packages/goal-executor/src/index.ts b/packages/goal-executor/src/index.ts
index b727e17..c19d71e 100644
--- a/packages/goal-executor/src/index.ts
+++ b/packages/goal-executor/src/index.ts
@@ -36,4 +36,5 @@ export type {
SpanTiming,
TimingMetadata,
LLMTrace,
+ LLMCallTrace,
} from './trace.js';
diff --git a/packages/goal-executor/src/trace.ts b/packages/goal-executor/src/trace.ts
index b7a31ae..6c1a78a 100644
--- a/packages/goal-executor/src/trace.ts
+++ b/packages/goal-executor/src/trace.ts
@@ -22,6 +22,44 @@ export interface LLMTrace {
parseMs: number;
}
+/**
+ * Per-LLM-call observability data — prompt, response, tokens, timing.
+ * Populated in AIAgent._callLLM() and bubbled up to TestExecutor so
+ * consumers (cloud-server) can forward to observability backends
+ * (e.g., Langfuse) without agent itself depending on any SDK.
+ *
+ * Field names mirror Langfuse's canonical ingestion schema to make
+ * forwarding a straight pass-through on the consumer side.
+ */
+export interface LLMCallTrace {
+ /** AI provider: 'openai' | 'google' | 'anthropic'. */
+ provider: string;
+ /** Full model name, e.g. 'gpt-4.1-mini', 'gemini-2.0-flash'. */
+ model: string;
+ /** Logical feature the call served: 'planner', 'grounder', 'visual_grounder', etc. */
+ feature: string;
+ /** Full prompt as the provider saw it — array of role/content messages (includes any base64 images inline). */
+ prompt: unknown;
+ /** Raw model response text. */
+ completion: string;
+ /** Normalized token counts (Langfuse canonical names — input/output/total). */
+ usage: {
+ input: number;
+ output: number;
+ total: number;
+ /** Only present if the provider reported cache-read input tokens > 0. */
+ input_cached_tokens?: number;
+ };
+ /** ISO-8601 timestamp when the call started. */
+ startedAt: string;
+ /** ISO-8601 timestamp when the call returned or errored. */
+ completedAt: string;
+ /** Wall-clock duration of the LLM call in ms. */
+ durationMs: number;
+ /** Provider error message, if the call threw. */
+ statusMessage?: string;
+}
+
export interface ActiveTracePhase {
phase: string;
startedAt: number;
diff --git a/packages/local-runtime/.gitignore b/packages/local-runtime/.gitignore
new file mode 100644
index 0000000..849ddff
--- /dev/null
+++ b/packages/local-runtime/.gitignore
@@ -0,0 +1 @@
+dist/
diff --git a/packages/local-runtime/package.json b/packages/local-runtime/package.json
new file mode 100644
index 0000000..d098303
--- /dev/null
+++ b/packages/local-runtime/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@finalrun/local-runtime",
+ "version": "0.1.7",
+ "private": true,
+ "description": "Per-platform runtime bundle for local FinalRun test execution. Builds finalrun-runtime--.tar.gz with on-disk assets (driver APKs/iOS zips, gRPC proto, Vite SPA dist) that the Bun-compiled CLI binary needs at runtime. Not published to npm — uploaded to GitHub Releases.",
+ "license": "Apache-2.0",
+ "scripts": {
+ "build:tarball": "node scripts/buildRuntimeTarball.mjs"
+ }
+}
diff --git a/packages/local-runtime/scripts/buildRuntimeTarball.mjs b/packages/local-runtime/scripts/buildRuntimeTarball.mjs
new file mode 100644
index 0000000..d1de1ac
--- /dev/null
+++ b/packages/local-runtime/scripts/buildRuntimeTarball.mjs
@@ -0,0 +1,195 @@
+#!/usr/bin/env node
+// Assemble a per-platform runtime tarball that the FinalRun CLI extracts to
+// ~/.finalrun/runtime// when local commands need their dependencies.
+//
+// Usage: node scripts/buildRuntimeTarball.mjs --target=
+// ∈ darwin-arm64 | darwin-x64 | linux-x64 | linux-arm64
+//
+// Output: packages/local-runtime/dist/finalrun-runtime--.tar.gz
+//
+// The tarball layout:
+// manifest.json # version, platform, file sha256 sums
+// install-resources/ # driver APKs (always) + iOS zips (darwin only)
+// proto/finalrun/driver.proto # gRPC schema
+// report-app/ # Vite SPA dist for the local report server
+//
+// We do NOT ship node_modules: the Bun-compiled CLI binary bundles all
+// JS module code (goal-executor, device-node, ai-sdk, grpc, etc.) into
+// itself. The runtime tarball only carries non-JS assets that need to live
+// on disk for the binary's runtime resolvers (driver APKs, the Vite SPA
+// dist served by the local report server, the gRPC .proto schema).
+
+import {
+ copyFileSync,
+ cpSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from 'node:fs';
+import { createHash } from 'node:crypto';
+import { spawnSync } from 'node:child_process';
+import { dirname, join, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const SUPPORTED_TARGETS = new Set([
+ 'darwin-arm64',
+ 'darwin-x64',
+ 'linux-x64',
+ 'linux-arm64',
+ 'windows-x64',
+]);
+
+function parseArgs() {
+ const targetArg = process.argv.find((a) => a.startsWith('--target='));
+ if (!targetArg) {
+ bail(
+ 'Missing --target=. Supported platforms: ' +
+ [...SUPPORTED_TARGETS].join(', '),
+ );
+ }
+ const target = targetArg.slice('--target='.length);
+ if (!SUPPORTED_TARGETS.has(target)) {
+ bail(
+ `Unsupported target: ${target}. Supported: ` +
+ [...SUPPORTED_TARGETS].join(', '),
+ );
+ }
+ return { target };
+}
+
+function bail(message) {
+ console.error(`[build-runtime] ${message}`);
+ process.exit(1);
+}
+
+const { target } = parseArgs();
+const isDarwin = target.startsWith('darwin');
+
+const here = dirname(fileURLToPath(import.meta.url));
+const packageRoot = resolve(here, '..');
+const repoRoot = resolve(packageRoot, '..', '..');
+const cliPackageJson = JSON.parse(
+ readFileSync(resolve(repoRoot, 'packages/cli/package.json'), 'utf8'),
+);
+const VERSION = cliPackageJson.version;
+
+const dist = resolve(packageRoot, 'dist');
+const stagingDir = resolve(dist, `staging-${target}`);
+const tarballPath = resolve(
+ dist,
+ `finalrun-runtime-${VERSION}-${target}.tar.gz`,
+);
+
+mkdirSync(dist, { recursive: true });
+rmSync(stagingDir, { recursive: true, force: true });
+mkdirSync(stagingDir, { recursive: true });
+rmSync(tarballPath, { force: true });
+
+// 1. Copy install-resources. iOS bits only ship in darwin tarballs.
+console.log('[build-runtime] Copying install-resources...');
+const installResourcesSource = resolve(repoRoot, 'resources');
+const installResourcesTarget = resolve(stagingDir, 'install-resources');
+mkdirSync(installResourcesTarget, { recursive: true });
+const androidAssets = ['android/app-debug.apk', 'android/app-debug-androidTest.apk'];
+const iosAssets = ['ios/finalrun-ios.zip', 'ios/finalrun-ios-test-Runner.zip'];
+const assetsToCopy = [...androidAssets, ...(isDarwin ? iosAssets : [])];
+// Hard-fail on any missing required asset rather than shipping a half-broken
+// tarball — the install-resources files are what local commands actually
+// look for at runtime, so a silent omission yields confusing
+// "X driver bundle is missing" doctor output later.
+const missingAssets = assetsToCopy.filter(
+ (asset) => !existsSync(resolve(installResourcesSource, asset)),
+);
+if (missingAssets.length > 0) {
+ bail(
+ `Missing required runtime assets for ${target}:\n` +
+ missingAssets.map((a) => ` - ${resolve(installResourcesSource, a)}`).join('\n') +
+ `\nBuild driver bundles first: \`npm run build:drivers\` at the repo root.`,
+ );
+}
+for (const asset of assetsToCopy) {
+ const source = resolve(installResourcesSource, asset);
+ const target = resolve(installResourcesTarget, asset);
+ mkdirSync(dirname(target), { recursive: true });
+ copyFileSync(source, target);
+}
+
+// 2. Copy proto.
+const protoSource = resolve(repoRoot, 'proto/finalrun/driver.proto');
+const protoTarget = resolve(stagingDir, 'proto/finalrun/driver.proto');
+if (!existsSync(protoSource)) bail(`Missing proto at ${protoSource}`);
+mkdirSync(dirname(protoTarget), { recursive: true });
+copyFileSync(protoSource, protoTarget);
+
+// 3. Copy report-app SPA.
+console.log('[build-runtime] Copying report-app SPA...');
+const reportAppSource = resolve(repoRoot, 'packages/report-web/dist/app');
+const reportAppTarget = resolve(stagingDir, 'report-app');
+if (!existsSync(reportAppSource)) {
+ bail(`Missing report-web/dist/app at ${reportAppSource} — run \`npm run build --workspace=@finalrun/report-web\` first.`);
+}
+cpSync(reportAppSource, reportAppTarget, { recursive: true });
+
+// 4. Compute manifest.
+console.log('[build-runtime] Computing sha256 manifest...');
+const manifestEntries = [];
+walk(stagingDir, (filePath) => {
+ const rel = relative(stagingDir, filePath).split('\\').join('/');
+ if (rel === 'manifest.json') return;
+ const buf = readFileSync(filePath);
+ const sha256 = createHash('sha256').update(buf).digest('hex');
+ manifestEntries.push({ path: rel, sha256, size: buf.length });
+});
+manifestEntries.sort((a, b) => a.path.localeCompare(b.path));
+const manifest = {
+ version: VERSION,
+ platform: target,
+ generatedAt: new Date().toISOString(),
+ files: manifestEntries,
+};
+writeFileSync(
+ resolve(stagingDir, 'manifest.json'),
+ JSON.stringify(manifest, null, 2) + '\n',
+ 'utf8',
+);
+
+function walk(root, fn) {
+ for (const name of readdirSync(root)) {
+ const full = join(root, name);
+ const s = statSync(full);
+ if (s.isDirectory()) walk(full, fn);
+ else fn(full);
+ }
+}
+
+// 5. tar -czf.
+console.log(`[build-runtime] Creating tarball at ${tarballPath}...`);
+const tar = spawnSync(
+ 'tar',
+ ['-czf', tarballPath, '-C', stagingDir, '.'],
+ { stdio: 'inherit' },
+);
+if (tar.status !== 0) {
+ bail(`tar exited with status ${tar.status}`);
+}
+
+// 6. Final sha256 of the tarball itself, for the install script to verify.
+const tarballSha = createHash('sha256').update(readFileSync(tarballPath)).digest('hex');
+const tarballSize = statSync(tarballPath).size;
+console.log('');
+console.log(`✓ Built ${tarballPath}`);
+console.log(` size: ${(tarballSize / (1024 * 1024)).toFixed(1)} MB`);
+console.log(` sha256: ${tarballSha}`);
+console.log('');
+writeFileSync(
+ `${tarballPath}.sha256`,
+ `${tarballSha} ${tarballPath.split('/').pop()}\n`,
+ 'utf8',
+);
+
+// Leave staging dir on disk for inspection; safe to rm-rf manually.
+console.log(`(staging dir kept for inspection: ${stagingDir})`);
diff --git a/packages/report-web/app/artifacts/[...artifactPath]/route.ts b/packages/report-web/app/artifacts/[...artifactPath]/route.ts
deleted file mode 100644
index 561657e..0000000
--- a/packages/report-web/app/artifacts/[...artifactPath]/route.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import {
- ArtifactRangeNotSatisfiableError,
- loadArtifactResponse,
- renderHtmlErrorPage,
-} from '../../../src/artifacts';
-
-export const runtime = 'nodejs';
-export const dynamic = 'force-dynamic';
-
-export async function GET(
- request: Request,
- context: { params: Promise<{ artifactPath?: string[] }> },
-): Promise {
- return await handleArtifactRequest(request, context, false);
-}
-
-export async function HEAD(
- request: Request,
- context: { params: Promise<{ artifactPath?: string[] }> },
-): Promise {
- return await handleArtifactRequest(request, context, true);
-}
-
-async function handleArtifactRequest(
- request: Request,
- context: { params: Promise<{ artifactPath?: string[] }> },
- headOnly: boolean,
-): Promise {
- try {
- const { artifactPath = [] } = await context.params;
- const artifact = await loadArtifactResponse(artifactPath, request.headers.get('range'));
- return new Response(headOnly ? null : artifact.body, {
- status: artifact.status,
- headers: {
- ...artifact.headers,
- 'cache-control': 'no-store',
- },
- });
- } catch (error) {
- if (error instanceof ArtifactRangeNotSatisfiableError) {
- return new Response(headOnly ? null : 'Requested range is not satisfiable.', {
- status: 416,
- headers: {
- 'content-range': `bytes */${error.size}`,
- 'content-type': 'text/plain; charset=utf-8',
- 'cache-control': 'no-store',
- },
- });
- }
- return new Response(
- renderHtmlErrorPage({
- title: 'Artifact Not Found',
- message: error instanceof Error ? error.message : String(error),
- }),
- {
- status: 404,
- headers: {
- 'content-type': 'text/html; charset=utf-8',
- 'cache-control': 'no-store',
- },
- },
- );
- }
-}
diff --git a/packages/report-web/app/health/route.ts b/packages/report-web/app/health/route.ts
deleted file mode 100644
index c65ee12..0000000
--- a/packages/report-web/app/health/route.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { NextResponse } from 'next/server';
-import { resolveReportWorkspaceContext } from '../../src/artifacts';
-
-export const runtime = 'nodejs';
-
-export async function GET(): Promise {
- try {
- const context = resolveReportWorkspaceContext();
- return NextResponse.json({
- status: 'ok',
- workspaceRoot: context.workspaceRoot,
- artifactsDir: context.artifactsDir,
- pid: process.pid,
- });
- } catch (error) {
- return NextResponse.json(
- {
- status: 'error',
- message: error instanceof Error ? error.message : String(error),
- },
- { status: 500 },
- );
- }
-}
diff --git a/packages/report-web/app/route.ts b/packages/report-web/app/route.ts
deleted file mode 100644
index 7522452..0000000
--- a/packages/report-web/app/route.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { NextResponse } from 'next/server';
-import { loadReportIndexViewModel, renderHtmlErrorPage } from '../src/artifacts';
-import { renderRunIndexHtml } from '../src/renderers';
-
-export const runtime = 'nodejs';
-
-export async function GET(): Promise {
- try {
- const index = await loadReportIndexViewModel();
- return new NextResponse(renderRunIndexHtml(index), {
- headers: {
- 'content-type': 'text/html; charset=utf-8',
- },
- });
- } catch (error) {
- return new NextResponse(
- renderHtmlErrorPage({
- title: 'Report Server Error',
- message: error instanceof Error ? error.message : String(error),
- }),
- {
- status: 500,
- headers: {
- 'content-type': 'text/html; charset=utf-8',
- },
- },
- );
- }
-}
diff --git a/packages/report-web/app/runs/[runId]/route.ts b/packages/report-web/app/runs/[runId]/route.ts
deleted file mode 100644
index 79a5485..0000000
--- a/packages/report-web/app/runs/[runId]/route.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { NextResponse } from 'next/server';
-import { loadReportRunManifestViewModel, renderHtmlErrorPage } from '../../../src/artifacts';
-import { renderRunHtml } from '../../../src/renderers';
-
-export const runtime = 'nodejs';
-
-export async function GET(
- _request: Request,
- context: { params: Promise<{ runId: string }> },
-): Promise {
- try {
- const { runId } = await context.params;
- const manifest = await loadReportRunManifestViewModel(runId);
- return new NextResponse(renderRunHtml(manifest), {
- headers: {
- 'content-type': 'text/html; charset=utf-8',
- },
- });
- } catch (error) {
- return new NextResponse(
- renderHtmlErrorPage({
- title: 'Run Not Found',
- message: error instanceof Error ? error.message : String(error),
- }),
- {
- status: 404,
- headers: {
- 'content-type': 'text/html; charset=utf-8',
- },
- },
- );
- }
-}
diff --git a/packages/report-web/index.html b/packages/report-web/index.html
new file mode 100644
index 0000000..b12517a
--- /dev/null
+++ b/packages/report-web/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ FinalRun Reports
+
+
+
+
+
+
+
+
+
diff --git a/packages/report-web/next-env.d.ts b/packages/report-web/next-env.d.ts
deleted file mode 100644
index 9edff1c..0000000
--- a/packages/report-web/next-env.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-///
-///
-import "./.next/types/routes.d.ts";
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/report-web/next.config.ts b/packages/report-web/next.config.ts
deleted file mode 100644
index fae17c7..0000000
--- a/packages/report-web/next.config.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { NextConfig } from 'next';
-
-const nextConfig: NextConfig = {
- transpilePackages: ['@finalrun/common'],
- turbopack: {
- root: __dirname,
- },
-};
-
-export default nextConfig;
diff --git a/packages/report-web/package.json b/packages/report-web/package.json
index 5a023e3..2f2a9c5 100644
--- a/packages/report-web/package.json
+++ b/packages/report-web/package.json
@@ -1,24 +1,56 @@
{
"name": "@finalrun/report-web",
- "version": "0.1.6",
- "private": true,
- "description": "Local FinalRun report app",
+ "version": "0.2.0",
+ "description": "Local FinalRun report SPA + importable React UI library",
+ "license": "Apache-2.0",
+ "sideEffects": [
+ "**/*.css"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "exports": {
+ "./ui": {
+ "types": "./dist/ui/index.d.mts",
+ "import": "./dist/ui/index.mjs"
+ },
+ "./ui/styles.css": "./dist/ui/styles.css",
+ "./routes": {
+ "types": "./dist/routes/index.d.mts",
+ "import": "./dist/routes/index.mjs"
+ }
+ },
+ "files": [
+ "dist",
+ "src/ui",
+ "src/routes",
+ "src/fetchers.ts",
+ "src/artifacts.ts"
+ ],
"scripts": {
- "dev": "next dev --webpack --hostname 127.0.0.1 --port 4173",
- "build": "next build --webpack",
- "start": "next start --hostname 127.0.0.1 --port 4173",
- "test": "tsx --test src/**/*.test.ts"
+ "dev": "vite",
+ "build:app": "vite build",
+ "build:lib": "tsup && node scripts/build-styles.mjs",
+ "build": "npm run build:lib && npm run build:app",
+ "preview": "vite preview",
+ "test": "tsx --test src/**/*.test.ts",
+ "prepublishOnly": "npm run build"
},
"dependencies": {
- "@finalrun/common": "*",
- "next": "^16.2.1",
+ "@finalrun/common": "^0.1.7",
+ "lightningcss": "^1.32.0",
"react": "^19.2.0",
- "react-dom": "^19.2.0"
+ "react-dom": "^19.2.0",
+ "react-router-dom": "^7.14.2",
+ "rolldown": "^1.0.0-rc.17"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "tsup": "^8.5.1",
"tsx": "^4.19.0",
- "typescript": "^5.9.0"
+ "typescript": "^6.0.3",
+ "vite": "^8.0.10"
}
}
diff --git a/packages/report-web/scripts/build-styles.mjs b/packages/report-web/scripts/build-styles.mjs
new file mode 100644
index 0000000..f2e66c3
--- /dev/null
+++ b/packages/report-web/scripts/build-styles.mjs
@@ -0,0 +1,31 @@
+// Concats the three source stylesheets into dist/ui/styles.css for consumers.
+// Order matters: shared.css defines the theme tokens + resets that the page
+// stylesheets reference via var(--token).
+
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(here, '..');
+
+const files = [
+ 'src/ui/styles/shared.css',
+ 'src/ui/styles/run-detail.css',
+ 'src/ui/styles/run-index.css',
+];
+
+const outPath = path.join(root, 'dist/ui/styles.css');
+await mkdir(path.dirname(outPath), { recursive: true });
+
+const parts = [];
+for (const rel of files) {
+ const abs = path.join(root, rel);
+ const body = await readFile(abs, 'utf8');
+ parts.push(`/* === ${rel} === */`);
+ parts.push(body.trim());
+ parts.push('');
+}
+
+await writeFile(outPath, parts.join('\n') + '\n');
+console.log(`✓ wrote ${path.relative(root, outPath)} (${parts.length} sections)`);
diff --git a/packages/report-web/scripts/scope-css.mjs b/packages/report-web/scripts/scope-css.mjs
new file mode 100644
index 0000000..f7f2176
--- /dev/null
+++ b/packages/report-web/scripts/scope-css.mjs
@@ -0,0 +1,102 @@
+// One-off: prefix every selector in run-index.css and run-detail.css with
+// `.fr-report-ui ` so the library's CSS can't leak into host apps.
+//
+// Rules kept simple intentionally:
+// - Any line whose contents end with `{` and doesn't start with `@`, `:`,
+// `}` or `/*` is treated as a selector list.
+// - Each comma-separated selector in that list gets the prefix, except
+// pseudo-element declarations like `::-webkit-scrollbar` which are
+// attached to the nearest prior selector (handled naturally by
+// comma-splitting since they're on a single line).
+// - @media blocks are untouched; their inner selectors still get scoped
+// because the line-by-line scan continues inside them.
+//
+// Safe to re-run: the script bails if the file already contains the prefix.
+
+import { readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(here, '..');
+const PREFIX = '.fr-report-ui ';
+
+const files = ['src/ui/styles/run-index.css', 'src/ui/styles/run-detail.css'];
+
+for (const rel of files) {
+ const abs = path.join(root, rel);
+ const src = await readFile(abs, 'utf8');
+
+ if (src.includes(PREFIX)) {
+ console.log(` (skip ${rel} — already scoped)`);
+ continue;
+ }
+
+ const out = scopeCss(src);
+ await writeFile(abs, out);
+ console.log(`✓ scoped ${rel}`);
+}
+
+function scopeCss(src) {
+ const lines = src.split('\n');
+ const outLines = [];
+ // Buffer selector lines until we hit the `{` that closes the selector list.
+ // This handles both `selector {` on one line and multi-line comma lists.
+ let selectorBuf = '';
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const trimmed = line.trim();
+
+ if (selectorBuf) {
+ // Accumulating a multi-line selector list.
+ selectorBuf += ' ' + trimmed;
+ if (trimmed.endsWith('{')) {
+ outLines.push(prefixSelectorLine(selectorBuf));
+ selectorBuf = '';
+ }
+ continue;
+ }
+
+ // Start of an at-rule block — pass through untouched.
+ if (trimmed.startsWith('@')) {
+ outLines.push(line);
+ continue;
+ }
+
+ // Closing brace, blank, comment, or declaration inside a rule — pass through.
+ if (
+ !trimmed ||
+ trimmed.startsWith('}') ||
+ trimmed.startsWith('/*') ||
+ trimmed.startsWith('*') ||
+ !trimmed.endsWith('{') && !trimmed.endsWith(',')
+ ) {
+ outLines.push(line);
+ continue;
+ }
+
+ // Single-line selector (`foo {`) or start of multi-line (`foo,`).
+ if (trimmed.endsWith('{')) {
+ outLines.push(prefixSelectorLine(trimmed));
+ } else {
+ // Line ends with `,` — start buffering.
+ selectorBuf = trimmed;
+ }
+ }
+
+ return outLines.join('\n');
+}
+
+function prefixSelectorLine(selectorLine) {
+ // Strip trailing `{`, split by comma, prefix each, rejoin.
+ const open = selectorLine.lastIndexOf('{');
+ const selectors = selectorLine.slice(0, open).trim();
+ const prefixed = selectors
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .map((s) => `${PREFIX}${s}`)
+ .join(',\n');
+ return `${prefixed} {`;
+}
diff --git a/packages/report-web/src/artifactRoute.test.ts b/packages/report-web/src/artifactRoute.test.ts
deleted file mode 100644
index 3927f32..0000000
--- a/packages/report-web/src/artifactRoute.test.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import assert from 'node:assert/strict';
-import fs from 'node:fs';
-import fsp from 'node:fs/promises';
-import os from 'node:os';
-import path from 'node:path';
-import test from 'node:test';
-import { GET, HEAD } from '../app/artifacts/[...artifactPath]/route';
-
-interface TestWorkspaceContext {
- workspaceRoot: string;
- artifactsDir: string;
- storageRoot: string;
-}
-
-function createWorkspaceContext(): TestWorkspaceContext {
- const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'finalrun-report-route-'));
- const storageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'finalrun-report-route-storage-'));
- const artifactsDir = path.join(storageRoot, '.finalrun', 'workspaces', 'workspace-hash', 'artifacts');
- fs.mkdirSync(artifactsDir, { recursive: true });
- return {
- workspaceRoot,
- storageRoot,
- artifactsDir,
- };
-}
-
-async function cleanupWorkspaceContext(context: TestWorkspaceContext): Promise {
- await fsp.rm(context.workspaceRoot, { recursive: true, force: true });
- await fsp.rm(context.storageRoot, { recursive: true, force: true });
-}
-
-test('artifact GET returns partial content headers for range requests', async () => {
- const context = createWorkspaceContext();
- const artifactPath = path.join(context.artifactsDir, 'runs', 'clip.mp4');
- const previousWorkspaceRoot = process.env.FINALRUN_REPORT_WORKSPACE_ROOT;
- const previousArtifactsDir = process.env.FINALRUN_REPORT_ARTIFACTS_DIR;
-
- try {
- process.env.FINALRUN_REPORT_WORKSPACE_ROOT = context.workspaceRoot;
- process.env.FINALRUN_REPORT_ARTIFACTS_DIR = context.artifactsDir;
- await fsp.mkdir(path.dirname(artifactPath), { recursive: true });
- await fsp.writeFile(artifactPath, Buffer.from('0123456789', 'utf-8'));
-
- const response = await GET(
- new Request('http://127.0.0.1:4173/artifacts/runs/clip.mp4', {
- headers: {
- range: 'bytes=2-5',
- },
- }),
- {
- params: Promise.resolve({
- artifactPath: ['runs', 'clip.mp4'],
- }),
- },
- );
-
- assert.equal(response.status, 206);
- assert.equal(response.headers.get('accept-ranges'), 'bytes');
- assert.equal(response.headers.get('content-range'), 'bytes 2-5/10');
- assert.equal(response.headers.get('content-length'), '4');
- assert.equal(response.headers.get('cache-control'), 'no-store');
- } finally {
- if (previousWorkspaceRoot === undefined) {
- delete process.env.FINALRUN_REPORT_WORKSPACE_ROOT;
- } else {
- process.env.FINALRUN_REPORT_WORKSPACE_ROOT = previousWorkspaceRoot;
- }
- if (previousArtifactsDir === undefined) {
- delete process.env.FINALRUN_REPORT_ARTIFACTS_DIR;
- } else {
- process.env.FINALRUN_REPORT_ARTIFACTS_DIR = previousArtifactsDir;
- }
- await cleanupWorkspaceContext(context);
- }
-});
-
-test('artifact HEAD preserves range headers without sending a response body', async () => {
- const context = createWorkspaceContext();
- const artifactPath = path.join(context.artifactsDir, 'runs', 'clip.mp4');
- const previousWorkspaceRoot = process.env.FINALRUN_REPORT_WORKSPACE_ROOT;
- const previousArtifactsDir = process.env.FINALRUN_REPORT_ARTIFACTS_DIR;
-
- try {
- process.env.FINALRUN_REPORT_WORKSPACE_ROOT = context.workspaceRoot;
- process.env.FINALRUN_REPORT_ARTIFACTS_DIR = context.artifactsDir;
- await fsp.mkdir(path.dirname(artifactPath), { recursive: true });
- await fsp.writeFile(artifactPath, Buffer.from('0123456789', 'utf-8'));
-
- const response = await HEAD(
- new Request('http://127.0.0.1:4173/artifacts/runs/clip.mp4', {
- method: 'HEAD',
- headers: {
- range: 'bytes=2-5',
- },
- }),
- {
- params: Promise.resolve({
- artifactPath: ['runs', 'clip.mp4'],
- }),
- },
- );
-
- assert.equal(response.status, 206);
- assert.equal(response.headers.get('accept-ranges'), 'bytes');
- assert.equal(response.headers.get('content-range'), 'bytes 2-5/10');
- assert.equal(response.headers.get('content-length'), '4');
- assert.equal(await response.text(), '');
- } finally {
- if (previousWorkspaceRoot === undefined) {
- delete process.env.FINALRUN_REPORT_WORKSPACE_ROOT;
- } else {
- process.env.FINALRUN_REPORT_WORKSPACE_ROOT = previousWorkspaceRoot;
- }
- if (previousArtifactsDir === undefined) {
- delete process.env.FINALRUN_REPORT_ARTIFACTS_DIR;
- } else {
- process.env.FINALRUN_REPORT_ARTIFACTS_DIR = previousArtifactsDir;
- }
- await cleanupWorkspaceContext(context);
- }
-});
diff --git a/packages/report-web/src/artifacts.test.ts b/packages/report-web/src/artifacts.test.ts
deleted file mode 100644
index f0de777..0000000
--- a/packages/report-web/src/artifacts.test.ts
+++ /dev/null
@@ -1,394 +0,0 @@
-import assert from 'node:assert/strict';
-import fs from 'node:fs';
-import fsp from 'node:fs/promises';
-import os from 'node:os';
-import path from 'node:path';
-import test from 'node:test';
-import {
- ArtifactRangeNotSatisfiableError,
- loadArtifactResponse,
- loadReportIndexViewModel,
- loadReportRunManifestViewModel,
- type ReportWorkspaceContext,
-} from './artifacts';
-
-interface TestWorkspaceContext extends ReportWorkspaceContext {
- storageRoot: string;
-}
-
-function createWorkspaceContext(): TestWorkspaceContext {
- const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'finalrun-report-artifacts-'));
- const storageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'finalrun-report-storage-'));
- const artifactsDir = path.join(storageRoot, '.finalrun', 'workspaces', 'workspace-hash', 'artifacts');
- fs.mkdirSync(artifactsDir, { recursive: true });
- return {
- workspaceRoot,
- storageRoot,
- artifactsDir,
- };
-}
-
-async function cleanupWorkspaceContext(context: TestWorkspaceContext): Promise {
- await fsp.rm(context.workspaceRoot, { recursive: true, force: true });
- await fsp.rm(context.storageRoot, { recursive: true, force: true });
-}
-
-test('loadArtifactResponse returns full-file headers for artifact reads', async () => {
- const context = createWorkspaceContext();
- const artifactPath = path.join(context.artifactsDir, 'runs', 'clip.mp4');
-
- try {
- await fsp.mkdir(path.dirname(artifactPath), { recursive: true });
- await fsp.writeFile(artifactPath, Buffer.from('0123456789', 'utf-8'));
-
- const response = await loadArtifactResponse(['runs', 'clip.mp4'], undefined, context);
-
- assert.equal(response.status, 200);
- assert.equal(response.contentType, 'video/mp4');
- assert.equal(response.headers['accept-ranges'], 'bytes');
- assert.equal(response.headers['content-length'], '10');
- assert.equal(response.headers['content-type'], 'video/mp4');
- } finally {
- await cleanupWorkspaceContext(context);
- }
-});
-
-test('loadReportIndexViewModel derives display metadata from persisted run manifests without changing shared schemas', async () => {
- const context = createWorkspaceContext();
-
- try {
- await writeJson(path.join(context.artifactsDir, 'runs.json'), {
- schemaVersion: 1,
- generatedAt: '2026-03-24T18:00:00.000Z',
- runs: [
- {
- runId: 'suite-run',
- success: false,
- status: 'failure',
- startedAt: '2026-03-24T18:00:00.000Z',
- completedAt: '2026-03-24T18:00:10.000Z',
- durationMs: 10000,
- envName: 'dev',
- platform: 'android',
- modelLabel: 'openai/gpt-5.4-mini',
- appLabel: 'repo app',
- target: {
- type: 'suite',
- suiteId: 'smoke',
- suiteName: 'Smoke Suite',
- suitePath: 'smoke.yaml',
- },
- testCount: 2,
- passedCount: 1,
- failedCount: 1,
- stepCount: 4,
- paths: {
- runJson: 'suite-run/run.json',
- log: 'suite-run/runner.log',
- },
- },
- {
- runId: 'direct-run',
- success: true,
- status: 'success',
- startedAt: '2026-03-24T19:00:00.000Z',
- completedAt: '2026-03-24T19:00:12.000Z',
- durationMs: 12000,
- envName: 'dev',
- platform: 'android',
- modelLabel: 'openai/gpt-5.4-mini',
- appLabel: 'repo app',
- target: {
- type: 'direct',
- },
- testCount: 3,
- passedCount: 3,
- failedCount: 0,
- stepCount: 6,
- paths: {
- runJson: 'direct-run/run.json',
- log: 'direct-run/runner.log',
- },
- },
- {
- runId: 'early-failure-run',
- success: false,
- status: 'failure',
- startedAt: '2026-03-24T20:00:00.000Z',
- completedAt: '2026-03-24T20:00:02.000Z',
- durationMs: 2000,
- envName: 'dev',
- platform: 'android',
- modelLabel: 'openai/gpt-5.4-mini',
- appLabel: 'repo app',
- target: {
- type: 'direct',
- },
- testCount: 0,
- passedCount: 0,
- failedCount: 0,
- stepCount: 0,
- paths: {
- runJson: 'early-failure-run/run.json',
- log: 'early-failure-run/runner.log',
- },
- },
- ],
- });
-
- await writeRunManifest(context, {
- runId: 'suite-run',
- target: {
- type: 'suite',
- suiteId: 'smoke',
- suiteName: 'Smoke Suite',
- suitePath: 'smoke.yaml',
- },
- selectedTests: [
- { testId: 'login', name: 'Valid login', relativePath: 'login/valid_login.yaml' },
- { testId: 'checkout', name: 'Guest checkout', relativePath: 'checkout/guest_checkout.yaml' },
- ],
- suite: {
- suiteId: 'smoke',
- name: 'Smoke Suite',
- workspaceSourcePath: '.finalrun/suites/smoke.yaml',
- snapshotYamlPath: 'input/suite.snapshot.yaml',
- snapshotJsonPath: 'input/suite.json',
- tests: ['login/valid_login.yaml', 'checkout/guest_checkout.yaml'],
- resolvedTestIds: ['login', 'checkout'],
- },
- });
-
- await writeRunManifest(context, {
- runId: 'direct-run',
- target: {
- type: 'direct',
- },
- selectedTests: [
- { testId: 'login', name: 'Valid login', relativePath: 'login/valid_login.yaml' },
- { testId: 'signup', name: 'Valid signup', relativePath: 'auth/valid_signup.yaml' },
- { testId: 'logout', name: 'Logout', relativePath: 'auth/logout.yaml' },
- ],
- });
-
- const viewModel = await loadReportIndexViewModel(context);
-
- assert.equal(viewModel.summary.totalRuns, 3);
- assert.equal(viewModel.summary.totalDurationMs, 24000);
- assert.ok(Math.abs(viewModel.summary.totalSuccessRate - 100 / 3) < 1e-9);
-
- assert.deepEqual(
- viewModel.runs.map((run) => ({
- runId: run.runId,
- displayName: run.displayName,
- displayKind: run.displayKind,
- triggeredFrom: run.triggeredFrom,
- selectedTestCount: run.selectedTestCount,
- })),
- [
- {
- runId: 'suite-run',
- displayName: 'Smoke Suite',
- displayKind: 'suite',
- triggeredFrom: 'Suite',
- selectedTestCount: 2,
- },
- {
- runId: 'direct-run',
- displayName: 'Valid login +2 more',
- displayKind: 'multi_test',
- triggeredFrom: 'Direct',
- selectedTestCount: 3,
- },
- {
- runId: 'early-failure-run',
- displayName: 'early-failure-run',
- displayKind: 'fallback',
- triggeredFrom: 'Direct',
- selectedTestCount: 0,
- },
- ],
- );
- } finally {
- await cleanupWorkspaceContext(context);
- }
-});
-
-test('loadReportRunManifestViewModel inlines snapshot YAML text for test detail rendering', async () => {
- const context = createWorkspaceContext();
-
- try {
- await writeRunManifest(context, {
- runId: 'yaml-run',
- target: {
- type: 'direct',
- },
- selectedTests: [
- { testId: 'login', name: 'Valid login', relativePath: 'login/valid_login.yaml' },
- ],
- });
- await fsp.mkdir(path.join(context.artifactsDir, 'yaml-run', 'input', 'tests'), { recursive: true });
- await fsp.writeFile(
- path.join(context.artifactsDir, 'yaml-run', 'input', 'tests', 'login.yaml'),
- ['name: valid login', 'steps:', ' - Tap login'].join('\n'),
- 'utf-8',
- );
-
- const manifest = await loadReportRunManifestViewModel('yaml-run', context);
-
- assert.equal(manifest.input.tests[0]?.snapshotYamlPath, 'input/tests/login.yaml');
- assert.equal(manifest.input.tests[0]?.snapshotYamlText, ['name: valid login', 'steps:', ' - Tap login'].join('\n'));
- assert.equal(manifest.tests.length, 0);
- } finally {
- await cleanupWorkspaceContext(context);
- }
-});
-
-
-async function writeJson(filePath: string, value: unknown): Promise {
- await fsp.mkdir(path.dirname(filePath), { recursive: true });
- await fsp.writeFile(filePath, JSON.stringify(value, null, 2), 'utf-8');
-}
-
-async function writeRunManifest(
- context: ReportWorkspaceContext,
- params: {
- runId: string;
- target: {
- type: 'direct' | 'suite';
- suiteId?: string;
- suiteName?: string;
- suitePath?: string;
- };
- selectedTests: Array<{
- testId: string;
- name: string;
- relativePath: string;
- }>;
- suite?: {
- suiteId: string;
- name: string;
- workspaceSourcePath: string;
- snapshotYamlPath: string;
- snapshotJsonPath: string;
- tests: string[];
- resolvedTestIds: string[];
- };
- },
-): Promise {
- await writeJson(path.join(context.artifactsDir, params.runId, 'run.json'), {
- schemaVersion: 2,
- run: {
- runId: params.runId,
- success: params.target.type === 'direct',
- status: params.target.type === 'direct' ? 'success' : 'failure',
- startedAt: '2026-03-24T18:00:00.000Z',
- completedAt: '2026-03-24T18:00:10.000Z',
- durationMs: 10000,
- envName: 'dev',
- platform: 'android',
- model: {
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
- label: 'openai/gpt-5.4-mini',
- },
- app: {
- source: 'repo',
- label: 'repo app',
- },
- selectors: params.target.type === 'direct'
- ? params.selectedTests.map((t) => t.relativePath)
- : [],
- target: params.target,
- counts: {
- tests: {
- total: params.selectedTests.length,
- passed: params.target.type === 'direct' ? params.selectedTests.length : 0,
- failed: params.target.type === 'direct' ? 0 : 1,
- },
- steps: {
- total: 0,
- passed: 0,
- failed: 0,
- },
- },
- },
- input: {
- environment: {
- envName: 'dev',
- variables: {},
- secretReferences: [],
- },
- suite: params.suite,
- tests: params.selectedTests.map((t) => ({
- ...t,
- workspaceSourcePath: `.finalrun/tests/${t.relativePath}`,
- snapshotYamlPath: `input/tests/${t.testId}.yaml`,
- snapshotJsonPath: `input/tests/${t.testId}.json`,
- bindingReferences: {
- variables: [],
- secrets: [],
- },
- setup: [],
- steps: [],
- expected_state: [],
- })),
- cli: {
- command: params.target.type === 'suite'
- ? `finalrun suite ${params.target.suitePath || 'suite.yaml'}`
- : `finalrun test ${params.selectedTests.map((t) => t.relativePath).join(' ')}`,
- selectors: params.target.type === 'direct'
- ? params.selectedTests.map((t) => t.relativePath)
- : [],
- suitePath: params.target.type === 'suite' ? params.target.suitePath : undefined,
- debug: false,
- },
- },
- tests: [],
- paths: {
- runJson: 'run.json',
- summaryJson: 'summary.json',
- log: 'runner.log',
- },
- });
-}
-
-test('loadArtifactResponse serves byte ranges for seekable media playback', async () => {
- const context = createWorkspaceContext();
- const artifactPath = path.join(context.artifactsDir, 'runs', 'clip.mp4');
-
- try {
- await fsp.mkdir(path.dirname(artifactPath), { recursive: true });
- await fsp.writeFile(artifactPath, Buffer.from('0123456789', 'utf-8'));
-
- const response = await loadArtifactResponse(['runs', 'clip.mp4'], 'bytes=2-5', context);
-
- assert.equal(response.status, 206);
- assert.equal(response.headers['accept-ranges'], 'bytes');
- assert.equal(response.headers['content-length'], '4');
- assert.equal(response.headers['content-range'], 'bytes 2-5/10');
- } finally {
- await cleanupWorkspaceContext(context);
- }
-});
-
-test('loadArtifactResponse rejects byte ranges outside the artifact size', async () => {
- const context = createWorkspaceContext();
- const artifactPath = path.join(context.artifactsDir, 'runs', 'clip.mp4');
-
- try {
- await fsp.mkdir(path.dirname(artifactPath), { recursive: true });
- await fsp.writeFile(artifactPath, Buffer.from('0123456789', 'utf-8'));
-
- await assert.rejects(
- loadArtifactResponse(['runs', 'clip.mp4'], 'bytes=25-30', context),
- (error: unknown) => {
- assert.ok(error instanceof ArtifactRangeNotSatisfiableError);
- assert.equal(error.size, 10);
- return true;
- },
- );
- } finally {
- await cleanupWorkspaceContext(context);
- }
-});
diff --git a/packages/report-web/src/artifacts.ts b/packages/report-web/src/artifacts.ts
index 6d1d8d2..5a6dc3e 100644
--- a/packages/report-web/src/artifacts.ts
+++ b/packages/report-web/src/artifacts.ts
@@ -1,23 +1,16 @@
-import * as fs from 'node:fs';
-import * as fsp from 'node:fs/promises';
-import * as path from 'node:path';
-import { Readable } from 'node:stream';
+// Type-only barrel consumed by src/ui/index.ts and re-exported from
+// @finalrun/report-web/ui. The runtime loaders and HTTP-streaming logic
+// live in packages/cli/src/reportViewModel.ts + reportArtifactStream.ts —
+// this package ships as a browser-facing UI library and must stay free of
+// Node built-ins.
+//
+// Keep these shapes in lockstep with packages/cli/src/reportViewModel.ts.
import type {
RunIndexEntry,
- RunIndex,
RunManifest,
TestDefinition,
TestResult,
} from '@finalrun/common';
-import { REPORT_CONTENT_TYPES } from './contentTypes';
-
-const MISSING_WORKSPACE_CONFIG_ERROR =
- 'The FinalRun report server is missing workspace configuration. Start it with `finalrun start-server`.';
-
-export interface ReportWorkspaceContext {
- workspaceRoot: string;
- artifactsDir: string;
-}
export interface ReportIndexRunRecord extends RunIndexEntry {
displayName: string;
@@ -51,413 +44,3 @@ export interface ReportRunManifest extends Omit
};
tests: ReportManifestTestRecord[];
}
-
-export class ArtifactRangeNotSatisfiableError extends Error {
- readonly size: number;
-
- constructor(size: number) {
- super('Requested artifact byte range is not satisfiable.');
- this.name = 'ArtifactRangeNotSatisfiableError';
- this.size = size;
- }
-}
-
-export function resolveReportWorkspaceContext(): ReportWorkspaceContext {
- const workspaceRoot = process.env.FINALRUN_REPORT_WORKSPACE_ROOT;
- const artifactsDir = process.env.FINALRUN_REPORT_ARTIFACTS_DIR;
- if (!workspaceRoot || !artifactsDir) {
- throw new Error(MISSING_WORKSPACE_CONFIG_ERROR);
- }
-
- return {
- workspaceRoot,
- artifactsDir,
- };
-}
-
-export async function loadRunIndexRecord(
- context: ReportWorkspaceContext = resolveReportWorkspaceContext(),
-): Promise {
- const indexPath = path.join(context.artifactsDir, 'runs.json');
- try {
- const raw = await fsp.readFile(indexPath, 'utf-8');
- return JSON.parse(raw) as RunIndex;
- } catch {
- return {
- schemaVersion: 1,
- generatedAt: new Date().toISOString(),
- runs: [],
- };
- }
-}
-
-export async function loadReportIndexViewModel(
- context: ReportWorkspaceContext = resolveReportWorkspaceContext(),
-): Promise {
- const index = await loadRunIndexRecord(context);
- const runs = await Promise.all(
- index.runs.map(async (run) => await enrichRunIndexEntry(run, context)),
- );
- const passedRuns = runs.filter((run) => run.success).length;
-
- return {
- generatedAt: index.generatedAt,
- summary: {
- totalRuns: runs.length,
- totalSuccessRate: runs.length === 0 ? 0 : (passedRuns / runs.length) * 100,
- totalDurationMs: runs.reduce((total, run) => total + Number(run.durationMs || 0), 0),
- },
- runs,
- };
-}
-
-export async function loadRunManifestRecord(
- runId: string,
- context: ReportWorkspaceContext = resolveReportWorkspaceContext(),
-): Promise {
- const runJsonPath = path.join(context.artifactsDir, runId, 'run.json');
- const raw = await fsp.readFile(runJsonPath, 'utf-8');
- const parsed = JSON.parse(raw) as RunManifest;
- if (parsed.schemaVersion !== 2 && parsed.schemaVersion !== 3) {
- throw new Error(`Unsupported schema version: ${parsed.schemaVersion}`);
- }
- return parsed;
-}
-
-export async function loadReportRunManifestViewModel(
- runId: string,
- context: ReportWorkspaceContext = resolveReportWorkspaceContext(),
-): Promise {
- return await enrichRunManifestRecord(await loadRunManifestRecord(runId, context), context);
-}
-
-export function buildRunRoute(runId: string): string {
- return `/runs/${encodeURIComponent(runId)}`;
-}
-
-export function buildArtifactRoute(relativePath: string): string {
- const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
- return `/artifacts/${normalized.split('/').map(encodeURIComponent).join('/')}`;
-}
-
-export function resolveArtifactPath(
- artifactSegments: string[],
- context: ReportWorkspaceContext = resolveReportWorkspaceContext(),
-): string {
- const relativeArtifactPath = artifactSegments.join('/');
- const normalizedRelativePath = relativeArtifactPath
- .replace(/\\/g, '/')
- .replace(/^\/+/, '');
- const resolvedPath = path.resolve(context.artifactsDir, normalizedRelativePath);
- const relativeToArtifacts = path.relative(context.artifactsDir, resolvedPath);
- if (relativeToArtifacts.startsWith('..') || path.isAbsolute(relativeToArtifacts)) {
- throw new Error('Artifact paths must stay within the workspace artifacts directory.');
- }
- return resolvedPath;
-}
-
-export async function loadArtifactResponse(
- artifactSegments: string[],
- rangeHeader?: string | null,
- context: ReportWorkspaceContext = resolveReportWorkspaceContext(),
-): Promise<{
- body: ReadableStream;
- contentType: string;
- status: number;
- headers: Record;
-}> {
- const filePath = resolveArtifactPath(artifactSegments, context);
- const stats = await fsp.stat(filePath);
- if (!stats.isFile()) {
- throw new Error(`Artifact is not a file: ${filePath}`);
- }
-
- const contentType =
- REPORT_CONTENT_TYPES[path.extname(filePath).toLowerCase()] ??
- 'application/octet-stream';
- const byteRange = parseByteRange(rangeHeader, stats.size);
-
- if (byteRange) {
- const contentLength = byteRange.end - byteRange.start + 1;
- return {
- body: Readable.toWeb(
- fs.createReadStream(filePath, {
- start: byteRange.start,
- end: byteRange.end,
- }),
- ) as ReadableStream,
- contentType,
- status: 206,
- headers: {
- 'accept-ranges': 'bytes',
- 'content-length': String(contentLength),
- 'content-range': `bytes ${byteRange.start}-${byteRange.end}/${stats.size}`,
- 'content-type': contentType,
- },
- };
- }
-
- return {
- body: Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream,
- contentType,
- status: 200,
- headers: {
- 'accept-ranges': 'bytes',
- 'content-length': String(stats.size),
- 'content-type': contentType,
- },
- };
-}
-
-export function renderHtmlErrorPage(params: {
- title: string;
- message: string;
-}): string {
- return `
-
-
-
-
- ${escapeHtml(params.title)}
-
-
-
-
-
- ${escapeHtml(params.title)}
- ${escapeHtml(params.message)}
-
-
-
-`;
-}
-
-function escapeHtml(value: string): string {
- return value
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-}
-
-async function enrichRunManifestRecord(
- manifest: RunManifest,
- context: ReportWorkspaceContext,
-): Promise {
- const runId = manifest.run.runId;
- const snapshotCache = new Map>();
- const readSnapshotYamlText = async (snapshotYamlPath: string | undefined): Promise => {
- if (!snapshotYamlPath) {
- return undefined;
- }
- let cached = snapshotCache.get(snapshotYamlPath);
- if (!cached) {
- cached = readRunArtifactText(context, runId, snapshotYamlPath);
- snapshotCache.set(snapshotYamlPath, cached);
- }
- return await cached;
- };
-
- const readDeviceLogTail = async (deviceLogPath: string | undefined): Promise => {
- if (!deviceLogPath) {
- return undefined;
- }
- const content = await readRunArtifactText(context, runId, deviceLogPath);
- if (!content) {
- return undefined;
- }
- const lines = content.split('\n');
- const maxLines = 500;
- if (lines.length > maxLines) {
- return `[… ${lines.length - maxLines} lines truncated]\n${lines.slice(-maxLines).join('\n')}`;
- }
- return content;
- };
-
- return {
- ...manifest,
- input: {
- ...manifest.input,
- tests: await Promise.all(
- manifest.input.tests.map(async (t) => ({
- ...t,
- snapshotYamlText: await readSnapshotYamlText(t.snapshotYamlPath),
- })),
- ),
- },
- tests: await Promise.all(
- manifest.tests.map(async (t) => ({
- ...t,
- snapshotYamlText: await readSnapshotYamlText(t.snapshotYamlPath),
- deviceLogTailText: await readDeviceLogTail(t.deviceLogFile),
- })),
- ),
- };
-}
-
-async function readRunArtifactText(
- context: ReportWorkspaceContext,
- runId: string,
- artifactPath: string,
-): Promise {
- const normalizedPath = normalizeRunArtifactPath(runId, artifactPath);
- if (!normalizedPath) {
- return undefined;
- }
-
- try {
- return await fsp.readFile(path.join(context.artifactsDir, runId, normalizedPath), 'utf-8');
- } catch {
- return undefined;
- }
-}
-
-function normalizeRunArtifactPath(runId: string, artifactPath: string): string | undefined {
- const normalized = artifactPath.replace(/\\/g, '/').replace(/^\/+/, '');
- if (normalized.length === 0) {
- return undefined;
- }
-
- if (!normalized.startsWith('artifacts/')) {
- return normalized;
- }
-
- const withoutArtifactsPrefix = normalized.slice('artifacts/'.length);
- if (withoutArtifactsPrefix.startsWith(`${runId}/`)) {
- return withoutArtifactsPrefix.slice(runId.length + 1);
- }
-
- return undefined;
-}
-
-async function enrichRunIndexEntry(
- run: RunIndexEntry,
- context: ReportWorkspaceContext,
-): Promise {
- const manifest = await loadRunManifestRecord(run.runId, context).catch(() => null);
- const selectedTests = manifest?.input.tests ?? [];
-
- return {
- ...run,
- displayName: deriveRunDisplayName(run, manifest),
- displayKind: deriveRunDisplayKind(run, manifest),
- triggeredFrom: run.target?.type === 'suite' ? 'Suite' : 'Direct',
- selectedTestCount: selectedTests.length > 0 ? selectedTests.length : run.testCount,
- };
-}
-
-function deriveRunDisplayName(
- run: RunIndexEntry,
- manifest: RunManifest | null,
-): string {
- if (run.target?.type === 'suite' && run.target.suiteName) {
- return run.target.suiteName;
- }
-
- const selectedTests = manifest?.input.tests ?? [];
- if (selectedTests.length === 1) {
- return selectedTests[0]?.name || selectedTests[0]?.relativePath || run.runId;
- }
- if (selectedTests.length > 1) {
- const firstLabel =
- selectedTests[0]?.name || selectedTests[0]?.relativePath || 'Selected tests';
- return `${firstLabel} +${selectedTests.length - 1} more`;
- }
-
- return run.runId;
-}
-
-function deriveRunDisplayKind(
- run: RunIndexEntry,
- manifest: RunManifest | null,
-): ReportIndexRunRecord['displayKind'] {
- if (run.target?.type === 'suite') {
- return 'suite';
- }
-
- const selectedCount = manifest?.input.tests?.length ?? run.testCount;
- if (selectedCount === 1) {
- return 'single_test';
- }
- if (selectedCount > 1) {
- return 'multi_test';
- }
-
- return 'fallback';
-}
-
-function parseByteRange(
- rangeHeader: string | null | undefined,
- totalSize: number,
-): { start: number; end: number } | undefined {
- if (!rangeHeader) {
- return undefined;
- }
-
- const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
- if (!match) {
- return undefined;
- }
-
- const [, startValue, endValue] = match;
- if (startValue === '' && endValue === '') {
- return undefined;
- }
-
- if (startValue === '') {
- const suffixLength = parseInt(endValue, 10);
- if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
- throw new ArtifactRangeNotSatisfiableError(totalSize);
- }
- const start = Math.max(0, totalSize - suffixLength);
- return {
- start,
- end: totalSize - 1,
- };
- }
-
- const start = parseInt(startValue, 10);
- const requestedEnd = endValue === '' ? totalSize - 1 : parseInt(endValue, 10);
- if (!Number.isFinite(start) || !Number.isFinite(requestedEnd)) {
- throw new ArtifactRangeNotSatisfiableError(totalSize);
- }
- if (start < 0 || start >= totalSize) {
- throw new ArtifactRangeNotSatisfiableError(totalSize);
- }
-
- const end = Math.min(requestedEnd, totalSize - 1);
- if (end < start) {
- throw new ArtifactRangeNotSatisfiableError(totalSize);
- }
-
- return { start, end };
-}
diff --git a/packages/report-web/src/css.d.ts b/packages/report-web/src/css.d.ts
new file mode 100644
index 0000000..35306c6
--- /dev/null
+++ b/packages/report-web/src/css.d.ts
@@ -0,0 +1 @@
+declare module '*.css';
diff --git a/packages/report-web/src/fetchers.ts b/packages/report-web/src/fetchers.ts
new file mode 100644
index 0000000..9908b74
--- /dev/null
+++ b/packages/report-web/src/fetchers.ts
@@ -0,0 +1,24 @@
+// Fetch helpers for the CLI-hosted SPA. The CLI report server (see
+// packages/cli/src/reportServer.ts) exposes matching JSON endpoints.
+
+import type { ReportIndexViewModel, ReportRunManifest } from './artifacts';
+
+export async function fetchReportIndex(): Promise {
+ const response = await fetch('/api/report/index', {
+ headers: { Accept: 'application/json' },
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to load report index (${response.status} ${response.statusText})`);
+ }
+ return (await response.json()) as ReportIndexViewModel;
+}
+
+export async function fetchReportRun(runId: string): Promise {
+ const response = await fetch(`/api/report/runs/${encodeURIComponent(runId)}`, {
+ headers: { Accept: 'application/json' },
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to load run ${runId} (${response.status} ${response.statusText})`);
+ }
+ return (await response.json()) as ReportRunManifest;
+}
diff --git a/packages/report-web/src/main.tsx b/packages/report-web/src/main.tsx
new file mode 100644
index 0000000..501e55c
--- /dev/null
+++ b/packages/report-web/src/main.tsx
@@ -0,0 +1,26 @@
+// Vite entry point for the standalone CLI-hosted report SPA. Mounts
+// StandaloneReportApp, which wires react-router + the default CLI data
+// source that fetches /api/report/*.
+//
+// The same page components are exported through @finalrun/report-web/ui for
+// embedding; the routes barrel (./routes) exposes router fragments for
+// downstream SPAs (finalrun-cloud/web) to splice into their own routers.
+
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { StandaloneReportApp } from './routes/index';
+
+import './ui/styles/shared.css';
+import './ui/styles/run-index.css';
+import './ui/styles/run-detail.css';
+
+const container = document.getElementById('root');
+if (!container) {
+ throw new Error('Missing #root container in index.html');
+}
+
+createRoot(container).render(
+
+
+ ,
+);
diff --git a/packages/report-web/src/renderers.test.ts b/packages/report-web/src/renderers.test.ts
deleted file mode 100644
index dfe0872..0000000
--- a/packages/report-web/src/renderers.test.ts
+++ /dev/null
@@ -1,562 +0,0 @@
-import assert from 'node:assert/strict';
-import test from 'node:test';
-import type { RunManifest } from '@finalrun/common';
-import type { ReportIndexViewModel } from './artifacts';
-import { renderRunHtml, renderRunIndexHtml } from './renderers';
-
-function createRunIndexViewModel(): ReportIndexViewModel {
- return {
- generatedAt: '2026-03-24T18:00:00.000Z',
- summary: {
- totalRuns: 2,
- totalSuccessRate: 50,
- totalDurationMs: 22000,
- },
- runs: [
- {
- runId: '2026-03-24T18-00-00.000Z-dev-android',
- success: false,
- status: 'failure',
- startedAt: '2026-03-24T18:00:00.000Z',
- completedAt: '2026-03-24T18:00:10.000Z',
- durationMs: 10000,
- envName: 'dev',
- platform: 'android',
- modelLabel: 'openai/gpt-5.4-mini',
- appLabel: 'repo app',
- target: {
- type: 'suite',
- suiteId: 'login_suite',
- suiteName: 'login suite',
- suitePath: 'login_suite.yaml',
- },
- testCount: 2,
- passedCount: 1,
- failedCount: 1,
- stepCount: 4,
- firstFailure: {
- testId: 'login',
- testName: 'login',
- message: 'button not found',
- },
- paths: {
- runJson: '2026-03-24T18-00-00.000Z-dev-android/run.json',
- log: '2026-03-24T18-00-00.000Z-dev-android/runner.log',
- },
- displayName: 'login suite',
- displayKind: 'suite',
- triggeredFrom: 'Suite',
- selectedTestCount: 2,
- },
- {
- runId: '2026-03-24T19-00-00.000Z-dev-android',
- success: true,
- status: 'success',
- startedAt: '2026-03-24T19:00:00.000Z',
- completedAt: '2026-03-24T19:00:12.000Z',
- durationMs: 12000,
- envName: 'dev',
- platform: 'android',
- modelLabel: 'openai/gpt-5.4-mini',
- appLabel: 'repo app',
- target: {
- type: 'direct',
- },
- testCount: 3,
- passedCount: 3,
- failedCount: 0,
- stepCount: 6,
- paths: {
- runJson: '2026-03-24T19-00-00.000Z-dev-android/run.json',
- log: '2026-03-24T19-00-00.000Z-dev-android/runner.log',
- },
- displayName: 'valid login +2 more',
- displayKind: 'multi_test',
- triggeredFrom: 'Direct',
- selectedTestCount: 3,
- },
- ],
- };
-}
-
-function createSuiteRunManifest(): RunManifest {
- return withSnapshotYamlText({
- schemaVersion: 2,
- run: {
- runId: '2026-03-24T18-00-00.000Z-dev-android',
- success: false,
- status: 'failure',
- failurePhase: 'execution',
- startedAt: '2026-03-24T18:00:00.000Z',
- completedAt: '2026-03-24T18:00:10.000Z',
- durationMs: 10000,
- envName: 'dev',
- platform: 'android',
- model: {
- provider: 'openai',
- modelName: 'gpt-5.4-mini',
- label: 'openai/gpt-5.4-mini',
- },
- app: {
- source: 'repo',
- label: 'repo app',
- },
- selectors: [],
- target: {
- type: 'suite',
- suiteId: 'login_suite',
- suiteName: 'login suite',
- suitePath: 'login_suite.yaml',
- },
- counts: {
- tests: {
- total: 2,
- passed: 0,
- failed: 1,
- },
- steps: {
- total: 1,
- passed: 0,
- failed: 1,
- },
- },
- firstFailure: {
- testId: 'login',
- testName: 'login',
- message: 'button not found',
- screenshotPath: 'tests/login/screenshots/001.jpg',
- },
- },
- input: {
- environment: {
- envName: 'dev',
- variables: {
- locale: 'en-US',
- },
- secretReferences: [
- {
- key: 'email',
- envVar: 'FINALRUN_TEST_EMAIL',
- },
- ],
- },
- suite: {
- suiteId: 'login_suite',
- name: 'login suite',
- workspaceSourcePath: '.finalrun/suites/login_suite.yaml',
- snapshotYamlPath: 'input/suite.snapshot.yaml',
- snapshotJsonPath: 'input/suite.json',
- tests: ['login/valid_login.yaml', 'checkout/guest_checkout.yaml'],
- resolvedTestIds: ['login', 'checkout'],
- },
- tests: [
- {
- testId: 'login',
- name: 'valid login',
- relativePath: 'login/valid_login.yaml',
- workspaceSourcePath: '.finalrun/tests/login/valid_login.yaml',
- snapshotYamlPath: 'input/tests/login.yaml',
- snapshotJsonPath: 'input/tests/login.json',
- bindingReferences: {
- variables: [],
- secrets: ['email'],
- },
- setup: [],
- steps: ['Tap login'],
- expected_state: ['Dashboard is visible'],
- },
- {
- testId: 'checkout',
- name: 'guest checkout',
- relativePath: 'checkout/guest_checkout.yaml',
- workspaceSourcePath: '.finalrun/tests/checkout/guest_checkout.yaml',
- snapshotYamlPath: 'input/tests/checkout.yaml',
- snapshotJsonPath: 'input/tests/checkout.json',
- bindingReferences: {
- variables: [],
- secrets: [],
- },
- setup: [],
- steps: ['Open checkout'],
- expected_state: ['Checkout page is visible'],
- },
- ],
- cli: {
- command: 'finalrun suite login_suite.yaml',
- selectors: [],
- suitePath: 'login_suite.yaml',
- debug: false,
- },
- },
- tests: [
- {
- testId: 'login',
- testName: 'valid login',
- sourcePath: '/repo/.finalrun/tests/login/valid_login.yaml',
- relativePath: 'login/valid_login.yaml',
- success: false,
- status: 'failure',
- message: 'button not found',
- analysis: 'button not found',
- platform: 'android',
- startedAt: '2026-03-24T18:00:00.000Z',
- completedAt: '2026-03-24T18:00:10.000Z',
- durationMs: 10000,
- recordingFile: 'tests/login/recording.mp4',
- steps: [
- {
- stepNumber: 1,
- iteration: 1,
- actionType: 'tap',
- naturalLanguageAction: 'Tap login',
- reason: 'Open the login form.',
- success: false,
- status: 'failure',
- errorMessage: 'button not found',
- durationMs: 1000,
- timestamp: '2026-03-24T18:00:05.000Z',
- screenshotFile: 'tests/login/screenshots/001.jpg',
- stepJsonFile: 'tests/login/actions/001.json',
- videoOffsetMs: 3200,
- analysis: 'The login button was not visible.',
- thought: {
- plan: 'Open the login form.',
- think: 'The login CTA is the fastest way to reach the authenticated screen.',
- },
- actionPayload: {
- direction: 'down',
- repeat: 1,
- },
- trace: {
- step: 1,
- action: 'tap',
- status: 'failure',
- totalMs: 1000,
- spans: [
- {
- name: 'locate_element',
- durationMs: 420,
- startMs: 0,
- status: 'failure',
- },
- ],
- },
- },
- ],
- workspaceSourcePath: '/repo/.finalrun/tests/login/valid_login.yaml',
- snapshotYamlPath: 'input/tests/login.yaml',
- snapshotJsonPath: 'input/tests/login.json',
- bindingReferences: {
- variables: [],
- secrets: ['email'],
- },
- authored: {
- name: 'valid login',
- setup: [],
- steps: ['Tap login'],
- expected_state: ['Dashboard is visible'],
- },
- effectiveGoal: 'Tap login',
- counts: {
- executionStepsTotal: 1,
- executionStepsPassed: 0,
- executionStepsFailed: 1,
- },
- firstFailure: {
- testId: 'login',
- testName: 'valid login',
- stepNumber: 1,
- actionType: 'tap',
- message: 'button not found',
- screenshotPath: 'tests/login/screenshots/001.jpg',
- stepJsonPath: 'tests/login/actions/001.json',
- },
- previewScreenshotPath: 'tests/login/screenshots/001.jpg',
- resultJsonPath: 'tests/login/result.json',
- },
- ],
- paths: {
- runJson: 'run.json',
- summaryJson: 'summary.json',
- log: 'runner.log',
- runContextJson: 'input/run-context.json',
- },
- });
-}
-
-function createSingleTestManifest(): RunManifest {
- const suiteManifest = createSuiteRunManifest();
- return withSnapshotYamlText({
- ...suiteManifest,
- run: {
- ...suiteManifest.run,
- runId: '2026-03-24T20-00-00.000Z-dev-android',
- success: true,
- status: 'success',
- target: {
- type: 'direct',
- },
- counts: {
- tests: {
- total: 1,
- passed: 1,
- failed: 0,
- },
- steps: {
- total: 1,
- passed: 1,
- failed: 0,
- },
- },
- firstFailure: undefined,
- },
- input: {
- ...suiteManifest.input,
- suite: undefined,
- tests: [suiteManifest.input.tests[0]],
- cli: {
- command: 'finalrun test login/valid_login.yaml',
- selectors: ['login/valid_login.yaml'],
- debug: false,
- },
- },
- tests: [
- {
- ...suiteManifest.tests[0],
- success: true,
- message: 'All assertions passed',
- analysis: 'Goal completed successfully.',
- durationMs: 4500,
- steps: [
- {
- ...suiteManifest.tests[0].steps[0],
- success: true,
- status: 'success',
- errorMessage: undefined,
- },
- ],
- },
- ],
- });
-}
-
-function withSnapshotYamlText(manifest: RunManifest): RunManifest {
- const snapshotByTestId = new Map([
- ['login', [
- 'name: valid login',
- 'steps:',
- ' - Tap login',
- 'expected_state:',
- ' - Dashboard is visible',
- ].join('\n')],
- ['checkout', [
- 'name: guest checkout',
- 'steps:',
- ' - Open checkout',
- 'expected_state:',
- ' - Checkout page is visible',
- ].join('\n')],
- ]);
-
- for (const t of manifest.input.tests) {
- (t as { snapshotYamlText?: string }).snapshotYamlText = snapshotByTestId.get(t.testId!);
- }
- for (const t of manifest.tests) {
- (t as { snapshotYamlText?: string }).snapshotYamlText = snapshotByTestId.get(t.testId);
- }
-
- return manifest;
-}
-
-function extractTestDetailPanel(html: string, testId: string): string {
- const marker = `data-test-panel="${testId}"`;
- const start = html.indexOf(marker);
- assert.notEqual(start, -1, `Expected test detail panel for ${testId}.`);
- const sectionStart = html.lastIndexOf('', start);
- assert.notEqual(sectionStart, -1);
- assert.notEqual(sectionEnd, -1);
- return html.slice(sectionStart, sectionEnd + ' '.length);
-}
-
-function assertTestDetailSectionOrder(html: string, testId: string): void {
- const panel = extractTestDetailPanel(html, testId);
- const testIndex = panel.indexOf('>Test<');
- const runContextIndex = panel.indexOf('>Run Context<');
- const analysisIndex = panel.indexOf('>Analysis<');
- const actionsIndex = panel.indexOf('Agent Actions');
- const recordingIndex = panel.indexOf('Session Recording');
-
- assert.ok(testIndex >= 0);
- assert.ok(runContextIndex > testIndex);
- assert.ok(analysisIndex > runContextIndex);
- assert.ok(actionsIndex > analysisIndex);
- assert.ok(recordingIndex > actionsIndex);
-}
-
-function assertSimplifiedTestDetailHtml(html: string): void {
- assert.match(html, />Test<\/h3>/);
- assert.match(html, /Open raw YAML/);
- assert.match(html, />Run Context<\/h3>/);
- assert.match(html, />Analysis<\/h3>/);
- assert.match(html, /Agent Actions/);
- assert.match(html, /Session Recording/);
- assert.match(html, /function selectNearestStepForTime/);
- assert.match(html, /function findNearestStepIndex/);
- assert.match(html, /selectNearestStepForTime\(testId, nextTime\)/);
- assert.doesNotMatch(html, /Selected Step/);
- assert.doesNotMatch(html, /Action<\/h4>/);
- assert.doesNotMatch(html, /Reasoning<\/h4>/);
- assert.doesNotMatch(html, /Planner Thought<\/h4>/);
- assert.doesNotMatch(html, /Analysis<\/h4>/);
- assert.doesNotMatch(html, /Trace<\/h4>/);
- assert.doesNotMatch(html, /Meta<\/h4>/);
- assert.doesNotMatch(html, /Raw Artifact Links/);
- assert.doesNotMatch(html, /data-role="screenshot"/);
- assert.doesNotMatch(html, /Back to suite list/);
- assert.doesNotMatch(html, /onclick="clearTestSelection\(\)"/);
- assert.doesNotMatch(html, />Goal<\/strong>/);
-}
-
-function assertAgentActionListHtml(html: string): void {
- assert.match(html, /class="timeline-scroll"/);
- assert.match(html, /\.timeline-scroll\s*\{/);
- assert.match(html, /class="step-title">Tap login<\/div>/);
- assert.match(html, /\.step-button\.is-selected \.step-expanded\s*\{/);
- assert.match(html, /class="step-reasoning-copy">The login CTA is the fastest way to reach the authenticated screen\.<\/div>/);
- assert.doesNotMatch(html, /class="step-reason"/);
- assert.doesNotMatch(html, /class="step-meta"/);
- assert.doesNotMatch(html, />Grounding<\/div>/);
-}
-
-function assertCompactRunContextHtml(html: string): void {
- assert.match(html, /class="run-context-summary"/);
- assert.match(html, /class="context-summary-label">Environment<\/span>/);
- assert.match(html, /class="context-summary-label">Platform<\/span>/);
- assert.match(html, /class="context-summary-label">Model<\/span>/);
- assert.match(html, /class="context-summary-label">App<\/span>/);
- assert.doesNotMatch(html, /class="run-context-grid"/);
- assert.doesNotMatch(html, /class="context-card"/);
- assert.doesNotMatch(html, /Run Target<\/strong>/);
- assert.doesNotMatch(html, /Suite<\/strong>/);
- assert.doesNotMatch(html, /Selectors<\/strong>/);
- assert.doesNotMatch(html, /Variables<\/strong>/);
- assert.doesNotMatch(html, /Secrets<\/strong>/);
- assert.doesNotMatch(html, /Artifacts<\/strong>/);
- assert.doesNotMatch(html, />run\.json<\/a>/);
- assert.doesNotMatch(html, />summary\.json<\/a>/);
- assert.doesNotMatch(html, />runner\.log<\/a>/);
- assert.doesNotMatch(html, />run-context\.json<\/a>/);
-}
-
-test('renderRunIndexHtml renders the Flutter-style history table with derived display metadata', () => {
- const html = renderRunIndexHtml(createRunIndexViewModel());
-
- assert.match(html, /Test Runs<\/h1>/);
- assert.match(html, /Run history/);
- assert.match(html, /Test Success Rate/);
- assert.match(html, /Triggered From/);
- assert.match(html, /login suite/);
- assert.match(html, /valid login \+2 more/);
- assert.match(html, /Local/);
- assert.match(html, /Suite/);
- assert.match(html, /Direct/);
- assert.match(html, /class="tinted-png-icon"/);
- assert.match(html, /background-color: #707EAE/);
- assert.match(html, / login suite<\/h1>/);
- assert.match(html, /id="suite-overview"/);
- assert.match(html, /Run summary/);
- assert.match(html, /Run Context/);
- assert.match(html, /Executed tests/);
- assert.match(html, /Tests passed/);
- assert.match(html, /Not Executed/);
- assert.match(html, /selectTest\('checkout'\)/);
- assert.match(html, /data-test-panel="login"/);
- assert.match(html, /data-test-panel="checkout"/);
- assert.match(html, /id="primary-back-button"/);
- assert.match(html, /handlePrimaryBack\(event\)/);
- assert.match(html, /data-role="recording-seekbar"/);
- assert.match(html, /data-role="recording-playpause"/);
- assert.doesNotMatch(html, /data-role="recording-fullscreen"/);
- assert.match(html, /dev<\/div>/);
- assert.match(html, /class="context-summary-value">android<\/div>/);
- assert.match(html, /class="context-summary-value">openai\/gpt-5.4-mini<\/div>/);
- assert.match(html, /class="context-summary-value">repo app<\/div>/);
- assert.match(html, /name: valid login/);
- assert.match(html, /name: guest checkout/);
- assertTestDetailSectionOrder(html, 'login');
- assertTestDetailSectionOrder(html, 'checkout');
- assertCompactRunContextHtml(html);
- assertSimplifiedTestDetailHtml(html);
- assertAgentActionListHtml(html);
-});
-
-test('renderRunHtml opens directly into the single-test layout for direct one-test runs', () => {
- const html = renderRunHtml(createSingleTestManifest());
-
- assert.match(html, /valid login<\/h1>/);
- assert.doesNotMatch(html, /id="suite-overview"/);
- assert.doesNotMatch(html, /Executed tests/);
- assert.doesNotMatch(html, /class="overview-grid"/);
- assert.match(html, /name: valid login/);
- assert.match(html, /id="report-back-button"/);
- assert.match(html, /\/artifacts\/2026-03-24T20-00-00\.000Z-dev-android\/tests\/login\/recording\.mp4/);
- assertTestDetailSectionOrder(html, 'login');
- assert.equal((html.match(/Run history/g) || []).length, 1);
- assertCompactRunContextHtml(html);
- assertSimplifiedTestDetailHtml(html);
- assertAgentActionListHtml(html);
-});
-
-test('renderRunHtml renders compact recording empty states without reintroducing debug panels', () => {
- const noRecordingManifest = createSingleTestManifest();
- noRecordingManifest.tests[0] = {
- ...noRecordingManifest.tests[0],
- recordingFile: undefined,
- };
-
- const noRecordingHtml = renderRunHtml(noRecordingManifest);
- assert.match(noRecordingHtml, /No session recording was captured for this test\./);
- assertSimplifiedTestDetailHtml(noRecordingHtml);
-
- const noActionsManifest = createSingleTestManifest();
- noActionsManifest.tests[0] = {
- ...noActionsManifest.tests[0],
- steps: [],
- };
-
- const noActionsHtml = renderRunHtml(noActionsManifest);
- assert.match(noActionsHtml, /No steps were recorded for this test\./);
- assert.match(noActionsHtml, /No recorded actions are available for this test\./);
- assertSimplifiedTestDetailHtml(noActionsHtml);
-});
-
-test('renderRunHtml surfaces the no-synced-timestamp caption when steps lack video offsets', () => {
- const manifest = createSingleTestManifest();
- manifest.tests[0] = {
- ...manifest.tests[0],
- steps: manifest.tests[0].steps.map((step) => ({
- ...step,
- videoOffsetMs: undefined,
- })),
- };
-
- const html = renderRunHtml(manifest);
- assert.match(html, /No synced recording timestamp is available for the selected step\./);
- assertSimplifiedTestDetailHtml(html);
-});
diff --git a/packages/report-web/src/renderers.ts b/packages/report-web/src/renderers.ts
deleted file mode 100644
index dfed869..0000000
--- a/packages/report-web/src/renderers.ts
+++ /dev/null
@@ -1,2906 +0,0 @@
-import type {
- RunManifest,
- AgentAction,
- RunTarget,
-} from '@finalrun/common';
-import type {
- ReportIndexRunRecord,
- ReportIndexViewModel,
- ReportManifestSelectedTestRecord,
- ReportManifestTestRecord,
- ReportRunManifest,
-} from './artifacts';
-import { buildArtifactRoute, buildRunRoute } from './artifacts';
-
-function svgDataUri(svg: string): string {
- return `data:image/svg+xml,${encodeURIComponent(svg)}`;
-}
-
-const TEST_ICON_SRC = svgDataUri(
- ' ',
-);
-
-const TEST_SUITE_ICON_SRC = svgDataUri(
- ' ',
-);
-
-const LOCAL_ICON_SRC = svgDataUri(
- ' ',
-);
-
-type TestOutcomeStatus = 'success' | 'failure' | 'error' | 'aborted' | 'not_executed';
-
-interface ReportTestListItem {
- input: ReportManifestSelectedTestRecord;
- executed?: ReportManifestTestRecord;
- status: TestOutcomeStatus;
- durationLabel: string;
-}
-
-interface OutcomeSummary {
- total: number;
- success: number;
- aborted: number;
- failure: number;
- error: number;
- notExecuted: number;
-}
-
-export function renderRunIndexHtml(index: ReportIndexViewModel): string {
- return `
-
-
-
-
- FinalRun Reports
- ${renderFontLinks()}
-
-
-
-
-
-
-
- ${renderSummaryCard('Total Runs', String(index.summary.totalRuns), 'accent', renderPlayCircleIconSvg())}
- ${renderSummaryCard('Test Success Rate', `${index.summary.totalSuccessRate.toFixed(1)}%`, successRateTone(index.summary.totalSuccessRate), renderCheckCircleIconSvg())}
- ${renderSummaryCard('Total time saved', formatLongDuration(index.summary.totalDurationMs), 'neutral', renderTimerIconSvg())}
-
-
-
-
- ${index.runs.length === 0
- ? 'No FinalRun reports found.
'
- : `
-
-
-
- TEST NAME
- APPS
- DURATION
- STATUS
- RESULT
- RAN ON
- Triggered From
-
-
-
- ${index.runs.map((run) => renderRunIndexRow(run)).join('')}
-
-
- `}
-
-
-
-`;
-}
-
-export function renderRunHtml(manifest: ReportRunManifest): string {
- const view = toReportViewModel(manifest);
- const run = view.run;
- const testItems = buildTestListItems(view);
- const isSingleTest = testItems.length <= 1;
- const outcomeSummary = summarizeTestItems(testItems);
- const initialTest = testItems[0];
- const reportTitle = deriveReportTitle(view);
- const reportPayload = JSON.stringify(stripSnapshotYamlText(view)).replace(/
-
-
-
-
- ${escapeHtml(reportTitle)}
- ${renderFontLinks()}
-
-
-
-
-
-
- ${isSingleTest
- ? renderSingleTestPage(view, initialTest)
- : renderSuiteRunPage(view, testItems, outcomeSummary)}
-
-
-
-
-
-`;
-}
-
-function renderSingleTestPage(
- manifest: ReportRunManifest,
- item: ReportTestListItem | undefined,
-): string {
- if (!item) {
- return `
-
-
-
No test details were recorded for this run.
-
-
- `;
- }
-
- return renderTestDetailSection(item, true, undefined, manifest);
-}
-
-function renderSuiteRunPage(
- manifest: ReportRunManifest,
- items: ReportTestListItem[],
- summary: OutcomeSummary,
-): string {
- const suiteLabel = deriveReportTitle(manifest);
- return `
-
-
-
-
Run summary
-
Completed suite-level view based on the locally captured report artifacts.
-
-
- ${renderSummarySegments(summary)}
-
-
-
-
${summary.success}/${summary.total}
-
Tests passed
-
-
-
${formatLongDuration(manifest.run.durationMs)}
-
Run duration
-
-
-
-
-
- ${renderRunContextPanel(manifest)}
-
- Executed tests
- Select a test to inspect the detailed step-by-step report.
-
-
-
- TEST NAME
- APPS
- DURATION
- STATUS
-
-
-
- ${items.map((item) => renderSuiteRow(item, manifest.run.app.label)).join('')}
-
-
-
-
- ${items.map((item) => renderTestDetailSection(item, false, suiteLabel, manifest)).join('')}
- `;
-}
-
-function renderRunContextPanel(manifest: ReportRunManifest): string {
- return `
-
-
- ${renderRunContextContent(manifest, 'overview-title', 'overview-subtitle')}
-
-
- `;
-}
-
-function renderRunContextContent(
- manifest: ReportRunManifest,
- titleClass: string,
- subtitleClass: string,
-): string {
- return `
- Run Context
- Inputs and environment captured for this report.
-
- ${renderRunContextSummary(manifest)}
-
- `;
-}
-
-function renderRunContextSummary(manifest: ReportRunManifest): string {
- return [
- renderContextSummaryItem('Environment', manifest.input.environment.envName),
- renderContextSummaryItem('Platform', manifest.run.platform),
- renderContextSummaryItem('Model', manifest.run.model.label),
- renderContextSummaryItem('App', manifest.run.app.label),
- ].join('');
-}
-
-function renderContextSummaryItem(label: string, value: string): string {
- return `
-
-
${escapeHtml(label)}
-
${escapeHtml(value)}
-
- `;
-}
-
-function renderSummarySegments(summary: OutcomeSummary): string {
- const segments = [
- { label: 'Success', className: 'success', count: summary.success },
- { label: 'Aborted', className: 'aborted', count: summary.aborted },
- { label: 'Failure', className: 'failure', count: summary.failure },
- { label: 'Error', className: 'error', count: summary.error },
- { label: 'Not Executed', className: 'not-executed', count: summary.notExecuted },
- ];
-
- return `
-
- ${segments
- .filter((segment) => segment.count > 0)
- .map((segment) => {
- const width = summary.total === 0 ? 0 : (segment.count / summary.total) * 100;
- return `
`;
- })
- .join('')}
-
-
- ${segments.map((segment) => {
- const percent = summary.total === 0 ? 0 : Math.round((segment.count / summary.total) * 100);
- return `
-
-
- ${segment.label} - ${percent}%
-
- `;
- }).join('')}
-
- `;
-}
-
-function renderRunIndexRow(run: ReportIndexRunRecord): string {
- const resultLabel = run.passedCount + run.failedCount === 0
- ? 'NA'
- : `${run.passedCount} / ${run.selectedTestCount}`;
- const href = buildRunRoute(run.runId);
-
- return `
-
-
-
- ${renderTintedPngIcon(run.displayKind === 'suite' ? TEST_SUITE_ICON_SRC : TEST_ICON_SRC)}
-
-
-
- ${escapeHtml(run.appLabel)}
- ${run.durationMs > 0 ? escapeHtml(formatLongDuration(run.durationMs)) : 'NA'}
- ${renderStatusPill(run.success ? 'success' : 'failure')}
- ${escapeHtml(resultLabel)}
-
-
-
- Local
-
-
- ${escapeHtml(run.triggeredFrom)}
-
- `;
-}
-
-function renderSuiteRow(item: ReportTestListItem, appLabel: string): string {
- return `
-
-
-
- ${renderTintedPngIcon(TEST_ICON_SRC)}
-
-
${escapeHtml(item.input.name)}
-
${escapeHtml(item.input.relativePath ?? '')}
-
-
-
- ${escapeHtml(appLabel)}
- ${escapeHtml(item.durationLabel)}
- ${renderStatusPill(item.status)}
-
- `;
-}
-
-function renderTestDetailSection(
- item: ReportTestListItem,
- visible: boolean,
- parentLabel?: string,
- manifest?: ReportRunManifest,
-): string {
- const detailClass = visible ? 'detail-shell is-visible' : 'detail-shell';
- const detailSubtitle = parentLabel
- ? `${parentLabel} · ${item.input.relativePath ?? ''}`
- : item.input.relativePath ?? '';
- const test = item.executed;
- const initialStep = test?.steps[0];
- const statusText = item.status === 'error'
- ? 'Error'
- : item.status === 'aborted'
- ? 'Aborted'
- : item.status === 'failure'
- ? 'Failed'
- : item.status === 'not_executed'
- ? 'Not executed'
- : 'Passed';
- const analysisText = test
- ? test.analysis || test.message || 'No overall analysis recorded.'
- : 'This test was selected for the run, but it never started. The batch ended before this test could execute.';
- const snapshotYamlText = test?.snapshotYamlText ?? item.input.snapshotYamlText;
- const snapshotYamlPath = test?.snapshotYamlPath ?? item.input.snapshotYamlPath;
- const stepCount = test?.steps.length ?? 0;
- const recordingSpeedId = `recording-speed-${item.input.testId!}`;
-
- return `
-
-
-
-
-
- ${renderTestSpecSection(snapshotYamlPath, snapshotYamlText)}
- ${manifest ? renderRunContextSection(manifest) : ''}
- ${renderTestAnalysisSection(item.status, analysisText)}
-
-
-
-
-
-
- ${renderPlayIconSvg()}
- ${formatVideoTimestamp(initialStep?.videoOffsetMs)}
-
- --:--
- Playback speed
-
- 1x
- 2x
- 4x
- 8x
-
-
-
-
-
-
-
- Actions
- ${test?.deviceLogFile
- ? 'Device Logs '
- : ''}
-
-
- ${test?.deviceLogFile
- ? `
-
-
-
${renderDeviceLogLines(test.deviceLogTailText ?? '', test.recordingStartedAt)}
-
Download full log
-
-
`
- : ''}
-
-
-
- `;
-}
-
-function renderTestSpecSection(
- snapshotYamlPath: string | undefined,
- snapshotYamlText: string | undefined,
-): string {
- const content = snapshotYamlText
- ? `${escapeHtml(snapshotYamlText)} `
- : 'Snapshot YAML was not available for this report.
';
- const action = snapshotYamlPath
- ? `Open raw YAML `
- : '';
- return renderDetailSectionCard({
- title: 'Test',
- subtitle: 'Captured YAML snapshot for this test.',
- action,
- content,
- });
-}
-
-function renderRunContextSection(manifest: ReportRunManifest): string {
- return renderDetailSectionCard({
- title: 'Run Context',
- subtitle: 'Inputs and environment captured for this report.',
- content: `${renderRunContextSummary(manifest)}
`,
- });
-}
-
-function renderTestAnalysisSection(status: TestOutcomeStatus, analysisText: string): string {
- return renderDetailSectionCard({
- title: 'Analysis',
- subtitle: 'Overall result commentary captured for this test.',
- action: renderStatusPill(status),
- cardClass: `analysis-card ${status}`,
- content: `${escapeHtml(analysisText)}
`,
- });
-}
-
-function renderDetailSectionCard(params: {
- title: string;
- subtitle: string;
- content: string;
- action?: string;
- cardClass?: string;
-}): string {
- return `
-
-
-
- ${params.content}
-
-
- `;
-}
-
-function renderStepButton(testId: string, step: AgentAction, index: number): string {
- const statusClass = step.success ? 'success' : step.actionType === 'run_failure' ? 'error' : 'failure';
- const reasoningText = resolveStepReasoning(step);
- return `
-
-
-
${statusClass === 'success' ? '✓' : '!'}
-
-
${escapeHtml(step.naturalLanguageAction || step.actionType)}
-
-
${escapeHtml(formatStepDuration(step.durationMs || step.trace?.totalMs || 0))}
-
- ${reasoningText
- ? `${escapeHtml(reasoningText)}
`
- : ''}
-
- `;
-}
-
-function resolveStepReasoning(step: AgentAction): string | undefined {
- const title = normalizeStepText(step.naturalLanguageAction || step.actionType);
- for (const candidate of [step.thought?.think, step.thought?.plan, step.reason]) {
- const normalized = normalizeStepText(candidate);
- if (!normalized || normalized === title) {
- continue;
- }
- return normalized;
- }
- return undefined;
-}
-
-function normalizeStepText(value: string | undefined): string | undefined {
- const normalized = value?.trim();
- return normalized ? normalized : undefined;
-}
-
-function toReportViewModel(manifest: ReportRunManifest): ReportRunManifest {
- const runId = manifest.run.runId;
- return {
- ...manifest,
- input: {
- ...manifest.input,
- suite: manifest.input.suite
- ? {
- ...manifest.input.suite,
- snapshotYamlPath: manifest.input.suite.snapshotYamlPath
- ? buildRunScopedArtifactPath(runId, manifest.input.suite.snapshotYamlPath)
- : undefined,
- snapshotJsonPath: manifest.input.suite.snapshotJsonPath
- ? buildRunScopedArtifactPath(runId, manifest.input.suite.snapshotJsonPath)
- : undefined,
- }
- : undefined,
- tests: manifest.input.tests.map((test) => toSelectedTestViewModel(runId, test)),
- },
- tests: manifest.tests.map((test) => toTestViewModel(runId, test)),
- paths: {
- ...manifest.paths,
- runJson: buildRunScopedArtifactPath(runId, manifest.paths.runJson),
- summaryJson: buildRunScopedArtifactPath(runId, manifest.paths.summaryJson),
- log: buildRunScopedArtifactPath(runId, manifest.paths.log),
- runContextJson: manifest.paths.runContextJson
- ? buildRunScopedArtifactPath(runId, manifest.paths.runContextJson)
- : undefined,
- },
- };
-}
-
-function toSelectedTestViewModel(
- runId: string,
- test: ReportManifestSelectedTestRecord,
-): ReportManifestSelectedTestRecord {
- return {
- ...test,
- snapshotYamlPath: test.snapshotYamlPath
- ? buildRunScopedArtifactPath(runId, test.snapshotYamlPath)
- : undefined,
- snapshotJsonPath: test.snapshotJsonPath
- ? buildRunScopedArtifactPath(runId, test.snapshotJsonPath)
- : undefined,
- };
-}
-
-function toTestViewModel(runId: string, test: ReportManifestTestRecord): ReportManifestTestRecord {
- return {
- ...test,
- snapshotYamlPath: test.snapshotYamlPath
- ? buildRunScopedArtifactPath(runId, test.snapshotYamlPath)
- : undefined,
- snapshotJsonPath: test.snapshotJsonPath
- ? buildRunScopedArtifactPath(runId, test.snapshotJsonPath)
- : undefined,
- previewScreenshotPath: test.previewScreenshotPath
- ? buildRunScopedArtifactPath(runId, test.previewScreenshotPath)
- : undefined,
- resultJsonPath: test.resultJsonPath
- ? buildRunScopedArtifactPath(runId, test.resultJsonPath)
- : undefined,
- recordingFile: test.recordingFile
- ? buildRunScopedArtifactPath(runId, test.recordingFile)
- : undefined,
- deviceLogFile: test.deviceLogFile
- ? buildRunScopedArtifactPath(runId, test.deviceLogFile)
- : undefined,
- steps: test.steps.map((step) => ({
- ...step,
- screenshotFile: step.screenshotFile
- ? buildRunScopedArtifactPath(runId, step.screenshotFile)
- : undefined,
- stepJsonFile: step.stepJsonFile
- ? buildRunScopedArtifactPath(runId, step.stepJsonFile)
- : undefined,
- })),
- firstFailure: test.firstFailure
- ? {
- ...test.firstFailure,
- screenshotPath: test.firstFailure.screenshotPath
- ? buildRunScopedArtifactPath(runId, test.firstFailure.screenshotPath)
- : undefined,
- stepJsonPath: test.firstFailure.stepJsonPath
- ? buildRunScopedArtifactPath(runId, test.firstFailure.stepJsonPath)
- : undefined,
- }
- : undefined,
- };
-}
-
-function buildRunScopedArtifactPath(runId: string, relativePath: string): string {
- return buildArtifactRoute(`${runId}/${relativePath}`);
-}
-
-function buildTestListItems(manifest: ReportRunManifest): ReportTestListItem[] {
- const executedById = new Map(manifest.tests.map((test) => [test.testId, test]));
- const selectedTests = manifest.input.tests;
- if (selectedTests.length === 0) {
- return manifest.tests.map((test) => ({
- input: {
- testId: test.testId,
- name: test.testName,
- relativePath: test.relativePath,
- workspaceSourcePath: test.workspaceSourcePath,
- snapshotYamlPath: test.snapshotYamlPath,
- snapshotJsonPath: test.snapshotJsonPath,
- snapshotYamlText: test.snapshotYamlText,
- bindingReferences: test.bindingReferences,
- setup: [],
- steps: [],
- expected_state: [],
- },
- executed: test,
- status: classifyTestStatus(test),
- durationLabel: formatLongDuration(test.durationMs),
- }));
- }
-
- return selectedTests.map((selected) => {
- const executed = executedById.get(selected.testId!);
- return {
- input: selected,
- executed,
- status: executed ? classifyTestStatus(executed) : 'not_executed',
- durationLabel: executed ? formatLongDuration(executed.durationMs) : 'NA',
- };
- });
-}
-
-function summarizeTestItems(items: ReportTestListItem[]): OutcomeSummary {
- return items.reduce(
- (summary, item) => {
- summary.total += 1;
- if (item.status === 'success') {
- summary.success += 1;
- } else if (item.status === 'aborted') {
- summary.aborted += 1;
- } else if (item.status === 'failure') {
- summary.failure += 1;
- } else if (item.status === 'error') {
- summary.error += 1;
- } else {
- summary.notExecuted += 1;
- }
- return summary;
- },
- {
- total: 0,
- success: 0,
- aborted: 0,
- failure: 0,
- error: 0,
- notExecuted: 0,
- },
- );
-}
-
-function classifyTestStatus(test: ReportManifestTestRecord): TestOutcomeStatus {
- if (test.status === 'aborted') {
- return 'aborted';
- }
- if (test.success) {
- return 'success';
- }
- if (test.steps[0]?.actionType === 'run_failure') {
- return 'error';
- }
- return 'failure';
-}
-
-function deriveReportTitle(manifest: ReportRunManifest): string {
- const target = resolveRunTarget(manifest);
- if (target.type === 'suite' && target.suiteName) {
- return target.suiteName;
- }
-
- if (manifest.input.tests.length === 1) {
- return manifest.input.tests[0]?.name || manifest.run.runId;
- }
-
- if (manifest.input.tests.length > 1) {
- const first = manifest.input.tests[0];
- return `${first?.name || 'Selected tests'} +${manifest.input.tests.length - 1} more`;
- }
-
- return manifest.run.runId;
-}
-
-function renderStatusPill(status: TestOutcomeStatus | 'success' | 'failure'): string {
- const label = status === 'success'
- ? 'Passed'
- : status === 'aborted'
- ? 'Aborted'
- : status === 'failure'
- ? 'Failed'
- : status === 'error'
- ? 'Error'
- : 'Not Executed';
- return `${escapeHtml(label)} `;
-}
-
-function renderSummaryCard(label: string, value: string, tone: 'accent' | 'success' | 'warning' | 'danger' | 'neutral', iconSvg: string): string {
- const iconStyle = tone === 'accent'
- ? 'color: var(--accent); background: rgba(67, 24, 255, 0.1);'
- : tone === 'success'
- ? 'color: var(--success); background: rgba(5, 205, 153, 0.12);'
- : tone === 'warning'
- ? 'color: var(--warning); background: rgba(255, 146, 12, 0.12);'
- : tone === 'danger'
- ? 'color: var(--failure); background: rgba(238, 93, 80, 0.12);'
- : 'color: var(--text); background: var(--panel-alt);';
- return `
-
-
${iconSvg}
-
- ${escapeHtml(label)}
- ${escapeHtml(value)}
-
-
- `;
-}
-
-function resolveRunTarget(manifest: ReportRunManifest): RunTarget {
- return manifest.run.target ?? { type: 'direct' };
-}
-
-function stripSnapshotYamlText(manifest: ReportRunManifest): RunManifest {
- return {
- ...manifest,
- input: {
- ...manifest.input,
- tests: manifest.input.tests.map(({ snapshotYamlText: _snapshotYamlText, ...test }) => test),
- },
- tests: manifest.tests.map(({ snapshotYamlText: _snapshotYamlText, deviceLogTailText: _deviceLogTailText, ...test }) => test),
- };
-}
-
-function formatLongDuration(durationMs: number | undefined): string {
- const ms = Number(durationMs || 0);
- if (ms <= 0) {
- return '0s';
- }
-
- const duration = Math.round(ms / 1000);
- const hours = Math.floor(duration / 3600);
- const minutes = Math.floor((duration % 3600) / 60);
- const seconds = duration % 60;
-
- if (hours > 0) {
- return `${hours}h ${minutes}m`;
- }
- if (minutes > 0) {
- return `${minutes}m ${seconds}s`;
- }
- return `${seconds}s`;
-}
-
-function formatStepDuration(durationMs: number | undefined): string {
- const seconds = Number(durationMs || 0) / 1000;
- return seconds >= 10 ? `${seconds.toFixed(0)}s` : `${seconds.toFixed(1)}s`;
-}
-
-function formatRelativeTime(timestamp: string): string {
- const deltaMs = Math.max(0, Date.now() - new Date(timestamp).getTime());
- const totalMinutes = Math.floor(deltaMs / 60000);
- if (totalMinutes < 1) {
- return 'just now';
- }
- if (totalMinutes < 60) {
- return `${totalMinutes}m`;
- }
- const totalHours = Math.floor(totalMinutes / 60);
- if (totalHours < 24) {
- return `${totalHours}h`;
- }
- const totalDays = Math.floor(totalHours / 24);
- if (totalDays < 7) {
- return `${totalDays}d`;
- }
- const totalWeeks = Math.floor(totalDays / 7);
- return `${totalWeeks}w`;
-}
-
-function parseLogTimestamp(line: string, referenceDate?: string): string | undefined {
- // Android logcat threadtime: "MM-DD HH:MM:SS.mmm ..."
- const androidMatch = /^(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\.(\d{3})/.exec(line);
- if (androidMatch) {
- const [, month, day, hour, min, sec, ms] = androidMatch;
- const year = referenceDate ? new Date(referenceDate).getFullYear() : new Date().getFullYear();
- return new Date(year, parseInt(month, 10) - 1, parseInt(day, 10),
- parseInt(hour, 10), parseInt(min, 10), parseInt(sec, 10), parseInt(ms, 10)).toISOString();
- }
-
- // iOS compact log: "YYYY-MM-DD HH:MM:SS.mmm ..."
- const iosMatch = /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}\.\d{3})/.exec(line);
- if (iosMatch) {
- return new Date(`${iosMatch[1]}T${iosMatch[2]}`).toISOString();
- }
-
- return undefined;
-}
-
-function parseLogLevel(line: string): string {
- // iOS compact log: timestamp then level code like "Df", "E ", "I ", "Dg"
- const iosMatch = /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s+(E|Ef)\s/.exec(line);
- if (iosMatch) return 'error';
- const iosWarnMatch = /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s+(W|Wf)\s/.exec(line);
- if (iosWarnMatch) return 'warn';
-
- // Android logcat threadtime: "MM-DD HH:MM:SS.mmm PID TID LEVEL TAG: ..."
- const androidMatch = /^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s+\d+\s+\d+\s+([EWDIV])\s/.exec(line);
- if (androidMatch) {
- const level = androidMatch[1];
- if (level === 'E') return 'error';
- if (level === 'W') return 'warn';
- }
-
- return 'info';
-}
-
-function renderDeviceLogLines(logText: string, recordingStartedAt?: string): string {
- if (!logText) {
- return 'No log content available.
';
- }
- const recStartMs = recordingStartedAt ? new Date(recordingStartedAt).getTime() : undefined;
- return logText.split('\n')
- .filter(line => {
- if (line.length === 0) return false;
- if (recStartMs === undefined) return true;
- const ts = parseLogTimestamp(line, recordingStartedAt);
- if (!ts) return true;
- const tsMs = new Date(ts).getTime();
- return !Number.isFinite(tsMs) || tsMs >= recStartMs;
- })
- .map(line => {
- const ts = parseLogTimestamp(line, recordingStartedAt);
- const level = parseLogLevel(line);
- return `${escapeHtml(line)}
`;
- })
- .join('');
-}
-
-function formatVideoTimestamp(videoOffsetMs: number | undefined): string {
- if (videoOffsetMs === undefined) {
- return '00:00';
- }
- const wholeSeconds = Math.floor(Math.max(0, videoOffsetMs / 1000));
- const minutesPart = Math.floor(wholeSeconds / 60);
- const secondsPart = wholeSeconds % 60;
- return `${String(minutesPart).padStart(2, '0')}:${String(secondsPart).padStart(2, '0')}`;
-}
-
-function escapeHtml(value: unknown): string {
- return String(value)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-}
-
-function escapeJs(value: string): string {
- return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
-}
-
-function successRateTone(rate: number): 'success' | 'warning' | 'danger' {
- if (rate >= 80) {
- return 'success';
- }
- if (rate >= 50) {
- return 'warning';
- }
- return 'danger';
-}
-
-function renderFontLinks(): string {
- return `
-
-
-
- `;
-}
-
-function renderSharedCss(): string {
- return `
- :root {
- --bg: #F4F7FE;
- --panel: #FFFFFF;
- --panel-alt: #F4F7FE;
- --text: #2B3674;
- --muted: #707EAE;
- --icon: #8E9AB9;
- --accent: #4318FF;
- --accent-soft: rgba(67, 24, 255, 0.1);
- --success: #05CD99;
- --aborted: #475569;
- --warning: #FF920C;
- --failure: #EE5D50;
- --border: #E0E5F2;
- --border-light: #E9EDF7;
- --selected: #F0F2F7;
- --shadow: 0 18px 40px rgba(112, 126, 174, 0.12);
- --radius: 20px;
- }
-
- * { box-sizing: border-box; }
-
- html, body {
- margin: 0;
- padding: 0;
- background: var(--bg);
- color: var(--text);
- font-family: "DM Sans", "Helvetica Neue", Arial, sans-serif;
- }
-
- body {
- background:
- radial-gradient(circle at top right, rgba(67, 24, 255, 0.08), transparent 32%),
- linear-gradient(180deg, #fbfcff 0%, var(--bg) 100%);
- }
-
- a {
- color: var(--accent);
- text-decoration: none;
- }
-
- a:hover {
- text-decoration: underline;
- }
-
- .page {
- max-width: 1360px;
- margin: 0 auto;
- padding: 28px;
- }
-
- .status-pill {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-height: 38px;
- padding: 8px 14px;
- border-radius: 999px;
- font-size: 13px;
- font-weight: 700;
- letter-spacing: 0.01em;
- white-space: nowrap;
- }
-
- .status-pill.success {
- background: rgba(5, 205, 153, 0.14);
- color: var(--success);
- }
-
- .status-pill.failure {
- background: rgba(238, 93, 80, 0.14);
- color: var(--failure);
- }
-
- .status-pill.error {
- background: rgba(255, 146, 12, 0.14);
- color: var(--warning);
- }
-
- .status-pill.aborted {
- background: rgba(71, 85, 105, 0.14);
- color: var(--aborted);
- }
-
- .status-pill.not_executed {
- background: rgba(112, 126, 174, 0.14);
- color: var(--muted);
- }
-
- .muted {
- color: var(--muted);
- }
-
- @media (max-width: 900px) {
- .page {
- padding: 20px;
- }
- }
- `;
-}
-
-function renderPlayCircleIconSvg(): string {
- return ' ';
-}
-
-function renderCheckCircleIconSvg(): string {
- return ' ';
-}
-
-function renderTimerIconSvg(): string {
- return ' ';
-}
-
-function renderBackArrowIconSvg(): string {
- return ' ';
-}
-
-function renderPlayIconSvg(): string {
- return ' ';
-}
-
-function renderPauseIconSvg(): string {
- return ' ';
-}
-
-function renderFullscreenIconSvg(): string {
- return ' ';
-}
-
-function renderTintedPngIcon(src: string): string {
- return ` `;
-}
-
-export function renderRunNotFoundHtml(runId: string): string {
- return `
-
-
-
-
- Run Not Found
- ${renderFontLinks()}
-
-
-
-
-
- Run Not Found
- No run manifest was found for ${escapeHtml(runId)}.
- Back to reports
-
-
-
-`;
-}
diff --git a/packages/report-web/src/routes/StandaloneReportApp.tsx b/packages/report-web/src/routes/StandaloneReportApp.tsx
new file mode 100644
index 0000000..8481019
--- /dev/null
+++ b/packages/report-web/src/routes/StandaloneReportApp.tsx
@@ -0,0 +1,172 @@
+// Router fragments + standalone app wrapper.
+//
+// The CLI-hosted SPA consumes StandaloneReportApp, which owns the
+// . finalrun-cloud/web instead spreads reportRouteObjects()
+// into its own data router so the report pages share the cloud's outer chrome
+// (sidebar, auth, etc.) — that's why the route config is exported as plain
+// RouteObject[] without a router wrapper.
+
+import { useEffect, useState, type ReactElement } from 'react';
+import {
+ BrowserRouter,
+ Routes,
+ Route,
+ useNavigate,
+ useParams,
+ type RouteObject,
+} from 'react-router-dom';
+import type { ReportIndexViewModel, ReportRunManifest } from '../artifacts';
+import { RunIndexView } from '../ui/pages/RunIndexView';
+import { RunDetailView } from '../ui/pages/RunDetailView';
+import { fetchReportIndex, fetchReportRun } from '../fetchers';
+
+export interface ReportDataSource {
+ fetchIndex(): Promise;
+ fetchRun(runId: string): Promise;
+}
+
+export const defaultCliDataSource: ReportDataSource = {
+ fetchIndex: fetchReportIndex,
+ fetchRun: fetchReportRun,
+};
+
+export interface ReportRouteObjectsOptions {
+ dataSource: ReportDataSource;
+ indexPath?: string;
+ detailPath?: string;
+ backHref?: string;
+}
+
+// Returns React Router v7 RouteObject fragments suitable for spreading into a
+// parent `createBrowserRouter([...])` config. Default paths match the OSS
+// CLI-hosted SPA; finalrun-cloud passes overrides that match its `/runs`
+// prefix and its own data source.
+export function reportRouteObjects(options: ReportRouteObjectsOptions): RouteObject[] {
+ const indexPath = options.indexPath ?? '/';
+ const detailPath = options.detailPath ?? '/runs/:runId';
+ const backHref = options.backHref ?? indexPath;
+
+ return [
+ {
+ path: indexPath,
+ element: ,
+ },
+ {
+ path: detailPath,
+ element: ,
+ },
+ ];
+}
+
+export function StandaloneReportApp({
+ dataSource = defaultCliDataSource,
+}: {
+ dataSource?: ReportDataSource;
+} = {}): ReactElement {
+ return (
+
+
+ } />
+ }
+ />
+
+
+ );
+}
+
+function RunIndexRoute({ dataSource }: { dataSource: ReportDataSource }): ReactElement {
+ const navigate = useNavigate();
+ const state = useAsyncResource(() => dataSource.fetchIndex(), []);
+
+ if (state.status === 'pending') return ;
+ if (state.status === 'error') return ;
+
+ return navigate(href)} />;
+}
+
+function RunDetailRoute({
+ dataSource,
+ backHref,
+}: {
+ dataSource: ReportDataSource;
+ backHref: string;
+}): ReactElement {
+ const params = useParams<{ runId: string }>();
+ const navigate = useNavigate();
+ const runId = params.runId ?? '';
+
+ const state = useAsyncResource(() => dataSource.fetchRun(runId), [runId]);
+
+ if (!runId) {
+ return ;
+ }
+ if (state.status === 'pending') return ;
+ if (state.status === 'error') {
+ return ;
+ }
+
+ return (
+ navigate(href)}
+ backHref={backHref}
+ />
+ );
+}
+
+type AsyncState =
+ | { status: 'pending' }
+ | { status: 'success'; data: T }
+ | { status: 'error'; error: string };
+
+function useAsyncResource(load: () => Promise, deps: unknown[]): AsyncState {
+ const [state, setState] = useState>({ status: 'pending' });
+
+ useEffect(() => {
+ let cancelled = false;
+ setState({ status: 'pending' });
+ load().then(
+ (data) => {
+ if (!cancelled) setState({ status: 'success', data });
+ },
+ (error) => {
+ if (cancelled) return;
+ const message = error instanceof Error ? error.message : String(error);
+ setState({ status: 'error', error: message });
+ },
+ );
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+
+ return state;
+}
+
+function LoadingShell(): ReactElement {
+ return (
+
+
+
+
+
+ );
+}
+
+function ErrorShell({ title, message }: { title: string; message: string }): ReactElement {
+ return (
+
+ );
+}
diff --git a/packages/report-web/src/routes/index.ts b/packages/report-web/src/routes/index.ts
new file mode 100644
index 0000000..f114218
--- /dev/null
+++ b/packages/report-web/src/routes/index.ts
@@ -0,0 +1,17 @@
+// Routes barrel — this file is built as a separate tsup entry and exported
+// via `@finalrun/report-web/routes`.
+//
+// Two consumers:
+// 1. The standalone CLI-hosted SPA (src/main.tsx) — uses StandaloneReportApp
+// with defaultCliDataSource, which fetches /api/report/* from the CLI's
+// local HTTP server.
+// 2. finalrun-cloud/web — spreads reportRouteObjects({...}) into its parent
+// React Router v7 data router, wiring its own data source to the cloud's
+// REST endpoints.
+
+export {
+ StandaloneReportApp,
+ reportRouteObjects,
+ defaultCliDataSource,
+} from './StandaloneReportApp';
+export type { ReportDataSource, ReportRouteObjectsOptions } from './StandaloneReportApp';
diff --git a/packages/report-web/src/ui/client/runDetailController.ts b/packages/report-web/src/ui/client/runDetailController.ts
new file mode 100644
index 0000000..b2a8578
--- /dev/null
+++ b/packages/report-web/src/ui/client/runDetailController.ts
@@ -0,0 +1,641 @@
+// Interactive controller for the run detail page. Extracted verbatim from the
+// legacy renderers.ts inline