chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
/config/scripts/create-draft-release.mjs text eol=lf
|
||||
/config/scripts/orca-dev.mjs text eol=lf
|
||||
/config/scripts/latest-stable-release.mjs text eol=lf
|
||||
/config/scripts/publish-complete-draft-releases.mjs text eol=lf
|
||||
/config/scripts/release-rc-history.mjs text eol=lf
|
||||
/config/scripts/run-internal-dev-setup.mjs text eol=lf
|
||||
/config/scripts/verify-cli-bin.mjs text eol=lf
|
||||
/config/scripts/verify-release-required-assets.mjs text eol=lf
|
||||
@@ -0,0 +1,134 @@
|
||||
# Contributing to Orca
|
||||
|
||||
Thanks for contributing to Orca.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- Keep changes scoped to a clear user-facing improvement, bug fix, or refactor.
|
||||
- Orca targets macOS, Linux, and Windows. Every change must stay compatible with all three platforms unless the code is explicitly guarded by a runtime platform check.
|
||||
- For keyboard shortcuts, use runtime platform checks in renderer code and `CmdOrCtrl` in Electron menu accelerators.
|
||||
- For shortcut labels, show `⌘` and `⇧` on macOS, and `Ctrl+` and `Shift+` on Linux and Windows.
|
||||
- For file paths, use Node or Electron path utilities such as `path.join`.
|
||||
- Orca must work against local repositories, remote servers, and SSH worktrees. Do not assume a process, file, credential, shell, or network path exists only on the local machine.
|
||||
- Orca supports many CLI agents, integrations, and git providers. Keep generic behavior provider-neutral; guard integration-specific logic behind explicit checks.
|
||||
- Keep changes well-engineered and performant: follow existing architecture, avoid unnecessary work in hot paths, clean up owned resources, and use concrete module names.
|
||||
- For UI work, follow [`docs/STYLEGUIDE.md`](../docs/STYLEGUIDE.md), use the tokens and shadcn primitives it specifies, and verify polished behavior across platforms, light/dark mode, and SSH latency.
|
||||
|
||||
## Local Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Branch Naming
|
||||
|
||||
Use a clear, descriptive branch name that reflects the change.
|
||||
|
||||
Good examples:
|
||||
|
||||
- `fix/ctrl-backspace-delete-word`
|
||||
- `feat/shift-enter-newline`
|
||||
- `chore/update-contributor-guide`
|
||||
|
||||
Avoid vague names like `test`, `misc`, or `changes`.
|
||||
|
||||
## Before Opening a PR
|
||||
|
||||
Run the same checks that CI runs:
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Add high-quality tests for behavior changes and bug fixes. Prefer tests that would actually catch a regression, not shallow coverage that only exercises the happy path.
|
||||
|
||||
If your change affects UI or interaction behavior, verify it on the platforms it could impact.
|
||||
|
||||
## Type Declarations: Prefer `.ts` Over `.d.ts`
|
||||
|
||||
Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for ambient shims (e.g., `env.d.ts`, `vite/client.d.ts`). TypeScript's `skipLibCheck: true` setting applies globally, including to our own `.d.ts` files, which means any unresolved type reference in a `.d.ts` silently becomes `any` at its call sites. Write your types in `.ts` files so the compiler actually checks them.
|
||||
|
||||
CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-typecheck-hole.md`.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
Each pull request should:
|
||||
|
||||
- explain the user-visible change
|
||||
- stay focused on a single topic when possible
|
||||
- include screenshots or screen recordings for new UI or behavior changes
|
||||
- include high-quality tests when behavior changes or bug fixes warrant them
|
||||
- include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk
|
||||
- mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes
|
||||
- **Include your X (Twitter) handle!** We love giving shoutouts to our contributors when we merge features on [@orca_build](https://x.com/orca_build).
|
||||
|
||||
If there is no visual change, say that explicitly in the PR description.
|
||||
|
||||
## Release Process
|
||||
|
||||
Version bumps, tags, and releases are maintainer-managed. Do not include release version changes in a normal contribution unless a maintainer asks for them.
|
||||
|
||||
### Cutting a release (maintainers)
|
||||
|
||||
All releases are cut from the **Cut Release** GitHub Actions workflow. There is no local `pnpm release:*` script — running releases locally is too easy to get wrong (dirty tree, wrong branch, stale main).
|
||||
|
||||
**To cut a release:**
|
||||
|
||||
1. Open [Actions → Cut Release](../../actions/workflows/release-cut.yml).
|
||||
2. Click **Run workflow** and pick:
|
||||
- **kind**: one of `rc`, `patch`, `minor`, `major`.
|
||||
- **ref**: the branch, tag, or SHA to build from. Defaults to `main`.
|
||||
3. Run it.
|
||||
|
||||
The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and runs the multi-platform build + publish inline.
|
||||
|
||||
**How the next version is chosen:**
|
||||
|
||||
All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable_ release, ignoring any RCs in between.
|
||||
|
||||
- `kind=rc` + last tag was stable (e.g. `v1.3.14`) → `v1.3.15-rc.0`.
|
||||
- `kind=rc` + active RC series (e.g. `v1.3.15-rc.2`) → `v1.3.15-rc.3`.
|
||||
- `kind=patch` + latest stable `v1.3.14` → `v1.3.15` (regardless of any intermediate RCs).
|
||||
- `kind=minor` + latest stable `v1.3.14` → `v1.4.0`.
|
||||
- `kind=major` + latest stable `v1.3.14` → `v2.0.0`.
|
||||
|
||||
**Safety guarantees:**
|
||||
|
||||
- Stable releases are refused if the new version isn't strictly greater than the latest published stable. This is the only rule `electron-updater` actually needs — it compares semver within the `latest` channel, so a regressing stable is the one thing that breaks auto-update for fresh installs.
|
||||
- Complete RC draft releases created by the release workflow are published before cutting a new tag only when the draft tag was built from the current release ref. Stale drafts are skipped so fixes cut a fresh RC instead of exposing old artifacts.
|
||||
- If the latest RC tag exists but is still draft-only or missing its GitHub Release, the workflow resumes that tag only when it was built from the current release ref. Otherwise the next RC number is cut.
|
||||
- RC numbering also considers release commits on `main`, so deleting a stale tag does not let a later cut reuse the same RC number.
|
||||
- Off-main releases (when `ref` is not the tip of `main`) only push the tag. `main` is never mutated from a non-main ref, so you can safely release an older commit without polluting history.
|
||||
- When `ref` is the tip of `main`, the version-bump commit is fast-forwarded onto `main` so local `package.json` stays in sync with what's shipped.
|
||||
|
||||
**Common scenarios:**
|
||||
|
||||
- **Normal release:** `kind=patch`, `ref=main`.
|
||||
- **"A bad commit just landed on main, release the commit before it":** `kind=patch`, `ref=<good-sha>`. `main` is left alone; the tag points at the good SHA. Fix forward on `main` afterward.
|
||||
- **One-off RC for a feature branch:** `kind=rc`, `ref=<branch-or-sha>`. Produces an RC tag that does not touch `main`.
|
||||
- **Minor or major bump:** `kind=minor` or `kind=major`.
|
||||
|
||||
The scheduled 2x/day RC cron in [`release-rc.yml`](../../actions/workflows/release-rc.yml) is independent and continues to run automatically from `main`.
|
||||
|
||||
|
||||
## Release Channels
|
||||
|
||||
The public Homebrew cask tracks stable desktop releases:
|
||||
|
||||
```bash
|
||||
brew install --cask stablyai/orca/orca
|
||||
```
|
||||
|
||||
Release candidates use a separate cask token:
|
||||
|
||||
```bash
|
||||
brew install --cask stablyai/orca/orca@rc
|
||||
```
|
||||
|
||||
The two casks conflict because both install `Orca.app`. Switch channels with a
|
||||
normal `brew uninstall --cask` followed by the install for the other channel.
|
||||
Do not use `--zap` unless you intentionally want to remove local Orca state.
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Bug report
|
||||
description: Report a problem with Orca
|
||||
title: '[Bug]: '
|
||||
type: Bug
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this form for Orca bugs. Include enough detail for someone else to reproduce the issue.
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: orca_version
|
||||
attributes:
|
||||
label: Orca version
|
||||
description: Include the exact Orca version if you can. You can find it in `Orca > About Orca`.
|
||||
placeholder: v0.14.2
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details
|
||||
description: Describe the bug in your own words. Include a short summary, reproduction steps, and anything else that seems relevant.
|
||||
placeholder: |
|
||||
Short summary:
|
||||
Orca crashes when ...
|
||||
|
||||
What happened?
|
||||
|
||||
How can we reproduce it?
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
Anything else that might help:
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Feature request
|
||||
description: Suggest an improvement for Orca
|
||||
title: '[Feature]: '
|
||||
type: Feature
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this form for new features or workflow improvements.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or use case
|
||||
description: What are you trying to do, and what is missing today? Include a short summary here too.
|
||||
placeholder: It is hard to switch between the same few repositories throughout the day.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe the change you want to see
|
||||
placeholder: Add a pinned repositories section to the home screen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives or additional context
|
||||
description: Workarounds, related tools, mockups, or anything else that helps
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,11 @@
|
||||
name: Other
|
||||
description: Report another Orca issue type
|
||||
title: '[Other]: '
|
||||
body:
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details
|
||||
description: Share the context, request, or issue. Include a short summary here too.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,29 @@
|
||||
## Summary
|
||||
|
||||
Describe the user-visible change.
|
||||
|
||||
## Screenshots
|
||||
|
||||
- Add screenshots or a screen recording for any new or changed UI behavior.
|
||||
- If there is no visual change, say `No visual change`.
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `pnpm lint`
|
||||
- [ ] `pnpm typecheck`
|
||||
- [ ] `pnpm test`
|
||||
- [ ] `pnpm build`
|
||||
- [ ] Added or updated high-quality tests that would catch regressions, or explained why tests were not needed
|
||||
|
||||
## AI Review Report
|
||||
|
||||
Summarize the code review you ran with your AI coding agent. Include the main risks it checked, what it flagged, and what you changed or verified as a result.
|
||||
Confirm that the review explicitly checked cross-platform compatibility for macOS, Linux, and Windows, including shortcuts, labels, paths, shell behavior, and any Electron-specific platform differences touched by this PR.
|
||||
|
||||
## Security Audit
|
||||
|
||||
Provide a basic security audit summary from your AI coding agent. Call out any input handling, command execution, path handling, auth, secrets, dependency, or IPC risks that were reviewed, plus any follow-up needed.
|
||||
|
||||
## Notes
|
||||
|
||||
Call out any platform-specific behavior, risks, or follow-up work.
|
||||
@@ -0,0 +1,112 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
const repository = process.env.GITHUB_REPOSITORY ?? 'stablyai/orca'
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
const outputPath = process.env.DOWNLOADS_BADGE_PATH ?? 'docs/assets/readme-downloads.svg'
|
||||
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'orca-readme-downloads-badge',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, { headers })
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function getTotalReleaseDownloads() {
|
||||
let page = 1
|
||||
let total = 0
|
||||
|
||||
while (true) {
|
||||
const releases = await fetchJson(
|
||||
`https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`
|
||||
)
|
||||
|
||||
if (releases.length === 0) {
|
||||
return total
|
||||
}
|
||||
|
||||
for (const release of releases) {
|
||||
if (release.draft) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const asset of release.assets ?? []) {
|
||||
total += asset.download_count ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
page += 1
|
||||
}
|
||||
}
|
||||
|
||||
function formatDownloads(total) {
|
||||
if (total < 1000) {
|
||||
return String(total)
|
||||
}
|
||||
|
||||
if (total < 1_000_000) {
|
||||
return `${Math.round(total / 1000)}k`
|
||||
}
|
||||
|
||||
const rounded = total / 1_000_000
|
||||
return `${rounded >= 10 ? Math.round(rounded) : rounded.toFixed(1)}m`
|
||||
}
|
||||
|
||||
function textWidth(label) {
|
||||
return Math.ceil(label.length * 7.1 + 10)
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
}
|
||||
|
||||
function renderBadge(value) {
|
||||
const label = 'downloads'
|
||||
const leftWidth = textWidth(label)
|
||||
const rightWidth = textWidth(value)
|
||||
const width = leftWidth + rightWidth
|
||||
const labelX = leftWidth / 2
|
||||
const valueX = leftWidth + rightWidth / 2
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${escapeXml(label)}: ${escapeXml(value)}">
|
||||
<title>${escapeXml(label)}: ${escapeXml(value)}</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="${width}" height="20" rx="3"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="${leftWidth}" height="20" fill="#555"/>
|
||||
<rect x="${leftWidth}" width="${rightWidth}" height="20" fill="#4c1"/>
|
||||
<rect width="${width}" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="${labelX}" y="15" fill="#010101" fill-opacity=".3">${escapeXml(label)}</text>
|
||||
<text x="${labelX}" y="14">${escapeXml(label)}</text>
|
||||
<text x="${valueX}" y="15" fill="#010101" fill-opacity=".3">${escapeXml(value)}</text>
|
||||
<text x="${valueX}" y="14">${escapeXml(value)}</text>
|
||||
</g>
|
||||
</svg>
|
||||
`
|
||||
}
|
||||
|
||||
const total = await getTotalReleaseDownloads()
|
||||
const badge = renderBadge(formatDownloads(total))
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, badge)
|
||||
console.log(`Rendered ${outputPath} from ${total} downloads.`)
|
||||
@@ -0,0 +1,197 @@
|
||||
name: Computer-use e2e
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/computer-e2e.yml'
|
||||
- 'config/electron-builder.config.cjs'
|
||||
- 'config/scripts/build-computer-macos.mjs'
|
||||
- 'config/scripts/computer-e2e-workflow.test.mjs'
|
||||
- 'config/scripts/computer-use-skill-guidance.test.mjs'
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
- 'config/scripts/computer-use-smoke.test.mjs'
|
||||
- 'config/scripts/daemon-boot-smoke.mjs'
|
||||
- 'config/scripts/windows-daemon-workspace-close-repro.mjs'
|
||||
- 'config/scripts/verify-computer-native.mjs'
|
||||
# Why: the native-smoke job boots the built terminal daemon under plain
|
||||
# Node, so any change to the daemon bundle graph or the main build must
|
||||
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
|
||||
- 'electron.vite.config.ts'
|
||||
- 'build-plugins/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
- 'native/computer-use-windows/**'
|
||||
- 'skills/computer-use/SKILL.md'
|
||||
- 'src/cli/**'
|
||||
- 'src/main/computer/**'
|
||||
- 'src/main/runtime/rpc/dispatcher.ts'
|
||||
- 'src/main/runtime/rpc/errors.ts'
|
||||
- 'src/main/runtime/rpc/methods/computer*.ts'
|
||||
- 'src/shared/computer-use-*.ts'
|
||||
- 'tests/e2e/computer-linux.e2e.ts'
|
||||
- 'tests/e2e/computer-mac.e2e.ts'
|
||||
- 'tests/e2e/computer-mac-safari.e2e.ts'
|
||||
- 'tests/e2e/computer-windows.e2e.ts'
|
||||
- 'tests/e2e/computer-windows-store.e2e.ts'
|
||||
- 'tests/e2e/helpers/computer-cli-driver.ts'
|
||||
- 'tests/e2e/helpers/computer-driver.ts'
|
||||
- 'tests/e2e/vitest.config.ts'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
|
||||
jobs:
|
||||
native-smoke:
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
|
||||
# Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
|
||||
# permission on Linux runners; node-pty's install fallback then fails
|
||||
# before this smoke job can exercise the native package.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: >-
|
||||
pnpm vitest run
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/computer-use-skill-guidance.test.mjs
|
||||
config/scripts/computer-use-smoke.test.mjs
|
||||
src/main/computer/computer-provider-lifecycle.test.ts
|
||||
src/main/computer/computer-provider-unavailable-message.test.ts
|
||||
src/main/computer/sidecar-client.test.ts
|
||||
src/main/computer/macos-native-provider-client.test.ts
|
||||
src/main/computer/macos-native-provider-socket.test.ts
|
||||
src/main/computer/macos-computer-use-permissions.test.ts
|
||||
src/main/computer/macos-computer-use-permission-status.test.ts
|
||||
src/main/computer/desktop-script-provider-client.test.ts
|
||||
src/main/computer/desktop-script-provider-cache.test.ts
|
||||
src/main/computer/desktop-script-provider-actions.test.ts
|
||||
src/main/computer/desktop-script-provider-cache-lifecycle.test.ts
|
||||
src/main/computer/desktop-script-provider-errors.test.ts
|
||||
src/main/computer/desktop-script-provider-action-errors.test.ts
|
||||
src/shared/computer-use-error-recovery.test.ts
|
||||
src/shared/computer-use-key-spec.test.ts
|
||||
src/cli/format.test.ts
|
||||
src/cli/handlers/computer.test.ts
|
||||
src/cli/handlers/computer-action-routing.test.ts
|
||||
src/cli/handlers/computer-action-validation.test.ts
|
||||
src/cli/handlers/computer-state-formatting.test.ts
|
||||
src/cli/specs/computer.test.ts
|
||||
src/cli/index.test.ts
|
||||
src/main/runtime/rpc/dispatcher-computer-errors.test.ts
|
||||
src/main/runtime/rpc/errors.test.ts
|
||||
src/main/runtime/rpc/methods/computer.test.ts
|
||||
src/main/runtime/rpc/methods/computer-actions.test.ts
|
||||
src/cli/runtime/envelope-schema.test.ts
|
||||
src/shared/remote-runtime-client.test.ts
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
# Why: boot the BUILT daemon-entry under plain Node the way production
|
||||
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
|
||||
# (leaked electron require) while every other check passed; this fails
|
||||
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
|
||||
- name: Daemon boot smoke
|
||||
run: node config/scripts/daemon-boot-smoke.mjs
|
||||
# Why: workspace removal overlaps graceful renderer teardown with the
|
||||
# forced main-process sweep; exercise that exact pair against real ConPTY.
|
||||
- name: Windows daemon workspace-close repro
|
||||
if: runner.os == 'Windows'
|
||||
run: node config/scripts/windows-daemon-workspace-close-repro.mjs
|
||||
- if: runner.os == 'Linux'
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
ACCESSIBILITY_ENABLED: '1'
|
||||
run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
|
||||
- if: runner.os == 'Windows'
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts
|
||||
|
||||
mac:
|
||||
# macOS Accessibility and Screen Recording require user-granted TCC entries.
|
||||
# Keep this on manual/scheduled permission-bearing runners instead of PR CI.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: macos-15
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:computer-macos
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts
|
||||
|
||||
linux:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
ACCESSIBILITY_ENABLED: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool
|
||||
# Why: keep scheduled Linux e2e on the same native install path as PR
|
||||
# smoke and pr.yml's verify job.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
|
||||
|
||||
windows:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts
|
||||
@@ -0,0 +1,97 @@
|
||||
name: Daemon Relocation Spike
|
||||
|
||||
# Why: Phase 1 of the Windows update-survival work relocates the terminal
|
||||
# daemon's file closure out of the install dir so it survives an update. Before
|
||||
# implementing it in the app, this spike finds the MINIMAL set of packaged files
|
||||
# that a copied Orca.exe (run as node) needs to start the daemon and drive a
|
||||
# ConPTY session from a relocated directory, holding no install-dir file locks.
|
||||
# Runs from the feature branch via push (workflow_dispatch only works on the
|
||||
# default branch); main is never touched.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- Jinwoo-H/windows-update-survival
|
||||
paths:
|
||||
- 'tools/daemon-relocation-spike/**'
|
||||
- '.github/workflows/daemon-relocation-spike.yml'
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: daemon-relocation-spike-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
spike:
|
||||
name: relocation file-set spike
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 40
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# This job only reads the repo to build/run the spike; it never pushes.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: cache the unpacked build keyed on the inputs that affect it, so
|
||||
# spike-script-only edits skip the ~15 min electron-builder build.
|
||||
- name: Cache unpacked build
|
||||
id: cache-unpacked
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: dist/win-unpacked
|
||||
key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
|
||||
|
||||
- name: Build unpacked app
|
||||
if: steps.cache-unpacked.outputs.cache-hit != 'true'
|
||||
run: pnpm run build:unpack
|
||||
|
||||
- name: Run relocation spike (all tiers)
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
$log = "artifacts/spike-output.log"
|
||||
$tiers = @('full', 'no-gpu', 'minimal')
|
||||
$any = $false
|
||||
foreach ($tier in $tiers) {
|
||||
"===== TIER: $tier =====" | Tee-Object -FilePath $log -Append
|
||||
$work = Join-Path $env:RUNNER_TEMP "spike-$tier"
|
||||
# --keep-work-dir so the per-tier daemon stdout/stderr logs survive
|
||||
# for the artifact upload (the spike otherwise removes work-dir),
|
||||
# which is what diagnoses a tier that fails to reach ready.
|
||||
node tools/daemon-relocation-spike/spike.mjs `
|
||||
--app-dir dist/win-unpacked `
|
||||
--work-dir "$work" `
|
||||
--tier $tier `
|
||||
--keep-work-dir 2>&1 | Tee-Object -FilePath $log -Append
|
||||
if ($LASTEXITCODE -eq 0) { $any = $true }
|
||||
}
|
||||
if (-not $any) { throw "No tier passed the relocation spike" }
|
||||
|
||||
- name: Upload spike output
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: daemon-relocation-spike-output
|
||||
path: |
|
||||
artifacts/spike-output.log
|
||||
${{ runner.temp }}/spike-*/**/*.log
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,175 @@
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: Ref to check out (defaults to the calling workflow's ref)
|
||||
required: false
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Ref to check out (defaults to the workflow ref)
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
# Why: GitHub cron uses UTC; these slots map to 10am and 3pm
|
||||
# America/Phoenix for the default-branch E2E run.
|
||||
- cron: '0 17,22 * * *'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build e2e app
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: the E2E build compiles native modules via node-gyp. Mirrors the
|
||||
# install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
|
||||
# errors.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: building once avoids five parallel electron-vite builds inside
|
||||
# Playwright globalSetup, which otherwise contends for CPU/RAM on OSS
|
||||
# runners before the sharded tests even start.
|
||||
- name: Build Electron app for E2E
|
||||
run: npx electron-vite build --mode e2e
|
||||
|
||||
- name: Upload E2E build output
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-build-out
|
||||
path: out/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
e2e:
|
||||
name: e2e ${{ matrix.shard_name }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- shard: '1/10'
|
||||
shard_name: 1-of-10
|
||||
- shard: '2/10'
|
||||
shard_name: 2-of-10
|
||||
- shard: '3/10'
|
||||
shard_name: 3-of-10
|
||||
- shard: '4/10'
|
||||
shard_name: 4-of-10
|
||||
- shard: '5/10'
|
||||
shard_name: 5-of-10
|
||||
- shard: '6/10'
|
||||
shard_name: 6-of-10
|
||||
- shard: '7/10'
|
||||
shard_name: 7-of-10
|
||||
- shard: '8/10'
|
||||
shard_name: 8-of-10
|
||||
- shard: '9/10'
|
||||
shard_name: 9-of-10
|
||||
- shard: '10/10'
|
||||
shard_name: 10-of-10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: pnpm install rebuilds native modules, and those postinstall
|
||||
# scripts still need the Linux toolchain even though this shard reuses
|
||||
# the prebuilt Electron output.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
|
||||
# Why: Electron on Linux needs an X display even when the app
|
||||
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
|
||||
# virtual framebuffer so Chromium can initialize without a real display.
|
||||
- name: Install xvfb
|
||||
run: sudo apt-get install -y xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
|
||||
# release.yml so the invariant "this workaround is Linux-only" is
|
||||
# consistent across all three workflows, even though this job
|
||||
# currently pins runs-on: ubuntu-latest.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: e2e-build-out
|
||||
path: out/
|
||||
|
||||
# Why: the Electron suite is wall-clock constrained on OSS runners, but
|
||||
# multiple Electron apps on one Xvfb VM contend on git/Chromium resources.
|
||||
# Sharding keeps each VM at one Playwright worker while splitting the
|
||||
# headless suite across separate runners.
|
||||
# SKIP_BUILD makes Playwright globalSetup reuse the single build job's
|
||||
# artifact instead of starting five concurrent electron-vite builds.
|
||||
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
|
||||
# launches but never creates a BrowserWindow.
|
||||
- name: Run E2E tests (${{ matrix.shard_name }})
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
|
||||
# Why: Playwright retains traces/screenshots only on failure. Uploading
|
||||
# them as an artifact makes post-mortem debugging on CI possible without
|
||||
# re-running locally.
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-traces-${{ matrix.shard_name }}
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,96 @@
|
||||
name: Golden E2E Experiment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/golden-e2e-experiment.yml'
|
||||
- 'package.json'
|
||||
- 'tests/e2e/golden-core-flows.spec.ts'
|
||||
- 'tests/e2e/fixtures/terminal-emoji-table.md'
|
||||
- 'tests/e2e/helpers/**'
|
||||
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
|
||||
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Ref to check out (defaults to the workflow ref)
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
golden-e2e:
|
||||
name: golden e2e ${{ matrix.platform }} experiment
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
# Why: Windows golden E2E is temporarily disabled on CI while its
|
||||
# flaky runner-only failures are investigated.
|
||||
# - os: windows-latest
|
||||
# platform: windows
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why: pull_request merge refs disappear after merge, but experiment
|
||||
# reruns still need a stable commit to checkout.
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }}
|
||||
|
||||
- name: Install native build tools
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: Linux golden E2E uses the same native install path as PR/release CI,
|
||||
# which needs pnpm to bypass its non-executable bundled gyp_main.py.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for E2E
|
||||
run: npx electron-vite build --mode e2e
|
||||
|
||||
- name: Run golden E2E tests on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
|
||||
- name: Run golden E2E tests on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: golden-${{ matrix.platform }}-playwright-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,199 @@
|
||||
name: Homebrew Cask Bump
|
||||
|
||||
# Why: keep the stablyai/homebrew-orca tap in sync with GitHub Releases.
|
||||
# Triggers:
|
||||
# - workflow_call → release-cut.yml finished publishing a stable tag
|
||||
# - workflow_dispatch → manual backfill for a specific tag
|
||||
#
|
||||
# The job computes the sha256 of orca-macos-arm64.dmg + orca-macos-x64.dmg
|
||||
# from the release, templates the channel-specific cask in this repo with the
|
||||
# new version and hashes, then PRs the result into stablyai/homebrew-orca.
|
||||
# Stable tags update Casks/orca.rb; RC tags update Casks/orca@rc.rb.
|
||||
#
|
||||
# Why no `release.published` trigger: other products shipped from this repo
|
||||
# (e.g. mobile under `mobile-v*`) also publish GitHub Releases. A blanket
|
||||
# `release.published` trigger would fire this workflow for any of them and
|
||||
# attempt to download `orca-macos-*.dmg` from a non-desktop release, at
|
||||
# best 404ing and at worst (if the filter were weaker) rewriting the cask
|
||||
# to a non-desktop version. The tap is only ever bumped by the desktop
|
||||
# release pipeline calling this workflow directly.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to sync (e.g. v1.3.25)
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to sync (e.g. v1.3.25)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
bump-cask:
|
||||
runs-on: ubuntu-latest
|
||||
# Why: only desktop tags have the macOS DMGs this workflow downloads.
|
||||
# A mobile tag such as `mobile-v0.0.1` must never rewrite either cask.
|
||||
if: >
|
||||
inputs.tag != '' &&
|
||||
startsWith(inputs.tag, 'v')
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout orca
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Resolve tag and version
|
||||
id: tag
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "$INPUT_TAG" ]]; then
|
||||
tag="$INPUT_TAG"
|
||||
else
|
||||
tag="$RELEASE_TAG"
|
||||
fi
|
||||
version="${tag#v}"
|
||||
echo "tag=$tag" >>"$GITHUB_OUTPUT"
|
||||
echo "version=$version" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve cask target
|
||||
id: cask
|
||||
env:
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
VERSION: ${{ steps.tag.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
token="orca@rc"
|
||||
branch_token="orca-rc"
|
||||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
token="orca"
|
||||
branch_token="orca"
|
||||
else
|
||||
echo "::error::Unsupported desktop tag shape: $TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
path="Casks/${token}.rb"
|
||||
if [[ ! -f "$path" ]]; then
|
||||
echo "::error::Missing cask template: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "token=$token" >>"$GITHUB_OUTPUT"
|
||||
echo "path=$path" >>"$GITHUB_OUTPUT"
|
||||
echo "branch=bump/${branch_token}-${VERSION}" >>"$GITHUB_OUTPUT"
|
||||
echo "title=${token} ${VERSION}" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download DMGs and compute sha256
|
||||
id: hash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dmgs
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern 'orca-macos-arm64.dmg' \
|
||||
--pattern 'orca-macos-x64.dmg' \
|
||||
--dir dmgs
|
||||
arm=$(sha256sum dmgs/orca-macos-arm64.dmg | awk '{print $1}')
|
||||
intel=$(sha256sum dmgs/orca-macos-x64.dmg | awk '{print $1}')
|
||||
echo "arm=$arm" >>"$GITHUB_OUTPUT"
|
||||
echo "intel=$intel" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Render updated cask file
|
||||
env:
|
||||
VERSION: ${{ steps.tag.outputs.version }}
|
||||
ARM_SHA: ${{ steps.hash.outputs.arm }}
|
||||
INTEL_SHA: ${{ steps.hash.outputs.intel }}
|
||||
CASK_PATH: ${{ steps.cask.outputs.path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import os, re, pathlib
|
||||
p = pathlib.Path(os.environ["CASK_PATH"])
|
||||
src = p.read_text()
|
||||
src = re.sub(r'version "[^"]+"',
|
||||
f'version "{os.environ["VERSION"]}"', src, count=1)
|
||||
src = re.sub(r'sha256 arm:\s*"[0-9a-f]+",\s*\n\s*intel:\s*"[0-9a-f]+"',
|
||||
'sha256 arm: "{arm}",\n intel: "{intel}"'.format(
|
||||
arm=os.environ["ARM_SHA"],
|
||||
intel=os.environ["INTEL_SHA"]),
|
||||
src, count=1)
|
||||
p.write_text(src)
|
||||
print(src)
|
||||
PY
|
||||
|
||||
# Why: reuse the existing buf0-bot GitHub App (also used by
|
||||
# track-community-prs.yaml) instead of minting a new PAT. The app is
|
||||
# installed org-wide, so it has access to stablyai/homebrew-orca
|
||||
# automatically. GITHUB_TOKEN cannot push cross-repo; a short-lived
|
||||
# installation token can.
|
||||
- name: Generate buf0-bot token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: 2590194
|
||||
private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }}
|
||||
owner: stablyai
|
||||
repositories: homebrew-orca
|
||||
|
||||
- name: Checkout tap
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: stablyai/homebrew-orca
|
||||
path: tap
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Copy cask into tap and open PR
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
VERSION: ${{ steps.tag.outputs.version }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
CASK_PATH: ${{ steps.cask.outputs.path }}
|
||||
BRANCH: ${{ steps.cask.outputs.branch }}
|
||||
TITLE: ${{ steps.cask.outputs.title }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "tap/$(dirname "$CASK_PATH")"
|
||||
cp "$CASK_PATH" "tap/$CASK_PATH"
|
||||
|
||||
cd tap
|
||||
# Why: commit author must match the buf0-bot app so commits are
|
||||
# attributed to the bot in the tap's history / PR UI.
|
||||
git config user.name "buf0-bot[bot]"
|
||||
git config user.email "252831055+buf0-bot[bot]@users.noreply.github.com"
|
||||
|
||||
if git diff --quiet; then
|
||||
echo "Cask already up-to-date for $TAG; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
branch="$BRANCH"
|
||||
git checkout -b "$branch"
|
||||
git add "$CASK_PATH"
|
||||
git commit -m "$TITLE"
|
||||
git push -u origin "$branch" --force
|
||||
|
||||
# Why: repeated backfills can race an existing PR branch, so edit
|
||||
# the PR title if creation reports that one already exists.
|
||||
gh pr create \
|
||||
--title "$TITLE" \
|
||||
--body "Automated bump of $CASK_PATH from stablyai/orca release $TAG." \
|
||||
--base main \
|
||||
--head "$branch" \
|
||||
|| gh pr edit "$branch" --title "$TITLE"
|
||||
|
||||
# Why: squash-merge immediately rather than --auto. The tap has no
|
||||
# required checks or branch protection, and `gh pr merge --auto`
|
||||
# only activates when there's something to wait on — on a repo
|
||||
# with no gates it silently no-ops, leaving the PR open forever.
|
||||
gh pr merge "$branch" --squash --delete-branch
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Label Issues by OS
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
apply-os-label:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Apply OS label from issue form
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const body = issue.body || "";
|
||||
const osMatch = body.match(/^### Operating system\s+(.+)$/m);
|
||||
|
||||
if (!osMatch) {
|
||||
core.info("No operating system field found. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOs = osMatch[1].trim().toLowerCase();
|
||||
const osLabelMap = {
|
||||
macos: "os:macos",
|
||||
windows: "os:Windows",
|
||||
linux: "os:linux",
|
||||
};
|
||||
|
||||
const desiredLabel = osLabelMap[selectedOs];
|
||||
if (!desiredLabel) {
|
||||
core.info(`No OS label configured for "${selectedOs}". Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const managedLabels = Object.values(osLabelMap);
|
||||
const existingLabels = issue.labels.map((label) =>
|
||||
typeof label === "string" ? label : label.name,
|
||||
);
|
||||
const labelsToKeep = existingLabels.filter(
|
||||
(label) => !managedLabels.includes(label),
|
||||
);
|
||||
const nextLabels = [...new Set([...labelsToKeep, desiredLabel])];
|
||||
|
||||
await github.rest.issues.setLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: nextLabels,
|
||||
});
|
||||
|
||||
core.info(`Applied label ${desiredLabel} to issue #${issue.number}.`);
|
||||
@@ -0,0 +1,138 @@
|
||||
name: Linux Wayland GPU Sandbox
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/linux-wayland-gpu-sandbox.yml
|
||||
- config/scripts/linux-wayland-renderer-diagnostics.mjs
|
||||
- config/scripts/linux-wayland-terminal-exercise.mjs
|
||||
- config/scripts/linux-wayland-validation-watchdog.mjs
|
||||
- config/scripts/verify-linux-wayland-gpu-sandbox.mjs
|
||||
- src/main/startup/configure-process.ts
|
||||
- src/main/startup/configure-process.test.ts
|
||||
- src/preload/api-types.ts
|
||||
- src/preload/index.ts
|
||||
- src/renderer/src/components/terminal-pane/pty-connection.ts
|
||||
- src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts
|
||||
- src/renderer/src/web/web-preload-api.ts
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: wayland terminal input
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install native build tools and Wayland compositor
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh weston
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: mirrors pr.yml so native module rebuilds do not use pnpm's
|
||||
# non-executable bundled gyp_main.py on Linux runners.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare dependency install
|
||||
run: |
|
||||
if [ -e node_modules ]; then
|
||||
ls -ld node_modules
|
||||
rm -rf node_modules
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
|
||||
# runners while creating the root node_modules. Use the normal resolver
|
||||
# path, then verify package metadata stayed unchanged.
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
|
||||
git diff --exit-code package.json pnpm-lock.yaml
|
||||
|
||||
- name: Start Weston
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export XDG_RUNTIME_DIR="$RUNNER_TEMP/xdg-runtime"
|
||||
mkdir -p "$XDG_RUNTIME_DIR"
|
||||
chmod 700 "$XDG_RUNTIME_DIR"
|
||||
unset DISPLAY
|
||||
weston --backend=headless-backend.so --socket=wayland-1 --idle-time=0 --log="$RUNNER_TEMP/weston.log" &
|
||||
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" >> "$GITHUB_ENV"
|
||||
echo "WAYLAND_DISPLAY=wayland-1" >> "$GITHUB_ENV"
|
||||
echo "XDG_SESSION_TYPE=wayland" >> "$GITHUB_ENV"
|
||||
echo "ELECTRON_OZONE_PLATFORM_HINT=wayland" >> "$GITHUB_ENV"
|
||||
echo "ORCA_WAYLAND_GPU_VERBOSE=1" >> "$GITHUB_ENV"
|
||||
for _ in $(seq 1 50); do
|
||||
if [ -S "$XDG_RUNTIME_DIR/wayland-1" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
test -S "$XDG_RUNTIME_DIR/wayland-1"
|
||||
|
||||
- name: Reproduce terminal input freeze without the workaround
|
||||
id: reproduce_base
|
||||
if: github.event_name == 'pull_request'
|
||||
# Why: still collect fixed-path Wayland evidence when the base repro
|
||||
# stops reproducing; the final gate below fails the job in that case.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}"
|
||||
# Why: once the PR base includes this workaround, the partial
|
||||
# checkout cannot reconstruct an unfixed Wayland negative control.
|
||||
if git show "${{ github.event.pull_request.base.sha }}:src/main/startup/configure-process.ts" | grep -Eq "appendSwitch\\(['\"]disable-gpu-sandbox['\"]\\)"; then
|
||||
echo "The base build already contains the Linux Wayland GPU sandbox workaround; skipping unfixed-path reproduction."
|
||||
exit 0
|
||||
fi
|
||||
# Why: keep the new verifier scripts, but run them against the
|
||||
# unfixed production terminal/GPU path instead of a hybrid checkout.
|
||||
git checkout "${{ github.event.pull_request.base.sha }}" -- \
|
||||
src/main/startup/configure-process.ts \
|
||||
src/preload/api-types.ts \
|
||||
src/preload/index.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-connection.ts \
|
||||
src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts \
|
||||
src/renderer/src/web/web-preload-api.ts
|
||||
rm -rf out
|
||||
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs --mode=expect-repro
|
||||
|
||||
- name: Verify terminal input under Wayland GPU sandbox workaround
|
||||
if: ${{ !cancelled() }}
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git checkout --force "$GITHUB_SHA"
|
||||
rm -rf out
|
||||
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs
|
||||
|
||||
- name: Require base reproduction
|
||||
if: ${{ github.event_name == 'pull_request' && steps.reproduce_base.outcome != 'success' }}
|
||||
run: |
|
||||
echo "The unfixed base build did not reproduce the Wayland terminal input failure."
|
||||
exit 1
|
||||
|
||||
- name: Upload Weston log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: weston-log
|
||||
path: ${{ runner.temp }}/weston.log
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,114 @@
|
||||
name: Mobile Android Release
|
||||
|
||||
# Why a separate workflow from iOS: iOS releases go through App Store review,
|
||||
# which can take days. Decoupling the triggers lets an Android release ship
|
||||
# immediately without waiting on iOS, and vice versa.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'mobile-android-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_version:
|
||||
description: 'Optional exact mobile marketing version assertion, e.g. 0.0.22'
|
||||
required: false
|
||||
type: string
|
||||
publish_github_release:
|
||||
description: 'Create or update the mobile-android-v<version> GitHub Release'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
android-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Why: the "Create GitHub Release" step below uses the default GITHUB_TOKEN
|
||||
# to call `gh release create`, which requires contents:write. Without this,
|
||||
# the job builds the APK fine but fails at release time with
|
||||
# "HTTP 403: Resource not accessible by integration".
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Resolve release version and version code
|
||||
id: release
|
||||
env:
|
||||
MOBILE_ANDROID_RELEASE_VERSION: ${{ github.event.inputs.release_version }}
|
||||
MOBILE_ANDROID_PUBLISH_RELEASE: ${{ github.event.inputs.publish_github_release }}
|
||||
run: node scripts/prepare-android-release.mjs
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Expo prebuild
|
||||
run: npx expo prebuild --platform android --no-install
|
||||
|
||||
- name: Build Android release APK
|
||||
run: cd android && ./gradlew assembleRelease
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-mobile-apk
|
||||
path: mobile/android/app/build/outputs/apk/release/*.apk
|
||||
|
||||
- name: Ensure GitHub release tag
|
||||
if: steps.release.outputs.publish_release == 'true' && !startsWith(github.ref, 'refs/tags/mobile-android-v')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${{ steps.release.outputs.tag }}"
|
||||
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then
|
||||
echo "Tag $tag already exists"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git tag "$tag" "$GITHUB_SHA"
|
||||
git push origin "refs/tags/$tag"
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.release.outputs.publish_release == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${{ steps.release.outputs.tag }}"
|
||||
|
||||
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release upload "$tag" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--clobber \
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
else
|
||||
gh release create "$tag" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "Orca Mobile Android $tag" \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
--generate-notes \
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
fi
|
||||
@@ -0,0 +1,134 @@
|
||||
name: Mobile iOS Release
|
||||
|
||||
# Why a separate workflow from Android: iOS releases go through App Store
|
||||
# review, which can take days. Decoupling the triggers lets an Android release
|
||||
# ship immediately without waiting on iOS, and vice versa.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'mobile-ios-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump_patch_version:
|
||||
description: 'Bump the iOS marketing version patch number before release. Tick this after a version has shipped to the App Store (the release fails fast if the current version''s train is already closed).'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
release_version:
|
||||
description: 'Optional exact iOS marketing version override, e.g. 0.0.15'
|
||||
required: false
|
||||
type: string
|
||||
testflight_changelog:
|
||||
description: 'Optional TestFlight external tester changelog'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
ios-build:
|
||||
# GitHub-hosted macOS runner: required for Xcode. Expo SDK 55's
|
||||
# expo-modules-core declares swift_version 6.0 and uses Swift 6 syntax
|
||||
# (@MainActor isolation). Xcode 16.x (macos-15) can't even parse it
|
||||
# ("unknown attribute 'MainActor'"), so we need Xcode 26.x → macos-26.
|
||||
runs-on: macos-26
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
# Xcode 26.x ships the Swift 6.x toolchain Expo SDK 55 requires.
|
||||
xcode-version: '26.5'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup Ruby and fastlane
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
working-directory: mobile
|
||||
|
||||
- name: Resolve release version and build number
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
|
||||
MOBILE_IOS_RELEASE_VERSION: ${{ github.event.inputs.release_version }}
|
||||
MOBILE_IOS_BUMP_PATCH_VERSION: ${{ github.event.inputs.bump_patch_version }}
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
run: bundle exec fastlane ios prepare_release_version version:"$MOBILE_IOS_RELEASE_VERSION" bump_patch:"$MOBILE_IOS_BUMP_PATCH_VERSION"
|
||||
|
||||
- name: Expo prebuild
|
||||
run: npx expo prebuild --platform ios --no-install
|
||||
|
||||
- name: Install CocoaPods
|
||||
run: npx pod-install ios
|
||||
|
||||
# Why: `-allowProvisioningUpdates` + the App Store Connect API key can
|
||||
# create/refresh provisioning profiles, but it cannot recreate the
|
||||
# distribution certificate's PRIVATE KEY across runs. So we import a
|
||||
# pre-exported distribution .p12 (created once via Apple Developer) into a
|
||||
# throwaway keychain. The keychain is ephemeral to the runner and torn
|
||||
# down with the VM; nothing secret is written to the repo.
|
||||
- name: Import distribution certificate
|
||||
env:
|
||||
IOS_DIST_CERT_P12: ${{ secrets.IOS_DIST_CERT_P12 }}
|
||||
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/orca-signing.keychain-db"
|
||||
# Random per-run keychain password; never persisted.
|
||||
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
|
||||
CERT_PATH="$RUNNER_TEMP/orca-dist-cert.p12"
|
||||
|
||||
echo "$IOS_DIST_CERT_P12" | base64 --decode > "$CERT_PATH"
|
||||
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" -P "$IOS_DIST_CERT_PASSWORD" \
|
||||
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
# Allow codesign/xcodebuild to use the key without an interactive prompt.
|
||||
security set-key-partition-list -S apple-tool:,apple: \
|
||||
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
|
||||
# Put our keychain in the search list so xcodebuild can find the identity.
|
||||
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
|
||||
rm -f "$CERT_PATH"
|
||||
|
||||
- name: Build and upload to TestFlight
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TESTFLIGHT_CHANGELOG: ${{ github.event.inputs.testflight_changelog }}
|
||||
# Keep fastlane non-interactive and quiet about analytics in CI.
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
run: bundle exec fastlane ios release
|
||||
|
||||
- name: Upload .ipa artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-mobile-ipa
|
||||
path: mobile/build/*.ipa
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Mobile Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- 'mobile/**'
|
||||
# Why: the mobile terminal link parsers are conformance-tested against
|
||||
# these shared fixtures; desktop-side fixture edits must re-run this suite.
|
||||
- 'src/shared/terminal-file-link-conformance.ts'
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: the mobile typecheck imports shared types from ../src/shared, and
|
||||
# some of those files import runtime deps (tweetnacl, ws) resolved from
|
||||
# the repo-root node_modules. Without a root install, tsc fails with
|
||||
# "Cannot find module 'tweetnacl'/'ws'". Mobile is a separate pnpm project
|
||||
# (not in the root workspace), so this is a distinct install.
|
||||
# --ignore-scripts skips the root postinstall (Electron native-module
|
||||
# rebuild) which is irrelevant to a type-only check and would only add
|
||||
# time and failure surface on this ubuntu mobile runner.
|
||||
- name: Install root dependencies
|
||||
working-directory: .
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm format:check
|
||||
@@ -0,0 +1,154 @@
|
||||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: pnpm's bundled node-gyp ships gyp_main.py without execute
|
||||
# permission, which breaks native module builds (e.g. node-pty's
|
||||
# postinstall) with "/bin/sh: gyp_main.py: Permission denied".
|
||||
# Pin the fallback to the lockfile's node-gyp version so CI stays
|
||||
# reproducible while forcing pnpm to bypass its broken bundled copy.
|
||||
# Gate on runner.os == 'Linux' to match release.yml — the
|
||||
# npm-global path layout this step assumes is POSIX-shaped, and the
|
||||
# failure has only been observed on Linux runners. Today this job
|
||||
# pins runs-on: ubuntu-latest so the guard is a no-op, but it
|
||||
# prevents a silent break if a Windows/macOS matrix is added later.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare dependency install
|
||||
run: |
|
||||
if [ -e node_modules ]; then
|
||||
ls -ld node_modules
|
||||
rm -rf node_modules
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
|
||||
# runners while creating the root node_modules. Use the normal resolver
|
||||
# path, then verify package metadata stayed unchanged.
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
|
||||
git diff --exit-code package.json pnpm-lock.yaml
|
||||
|
||||
- name: Lint
|
||||
run: pnpm exec oxlint --format github
|
||||
|
||||
- name: Check styled scrollbars
|
||||
run: pnpm check:styled-scrollbars
|
||||
|
||||
- name: Check reliability gate manifest
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
|
||||
# ratchet forbids ADDING a new suppression (inline disable or mobile max
|
||||
# bump). Existing oversized files are grandfathered in
|
||||
# config/max-lines-baseline.txt, which may only shrink — new bypasses fail
|
||||
# here with a clear message instead of silently growing the debt.
|
||||
- name: Enforce max-lines ratchet (no new bypasses)
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
# Why: project-owned type declarations must live in .ts so tsc
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# from @electron-toolkit/tsconfig) silently widens unresolved names
|
||||
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
|
||||
# past typecheck. See docs/preload-typecheck-hole.md.
|
||||
- name: Guard against project-owned .d.ts in preload/shared
|
||||
run: |
|
||||
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
|
||||
if [ -n "$matches" ]; then
|
||||
echo "::error::Project-owned .d.ts files are not allowed under src/preload or src/shared."
|
||||
echo "Move type declarations into a .ts file so skipLibCheck does not hide errors."
|
||||
echo "See docs/preload-typecheck-hole.md."
|
||||
echo "Found:"
|
||||
echo "$matches"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check feature wall asset budget
|
||||
run: pnpm check:feature-wall-assets
|
||||
|
||||
- name: Verify macOS entitlements
|
||||
run: pnpm verify:macos-entitlements
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
# Why: real old Git diagnostics differ from mocked errors. Keep the
|
||||
# fallback predicates executable across the baseline, transition, and
|
||||
# current command shapes so a newly added flag cannot silently regress.
|
||||
- name: Verify Git binary compatibility matrix
|
||||
run: |
|
||||
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
|
||||
source="$RUNNER_TEMP/git-2.25.5"
|
||||
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
|
||||
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
|
||||
| sha256sum --check
|
||||
mkdir -p "$source"
|
||||
tar -xzf "$archive" -C "$source" --strip-components=1
|
||||
make -C "$source" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
|
||||
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
|
||||
for spec in \
|
||||
"alpine/git:edge-2.38.1|2.38.1" \
|
||||
"alpine/git:v2.49.1|2.49.1"; do
|
||||
image="${spec%%|*}"
|
||||
version="${spec#*|}"
|
||||
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
done
|
||||
|
||||
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
|
||||
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
|
||||
# it for Node so orchestration tests can load the native module.
|
||||
- name: Rebuild better-sqlite3 for Node
|
||||
run: pnpm rebuild better-sqlite3
|
||||
|
||||
# Why: install intentionally blocks Electron's package postinstall, but
|
||||
# some unit tests import `electron` under Node and require path.txt.
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Build unpacked app
|
||||
run: pnpm build:unpack
|
||||
|
||||
# Why: the packaged CLI runs outside Electron's asar integration, so
|
||||
# bare runtime imports must be present in the package itself. Run from a
|
||||
# temp copy so Node cannot mask missing package deps with repo node_modules.
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
|
||||
@@ -0,0 +1,57 @@
|
||||
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
|
||||
name: Pullfrog
|
||||
run-name: ${{ inputs.name || github.workflow }}
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
prompt:
|
||||
type: string
|
||||
description: Agent prompt
|
||||
name:
|
||||
type: string
|
||||
description: Run name
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pullfrog:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Run agent
|
||||
uses: pullfrog/pullfrog@v0
|
||||
with:
|
||||
prompt: ${{ inputs.prompt }}
|
||||
env:
|
||||
# add at least one provider API key
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
|
||||
# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
|
||||
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
|
||||
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
# AWS_REGION: us-east-1
|
||||
# BEDROCK_MODEL_ID: <bedrock-model-id>
|
||||
|
||||
# for Google Vertex AI (https://docs.pullfrog.com/vertex)
|
||||
# VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
|
||||
# GOOGLE_CLOUD_PROJECT: my-project
|
||||
# VERTEX_LOCATION: global
|
||||
# VERTEX_MODEL_ID: <vertex-model-id>
|
||||
@@ -0,0 +1,48 @@
|
||||
name: README Downloads Badge
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '17 */6 * * *'
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
- edited
|
||||
- deleted
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: readme-downloads-badge
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update:
|
||||
# Why: this workflow commits to main, so forks should not create divergent badge commits.
|
||||
if: github.repository == 'stablyai/orca'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Render downloads badge
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node .github/scripts/render-readme-downloads-badge.mjs
|
||||
|
||||
- name: Commit badge update
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- docs/assets/readme-downloads.svg; then
|
||||
echo "Downloads badge is already current."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add docs/assets/readme-downloads.svg
|
||||
git commit -m "Update README downloads badge"
|
||||
git push origin main
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
name: Release macOS Build
|
||||
|
||||
run-name: Mac release build ${{ inputs.tag }} (${{ inputs.release_run_id }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag whose draft should receive macOS artifacts
|
||||
required: true
|
||||
type: string
|
||||
release_run_id:
|
||||
description: release-cut workflow run that requested this build
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-mac-build-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-mac:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
# Why: this workflow is outside the SignPath signing run, so Blacksmith
|
||||
# cannot enter Windows artifact provenance while mac notarization gets the
|
||||
# faster runner.
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Cache the Electron binary + electron-builder tool downloads (notarytool,
|
||||
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/electron
|
||||
~/Library/Caches/electron-builder
|
||||
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-mac-
|
||||
|
||||
# Why: pnpm install triggers electron's postinstall, which downloads the
|
||||
# Electron binary from GitHub release assets. GitHub's download CDN
|
||||
# occasionally returns 504s that fail the whole release. Retry on
|
||||
# failure so transient network errors don't require a manual re-run.
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify macOS signing environment
|
||||
run: node config/scripts/verify-macos-release-env.mjs
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Why: `plutil -lint` accepts duplicate plist keys, but `codesign`
|
||||
# rejects duplicate entitlements after the expensive app build.
|
||||
- name: Verify macOS entitlements
|
||||
run: pnpm verify:macos-entitlements
|
||||
|
||||
# Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`)
|
||||
# requires the build identity to be the literal string `stable` or `rc`,
|
||||
# substituted by electron-vite's `define` block at build time.
|
||||
- name: Classify release tag for telemetry build identity
|
||||
id: tag-classify
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why the optional trailing identifier: suffixed side-branch RCs
|
||||
# (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry
|
||||
# identity as plain RCs.
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then
|
||||
identity=rc
|
||||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
identity=stable
|
||||
else
|
||||
echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact"
|
||||
exit 1
|
||||
fi
|
||||
echo "identity=$identity" >>"$GITHUB_OUTPUT"
|
||||
echo "Classified $TAG as $identity"
|
||||
|
||||
- name: Build app
|
||||
run: pnpm build:release
|
||||
env:
|
||||
# Why: Vite's web build crossed Node's default old-space ceiling on
|
||||
# the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft.
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
|
||||
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
|
||||
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
|
||||
|
||||
- name: Gate runtime file-watcher process isolation
|
||||
run: |
|
||||
# Why: #8212 is a native-process crash contract. Prove both the Node
|
||||
# host and the exact Electron runtime survive SIGSEGV before packaging.
|
||||
node config/scripts/runtime-file-watcher-fault-harness.mjs
|
||||
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
|
||||
|
||||
- name: Gate SSH relay watcher process isolation
|
||||
run: |
|
||||
# Why: the remote native watcher shares a daemon with live PTYs.
|
||||
# Kill only its child and require both PTY and watch recovery before packaging.
|
||||
node config/scripts/relay-watcher-fault-harness.mjs
|
||||
|
||||
- name: Publish release artifacts (macOS)
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Verify release remains draft after artifact upload
|
||||
# Why: the macOS build must never be the actor that exposes a partial
|
||||
# release. If an uploader or GitHub transition flips draft early, fail
|
||||
# this job so release-cut never publishes the release.
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")"
|
||||
# Why: release upload must validate the draft before it is publicly visible.
|
||||
draft="$(jq -e -r --arg tag "$TAG" '
|
||||
map(select(.tag_name == $tag))
|
||||
| if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end
|
||||
' <<<"$releases_json")" || {
|
||||
echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing."
|
||||
exit 1
|
||||
}
|
||||
if [[ "$draft" != "true" ]]; then
|
||||
echo "::error::Release $TAG was published during the mac artifact upload."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Why post-publish for macOS: electron-builder packs and uploads in a
|
||||
# single `--publish always` invocation, so there is no cheap insertion
|
||||
# point between pack and upload without splitting those steps.
|
||||
- name: Verify telemetry constants present in app.asar
|
||||
run: node config/scripts/verify-telemetry-constants.mjs
|
||||
@@ -0,0 +1,148 @@
|
||||
name: Terminal Perf
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Ref to check out (defaults to the workflow ref)
|
||||
required: false
|
||||
type: string
|
||||
grep:
|
||||
description: Optional Playwright grep for a targeted terminal perf run
|
||||
required: false
|
||||
type: string
|
||||
report_path:
|
||||
description: Path for the generated terminal perf JSON report
|
||||
required: false
|
||||
type: string
|
||||
frame_count:
|
||||
description: Number of prompt redraw frames per typing measurement
|
||||
required: false
|
||||
type: string
|
||||
frame_interval_ms:
|
||||
description: Milliseconds between synthetic OpenCode redraw frames
|
||||
required: false
|
||||
type: string
|
||||
pressure_output_chars:
|
||||
description: Bytes/chars each ACK-backpressured real PTY should emit
|
||||
required: false
|
||||
type: string
|
||||
scale_panes:
|
||||
description: Comma-separated same-workspace pane counts
|
||||
required: false
|
||||
type: string
|
||||
scale_cross_workspace_panes:
|
||||
description: Comma-separated hidden cross-workspace pane counts
|
||||
required: false
|
||||
type: string
|
||||
scale_pressure_panes:
|
||||
description: Comma-separated active real-PTY pressure pane counts
|
||||
required: false
|
||||
type: string
|
||||
scale_hidden_pressure_panes:
|
||||
description: Comma-separated hidden real-PTY pressure pane counts
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
# Why: keep the expensive scale run off PRs, but exercise it daily so
|
||||
# terminal throughput regressions are caught before users report typing lag.
|
||||
- cron: '30 8 * * *'
|
||||
|
||||
jobs:
|
||||
terminal-perf:
|
||||
name: terminal scale perf
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1'
|
||||
ORCA_E2E_TERMINAL_PERF_REPORT_PATH: ${{ inputs.report_path || 'test-results/terminal-scale-perf-report.json' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build tools and xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: this scheduled/manual workflow uses the same native install path as
|
||||
# PR and E2E CI, which needs pnpm to bypass its bundled gyp_main.py.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for terminal perf
|
||||
run: npx electron-vite build --mode e2e
|
||||
|
||||
- name: Run terminal scale perf report gate
|
||||
env:
|
||||
ORCA_TERMINAL_PERF_GREP: ${{ inputs.grep }}
|
||||
ORCA_TERMINAL_PERF_FRAME_COUNT: ${{ inputs.frame_count }}
|
||||
ORCA_TERMINAL_PERF_FRAME_INTERVAL_MS: ${{ inputs.frame_interval_ms }}
|
||||
ORCA_TERMINAL_PERF_PRESSURE_OUTPUT_CHARS: ${{ inputs.pressure_output_chars }}
|
||||
ORCA_TERMINAL_PERF_SCALE_PANES: ${{ inputs.scale_panes }}
|
||||
ORCA_TERMINAL_PERF_SCALE_CROSS_WORKSPACE_PANES: ${{ inputs.scale_cross_workspace_panes }}
|
||||
ORCA_TERMINAL_PERF_SCALE_PRESSURE_PANES: ${{ inputs.scale_pressure_panes }}
|
||||
ORCA_TERMINAL_PERF_SCALE_HIDDEN_PRESSURE_PANES: ${{ inputs.scale_hidden_pressure_panes }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
if [ -n "${ORCA_TERMINAL_PERF_GREP:-}" ]; then
|
||||
args+=(--grep "$ORCA_TERMINAL_PERF_GREP")
|
||||
fi
|
||||
if [ -n "${ORCA_TERMINAL_PERF_FRAME_COUNT:-}" ]; then
|
||||
export ORCA_E2E_OPENCODE_FRAME_COUNT="$ORCA_TERMINAL_PERF_FRAME_COUNT"
|
||||
fi
|
||||
if [ -n "${ORCA_TERMINAL_PERF_FRAME_INTERVAL_MS:-}" ]; then
|
||||
export ORCA_E2E_OPENCODE_FRAME_INTERVAL_MS="$ORCA_TERMINAL_PERF_FRAME_INTERVAL_MS"
|
||||
fi
|
||||
if [ -n "${ORCA_TERMINAL_PERF_PRESSURE_OUTPUT_CHARS:-}" ]; then
|
||||
export ORCA_E2E_OPENCODE_PRESSURE_OUTPUT_CHARS="$ORCA_TERMINAL_PERF_PRESSURE_OUTPUT_CHARS"
|
||||
fi
|
||||
if [ -n "${ORCA_TERMINAL_PERF_SCALE_PANES:-}" ]; then
|
||||
export ORCA_E2E_OPENCODE_SCALE_PANES="$ORCA_TERMINAL_PERF_SCALE_PANES"
|
||||
fi
|
||||
if [ -n "${ORCA_TERMINAL_PERF_SCALE_CROSS_WORKSPACE_PANES:-}" ]; then
|
||||
export ORCA_E2E_OPENCODE_SCALE_CROSS_WORKSPACE_PANES="$ORCA_TERMINAL_PERF_SCALE_CROSS_WORKSPACE_PANES"
|
||||
fi
|
||||
if [ -n "${ORCA_TERMINAL_PERF_SCALE_PRESSURE_PANES:-}" ]; then
|
||||
export ORCA_E2E_OPENCODE_SCALE_PRESSURE_PANES="$ORCA_TERMINAL_PERF_SCALE_PRESSURE_PANES"
|
||||
fi
|
||||
if [ -n "${ORCA_TERMINAL_PERF_SCALE_HIDDEN_PRESSURE_PANES:-}" ]; then
|
||||
export ORCA_E2E_OPENCODE_SCALE_HIDDEN_PRESSURE_PANES="$ORCA_TERMINAL_PERF_SCALE_HIDDEN_PRESSURE_PANES"
|
||||
fi
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 pnpm run test:e2e:terminal-perf:scale:report -- "${args[@]}"
|
||||
|
||||
- name: Upload terminal perf report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-scale-perf-report
|
||||
path: ${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Playwright traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-scale-perf-traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,119 @@
|
||||
name: Track Community PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to backfill or retry'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
track-community-pr:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Generate bufo-bot token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: 2590194
|
||||
private-key: ${{ secrets.BUFO_BOT_PRIVATE_KEY }}
|
||||
owner: stablyai
|
||||
|
||||
- name: Add PR to project
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
PROJECT_OWNER: stablyai
|
||||
PROJECT_NUMBER: '13'
|
||||
INTERNAL_TEAM_SLUG: stably-eng
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const projectOwner = process.env.PROJECT_OWNER;
|
||||
const projectNumber = Number(process.env.PROJECT_NUMBER);
|
||||
const internalTeamSlug = process.env.INTERNAL_TEAM_SLUG;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const prNumber = context.eventName === 'workflow_dispatch'
|
||||
? Number(context.payload.inputs.pr_number)
|
||||
: context.payload.pull_request.number;
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
const author = pr.user.login;
|
||||
|
||||
const skippedAuthors = new Set([
|
||||
'github-actions[bot]',
|
||||
'dependabot[bot]',
|
||||
]);
|
||||
|
||||
if (skippedAuthors.has(author)) {
|
||||
core.info(`Skipping bot PR author ${author}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let isInternalAuthor = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: projectOwner,
|
||||
team_slug: internalTeamSlug,
|
||||
username: author,
|
||||
});
|
||||
isInternalAuthor = membership.data.state === 'active';
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isInternalAuthor) {
|
||||
core.info(`Skipping internal PR author ${author}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectResult = await github.graphql(
|
||||
`
|
||||
query($owner: String!, $number: Int!) {
|
||||
organization(login: $owner) {
|
||||
projectV2(number: $number) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ owner: projectOwner, number: projectNumber },
|
||||
);
|
||||
|
||||
const projectId = projectResult.organization.projectV2.id;
|
||||
|
||||
await github.graphql(
|
||||
`
|
||||
mutation($projectId: ID!, $contentId: ID!) {
|
||||
addProjectV2ItemById(input: {
|
||||
projectId: $projectId,
|
||||
contentId: $contentId
|
||||
}) {
|
||||
item {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
projectId,
|
||||
contentId: pr.node_id,
|
||||
},
|
||||
);
|
||||
|
||||
core.info(`Added PR #${pr.number} (${pr.html_url}) to project ${projectOwner}/${projectNumber}.`);
|
||||
@@ -0,0 +1,145 @@
|
||||
name: Windows Update-Survival E2E
|
||||
|
||||
# Why: proves what happens to terminal sessions across a real Windows app update
|
||||
# by installing one released version, updating to another, and asserting on the
|
||||
# packaged artifacts (daemon survival, terminal interactivity, zero console
|
||||
# flashes). This MUST run on a disposable CI Windows: electron-builder's oneClick
|
||||
# installer uninstalls the registry-registered copy of the app before every
|
||||
# install, so running the harness on a machine with a real Orca install would
|
||||
# delete it. A fresh runner has no Orca registered, so it is the only safe home
|
||||
# for install-based testing. Manual dispatch only.
|
||||
|
||||
# Why: workflow_dispatch only works once a workflow is on the default branch.
|
||||
# To exercise this from the unmerged feature branch WITHOUT touching main, a
|
||||
# push trigger scoped to that exact branch runs the workflow from the branch's
|
||||
# own tree. The paths filter keeps it to harness/workflow edits so ordinary
|
||||
# commits don't spend 30 min of Windows runner time. Push runs have no inputs,
|
||||
# so every parameter falls back to a default below.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- Jinwoo-H/windows-update-survival
|
||||
paths:
|
||||
- 'tools/win-update-e2e/**'
|
||||
- '.github/workflows/win-update-e2e.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
from_tag:
|
||||
description: Release tag to install first (e.g. v1.4.124-rc.8)
|
||||
required: true
|
||||
type: string
|
||||
to_tag:
|
||||
description: Release tag to update to (e.g. v1.4.124-rc.9)
|
||||
required: true
|
||||
type: string
|
||||
expect:
|
||||
description: Expected outcome profile
|
||||
required: true
|
||||
type: choice
|
||||
default: cold-restore
|
||||
options:
|
||||
- cold-restore
|
||||
- survival
|
||||
asset_pattern:
|
||||
description: Installer asset glob
|
||||
required: false
|
||||
type: string
|
||||
default: '*windows-setup.exe'
|
||||
soak_seconds:
|
||||
description: Post-relaunch console-window watch duration
|
||||
required: false
|
||||
type: string
|
||||
default: '60'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Why: cancel a superseded run when iterating — a new push to the branch makes
|
||||
# the in-flight Windows job obsolete, so free the runner immediately.
|
||||
concurrency:
|
||||
group: win-update-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-e2e:
|
||||
name: update ${{ inputs.from_tag || 'v1.4.124-rc.8' }} -> ${{ inputs.to_tag || 'v1.4.124-rc.9' }} (${{ inputs.expect || 'cold-restore' }})
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
FROM_TAG: ${{ inputs.from_tag || 'v1.4.124-rc.8' }}
|
||||
TO_TAG: ${{ inputs.to_tag || 'v1.4.124-rc.9' }}
|
||||
EXPECT: ${{ inputs.expect || 'cold-restore' }}
|
||||
ASSET_PATTERN: ${{ inputs.asset_pattern || '*windows-setup.exe' }}
|
||||
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# This job only reads the repo and downloads a release; it never pushes.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: the harness only needs its runtime deps (the Playwright Electron
|
||||
# driver); it drives already-built installer artifacts, so no app build.
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: fail fast with a readable message if a tag has no matching Windows
|
||||
# installer, rather than deep inside the harness.
|
||||
- name: Download installers
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts/from | Out-Null
|
||||
New-Item -ItemType Directory -Force artifacts/to | Out-Null
|
||||
gh release download "$env:FROM_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/from
|
||||
gh release download "$env:TO_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/to
|
||||
$from = Get-ChildItem artifacts/from -Filter *.exe | Select-Object -First 1
|
||||
$to = Get-ChildItem artifacts/to -Filter *.exe | Select-Object -First 1
|
||||
if (-not $from) { throw "No installer matching '$env:ASSET_PATTERN' in $env:FROM_TAG" }
|
||||
if (-not $to) { throw "No installer matching '$env:ASSET_PATTERN' in $env:TO_TAG" }
|
||||
"FROM_EXE=$($from.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"TO_EXE=$($to.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
# Why: no --install-dir here. A CI runner has no real Orca registered, so
|
||||
# the harness installs to the default per-user location and owns it — the
|
||||
# isolated-install machinery exists only to (imperfectly) protect a real
|
||||
# developer install, which does not apply on a throwaway runner.
|
||||
- name: Run update-survival harness
|
||||
id: harness
|
||||
shell: pwsh
|
||||
env:
|
||||
# Why: on a driving failure the harness dumps a screenshot + DOM state
|
||||
# here so the actual post-onboarding UI is visible in the artifacts.
|
||||
ORCA_E2E_DIAG_DIR: artifacts/diag
|
||||
run: |
|
||||
$log = "artifacts/harness-output.log"
|
||||
node tools/win-update-e2e/run.mjs `
|
||||
--from "$env:FROM_EXE" `
|
||||
--to "$env:TO_EXE" `
|
||||
--expect "$env:EXPECT" `
|
||||
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
|
||||
exit $LASTEXITCODE
|
||||
|
||||
- name: Upload harness output
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: win-update-e2e-output
|
||||
# Why: log + diagnostics only, never the multi-hundred-MB installers.
|
||||
path: |
|
||||
artifacts/harness-output.log
|
||||
artifacts/diag/**
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Windows Update-Survival E2E (branch build)
|
||||
|
||||
# Why: proves the Phase 1 daemon-host relocation actually makes terminal
|
||||
# sessions SURVIVE a Windows update. Builds an (unsigned) installer FROM THIS
|
||||
# BRANCH, then runs the win-update-e2e harness with --expect survival: install,
|
||||
# open a terminal, silently update over it, relaunch, and assert the daemon PID
|
||||
# is unchanged and the pre-update terminal is still interactive with no console
|
||||
# flashing. Runs from the feature branch via push (workflow_dispatch only works
|
||||
# on the default branch); main is never touched. A CI runner is the only safe
|
||||
# place to install/update — see the relocation post-mortem.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- Jinwoo-H/windows-update-survival
|
||||
paths:
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/pty/**'
|
||||
- 'tools/win-update-e2e/**'
|
||||
- 'config/electron-builder.config.cjs'
|
||||
- '.github/workflows/win-update-survival-e2e.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expect:
|
||||
description: Expected outcome profile
|
||||
required: true
|
||||
type: choice
|
||||
default: survival
|
||||
options:
|
||||
- survival
|
||||
- cold-restore
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: win-update-survival-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
survival:
|
||||
name: survival (branch build over itself)
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 50
|
||||
env:
|
||||
EXPECT: ${{ inputs.expect || 'survival' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# This job only builds and runs the harness locally; it never pushes.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: cache the built installer keyed on the inputs that affect it, so a
|
||||
# harness-only edit skips the ~20 min electron-builder build. The daemon
|
||||
# relocation code lives under src/, so changing it correctly rebuilds.
|
||||
- name: Cache branch installer
|
||||
id: cache-installer
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: dist/orca-windows-setup.exe
|
||||
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }}
|
||||
|
||||
- name: Build Windows installer (unsigned)
|
||||
if: steps.cache-installer.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
pnpm run build:desktop
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
|
||||
|
||||
# Why: install the branch build, open a terminal, update the SAME build
|
||||
# over it, and assert the relocated daemon survives. Same build on both
|
||||
# sides isolates the survival mechanism (NSIS kill sweep + userData daemon
|
||||
# + adoption) from cross-version staleness policy. No --install-dir: a CI
|
||||
# runner has no real Orca to protect.
|
||||
- name: Run survival harness
|
||||
id: harness
|
||||
shell: pwsh
|
||||
env:
|
||||
ORCA_E2E_DIAG_DIR: artifacts/diag
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
$exe = "dist/orca-windows-setup.exe"
|
||||
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
|
||||
$log = "artifacts/survival-output.log"
|
||||
node tools/win-update-e2e/run.mjs `
|
||||
--from "$exe" `
|
||||
--to "$exe" `
|
||||
--expect "$env:EXPECT" `
|
||||
--soak-seconds 60 2>&1 | Tee-Object -FilePath $log
|
||||
exit $LASTEXITCODE
|
||||
|
||||
- name: Upload survival output
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: win-update-survival-output
|
||||
path: |
|
||||
artifacts/survival-output.log
|
||||
artifacts/diag/**
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,360 @@
|
||||
# Windows inner-binary signing rehearsal.
|
||||
#
|
||||
# Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed
|
||||
# inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires
|
||||
# a two-request flow: sign the unpacked PE files first, then build the NSIS
|
||||
# installer from the signed tree, then sign the installer. This workflow
|
||||
# rehearses that entire flow from a branch, end to end, without publishing
|
||||
# anything — so the release pipeline on main is never at risk while we verify.
|
||||
#
|
||||
# Runs only via manual dispatch. Use the test-signing policy for iteration
|
||||
# (auto-approved test certificate) and release-signing to rehearse the
|
||||
# production flow (requires manual SignPath approvals). Rehearsed successfully
|
||||
# with test-signing (run 28987534795) and release-signing (run 28988432001).
|
||||
name: Windows signing rehearsal
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
signing-policy-slug:
|
||||
description: SignPath signing policy
|
||||
type: choice
|
||||
default: test-signing
|
||||
options:
|
||||
- test-signing
|
||||
- release-signing
|
||||
inner-artifact-configuration-slug:
|
||||
description: SignPath artifact configuration for the inner-binaries zip
|
||||
type: string
|
||||
default: windows-inner-binaries-zip
|
||||
|
||||
jobs:
|
||||
rehearse:
|
||||
# Why: SignPath origin verification requires GitHub-hosted runners, and
|
||||
# windows-2022 matches the real release job's build environment.
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 360
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why: nothing here pushes; keep the token out of .git/config.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-win-
|
||||
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: rehearsal builds are never published, so the official-build
|
||||
# secrets (telemetry key, diagnostics URL) are intentionally omitted.
|
||||
- name: Build app
|
||||
run: pnpm build:release
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
- name: Package unpacked Windows app
|
||||
shell: pwsh
|
||||
run: |
|
||||
node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --dir --publish never
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if (-not (Test-Path 'dist/win-unpacked/Orca.exe')) {
|
||||
throw 'electron-builder --dir did not produce dist/win-unpacked/Orca.exe'
|
||||
}
|
||||
|
||||
# Why: only unsigned PE files go to SignPath. Files that already carry a
|
||||
# valid signature (Microsoft's OpenConsole.exe, signed vendor DLLs) must
|
||||
# keep their original signer, so they are excluded from the request.
|
||||
- name: Stage unsigned PE files for inner signing
|
||||
id: stage-inner
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
|
||||
$list = New-Object System.Collections.Generic.List[string]
|
||||
$skipped = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Get-ChildItem -Path $root -Recurse -File |
|
||||
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
|
||||
ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
|
||||
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
|
||||
if ($signature.Status -eq 'Valid') {
|
||||
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
|
||||
return
|
||||
}
|
||||
$destination = Join-Path $stage.FullName $relative
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
|
||||
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||||
$list.Add($relative)
|
||||
}
|
||||
|
||||
if (-not ($list -contains 'Orca.exe')) {
|
||||
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
|
||||
}
|
||||
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
|
||||
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
|
||||
}
|
||||
|
||||
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
|
||||
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
|
||||
$list | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host "Skipped $($skipped.Count) already-signed files:"
|
||||
$skipped | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
- name: Upload unsigned inner binaries for SignPath
|
||||
id: upload-unsigned-inner
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-inner-unsigned-${{ github.run_id }}
|
||||
path: signing-stage/**
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install SignPath PowerShell module
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
|
||||
if ($useResourceGet) {
|
||||
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSResourceRepository -PSGallery -Trusted
|
||||
} else {
|
||||
Set-PSResourceRepository -Name PSGallery -Trusted
|
||||
}
|
||||
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
|
||||
} else {
|
||||
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||
}
|
||||
Import-Module SignPath -ErrorAction Stop
|
||||
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
|
||||
|
||||
- name: Submit inner binaries signing request
|
||||
id: submit-inner-signing
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||||
project-slug: orca
|
||||
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
artifact-configuration-slug: ${{ inputs.inner-artifact-configuration-slug || 'windows-inner-binaries-zip' }}
|
||||
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
|
||||
wait-for-completion: false
|
||||
|
||||
- name: Download signed inner binaries from SignPath
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||||
run: |
|
||||
Get-SignedArtifact `
|
||||
-ApiToken $env:SIGNPATH_API_TOKEN `
|
||||
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
|
||||
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
||||
-OutputArtifactPath signed-inner.zip `
|
||||
-Force `
|
||||
-WaitForCompletionTimeoutInSeconds 3600
|
||||
New-Item -ItemType Directory -Path signed-inner -Force
|
||||
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
|
||||
|
||||
# Why: copy back strictly by the staged list so a layout mismatch in the
|
||||
# returned artifact fails loudly instead of silently shipping a mix of
|
||||
# signed and unsigned binaries.
|
||||
- name: Restore signed inner binaries into unpacked app
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
||||
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
|
||||
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $signed) {
|
||||
$failures.Add("missing from signed artifact: $relative")
|
||||
continue
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
|
||||
if ($null -eq $signature.SignerCertificate) {
|
||||
$failures.Add("returned without a signature: $relative")
|
||||
continue
|
||||
}
|
||||
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
|
||||
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
|
||||
}
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
|
||||
}
|
||||
|
||||
- name: Build NSIS installer from signed unpacked app
|
||||
shell: pwsh
|
||||
run: |
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
|
||||
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
|
||||
}
|
||||
|
||||
- name: Upload unsigned Windows installer for SignPath
|
||||
id: upload-unsigned-installer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-installer-unsigned-${{ github.run_id }}
|
||||
path: dist/orca-windows-setup.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit Windows installer signing request
|
||||
id: submit-installer-signing
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||||
project-slug: orca
|
||||
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
artifact-configuration-slug: github-actions-windows-installer
|
||||
github-artifact-id: ${{ steps.upload-unsigned-installer.outputs.artifact-id }}
|
||||
wait-for-completion: false
|
||||
|
||||
- name: Download signed Windows installer from SignPath
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-installer-signing.outputs.signing-request-id }}
|
||||
run: |
|
||||
Get-SignedArtifact `
|
||||
-ApiToken $env:SIGNPATH_API_TOKEN `
|
||||
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
|
||||
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
||||
-OutputArtifactPath signed-windows.zip `
|
||||
-Force `
|
||||
-WaitForCompletionTimeoutInSeconds 14400
|
||||
New-Item -ItemType Directory -Path signed-windows -Force
|
||||
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
|
||||
|
||||
# Why: signing changes installer bytes, so the updater metadata must be
|
||||
# regenerated exactly like the release job's staging step does.
|
||||
- name: Stage signed Windows installer and regenerate updater metadata
|
||||
shell: pwsh
|
||||
run: |
|
||||
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
|
||||
if ($null -eq $signedInstaller) {
|
||||
throw 'Signed Windows installer was not returned by SignPath.'
|
||||
}
|
||||
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
|
||||
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
$installer = Get-Item 'dist/orca-windows-setup.exe'
|
||||
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
|
||||
$stream = [System.IO.File]::OpenRead($installer.FullName)
|
||||
try {
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hash = [Convert]::ToBase64String($sha512.ComputeHash($stream))
|
||||
} finally {
|
||||
if ($null -ne $sha512) { $sha512.Dispose() }
|
||||
$stream.Dispose()
|
||||
}
|
||||
$latestYml = Get-Content -Path 'dist/latest.yml' -Raw
|
||||
$latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', {
|
||||
param($match)
|
||||
"$($match.Groups[1].Value)sha512: $hash"
|
||||
})
|
||||
$latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)"
|
||||
$latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)"
|
||||
Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline
|
||||
|
||||
# Why: this is the pass/fail heart of the rehearsal — the same evidence
|
||||
# check that will later gate releases (see PR #7170). Test certificates
|
||||
# do not chain to a trusted root, so Status=Valid is only required for
|
||||
# release-signing runs; test-signing runs require a present signature.
|
||||
- name: Verify signatures end to end
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
run: |
|
||||
$report = New-Object System.Collections.Generic.List[string]
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
$requireValid = $env:SIGNING_POLICY -eq 'release-signing'
|
||||
|
||||
function Test-Signature([string]$label, [string]$path) {
|
||||
$signature = Get-AuthenticodeSignature -FilePath $path
|
||||
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
||||
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $label, $subject
|
||||
$script:report.Add($line)
|
||||
Write-Host $line
|
||||
if ($null -eq $signature.SignerCertificate -or $signature.Status -eq 'NotSigned') {
|
||||
$script:failures.Add("unsigned: $label")
|
||||
} elseif ($script:requireValid -and $signature.Status -ne 'Valid') {
|
||||
$script:failures.Add("not Valid under release-signing: $label ($($signature.Status))")
|
||||
} elseif ($script:requireValid -and $subject -notlike '*CN=SignPath Foundation*') {
|
||||
$script:failures.Add("unexpected signer: $label ($subject)")
|
||||
}
|
||||
}
|
||||
|
||||
Test-Signature 'installer: orca-windows-setup.exe' 'dist/orca-windows-setup.exe'
|
||||
|
||||
# Extract the signed installer and verify the files a user actually
|
||||
# gets on disk — including the exact file from issue #7785.
|
||||
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
|
||||
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
|
||||
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
|
||||
|
||||
$root = Resolve-Path 'extracted-app'
|
||||
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
||||
$path = Join-Path $root $relative
|
||||
if (-not (Test-Path $path)) {
|
||||
$failures.Add("missing from installer payload: $relative")
|
||||
continue
|
||||
}
|
||||
Test-Signature "installed: $relative" $path
|
||||
}
|
||||
|
||||
Set-Content -Path 'signing-evidence.txt' -Value ($report -join "`n")
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Signing rehearsal failed with $($failures.Count) problems."
|
||||
}
|
||||
Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries plus the installer are signed."
|
||||
|
||||
- name: Upload rehearsal evidence and installer
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: windows-signing-rehearsal-${{ inputs.signing-policy-slug || 'test-signing' }}-${{ github.run_id }}
|
||||
path: |
|
||||
signing-evidence.txt
|
||||
inner-signing-list.txt
|
||||
dist/orca-windows-setup.exe
|
||||
dist/orca-windows-setup.exe.blockmap
|
||||
dist/latest.yml
|
||||
retention-days: 7
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Build artifacts
|
||||
tsconfig.*.tsbuildinfo
|
||||
|
||||
# TypeScript emit artifacts next to sources (tsc produced these accidentally;
|
||||
# real source lives in .ts/.tsx). Hand-authored declaration files are
|
||||
# re-included below.
|
||||
src/**/*.js
|
||||
src/**/*.d.ts
|
||||
/electron.vite.config.js
|
||||
/electron.vite.config.d.ts
|
||||
!src/main/types/hosted-git-info.d.ts
|
||||
!src/preload/api-types.d.ts
|
||||
!src/preload/index.d.ts
|
||||
!src/renderer/src/env.d.ts
|
||||
!src/renderer/src/mermaid.d.ts
|
||||
!src/types/build-constants.d.ts
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dist-electron/
|
||||
out/
|
||||
/build/
|
||||
release/
|
||||
native/**/.build/
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
package-lock.json
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.serena/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
*.stackdump
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Lint/cache
|
||||
.oxlintcache
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
*.log.*
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Prod release scan output (accidental adds)
|
||||
prod-release-scan-*.md
|
||||
|
||||
# Benchmark run output
|
||||
/tools/benchmarks/results/*.json
|
||||
/.bench-fixtures/
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
.tmp/
|
||||
design-docs/
|
||||
.context/
|
||||
.atl/
|
||||
|
||||
# Machine-local agent hook endpoint files may contain auth tokens.
|
||||
/agent-hooks/
|
||||
|
||||
# Local-only design/planning docs (not checked in).
|
||||
# Durable docs must live in one of the allow-listed locations below.
|
||||
docs/**
|
||||
!docs/
|
||||
!docs/assets/
|
||||
!docs/assets/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/reference/
|
||||
!docs/reference/**
|
||||
# Keep generated React perf scan ledgers local; durable docs still use docs/reference.
|
||||
docs/reference/react-performance-audit.md
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
!.stably/docs/
|
||||
.playwright-cli
|
||||
.validate-ui-screenshots/
|
||||
validation-screenshots/
|
||||
.stably-browser
|
||||
|
||||
# PR verification evidence screenshots are referenced from notes but should not
|
||||
# be committed.
|
||||
notes/artifacts/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Agent skill installations (machine-local, populated by agent tooling)
|
||||
/.claude/skills/
|
||||
/.agents/skills/
|
||||
/skills-lock.json
|
||||
|
||||
# Agent hook runtime endpoints (machine-local secrets)
|
||||
/agent-hooks/
|
||||
validation-screenshots/
|
||||
|
||||
# Localization bootstrap cache (regenerated by bootstrap:*-catalog)
|
||||
src/renderer/src/i18n/locales/.zh-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.ko-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.ja-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.es-catalog-cache.json
|
||||
|
||||
# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md
|
||||
tools/benchmarks/results/terminal-pipeline-*.json
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env sh
|
||||
pnpm exec lint-staged
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schemas/oxfmt/v0.41.0/config.json",
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "react", "react-hooks", "react-perf", "unicorn"],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"rules": {
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/no-children-prop": "error",
|
||||
"react/no-danger-with-children": "error",
|
||||
"react/no-direct-mutation-state": "error",
|
||||
"react/no-find-dom-node": "error",
|
||||
"react/no-render-return-value": "error",
|
||||
"react/no-string-refs": "error",
|
||||
"react/no-unescaped-entities": "error",
|
||||
"react/require-render-return": "error",
|
||||
"react/jsx-curly-brace-presence": [
|
||||
"error",
|
||||
{ "props": "never", "children": "never", "propElementValues": "always" }
|
||||
],
|
||||
"react/jsx-filename-extension": ["error", { "extensions": [".tsx", ".jsx"] }],
|
||||
"react/jsx-fragments": "error",
|
||||
"react/jsx-key": "error",
|
||||
"react/jsx-no-constructed-context-values": "error",
|
||||
"react/jsx-no-target-blank": "error",
|
||||
"react/jsx-no-useless-fragment": ["error", { "allowExpressions": true }],
|
||||
"react/jsx-pascal-case": "error",
|
||||
"react/no-object-type-as-default-prop": "error",
|
||||
"react/self-closing-comp": "error",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"typescript/array-type": "error",
|
||||
"typescript/consistent-indexed-object-style": "error",
|
||||
"typescript/consistent-type-assertions": "error",
|
||||
"typescript/consistent-type-definitions": ["error", "type"],
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"typescript/no-explicit-any": ["error", { "ignoreRestArgs": true }],
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-unnecessary-boolean-literal-compare": "error",
|
||||
"typescript/no-unnecessary-template-expression": "error",
|
||||
"typescript/no-unsafe-function-type": "warn",
|
||||
"typescript/prefer-function-type": "error",
|
||||
"typescript/prefer-includes": "error",
|
||||
"typescript/prefer-optional-chain": "error",
|
||||
"typescript/switch-exhaustiveness-check": [
|
||||
"error",
|
||||
{ "allowDefaultCaseForExhaustiveSwitch": false }
|
||||
],
|
||||
"curly": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-useless-return": "error",
|
||||
"prefer-template": "error",
|
||||
"unicorn/consistent-empty-array-spread": "error",
|
||||
"unicorn/error-message": "error",
|
||||
"unicorn/no-array-fill-with-reference-type": "warn",
|
||||
"unicorn/no-array-reverse": "error",
|
||||
"unicorn/no-instanceof-builtins": "error",
|
||||
"unicorn/no-useless-promise-resolve-reject": "error",
|
||||
"unicorn/prefer-array-find": "error",
|
||||
"unicorn/prefer-array-flat-map": "warn",
|
||||
"unicorn/prefer-array-index-of": "error",
|
||||
"unicorn/prefer-at": "error",
|
||||
"unicorn/prefer-date-now": "error",
|
||||
"unicorn/prefer-includes": "error",
|
||||
"unicorn/prefer-import-meta-properties": "error",
|
||||
"unicorn/prefer-math-min-max": "error",
|
||||
"unicorn/prefer-negative-index": "error",
|
||||
"unicorn/prefer-node-protocol": "error",
|
||||
"unicorn/prefer-number-properties": "error",
|
||||
"unicorn/prefer-object-from-entries": "error",
|
||||
"unicorn/prefer-regexp-test": "warn",
|
||||
"unicorn/prefer-ternary": "error",
|
||||
"unicorn/throw-new-error": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 300, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 400, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.mjs"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 600, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 800, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Design System
|
||||
|
||||
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
|
||||
|
||||
## Code Comments: Document the "Why", Briefly
|
||||
|
||||
When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does.
|
||||
|
||||
Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point.
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`.
|
||||
|
||||
## File and Module Naming
|
||||
|
||||
Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuff` for files, folders, or modules. They carry zero info and tend to become dumping grounds. Name files after what they _actually_ contain — prefer the concrete domain concept (e.g. `tab-group-state.ts`, `terminal-orphan-cleanup.ts`) over the generic role (`tabs-helpers.ts`, `terminal-utils.ts`). If you find yourself reaching for `helpers`, the file probably has more than one responsibility and should be split, or there's a better name hiding in the code that describes what the functions operate on.
|
||||
|
||||
## Worktree Safety
|
||||
|
||||
Always use the primary working directory (the worktree) for all file reads and edits. Never follow absolute paths from subagent results that point to the main repo.
|
||||
|
||||
## Cross-Platform Support
|
||||
|
||||
Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior behind runtime checks:
|
||||
|
||||
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
|
||||
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
|
||||
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
|
||||
|
||||
## SSH Use Case
|
||||
|
||||
All changes must consider the SSH use case. Don't assume local-only execution.
|
||||
|
||||
## Git Binary Compatibility
|
||||
|
||||
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
|
||||
|
||||
When adding or changing a Git command:
|
||||
|
||||
- Check when every subcommand and option was introduced. For newer behavior, keep a baseline-compatible fallback or degrade safely.
|
||||
- Use `GitCapabilityCache` with a narrow unsupported-error predicate so recurring operations do not retry a known-invalid command. Do not rely only on `git --version`; wrappers such as `simple-git` do not remove host-version differences.
|
||||
- Scope capability state to the host that executes Git: native, WSL distro, SSH provider, or relay connection. Cover the first fallback, later cached calls, concurrent probes, and relevant host isolation in tests.
|
||||
- Keep the real-binary compatibility contract in PR CI current. When adopting a newer Git feature, add its version boundary so the preferred command and fallback both run against representative Git releases.
|
||||
- Preserve commands that begin with global Git options such as `-c` before the subcommand, including auto-maintenance suppression used by worktree-create fetches.
|
||||
|
||||
## Git Provider Compatibility
|
||||
|
||||
Source-control and review changes must consider GitLab and other supported git providers, not only GitHub. Keep provider-specific behavior behind explicit checks, and avoid GitHub-only naming for generic review concepts.
|
||||
|
||||
## GitHub CLI Usage
|
||||
|
||||
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
|
||||
|
||||
## Type Declarations: Prefer `.ts` Over `.d.ts`
|
||||
@@ -0,0 +1,49 @@
|
||||
cask "orca" do
|
||||
arch arm: "arm64", intel: "x64"
|
||||
|
||||
version "1.3.24"
|
||||
sha256 arm: "fc707f290ff3b631b7b7947bf339885b61a43d2e89475997c125b61268ed4966",
|
||||
intel: "5f677c13a08f7a5740442e29d388285a86488c8c1f7aa5f10a8721a2c6ede8e4"
|
||||
|
||||
url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg",
|
||||
verified: "github.com/stablyai/orca/"
|
||||
name "Orca"
|
||||
desc "IDE for orchestrating AI coding agents across terminals and worktrees"
|
||||
homepage "https://onorca.dev/"
|
||||
|
||||
livecheck do
|
||||
url :url
|
||||
strategy :github_latest
|
||||
end
|
||||
|
||||
# Why: electron-updater (src/main/updater.ts) handles in-place updates by
|
||||
# writing a new Orca.app into /Applications. Marking the cask auto_updates
|
||||
# tells Homebrew not to compete with the in-app updater — `brew upgrade`
|
||||
# becomes a no-op unless the user passes --greedy, and brew's version
|
||||
# metadata stays aligned with whatever the app has swapped itself to.
|
||||
auto_updates true
|
||||
conflicts_with cask: "orca@rc"
|
||||
depends_on macos: :big_sur
|
||||
|
||||
app "Orca.app"
|
||||
|
||||
# Why: expose the bundled `orca` CLI on PATH at install time (Homebrew symlinks
|
||||
# this into its already-on-PATH bin dir). Without it, the CLI is only registered
|
||||
# by the in-app "Install CLI" action, which a headless host can never trigger —
|
||||
# so `orca serve` on a server would be unreachable from the shell. The shim
|
||||
# resolves the real app by walking symlinks, so the Homebrew symlink works.
|
||||
binary "#{appdir}/Orca.app/Contents/Resources/bin/orca"
|
||||
|
||||
# Why: Orca writes user data under ~/.orca (worktrees, agent state) and
|
||||
# Electron's standard userData directories. Zap removes everything the app
|
||||
# creates during normal use so `brew uninstall --zap` is a clean slate.
|
||||
zap trash: [
|
||||
"~/.orca",
|
||||
"~/Library/Application Support/Orca",
|
||||
"~/Library/Caches/com.stablyai.orca",
|
||||
"~/Library/Caches/com.stablyai.orca.ShipIt",
|
||||
"~/Library/HTTPStorages/com.stablyai.orca",
|
||||
"~/Library/Preferences/com.stablyai.orca.plist",
|
||||
"~/Library/Saved Application State/com.stablyai.orca.savedState",
|
||||
]
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
cask "orca@rc" do
|
||||
arch arm: "arm64", intel: "x64"
|
||||
|
||||
version "1.4.36-rc.3"
|
||||
sha256 arm: "563b6b14323fc9d5489299c82442d514bc12cabffc9d06d3964ed572af4b3955",
|
||||
intel: "457088c7021f07de1a419197f7b2bd00092741ad4727d4fef3d86af38a6831e7"
|
||||
|
||||
url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg",
|
||||
verified: "github.com/stablyai/orca/"
|
||||
name "Orca RC"
|
||||
desc "IDE for orchestrating AI coding agents across terminals and worktrees"
|
||||
homepage "https://onorca.dev/"
|
||||
|
||||
livecheck do
|
||||
url "https://github.com/stablyai/orca"
|
||||
regex(/^v?(\d+(?:\.\d+)+-rc\.\d+)$/i)
|
||||
strategy :github_releases do |json, regex|
|
||||
json.map do |release|
|
||||
next if release["draft"]
|
||||
next unless release["prerelease"]
|
||||
|
||||
match = release["tag_name"]&.match(regex)
|
||||
next if match.blank?
|
||||
|
||||
match[1]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Why: RC installs should follow Orca's prerelease-aware updater instead of
|
||||
# waiting for Homebrew metadata churn between frequent release candidates.
|
||||
auto_updates true
|
||||
conflicts_with cask: "orca"
|
||||
depends_on macos: :big_sur
|
||||
|
||||
app "Orca.app"
|
||||
|
||||
# Why: expose the bundled `orca` CLI on PATH at install time (Homebrew symlinks
|
||||
# this into its already-on-PATH bin dir). Without it, the CLI is only registered
|
||||
# by the in-app "Install CLI" action, which a headless host can never trigger —
|
||||
# so `orca serve` on a server would be unreachable from the shell. The shim
|
||||
# resolves the real app by walking symlinks, so the Homebrew symlink works.
|
||||
binary "#{appdir}/Orca.app/Contents/Resources/bin/orca"
|
||||
|
||||
# Why: Orca writes user data under ~/.orca (worktrees, agent state) and
|
||||
# Electron's standard userData directories. Zap removes everything the app
|
||||
# creates during normal use so `brew uninstall --zap` is a clean slate.
|
||||
zap trash: [
|
||||
"~/.orca",
|
||||
"~/Library/Application Support/Orca",
|
||||
"~/Library/Caches/com.stablyai.orca",
|
||||
"~/Library/Caches/com.stablyai.orca.ShipIt",
|
||||
"~/Library/HTTPStorages/com.stablyai.orca",
|
||||
"~/Library/Preferences/com.stablyai.orca.plist",
|
||||
"~/Library/Saved Application State/com.stablyai.orca.savedState",
|
||||
]
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Lovecast Inc.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,266 @@
|
||||
<h1 align="center">
|
||||
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub stars" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" alt="Total downloads across all releases" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Follow Orca on X" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>The AI Orchestrator for 100x builders.</strong><br/>
|
||||
Run Codex, ClaudeCode, OpenCode or Pi side-by-side — each in its own worktree, tracked in one place.
|
||||
</p>
|
||||
|
||||
<h3 align="center"><a href="https://onorca.dev/download"><ins>Download Orca</ins></a></h3>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/readme-hero.jpg" alt="Orca desktop app running agents in parallel worktrees, with the Orca mobile companion app in the corner" width="960" />
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Mobile Companion
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="docs/assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="docs/assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop with the mobile companion app" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Parallel Worktrees
|
||||
|
||||
Fan one prompt across five agents, each in its own isolated git worktree — compare the results and merge the winner.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/model/worktrees)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="docs/assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/parallel-worktrees.jpg" alt="Parallel worktree orchestration" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Terminal Splits
|
||||
|
||||
Ghostty-class terminals with WebGL rendering, infinite splits, and scrollback that survives restarts.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/terminal)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="docs/assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="docs/assets/feature-wall/terminal-splits.jpg" alt="Terminal splits" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Design Mode
|
||||
|
||||
Click any UI element in a real Chromium window to send its HTML, CSS, and a cropped screenshot straight into your agent's prompt.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/browser/design-mode)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="docs/assets/feature-wall/design-mode.gif" type="image/gif"><img src="docs/assets/feature-wall/design-mode.jpg" alt="Embedded browser and Design Mode" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### GitHub & Linear, Native
|
||||
|
||||
Browse PRs, issues, and project boards in-app — open a worktree from any task and review without a context switch.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/review/linear)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="docs/assets/feature-wall/github-linear.gif" type="image/gif"><img src="docs/assets/feature-wall/github-linear.jpg" alt="GitHub and Linear task workflows in Orca" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### SSH Worktrees
|
||||
|
||||
Run agents on a beefy remote box with full file editing, git, and terminals — auto-reconnect and port forwarding included.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/ssh)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="docs/assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/ssh-worktrees.jpg" alt="Remote worktrees over SSH" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Annotate AI Diffs
|
||||
|
||||
Drop comments on any diff line and ship them back to the agent — review, edit, and commit without leaving Orca.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="docs/assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="docs/assets/feature-wall/annotate-diff.jpg" alt="Annotate AI-generated diffs" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Drag Files to Agents
|
||||
|
||||
VS Code's editor with autosave everywhere — drag files or images straight into an agent prompt.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="docs/assets/feature-wall/file-drag.gif" type="image/gif"><img src="docs/assets/feature-wall/file-drag.jpg" alt="Drag files and images into an agent prompt" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Orca CLI
|
||||
|
||||
Agents drive Orca too — script every workflow with `orca worktree create`, `snapshot`, `click`, and `fill`.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/cli/overview)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="docs/assets/feature-wall/orca-cli.gif" type="image/gif"><img src="docs/assets/feature-wall/orca-cli.jpg" alt="Script Orca from the CLI" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**Also in the box:**
|
||||
|
||||
- **[Quick open](https://www.onorca.dev/docs/model/quick-open)** — Search across worktrees, files, agents, commands, and repo context without leaving your flow.
|
||||
- **[Account switcher & usage tracking](https://www.onorca.dev/docs/agents/usage-tracking)** — See Claude and Codex usage and rate-limit resets, and hot-swap accounts without re-logging in.
|
||||
- **[Rich repo previews](https://www.onorca.dev/docs/editing/markdown)** — Preview Markdown, images, PDFs, and repo docs in the workspace.
|
||||
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Let agents operate desktop apps and visible UI when a workflow needs real interaction.
|
||||
- **[Notifications and unread state](https://www.onorca.dev/docs/notifications)** — Know when an agent finishes or needs attention, then mark threads unread to come back later.
|
||||
- **And many, many more** — we ship daily, so this list is perpetually behind. The [changelog](https://github.com/stablyai/orca/releases) is the real feature list.
|
||||
|
||||
---
|
||||
|
||||
## Supported Agents
|
||||
|
||||
Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Devin logo" width="16" valign="middle" /> Devin</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<kbd>+ any CLI agent</kbd>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
### Desktop — macOS, Windows, Linux
|
||||
|
||||
- **[Download from onOrca.dev](https://onorca.dev/download)**
|
||||
- Or grab a build directly: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest)
|
||||
- Running `orca serve` on a headless Linux server? See the [headless Linux server guide](docs/reference/headless-linux-server.md).
|
||||
|
||||
_Or via a package manager:_
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask stablyai/orca/orca
|
||||
|
||||
# Arch Linux (AUR) — or stably-orca-git to build from source
|
||||
yay -S stably-orca-bin
|
||||
```
|
||||
|
||||
### Mobile Companion — iOS, Android
|
||||
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
## Community & Support
|
||||
|
||||
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
|
||||
- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group.
|
||||
|
||||
<img src="docs/assets/wechat-qr.png" alt="WeChat QR code for the Orca community" width="160" />
|
||||
<img src="docs/assets/wechat-qr-backup.jpg" alt="Backup WeChat QR code for the Orca community" width="160" />
|
||||
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
- **Show Support:** [Star](https://github.com/stablyai/orca) this repo to follow along with our daily ships.
|
||||
|
||||
---
|
||||
|
||||
## Developing
|
||||
|
||||
Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTING.md) guide.
|
||||
|
||||
<a href="https://github.com/stablyai/orca/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca contributors" />
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
Orca is free and open source under the [MIT License](LICENSE).
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`stablyai/orca`
|
||||
- 原始仓库:https://github.com/stablyai/orca
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,130 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup'
|
||||
|
||||
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
|
||||
// chunk gained `require("electron")` (an import edge added in #7642), and the
|
||||
// daemon is forked as a plain-Node process where electron cannot be required.
|
||||
// Nothing in CI executes the built daemon-entry under plain Node, so the leak
|
||||
// stayed invisible until an adopted old daemon died. This guard fails the
|
||||
// build when any chunk reachable from a plain-Node fork entry requires
|
||||
// electron, and smoke-loads daemon-entry under plain Node to prove its module
|
||||
// graph still resolves.
|
||||
|
||||
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
|
||||
// forked daemon, parcel-watcher and computer sidecars, and the CLI-run
|
||||
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
'computer-sidecar',
|
||||
'agent-hooks/managed-agent-hook-controls'
|
||||
] as const
|
||||
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
|
||||
|
||||
function collectReachableChunks(
|
||||
entry: OutputChunk,
|
||||
byFileName: Map<string, OutputChunk>
|
||||
): OutputChunk[] {
|
||||
const seen = new Set<string>()
|
||||
const reachable: OutputChunk[] = []
|
||||
const stack = [entry.fileName]
|
||||
while (stack.length > 0) {
|
||||
const fileName = stack.pop() as string
|
||||
if (seen.has(fileName)) {
|
||||
continue
|
||||
}
|
||||
seen.add(fileName)
|
||||
const chunk = byFileName.get(fileName)
|
||||
if (!chunk) {
|
||||
continue
|
||||
}
|
||||
reachable.push(chunk)
|
||||
for (const imported of [...chunk.imports, ...chunk.dynamicImports]) {
|
||||
stack.push(imported)
|
||||
}
|
||||
}
|
||||
return reachable
|
||||
}
|
||||
|
||||
function assertNoElectronRequire(
|
||||
entryName: string,
|
||||
entry: OutputChunk,
|
||||
byFileName: Map<string, OutputChunk>
|
||||
): void {
|
||||
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
||||
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
|
||||
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
|
||||
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
|
||||
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: proves the whole daemon-entry graph resolves under plain Node (no
|
||||
// unresolved requires). require("electron") does not throw in a dev tree with
|
||||
// node_modules present, so the static scan above — not this smoke — is the
|
||||
// electron regression guard; this only catches gross load failures.
|
||||
function smokeLoadDaemonEntry(outputDir: string): void {
|
||||
const entryPath = join(outputDir, 'daemon-entry.js')
|
||||
const result = spawnSync(process.execPath, [entryPath], {
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
|
||||
`${result.error.message}`
|
||||
)
|
||||
}
|
||||
const stderr = result.stderr ?? ''
|
||||
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
|
||||
)
|
||||
}
|
||||
if (!stderr.includes('Usage: daemon-entry')) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
|
||||
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
return {
|
||||
name: 'orca-plain-node-entry-guard',
|
||||
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
|
||||
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
|
||||
// every rebuild, and the guard only needs to gate produced builds.
|
||||
if (this.meta.watchMode) {
|
||||
return
|
||||
}
|
||||
const chunks = Object.values(bundle).filter(
|
||||
(item): item is OutputChunk => item.type === 'chunk'
|
||||
)
|
||||
const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]))
|
||||
const entryByName = new Map<string, OutputChunk>()
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.isEntry && chunk.name) {
|
||||
entryByName.set(chunk.name, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName)
|
||||
}
|
||||
}
|
||||
|
||||
if (entryByName.has('daemon-entry') && options.dir) {
|
||||
smokeLoadDaemonEntry(options.dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york-v4",
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/renderer/src/assets/main.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"aliases": {
|
||||
"utils": "@/lib/utils",
|
||||
"components": "@/components",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
provider: github
|
||||
owner: stablyai
|
||||
repo: orca
|
||||
updaterCacheDirName: orca-updater
|
||||
@@ -0,0 +1,511 @@
|
||||
const { chmodSync, existsSync, readdirSync } = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { join, resolve } = require('node:path')
|
||||
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs')
|
||||
const {
|
||||
assertPackagedDaemonEntryExists,
|
||||
verifyPackagedDaemonEntryBoots
|
||||
} = require('./scripts/verify-packaged-daemon-entry.cjs')
|
||||
const {
|
||||
createPackagedRuntimeNodeModuleResources,
|
||||
prunePackagedRuntimeNodeModules,
|
||||
verifyPackagedMainRuntimeDeps
|
||||
} = require('./packaged-runtime-node-modules.cjs')
|
||||
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
|
||||
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
|
||||
const featureWallResources = {
|
||||
from: 'resources/onboarding/feature-wall',
|
||||
to: 'onboarding/feature-wall'
|
||||
}
|
||||
// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
|
||||
// apps. Keeping relay assets as extraResources makes them real directories
|
||||
// instead of paths hidden inside app.asar.
|
||||
const relayExtraResource = {
|
||||
from: 'out/relay',
|
||||
to: 'relay'
|
||||
}
|
||||
// Why: the main bundle, packaged CLI, SSH paths, and speech worker all execute
|
||||
// from package directories where pnpm's symlink farm is absent. Copy the exact
|
||||
// runtime dependency closure to Resources/node_modules so bare require() calls
|
||||
// do not fall through to a developer checkout's node_modules.
|
||||
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
|
||||
|
||||
const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
|
||||
const macSpeechNativeResource = {
|
||||
from: 'node_modules/sherpa-onnx-darwin-${arch}',
|
||||
to: 'node_modules/sherpa-onnx-darwin-${arch}'
|
||||
}
|
||||
const linuxSpeechNativeResource = {
|
||||
from: 'node_modules/sherpa-onnx-linux-${arch}',
|
||||
to: 'node_modules/sherpa-onnx-linux-${arch}'
|
||||
}
|
||||
const winSpeechNativeResource = {
|
||||
from: 'node_modules/sherpa-onnx-win-x64',
|
||||
to: 'node_modules/sherpa-onnx-win-x64'
|
||||
}
|
||||
|
||||
/** @type {import('electron-builder').Configuration} */
|
||||
module.exports = {
|
||||
appId: 'com.stablyai.orca',
|
||||
productName: 'Orca',
|
||||
directories: {
|
||||
buildResources: 'resources/build'
|
||||
},
|
||||
files: [
|
||||
'!**/.vscode/*',
|
||||
// Why: these repo-only inputs are either bundled into out/ or copied via
|
||||
// extraResources. Shipping them in app.asar bloats the desktop bundle.
|
||||
'!src{,/**/*}',
|
||||
'!config{,/**/*}',
|
||||
'!docs{,/**/*}',
|
||||
'!mobile{,/**/*}',
|
||||
'!native{,/**/*}',
|
||||
'!skills{,/**/*}',
|
||||
'!tests{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
'!out/**/*.test.js',
|
||||
'!electron.vite.config.{js,ts,mjs,cjs}',
|
||||
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
|
||||
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
|
||||
'!tsconfig.json',
|
||||
// Why: feature-wall media is copied via extraResources so runtime can read
|
||||
// it from process.resourcesPath; exclude the source copy from app.asar.
|
||||
'!resources/onboarding/feature-wall/**'
|
||||
],
|
||||
// Why: the CLI entry-point lives in out/cli/ but imports shared modules
|
||||
// from out/shared/ and local hook mutators from out/main/. These paths must be
|
||||
// unpacked so that Node's require() can resolve the cross-directory imports
|
||||
// when the CLI runs outside the asar archive.
|
||||
// Why: daemon-entry.js is forked as a separate Node.js process and must be
|
||||
// accessible on disk (not inside the asar archive) for child_process.fork().
|
||||
// Why: the CLI is compiled by tsc (not bundled), so its runtime imports
|
||||
// resolve at runtime via Node's normal module lookup. The shim launches
|
||||
// the CLI with ELECTRON_RUN_AS_NODE, which bypasses Electron's asar
|
||||
// integration — dependencies inside the asar archive are invisible to
|
||||
// require(). Unpack CLI runtime deps so they resolve from
|
||||
// app.asar.unpacked/node_modules/.
|
||||
// Why: remote runtime connections use WebSocket + E2EE from the packaged CLI
|
||||
// before the GUI process starts, so those deps need the same treatment.
|
||||
// Why: out/package.json pins compiled output to CommonJS so parent
|
||||
// package.json files with type=module cannot change the packaged CLI loader.
|
||||
// Why: sherpa-onnx native bindings (platform-specific subpackages) must be
|
||||
// unpacked because they ship .node addons + .dylib/.so files that cannot be
|
||||
// dlopen()'d from inside the asar archive.
|
||||
asarUnpack: [
|
||||
'out/package.json',
|
||||
'out/cli/**',
|
||||
'out/shared/**',
|
||||
'out/main/agent-hooks/**',
|
||||
'out/main/antigravity/**',
|
||||
'out/main/claude/**',
|
||||
'out/main/codex/**',
|
||||
'out/main/copilot/**',
|
||||
'out/main/cursor/**',
|
||||
'out/main/droid/**',
|
||||
'out/main/gemini/**',
|
||||
'out/main/grok/**',
|
||||
'out/main/hermes/**',
|
||||
'out/main/win32-utils.js',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/ws/**',
|
||||
'node_modules/tweetnacl/**',
|
||||
'node_modules/zod/**',
|
||||
'node_modules/yaml/**',
|
||||
'node_modules/sherpa-onnx*/**'
|
||||
],
|
||||
afterPack: async (context) => {
|
||||
const resourcesDir =
|
||||
context.electronPlatformName === 'darwin'
|
||||
? join(
|
||||
context.appOutDir,
|
||||
`${context.packager.appInfo.productFilename}.app`,
|
||||
'Contents',
|
||||
'Resources'
|
||||
)
|
||||
: join(context.appOutDir, 'resources')
|
||||
if (!existsSync(resourcesDir)) {
|
||||
return
|
||||
}
|
||||
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
|
||||
verifyPackagedMainRuntimeDeps(resourcesDir)
|
||||
// Why: boot the packaged daemon-entry under plain Node, but only for the
|
||||
// slice matching the packaging host's arch — daemon-entry.js is JS, yet it
|
||||
// require()s the native (N-API) node-pty for the TARGET arch, which the host
|
||||
// Node cannot load cross-arch. `Arch` enum: ia32=0, x64=1, armv7l=2,
|
||||
// arm64=3, universal=4 (universal contains the host slice, so run it).
|
||||
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
|
||||
const hostArchEnum = archEnumByNodeArch[process.arch]
|
||||
if (context.arch === hostArchEnum || context.arch === 4) {
|
||||
verifyPackagedDaemonEntryBoots(resourcesDir)
|
||||
} else {
|
||||
// Why: a cross-arch slice can't be booted by the host Node, but the
|
||||
// unpacked entry must still exist — its absence is a layout regression
|
||||
// regardless of arch, so only the boot is skipped, not the check.
|
||||
assertPackagedDaemonEntryExists(resourcesDir)
|
||||
console.log(
|
||||
`[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})`
|
||||
)
|
||||
}
|
||||
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
|
||||
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
|
||||
for (const filename of readdirSync(resourcesDir)) {
|
||||
if (!filename.startsWith('agent-browser-')) {
|
||||
continue
|
||||
}
|
||||
// Why: the upstream package has inconsistent executable bits across
|
||||
// platform binaries (notably darwin-x64). child_process.execFile needs
|
||||
// the copied binary to be executable in packaged apps.
|
||||
chmodSync(join(resourcesDir, filename), 0o755)
|
||||
}
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
|
||||
await signMacNotificationStatusHelper(
|
||||
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
|
||||
context.packager
|
||||
)
|
||||
}
|
||||
},
|
||||
win: {
|
||||
executableName: 'Orca',
|
||||
// Why: Windows installers are signed after electron-builder packaging by
|
||||
// SignPath, so the packager cannot infer the updater publisherName.
|
||||
signtoolOptions: {
|
||||
publisherName: 'SignPath Foundation'
|
||||
},
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
winSpeechNativeResource,
|
||||
{
|
||||
from: 'resources/win32/bin/orca.cmd',
|
||||
to: 'bin/orca.cmd'
|
||||
},
|
||||
{
|
||||
from: 'native/windows-cli-launcher/.build/orca.exe',
|
||||
to: 'bin/orca.exe'
|
||||
},
|
||||
{
|
||||
from: 'node_modules/agent-browser/bin/agent-browser-win32-x64.exe',
|
||||
to: 'agent-browser-win32-x64.exe'
|
||||
},
|
||||
{
|
||||
from: 'native/computer-use-windows/runtime.ps1',
|
||||
to: 'computer-use-windows/runtime.ps1'
|
||||
},
|
||||
featureWallResources
|
||||
]
|
||||
},
|
||||
nsis: {
|
||||
artifactName: 'orca-windows-setup.${ext}',
|
||||
shortcutName: '${productName}',
|
||||
uninstallDisplayName: '${productName}',
|
||||
createDesktopShortcut: 'always',
|
||||
// Why: on a real uninstall, stop and remove the relocated terminal daemon
|
||||
// (which lives outside the install dir under LOCALAPPDATA by design). Guarded
|
||||
// by ${isUpdated} inside so it never runs during an update's uninstallOldVersion.
|
||||
include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh')
|
||||
},
|
||||
mac: {
|
||||
icon: 'resources/build/icon.icns',
|
||||
entitlements: 'resources/build/entitlements.mac.plist',
|
||||
entitlementsInherit: 'resources/build/entitlements.mac.plist',
|
||||
extendInfo: {
|
||||
NSAppleEventsUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to automate local apps when you request it.',
|
||||
NSBluetoothAlwaysUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.',
|
||||
NSBluetoothPeripheralUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to access Bluetooth devices when you request it.',
|
||||
NSCameraUsageDescription: "Application requests access to the device's camera.",
|
||||
NSLocationUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to access location when you request it.',
|
||||
NSLocalNetworkUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to discover and connect to local development servers when you request it.',
|
||||
NSMicrophoneUsageDescription: "Application requests access to the device's microphone.",
|
||||
NSAudioCaptureUsageDescription:
|
||||
'Orca allows terminal-launched developer tools to capture desktop audio when you request it.',
|
||||
NSBonjourServices: ['_http._tcp', '_https._tcp'],
|
||||
NSDocumentsFolderUsageDescription:
|
||||
"Application requests access to the user's Documents folder.",
|
||||
NSDownloadsFolderUsageDescription:
|
||||
"Application requests access to the user's Downloads folder."
|
||||
},
|
||||
// Why: local macOS validation builds should launch without Apple release
|
||||
// credentials. Hardened runtime + notarization stay enabled only on the
|
||||
// explicit release path so production artifacts remain strict while dev
|
||||
// artifacts do not fail with broken ad-hoc launch behavior.
|
||||
hardenedRuntime: isMacRelease,
|
||||
notarize: isMacRelease,
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
macSpeechNativeResource,
|
||||
{
|
||||
from: 'resources/darwin/bin/orca',
|
||||
to: 'bin/orca'
|
||||
},
|
||||
{
|
||||
from: 'node_modules/agent-browser/bin/agent-browser-darwin-${arch}',
|
||||
to: 'agent-browser-darwin-${arch}'
|
||||
},
|
||||
// Why: serve-sim resolves its helper binary and camera assets relative
|
||||
// to dist/serve-sim.js, so the whole package must be a real resource dir.
|
||||
{
|
||||
from: 'node_modules/serve-sim',
|
||||
to: 'serve-sim'
|
||||
},
|
||||
{
|
||||
from: 'native/computer-use-macos/.build/release/Orca Computer Use.app',
|
||||
to: 'Orca Computer Use.app'
|
||||
},
|
||||
featureWallResources
|
||||
],
|
||||
// Why: the notification-status helper must execute from Contents/MacOS —
|
||||
// on macOS 26 UNUserNotificationCenter aborts (bundleProxyForCurrentProcess
|
||||
// is nil) for executables launched out of Contents/Resources (#7929).
|
||||
extraFiles: [
|
||||
{
|
||||
from: 'native/notification-status-macos/.build/release/orca-notification-status',
|
||||
to: 'MacOS/orca-notification-status'
|
||||
}
|
||||
],
|
||||
target: [
|
||||
{
|
||||
target: 'dmg',
|
||||
arch: ['x64', 'arm64']
|
||||
},
|
||||
{
|
||||
target: 'zip',
|
||||
arch: ['x64', 'arm64']
|
||||
}
|
||||
]
|
||||
},
|
||||
// Why: release builds should fail if signing is unavailable instead of
|
||||
// silently downgrading to ad-hoc artifacts that look shippable in CI logs.
|
||||
forceCodeSigning: isMacRelease,
|
||||
dmg: {
|
||||
artifactName: 'orca-macos-${arch}.${ext}'
|
||||
},
|
||||
linux: {
|
||||
// Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca.
|
||||
// The Linux installer should not claim those system package/file names.
|
||||
executableName: 'orca-ide',
|
||||
// Why: the icns source lets electron-builder emit standard hicolor PNG
|
||||
// sizes; a single 1024px PNG is ignored by some Linux docks/launchers.
|
||||
icon: 'resources/build/icon.icns',
|
||||
desktop: {
|
||||
entry: {
|
||||
// Why: Electron reports WM_CLASS=orca for the visible Linux window;
|
||||
// GNOME docks need an exact match to group it with orca-ide.desktop.
|
||||
StartupWMClass: 'orca'
|
||||
}
|
||||
},
|
||||
extraResources: [
|
||||
...commonExtraResources,
|
||||
linuxSpeechNativeResource,
|
||||
{
|
||||
from: 'resources/linux/bin/orca-ide',
|
||||
to: 'bin/orca-ide'
|
||||
},
|
||||
{
|
||||
from: 'node_modules/agent-browser/bin/agent-browser-linux-${arch}',
|
||||
to: 'agent-browser-linux-${arch}'
|
||||
},
|
||||
{
|
||||
from: 'native/computer-use-linux/runtime.py',
|
||||
to: 'computer-use-linux/runtime.py'
|
||||
},
|
||||
featureWallResources
|
||||
],
|
||||
target: ['AppImage', 'deb'],
|
||||
maintainer: 'stablyai',
|
||||
category: 'Utility'
|
||||
},
|
||||
appImage: {
|
||||
artifactName: isLinuxArm64Release ? 'orca-linux-arm64.${ext}' : 'orca-linux.${ext}'
|
||||
},
|
||||
deb: {
|
||||
packageName: 'orca-ide',
|
||||
artifactName: 'orca-ide_${version}_${arch}.${ext}',
|
||||
// Why: xvfb lets the bundled `orca serve` CLI run browser panes on a headless
|
||||
// Linux host — Chromium needs a display server even for offscreen rendering,
|
||||
// and serve starts Xvfb itself when present (see ensure-virtual-display.ts).
|
||||
depends: [
|
||||
'python3',
|
||||
'python3-gi',
|
||||
'gir1.2-atspi-2.0',
|
||||
'at-spi2-core',
|
||||
'xdotool',
|
||||
'xclip',
|
||||
'xvfb'
|
||||
],
|
||||
// Why: symlink the bundled CLI onto PATH at install time so `orca-ide serve`
|
||||
// works on a headless host. The in-app CLI registration (CliInstaller) is
|
||||
// GUI-triggered and can never run on a server, so without this the CLI is
|
||||
// unreachable from the shell on exactly the hosts that need it.
|
||||
afterInstall: 'resources/linux/packaging/after-install.sh',
|
||||
afterRemove: 'resources/linux/packaging/after-remove.sh'
|
||||
},
|
||||
rpm: {
|
||||
packageName: 'orca-ide',
|
||||
artifactName: 'orca-ide-${version}.${arch}.${ext}',
|
||||
// Why: see deb depends. RPM distros ship Xvfb as xorg-x11-server-Xvfb (there
|
||||
// is no `xvfb` package), so the name differs from the deb here.
|
||||
depends: [
|
||||
'python3',
|
||||
'python3-gobject',
|
||||
'at-spi2-core',
|
||||
'xdotool',
|
||||
'xclip',
|
||||
'xorg-x11-server-Xvfb'
|
||||
],
|
||||
// Why: same headless CLI-on-PATH registration as deb; rpm runs these via fpm.
|
||||
afterInstall: 'resources/linux/packaging/after-install.sh',
|
||||
afterRemove: 'resources/linux/packaging/after-remove.sh'
|
||||
},
|
||||
beforeBuild: electronBuilderNativeRebuild,
|
||||
// Why: must be true so that electron-builder rebuilds native modules
|
||||
// (node-pty) for each target architecture when producing dual-arch macOS
|
||||
// builds (x64 + arm64). With npmRebuild disabled, CI on an arm64 runner
|
||||
// packages arm64 binaries into the x64 DMG, causing "posix_spawnp failed"
|
||||
// on Intel Macs. The beforeBuild hook performs Orca's targeted rebuild and
|
||||
// returns false so electron-builder does not rebuild optional cpu-features.
|
||||
npmRebuild: true,
|
||||
publish: {
|
||||
provider: 'github',
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
releaseType: 'release'
|
||||
}
|
||||
}
|
||||
|
||||
function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) {
|
||||
if (electronPlatformName === 'win32') {
|
||||
return
|
||||
}
|
||||
for (const launcherName of ['orca', 'orca-ide']) {
|
||||
const launcherPath = join(resourcesDir, 'bin', launcherName)
|
||||
if (!existsSync(launcherPath)) {
|
||||
continue
|
||||
}
|
||||
// Why: packaged Unix installs expose these extraResources as public shell
|
||||
// commands, and source/packager mode drift must not ship a non-executable CLI.
|
||||
chmodSync(launcherPath, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
function chmodMacServeSimHelpers(resourcesDir, electronPlatformName) {
|
||||
if (electronPlatformName !== 'darwin') {
|
||||
return
|
||||
}
|
||||
const helperPaths = [
|
||||
join(resourcesDir, 'serve-sim', 'bin', 'serve-sim-bin'),
|
||||
join(resourcesDir, 'serve-sim', 'dist', 'simcam', 'serve-sim-camera-helper'),
|
||||
join(resourcesDir, 'node_modules', 'serve-sim', 'bin', 'serve-sim-bin'),
|
||||
join(resourcesDir, 'node_modules', 'serve-sim', 'dist', 'simcam', 'serve-sim-camera-helper')
|
||||
]
|
||||
for (const helperPath of helperPaths) {
|
||||
if (existsSync(helperPath)) {
|
||||
chmodSync(helperPath, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function signMacComputerUseHelper(helperAppPath, packager) {
|
||||
if (!existsSync(helperAppPath)) {
|
||||
if (isMacRelease) {
|
||||
throw new Error(`Missing Orca Computer Use helper app at ${helperAppPath}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const codeSigningInfo =
|
||||
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
|
||||
? await packager.codeSigningInfo.value
|
||||
: null
|
||||
const identity =
|
||||
process.env.ORCA_COMPUTER_MACOS_SIGN_IDENTITY ??
|
||||
process.env.CSC_NAME ??
|
||||
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
|
||||
(isMacRelease ? null : '-')
|
||||
if (!identity) {
|
||||
throw new Error('Missing signing identity for Orca Computer Use helper app')
|
||||
}
|
||||
// Why: TCC grants attach to this nested app's code identity. Sign it before
|
||||
// the outer Orca.app is sealed so production builds preserve that identity.
|
||||
execFileSync('codesign', codesignArgs(identity, helperAppPath), { stdio: 'inherit' })
|
||||
execFileSync('codesign', ['--verify', '--deep', '--strict', helperAppPath], {
|
||||
stdio: 'inherit'
|
||||
})
|
||||
}
|
||||
|
||||
async function signMacNotificationStatusHelper(helperPath, packager) {
|
||||
if (!existsSync(helperPath)) {
|
||||
if (isMacRelease) {
|
||||
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const codeSigningInfo =
|
||||
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
|
||||
? await packager.codeSigningInfo.value
|
||||
: null
|
||||
const identity =
|
||||
process.env.CSC_NAME ??
|
||||
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
|
||||
(isMacRelease ? null : '-')
|
||||
if (!identity) {
|
||||
throw new Error('Missing signing identity for orca-notification-status helper')
|
||||
}
|
||||
// Why: macOS keys notification records to the code-signing identifier; the
|
||||
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
|
||||
// (and any later) `codesign --force` derives the correct identifier. Sign
|
||||
// before the outer Orca.app is sealed, like the computer-use helper.
|
||||
const args = ['--force', '--sign', identity]
|
||||
if (isMacRelease) {
|
||||
args.push('--options', 'runtime', '--timestamp')
|
||||
}
|
||||
args.push(helperPath)
|
||||
execFileSync('codesign', args, { stdio: 'inherit' })
|
||||
execFileSync('codesign', ['--verify', '--strict', helperPath], { stdio: 'inherit' })
|
||||
}
|
||||
|
||||
function codesignArgs(identity, targetPath) {
|
||||
const args = ['--force', '--deep', '--sign', identity]
|
||||
if (isMacRelease) {
|
||||
args.push(
|
||||
'--options',
|
||||
'runtime',
|
||||
'--timestamp',
|
||||
'--entitlements',
|
||||
resolve(__dirname, '../resources/build/entitlements.computer-use.mac.plist')
|
||||
)
|
||||
}
|
||||
args.push(targetPath)
|
||||
return args
|
||||
}
|
||||
|
||||
function findInstalledMacSigningIdentity(keychainFile) {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'security',
|
||||
['find-identity', '-v', '-p', 'codesigning', ...(keychainFile ? [keychainFile] : [])],
|
||||
{
|
||||
encoding: 'utf8'
|
||||
}
|
||||
)
|
||||
const releaseMatch =
|
||||
output.match(/"([^"]*Developer ID Application:[^"]+)"/) ??
|
||||
output.match(/"([^"]*Apple Distribution:[^"]+)"/)
|
||||
if (releaseMatch?.[1]) {
|
||||
return releaseMatch[1]
|
||||
}
|
||||
if (!isMacRelease) {
|
||||
return output.match(/"([^"]*Apple Development:[^"]+)"/)?.[1] ?? null
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Localization Audit
|
||||
|
||||
This is the pre-work artifact for migrating Orca to a localized UI. The goal is
|
||||
to make coverage repeatable: every detected user-facing string is either moved
|
||||
behind the localization layer or explicitly excluded with a reason.
|
||||
|
||||
## Coverage Contract
|
||||
|
||||
Coverage means all strings matching the audit scope below are accounted for:
|
||||
|
||||
- JSX text rendered in the renderer.
|
||||
- Accessibility and form attributes such as `aria-label`, `ariaLabel`, `alt`,
|
||||
`placeholder`, `title`, `label`, `description`, `subtitle`, and `tooltip`.
|
||||
- User-facing object metadata such as Settings search `title`, `description`,
|
||||
`keywords`, labels, badges, helper text, and tooltips.
|
||||
- User-facing calls such as `toast.success(...)`, `toast.error(...)`, browser
|
||||
`alert(...)`, `confirm(...)`, and `prompt(...)`.
|
||||
|
||||
The audit intentionally does not treat these as localization misses unless they
|
||||
are surfaced directly as UI copy:
|
||||
|
||||
- Terminal output, agent output, git output, provider API errors, and shell
|
||||
commands.
|
||||
- File paths, URLs, environment variables, telemetry event names, IDs, and
|
||||
protocol names.
|
||||
- Developer logs, internal diagnostics, test fixtures, and snapshots.
|
||||
- Brand, provider, model, command, and product names that should remain exact.
|
||||
|
||||
## Inventory Command
|
||||
|
||||
Generate a machine-readable inventory:
|
||||
|
||||
```sh
|
||||
node config/scripts/audit-localization-coverage.mjs --json --output tmp/localization-candidates.json
|
||||
```
|
||||
|
||||
Generate a reviewable Markdown inventory:
|
||||
|
||||
```sh
|
||||
node config/scripts/audit-localization-coverage.mjs --markdown --output tmp/localization-candidates.md
|
||||
```
|
||||
|
||||
Run the maintained coverage gate:
|
||||
|
||||
```sh
|
||||
pnpm run verify:localization-coverage
|
||||
```
|
||||
|
||||
Sync catalog keys after adding or removing `translate(...)` calls:
|
||||
|
||||
```sh
|
||||
pnpm run sync:localization-catalog
|
||||
```
|
||||
|
||||
The sync command adds missing `en.json` entries from each call's string fallback,
|
||||
copies untranslated English placeholders into other locale catalogs to keep
|
||||
parity, removes locale entries whose English key was deleted, and repairs
|
||||
placeholder mismatches. Run the machine-translation bootstrap commands only when
|
||||
refreshing real translations, not for ordinary UI copy changes.
|
||||
|
||||
The coverage gate compares current candidates against
|
||||
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
|
||||
new candidates fail the check and must be localized or added with a reviewed
|
||||
reason in the same change.
|
||||
|
||||
The script scans `src/renderer/src` by default. That is the primary UI surface.
|
||||
Use `--source-root src` for a wider audit when checking renderer-adjacent shared
|
||||
copy, then classify non-renderer findings carefully because many are diagnostics
|
||||
or external tool text.
|
||||
|
||||
## Migration States
|
||||
|
||||
Each candidate should end in one of these states:
|
||||
|
||||
- `localized`: the component reads the string from the locale catalog.
|
||||
- `excluded`: the string is intentionally not localized, with a reason from the
|
||||
coverage contract.
|
||||
- `deferred`: the string is user-facing but belongs to a later PR wave.
|
||||
|
||||
`deferred` is acceptable for planning, but not for the localization coverage
|
||||
gate.
|
||||
|
||||
## PR Waves
|
||||
|
||||
Recommended migration order:
|
||||
|
||||
1. Infrastructure, English catalog, language setting, and language selector.
|
||||
2. Settings shell, Settings search metadata, and Appearance.
|
||||
3. App shell, sidebars, titlebar, status bar, command surfaces, and global
|
||||
dialogs/toasts.
|
||||
4. Task pages, source control, hosted review, and provider-specific UI.
|
||||
5. Terminal chrome, onboarding, feature tips, mobile, browser, and remaining
|
||||
secondary surfaces.
|
||||
|
||||
## Proof Strategy
|
||||
|
||||
The final gate should combine three checks:
|
||||
|
||||
1. Scanner coverage: no unclassified localizable candidates remain.
|
||||
2. Catalog coverage: every supported locale has the same keys as English, with
|
||||
matching interpolation variables.
|
||||
3. Runtime coverage: pseudo-localization and real locale smoke tests show no
|
||||
obvious English leftovers or layout clipping in core screens.
|
||||
|
||||
Subagent or human review should verify ambiguous exclusions, but the scanner is
|
||||
the coverage source of truth.
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,359 @@
|
||||
# Files/globs currently allowed to exceed the oxlint `max-lines` budget.
|
||||
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline mobile/src/constants/marine-creatures.ts
|
||||
inline src/cli/handlers/automations.ts
|
||||
inline src/cli/handlers/orchestration.ts
|
||||
inline src/cli/help.ts
|
||||
inline src/cli/index.test.ts
|
||||
inline src/main/agent-hooks/server.test.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/amp/hook-service.ts
|
||||
inline src/main/antigravity/hook-service.ts
|
||||
inline src/main/attribution/terminal-attribution.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
inline src/main/browser/agent-browser-bridge.test.ts
|
||||
inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-guest-ui.ts
|
||||
inline src/main/browser/browser-manager-grab.test.ts
|
||||
inline src/main/browser/browser-manager.test.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/browser/browser-screencast-stream.ts
|
||||
inline src/main/browser/browser-session-registry.ts
|
||||
inline src/main/browser/cdp-bridge.ts
|
||||
inline src/main/browser/cdp-ws-proxy.ts
|
||||
inline src/main/browser/grab-guest-script.ts
|
||||
inline src/main/browser/snapshot-engine.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.test.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/claude-accounts/service.test.ts
|
||||
inline src/main/claude-accounts/service.ts
|
||||
inline src/main/claude-usage/scanner.ts
|
||||
inline src/main/claude-usage/store.ts
|
||||
inline src/main/cli/cli-installer.test.ts
|
||||
inline src/main/cli/cli-installer.ts
|
||||
inline src/main/cli/wsl-cli-installer.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.test.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.test.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/codex-usage/scanner.ts
|
||||
inline src/main/codex-usage/store.test.ts
|
||||
inline src/main/codex-usage/store.ts
|
||||
inline src/main/codex/config-toml-trust.test.ts
|
||||
inline src/main/codex/config-toml-trust.ts
|
||||
inline src/main/codex/hook-service.test.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
inline src/main/copilot/hook-service.ts
|
||||
inline src/main/daemon/client.ts
|
||||
inline src/main/daemon/daemon-health.ts
|
||||
inline src/main/daemon/daemon-init.test.ts
|
||||
inline src/main/daemon/daemon-init.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.test.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.ts
|
||||
inline src/main/daemon/daemon-server.ts
|
||||
inline src/main/daemon/pty-subprocess.test.ts
|
||||
inline src/main/daemon/pty-subprocess.ts
|
||||
inline src/main/daemon/session.ts
|
||||
inline src/main/daemon/shell-ready.ts
|
||||
inline src/main/git/remove-worktree.test.ts
|
||||
inline src/main/git/repo.ts
|
||||
inline src/main/git/runner.ts
|
||||
inline src/main/git/status.test.ts
|
||||
inline src/main/git/status.ts
|
||||
inline src/main/git/worktree.test.ts
|
||||
inline src/main/git/worktree.ts
|
||||
inline src/main/github/client.test.ts
|
||||
inline src/main/github/client.ts
|
||||
inline src/main/github/issues.ts
|
||||
inline src/main/github/pr-refresh-coordinator.test.ts
|
||||
inline src/main/github/pr-refresh-coordinator.ts
|
||||
inline src/main/github/project-view.ts
|
||||
inline src/main/github/project-view/mutations.ts
|
||||
inline src/main/github/work-item-details.ts
|
||||
inline src/main/gitlab/client-mr.test.ts
|
||||
inline src/main/gitlab/client.ts
|
||||
inline src/main/gitlab/issues.ts
|
||||
inline src/main/gitlab/work-item-details.ts
|
||||
inline src/main/hermes/hook-service.ts
|
||||
inline src/main/hooks.test.ts
|
||||
inline src/main/hooks.ts
|
||||
inline src/main/index.ts
|
||||
inline src/main/ipc/browser.ts
|
||||
inline src/main/ipc/crash-reporting.ts
|
||||
inline src/main/ipc/filesystem-auth.ts
|
||||
inline src/main/ipc/filesystem-mutations.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.test.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/github.test.ts
|
||||
inline src/main/ipc/github.ts
|
||||
inline src/main/ipc/gitlab.ts
|
||||
inline src/main/ipc/linear.ts
|
||||
inline src/main/ipc/notifications.test.ts
|
||||
inline src/main/ipc/notifications.ts
|
||||
inline src/main/ipc/pet.ts
|
||||
inline src/main/ipc/preflight.test.ts
|
||||
inline src/main/ipc/pty.test.ts
|
||||
inline src/main/ipc/pty.ts
|
||||
inline src/main/ipc/remote-workspace.ts
|
||||
inline src/main/ipc/repos-remote.test.ts
|
||||
inline src/main/ipc/repos.ts
|
||||
inline src/main/ipc/runtime-environments.test.ts
|
||||
inline src/main/ipc/ssh.test.ts
|
||||
inline src/main/ipc/ssh.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/ipc/worktrees.test.ts
|
||||
inline src/main/ipc/worktrees.ts
|
||||
inline src/main/jira/client.ts
|
||||
inline src/main/jira/issues.ts
|
||||
inline src/main/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/client.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/linear/projects.ts
|
||||
inline src/main/memory/collector.ts
|
||||
inline src/main/opencode-usage/scanner.ts
|
||||
inline src/main/opencode-usage/store.ts
|
||||
inline src/main/opencode/hook-service.ts
|
||||
inline src/main/persistence.test.ts
|
||||
inline src/main/persistence.ts
|
||||
inline src/main/ports/advertised-url-watcher.ts
|
||||
inline src/main/ports/local-workspace-port-scanner.ts
|
||||
inline src/main/project-groups/nested-repo-discovery.ts
|
||||
inline src/main/providers/local-pty-provider.test.ts
|
||||
inline src/main/providers/local-pty-provider.ts
|
||||
inline src/main/providers/local-pty-shell-ready.test.ts
|
||||
inline src/main/providers/local-pty-shell-ready.ts
|
||||
inline src/main/providers/ssh-git-provider.test.ts
|
||||
inline src/main/providers/ssh-git-provider.ts
|
||||
inline src/main/rate-limits/claude-fetcher.test.ts
|
||||
inline src/main/rate-limits/claude-fetcher.ts
|
||||
inline src/main/rate-limits/claude-pty.ts
|
||||
inline src/main/rate-limits/codex-fetcher.ts
|
||||
inline src/main/rate-limits/service.test.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
inline src/main/runtime/orca-runtime-files.test.ts
|
||||
inline src/main/runtime/orca-runtime-files.ts
|
||||
inline src/main/runtime/orca-runtime-git.ts
|
||||
inline src/main/runtime/orca-runtime.test.ts
|
||||
inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/orchestration/coordinator.ts
|
||||
inline src/main/runtime/orchestration/db.ts
|
||||
inline src/main/runtime/rpc/methods/files.ts
|
||||
inline src/main/runtime/rpc/methods/git.ts
|
||||
inline src/main/runtime/rpc/methods/github.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.test.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/rpc/methods/terminal.ts
|
||||
inline src/main/runtime/rpc/terminal-multiplex.test.ts
|
||||
inline src/main/runtime/runtime-rpc.test.ts
|
||||
inline src/main/runtime/runtime-rpc.ts
|
||||
inline src/main/source-control/hosted-review-creation.ts
|
||||
inline src/main/speech/model-manager.ts
|
||||
inline src/main/speech/stt-service.ts
|
||||
inline src/main/ssh/ssh-channel-multiplexer.ts
|
||||
inline src/main/ssh/ssh-connection.test.ts
|
||||
inline src/main/ssh/ssh-connection.ts
|
||||
inline src/main/ssh/ssh-relay-deploy.ts
|
||||
inline src/main/ssh/ssh-relay-session.ts
|
||||
inline src/main/star-nag/service.test.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.test.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.ts
|
||||
inline src/main/updater.test.ts
|
||||
inline src/main/updater.ts
|
||||
inline src/main/window/attach-main-window-services.ts
|
||||
inline src/main/window/createMainWindow.test.ts
|
||||
inline src/main/window/createMainWindow.ts
|
||||
inline src/main/workspace-space-analysis.ts
|
||||
inline src/preload/api-types.ts
|
||||
inline src/preload/index.ts
|
||||
inline src/relay/agent-hook-server.ts
|
||||
inline src/relay/dispatcher.ts
|
||||
inline src/relay/external-automations-handler.ts
|
||||
inline src/relay/fs-handler.ts
|
||||
inline src/relay/git-handler.test.ts
|
||||
inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.test.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/relay.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/App.tsx
|
||||
inline src/renderer/src/components/GitHubItemDialog.tsx
|
||||
inline src/renderer/src/components/GitLabItemDialog.tsx
|
||||
inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
|
||||
inline src/renderer/src/components/PullRequestPage.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
inline src/renderer/src/components/UpdateCard.tsx
|
||||
inline src/renderer/src/components/WorktreeJumpPalette.tsx
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
|
||||
inline src/renderer/src/components/automations/AutomationsPage.tsx
|
||||
inline src/renderer/src/components/browser-pane/BrowserPane.tsx
|
||||
inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx
|
||||
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
|
||||
inline src/renderer/src/components/editor/EditorContent.tsx
|
||||
inline src/renderer/src/components/editor/IpynbViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/editor/MonacoEditor.tsx
|
||||
inline src/renderer/src/components/editor/editor-autosave-controller.ts
|
||||
inline src/renderer/src/components/editor/ipynb-parse.ts
|
||||
inline src/renderer/src/components/editor/useEditorPanelContentState.ts
|
||||
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectCell.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectPicker.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
|
||||
inline src/renderer/src/components/linear-project-view-surfaces.tsx
|
||||
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
|
||||
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
|
||||
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorer.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
|
||||
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/SourceControl.tsx
|
||||
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
|
||||
inline src/renderer/src/components/right-sidebar/index.tsx
|
||||
inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
|
||||
inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
|
||||
inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
|
||||
inline src/renderer/src/components/settings/AccountsPane.tsx
|
||||
inline src/renderer/src/components/settings/AgentsPane.tsx
|
||||
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
|
||||
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
|
||||
inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/settings/SettingsFormControls.tsx
|
||||
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeCard.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.tsx
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
|
||||
inline src/renderer/src/components/stats/usage-overview-model.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
|
||||
inline src/renderer/src/components/tab-bar/TabBar.tsx
|
||||
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
|
||||
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
|
||||
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
|
||||
inline src/renderer/src/components/task-page-cache-selectors.ts
|
||||
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
|
||||
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
|
||||
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
|
||||
inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.test.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.ts
|
||||
inline src/renderer/src/hooks/useIssueMetadata.ts
|
||||
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
|
||||
inline src/renderer/src/lib/active-agent-note-send.test.ts
|
||||
inline src/renderer/src/lib/file-type-icons.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-manager.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
|
||||
inline src/renderer/src/lib/terminal-links.ts
|
||||
inline src/renderer/src/lib/worktree-activation.test.ts
|
||||
inline src/renderer/src/lib/worktree-activation.ts
|
||||
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.test.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.ts
|
||||
inline src/renderer/src/runtime/runtime-git-client.ts
|
||||
inline src/renderer/src/runtime/runtime-linear-client.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.test.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.test.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.test.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.ts
|
||||
inline src/renderer/src/store/slices/agent-status.test.ts
|
||||
inline src/renderer/src/store/slices/agent-status.ts
|
||||
inline src/renderer/src/store/slices/browser.test.ts
|
||||
inline src/renderer/src/store/slices/browser.ts
|
||||
inline src/renderer/src/store/slices/diffComments.ts
|
||||
inline src/renderer/src/store/slices/editor.test.ts
|
||||
inline src/renderer/src/store/slices/editor.ts
|
||||
inline src/renderer/src/store/slices/github.test.ts
|
||||
inline src/renderer/src/store/slices/github.ts
|
||||
inline src/renderer/src/store/slices/hosted-review.ts
|
||||
inline src/renderer/src/store/slices/jira.ts
|
||||
inline src/renderer/src/store/slices/linear.test.ts
|
||||
inline src/renderer/src/store/slices/linear.ts
|
||||
inline src/renderer/src/store/slices/repos.ts
|
||||
inline src/renderer/src/store/slices/store-cascades.test.ts
|
||||
inline src/renderer/src/store/slices/store-session-cascades.test.ts
|
||||
inline src/renderer/src/store/slices/tabs.test.ts
|
||||
inline src/renderer/src/store/slices/tabs.ts
|
||||
inline src/renderer/src/store/slices/terminals.ts
|
||||
inline src/renderer/src/store/slices/ui.test.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/renderer/src/store/slices/workspace-cleanup.ts
|
||||
inline src/renderer/src/store/slices/worktrees.test.ts
|
||||
inline src/renderer/src/store/slices/worktrees.ts
|
||||
inline src/renderer/src/web/web-preload-api.test.ts
|
||||
inline src/renderer/src/web/web-preload-api.ts
|
||||
inline src/renderer/src/web/web-runtime-client.ts
|
||||
inline src/shared/agent-hook-listener.test.ts
|
||||
inline src/shared/agent-hook-listener.ts
|
||||
inline src/shared/automation-schedules.ts
|
||||
inline src/shared/commit-message-agent-spec.ts
|
||||
inline src/shared/constants.ts
|
||||
inline src/shared/github-project-types.ts
|
||||
inline src/shared/keybindings.test.ts
|
||||
inline src/shared/keybindings.ts
|
||||
inline src/shared/marine-creatures.ts
|
||||
inline src/shared/remote-runtime-client.ts
|
||||
inline src/shared/runtime-types.ts
|
||||
inline src/shared/source-control-ai.ts
|
||||
inline src/shared/telemetry-events.ts
|
||||
inline src/shared/text-search.ts
|
||||
inline src/shared/types.ts
|
||||
inline tests/e2e/helpers/terminal.ts
|
||||
inline tests/e2e/terminal-panes.spec.ts
|
||||
mobile-config app/h/*/files/*.tsx
|
||||
mobile-config app/h/*/index.tsx
|
||||
mobile-config app/h/*/session/*.tsx
|
||||
mobile-config app/h/*/source-control/*.tsx
|
||||
mobile-config app/h/*/tasks.tsx
|
||||
mobile-config app/index.tsx
|
||||
mobile-config app/pair-scan.tsx
|
||||
mobile-config app/terminal-settings.tsx
|
||||
mobile-config app/troubleshoot.tsx
|
||||
mobile-config scripts/mock-server.ts
|
||||
mobile-config scripts/repro-terminal-colors.ts
|
||||
mobile-config scripts/repro-worktree-startup-stream.ts
|
||||
mobile-config src/browser/MobileBrowserPane.tsx
|
||||
mobile-config src/components/CustomKeyModal.tsx
|
||||
mobile-config src/components/NewWorktreeModal.tsx
|
||||
mobile-config src/components/mobile-rich-markdown-editor-html.ts
|
||||
mobile-config src/terminal/terminal-accessory-keys.ts
|
||||
mobile-config src/terminal/terminal-webview-html.ts
|
||||
mobile-config src/transport/rpc-client.ts
|
||||
@@ -0,0 +1,23 @@
|
||||
; Clean up the relocated terminal daemon on a REAL uninstall.
|
||||
;
|
||||
; Why: the daemon host is deliberately copied to a distinct image name
|
||||
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
|
||||
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
|
||||
; updates. The same design means a normal uninstall's process sweep and file
|
||||
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
|
||||
;
|
||||
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
|
||||
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
|
||||
; defeat the whole feature. Only clean up on a genuine uninstall.
|
||||
;
|
||||
; The image name and the LOCALAPPDATA folder name must stay in sync with
|
||||
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
|
||||
; src/main/daemon/daemon-host-relocation.ts.
|
||||
!macro customUnInstall
|
||||
${ifNot} ${isUpdated}
|
||||
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
|
||||
; Give the OS a moment to release the image lock before removing the tree.
|
||||
Sleep 500
|
||||
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
|
||||
${endIf}
|
||||
!macroend
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "../node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": [],
|
||||
"categories": {
|
||||
"correctness": "off",
|
||||
"suspicious": "off",
|
||||
"pedantic": "off",
|
||||
"perf": "off",
|
||||
"style": "off",
|
||||
"restriction": "off",
|
||||
"nursery": "off"
|
||||
},
|
||||
"jsPlugins": [{ "name": "react-doctor", "specifier": "oxlint-plugin-react-doctor" }],
|
||||
"rules": {
|
||||
"react-doctor/no-adjust-state-on-prop-change": "warn",
|
||||
"react-doctor/no-derived-state-effect": "warn",
|
||||
"react-doctor/no-initialize-state": "warn"
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "../node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript"],
|
||||
"categories": {
|
||||
"correctness": "off",
|
||||
"suspicious": "off",
|
||||
"pedantic": "off",
|
||||
"perf": "off",
|
||||
"style": "off",
|
||||
"restriction": "off",
|
||||
"nursery": "off"
|
||||
},
|
||||
"rules": {
|
||||
"typescript/switch-exhaustiveness-check": [
|
||||
"error",
|
||||
{ "allowDefaultCaseForExhaustiveSwitch": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
const {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync
|
||||
} = require('node:fs')
|
||||
const { dirname, join, resolve } = require('node:path')
|
||||
const { builtinModules, createRequire } = require('node:module')
|
||||
|
||||
const projectDir = resolve(__dirname, '..')
|
||||
const requireFromProject = createRequire(join(projectDir, 'package.json'))
|
||||
|
||||
const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'@electron-toolkit/utils',
|
||||
'@linear/sdk',
|
||||
'@parcel/watcher',
|
||||
'electron-updater',
|
||||
'i18next',
|
||||
'jsonc-parser',
|
||||
'node-pty',
|
||||
'posthog-node',
|
||||
// serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too.
|
||||
'serve-sim',
|
||||
'qrcode',
|
||||
'ssh2',
|
||||
'tweetnacl',
|
||||
'ws',
|
||||
'yaml',
|
||||
'zod'
|
||||
]
|
||||
|
||||
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
|
||||
darwin: 'darwin-',
|
||||
linux: 'linux-',
|
||||
win32: 'win32-'
|
||||
}
|
||||
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
|
||||
const PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM = {
|
||||
darwin: 'watcher-darwin',
|
||||
linux: 'watcher-linux',
|
||||
win32: 'watcher-win32'
|
||||
}
|
||||
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
|
||||
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
|
||||
|
||||
const NODE_BUILTINS = new Set([
|
||||
...builtinModules,
|
||||
...builtinModules.map((moduleName) => `node:${moduleName}`)
|
||||
])
|
||||
|
||||
function packageNameFromSpecifier(specifier) {
|
||||
if (specifier.startsWith('@')) {
|
||||
const [scope, name] = specifier.split('/')
|
||||
return scope && name ? `${scope}/${name}` : specifier
|
||||
}
|
||||
return specifier.split('/')[0]
|
||||
}
|
||||
|
||||
function isPackagedExternalSpecifier(specifier) {
|
||||
return (
|
||||
!specifier.startsWith('.') &&
|
||||
!specifier.startsWith('/') &&
|
||||
specifier !== 'electron' &&
|
||||
!NODE_BUILTINS.has(specifier)
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePackageJsonPath(packageName, fromDir = projectDir) {
|
||||
const nested = join(fromDir, 'node_modules', packageName, 'package.json')
|
||||
if (existsSync(nested)) {
|
||||
return nested
|
||||
}
|
||||
// Why: published serve-sim has no "." export (only ./middleware and ./state), so
|
||||
// require.resolve('serve-sim') fails even though the package is present for bridge exec.
|
||||
if (packageName === 'serve-sim') {
|
||||
const direct = join(projectDir, 'node_modules', 'serve-sim', 'package.json')
|
||||
if (existsSync(direct)) {
|
||||
return direct
|
||||
}
|
||||
}
|
||||
try {
|
||||
return requireFromProject.resolve(`${packageName}/package.json`, { paths: [fromDir] })
|
||||
} catch {
|
||||
let entryPath
|
||||
try {
|
||||
entryPath = requireFromProject.resolve(packageName, { paths: [fromDir] })
|
||||
} catch {
|
||||
throw new Error(`Could not resolve package ${packageName} from ${fromDir}`)
|
||||
}
|
||||
let dir = dirname(entryPath)
|
||||
while (dir !== dirname(dir)) {
|
||||
const packageJsonPath = join(dir, 'package.json')
|
||||
if (existsSync(packageJsonPath)) {
|
||||
return packageJsonPath
|
||||
}
|
||||
dir = dirname(dir)
|
||||
}
|
||||
throw new Error(`Could not find package.json for ${packageName}`)
|
||||
}
|
||||
}
|
||||
|
||||
function readPackage(packageName, fromDir = projectDir) {
|
||||
const packageJsonPath = resolvePackageJsonPath(packageName, fromDir)
|
||||
const packageDir = realpathSync(dirname(packageJsonPath))
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
||||
return {
|
||||
name: packageJson.name ?? packageName,
|
||||
packageDir,
|
||||
dependencies: Object.keys(packageJson.dependencies ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
function isKnownOmittedServeSimDependency(packageName, fromDir) {
|
||||
if (packageName !== 'inspect-webkit') {
|
||||
return false
|
||||
}
|
||||
const serveSimPackageJsonPath = join(projectDir, 'node_modules', 'serve-sim', 'package.json')
|
||||
if (!existsSync(serveSimPackageJsonPath)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return realpathSync(fromDir) === realpathSync(dirname(serveSimPackageJsonPath))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function collectPackagedRuntimePackages() {
|
||||
const packages = new Map()
|
||||
const visit = (packageName, fromDir = projectDir) => {
|
||||
if (packageName === 'electron' || packages.has(packageName)) {
|
||||
return
|
||||
}
|
||||
|
||||
let packageInfo
|
||||
try {
|
||||
packageInfo = readPackage(packageName, fromDir)
|
||||
} catch (error) {
|
||||
// Why: serve-sim declares inspect-webkit, but current installs omit it.
|
||||
// Keep that escape hatch narrow so broken packages still fail packaging.
|
||||
if (isKnownOmittedServeSimDependency(packageName, fromDir)) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (packages.has(packageInfo.name)) {
|
||||
return
|
||||
}
|
||||
packages.set(packageInfo.name, packageInfo.packageDir)
|
||||
|
||||
for (const dependencyName of packageInfo.dependencies) {
|
||||
visit(dependencyName, packageInfo.packageDir)
|
||||
}
|
||||
}
|
||||
|
||||
for (const packageName of PACKAGED_RUNTIME_PACKAGE_ROOTS) {
|
||||
visit(packageName)
|
||||
}
|
||||
|
||||
// Why: @parcel/watcher loads its native .node addon from a platform-specific
|
||||
// optionalDependency (e.g. @parcel/watcher-linux-x64-glibc) that the
|
||||
// dependencies graph above never reaches. Include the ones installed for the
|
||||
// build's supported architectures; afterPack pruning trims non-target
|
||||
// platforms. Without this the packaged main bundle's import of
|
||||
// '@parcel/watcher' resolves at runtime but throws loading its binary.
|
||||
const parcelWatcherDir = packages.get('@parcel/watcher')
|
||||
if (parcelWatcherDir) {
|
||||
const parcelWatcherPackage = JSON.parse(
|
||||
readFileSync(join(parcelWatcherDir, 'package.json'), 'utf8')
|
||||
)
|
||||
for (const optionalName of Object.keys(parcelWatcherPackage.optionalDependencies ?? {})) {
|
||||
try {
|
||||
visit(optionalName)
|
||||
} catch {
|
||||
// Optional platform subpackage is not installed for this build; skip it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...packages.entries()].sort(([left], [right]) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function createPackagedRuntimeNodeModuleResources() {
|
||||
return collectPackagedRuntimePackages().map(([packageName, packageDir]) => ({
|
||||
from: packageDir,
|
||||
to: join('node_modules', ...packageName.split('/'))
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeAsarEntryPath(entry) {
|
||||
return entry.replace(/\\/g, '/').replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
function findAsarEntry(entries, expectedPath) {
|
||||
return entries.find((entry) => normalizeAsarEntryPath(entry) === expectedPath)
|
||||
}
|
||||
|
||||
function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/asar')) {
|
||||
const asarPath = join(resourcesDir, 'app.asar')
|
||||
if (!existsSync(asarPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const mainFiles = ['out/main/index.js', 'out/main/agent-hooks/managed-agent-hook-controls.js']
|
||||
const entries = asar.listPackage(asarPath)
|
||||
const missing = new Set()
|
||||
|
||||
for (const file of mainFiles) {
|
||||
const entry = findAsarEntry(entries, file)
|
||||
if (!entry) {
|
||||
throw new Error(`Packaged main file ${file} was not found in ${asarPath}`)
|
||||
}
|
||||
|
||||
// Why: @electron/asar lists entries with host separators; Windows returns
|
||||
// backslashes, and extractFile expects that same host-style path.
|
||||
const internalPath = entry.replace(/^[\\/]+/, '')
|
||||
const source = asar.extractFile(asarPath, internalPath).toString('utf8')
|
||||
for (const match of source.matchAll(/require\(["']([^"']+)["']\)/g)) {
|
||||
const specifier = match[1]
|
||||
if (!isPackagedExternalSpecifier(specifier)) {
|
||||
continue
|
||||
}
|
||||
const packageName = packageNameFromSpecifier(specifier)
|
||||
if (!existsSync(join(resourcesDir, 'node_modules', ...packageName.split('/')))) {
|
||||
missing.add(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size > 0) {
|
||||
throw new Error(
|
||||
`Packaged main bundle has bare runtime imports without copied node_modules: ${[
|
||||
...missing
|
||||
].join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNodePtyWindowsArch(electronArch) {
|
||||
if (electronArch === 'x64' || electronArch === 1) {
|
||||
return 'x64'
|
||||
}
|
||||
if (electronArch === 'arm64' || electronArch === 3) {
|
||||
return 'arm64'
|
||||
}
|
||||
return process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
}
|
||||
|
||||
function findNodePtyConptySourceDir(nodePtyDir, windowsArch) {
|
||||
const conptyRoot = join(nodePtyDir, 'third_party', 'conpty')
|
||||
if (!existsSync(conptyRoot)) {
|
||||
throw new Error(`Packaged node-pty is missing ${conptyRoot}`)
|
||||
}
|
||||
for (const entry of readdirSync(conptyRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
const sourceDir = join(conptyRoot, entry.name, `win10-${windowsArch}`)
|
||||
if (existsSync(sourceDir)) {
|
||||
return sourceDir
|
||||
}
|
||||
}
|
||||
throw new Error(`Packaged node-pty has no ConPTY payload for win10-${windowsArch}`)
|
||||
}
|
||||
|
||||
function ensurePackagedNodePtyConptyRuntime(nodePtyDir, electronArch) {
|
||||
const releaseDir = join(nodePtyDir, 'build', 'Release')
|
||||
if (!existsSync(join(releaseDir, 'conpty.node'))) {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeDir = join(releaseDir, 'conpty')
|
||||
const missingRuntimeFiles = NODE_PTY_CONPTY_RUNTIME_FILES.filter(
|
||||
(filename) => !existsSync(join(runtimeDir, filename))
|
||||
)
|
||||
if (missingRuntimeFiles.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const windowsArch = normalizeNodePtyWindowsArch(electronArch)
|
||||
const sourceDir = findNodePtyConptySourceDir(nodePtyDir, windowsArch)
|
||||
mkdirSync(runtimeDir, { recursive: true })
|
||||
for (const filename of missingRuntimeFiles) {
|
||||
const sourceFile = join(sourceDir, filename)
|
||||
if (!existsSync(sourceFile)) {
|
||||
throw new Error(`Packaged node-pty is missing ${sourceFile}`)
|
||||
}
|
||||
// Why: node-pty's Windows addon loads conpty.dll relative to conpty.node,
|
||||
// but its install script can run before electron-builder gathers resources.
|
||||
copyFileSync(sourceFile, join(runtimeDir, filename))
|
||||
}
|
||||
}
|
||||
|
||||
function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch) {
|
||||
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
|
||||
if (!existsSync(nodePtyDir)) {
|
||||
return
|
||||
}
|
||||
|
||||
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
if (allowedPrebuildPrefix) {
|
||||
const prebuildsDir = join(nodePtyDir, 'prebuilds')
|
||||
if (existsSync(prebuildsDir)) {
|
||||
for (const entry of readdirSync(prebuildsDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith(allowedPrebuildPrefix)) {
|
||||
rmSync(join(prebuildsDir, entry.name), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (electronPlatformName === 'win32') {
|
||||
ensurePackagedNodePtyConptyRuntime(nodePtyDir, electronArch)
|
||||
} else {
|
||||
// Why: conpty is Windows-only and node-pty resolves runtime binaries from
|
||||
// build/Release or prebuilds/<platform>-<arch>, not third_party/conpty.
|
||||
rmSync(join(nodePtyDir, 'third_party', 'conpty'), { recursive: true, force: true })
|
||||
rmSync(join(nodePtyDir, 'deps', 'winpty'), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function prunePackagedParcelWatcher(resourcesDir, electronPlatformName) {
|
||||
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
|
||||
if (!existsSync(parcelDir)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: we package every installed @parcel/watcher-<platform> optional
|
||||
// subpackage (supportedArchitectures fetches all), but each build only needs
|
||||
// its own platform's binary. Keep the core package and the matching platform
|
||||
// subpackages; drop the rest so a Linux serve doesn't ship macOS/Windows .node.
|
||||
const keepPrefix = PARCEL_WATCHER_PLATFORM_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
for (const entry of readdirSync(parcelDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name === 'watcher') {
|
||||
continue
|
||||
}
|
||||
// Why: only ever prune the watcher's own platform subpackages. Guards against
|
||||
// nuking an unrelated @parcel/* runtime dep if one is added to the roots later.
|
||||
if (!entry.name.startsWith('watcher-')) {
|
||||
continue
|
||||
}
|
||||
if (keepPrefix && entry.name.startsWith(keepPrefix)) {
|
||||
continue
|
||||
}
|
||||
rmSync(join(parcelDir, entry.name), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function prunePackagedRuntimeTypeDeclarations(resourcesDir) {
|
||||
const nodeModulesDir = join(resourcesDir, 'node_modules')
|
||||
if (!existsSync(nodeModulesDir)) {
|
||||
return
|
||||
}
|
||||
pruneMatchingFiles(nodeModulesDir, (filename) => TYPE_DECLARATION_ARTIFACT_RE.test(filename))
|
||||
}
|
||||
|
||||
function prunePackagedSherpaOnnx(resourcesDir, electronPlatformName) {
|
||||
if (electronPlatformName !== 'darwin') {
|
||||
return
|
||||
}
|
||||
const nodeModulesDir = join(resourcesDir, 'node_modules')
|
||||
if (!existsSync(nodeModulesDir)) {
|
||||
return
|
||||
}
|
||||
for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('sherpa-onnx-darwin-')) {
|
||||
continue
|
||||
}
|
||||
const packageDir = join(nodeModulesDir, entry.name)
|
||||
const packageEntries = readdirSync(packageDir)
|
||||
const hasVersionedOnnxRuntime = packageEntries.some((filename) =>
|
||||
VERSIONED_ONNXRUNTIME_DYLIB_RE.test(filename)
|
||||
)
|
||||
if (hasVersionedOnnxRuntime) {
|
||||
// Why: darwin sherpa-onnx binaries link to the versioned ONNX Runtime
|
||||
// install name; the unversioned dylib is a duplicate fallback copy.
|
||||
rmSync(join(packageDir, 'libonnxruntime.dylib'), { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prunePackagedZodSources(resourcesDir) {
|
||||
// Why: Zod's src tree is TypeScript source only selected by the @zod/source
|
||||
// condition; packaged runtime import/require paths resolve to built JS.
|
||||
rmSync(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName, electronArch) {
|
||||
prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
prunePackagedParcelWatcher(resourcesDir, electronPlatformName)
|
||||
prunePackagedRuntimeTypeDeclarations(resourcesDir)
|
||||
prunePackagedSherpaOnnx(resourcesDir, electronPlatformName)
|
||||
prunePackagedZodSources(resourcesDir)
|
||||
}
|
||||
|
||||
function pruneMatchingFiles(directory, shouldPrune) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const entryPath = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
pruneMatchingFiles(entryPath, shouldPrune)
|
||||
} else if (entry.isFile() && shouldPrune(entry.name)) {
|
||||
rmSync(entryPath, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PACKAGED_RUNTIME_PACKAGE_ROOTS,
|
||||
createPackagedRuntimeNodeModuleResources,
|
||||
findAsarEntry,
|
||||
isPackagedExternalSpecifier,
|
||||
packageNameFromSpecifier,
|
||||
prunePackagedNodePty,
|
||||
prunePackagedParcelWatcher,
|
||||
prunePackagedRuntimeNodeModules,
|
||||
prunePackagedSherpaOnnx,
|
||||
prunePackagedRuntimeTypeDeclarations,
|
||||
prunePackagedZodSources,
|
||||
verifyPackagedMainRuntimeDeps
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
diff --git a/package.json b/package.json
|
||||
index c697677b35e736e23702c1abb357b3445c57d949..0a9f8b69e5a6f4ff7f54dbf437cf91bb1e3d3b61 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -7,8 +7,15 @@
|
||||
"url": "https://xtermjs.org/"
|
||||
},
|
||||
"main": "lib/addon-ligatures.js",
|
||||
- "module": "lib/addon-ligatures.mjs",
|
||||
+ "module": "lib/addon-ligatures.js",
|
||||
"types": "typings/addon-ligatures.d.ts",
|
||||
+ "exports": {
|
||||
+ ".": {
|
||||
+ "types": "./typings/addon-ligatures.d.ts",
|
||||
+ "import": "./lib/addon-ligatures.js",
|
||||
+ "default": "./lib/addon-ligatures.js"
|
||||
+ }
|
||||
+ },
|
||||
"repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-ligatures",
|
||||
"engines": {
|
||||
"node": ">8.0.0"
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,386 @@
|
||||
diff --git a/binding.gyp b/binding.gyp
|
||||
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc3500acacae 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -5,9 +5,6 @@
|
||||
],
|
||||
'conditions': [
|
||||
['OS=="win"', {
|
||||
- 'msvs_configuration_attributes': {
|
||||
- 'SpectreMitigation': 'Spectre'
|
||||
- },
|
||||
'msvs_settings': {
|
||||
'VCCLCompilerTool': {
|
||||
'AdditionalOptions': [
|
||||
diff --git a/deps/winpty/src/winpty.gyp b/deps/winpty/src/winpty.gyp
|
||||
index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8360130ef 100644
|
||||
--- a/deps/winpty/src/winpty.gyp
|
||||
+++ b/deps/winpty/src/winpty.gyp
|
||||
@@ -10,7 +10,7 @@
|
||||
# make -j4 CXX=i686-w64-mingw32-g++ LDFLAGS="-static -static-libgcc -static-libstdc++"
|
||||
|
||||
'variables': {
|
||||
- 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && GetCommitHash.bat")',
|
||||
+ 'WINPTY_COMMIT_HASH%': '<!(cmd /c "cd shared && .\\GetCommitHash.bat")',
|
||||
},
|
||||
'target_defaults' : {
|
||||
'defines' : [
|
||||
@@ -22,7 +22,7 @@
|
||||
'include_dirs': [
|
||||
# Add the 'src/gen' directory to the include path and force gyp to
|
||||
# run the script (re)generating the version header.
|
||||
- '<!(cmd /c "cd shared && UpdateGenVersion.bat <(WINPTY_COMMIT_HASH)")',
|
||||
+ '<!(cmd /c "cd shared && .\\UpdateGenVersion.bat <(WINPTY_COMMIT_HASH)")',
|
||||
]
|
||||
},
|
||||
'targets' : [
|
||||
@@ -40,9 +40,6 @@
|
||||
'-lshell32',
|
||||
'-luser32',
|
||||
],
|
||||
- 'msvs_configuration_attributes': {
|
||||
- 'SpectreMitigation': 'Spectre'
|
||||
- },
|
||||
'msvs_settings': {
|
||||
# Specify this setting here to override a setting from somewhere
|
||||
# else, such as node's common.gypi.
|
||||
@@ -142,9 +139,6 @@
|
||||
'-ladvapi32',
|
||||
'-luser32',
|
||||
],
|
||||
- 'msvs_configuration_attributes': {
|
||||
- 'SpectreMitigation': 'Spectre'
|
||||
- },
|
||||
'msvs_settings': {
|
||||
# Specify this setting here to override a setting from somewhere
|
||||
# else, such as node's common.gypi.
|
||||
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
|
||||
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
|
||||
--- a/lib/unixTerminal.js
|
||||
+++ b/lib/unixTerminal.js
|
||||
@@ -28,8 +28,12 @@ var native = utils_1.loadNativeModule('pty');
|
||||
var pty = native.module;
|
||||
var helperPath = native.dir + '/spawn-helper';
|
||||
helperPath = path.resolve(__dirname, helperPath);
|
||||
-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
|
||||
-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
|
||||
+if (!helperPath.includes('app.asar.unpacked')) {
|
||||
+ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
|
||||
+}
|
||||
+if (!helperPath.includes('node_modules.asar.unpacked')) {
|
||||
+ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
|
||||
+}
|
||||
var DEFAULT_FILE = 'sh';
|
||||
var DEFAULT_NAME = 'xterm';
|
||||
var DESTROY_SOCKET_TIMEOUT_MS = 200;
|
||||
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
|
||||
index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644
|
||||
--- a/lib/conpty_console_list_agent.js
|
||||
+++ b/lib/conpty_console_list_agent.js
|
||||
@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
var utils_1 = require("./utils");
|
||||
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
|
||||
var shellPid = parseInt(process.argv[2], 10);
|
||||
-var consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+var consoleProcessList;
|
||||
+try {
|
||||
+ consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+}
|
||||
+catch (_a) {
|
||||
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
|
||||
+ consoleProcessList = [shellPid];
|
||||
+}
|
||||
process.send({ consoleProcessList: consoleProcessList });
|
||||
process.exit(0);
|
||||
//# sourceMappingURL=conpty_console_list_agent.js.map
|
||||
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
|
||||
index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644
|
||||
--- a/src/conpty_console_list_agent.ts
|
||||
+++ b/src/conpty_console_list_agent.ts
|
||||
@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils';
|
||||
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
|
||||
const shellPid = parseInt(process.argv[2], 10);
|
||||
-const consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+let consoleProcessList: number[];
|
||||
+try {
|
||||
+ consoleProcessList = getConsoleProcessList(shellPid);
|
||||
+} catch {
|
||||
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
|
||||
+ consoleProcessList = [shellPid];
|
||||
+}
|
||||
process.send!({ consoleProcessList });
|
||||
process.exit(0);
|
||||
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
|
||||
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
|
||||
--- a/src/unix/pty.cc
|
||||
+++ b/src/unix/pty.cc
|
||||
@@ -23,7 +23,9 @@
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
+#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
+#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include <sys/types.h>
|
||||
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__) || defined(__OpenBSD__)
|
||||
+struct pty_spawn_error {
|
||||
+ const char* step;
|
||||
+ int errnum;
|
||||
+ std::string detail_name;
|
||||
+ std::string detail_value;
|
||||
+};
|
||||
+
|
||||
+static std::string
|
||||
+pty_format_spawn_error(const pty_spawn_error&);
|
||||
+
|
||||
static void
|
||||
pty_posix_spawn(char** argv, char** env,
|
||||
const struct termios *termp,
|
||||
const struct winsize *winp,
|
||||
int* master,
|
||||
pid_t* pid,
|
||||
- int* err);
|
||||
+ pty_spawn_error* err);
|
||||
#endif
|
||||
|
||||
struct DelBuf {
|
||||
@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
|
||||
argv[i + 3] = strdup(arg.c_str());
|
||||
}
|
||||
|
||||
- int err = -1;
|
||||
- pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err);
|
||||
- if (err != 0) {
|
||||
- throw Napi::Error::New(napiEnv, "posix_spawnp failed.");
|
||||
+ pty_spawn_error spawn_error = { NULL, 0, "", "" };
|
||||
+ pty_posix_spawn(argv, env, term, &winp, &master, &pid, &spawn_error);
|
||||
+ if (spawn_error.errnum != 0) {
|
||||
+ std::string spawn_message = pty_format_spawn_error(spawn_error);
|
||||
+ throw Napi::Error::New(napiEnv, spawn_message);
|
||||
}
|
||||
if (pty_nonblock(master) == -1) {
|
||||
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
|
||||
@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) {
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__)
|
||||
+static const char*
|
||||
+pty_errno_name(int errnum) {
|
||||
+ switch (errnum) {
|
||||
+ case E2BIG: return "E2BIG";
|
||||
+ case EACCES: return "EACCES";
|
||||
+ case EAGAIN: return "EAGAIN";
|
||||
+ case EMFILE: return "EMFILE";
|
||||
+ case ENFILE: return "ENFILE";
|
||||
+ case ENOENT: return "ENOENT";
|
||||
+ case ENOMEM: return "ENOMEM";
|
||||
+ default: return "errno";
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+static void
|
||||
+pty_set_spawn_error(pty_spawn_error* err,
|
||||
+ const char* step,
|
||||
+ int errnum,
|
||||
+ const char* detail_name = NULL,
|
||||
+ const char* detail_value = NULL) {
|
||||
+ err->step = step;
|
||||
+ err->errnum = errnum;
|
||||
+ err->detail_name = detail_name ? detail_name : "";
|
||||
+ err->detail_value = detail_value ? detail_value : "";
|
||||
+}
|
||||
+
|
||||
+static std::string
|
||||
+pty_format_spawn_error(const pty_spawn_error& err) {
|
||||
+ char errno_buf[64];
|
||||
+ snprintf(errno_buf, sizeof(errno_buf), "%d", err.errnum);
|
||||
+
|
||||
+ std::string message = "node-pty: ";
|
||||
+ message += err.step ? err.step : "unknown";
|
||||
+ message += " failed: ";
|
||||
+ message += pty_errno_name(err.errnum);
|
||||
+ message += " (errno ";
|
||||
+ message += errno_buf;
|
||||
+ message += ", ";
|
||||
+ message += strerror(err.errnum);
|
||||
+ message += ")";
|
||||
+
|
||||
+ if (!err.detail_name.empty()) {
|
||||
+ message += " - ";
|
||||
+ message += err.detail_name;
|
||||
+ message += "='";
|
||||
+ message += err.detail_value;
|
||||
+ message += "'";
|
||||
+ }
|
||||
+
|
||||
+ return message;
|
||||
+}
|
||||
+
|
||||
static void
|
||||
pty_posix_spawn(char** argv, char** env,
|
||||
const struct termios *termp,
|
||||
const struct winsize *winp,
|
||||
int* master,
|
||||
pid_t* pid,
|
||||
- int* err) {
|
||||
- int low_fds[3];
|
||||
+ pty_spawn_error* err) {
|
||||
+ int low_fds[3] = {-1, -1, -1};
|
||||
size_t count = 0;
|
||||
+ int res = -1;
|
||||
+ int slave = -1;
|
||||
+ posix_spawn_file_actions_t acts;
|
||||
+ bool acts_initialized = false;
|
||||
+ posix_spawnattr_t attrs;
|
||||
+ bool attrs_initialized = false;
|
||||
|
||||
for (; count < 3; count++) {
|
||||
low_fds[count] = posix_openpt(O_RDWR);
|
||||
@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env,
|
||||
POSIX_SPAWN_SETSID;
|
||||
*master = posix_openpt(O_RDWR);
|
||||
if (*master == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "posix_openpt", errno);
|
||||
+ goto done;
|
||||
+ }
|
||||
+
|
||||
+ res = grantpt(*master);
|
||||
+ if (res == -1) {
|
||||
+ pty_set_spawn_error(err, "grantpt", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
- int res = grantpt(*master) || unlockpt(*master);
|
||||
+ res = unlockpt(*master);
|
||||
if (res == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "unlockpt", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
|
||||
- int slave;
|
||||
char slave_pty_name[128];
|
||||
res = ioctl(*master, TIOCPTYGNAME, slave_pty_name);
|
||||
if (res == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "ioctl_TIOCPTYGNAME", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
slave = open(slave_pty_name, O_RDWR | O_NOCTTY);
|
||||
if (slave == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "open_slave", errno, "slave", slave_pty_name);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
if (termp) {
|
||||
res = tcsetattr(slave, TCSANOW, termp);
|
||||
if (res == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "tcsetattr", errno, "slave", slave_pty_name);
|
||||
+ goto done;
|
||||
};
|
||||
}
|
||||
|
||||
if (winp) {
|
||||
res = ioctl(slave, TIOCSWINSZ, winp);
|
||||
if (res == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "ioctl_TIOCSWINSZ", errno, "slave", slave_pty_name);
|
||||
+ goto done;
|
||||
}
|
||||
}
|
||||
|
||||
- posix_spawn_file_actions_t acts;
|
||||
- posix_spawn_file_actions_init(&acts);
|
||||
+ res = posix_spawn_file_actions_init(&acts);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawn_file_actions_init", res);
|
||||
+ goto done;
|
||||
+ }
|
||||
+ acts_initialized = true;
|
||||
posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO);
|
||||
posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO);
|
||||
posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO);
|
||||
posix_spawn_file_actions_addclose(&acts, slave);
|
||||
posix_spawn_file_actions_addclose(&acts, *master);
|
||||
|
||||
- posix_spawnattr_t attrs;
|
||||
- posix_spawnattr_init(&attrs);
|
||||
- *err = posix_spawnattr_setflags(&attrs, flags);
|
||||
- if (*err != 0) {
|
||||
+ res = posix_spawnattr_init(&attrs);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawnattr_init", res);
|
||||
+ goto done;
|
||||
+ }
|
||||
+ attrs_initialized = true;
|
||||
+ res = posix_spawnattr_setflags(&attrs, flags);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawnattr_setflags", res);
|
||||
goto done;
|
||||
}
|
||||
|
||||
sigset_t signal_set;
|
||||
/* Reset all signal the child to their default behavior */
|
||||
sigfillset(&signal_set);
|
||||
- *err = posix_spawnattr_setsigdefault(&attrs, &signal_set);
|
||||
- if (*err != 0) {
|
||||
+ res = posix_spawnattr_setsigdefault(&attrs, &signal_set);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawnattr_setsigdefault", res);
|
||||
goto done;
|
||||
}
|
||||
|
||||
/* Reset the signal mask for all signals */
|
||||
sigemptyset(&signal_set);
|
||||
- *err = posix_spawnattr_setsigmask(&attrs, &signal_set);
|
||||
- if (*err != 0) {
|
||||
+ res = posix_spawnattr_setsigmask(&attrs, &signal_set);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawnattr_setsigmask", res);
|
||||
goto done;
|
||||
}
|
||||
|
||||
do
|
||||
- *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
|
||||
- while (*err == EINTR);
|
||||
+ res = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
|
||||
+ while (res == EINTR);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawn", res, "helper", argv[0]);
|
||||
+ }
|
||||
done:
|
||||
- posix_spawn_file_actions_destroy(&acts);
|
||||
- posix_spawnattr_destroy(&attrs);
|
||||
+ if (acts_initialized) {
|
||||
+ posix_spawn_file_actions_destroy(&acts);
|
||||
+ }
|
||||
+ if (attrs_initialized) {
|
||||
+ posix_spawnattr_destroy(&attrs);
|
||||
+ }
|
||||
+ if (slave != -1) {
|
||||
+ close(slave);
|
||||
+ }
|
||||
+ if (err->errnum != 0 && *master != -1) {
|
||||
+ close(*master);
|
||||
+ *master = -1;
|
||||
+ }
|
||||
|
||||
- for (; count > 0; count--) {
|
||||
- close(low_fds[count]);
|
||||
+ for (size_t i = 0; i <= count && i < 3; i++) {
|
||||
+ if (low_fds[i] != -1) {
|
||||
+ close(low_fds[i]);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,619 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate'])
|
||||
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
|
||||
'ariaLabel',
|
||||
'aria-label',
|
||||
'aria-description',
|
||||
'alt',
|
||||
'description',
|
||||
'emptyText',
|
||||
'helperText',
|
||||
'keywords',
|
||||
'label',
|
||||
'message',
|
||||
'placeholder',
|
||||
'subtitle',
|
||||
'text',
|
||||
'title',
|
||||
'toggleDescription',
|
||||
'tooltip'
|
||||
])
|
||||
const USER_VISIBLE_OBJECT_KEYS = new Set([
|
||||
'ariaLabel',
|
||||
'badge',
|
||||
'description',
|
||||
'emptyText',
|
||||
'error',
|
||||
'helperText',
|
||||
'keywords',
|
||||
'label',
|
||||
'message',
|
||||
'placeholder',
|
||||
'subtitle',
|
||||
'title',
|
||||
'toggleDescription',
|
||||
'tooltip'
|
||||
])
|
||||
const USER_VISIBLE_FUNCTION_NAMES = new Set([
|
||||
'alert',
|
||||
'confirm',
|
||||
'prompt',
|
||||
'showError',
|
||||
'showToast'
|
||||
])
|
||||
const USER_VISIBLE_OBJECT_METHODS = new Set([
|
||||
'error',
|
||||
'info',
|
||||
'loading',
|
||||
'message',
|
||||
'promise',
|
||||
'success',
|
||||
'warning'
|
||||
])
|
||||
const USER_VISIBLE_OBJECT_NAMES = new Set(['toast'])
|
||||
|
||||
function normalizePath(root, filePath) {
|
||||
return path.relative(root, filePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function isSkippedFile(root, filePath) {
|
||||
const relative = normalizePath(root, filePath)
|
||||
if (
|
||||
relative.endsWith('.d.ts') ||
|
||||
relative.includes('.test.') ||
|
||||
relative.includes('.spec.') ||
|
||||
relative.includes('/__tests__/')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
|
||||
}
|
||||
|
||||
async function collectSourceFiles(root, dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_PATH_PARTS.has(entry.name)) {
|
||||
files.push(...(await collectSourceFiles(root, fullPath)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (
|
||||
entry.isFile() &&
|
||||
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
|
||||
!isSkippedFile(root, fullPath)
|
||||
) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function hasHumanLanguageText(text) {
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim()
|
||||
if (trimmed.length < 2) {
|
||||
return false
|
||||
}
|
||||
if (/^[\d\s!-/:-@[-`{-~]+$/.test(trimmed)) {
|
||||
return false
|
||||
}
|
||||
return /[A-Za-z\u00C0-\u024F\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(trimmed)
|
||||
}
|
||||
|
||||
function compactText(text) {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function lineAndColumn(sourceFile, node) {
|
||||
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
||||
return { line: position.line + 1, column: position.character + 1 }
|
||||
}
|
||||
|
||||
function propertyNameText(name) {
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
|
||||
return name.text
|
||||
}
|
||||
if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) {
|
||||
return name.expression.text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function expressionNameText(node) {
|
||||
if (ts.isIdentifier(node)) {
|
||||
return node.text
|
||||
}
|
||||
if (ts.isPropertyAccessExpression(node)) {
|
||||
return `${expressionNameText(node.expression) ?? ''}.${node.name.text}`.replace(/^\./, '')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function stringParts(node) {
|
||||
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return [{ text: node.text, dynamic: false }]
|
||||
}
|
||||
if (!ts.isTemplateExpression(node)) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{ text: node.head.text, dynamic: true },
|
||||
...node.templateSpans.map((span) => ({ text: span.literal.text, dynamic: true }))
|
||||
]
|
||||
}
|
||||
|
||||
function isInsideLocalizationCall(node) {
|
||||
let current = node.parent
|
||||
while (current) {
|
||||
if (ts.isCallExpression(current)) {
|
||||
const name = expressionNameText(current.expression)
|
||||
if (name && LOCALIZATION_CALL_NAMES.has(name.split('.').at(-1) ?? name)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
current = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isJsxAttributeValue(node) {
|
||||
const parent = node.parent
|
||||
if (!parent) {
|
||||
return undefined
|
||||
}
|
||||
if (ts.isJsxAttribute(parent)) {
|
||||
return propertyNameText(parent.name)
|
||||
}
|
||||
if (parent && ts.isJsxExpression(parent) && parent.parent && ts.isJsxAttribute(parent.parent)) {
|
||||
return propertyNameText(parent.parent.name)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function ancestorJsxAttributeName(node) {
|
||||
let current = node.parent
|
||||
while (current) {
|
||||
if (ts.isJsxAttribute(current)) {
|
||||
return propertyNameText(current.name)
|
||||
}
|
||||
if (
|
||||
ts.isJsxExpression(current) ||
|
||||
ts.isConditionalExpression(current) ||
|
||||
ts.isParenthesizedExpression(current) ||
|
||||
ts.isBinaryExpression(current)
|
||||
) {
|
||||
current = current.parent
|
||||
continue
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isRenderedJsxExpression(node) {
|
||||
let current = node.parent
|
||||
while (current) {
|
||||
if (ts.isJsxExpression(current)) {
|
||||
return (
|
||||
ts.isJsxElement(current.parent) ||
|
||||
ts.isJsxFragment(current.parent) ||
|
||||
ts.isJsxSelfClosingElement(current.parent)
|
||||
)
|
||||
}
|
||||
if (
|
||||
ts.isConditionalExpression(current) ||
|
||||
ts.isParenthesizedExpression(current) ||
|
||||
ts.isTemplateExpression(current) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(current)
|
||||
) {
|
||||
if (ts.isConditionalExpression(current) && current.condition === node) {
|
||||
return false
|
||||
}
|
||||
current = current.parent
|
||||
continue
|
||||
}
|
||||
if (ts.isBinaryExpression(current)) {
|
||||
if (current.operatorToken.kind !== ts.SyntaxKind.PlusToken) {
|
||||
return false
|
||||
}
|
||||
current = current.parent
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function nearestObjectPropertyName(node) {
|
||||
let current = node.parent
|
||||
while (current) {
|
||||
if (ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) {
|
||||
return propertyNameText(current.name)
|
||||
}
|
||||
if (ts.isObjectLiteralExpression(current) || ts.isArrayLiteralExpression(current)) {
|
||||
current = current.parent
|
||||
continue
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function hasAncestorObjectPropertyName(node, names) {
|
||||
let current = node.parent
|
||||
while (current) {
|
||||
if (
|
||||
(ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) &&
|
||||
names.has(propertyNameText(current.name) ?? '')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
current = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function nearestAncestorObjectPropertyName(node) {
|
||||
let current = node.parent
|
||||
while (current) {
|
||||
if (ts.isPropertyAssignment(current) || ts.isShorthandPropertyAssignment(current)) {
|
||||
return propertyNameText(current.name)
|
||||
}
|
||||
current = current.parent
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function findAncestor(node, predicate) {
|
||||
let current = node.parent
|
||||
while (current) {
|
||||
if (predicate(current)) {
|
||||
return current
|
||||
}
|
||||
current = current.parent
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isUserVisibleCallArgument(node) {
|
||||
const call = findAncestor(node, ts.isCallExpression)
|
||||
if (!call) {
|
||||
return false
|
||||
}
|
||||
const expressionName = expressionNameText(call.expression)
|
||||
if (!expressionName) {
|
||||
return false
|
||||
}
|
||||
const parts = expressionName.split('.')
|
||||
const methodName = parts.at(-1)
|
||||
const objectName = parts.at(-2)
|
||||
return (
|
||||
USER_VISIBLE_FUNCTION_NAMES.has(expressionName) ||
|
||||
USER_VISIBLE_FUNCTION_NAMES.has(methodName ?? '') ||
|
||||
(objectName !== undefined &&
|
||||
USER_VISIBLE_OBJECT_NAMES.has(objectName) &&
|
||||
USER_VISIBLE_OBJECT_METHODS.has(methodName ?? ''))
|
||||
)
|
||||
}
|
||||
|
||||
function classifyStringNode(node) {
|
||||
if (hasAncestorObjectPropertyName(node, new Set(['className', 'classNames']))) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (
|
||||
findAncestor(
|
||||
node,
|
||||
(ancestor) =>
|
||||
ts.isBinaryExpression(ancestor) && ancestor.operatorToken.kind !== ts.SyntaxKind.PlusToken
|
||||
)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const jsxAttributeName = isJsxAttributeValue(node)
|
||||
if (jsxAttributeName) {
|
||||
return USER_VISIBLE_JSX_ATTRIBUTES.has(jsxAttributeName)
|
||||
? `jsx-attribute:${jsxAttributeName}`
|
||||
: undefined
|
||||
}
|
||||
|
||||
const ancestorAttributeName = ancestorJsxAttributeName(node)
|
||||
if (ancestorAttributeName) {
|
||||
return USER_VISIBLE_JSX_ATTRIBUTES.has(ancestorAttributeName)
|
||||
? `jsx-attribute:${ancestorAttributeName}`
|
||||
: undefined
|
||||
}
|
||||
|
||||
if (ts.isJsxText(node)) {
|
||||
return 'jsx-text'
|
||||
}
|
||||
|
||||
const objectPropertyName = nearestObjectPropertyName(node)
|
||||
if (objectPropertyName && !USER_VISIBLE_OBJECT_KEYS.has(objectPropertyName)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const ancestorObjectPropertyName = nearestAncestorObjectPropertyName(node)
|
||||
if (ancestorObjectPropertyName && !USER_VISIBLE_OBJECT_KEYS.has(ancestorObjectPropertyName)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (isRenderedJsxExpression(node)) {
|
||||
return 'jsx-expression'
|
||||
}
|
||||
|
||||
if (isUserVisibleCallArgument(node)) {
|
||||
return 'user-visible-call'
|
||||
}
|
||||
|
||||
if (objectPropertyName) {
|
||||
return `object-property:${objectPropertyName}`
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function areaForFile(relativePath) {
|
||||
const rendererPrefix = 'src/renderer/src/'
|
||||
if (!relativePath.startsWith(rendererPrefix)) {
|
||||
return relativePath.split('/').slice(0, 2).join('/')
|
||||
}
|
||||
|
||||
const withoutPrefix = relativePath.slice(rendererPrefix.length)
|
||||
const parts = withoutPrefix.split('/')
|
||||
if (parts[0] === 'components' && parts[1]) {
|
||||
return `renderer/${parts[1]}`
|
||||
}
|
||||
return `renderer/${parts[0] ?? 'root'}`
|
||||
}
|
||||
|
||||
export function collectLocalizationCandidates(filePath, sourceText, root = process.cwd()) {
|
||||
const sourceKind =
|
||||
filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
sourceKind
|
||||
)
|
||||
const reports = []
|
||||
const relativePath = normalizePath(root, filePath)
|
||||
|
||||
function pushReport(node, kind, text, dynamic = false) {
|
||||
const value = compactText(text)
|
||||
if (!hasHumanLanguageText(value) || isInsideLocalizationCall(node)) {
|
||||
return
|
||||
}
|
||||
const position = lineAndColumn(sourceFile, node)
|
||||
reports.push({
|
||||
area: areaForFile(relativePath),
|
||||
filePath: relativePath,
|
||||
start: node.getStart(sourceFile),
|
||||
end: node.getEnd(),
|
||||
line: position.line,
|
||||
column: position.column,
|
||||
kind,
|
||||
text: value,
|
||||
dynamic
|
||||
})
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isJsxText(node)) {
|
||||
pushReport(node, 'jsx-text', node.text)
|
||||
return
|
||||
}
|
||||
|
||||
const kind = classifyStringNode(node)
|
||||
if (kind) {
|
||||
for (const part of stringParts(node)) {
|
||||
pushReport(node, kind, part.text, part.dynamic)
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return reports
|
||||
}
|
||||
|
||||
function groupByArea(reports) {
|
||||
const groups = new Map()
|
||||
for (const report of reports) {
|
||||
const group = groups.get(report.area) ?? { area: report.area, count: 0, files: new Map() }
|
||||
group.count += 1
|
||||
group.files.set(report.filePath, (group.files.get(report.filePath) ?? 0) + 1)
|
||||
groups.set(report.area, group)
|
||||
}
|
||||
return [...groups.values()].sort((left, right) => right.count - left.count)
|
||||
}
|
||||
|
||||
function formatReports(_root, reports) {
|
||||
return reports
|
||||
.map(
|
||||
(report) =>
|
||||
`${report.filePath}:${report.line}:${report.column} ${report.kind}: ${JSON.stringify(report.text)}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function formatMarkdownReport(reports) {
|
||||
const groups = groupByArea(reports)
|
||||
const lines = [
|
||||
'# Localization Candidate Inventory',
|
||||
'',
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
'',
|
||||
`Total candidates: ${reports.length}`,
|
||||
'',
|
||||
'## Area Summary',
|
||||
''
|
||||
]
|
||||
|
||||
for (const group of groups) {
|
||||
lines.push(`- ${group.area}: ${group.count} candidates across ${group.files.size} files`)
|
||||
}
|
||||
|
||||
lines.push('', '## Candidates', '')
|
||||
for (const group of groups) {
|
||||
lines.push(`### ${group.area}`, '')
|
||||
for (const report of reports.filter((entry) => entry.area === group.area)) {
|
||||
lines.push(
|
||||
`- \`${report.filePath}:${report.line}:${report.column}\` ${report.kind}: ${JSON.stringify(report.text)}`
|
||||
)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
allowlistPath: path.join('config', 'localization-coverage-allowlist.json'),
|
||||
check: false,
|
||||
format: 'summary',
|
||||
outputPath: null,
|
||||
sourceRoot: path.join('src', 'renderer', 'src')
|
||||
}
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg === '--json') {
|
||||
options.format = 'json'
|
||||
} else if (arg === '--markdown') {
|
||||
options.format = 'markdown'
|
||||
} else if (arg === '--check') {
|
||||
options.check = true
|
||||
} else if (arg === '--allowlist') {
|
||||
options.allowlistPath = argv[index + 1] ?? options.allowlistPath
|
||||
index += 1
|
||||
} else if (arg === '--output') {
|
||||
options.outputPath = argv[index + 1] ?? null
|
||||
index += 1
|
||||
} else if (arg === '--source-root') {
|
||||
options.sourceRoot = argv[index + 1] ?? options.sourceRoot
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function candidateSignature(candidate) {
|
||||
return JSON.stringify({
|
||||
filePath: candidate.filePath,
|
||||
kind: candidate.kind,
|
||||
text: candidate.text,
|
||||
dynamic: candidate.dynamic
|
||||
})
|
||||
}
|
||||
|
||||
function countBySignature(reports) {
|
||||
const counts = new Map()
|
||||
for (const report of reports) {
|
||||
const signature = candidateSignature(report)
|
||||
counts.set(signature, (counts.get(signature) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
async function readAllowlist(root, allowlistPath) {
|
||||
const absolutePath = path.resolve(root, allowlistPath)
|
||||
const raw = await fs.readFile(absolutePath, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
function findNewCandidates(reports, allowlist) {
|
||||
const allowedCounts = new Map(
|
||||
allowlist.map((entry) => [
|
||||
JSON.stringify({
|
||||
filePath: entry.filePath,
|
||||
kind: entry.kind,
|
||||
text: entry.text,
|
||||
dynamic: entry.dynamic
|
||||
}),
|
||||
entry.count
|
||||
])
|
||||
)
|
||||
const seenCounts = countBySignature(reports)
|
||||
const newCandidates = []
|
||||
|
||||
for (const report of reports) {
|
||||
const signature = candidateSignature(report)
|
||||
const seenCount = seenCounts.get(signature) ?? 0
|
||||
const allowedCount = allowedCounts.get(signature) ?? 0
|
||||
if (seenCount > allowedCount) {
|
||||
newCandidates.push(report)
|
||||
seenCounts.set(signature, seenCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return newCandidates
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd(), argv = process.argv.slice(2)) {
|
||||
const options = parseArgs(argv)
|
||||
const absoluteSourceRoot = path.resolve(root, options.sourceRoot)
|
||||
const files = await collectSourceFiles(root, absoluteSourceRoot)
|
||||
const reports = []
|
||||
|
||||
for (const filePath of files) {
|
||||
const sourceText = await fs.readFile(filePath, 'utf8')
|
||||
reports.push(...collectLocalizationCandidates(filePath, sourceText, root))
|
||||
}
|
||||
|
||||
if (options.check) {
|
||||
const allowlist = await readAllowlist(root, options.allowlistPath)
|
||||
const newCandidates = findNewCandidates(reports, allowlist)
|
||||
if (newCandidates.length > 0) {
|
||||
console.error('New unlocalized renderer strings were found.')
|
||||
console.error('Localize them or add a reviewed exclusion to the localization allowlist.')
|
||||
console.error('')
|
||||
console.error(formatReports(root, newCandidates))
|
||||
return 1
|
||||
}
|
||||
console.log(`Localization coverage check passed with ${reports.length} allowlisted candidates.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
const output =
|
||||
options.format === 'json'
|
||||
? `${JSON.stringify(reports, null, 2)}\n`
|
||||
: options.format === 'markdown'
|
||||
? `${formatMarkdownReport(reports)}\n`
|
||||
: `${reports.length} localization candidates in ${files.length} files.\n${groupByArea(
|
||||
reports
|
||||
)
|
||||
.map((group) => `${group.area}: ${group.count}`)
|
||||
.join('\n')}\n`
|
||||
|
||||
if (options.outputPath) {
|
||||
await fs.mkdir(path.dirname(path.resolve(root, options.outputPath)), { recursive: true })
|
||||
await fs.writeFile(path.resolve(root, options.outputPath), output)
|
||||
} else {
|
||||
process.stdout.write(output)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
compareBenchmarkArtifacts,
|
||||
formatBenchmarkComparisonMarkdown,
|
||||
parseBenchmarkComparisonArgs
|
||||
} from './compare-benchmark-artifacts.mjs'
|
||||
|
||||
const scriptPath = 'config/scripts/compare-benchmark-artifacts.mjs'
|
||||
const tempDirs = []
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-benchmark-comparison-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function writeArtifact(dir, name, artifact) {
|
||||
const artifactPath = join(dir, name)
|
||||
writeFileSync(artifactPath, JSON.stringify(artifact))
|
||||
return artifactPath
|
||||
}
|
||||
|
||||
function comparePaths(baselinePath, candidatePath, extra = {}) {
|
||||
return compareBenchmarkArtifacts({
|
||||
baselinePath,
|
||||
candidatePath,
|
||||
now: () => new Date('2026-06-21T12:00:00.000Z'),
|
||||
title: 'Test Compare',
|
||||
...extra
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('benchmark artifact comparison', () => {
|
||||
it('parses required CLI flags and rejects missing paths', () => {
|
||||
expect(() => parseBenchmarkComparisonArgs(['--baseline'])).toThrow('--baseline requires a path')
|
||||
expect(() => parseBenchmarkComparisonArgs(['--candidate'])).toThrow(
|
||||
'--candidate requires a path'
|
||||
)
|
||||
expect(() => parseBenchmarkComparisonArgs(['--candidate', 'candidate.json'])).toThrow(
|
||||
'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline <path> --candidate <path> [--title <title>] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]'
|
||||
)
|
||||
})
|
||||
|
||||
it('compares startup-style summary median metrics and skips null candidates', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline.json', {
|
||||
label: 'baseline',
|
||||
summaryMedianMs: {
|
||||
missingLater: 5,
|
||||
spawnToAppReady: 25,
|
||||
totalToDidFinishLoad: 100
|
||||
}
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'candidate.json', {
|
||||
label: 'candidate',
|
||||
summaryMedianMs: {
|
||||
missingLater: null,
|
||||
spawnToAppReady: 30,
|
||||
totalToDidFinishLoad: 40
|
||||
}
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath)
|
||||
|
||||
expect(comparison.schemaVersion).toBe(1)
|
||||
expect(comparison.createdAt).toBe('2026-06-21T12:00:00.000Z')
|
||||
expect(comparison.baseline.label).toBe('baseline')
|
||||
expect(comparison.candidate.label).toBe('candidate')
|
||||
expect(
|
||||
comparison.metrics.find((metric) => metric.key === 'totalToDidFinishLoad')
|
||||
).toMatchObject({
|
||||
absoluteDelta: -60,
|
||||
baseline: 100,
|
||||
candidate: 40,
|
||||
percentDelta: -60,
|
||||
status: 'improved',
|
||||
unit: 'ms'
|
||||
})
|
||||
expect(comparison.metrics.find((metric) => metric.key === 'spawnToAppReady')).toMatchObject({
|
||||
status: 'regressed'
|
||||
})
|
||||
expect(comparison.skippedMetrics).toContainEqual({
|
||||
key: 'missingLater',
|
||||
reason: 'missing candidate metric'
|
||||
})
|
||||
})
|
||||
|
||||
it('compares numeric Playwright annotation metrics and omits metadata fields', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline-playwright.json', {
|
||||
suites: [
|
||||
{
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-scale-same-workspace-50',
|
||||
description:
|
||||
'panes=50 frames=60 median=80.0ms worst=120.0ms rendererQueuedChars=1000 samples=1,2'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'candidate-playwright.json', {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-scale-same-workspace-50',
|
||||
description:
|
||||
'panes=50 frames=60 median=60.0ms worst=100.0ms rendererQueuedChars=800 samples=1,2'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath)
|
||||
const metricKeys = comparison.metrics.map((metric) => metric.key)
|
||||
|
||||
expect(metricKeys).toContain('opencode-scale-same-workspace-50.median')
|
||||
expect(metricKeys).toContain('opencode-scale-same-workspace-50.rendererQueuedChars')
|
||||
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.panes')
|
||||
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.frames')
|
||||
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.samples')
|
||||
expect(
|
||||
comparison.metrics.find((metric) => metric.key === 'opencode-scale-same-workspace-50.median')
|
||||
).toMatchObject({ status: 'improved', unit: 'ms' })
|
||||
})
|
||||
|
||||
it('aggregates duplicate Playwright scenario metrics before comparison', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline-playwright-duplicates.json', {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-duplicate',
|
||||
description: 'median=80.0ms rendererQueuedChars=1000'
|
||||
},
|
||||
{
|
||||
type: 'opencode-duplicate',
|
||||
description: 'median=100.0ms rendererQueuedChars=1400'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'candidate-playwright-duplicates.json', {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-duplicate',
|
||||
description: 'median=60.0ms rendererQueuedChars=800'
|
||||
},
|
||||
{
|
||||
type: 'opencode-duplicate',
|
||||
description: 'median=70.0ms rendererQueuedChars=1000'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath)
|
||||
const duplicateMedianMetrics = comparison.metrics.filter(
|
||||
(metric) => metric.key === 'opencode-duplicate.median'
|
||||
)
|
||||
|
||||
expect(duplicateMedianMetrics).toHaveLength(1)
|
||||
expect(duplicateMedianMetrics[0]).toMatchObject({
|
||||
absoluteDelta: -25,
|
||||
baseline: 90,
|
||||
candidate: 65,
|
||||
percentDelta: -27.8,
|
||||
status: 'improved',
|
||||
unit: 'ms'
|
||||
})
|
||||
expect(
|
||||
comparison.metrics.filter((metric) => metric.key === 'opencode-duplicate.rendererQueuedChars')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips unit mismatches instead of comparing incompatible metrics', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline-playwright-units.json', {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-units',
|
||||
description: 'median=80.0ms rendererQueuedChars=1000'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'candidate-playwright-units.json', {
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-units',
|
||||
description: 'median=60 rendererQueuedChars=800'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath)
|
||||
|
||||
expect(comparison.metrics.map((metric) => metric.key)).toEqual([
|
||||
'opencode-units.rendererQueuedChars'
|
||||
])
|
||||
expect(comparison.skippedMetrics).toContainEqual({
|
||||
key: 'opencode-units.median',
|
||||
reason: 'unit mismatch (ms vs count)'
|
||||
})
|
||||
})
|
||||
|
||||
it('supports higher-is-better metrics for generic summary artifacts', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'generic-baseline.json', {
|
||||
summary: {
|
||||
totalBytes: 2048,
|
||||
totalCpuPercent: { mean: 80 },
|
||||
throughput: 10
|
||||
}
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'generic-candidate.json', {
|
||||
summary: {
|
||||
totalBytes: 1024,
|
||||
totalCpuPercent: { mean: 70 },
|
||||
throughput: 12
|
||||
}
|
||||
})
|
||||
|
||||
const comparison = comparePaths(baselinePath, candidatePath, {
|
||||
higherIsBetter: new Set(['summary.throughput'])
|
||||
})
|
||||
|
||||
expect(comparison.metrics.find((metric) => metric.key === 'summary.throughput')).toMatchObject({
|
||||
direction: 'higher-is-better',
|
||||
status: 'improved'
|
||||
})
|
||||
expect(comparison.metrics.find((metric) => metric.key === 'summary.totalBytes')).toMatchObject({
|
||||
unit: 'bytes'
|
||||
})
|
||||
expect(
|
||||
comparison.metrics.find((metric) => metric.key === 'summary.totalCpuPercent.mean')
|
||||
).toMatchObject({
|
||||
unit: '%'
|
||||
})
|
||||
})
|
||||
|
||||
it('writes Markdown and JSON reports from the CLI while printing Markdown', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline.json', {
|
||||
label: 'baseline',
|
||||
summaryMedianMs: { totalToDidFinishLoad: 100 }
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'candidate.json', {
|
||||
label: 'candidate',
|
||||
summaryMedianMs: { totalToDidFinishLoad: 40 }
|
||||
})
|
||||
const markdownPath = join(dir, 'nested', 'comparison.md')
|
||||
const jsonPath = join(dir, 'nested', 'comparison.json')
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
scriptPath,
|
||||
'--baseline',
|
||||
baselinePath,
|
||||
'--candidate',
|
||||
candidatePath,
|
||||
'--title',
|
||||
'Test Compare',
|
||||
'--output',
|
||||
markdownPath,
|
||||
'--json-output',
|
||||
jsonPath
|
||||
],
|
||||
{ cwd: process.cwd(), encoding: 'utf8' }
|
||||
)
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('| Metric | Baseline | Candidate | Delta | Delta % | Result |')
|
||||
expect(result.stdout).toContain(
|
||||
'| totalToDidFinishLoad | 100.0ms | 40.0ms | -60.0ms | -60.0% | improved |'
|
||||
)
|
||||
expect(existsSync(markdownPath)).toBe(true)
|
||||
expect(existsSync(jsonPath)).toBe(true)
|
||||
expect(JSON.parse(readFileSync(jsonPath, 'utf8')).schemaVersion).toBe(1)
|
||||
})
|
||||
|
||||
it('redacts absolute input paths from generated reports', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'absolute-baseline.json', {
|
||||
label: 'baseline',
|
||||
summaryMedianMs: { totalToDidFinishLoad: 100 }
|
||||
})
|
||||
const candidatePath = writeArtifact(dir, 'absolute-candidate.json', {
|
||||
label: 'candidate',
|
||||
summaryMedianMs: { totalToDidFinishLoad: 40 }
|
||||
})
|
||||
const markdownPath = join(dir, 'comparison.md')
|
||||
const jsonPath = join(dir, 'comparison.json')
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
scriptPath,
|
||||
'--baseline',
|
||||
baselinePath,
|
||||
'--candidate',
|
||||
candidatePath,
|
||||
'--output',
|
||||
markdownPath,
|
||||
'--json-output',
|
||||
jsonPath
|
||||
],
|
||||
{ cwd: process.cwd(), encoding: 'utf8' }
|
||||
)
|
||||
const json = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||
const markdown = readFileSync(markdownPath, 'utf8')
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(json.baseline.path).toBe('absolute-baseline.json')
|
||||
expect(json.candidate.path).toBe('absolute-candidate.json')
|
||||
expect(markdown).not.toContain(dir)
|
||||
expect(result.stdout).not.toContain(dir)
|
||||
})
|
||||
|
||||
it('escapes artifact-controlled Markdown fields in reports', () => {
|
||||
const markdown = formatBenchmarkComparisonMarkdown({
|
||||
title: 'Compare\n[Injected](https://example.test)',
|
||||
createdAt: '2026-06-21T12:00:00.000Z',
|
||||
baseline: { label: 'base\n<label>', path: 'base|path', kind: 'summary' },
|
||||
candidate: { label: 'candidate', path: 'candidate.md', kind: 'summary' },
|
||||
metrics: [
|
||||
{
|
||||
key: 'summary.value|with-pipe',
|
||||
unit: '',
|
||||
baseline: 1,
|
||||
candidate: 2,
|
||||
absoluteDelta: 1,
|
||||
percentDelta: 100,
|
||||
status: 'regressed'
|
||||
}
|
||||
],
|
||||
skippedMetrics: [{ key: 'missing\nmetric', reason: 'missing | candidate' }]
|
||||
})
|
||||
|
||||
expect(markdown).toContain('# Compare \\[Injected\\]\\(https://example\\.test\\)')
|
||||
expect(markdown).toContain('Baseline: base \\<label\\> (base|path)')
|
||||
expect(markdown).toContain(
|
||||
'| summary\\.value\\|with\\-pipe | 1.0 | 2.0 | 1.0 | 100.0% | regressed |'
|
||||
)
|
||||
expect(markdown).toContain('- missing metric: missing | candidate')
|
||||
})
|
||||
|
||||
it('fails unsupported artifacts in the CLI', () => {
|
||||
const dir = makeTempDir()
|
||||
const baselinePath = writeArtifact(dir, 'baseline.json', { hello: 'world' })
|
||||
const candidatePath = writeArtifact(dir, 'candidate.json', { hello: 'world' })
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--baseline', baselinePath, '--candidate', candidatePath],
|
||||
{ cwd: process.cwd(), encoding: 'utf8' }
|
||||
)
|
||||
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stderr).toContain('unsupported benchmark artifact')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import {
|
||||
collectStringLeaves,
|
||||
repairCatalog,
|
||||
repairTranslatedValue,
|
||||
setLeaf,
|
||||
shouldPreserveEnglishValue
|
||||
} from './locale-translation-policy.mjs'
|
||||
|
||||
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
|
||||
const LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
|
||||
|
||||
const LOCALE_CONFIG = {
|
||||
zh: {
|
||||
targetLanguage: 'zh-CN',
|
||||
displayName: 'Simplified Chinese',
|
||||
cacheFile: '.zh-catalog-cache.json'
|
||||
},
|
||||
ko: {
|
||||
targetLanguage: 'ko',
|
||||
displayName: 'Korean',
|
||||
cacheFile: '.ko-catalog-cache.json'
|
||||
},
|
||||
ja: {
|
||||
targetLanguage: 'ja',
|
||||
displayName: 'Japanese',
|
||||
cacheFile: '.ja-catalog-cache.json'
|
||||
},
|
||||
es: {
|
||||
targetLanguage: 'es',
|
||||
displayName: 'Spanish',
|
||||
cacheFile: '.es-catalog-cache.json'
|
||||
}
|
||||
}
|
||||
|
||||
function protectPlaceholders(text) {
|
||||
const tokens = []
|
||||
const protectedText = text.replace(PLACEHOLDER_RE, (match) => {
|
||||
const token = `__PH${tokens.length}__`
|
||||
tokens.push(match)
|
||||
return token
|
||||
})
|
||||
return { protectedText, tokens }
|
||||
}
|
||||
|
||||
function restorePlaceholders(text, tokens) {
|
||||
let result = text
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const patterns = [`__PH${index}__`, `__ PH ${index} __`, `__PH ${index}__`, `__ PH${index}__`]
|
||||
for (const pattern of patterns) {
|
||||
result = result.replaceAll(pattern, tokens[index])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function shouldSkipTranslation(text) {
|
||||
return shouldPreserveEnglishValue(text)
|
||||
}
|
||||
|
||||
async function translateText(text, targetLanguage) {
|
||||
const url = new URL('https://translate.googleapis.com/translate_a/single')
|
||||
url.searchParams.set('client', 'gtx')
|
||||
url.searchParams.set('sl', 'en')
|
||||
url.searchParams.set('tl', targetLanguage)
|
||||
url.searchParams.set('dt', 't')
|
||||
url.searchParams.set('q', text)
|
||||
|
||||
let lastError
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Translation request failed with status ${response.status}`)
|
||||
}
|
||||
const payload = await response.json()
|
||||
return payload[0].map((part) => part[0]).join('')
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, concurrency, mapper) {
|
||||
const results = Array.from({ length: items.length })
|
||||
let nextIndex = 0
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < items.length) {
|
||||
const currentIndex = nextIndex
|
||||
nextIndex += 1
|
||||
results[currentIndex] = await mapper(items[currentIndex], currentIndex)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()))
|
||||
return results
|
||||
}
|
||||
|
||||
async function loadCache(cachePath) {
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(cachePath, 'utf8'))
|
||||
return new Map(Object.entries(raw))
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCache(cachePath, cache) {
|
||||
const raw = Object.fromEntries(cache.entries())
|
||||
await fs.writeFile(cachePath, `${JSON.stringify(raw, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function parseLocaleArg(argv) {
|
||||
const localeFlagIndex = argv.indexOf('--locale')
|
||||
if (localeFlagIndex !== -1 && argv[localeFlagIndex + 1]) {
|
||||
return argv[localeFlagIndex + 1]
|
||||
}
|
||||
return argv[2]
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd(), locale = parseLocaleArg(process.argv)) {
|
||||
const config = LOCALE_CONFIG[locale]
|
||||
if (!config) {
|
||||
console.error(
|
||||
`Unsupported locale "${locale}". Supported: ${Object.keys(LOCALE_CONFIG).join(', ')}`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
const enPath = path.join(root, LOCALES_DIR, 'en.json')
|
||||
const localePath = path.join(root, LOCALES_DIR, `${locale}.json`)
|
||||
const cachePath = path.join(root, LOCALES_DIR, config.cacheFile)
|
||||
const enCatalog = JSON.parse(await fs.readFile(enPath, 'utf8'))
|
||||
const localeCatalog = structuredClone(enCatalog)
|
||||
const leaves = collectStringLeaves(enCatalog)
|
||||
const uniqueValues = [...new Set(leaves.map((leaf) => leaf.value))]
|
||||
const cache = await loadCache(cachePath)
|
||||
const toTranslate = uniqueValues.filter(
|
||||
(value) => !shouldSkipTranslation(value) && !cache.has(value)
|
||||
)
|
||||
|
||||
console.log(
|
||||
`Translating ${toTranslate.length} unique strings to ${config.displayName} (${cache.size} cached)...`
|
||||
)
|
||||
|
||||
let completed = 0
|
||||
await mapWithConcurrency(toTranslate, 2, async (value) => {
|
||||
completed += 1
|
||||
if (completed % 25 === 0) {
|
||||
console.log(` ${completed}/${toTranslate.length}`)
|
||||
await saveCache(cachePath, cache)
|
||||
}
|
||||
const { protectedText, tokens } = protectPlaceholders(value)
|
||||
const translated = await translateText(protectedText, config.targetLanguage)
|
||||
const restored = restorePlaceholders(translated, tokens)
|
||||
cache.set(
|
||||
value,
|
||||
repairTranslatedValue({ key: '', enValue: value, localeValue: restored, locale })
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
})
|
||||
|
||||
for (const value of uniqueValues) {
|
||||
if (shouldSkipTranslation(value) && !cache.has(value)) {
|
||||
cache.set(value, value)
|
||||
}
|
||||
}
|
||||
|
||||
await saveCache(cachePath, cache)
|
||||
|
||||
for (const leaf of leaves) {
|
||||
const cached = cache.get(leaf.value) ?? leaf.value
|
||||
setLeaf(
|
||||
localeCatalog,
|
||||
leaf.key,
|
||||
repairTranslatedValue({
|
||||
key: leaf.key,
|
||||
enValue: leaf.value,
|
||||
localeValue: cached,
|
||||
locale
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
repairCatalog(enCatalog, localeCatalog, locale)
|
||||
|
||||
await fs.writeFile(localePath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
|
||||
console.log(`Wrote ${localePath}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { main as bootstrapLocaleCatalog } from './bootstrap-locale-catalog.mjs'
|
||||
|
||||
export async function main(root = process.cwd()) {
|
||||
return bootstrapLocaleCatalog(root, 'zh')
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { chmodSync, copyFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../..')
|
||||
const packagePath = path.join(repoRoot, 'native', 'computer-use-macos')
|
||||
const binaryPath = path.join(packagePath, '.build', 'release', 'orca-computer-use-macos')
|
||||
const appPath = path.join(packagePath, '.build', 'release', 'Orca Computer Use.app')
|
||||
const appExecutablePath = path.join(appPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
|
||||
const appIconPath = path.join(appPath, 'Contents', 'Resources', 'AppIcon.icns')
|
||||
const entitlementsPath = path.join(
|
||||
repoRoot,
|
||||
'resources',
|
||||
'build',
|
||||
'entitlements.computer-use.mac.plist'
|
||||
)
|
||||
const bundleId = process.env.ORCA_COMPUTER_MACOS_BUNDLE_ID ?? 'com.stablyai.orca.computer-use'
|
||||
const displayName = 'Orca Computer Use'
|
||||
const signingIdentity = resolveSigningIdentity()
|
||||
const universalTriples = ['arm64-apple-macosx', 'x86_64-apple-macosx']
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
buildUniversalBinary()
|
||||
chmodSync(binaryPath, 0o755)
|
||||
createHelperApp()
|
||||
|
||||
function buildUniversalBinary() {
|
||||
const builtBinaries = universalTriples.map((triple) => {
|
||||
run('swift', ['build', '-c', 'release', '--package-path', packagePath, '--triple', triple])
|
||||
return path.join(packagePath, '.build', triple, 'release', 'orca-computer-use-macos')
|
||||
})
|
||||
mkdirSync(path.dirname(binaryPath), { recursive: true })
|
||||
run('lipo', ['-create', ...builtBinaries, '-output', binaryPath])
|
||||
}
|
||||
|
||||
function createHelperApp() {
|
||||
rmSync(appPath, { recursive: true, force: true })
|
||||
mkdirSync(path.dirname(appExecutablePath), { recursive: true })
|
||||
mkdirSync(path.join(appPath, 'Contents', 'Resources'), { recursive: true })
|
||||
copyFileSync(binaryPath, appExecutablePath)
|
||||
copyFileSync(path.join(repoRoot, 'resources', 'build', 'icon.icns'), appIconPath)
|
||||
chmodSync(appExecutablePath, 0o755)
|
||||
writeFileSync(path.join(appPath, 'Contents', 'Info.plist'), infoPlist(), 'utf8')
|
||||
const signer = spawnSync('codesign', codesignArgs(signingIdentity, appPath), { stdio: 'inherit' })
|
||||
if (signer.signal) {
|
||||
process.kill(process.pid, signer.signal)
|
||||
}
|
||||
if (signer.status !== 0) {
|
||||
process.exit(signer.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
function codesignArgs(identity, targetPath) {
|
||||
const args = ['--force', '--deep', '--sign', identity]
|
||||
if (process.env.ORCA_MAC_RELEASE === '1' && identity !== '-') {
|
||||
args.push('--options', 'runtime', '--timestamp', '--entitlements', entitlementsPath)
|
||||
}
|
||||
args.push(targetPath)
|
||||
return args
|
||||
}
|
||||
|
||||
function resolveSigningIdentity() {
|
||||
const explicitIdentity = process.env.ORCA_COMPUTER_MACOS_SIGN_IDENTITY ?? process.env.CSC_NAME
|
||||
if (explicitIdentity) {
|
||||
return explicitIdentity
|
||||
}
|
||||
const identities = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
if (identities.status !== 0 || !identities.stdout) {
|
||||
return '-'
|
||||
}
|
||||
const developmentMatch = identities.stdout.match(/"([^"]*Apple Development:[^"]+)"/)
|
||||
if (process.env.ORCA_MAC_RELEASE !== '1' && developmentMatch) {
|
||||
return developmentMatch[1]
|
||||
}
|
||||
const releaseMatch =
|
||||
identities.stdout.match(/"([^"]*Developer ID Application:[^"]+)"/) ??
|
||||
identities.stdout.match(/"([^"]*Apple Distribution:[^"]+)"/)
|
||||
return releaseMatch?.[1] ?? developmentMatch?.[1] ?? '-'
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, { stdio: 'inherit' })
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
function infoPlist() {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>orca-computer-use-macos</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${escapePlist(bundleId)}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>${escapePlist(displayName)}</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${escapePlist(displayName)}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSAccessibilityUsageDescription</key>
|
||||
<string>Orca Computer Use needs Accessibility permission to read and interact with app interfaces when you ask Orca to use apps.</string>
|
||||
<key>NSScreenCaptureUsageDescription</key>
|
||||
<string>Orca Computer Use needs Screen Recording permission to capture app windows when you ask Orca to inspect your screen.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
}
|
||||
|
||||
function escapePlist(value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
runNodeScript('config/scripts/build-windows-cli-launcher.mjs')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
console.log(`[native-build] no macOS native computer build required on ${process.platform}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
runPnpmScript('build:computer-macos')
|
||||
runPnpmScript('build:notification-status-macos')
|
||||
process.exit(0)
|
||||
|
||||
function runPnpmScript(scriptName) {
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
const command = npmExecPath
|
||||
? process.execPath
|
||||
: process.platform === 'win32'
|
||||
? 'pnpm.cmd'
|
||||
: 'pnpm'
|
||||
const args = npmExecPath ? [npmExecPath, 'run', scriptName] : ['run', scriptName]
|
||||
const result = spawnSync(command, args, { stdio: 'inherit' })
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.status !== 0 || result.error) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
function runNodeScript(scriptPath) {
|
||||
const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' })
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.status !== 0 || result.error) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds the orca-notification-status helper binary.
|
||||
//
|
||||
// The helper reads UNUserNotificationCenter settings for the app it ships
|
||||
// inside (see native/notification-status-macos/main.swift). The target
|
||||
// CFBundleIdentifier is embedded as a __TEXT,__info_plist section so every
|
||||
// later `codesign --force` pass (electron-builder's signing, the dev runner's
|
||||
// ad-hoc deep sign) derives the correct code identifier automatically —
|
||||
// macOS keys notification records to that identifier.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = path.join(repoRoot, 'native', 'notification-status-macos', 'main.swift')
|
||||
const defaultOutputPath = path.join(
|
||||
repoRoot,
|
||||
'native',
|
||||
'notification-status-macos',
|
||||
'.build',
|
||||
'release',
|
||||
'orca-notification-status'
|
||||
)
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const bundleId = readArg('--bundle-id') ?? 'com.stablyai.orca'
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath
|
||||
// Why: dev launches only need the host architecture; release builds ship a
|
||||
// universal binary matching the app's x64 + arm64 targets.
|
||||
const singleArch = args.includes('--single-arch')
|
||||
|
||||
const workDir = path.join(tmpdir(), `orca-notification-status-${process.pid}`)
|
||||
mkdirSync(workDir, { recursive: true })
|
||||
try {
|
||||
const plistPath = path.join(workDir, 'Info.plist')
|
||||
writeFileSync(plistPath, embeddedInfoPlist(bundleId), 'utf8')
|
||||
const triples = singleArch
|
||||
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
|
||||
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
|
||||
const builtBinaries = triples.map((triple) => {
|
||||
const output = path.join(workDir, `orca-notification-status-${triple}`)
|
||||
execFileSync(
|
||||
'swiftc',
|
||||
[
|
||||
'-O',
|
||||
sourcePath,
|
||||
'-target',
|
||||
triple.replace('-apple-macosx', '-apple-macosx11.0'),
|
||||
'-o',
|
||||
output,
|
||||
'-Xlinker',
|
||||
'-sectcreate',
|
||||
'-Xlinker',
|
||||
'__TEXT',
|
||||
'-Xlinker',
|
||||
'__info_plist',
|
||||
'-Xlinker',
|
||||
plistPath
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
return output
|
||||
})
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||
if (builtBinaries.length === 1) {
|
||||
execFileSync('cp', [builtBinaries[0], outputPath])
|
||||
} else {
|
||||
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
|
||||
}
|
||||
execFileSync('chmod', ['755', outputPath])
|
||||
} finally {
|
||||
rmSync(workDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function readArg(name) {
|
||||
const index = args.indexOf(name)
|
||||
return index >= 0 ? args[index + 1] : undefined
|
||||
}
|
||||
|
||||
function embeddedInfoPlist(identifier) {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${identifier}</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>orca-notification-status</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bundle the relay daemon and its crash-isolated watcher child per platform.
|
||||
*
|
||||
* The relay runs on remote hosts via `node relay.js`, so both outputs use
|
||||
* self-contained CommonJS bundles with no external dependencies beyond
|
||||
* Node.js built-ins. Native addons (node-pty, @parcel/watcher) are
|
||||
* marked external and expected to be installed on the remote or
|
||||
* gracefully degraded.
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const __dirname = import.meta.dirname
|
||||
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
|
||||
const ROOT = join(__dirname, '..', '..')
|
||||
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
|
||||
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
|
||||
|
||||
const PLATFORMS = [
|
||||
'linux-x64',
|
||||
'linux-arm64',
|
||||
'darwin-x64',
|
||||
'darwin-arm64',
|
||||
'win32-x64',
|
||||
'win32-arm64'
|
||||
]
|
||||
|
||||
const RELAY_VERSION = '0.1.0'
|
||||
|
||||
for (const platform of PLATFORMS) {
|
||||
const outDir = join(ROOT, 'out', 'relay', platform)
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
await build({
|
||||
entryPoints: [RELAY_ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'relay.js'),
|
||||
// Native addons cannot be bundled — they must exist on the remote host.
|
||||
// The relay gracefully degrades when they are absent.
|
||||
external: ['node-pty', '@parcel/watcher', 'electron'],
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [WATCHER_ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'relay-watcher.js'),
|
||||
external: ['@parcel/watcher'],
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
|
||||
// Why: include a content hash so the deploy check detects code changes
|
||||
// even when RELAY_VERSION hasn't been bumped. Hash both process artifacts
|
||||
// so a watcher-only change always deploys beside the matching relay host.
|
||||
const relayContent = readFileSync(join(outDir, 'relay.js'))
|
||||
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
|
||||
const hash = createHash('sha256')
|
||||
.update(relayContent)
|
||||
.update(watcherContent)
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
|
||||
|
||||
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
|
||||
}
|
||||
|
||||
// WSL agent-hook relay: a hooks-only guest receiver launched inside WSL
|
||||
// distros via wsl.exe. Pure Node built-ins (no node-pty/@parcel/watcher),
|
||||
// so a single platform-independent bundle suffices; it ships inside the
|
||||
// Windows app via the same out/relay extraResources mapping.
|
||||
{
|
||||
const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
|
||||
const outDir = join(ROOT, 'out', 'relay', 'wsl')
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
await build({
|
||||
entryPoints: [wslEntry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'wsl-agent-hook-relay.js'),
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
const content = readFileSync(join(outDir, 'wsl-agent-hook-relay.js'))
|
||||
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
|
||||
console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`)
|
||||
}
|
||||
|
||||
console.log('Relay build complete.')
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
|
||||
const compilerPath = findFrameworkCompiler(process.env)
|
||||
|
||||
if (!compilerPath) {
|
||||
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
const result = spawnSync(
|
||||
compilerPath,
|
||||
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
|
||||
{ cwd: repoRoot, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
function defaultOutputPath(projectRoot) {
|
||||
return join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe')
|
||||
}
|
||||
|
||||
function findFrameworkCompiler(env) {
|
||||
const windowsDirectory = env.WINDIR ?? env.SystemRoot
|
||||
if (!windowsDirectory) {
|
||||
return null
|
||||
}
|
||||
const candidates = [
|
||||
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
|
||||
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
|
||||
]
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? null
|
||||
}
|
||||
|
||||
function readArg(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
return index >= 0 ? process.argv[index + 1] : undefined
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const itWindows = process.platform === 'win32' ? it : it.skip
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('Windows CLI launcher', () => {
|
||||
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
|
||||
try {
|
||||
const resourcesPath = join(appRoot, 'resources')
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
|
||||
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
|
||||
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
|
||||
mkdirSync(dirname(cliPath), { recursive: true })
|
||||
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
|
||||
writeFileSync(
|
||||
cliPath,
|
||||
`process.stdout.write(JSON.stringify({
|
||||
argv: process.argv.slice(2),
|
||||
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
|
||||
nodeOptions: process.env.NODE_OPTIONS ?? null,
|
||||
orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null
|
||||
}))\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
||||
|
||||
const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two'
|
||||
const powershell = spawnSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'& $env:ORCA_TEST_LAUNCHER orchestration send --body $env:ORCA_TEST_BODY --json'
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: '--no-warnings',
|
||||
ORCA_TEST_BODY: body,
|
||||
ORCA_TEST_LAUNCHER: launcherPath
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(powershell.status, powershell.stderr).toBe(0)
|
||||
expect(JSON.parse(powershell.stdout)).toEqual({
|
||||
argv: ['orchestration', 'send', '--body', body, '--json'],
|
||||
electronRunAsNode: '1',
|
||||
nodeOptions: null,
|
||||
orcaNodeOptions: '--no-warnings'
|
||||
})
|
||||
} finally {
|
||||
rmSync(appRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const __dirname = import.meta.dirname
|
||||
const ROOT = path.join(__dirname, '..', '..')
|
||||
const FEATURE_WALL_ASSET_DIR = path.join(ROOT, 'resources', 'onboarding', 'feature-wall')
|
||||
const MAX_BYTES = 11 * 1024 * 1024
|
||||
const MEDIA_TILE_IDS = [
|
||||
'tile-01',
|
||||
'tile-02',
|
||||
'tile-03',
|
||||
'tile-04',
|
||||
'tile-05',
|
||||
'tile-06',
|
||||
'tile-07',
|
||||
'tile-08',
|
||||
'tile-09',
|
||||
'tile-10',
|
||||
'tile-11',
|
||||
'tile-12'
|
||||
]
|
||||
const EXPECTED_FILES = MEDIA_TILE_IDS.flatMap((id) => [
|
||||
`${id}.gif`,
|
||||
`${id}.poster.jpg`,
|
||||
`${id}.recorded-at.json`
|
||||
])
|
||||
|
||||
async function collectFiles(dir) {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const files = []
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectFiles(fullPath)))
|
||||
} else if (entry.isFile()) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
const files = await collectFiles(FEATURE_WALL_ASSET_DIR)
|
||||
const fileNames = new Set(files.map((file) => path.relative(FEATURE_WALL_ASSET_DIR, file)))
|
||||
const missingFiles = EXPECTED_FILES.filter((file) => !fileNames.has(file))
|
||||
if (missingFiles.length > 0) {
|
||||
// Why: a byte-budget-only check lets an empty asset directory pass, which
|
||||
// ships the feature tour as text-only cards instead of the recorded media.
|
||||
console.error(`Feature wall assets are missing: ${missingFiles.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let totalBytes = 0
|
||||
for (const file of files) {
|
||||
const fileStat = await stat(file)
|
||||
totalBytes += fileStat.size
|
||||
}
|
||||
|
||||
if (totalBytes > MAX_BYTES) {
|
||||
const totalMb = (totalBytes / 1024 / 1024).toFixed(2)
|
||||
const maxMb = (MAX_BYTES / 1024 / 1024).toFixed(2)
|
||||
console.error(
|
||||
`Feature wall assets are ${totalMb} MB, which exceeds the ${maxMb} MB installer budget.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Feature wall assets: ${(totalBytes / 1024 / 1024).toFixed(2)} MB / ${(
|
||||
MAX_BYTES /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(2)} MB`
|
||||
)
|
||||
@@ -0,0 +1,254 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// Ratchet gate for the oxlint `max-lines` rule.
|
||||
//
|
||||
// oxlint already fails any file that exceeds max-lines WITHOUT a suppression, so
|
||||
// the only way a file grows past the budget is by adding an `eslint/oxlint-disable
|
||||
// max-lines` comment or a per-file `max-lines` bump in mobile/.oxlintrc.json. This
|
||||
// check freezes the set of files currently allowed to do that (the baseline) and
|
||||
// fails CI when a NEW bypass appears — the existing over-limit files are
|
||||
// grandfathered; new ones must split instead. The baseline may only shrink.
|
||||
|
||||
const BASELINE_PATH = 'config/max-lines-baseline.txt'
|
||||
const MOBILE_CONFIG_PATH = 'mobile/.oxlintrc.json'
|
||||
// These two files legitimately contain the directive text as data (regex, fixtures),
|
||||
// so scanning them would self-flag. The ratchet does not police itself.
|
||||
const SELF_FILES = new Set([
|
||||
'config/scripts/check-max-lines-ratchet.mjs',
|
||||
'config/scripts/check-max-lines-ratchet.test.mjs'
|
||||
])
|
||||
|
||||
// Default max-lines budgets from .oxlintrc.json (counted lines).
|
||||
export function defaultLimitForPath(p) {
|
||||
if (/\.(test|spec)\.(ts|tsx)$/.test(p)) {
|
||||
return 800
|
||||
}
|
||||
if (p.endsWith('.tsx')) {
|
||||
return 400
|
||||
}
|
||||
if (p.endsWith('.mjs')) {
|
||||
return 600
|
||||
}
|
||||
return 300
|
||||
}
|
||||
|
||||
// True if the source contains an eslint/oxlint disable directive listing `max-lines`
|
||||
// (block or line, bare or compound, with or without a `-- Why:` reason).
|
||||
export function hasMaxLinesDisable(sourceText) {
|
||||
const re = /(?:eslint|oxlint)-disable(?:-next-line|-line)?\b([^\n]*)/g
|
||||
let m
|
||||
while ((m = re.exec(sourceText)) !== null) {
|
||||
let rules = m[1]
|
||||
rules = rules.split('--')[0] // strip the reason
|
||||
const close = rules.indexOf('*/')
|
||||
if (close !== -1) {
|
||||
rules = rules.slice(0, close) // strip block-comment tail
|
||||
}
|
||||
if (/\bmax-lines\b/.test(rules)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Per-file `max-lines` bumps in mobile/.oxlintrc.json whose `max` exceeds the
|
||||
// default for that glob (a lower `max` is stricter, not a bypass).
|
||||
export function collectMobileBumps(configText) {
|
||||
const cfg = JSON.parse(configText)
|
||||
const bumps = []
|
||||
for (const override of cfg.overrides ?? []) {
|
||||
const rule = override.rules?.['max-lines']
|
||||
if (!Array.isArray(rule) || typeof rule[1]?.max !== 'number') {
|
||||
continue
|
||||
}
|
||||
for (const glob of override.files ?? []) {
|
||||
if (rule[1].max > defaultLimitForPath(glob)) {
|
||||
bumps.push(`mobile-config ${glob}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return bumps
|
||||
}
|
||||
|
||||
export function parseBaseline(text) {
|
||||
return new Set(
|
||||
text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('#'))
|
||||
)
|
||||
}
|
||||
|
||||
export function diffBaseline(current, baseline) {
|
||||
const cur = new Set(current)
|
||||
const base = baseline instanceof Set ? baseline : new Set(baseline)
|
||||
const added = [...cur].filter((e) => !base.has(e)).sort()
|
||||
const stale = [...base].filter((e) => !cur.has(e)).sort()
|
||||
return { added, stale }
|
||||
}
|
||||
|
||||
// Collect every current suppression entry from the tracked tree.
|
||||
export function collectCurrentSuppressions(root = process.cwd()) {
|
||||
const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mjs'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.filter((f) => !SELF_FILES.has(f))
|
||||
|
||||
const entries = []
|
||||
for (const rel of tracked) {
|
||||
let src
|
||||
try {
|
||||
src = fs.readFileSync(path.join(root, rel), 'utf8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (hasMaxLinesDisable(src)) {
|
||||
entries.push(`inline ${rel}`)
|
||||
}
|
||||
}
|
||||
|
||||
const mobileCfgPath = path.join(root, MOBILE_CONFIG_PATH)
|
||||
if (fs.existsSync(mobileCfgPath)) {
|
||||
entries.push(...collectMobileBumps(fs.readFileSync(mobileCfgPath, 'utf8')))
|
||||
}
|
||||
|
||||
return entries.sort()
|
||||
}
|
||||
|
||||
function printAddedFailure(added) {
|
||||
for (const entry of added) {
|
||||
console.error(`::error::New max-lines bypass not allowed: ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
|
||||
console.error('│ ❌ max-lines ratchet failed — a NEW file is trying to exceed the line cap. │')
|
||||
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
|
||||
console.error('')
|
||||
console.error(` ${added.length} file(s)/glob(s) newly bypass the oxlint \`max-lines\` rule:`)
|
||||
console.error('')
|
||||
for (const entry of added) {
|
||||
const [kind, ...rest] = entry.split(' ')
|
||||
const target = rest.join(' ')
|
||||
const how =
|
||||
kind === 'inline'
|
||||
? 'added an eslint/oxlint-disable max-lines comment'
|
||||
: 'added a per-file max-lines bump in mobile/.oxlintrc.json'
|
||||
console.error(` • ${target}\n ↳ ${how}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(' Orca caps file size (300 .ts / 400 .tsx / 600 .mjs / 800 test — non-blank,')
|
||||
console.error(
|
||||
' non-comment lines). Existing oversized files are grandfathered; NEW ones are not.'
|
||||
)
|
||||
console.error('')
|
||||
console.error(' ✅ Fix it: SPLIT the file into focused modules — do NOT suppress the rule.')
|
||||
console.error(' See AGENTS.md → "Lint Rules: Do Not Disable Max Lines".')
|
||||
console.error('')
|
||||
console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable')
|
||||
console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`)
|
||||
console.error('')
|
||||
}
|
||||
|
||||
function printStaleFailure(stale) {
|
||||
for (const entry of stale) {
|
||||
console.error(`::error::Stale max-lines baseline entry (prune it): ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
|
||||
console.error('│ ⚠️ max-lines baseline is out of date — nice work removing a bypass! │')
|
||||
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
|
||||
console.error('')
|
||||
console.error(` ${stale.length} baseline entr(y/ies) no longer have a max-lines suppression.`)
|
||||
console.error(
|
||||
' The baseline may only shrink, so these must be removed to keep re-adding blocked:'
|
||||
)
|
||||
console.error('')
|
||||
for (const entry of stale) {
|
||||
console.error(` • ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(` ✅ Fix it (one command): pnpm check:max-lines-ratchet --prune`)
|
||||
console.error('')
|
||||
}
|
||||
|
||||
export function main(root = process.cwd()) {
|
||||
const baselineFile = path.join(root, BASELINE_PATH)
|
||||
if (!fs.existsSync(baselineFile)) {
|
||||
console.error(
|
||||
`::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-max-lines-ratchet.mjs --init`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
|
||||
const current = collectCurrentSuppressions(root)
|
||||
const { added, stale } = diffBaseline(current, baseline)
|
||||
|
||||
if (added.length > 0) {
|
||||
printAddedFailure(added)
|
||||
if (stale.length > 0) {
|
||||
console.error(
|
||||
` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)`
|
||||
)
|
||||
printStaleFailure(stale)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
printStaleFailure(stale)
|
||||
return 1
|
||||
}
|
||||
console.log(
|
||||
`max-lines ratchet OK — ${current.length} grandfathered suppression(s), no new bypasses.`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
function writeBaseline(root, entries) {
|
||||
const header = [
|
||||
'# Files/globs currently allowed to exceed the oxlint `max-lines` budget.',
|
||||
'# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —',
|
||||
'# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").',
|
||||
'# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)',
|
||||
''
|
||||
].join('\n')
|
||||
fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const root = process.cwd()
|
||||
const arg = process.argv[2]
|
||||
if (arg === '--init') {
|
||||
// One-time bootstrap: capture the current suppression set as the baseline.
|
||||
const entries = collectCurrentSuppressions(root)
|
||||
writeBaseline(root, entries)
|
||||
console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`)
|
||||
process.exit(0)
|
||||
}
|
||||
if (arg === '--prune') {
|
||||
// Remove baseline entries whose suppression is gone (shrink only; never adds).
|
||||
const current = new Set(collectCurrentSuppressions(root))
|
||||
const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8'))
|
||||
const kept = [...baseline].filter((e) => current.has(e)).sort()
|
||||
const newlyAdded = [...current].filter((e) => !baseline.has(e))
|
||||
writeBaseline(root, kept)
|
||||
console.log(
|
||||
`Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).`
|
||||
)
|
||||
if (newlyAdded.length > 0) {
|
||||
console.error(
|
||||
`::error::--prune does not add entries; ${newlyAdded.length} new bypass(es) remain — split those files.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
process.exit(main(root))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
collectMobileBumps,
|
||||
defaultLimitForPath,
|
||||
diffBaseline,
|
||||
hasMaxLinesDisable,
|
||||
parseBaseline
|
||||
} from './check-max-lines-ratchet.mjs'
|
||||
|
||||
describe('hasMaxLinesDisable', () => {
|
||||
it('detects a bare block disable', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable max-lines */\nexport const a = 1\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects the oxlint spelling', () => {
|
||||
expect(hasMaxLinesDisable('/* oxlint-disable max-lines */\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a disable with a -- Why reason', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable max-lines -- Why: one owner. */\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a multi-line block where the reason wraps', () => {
|
||||
const src =
|
||||
'/* eslint-disable max-lines -- Why: this contract is\n * intentionally centralized. */\nimport x from "y"\n'
|
||||
expect(hasMaxLinesDisable(src)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects max-lines inside a compound rule list', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable no-control-regex, max-lines -- Why: x */\n')).toBe(
|
||||
true
|
||||
)
|
||||
expect(hasMaxLinesDisable('/* eslint-disable max-lines, no-control-regex */\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a line-scoped disable', () => {
|
||||
expect(hasMaxLinesDisable('const a = 1 // eslint-disable-line max-lines\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a disable for an unrelated rule', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable no-console */\n')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat "max-lines" appearing only in the reason text as a suppression', () => {
|
||||
// max-lines is after the `--`, so it is prose, not a suppressed rule.
|
||||
expect(
|
||||
hasMaxLinesDisable('/* eslint-disable no-console -- we could hit max-lines later */\n')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for ordinary source', () => {
|
||||
expect(hasMaxLinesDisable('export function f() {\n return 42\n}\n')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultLimitForPath', () => {
|
||||
it('uses 800 for tests, 400 for tsx, 600 for mjs, 300 otherwise', () => {
|
||||
expect(defaultLimitForPath('a/b.test.ts')).toBe(800)
|
||||
expect(defaultLimitForPath('a/b.spec.tsx')).toBe(800)
|
||||
expect(defaultLimitForPath('a/b.tsx')).toBe(400)
|
||||
expect(defaultLimitForPath('a/b.mjs')).toBe(600)
|
||||
expect(defaultLimitForPath('a/b.ts')).toBe(300)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectMobileBumps', () => {
|
||||
it('captures only overrides whose max exceeds the default for the glob', () => {
|
||||
const cfg = JSON.stringify({
|
||||
overrides: [
|
||||
{ files: ['app/h/*/tasks.tsx'], rules: { 'max-lines': ['error', { max: 14682 }] } }, // bump (>400)
|
||||
{
|
||||
files: ['src/terminal/TerminalWebView.tsx'],
|
||||
rules: { 'max-lines': ['error', { max: 379 }] }
|
||||
}, // stricter (<400), skip
|
||||
{ files: ['scripts/mock-server.ts'], rules: { 'max-lines': ['error', { max: 407 }] } } // bump (>300)
|
||||
]
|
||||
})
|
||||
expect(collectMobileBumps(cfg)).toEqual([
|
||||
'mobile-config app/h/*/tasks.tsx',
|
||||
'mobile-config scripts/mock-server.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores overrides without a max-lines rule', () => {
|
||||
const cfg = JSON.stringify({
|
||||
overrides: [{ files: ['a.tsx'], rules: { 'no-console': 'off' } }]
|
||||
})
|
||||
expect(collectMobileBumps(cfg)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBaseline', () => {
|
||||
it('drops comments and blank lines', () => {
|
||||
const b = parseBaseline('# header\n\ninline a.ts\nmobile-config x/*.tsx\n')
|
||||
expect(b).toEqual(new Set(['inline a.ts', 'mobile-config x/*.tsx']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffBaseline', () => {
|
||||
it('reports added and stale entries', () => {
|
||||
const { added, stale } = diffBaseline(
|
||||
['inline b.ts', 'inline c.ts'],
|
||||
new Set(['inline a.ts', 'inline b.ts'])
|
||||
)
|
||||
expect(added).toEqual(['inline c.ts']) // new bypass
|
||||
expect(stale).toEqual(['inline a.ts']) // suppression removed
|
||||
})
|
||||
|
||||
it('is clean when current matches baseline', () => {
|
||||
const { added, stale } = diffBaseline(['inline a.ts'], new Set(['inline a.ts']))
|
||||
expect(added).toEqual([])
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,431 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import { parse, printParseErrorCode } from 'jsonc-parser'
|
||||
|
||||
const MANIFEST_PATH = path.join('config', 'reliability-gates.jsonc')
|
||||
const RED_GREEN_STATUSES = new Set(['missing', 'partial', 'complete', 'not-required'])
|
||||
const FLAKE_STATUSES = new Set(['not-started', 'unknown', 'soaking', 'stable', 'flaky'])
|
||||
const PROTECTION_LEVELS = new Set(['none', 'partial', 'active'])
|
||||
const EVIDENCE_RUN_RESULTS = new Set(['passed', 'failed', 'skipped'])
|
||||
const EVIDENCE_RUNNERS = new Set(['local', 'ci', 'soak', 'manual'])
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
function hasNonEmptyStringArray(value) {
|
||||
return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString)
|
||||
}
|
||||
|
||||
function requireNonEmptyString(gate, field, failures) {
|
||||
if (!isNonEmptyString(gate[field])) {
|
||||
failures.push(`${gate.id ?? '<unknown>'}: ${field} must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function requireStringArray(gate, field, failures) {
|
||||
if (!hasNonEmptyStringArray(gate[field])) {
|
||||
failures.push(`${gate.id ?? '<unknown>'}: ${field} must be a non-empty string array`)
|
||||
}
|
||||
}
|
||||
|
||||
function requireStringArrayAllowEmpty(gate, field, failures) {
|
||||
if (!Array.isArray(gate[field]) || !gate[field].every(isNonEmptyString)) {
|
||||
failures.push(`${gate.id ?? '<unknown>'}: ${field} must be an array of strings`)
|
||||
}
|
||||
}
|
||||
|
||||
function requireNonNegativeNumber(record, field, owner, failures) {
|
||||
if (!Number.isFinite(record[field]) || record[field] < 0) {
|
||||
failures.push(`${owner}.${field} must be a non-negative number`)
|
||||
}
|
||||
}
|
||||
|
||||
function validatePolicy(manifest, failures) {
|
||||
if (!isRecord(manifest.policy)) {
|
||||
failures.push('policy must be an object')
|
||||
return new Set()
|
||||
}
|
||||
if (!hasNonEmptyStringArray(manifest.policy.maturityLevels)) {
|
||||
failures.push('policy.maturityLevels must be a non-empty string array')
|
||||
}
|
||||
if (!isRecord(manifest.policy.blockingPromotion)) {
|
||||
failures.push('policy.blockingPromotion must be an object')
|
||||
} else {
|
||||
for (const field of ['minimumSoakRuns', 'minimumSoakDays', 'maximumUnexplainedFlakes']) {
|
||||
requireNonNegativeNumber(
|
||||
manifest.policy.blockingPromotion,
|
||||
field,
|
||||
'policy.blockingPromotion',
|
||||
failures
|
||||
)
|
||||
}
|
||||
}
|
||||
return new Set(
|
||||
Array.isArray(manifest.policy.maturityLevels)
|
||||
? manifest.policy.maturityLevels.filter(isNonEmptyString)
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
function validateRuntimeBudget(gate, failures) {
|
||||
if (!isRecord(gate.runtimeBudget)) {
|
||||
failures.push(`${gate.id}: runtimeBudget must be an object`)
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(gate.runtimeBudget.p95Seconds) || gate.runtimeBudget.p95Seconds <= 0) {
|
||||
failures.push(`${gate.id}: runtimeBudget.p95Seconds must be a positive number`)
|
||||
}
|
||||
if (!isNonEmptyString(gate.runtimeBudget.scope)) {
|
||||
failures.push(`${gate.id}: runtimeBudget.scope must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateEvidence(gate, field, allowedStatuses, failures) {
|
||||
const evidence = gate[field]
|
||||
if (!isRecord(evidence)) {
|
||||
failures.push(`${gate.id}: ${field} must be an object`)
|
||||
return
|
||||
}
|
||||
if (!allowedStatuses.has(evidence.status)) {
|
||||
failures.push(`${gate.id}: ${field}.status is invalid`)
|
||||
}
|
||||
if (!isNonEmptyString(evidence.evidence)) {
|
||||
failures.push(`${gate.id}: ${field}.evidence must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function validatePerformanceBudget(gate, failures) {
|
||||
if (!isRecord(gate.performanceBudget)) {
|
||||
failures.push(`${gate.id}: performanceBudget must be an object`)
|
||||
return
|
||||
}
|
||||
if (typeof gate.performanceBudget.required !== 'boolean') {
|
||||
failures.push(`${gate.id}: performanceBudget.required must be boolean`)
|
||||
}
|
||||
if (!isNonEmptyString(gate.performanceBudget.evidence)) {
|
||||
failures.push(`${gate.id}: performanceBudget.evidence must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function commandUsesBrittleTestSelector(command) {
|
||||
return /(?:^|\s)(?:-t|--testNamePattern|--grep)(?:=|\s)/.test(command)
|
||||
}
|
||||
|
||||
function validateCommandCoverage(gate, failures) {
|
||||
if (!hasNonEmptyStringArray(gate.commands) || !hasNonEmptyStringArray(gate.testFiles)) {
|
||||
return
|
||||
}
|
||||
for (const testFile of gate.testFiles) {
|
||||
if (!gate.commands.some((command) => command.includes(testFile))) {
|
||||
failures.push(`${gate.id}: test file is not referenced by any gate command: ${testFile}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateProtection(gate, failures) {
|
||||
if (!PROTECTION_LEVELS.has(gate.protection)) {
|
||||
failures.push(`${gate.id}: protection must be one of none, partial, or active`)
|
||||
return
|
||||
}
|
||||
const hasCommands = hasNonEmptyStringArray(gate.commands)
|
||||
const hasTestFiles = hasNonEmptyStringArray(gate.testFiles)
|
||||
if (gate.protection === 'none' && (hasCommands || hasTestFiles)) {
|
||||
failures.push(`${gate.id}: protection none gates must not declare commands or testFiles`)
|
||||
}
|
||||
if (gate.protection === 'partial' && (!hasCommands || !hasTestFiles)) {
|
||||
failures.push(`${gate.id}: protection partial gates must declare commands and testFiles`)
|
||||
}
|
||||
if (gate.protection === 'active') {
|
||||
if (gate.maturity !== 'blocking') {
|
||||
failures.push(`${gate.id}: protection active is reserved for blocking gates`)
|
||||
}
|
||||
if (!hasCommands || !hasTestFiles) {
|
||||
failures.push(`${gate.id}: protection active gates must declare commands and testFiles`)
|
||||
}
|
||||
if (gate.flakeHistory?.status !== 'stable') {
|
||||
failures.push(`${gate.id}: protection active gates must have stable flakeHistory`)
|
||||
}
|
||||
if (!hasCompleteRedGreenEvidence(gate)) {
|
||||
failures.push(`${gate.id}: protection active gates must have complete red/green evidence`)
|
||||
}
|
||||
}
|
||||
if (!hasCommands && gate.protection !== 'none') {
|
||||
failures.push(`${gate.id}: commandless gates must declare protection none`)
|
||||
}
|
||||
}
|
||||
|
||||
function isIsoDate(value) {
|
||||
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
|
||||
}
|
||||
|
||||
// Why: cross-field validators run even after an earlier type check flagged a
|
||||
// malformed field, so coerce to an array before `.includes` to report the error
|
||||
// instead of throwing on hand-edited manifests.
|
||||
function asArray(value) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function hasCompleteRedGreenEvidence(gate) {
|
||||
return ['complete', 'not-required'].includes(gate.redGreenEvidence?.status)
|
||||
}
|
||||
|
||||
function validateEvidenceRun(gate, run, index, failures) {
|
||||
const owner = `${gate.id}: evidenceRuns[${index}]`
|
||||
if (!isRecord(run)) {
|
||||
failures.push(`${owner} must be an object`)
|
||||
return
|
||||
}
|
||||
if (!isIsoDate(run.date)) {
|
||||
failures.push(`${owner}.date must be YYYY-MM-DD`)
|
||||
}
|
||||
if (!EVIDENCE_RUNNERS.has(run.runner)) {
|
||||
failures.push(`${owner}.runner must be one of local, ci, soak, or manual`)
|
||||
}
|
||||
if (!isNonEmptyString(run.platform)) {
|
||||
failures.push(`${owner}.platform must be a non-empty string`)
|
||||
}
|
||||
if (!EVIDENCE_RUN_RESULTS.has(run.result)) {
|
||||
failures.push(`${owner}.result must be one of passed, failed, or skipped`)
|
||||
}
|
||||
if (!isNonEmptyString(run.command)) {
|
||||
failures.push(`${owner}.command must be a non-empty string`)
|
||||
} else if (!asArray(gate.commands).includes(run.command)) {
|
||||
failures.push(`${owner}.command must match one of the gate commands`)
|
||||
}
|
||||
if (!Number.isFinite(run.durationSeconds) || run.durationSeconds < 0) {
|
||||
failures.push(`${owner}.durationSeconds must be a non-negative number`)
|
||||
}
|
||||
if (!isNonEmptyString(run.summary)) {
|
||||
failures.push(`${owner}.summary must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateEvidenceRuns(gate, failures) {
|
||||
if (!Array.isArray(gate.evidenceRuns)) {
|
||||
failures.push(`${gate.id}: evidenceRuns must be an array`)
|
||||
return
|
||||
}
|
||||
gate.evidenceRuns.forEach((run, index) => validateEvidenceRun(gate, run, index, failures))
|
||||
if (
|
||||
['partial', 'active'].includes(gate.protection) &&
|
||||
!gate.evidenceRuns.some((run) => isRecord(run) && run.result === 'passed')
|
||||
) {
|
||||
failures.push(`${gate.id}: protection ${gate.protection} gates need a passed evidence run`)
|
||||
}
|
||||
if (gate.protection === 'none' && gate.evidenceRuns.length > 0) {
|
||||
failures.push(`${gate.id}: protection none gates must not declare evidenceRuns`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateAssertionRef(gate, ref, index, failures) {
|
||||
const owner = `${gate.id}: assertionRefs[${index}]`
|
||||
if (!isRecord(ref)) {
|
||||
failures.push(`${owner} must be an object`)
|
||||
return
|
||||
}
|
||||
if (!isNonEmptyString(ref.file)) {
|
||||
failures.push(`${owner}.file must be a non-empty string`)
|
||||
} else if (!asArray(gate.testFiles).includes(ref.file)) {
|
||||
failures.push(`${owner}.file must be one of the gate testFiles`)
|
||||
}
|
||||
if (!hasNonEmptyStringArray(ref.assertions)) {
|
||||
failures.push(`${owner}.assertions must be a non-empty string array`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateAssertionRefs(gate, failures) {
|
||||
if (!Array.isArray(gate.assertionRefs)) {
|
||||
failures.push(`${gate.id}: assertionRefs must be an array`)
|
||||
return
|
||||
}
|
||||
gate.assertionRefs.forEach((ref, index) => validateAssertionRef(gate, ref, index, failures))
|
||||
if (['partial', 'active'].includes(gate.protection) && gate.assertionRefs.length === 0) {
|
||||
failures.push(`${gate.id}: protection ${gate.protection} gates need assertionRefs`)
|
||||
}
|
||||
if (gate.protection === 'none' && gate.assertionRefs.length > 0) {
|
||||
failures.push(`${gate.id}: protection none gates must not declare assertionRefs`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateCoveredScope(gate, failures) {
|
||||
requireStringArrayAllowEmpty(gate, 'coveredPlatforms', failures)
|
||||
requireStringArrayAllowEmpty(gate, 'coveredProviders', failures)
|
||||
requireNonEmptyString(gate, 'coverageNotes', failures)
|
||||
|
||||
if (!Array.isArray(gate.coveredPlatforms) || !Array.isArray(gate.coveredProviders)) {
|
||||
return
|
||||
}
|
||||
for (const platform of gate.coveredPlatforms) {
|
||||
if (!asArray(gate.platforms).includes(platform)) {
|
||||
failures.push(`${gate.id}: covered platform is outside risk scope: ${platform}`)
|
||||
}
|
||||
}
|
||||
for (const provider of gate.coveredProviders) {
|
||||
if (!asArray(gate.providers).includes(provider)) {
|
||||
failures.push(`${gate.id}: covered provider is outside risk scope: ${provider}`)
|
||||
}
|
||||
}
|
||||
if (
|
||||
['partial', 'active'].includes(gate.protection) &&
|
||||
gate.evidenceRuns.some((run) => isRecord(run) && run.result === 'passed')
|
||||
) {
|
||||
const evidencePlatforms = new Set(
|
||||
gate.evidenceRuns
|
||||
.filter((run) => isRecord(run) && run.result === 'passed')
|
||||
.map((run) => run.platform)
|
||||
)
|
||||
for (const platform of evidencePlatforms) {
|
||||
if (!gate.coveredPlatforms.includes(platform)) {
|
||||
failures.push(
|
||||
`${gate.id}: coveredPlatforms must include passed evidence platform ${platform}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
gate.protection === 'none' &&
|
||||
(gate.coveredPlatforms.length > 0 || gate.coveredProviders.length > 0)
|
||||
) {
|
||||
failures.push(`${gate.id}: protection none gates must not declare covered scope`)
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(root, filePath) {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(root, filePath))
|
||||
return stat.isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function validateGate(gate, maturities, root) {
|
||||
const failures = []
|
||||
if (!isRecord(gate)) {
|
||||
return ['gate entry must be an object']
|
||||
}
|
||||
for (const field of ['id', 'title', 'owner', 'layer', 'invariant', 'oracle', 'demotionRule']) {
|
||||
requireNonEmptyString(gate, field, failures)
|
||||
}
|
||||
if (!maturities.has(gate.maturity)) {
|
||||
failures.push(`${gate.id ?? '<unknown>'}: maturity is invalid`)
|
||||
}
|
||||
for (const field of [
|
||||
'surfaces',
|
||||
'platforms',
|
||||
'providers',
|
||||
'motivatingLinks',
|
||||
'promotionCriteria'
|
||||
]) {
|
||||
requireStringArray(gate, field, failures)
|
||||
}
|
||||
if (!Array.isArray(gate.commands) || !gate.commands.every(isNonEmptyString)) {
|
||||
failures.push(`${gate.id}: commands must be an array of strings`)
|
||||
} else if (gate.commands.some(commandUsesBrittleTestSelector)) {
|
||||
failures.push(
|
||||
`${gate.id}: commands must not rely on title selectors (-t, --grep, or --testNamePattern)`
|
||||
)
|
||||
}
|
||||
if (!Array.isArray(gate.testFiles) || !gate.testFiles.every(isNonEmptyString)) {
|
||||
failures.push(`${gate.id}: testFiles must be an array of strings`)
|
||||
} else {
|
||||
for (const testFile of gate.testFiles) {
|
||||
if (!(await fileExists(root, testFile))) {
|
||||
failures.push(`${gate.id}: test file does not exist: ${testFile}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
validateCommandCoverage(gate, failures)
|
||||
if (['soak', 'blocking'].includes(gate.maturity)) {
|
||||
if (!hasNonEmptyStringArray(gate.commands)) {
|
||||
failures.push(`${gate.id}: ${gate.maturity} gates must declare at least one command`)
|
||||
}
|
||||
if (!hasNonEmptyStringArray(gate.testFiles)) {
|
||||
failures.push(`${gate.id}: ${gate.maturity} gates must declare at least one test file`)
|
||||
}
|
||||
if (!['soaking', 'stable'].includes(gate.flakeHistory?.status)) {
|
||||
failures.push(`${gate.id}: ${gate.maturity} gates must have soaking or stable flakeHistory`)
|
||||
}
|
||||
if (!hasCompleteRedGreenEvidence(gate)) {
|
||||
failures.push(`${gate.id}: ${gate.maturity} gates must have complete red/green evidence`)
|
||||
}
|
||||
}
|
||||
validateRuntimeBudget(gate, failures)
|
||||
validateEvidence(gate, 'flakeHistory', FLAKE_STATUSES, failures)
|
||||
validateEvidence(gate, 'redGreenEvidence', RED_GREEN_STATUSES, failures)
|
||||
validateProtection(gate, failures)
|
||||
validateEvidenceRuns(gate, failures)
|
||||
validateAssertionRefs(gate, failures)
|
||||
validateCoveredScope(gate, failures)
|
||||
validatePerformanceBudget(gate, failures)
|
||||
if (!Array.isArray(gate.knownGaps) || !gate.knownGaps.every(isNonEmptyString)) {
|
||||
failures.push(`${gate.id}: knownGaps must be an array of strings`)
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd()) {
|
||||
const manifestPath = path.join(root, MANIFEST_PATH)
|
||||
let raw
|
||||
try {
|
||||
raw = await fs.readFile(manifestPath, 'utf8')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`${MANIFEST_PATH}: unable to read manifest (${message})`)
|
||||
return 1
|
||||
}
|
||||
const parseErrors = []
|
||||
const manifest = parse(raw, parseErrors, { allowTrailingComma: true })
|
||||
if (parseErrors.length > 0) {
|
||||
for (const error of parseErrors) {
|
||||
console.error(
|
||||
`${MANIFEST_PATH}: JSONC parse error ${printParseErrorCode(error.error)} at offset ${error.offset}`
|
||||
)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
const failures = []
|
||||
if (!isRecord(manifest)) {
|
||||
failures.push('manifest must be an object')
|
||||
} else {
|
||||
if (manifest.schemaVersion !== 1) {
|
||||
failures.push('schemaVersion must be 1')
|
||||
}
|
||||
const maturities = validatePolicy(manifest, failures)
|
||||
if (!Array.isArray(manifest.gates) || manifest.gates.length === 0) {
|
||||
failures.push('gates must be a non-empty array')
|
||||
} else {
|
||||
const seenIds = new Set()
|
||||
for (const gate of manifest.gates) {
|
||||
if (isRecord(gate) && isNonEmptyString(gate.id)) {
|
||||
if (seenIds.has(gate.id)) {
|
||||
failures.push(`${gate.id}: duplicate gate id`)
|
||||
}
|
||||
seenIds.add(gate.id)
|
||||
}
|
||||
failures.push(...(await validateGate(gate, maturities, root)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
console.error(`Reliability gate manifest check failed with ${failures.length} issue(s):`)
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
console.log(`Reliability gate manifest check passed for ${manifest.gates.length} gate(s).`)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { main } from './check-reliability-gates.mjs'
|
||||
|
||||
const tempDirs = []
|
||||
|
||||
function makeTempRoot(manifest) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-reliability-gates-'))
|
||||
tempDirs.push(root)
|
||||
const configDir = path.join(root, 'config')
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
writeFileSync(path.join(configDir, 'reliability-gates.jsonc'), manifest, 'utf8')
|
||||
writeFileSync(path.join(root, 'some.test.ts'), '', 'utf8')
|
||||
return root
|
||||
}
|
||||
|
||||
function validManifest(overrides = {}) {
|
||||
return JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
policy: {
|
||||
maturityLevels: ['experimental', 'soak', 'blocking', 'accepted-gap', 'deprecated'],
|
||||
blockingPromotion: {
|
||||
minimumSoakRuns: 100,
|
||||
minimumSoakDays: 14,
|
||||
maximumUnexplainedFlakes: 0
|
||||
}
|
||||
},
|
||||
gates: [
|
||||
{
|
||||
id: 'terminal-session.snapshot-freshness',
|
||||
title: 'Stale liveness snapshots cannot close newer PTY bindings',
|
||||
maturity: 'soak',
|
||||
protection: 'partial',
|
||||
owner: 'terminal-runtime',
|
||||
layer: 'renderer-unit',
|
||||
surfaces: ['terminal lifecycle'],
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
providers: ['local', 'daemon'],
|
||||
coveredPlatforms: ['macos'],
|
||||
coveredProviders: ['local'],
|
||||
coverageNotes: 'One local macOS unit run covers the local decision-layer fixture only.',
|
||||
motivatingLinks: ['https://github.com/stablyai/orca/issues/6773'],
|
||||
invariant: 'A stale snapshot cannot close a newer binding.',
|
||||
oracle: 'The test rejects reconciliation when the binding is newer than the snapshot.',
|
||||
commands: ['pnpm exec vitest run some.test.ts'],
|
||||
testFiles: ['some.test.ts'],
|
||||
assertionRefs: [
|
||||
{
|
||||
file: 'some.test.ts',
|
||||
assertions: ['stale liveness snapshots do not close newer PTY bindings']
|
||||
}
|
||||
],
|
||||
evidenceRuns: [
|
||||
{
|
||||
date: '2026-07-02',
|
||||
runner: 'local',
|
||||
platform: 'macos',
|
||||
command: 'pnpm exec vitest run some.test.ts',
|
||||
result: 'passed',
|
||||
durationSeconds: 1.2,
|
||||
summary: '1 file passed.'
|
||||
}
|
||||
],
|
||||
runtimeBudget: {
|
||||
p95Seconds: 10,
|
||||
scope: 'local unit test'
|
||||
},
|
||||
flakeHistory: {
|
||||
status: 'soaking',
|
||||
evidence: 'Soak history exists.'
|
||||
},
|
||||
redGreenEvidence: {
|
||||
status: 'complete',
|
||||
evidence: 'Fails when the guard is removed and passes with the fix.'
|
||||
},
|
||||
performanceBudget: {
|
||||
required: true,
|
||||
evidence: 'Perf measurement is required before blocking promotion.'
|
||||
},
|
||||
promotionCriteria: ['Collect soak history.'],
|
||||
knownGaps: ['Needs an Electron survival test.'],
|
||||
demotionRule: 'Demote on unexplained flakes.',
|
||||
...overrides
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('check-reliability-gates', () => {
|
||||
it('accepts the checked-in manifest', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await expect(main(process.cwd())).resolves.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects soak gates without executable commands', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ commands: [] }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Reliability gate manifest check failed')
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('soak gates must declare at least one command')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects soak gates without test files', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ testFiles: [] }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('soak gates must declare at least one test file')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects command-backed gates that select tests by title', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({ maturity: 'experimental', commands: ['pnpm vitest -t flaky-title'] })
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('commands must not rely on title selectors')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects gates without a protection level', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const manifest = JSON.parse(validManifest())
|
||||
delete manifest.gates[0].protection
|
||||
const root = makeTempRoot(JSON.stringify(manifest, null, 2))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection must be one of none, partial, or active')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects commandless gates unless they declare protection none', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
protection: 'partial',
|
||||
commands: [],
|
||||
testFiles: [],
|
||||
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
|
||||
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('commandless gates must declare protection none')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects command-backed gates marked as no protection', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ maturity: 'experimental', protection: 'none' }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection none gates must not declare commands or testFiles')
|
||||
)
|
||||
})
|
||||
|
||||
it('reserves active protection for stable blocking gates', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ maturity: 'experimental', protection: 'active' }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection active is reserved for blocking gates')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects partial gates without a passed evidence run', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ maturity: 'experimental', evidenceRuns: [] }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection partial gates need a passed evidence run')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects evidence runs whose command is not declared by the gate', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
evidenceRuns: [
|
||||
{
|
||||
date: '2026-07-02',
|
||||
runner: 'local',
|
||||
platform: 'macos',
|
||||
command: 'pnpm exec vitest run other.test.ts',
|
||||
result: 'passed',
|
||||
durationSeconds: 1,
|
||||
summary: 'Wrong command.'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('evidenceRuns[0].command must match one of the gate commands')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects protection none gates with evidence runs', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
protection: 'none',
|
||||
commands: [],
|
||||
testFiles: [],
|
||||
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
|
||||
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection none gates must not declare evidenceRuns')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects partial gates without assertion refs', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ maturity: 'experimental', assertionRefs: [] }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection partial gates need assertionRefs')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects assertion refs outside declared test files', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
assertionRefs: [
|
||||
{
|
||||
file: 'other.test.ts',
|
||||
assertions: ['important invariant']
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('assertionRefs[0].file must be one of the gate testFiles')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects protection none gates with assertion refs', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
protection: 'none',
|
||||
commands: [],
|
||||
testFiles: [],
|
||||
evidenceRuns: [],
|
||||
assertionRefs: [
|
||||
{
|
||||
file: 'some.test.ts',
|
||||
assertions: ['planning entry must not look tested']
|
||||
}
|
||||
],
|
||||
coveredPlatforms: [],
|
||||
coveredProviders: [],
|
||||
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
|
||||
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection none gates must not declare assertionRefs')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects covered scope outside the risk scope', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
coveredPlatforms: ['ios'],
|
||||
coveredProviders: ['ssh']
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('covered platform is outside risk scope: ios')
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('covered provider is outside risk scope: ssh')
|
||||
)
|
||||
})
|
||||
|
||||
it('requires coveredPlatforms to include passed evidence platforms', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
coveredPlatforms: ['linux']
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('coveredPlatforms must include passed evidence platform macos')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects covered scope on protection none gates', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
protection: 'none',
|
||||
commands: [],
|
||||
testFiles: [],
|
||||
evidenceRuns: [],
|
||||
coveredPlatforms: ['macos'],
|
||||
coveredProviders: [],
|
||||
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
|
||||
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection none gates must not declare covered scope')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects declared test files that do not exist', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({ maturity: 'experimental', testFiles: ['missing.test.ts'] })
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test file does not exist: missing.test.ts')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects executable gates whose commands do not reference declared test files', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const command = 'pnpm exec vitest run'
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
commands: [command],
|
||||
evidenceRuns: [
|
||||
{
|
||||
date: '2026-07-02',
|
||||
runner: 'local',
|
||||
platform: 'macos',
|
||||
command,
|
||||
result: 'passed',
|
||||
durationSeconds: 1,
|
||||
summary: 'Command did not name the declared file.'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test file is not referenced by any gate command: some.test.ts')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects soak gates without mature flake and red/green evidence', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
flakeHistory: { status: 'unknown', evidence: 'No soak yet.' },
|
||||
redGreenEvidence: { status: 'partial', evidence: 'No saved artifact yet.' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('soak gates must have soaking or stable flakeHistory')
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('soak gates must have complete red/green evidence')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed JSONC', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot('{ "schemaVersion": 1, } trailing')
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('JSONC parse error'))
|
||||
})
|
||||
|
||||
it('rejects missing manifests with a structured error', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-reliability-gates-'))
|
||||
tempDirs.push(root)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('unable to read manifest'))
|
||||
})
|
||||
|
||||
it('uses policy maturity levels as the source of truth', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const manifest = JSON.parse(validManifest())
|
||||
manifest.policy.maturityLevels = ['experimental']
|
||||
const root = makeTempRoot(JSON.stringify(manifest, null, 2))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('maturity is invalid'))
|
||||
})
|
||||
|
||||
it('rejects malformed blocking promotion policy', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const manifest = JSON.parse(validManifest())
|
||||
manifest.policy.blockingPromotion.maximumUnexplainedFlakes = -1
|
||||
const root = makeTempRoot(JSON.stringify(manifest, null, 2))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'policy.blockingPromotion.maximumUnexplainedFlakes must be a non-negative number'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import { reportUnstyledScrollbars } from './styled-scrollbars/styled-scrollbar-jsx-check.mjs'
|
||||
export {
|
||||
plainClassName,
|
||||
reportUnstyledScrollbars
|
||||
} from './styled-scrollbars/styled-scrollbar-jsx-check.mjs'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__'])
|
||||
|
||||
export function normalizePath(root, filePath) {
|
||||
return path.relative(root, filePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function isSkippedFile(root, filePath) {
|
||||
const relative = normalizePath(root, filePath)
|
||||
if (relative.includes('.test.') || relative.includes('.spec.')) {
|
||||
return true
|
||||
}
|
||||
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
|
||||
}
|
||||
|
||||
async function collectSourceFiles(root, dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_PATH_PARTS.has(entry.name)) {
|
||||
files.push(...(await collectSourceFiles(root, fullPath)))
|
||||
}
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
|
||||
!isSkippedFile(root, fullPath)
|
||||
) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
async function collectUnstyledScrollbarReports(root) {
|
||||
const sourceRoot = path.join(root, 'src', 'renderer', 'src')
|
||||
const files = await collectSourceFiles(root, sourceRoot)
|
||||
const reports = []
|
||||
|
||||
for (const filePath of files) {
|
||||
const sourceText = await fs.readFile(filePath, 'utf8')
|
||||
reports.push(...reportUnstyledScrollbars(filePath, sourceText))
|
||||
}
|
||||
|
||||
return reports
|
||||
}
|
||||
|
||||
function formatReports(root, reports) {
|
||||
return reports
|
||||
.map(
|
||||
(report) =>
|
||||
`${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.text.replace(/\s+/g, ' ')}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd()) {
|
||||
const reports = await collectUnstyledScrollbarReports(root)
|
||||
if (reports.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
console.error('Renderer vertical scroll containers must use an Orca scrollbar style.')
|
||||
console.error('Put the scrollbar class in the same class literal as the vertical overflow class.')
|
||||
console.error('Use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.')
|
||||
console.error('')
|
||||
console.error(formatReports(root, reports))
|
||||
return 1
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { plainClassName, reportUnstyledScrollbars } from './check-styled-scrollbars.mjs'
|
||||
|
||||
describe('check-styled-scrollbars', () => {
|
||||
it('reports renderer vertical scroll containers without an Orca scrollbar style', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="max-h-64 overflow-y-auto" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts obvious styled vertical scroll containers', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not accept nonexistent scrollbar classes as Orca scrollbar styles', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-none" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails closed when a separate class composer argument supplies the scrollbar style', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto', 'scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts static class composer arguments when the same literal is styled', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails closed when a scrollbar class is only conditionally present', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example({ enabled }) { return <div className={cn('overflow-y-auto', enabled && 'scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts conditional branches when overflow and scrollbar live in the same class literal', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example({ enabled }) { return <div className={cn(enabled && 'overflow-y-auto scrollbar-sleek')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports vertical scroll inside arbitrary wrappers when the literal is unstyled', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={identity('overflow-y-auto')} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not require a vertical scrollbar style for horizontal-only overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <pre className="max-w-full overflow-x-auto" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not let responsive scrollbar variants satisfy unconditional overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="overflow-y-auto md:scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts matching responsive overflow and scrollbar variants', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="md:overflow-y-auto md:scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts unconditional scrollbar styles for responsive overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="md:overflow-y-auto scrollbar-sleek" /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports inline vertical overflow without an Orca scrollbar class', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div style={{ overflowY: 'auto' }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts inline vertical overflow with a stable Orca scrollbar class', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="scrollbar-editor" style={{ overflow: \'auto\' }} /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports inline vertical overflow when the scrollbar class is conditional or short-circuited', () => {
|
||||
for (const classNameExpression of [
|
||||
"enabled && 'scrollbar-sleek'",
|
||||
"enabled ? 'scrollbar-sleek' : undefined",
|
||||
"enabled || 'scrollbar-sleek'",
|
||||
"enabled ?? 'scrollbar-sleek'"
|
||||
]) {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
`export function Example({ enabled }) { return <div className={${classNameExpression}} style={{ overflowY: 'auto' }} /> }`
|
||||
)
|
||||
|
||||
expect(reports, classNameExpression).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports logical inline style spreads without an Orca scrollbar class', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example({ open }) { return <div style={{ ...(open && { overflowY: 'auto' }) }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports JSX spread className props with unstyled vertical overflow', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div {...{ className: 'overflow-y-auto' }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts JSX spread className props when the same literal is styled', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div {...{ className: 'overflow-y-auto scrollbar-sleek' }} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses later spread className props over earlier explicit className props', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
'export function Example() { return <div className="scrollbar-sleek" {...{ className: \'overflow-y-auto\' }} /> }'
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('supports variant helper className config', () => {
|
||||
const reports = reportUnstyledScrollbars(
|
||||
'Example.tsx',
|
||||
"export function Example() { return <div className={buttonVariants({ className: 'overflow-y-auto scrollbar-sleek' })} /> }"
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('normalizes Tailwind variants and important prefixes before matching', () => {
|
||||
expect(plainClassName('md:overflow-y-auto')).toBe('overflow-y-auto')
|
||||
expect(plainClassName('[&:hover]:overflow-y-auto')).toBe('overflow-y-auto')
|
||||
expect(plainClassName('md:!scrollbar-editor')).toBe('scrollbar-editor')
|
||||
expect(plainClassName('!scrollbar-editor')).toBe('scrollbar-editor')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { basename } from 'node:path'
|
||||
import { collectTerminalPerfRows, readJsonReport } from './terminal-perf-report-annotations.mjs'
|
||||
|
||||
const reportPaths = process.argv.slice(2)
|
||||
if (reportPaths[0] === '--') {
|
||||
reportPaths.shift()
|
||||
}
|
||||
|
||||
if (reportPaths.length === 0) {
|
||||
console.error(
|
||||
'Usage: node config/scripts/check-terminal-perf-report-budgets.mjs <playwright-json>...'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Why: these mirror the e2e regression ceilings so saved JSON reports can fail
|
||||
// in automation without rerunning Electron or changing the human summary table.
|
||||
const BUDGETS = {
|
||||
maxMedianKeyLatencyMs: 75,
|
||||
maxWorstKeyLatencyMs: 300,
|
||||
maxRevisitLatencyMs: 300,
|
||||
maxTimerDriftMs: 150,
|
||||
maxScrollLatencyMs: 150,
|
||||
maxRestoreLatencyMs: 1000,
|
||||
maxRendererQueuedChars: 2 * 1024 * 1024,
|
||||
maxRendererPeakQueuedChars: 2 * 1024 * 1024,
|
||||
maxRendererDroppedBacklogs: 0
|
||||
}
|
||||
|
||||
function parseMs(value, fieldName, row, failures) {
|
||||
if (value == null || value === '') {
|
||||
return null
|
||||
}
|
||||
const match = String(value).match(/^(-?\d+(?:\.\d+)?)ms$/)
|
||||
if (!match) {
|
||||
failures.push(`${row.source} ${row.scenario}: ${fieldName} value "${value}" is malformed`)
|
||||
return null
|
||||
}
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
function parseCount(value, fieldName, row, failures) {
|
||||
if (value == null || value === '') {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
failures.push(`${row.source} ${row.scenario}: ${fieldName} value "${value}" is malformed`)
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function addMaxFailure(failures, row, label, actual, budget, unit = '') {
|
||||
if (actual == null || actual <= budget) {
|
||||
return
|
||||
}
|
||||
failures.push(
|
||||
`${row.source} ${row.scenario}: ${label} ${actual}${unit} exceeded budget ${budget}${unit}`
|
||||
)
|
||||
}
|
||||
|
||||
function validateRow(row) {
|
||||
const failures = []
|
||||
let checkedMetricCount = 0
|
||||
const addBudgetCheck = (label, actual, budget, unit = '') => {
|
||||
if (actual != null) {
|
||||
checkedMetricCount += 1
|
||||
}
|
||||
addMaxFailure(failures, row, label, actual, budget, unit)
|
||||
}
|
||||
addBudgetCheck(
|
||||
'median typing latency',
|
||||
parseMs(row.median, 'median', row, failures),
|
||||
BUDGETS.maxMedianKeyLatencyMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'worst typing latency',
|
||||
parseMs(row.worst, 'worst', row, failures),
|
||||
BUDGETS.maxWorstKeyLatencyMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'revisit latency',
|
||||
parseMs(row.revisit, 'revisit', row, failures),
|
||||
BUDGETS.maxRevisitLatencyMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'timer drift',
|
||||
parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures),
|
||||
BUDGETS.maxTimerDriftMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'scroll latency',
|
||||
parseMs(row.scroll, 'scroll', row, failures),
|
||||
BUDGETS.maxScrollLatencyMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'restore latency',
|
||||
parseMs(row.restore, 'restore', row, failures),
|
||||
BUDGETS.maxRestoreLatencyMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'renderer queued chars',
|
||||
parseCount(row.rendererQueuedChars, 'rendererQueuedChars', row, failures),
|
||||
BUDGETS.maxRendererQueuedChars
|
||||
)
|
||||
addBudgetCheck(
|
||||
'renderer peak queued chars',
|
||||
parseCount(row.rendererPeakQueuedChars, 'rendererPeakQueuedChars', row, failures),
|
||||
BUDGETS.maxRendererPeakQueuedChars
|
||||
)
|
||||
addBudgetCheck(
|
||||
'renderer dropped backlogs',
|
||||
parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures),
|
||||
BUDGETS.maxRendererDroppedBacklogs
|
||||
)
|
||||
// Why: parked-memory rows carry heap/view-count metrics with no latency
|
||||
// budget; recognize them so memory-only scenarios pass the gate instead of
|
||||
// tripping the "no recognized budget metrics" guard.
|
||||
for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) {
|
||||
if (parseCount(row[fieldName], fieldName, row, failures) != null) {
|
||||
checkedMetricCount += 1
|
||||
}
|
||||
}
|
||||
if (checkedMetricCount === 0) {
|
||||
failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`)
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
const rows = reportPaths.flatMap((path) =>
|
||||
collectTerminalPerfRows(readJsonReport(path), basename(path))
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.error('No OpenCode terminal perf annotations found.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const failures = rows.flatMap(validateRow)
|
||||
if (failures.length > 0) {
|
||||
console.error(`Terminal perf budget check failed with ${failures.length} violation(s):`)
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Terminal perf budget check passed for ${rows.length} annotation row(s).`)
|
||||
@@ -0,0 +1,164 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const scriptPath = 'config/scripts/check-terminal-perf-report-budgets.mjs'
|
||||
const tempDirs = []
|
||||
|
||||
function writeReport(annotationDescription, annotationType = 'opencode-test') {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-report-'))
|
||||
tempDirs.push(dir)
|
||||
const reportPath = join(dir, 'report.json')
|
||||
writeFileSync(
|
||||
reportPath,
|
||||
JSON.stringify({
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: annotationType,
|
||||
description: annotationDescription
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
return reportPath
|
||||
}
|
||||
|
||||
function runChecker(reportPath) {
|
||||
return spawnSync(process.execPath, [scriptPath, reportPath], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('check-terminal-perf-report-budgets', () => {
|
||||
it('passes reports whose OpenCode terminal perf annotations stay within budgets', () => {
|
||||
const reportPath = writeReport(
|
||||
[
|
||||
'panes=51',
|
||||
'frames=180',
|
||||
'median=2.9ms',
|
||||
'worst=5.9ms',
|
||||
'revisit=42.0ms',
|
||||
'maxTimerDrift=12.1ms',
|
||||
'scroll=149.9ms',
|
||||
'restore=642.0ms',
|
||||
'rendererQueuedChars=0',
|
||||
'rendererPeakQueuedChars=2097152',
|
||||
'rendererDroppedBacklogs=0'
|
||||
].join(' ')
|
||||
)
|
||||
|
||||
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
|
||||
})
|
||||
|
||||
it('fails reports with over-budget metrics', () => {
|
||||
const reportPath = writeReport(
|
||||
[
|
||||
'panes=101',
|
||||
'frames=60',
|
||||
'median=76.0ms',
|
||||
'worst=301.0ms',
|
||||
'revisit=301.0ms',
|
||||
'maxTimerDrift=151.0ms',
|
||||
'scroll=151.0ms',
|
||||
'restore=1001.0ms',
|
||||
'rendererQueuedChars=2097153',
|
||||
'rendererPeakQueuedChars=2097153',
|
||||
'rendererDroppedBacklogs=1'
|
||||
].join(' ')
|
||||
)
|
||||
|
||||
const result = runChecker(reportPath)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('median typing latency 76ms exceeded budget 75ms')
|
||||
expect(result.stderr).toContain('worst typing latency 301ms exceeded budget 300ms')
|
||||
expect(result.stderr).toContain('revisit latency 301ms exceeded budget 300ms')
|
||||
expect(result.stderr).toContain('timer drift 151ms exceeded budget 150ms')
|
||||
expect(result.stderr).toContain('scroll latency 151ms exceeded budget 150ms')
|
||||
expect(result.stderr).toContain('restore latency 1001ms exceeded budget 1000ms')
|
||||
expect(result.stderr).toContain('renderer queued chars 2097153 exceeded budget 2097152')
|
||||
expect(result.stderr).toContain('renderer peak queued chars 2097153 exceeded budget 2097152')
|
||||
expect(result.stderr).toContain('renderer dropped backlogs 1 exceeded budget 0')
|
||||
})
|
||||
|
||||
it('fails malformed metric values instead of treating them as absent', () => {
|
||||
const reportPath = writeReport('panes=1 median=999 worst=abcms rendererQueuedChars=wat')
|
||||
|
||||
const result = runChecker(reportPath)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('median value "999" is malformed')
|
||||
expect(result.stderr).toContain('worst value "abcms" is malformed')
|
||||
expect(result.stderr).toContain('rendererQueuedChars value "wat" is malformed')
|
||||
expect(result.stderr).toContain('no recognized budget metrics found')
|
||||
})
|
||||
|
||||
it('accepts revisit-only marker rows as budgeted perf evidence', () => {
|
||||
const reportPath = writeReport('panes=19 revisit=25.7ms heldAckChars=2097184')
|
||||
|
||||
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
|
||||
})
|
||||
|
||||
it('accepts parked-memory rows that carry only heap and view-count metrics', () => {
|
||||
const reportPath = writeReport(
|
||||
'panes=8 parkedTabs=8 heapUsedMB=87.8 liveTerminals=1 livePaneManagers=1',
|
||||
'opencode-parked-memory'
|
||||
)
|
||||
|
||||
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
|
||||
})
|
||||
|
||||
it('fails OpenCode annotation rows that contain no budget metrics', () => {
|
||||
const reportPath = writeReport('panes=1 frames=60')
|
||||
|
||||
const result = runChecker(reportPath)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('no recognized budget metrics found')
|
||||
})
|
||||
|
||||
it('ignores non-OpenCode annotations but fails when no perf rows remain', () => {
|
||||
const reportPath = writeReport('median=999.0ms', 'browser-unrelated')
|
||||
|
||||
const result = runChecker(reportPath)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('No OpenCode terminal perf annotations found.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const SETTINGS_DIR = path.join('src', 'renderer', 'src', 'components', 'settings')
|
||||
const IMPORT_LINE = "import { translateSearchKeyword } from './settings-search-keywords'"
|
||||
|
||||
function transformKeywordsBlock(block) {
|
||||
return block.replace(
|
||||
/(?<!\.\.\.)translate\((['"`])([^'"`]+)\1,\s*(['"`])([^'"`]*)\3\)/g,
|
||||
'...translateSearchKeyword($1$2$1, $3$4$3)'
|
||||
)
|
||||
}
|
||||
|
||||
function transformFile(content) {
|
||||
if (!content.includes('keywords:') || content.includes('translateSearchKeyword')) {
|
||||
return content
|
||||
}
|
||||
|
||||
let next = content.replace(/keywords:\s*\[([\s\S]*?)\]/g, (match, body) => {
|
||||
const transformedBody = transformKeywordsBlock(body)
|
||||
return transformedBody === body ? match : `keywords: [${transformedBody}]`
|
||||
})
|
||||
|
||||
if (next === content) {
|
||||
return content
|
||||
}
|
||||
|
||||
if (!next.includes(IMPORT_LINE)) {
|
||||
const i18nImport = "import { translate } from '@/i18n/i18n'"
|
||||
next = next.includes(i18nImport)
|
||||
? next.replace(i18nImport, `${i18nImport}\n${IMPORT_LINE}`)
|
||||
: `${IMPORT_LINE}\n${next}`
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const entries = await fs.readdir(SETTINGS_DIR)
|
||||
const files = entries
|
||||
.filter((name) => name.endsWith('-search.ts'))
|
||||
.map((name) => path.join(SETTINGS_DIR, name))
|
||||
|
||||
let changed = 0
|
||||
for (const filePath of files) {
|
||||
const original = await fs.readFile(filePath, 'utf8')
|
||||
const updated = transformFile(original)
|
||||
if (updated !== original) {
|
||||
await fs.writeFile(filePath, updated, 'utf8')
|
||||
changed += 1
|
||||
console.log(`Updated ${filePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Codemod complete (${changed} files).`)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,394 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, dirname, isAbsolute, relative } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { collectTerminalPerfRows } from './terminal-perf-report-annotations.mjs'
|
||||
|
||||
const USAGE =
|
||||
'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline <path> --candidate <path> [--title <title>] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]'
|
||||
|
||||
const PLAYWRIGHT_METADATA_FIELDS = new Set(['source', 'scenario', 'panes', 'frames', 'samples'])
|
||||
|
||||
export function parseBenchmarkComparisonArgs(argv = process.argv.slice(2)) {
|
||||
const args = argv[0] === '--' ? argv.slice(1) : [...argv]
|
||||
const parsed = {
|
||||
higherIsBetter: new Set(),
|
||||
title: 'Benchmark comparison'
|
||||
}
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const flag = args[index]
|
||||
if (flag === '--baseline') {
|
||||
parsed.baselinePath = readRequiredValue(args, ++index, '--baseline requires a path')
|
||||
continue
|
||||
}
|
||||
if (flag === '--candidate') {
|
||||
parsed.candidatePath = readRequiredValue(args, ++index, '--candidate requires a path')
|
||||
continue
|
||||
}
|
||||
if (flag === '--title') {
|
||||
parsed.title = readRequiredValue(args, ++index, '--title requires a value')
|
||||
continue
|
||||
}
|
||||
if (flag === '--output') {
|
||||
parsed.outputPath = readRequiredValue(args, ++index, '--output requires a path')
|
||||
continue
|
||||
}
|
||||
if (flag === '--json-output') {
|
||||
parsed.jsonOutputPath = readRequiredValue(args, ++index, '--json-output requires a path')
|
||||
continue
|
||||
}
|
||||
if (flag === '--higher-is-better') {
|
||||
let consumed = 0
|
||||
while (args[index + 1] != null && !args[index + 1].startsWith('--')) {
|
||||
parsed.higherIsBetter.add(args[index + 1])
|
||||
index += 1
|
||||
consumed += 1
|
||||
}
|
||||
if (consumed === 0) {
|
||||
throw new Error('--higher-is-better requires a metric key')
|
||||
}
|
||||
continue
|
||||
}
|
||||
throw new Error(USAGE)
|
||||
}
|
||||
|
||||
if (!parsed.baselinePath) {
|
||||
throw new Error(USAGE)
|
||||
}
|
||||
if (!parsed.candidatePath) {
|
||||
throw new Error(USAGE)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function readRequiredValue(args, index, message) {
|
||||
const value = args[index]
|
||||
if (value == null || value.startsWith('--')) {
|
||||
throw new Error(message)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function readBenchmarkArtifact(path) {
|
||||
return JSON.parse(readFileSync(path, 'utf8'))
|
||||
}
|
||||
|
||||
export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifact(path)) {
|
||||
if (artifact?.summaryMedianMs != null) {
|
||||
return normalizeNumericObject(path, artifact, 'startup', artifact.summaryMedianMs, () => 'ms')
|
||||
}
|
||||
if (artifact?.summaryMedian != null) {
|
||||
return normalizeNumericObject(path, artifact, 'daemon', artifact.summaryMedian, (key) =>
|
||||
key.endsWith('Count') || key.endsWith('After') ? 'count' : 'ms'
|
||||
)
|
||||
}
|
||||
if (artifact?.suites != null) {
|
||||
return normalizePlaywrightArtifact(path, artifact)
|
||||
}
|
||||
if (artifact?.summary != null) {
|
||||
return normalizeSummaryArtifact(path, artifact)
|
||||
}
|
||||
throw new Error(
|
||||
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, Playwright suites, or top-level summary`
|
||||
)
|
||||
}
|
||||
|
||||
function artifactLabel(path, artifact) {
|
||||
return typeof artifact?.label === 'string' && artifact.label.length > 0
|
||||
? artifact.label
|
||||
: basename(path)
|
||||
}
|
||||
|
||||
function normalizeNumericObject(path, artifact, kind, values, unitForKey) {
|
||||
return {
|
||||
kind,
|
||||
label: artifactLabel(path, artifact),
|
||||
metrics: Object.entries(values ?? {})
|
||||
.filter(([, value]) => Number.isFinite(value))
|
||||
.map(([key, value]) => ({
|
||||
direction: 'lower-is-better',
|
||||
key,
|
||||
unit: unitForKey(key),
|
||||
value
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlaywrightArtifact(path, artifact) {
|
||||
const rows = collectTerminalPerfRows(artifact, basename(path), { typePrefix: 'opencode-' })
|
||||
const groupedMetrics = new Map()
|
||||
for (const row of rows) {
|
||||
for (const [field, rawValue] of Object.entries(row)) {
|
||||
if (PLAYWRIGHT_METADATA_FIELDS.has(field)) {
|
||||
continue
|
||||
}
|
||||
const parsed = parseMetricValue(rawValue)
|
||||
if (parsed == null) {
|
||||
continue
|
||||
}
|
||||
const key = `${row.scenario}.${field}`
|
||||
const metricGroup = groupedMetrics.get(key) ?? {
|
||||
unit: parsed.unit,
|
||||
values: []
|
||||
}
|
||||
metricGroup.values.push(parsed.value)
|
||||
groupedMetrics.set(key, metricGroup)
|
||||
}
|
||||
}
|
||||
const metrics = [...groupedMetrics.entries()].map(([key, metricGroup]) => ({
|
||||
direction: 'lower-is-better',
|
||||
key,
|
||||
unit: metricGroup.unit,
|
||||
value: mean(metricGroup.values)
|
||||
}))
|
||||
return {
|
||||
kind: 'playwright',
|
||||
label: artifactLabel(path, artifact),
|
||||
metrics
|
||||
}
|
||||
}
|
||||
|
||||
function mean(values) {
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function parseMetricValue(rawValue) {
|
||||
if (typeof rawValue === 'string') {
|
||||
const msMatch = rawValue.match(/^(-?\d+(?:\.\d+)?)ms$/)
|
||||
if (msMatch) {
|
||||
return { unit: 'ms', value: Number(msMatch[1]) }
|
||||
}
|
||||
}
|
||||
const numericValue = Number(rawValue)
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return null
|
||||
}
|
||||
return { unit: 'count', value: numericValue }
|
||||
}
|
||||
|
||||
function normalizeSummaryArtifact(path, artifact) {
|
||||
const metrics = []
|
||||
flattenSummary(metrics, ['summary'], artifact.summary)
|
||||
return {
|
||||
kind: 'summary',
|
||||
label: artifactLabel(path, artifact),
|
||||
metrics
|
||||
}
|
||||
}
|
||||
|
||||
function flattenSummary(metrics, pathParts, value) {
|
||||
if (Number.isFinite(value)) {
|
||||
const key = pathParts.join('.')
|
||||
metrics.push({
|
||||
direction: 'lower-is-better',
|
||||
key,
|
||||
unit: unitForSummaryKey(pathParts),
|
||||
value
|
||||
})
|
||||
return
|
||||
}
|
||||
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return
|
||||
}
|
||||
for (const [childKey, childValue] of Object.entries(value)) {
|
||||
flattenSummary(metrics, [...pathParts, childKey], childValue)
|
||||
}
|
||||
}
|
||||
|
||||
function unitForSummaryKey(pathParts) {
|
||||
if (pathParts.some((part) => part.endsWith('CpuPercent'))) {
|
||||
return '%'
|
||||
}
|
||||
if (pathParts.some((part) => part.endsWith('Bytes'))) {
|
||||
return 'bytes'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function compareBenchmarkArtifacts({
|
||||
baselinePath,
|
||||
candidatePath,
|
||||
title = 'Benchmark comparison',
|
||||
higherIsBetter = new Set(),
|
||||
now = () => new Date()
|
||||
}) {
|
||||
const baseline = normalizeBenchmarkArtifact(baselinePath)
|
||||
const candidate = normalizeBenchmarkArtifact(candidatePath)
|
||||
const candidateMetrics = new Map(candidate.metrics.map((metric) => [metric.key, metric]))
|
||||
const baselineMetrics = new Map(baseline.metrics.map((metric) => [metric.key, metric]))
|
||||
const metrics = []
|
||||
const skippedMetrics = []
|
||||
|
||||
for (const baselineMetric of baseline.metrics) {
|
||||
const candidateMetric = candidateMetrics.get(baselineMetric.key)
|
||||
if (!isComparableMetric(baselineMetric)) {
|
||||
skippedMetrics.push({ key: baselineMetric.key, reason: 'missing baseline metric' })
|
||||
continue
|
||||
}
|
||||
if (!isComparableMetric(candidateMetric)) {
|
||||
skippedMetrics.push({ key: baselineMetric.key, reason: 'missing candidate metric' })
|
||||
continue
|
||||
}
|
||||
if (baselineMetric.unit !== candidateMetric.unit) {
|
||||
skippedMetrics.push({
|
||||
key: baselineMetric.key,
|
||||
reason: `unit mismatch (${formatUnitLabel(baselineMetric.unit)} vs ${formatUnitLabel(candidateMetric.unit)})`
|
||||
})
|
||||
continue
|
||||
}
|
||||
const direction = higherIsBetter.has(baselineMetric.key)
|
||||
? 'higher-is-better'
|
||||
: baselineMetric.direction
|
||||
metrics.push(compareMetric(baselineMetric, candidateMetric, direction))
|
||||
}
|
||||
|
||||
for (const candidateMetric of candidate.metrics) {
|
||||
if (!baselineMetrics.has(candidateMetric.key)) {
|
||||
skippedMetrics.push({ key: candidateMetric.key, reason: 'missing baseline metric' })
|
||||
}
|
||||
}
|
||||
|
||||
if (metrics.length === 0) {
|
||||
throw new Error('No comparable benchmark metrics found.')
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
createdAt: now().toISOString(),
|
||||
title,
|
||||
baseline: {
|
||||
path: benchmarkDisplayPath(baselinePath),
|
||||
label: baseline.label,
|
||||
kind: baseline.kind
|
||||
},
|
||||
candidate: {
|
||||
path: benchmarkDisplayPath(candidatePath),
|
||||
label: candidate.label,
|
||||
kind: candidate.kind
|
||||
},
|
||||
metrics,
|
||||
skippedMetrics
|
||||
}
|
||||
}
|
||||
|
||||
function benchmarkDisplayPath(path, cwd = process.cwd()) {
|
||||
if (!isAbsolute(path)) {
|
||||
return path
|
||||
}
|
||||
const relativePath = relative(cwd, path)
|
||||
if (relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)) {
|
||||
return relativePath
|
||||
}
|
||||
return basename(path)
|
||||
}
|
||||
|
||||
function isComparableMetric(metric) {
|
||||
return metric != null && Number.isFinite(metric.value)
|
||||
}
|
||||
|
||||
function compareMetric(baselineMetric, candidateMetric, direction) {
|
||||
const rawDelta = candidateMetric.value - baselineMetric.value
|
||||
const absoluteDelta = roundOneDecimal(rawDelta)
|
||||
const percentDelta =
|
||||
baselineMetric.value === 0
|
||||
? null
|
||||
: roundOneDecimal((rawDelta / Math.abs(baselineMetric.value)) * 100)
|
||||
return {
|
||||
key: baselineMetric.key,
|
||||
unit: baselineMetric.unit,
|
||||
direction,
|
||||
baseline: baselineMetric.value,
|
||||
candidate: candidateMetric.value,
|
||||
absoluteDelta,
|
||||
percentDelta,
|
||||
status: metricStatus(absoluteDelta, direction)
|
||||
}
|
||||
}
|
||||
|
||||
function formatUnitLabel(unit) {
|
||||
return unit === '' ? 'none' : unit
|
||||
}
|
||||
|
||||
function roundOneDecimal(value) {
|
||||
return Math.round(value * 10) / 10
|
||||
}
|
||||
|
||||
function metricStatus(absoluteDelta, direction) {
|
||||
if (absoluteDelta === 0) {
|
||||
return 'unchanged'
|
||||
}
|
||||
if (direction === 'higher-is-better') {
|
||||
return absoluteDelta > 0 ? 'improved' : 'regressed'
|
||||
}
|
||||
return absoluteDelta < 0 ? 'improved' : 'regressed'
|
||||
}
|
||||
|
||||
export function formatBenchmarkComparisonMarkdown(comparison) {
|
||||
const lines = [
|
||||
`# ${markdownText(comparison.title)}`,
|
||||
'',
|
||||
`Baseline: ${markdownText(comparison.baseline.label)} (${markdownText(comparison.baseline.path)})`,
|
||||
`Candidate: ${markdownText(comparison.candidate.label)} (${markdownText(comparison.candidate.path)})`,
|
||||
`Generated: ${comparison.createdAt}`,
|
||||
'',
|
||||
'| Metric | Baseline | Candidate | Delta | Delta % | Result |',
|
||||
'|---|---:|---:|---:|---:|---|'
|
||||
]
|
||||
for (const metric of comparison.metrics) {
|
||||
lines.push(
|
||||
`| ${markdownTableCell(metric.key)} | ${formatMetricValue(metric.baseline, metric.unit)} | ${formatMetricValue(metric.candidate, metric.unit)} | ${formatMetricValue(metric.absoluteDelta, metric.unit)} | ${formatPercent(metric.percentDelta)} | ${metric.status} |`
|
||||
)
|
||||
}
|
||||
if (comparison.skippedMetrics.length > 0) {
|
||||
lines.push('', '## Skipped metrics')
|
||||
for (const skippedMetric of comparison.skippedMetrics) {
|
||||
lines.push(`- ${markdownText(skippedMetric.key)}: ${markdownText(skippedMetric.reason)}`)
|
||||
}
|
||||
}
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function markdownText(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/\s*\r?\n\s*/g, ' ')
|
||||
.replace(/[\\`*_{}<>()#+.!-]|\[|\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function markdownTableCell(value) {
|
||||
return markdownText(value).replaceAll('|', '\\|')
|
||||
}
|
||||
|
||||
function formatMetricValue(value, unit) {
|
||||
return `${Number(value).toFixed(1)}${unit}`
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return value == null ? '' : `${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
export function runBenchmarkComparisonCli(args = {}) {
|
||||
const argv = args.argv ?? process.argv.slice(2)
|
||||
const parsed = parseBenchmarkComparisonArgs(argv)
|
||||
const comparison = compareBenchmarkArtifacts(parsed)
|
||||
const markdown = formatBenchmarkComparisonMarkdown(comparison)
|
||||
if (parsed.outputPath) {
|
||||
mkdirSync(dirname(parsed.outputPath), { recursive: true })
|
||||
writeFileSync(parsed.outputPath, markdown)
|
||||
}
|
||||
if (parsed.jsonOutputPath) {
|
||||
mkdirSync(dirname(parsed.jsonOutputPath), { recursive: true })
|
||||
writeFileSync(parsed.jsonOutputPath, `${JSON.stringify(comparison, null, 2)}\n`)
|
||||
}
|
||||
const stdout = args.stdout ?? process.stdout
|
||||
stdout.write(markdown)
|
||||
return comparison
|
||||
}
|
||||
|
||||
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
runBenchmarkComparisonCli()
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('computer-use e2e workflow', () => {
|
||||
it('runs computer-use e2e files serially because they share desktop focus', () => {
|
||||
const config = readFileSync(join(projectDir, 'tests/e2e/vitest.config.ts'), 'utf8')
|
||||
|
||||
expect(config).toContain('fileParallelism: false')
|
||||
})
|
||||
|
||||
it('guards e2e source against fragile fixed waits and stale element indexes', () => {
|
||||
const driver = readFileSync(join(projectDir, 'tests/e2e/helpers/computer-driver.ts'), 'utf8')
|
||||
const cliDriver = readFileSync(
|
||||
join(projectDir, 'tests/e2e/helpers/computer-cli-driver.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const windowsStoreE2e = readFileSync(
|
||||
join(projectDir, 'tests/e2e/computer-windows-store.e2e.ts'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(driver).not.toContain('await delay(3500)')
|
||||
expect(driver).toContain("await waitForComputerWindowTitle('gedit', fileName, 15000)")
|
||||
expect(cliDriver).toContain('ORCA_DEV_USER_DATA_PATH')
|
||||
expect(cliDriver).toContain('orca-computer-runtime-')
|
||||
expect(cliDriver).toContain('retryMissingRuntimeMetadata')
|
||||
expect(cliDriver).toContain('Could not read Orca runtime metadata')
|
||||
expect(cliDriver).toContain("'serve', '--no-pairing', '--json'")
|
||||
|
||||
expect(windowsStoreE2e).toMatch(
|
||||
/for \(const buttonName of \['One', 'Plus', 'Two', 'Equals'\]\) \{[\s\S]*findRoleIndex\(state\.result\.snapshot\.treeText, `button \$\{buttonName\}`\)[\s\S]*state = parseJsonOutput/
|
||||
)
|
||||
expect(windowsStoreE2e).not.toMatch(/const one = findRoleIndex/)
|
||||
expect(windowsStoreE2e).not.toMatch(/for \(const index of \[one, plus, two, equals\]\)/)
|
||||
})
|
||||
|
||||
it('triggers on computer-use shared contracts, scripts, and agent skill changes', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const triggerPaths = workflow.on.pull_request.paths
|
||||
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
'skills/computer-use/SKILL.md',
|
||||
'src/main/computer/**',
|
||||
'src/main/runtime/rpc/dispatcher.ts',
|
||||
'src/main/runtime/rpc/errors.ts',
|
||||
'src/main/runtime/rpc/methods/computer*.ts',
|
||||
'src/shared/computer-use-*.ts',
|
||||
'tests/e2e/vitest.config.ts'
|
||||
])
|
||||
)
|
||||
expect(triggerPaths).not.toContain('src/shared/runtime-types.ts')
|
||||
})
|
||||
|
||||
it('runs focused computer-use regression tests in the PR native-smoke job', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
'src/main/computer/computer-provider-lifecycle.test.ts',
|
||||
'src/main/computer/computer-provider-unavailable-message.test.ts',
|
||||
'src/main/computer/sidecar-client.test.ts',
|
||||
'src/main/computer/macos-native-provider-client.test.ts',
|
||||
'src/main/computer/macos-native-provider-socket.test.ts',
|
||||
'src/main/computer/macos-computer-use-permissions.test.ts',
|
||||
'src/main/computer/macos-computer-use-permission-status.test.ts',
|
||||
'src/main/computer/desktop-script-provider-client.test.ts',
|
||||
'src/main/computer/desktop-script-provider-cache.test.ts',
|
||||
'src/main/computer/desktop-script-provider-actions.test.ts',
|
||||
'src/main/computer/desktop-script-provider-cache-lifecycle.test.ts',
|
||||
'src/main/computer/desktop-script-provider-errors.test.ts',
|
||||
'src/main/computer/desktop-script-provider-action-errors.test.ts',
|
||||
'src/shared/computer-use-error-recovery.test.ts',
|
||||
'src/shared/computer-use-key-spec.test.ts',
|
||||
'src/cli/format.test.ts',
|
||||
'src/cli/handlers/computer.test.ts',
|
||||
'src/cli/handlers/computer-action-routing.test.ts',
|
||||
'src/cli/handlers/computer-action-validation.test.ts',
|
||||
'src/cli/handlers/computer-state-formatting.test.ts',
|
||||
'src/cli/specs/computer.test.ts',
|
||||
'src/cli/index.test.ts',
|
||||
'src/main/runtime/rpc/dispatcher-computer-errors.test.ts',
|
||||
'src/main/runtime/rpc/errors.test.ts',
|
||||
'src/main/runtime/rpc/methods/computer.test.ts',
|
||||
'src/main/runtime/rpc/methods/computer-actions.test.ts',
|
||||
'src/cli/runtime/envelope-schema.test.ts',
|
||||
'src/shared/remote-runtime-client.test.ts'
|
||||
]
|
||||
|
||||
expect(regressionRun).toBeTruthy()
|
||||
for (const file of expectedRegressionFiles) {
|
||||
expect(regressionRun).toContain(file)
|
||||
}
|
||||
})
|
||||
|
||||
it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const steps = workflow.jobs['native-smoke'].steps
|
||||
const runs = steps.map((step) => step.run).filter((run) => typeof run === 'string')
|
||||
const buildIndex = runs.indexOf('pnpm build:electron-vite')
|
||||
const daemonSmokeIndex = runs.indexOf('node config/scripts/daemon-boot-smoke.mjs')
|
||||
|
||||
expect(daemonSmokeIndex, 'native-smoke must boot the built daemon').toBeGreaterThanOrEqual(0)
|
||||
expect(
|
||||
buildIndex,
|
||||
'daemon boot smoke must run after the main bundle is built'
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
expect(daemonSmokeIndex).toBeGreaterThan(buildIndex)
|
||||
})
|
||||
|
||||
it('runs the Windows workspace-close daemon repro after the main build', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const steps = workflow.jobs['native-smoke'].steps
|
||||
const buildIndex = steps.findIndex((step) => step.run === 'pnpm build:electron-vite')
|
||||
const reproIndex = steps.findIndex(
|
||||
(step) => step.run === 'node config/scripts/windows-daemon-workspace-close-repro.mjs'
|
||||
)
|
||||
|
||||
expect(reproIndex).toBeGreaterThan(buildIndex)
|
||||
expect(steps[reproIndex].if).toBe("runner.os == 'Windows'")
|
||||
expect(workflow.on.pull_request.paths).toContain(
|
||||
'config/scripts/windows-daemon-workspace-close-repro.mjs'
|
||||
)
|
||||
})
|
||||
|
||||
it('re-runs the native-smoke job when the daemon bundle graph changes', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const triggerPaths = workflow.on.pull_request.paths
|
||||
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/daemon-boot-smoke.mjs',
|
||||
'config/scripts/windows-daemon-workspace-close-repro.mjs',
|
||||
'electron.vite.config.ts',
|
||||
'build-plugins/**',
|
||||
'src/main/daemon/**'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('runs Linux computer-use e2e in the PR native-smoke job under Xvfb', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const installRun = nativeSmokeRuns.find((run) => run.includes('apt-get install'))
|
||||
|
||||
expect(installRun).toContain('gedit')
|
||||
expect(installRun).toContain('xvfb')
|
||||
expect(nativeSmokeRuns).toContain(
|
||||
'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds Electron main output before every computer-use e2e run', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
|
||||
for (const jobName of ['native-smoke', 'mac', 'linux', 'windows']) {
|
||||
const runs = workflow.jobs[jobName].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const buildIndex = runs.indexOf('pnpm build:electron-vite')
|
||||
const e2eIndexes = runs
|
||||
.map((run, index) => (run.includes('test:e2e:computer') ? index : -1))
|
||||
.filter((index) => index >= 0)
|
||||
|
||||
expect(
|
||||
buildIndex,
|
||||
`${jobName} should build out/main before computer e2e`
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
for (const e2eIndex of e2eIndexes) {
|
||||
expect(buildIndex, `${jobName} should build out/main before computer e2e`).toBeLessThan(
|
||||
e2eIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('runs core Windows computer-use e2e in the PR native-smoke job', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const allRuns = [
|
||||
...nativeSmokeRuns,
|
||||
...workflow.jobs.mac.steps.map((step) => step.run).filter((run) => typeof run === 'string'),
|
||||
...workflow.jobs.linux.steps.map((step) => step.run).filter((run) => typeof run === 'string'),
|
||||
...workflow.jobs.windows.steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
]
|
||||
|
||||
expect(nativeSmokeRuns).toContain(
|
||||
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts'
|
||||
)
|
||||
expect(allRuns.join('\n')).not.toContain('test:e2e:computer -- --reporter')
|
||||
})
|
||||
|
||||
it('runs macOS and Linux computer-use e2e files in scheduled jobs', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const triggerPaths = workflow.on.pull_request.paths
|
||||
const macRuns = workflow.jobs.mac.steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const linuxRuns = workflow.jobs.linux.steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'tests/e2e/computer-mac.e2e.ts',
|
||||
'tests/e2e/computer-mac-safari.e2e.ts',
|
||||
'tests/e2e/computer-linux.e2e.ts',
|
||||
'tests/e2e/helpers/computer-cli-driver.ts',
|
||||
'tests/e2e/helpers/computer-driver.ts'
|
||||
])
|
||||
)
|
||||
expect(macRuns).toContain(
|
||||
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts'
|
||||
)
|
||||
expect(linuxRuns).toContain(
|
||||
'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts'
|
||||
)
|
||||
})
|
||||
|
||||
it('runs every Windows computer-use e2e file in the scheduled Windows job', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const triggerPaths = workflow.on.pull_request.paths
|
||||
const windowsRuns = workflow.jobs.windows.steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'tests/e2e/computer-windows.e2e.ts',
|
||||
'tests/e2e/computer-windows-store.e2e.ts'
|
||||
])
|
||||
)
|
||||
expect(windowsRuns).toContain(
|
||||
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const skillPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
|
||||
|
||||
describe('computer-use skill guidance', () => {
|
||||
it('keeps web-app targeting on the computer-use surface', () => {
|
||||
const skill = readFileSync(skillPath, 'utf8')
|
||||
|
||||
expect(skill).toContain('Use this skill for desktop UI through `orca computer`')
|
||||
expect(skill).toContain('operate the desktop browser app/window that contains the page')
|
||||
expect(skill).not.toContain('orca goto')
|
||||
expect(skill).not.toContain('orca snapshot')
|
||||
expect(skill).not.toContain('orca click')
|
||||
expect(skill).not.toContain('orca fill')
|
||||
expect(skill).not.toContain('Routing:')
|
||||
})
|
||||
|
||||
it('warns agents to verify browser-hosted form focus before drafting text', () => {
|
||||
const skill = readFileSync(skillPath, 'utf8')
|
||||
|
||||
expect(skill).toContain('For browser-hosted forms such as Gmail compose')
|
||||
expect(skill).toContain('verify the focused UI element after each field action')
|
||||
expect(skill).toContain('Prefer `paste-text` into the verified focused field')
|
||||
})
|
||||
|
||||
it('warns agents about occluded Linux and Windows screenshots', () => {
|
||||
const skill = readFileSync(skillPath, 'utf8')
|
||||
|
||||
expect(skill).toContain('On Linux and Windows')
|
||||
expect(skill).toContain('use `--restore-window` so another window does not cover')
|
||||
expect(skill).toContain('trust the tree over potentially occluded pixels')
|
||||
})
|
||||
|
||||
it('points JSON users to the public accessibility-tree field', () => {
|
||||
const skill = readFileSync(skillPath, 'utf8')
|
||||
|
||||
expect(skill).toContain('`result.snapshot.treeText`')
|
||||
expect(skill).not.toContain('`result.elements`')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..', '..')
|
||||
const cliPath =
|
||||
process.env.ORCA_COMPUTER_SMOKE_CLI_PATH ?? resolve(repoRoot, 'out', 'cli', 'index.js')
|
||||
const args = new Set(process.argv.slice(2))
|
||||
const requestedApps = valueFlag('--apps')
|
||||
const includeScreenshot = args.has('--screenshot')
|
||||
const launchRuntime = args.has('--launch')
|
||||
const requireTarget = args.has('--require-target')
|
||||
const session = valueFlag('--session') ?? `computer-smoke-${process.pid}`
|
||||
const preferredApps = (
|
||||
requestedApps ??
|
||||
process.env.ORCA_COMPUTER_SMOKE_APPS ??
|
||||
'Finder,TextEdit,Text Editor,gedit,Notepad,Calculator,Microsoft Edge,Google Chrome,Safari,Slack,Spotify'
|
||||
)
|
||||
.split(',')
|
||||
.map((app) => app.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (!existsSync(cliPath)) {
|
||||
fail(`Missing built CLI at ${cliPath}. Run pnpm build:cli first.`)
|
||||
}
|
||||
|
||||
if (launchRuntime) {
|
||||
const opened = unwrapResult(runCli(['open', '--json']))
|
||||
console.log(
|
||||
`computer-use smoke: runtime ${opened.runtime?.state ?? 'unknown'} (${opened.runtime?.runtimeId ?? 'unknown'})`
|
||||
)
|
||||
}
|
||||
|
||||
const list = unwrapResult(runCli(['computer', 'list-apps', '--json']))
|
||||
const apps = Array.isArray(list.apps) ? list.apps : []
|
||||
const availableNames = new Set(apps.map((app) => String(app.name ?? '').toLowerCase()))
|
||||
const availableBundles = new Set(
|
||||
apps.map((app) => String(app.bundleId ?? '').toLowerCase()).filter(Boolean)
|
||||
)
|
||||
const targets = preferredApps.filter(
|
||||
(app) => availableNames.has(app.toLowerCase()) || availableBundles.has(app.toLowerCase())
|
||||
)
|
||||
|
||||
console.log(`computer-use smoke: ${apps.length} apps listed`)
|
||||
if (targets.length === 0) {
|
||||
const message = `no preferred apps are running (${preferredApps.join(', ')})`
|
||||
if (requireTarget) {
|
||||
fail(message)
|
||||
}
|
||||
console.log(`computer-use smoke: ${message}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
let successes = 0
|
||||
for (const app of targets) {
|
||||
const result = runSnapshotSmoke(app)
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.skipped) {
|
||||
console.log(`computer-use smoke: ${app}: skipped (${result.reason})`)
|
||||
continue
|
||||
}
|
||||
failures += 1
|
||||
console.log(`computer-use smoke: ${app}: failed: ${result.error}`)
|
||||
continue
|
||||
}
|
||||
successes += 1
|
||||
|
||||
const state = unwrapResult(result.value)
|
||||
const snapshot = state.snapshot
|
||||
const treeText = String(snapshot.treeText ?? '')
|
||||
const lineCount = treeText.split('\n').filter(Boolean).length
|
||||
const secondaryActions = (treeText.match(/Secondary Actions:/g) ?? []).length
|
||||
const settable = (treeText.match(/\bsettable\b/g) ?? []).length
|
||||
const screenshotState = state.screenshot
|
||||
? `${state.screenshot.width}x${state.screenshot.height}`
|
||||
: 'missing'
|
||||
console.log(
|
||||
[
|
||||
`computer-use smoke: ${snapshot.app.name}`,
|
||||
`${snapshot.elementCount} elements`,
|
||||
`${lineCount} lines`,
|
||||
`${secondaryActions} secondary-action lines`,
|
||||
`${settable} settable elements`,
|
||||
`screenshot=${screenshotState}`
|
||||
].join(' | ')
|
||||
)
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
fail(`${failures} app snapshot smoke check(s) failed`)
|
||||
}
|
||||
if (requireTarget && successes === 0) {
|
||||
fail('no preferred app snapshots succeeded')
|
||||
}
|
||||
|
||||
function runSnapshotSmoke(app) {
|
||||
const baseArgs = [
|
||||
'computer',
|
||||
'get-app-state',
|
||||
'--session',
|
||||
session,
|
||||
'--app',
|
||||
app,
|
||||
'--restore-window',
|
||||
...(includeScreenshot ? [] : ['--no-screenshot']),
|
||||
'--json'
|
||||
]
|
||||
const initial = runCli(baseArgs, { allowFailure: true })
|
||||
if (initial.ok) {
|
||||
return initial
|
||||
}
|
||||
const error = parseCliFailure(initial.error)
|
||||
if (error?.code === 'window_not_found') {
|
||||
return { ok: false, skipped: true, reason: error.message }
|
||||
}
|
||||
return initial
|
||||
}
|
||||
|
||||
function parseCliFailure(raw) {
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed.error ?? null
|
||||
} catch {
|
||||
return { code: 'runtime_error', message: String(raw) }
|
||||
}
|
||||
}
|
||||
|
||||
function valueFlag(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
return process.argv[index + 1] ?? null
|
||||
}
|
||||
|
||||
function runCli(cliArgs, options = {}) {
|
||||
const child = spawnSync(process.execPath, [cliPath, ...cliArgs], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_USER_DATA_PATH:
|
||||
process.env.ORCA_COMPUTER_SMOKE_USER_DATA_PATH ?? defaultDevUserDataPath()
|
||||
}
|
||||
})
|
||||
if (child.status !== 0) {
|
||||
const error = (child.stderr || child.stdout || `exit ${child.status}`).trim()
|
||||
if (options.allowFailure) {
|
||||
return { ok: false, error }
|
||||
}
|
||||
fail(error)
|
||||
}
|
||||
try {
|
||||
return options.allowFailure
|
||||
? { ok: true, value: JSON.parse(child.stdout) }
|
||||
: JSON.parse(child.stdout)
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
fail(`Could not parse CLI JSON for ${cliArgs.join(' ')}: ${detail}\n${child.stdout}`)
|
||||
}
|
||||
}
|
||||
|
||||
function defaultDevUserDataPath() {
|
||||
if (process.platform === 'darwin') {
|
||||
return resolve(homedir(), 'Library', 'Application Support', 'orca-dev')
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return resolve(process.env.APPDATA ?? resolve(homedir(), 'AppData', 'Roaming'), 'orca-dev')
|
||||
}
|
||||
return resolve(process.env.XDG_CONFIG_HOME ?? resolve(homedir(), '.config'), 'orca-dev')
|
||||
}
|
||||
|
||||
function unwrapResult(value) {
|
||||
if (value && typeof value === 'object' && 'result' in value) {
|
||||
return value.result
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`computer-use smoke: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = path.resolve(import.meta.dirname, '../..')
|
||||
const smokeScript = path.join(projectDir, 'config', 'scripts', 'computer-use-smoke.mjs')
|
||||
|
||||
describe('computer-use smoke script', () => {
|
||||
it('can launch a runtime before checking apps', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-computer-smoke-test-'))
|
||||
const cliPath = path.join(root, 'fake-cli.cjs')
|
||||
const callsPath = path.join(root, 'calls.jsonl')
|
||||
writeFileSync(
|
||||
cliPath,
|
||||
[
|
||||
'const fs = require("node:fs");',
|
||||
`fs.appendFileSync(${JSON.stringify(callsPath)}, JSON.stringify(process.argv.slice(2)) + "\\n");`,
|
||||
'const args = process.argv.slice(2);',
|
||||
'if (args[0] === "open") {',
|
||||
' console.log(JSON.stringify({ result: { runtime: { state: "ready", runtimeId: "runtime-test" } } }));',
|
||||
'} else if (args.join(" ") === "computer list-apps --json") {',
|
||||
' console.log(JSON.stringify({ result: { apps: [] } }));',
|
||||
'} else {',
|
||||
' console.error("unexpected args: " + args.join(" "));',
|
||||
' process.exit(1);',
|
||||
'}'
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(cliPath, 0o755)
|
||||
|
||||
const output = execFileSync(process.execPath, [smokeScript, '--launch'], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_COMPUTER_SMOKE_CLI_PATH: cliPath,
|
||||
ORCA_COMPUTER_SMOKE_USER_DATA_PATH: path.join(root, 'user-data')
|
||||
}
|
||||
})
|
||||
|
||||
expect(output).toContain('computer-use smoke: runtime ready (runtime-test)')
|
||||
expect(
|
||||
readFileSync(callsPath, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line))
|
||||
).toEqual([
|
||||
['open', '--json'],
|
||||
['computer', 'list-apps', '--json']
|
||||
])
|
||||
})
|
||||
|
||||
it('fails closed when a target app is required but none are available', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-computer-smoke-test-'))
|
||||
const cliPath = writeFakeListAppsCli(root, [])
|
||||
|
||||
const result = spawnSync(process.execPath, [smokeScript, '--require-target'], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_COMPUTER_SMOKE_CLI_PATH: cliPath,
|
||||
ORCA_COMPUTER_SMOKE_USER_DATA_PATH: path.join(root, 'user-data'),
|
||||
ORCA_COMPUTER_SMOKE_APPS: 'TestApp'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('computer-use smoke: no preferred apps are running (TestApp)')
|
||||
})
|
||||
|
||||
it('keeps no-target smoke permissive by default for local probing', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-computer-smoke-test-'))
|
||||
const cliPath = writeFakeListAppsCli(root, [])
|
||||
|
||||
const result = spawnSync(process.execPath, [smokeScript], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_COMPUTER_SMOKE_CLI_PATH: cliPath,
|
||||
ORCA_COMPUTER_SMOKE_USER_DATA_PATH: path.join(root, 'user-data'),
|
||||
ORCA_COMPUTER_SMOKE_APPS: 'TestApp'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('computer-use smoke: no preferred apps are running (TestApp)')
|
||||
})
|
||||
|
||||
it('skips background apps that report window_not_found instead of failing smoke', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-computer-smoke-test-'))
|
||||
const cliPath = writeFakeSnapshotCli(
|
||||
root,
|
||||
[
|
||||
{ name: 'Edge', bundleId: 'com.microsoft.edgemac' },
|
||||
{ name: 'Notepad', bundleId: null }
|
||||
],
|
||||
{
|
||||
windowNotFoundApps: ['Edge']
|
||||
}
|
||||
)
|
||||
|
||||
const result = spawnSync(process.execPath, [smokeScript, '--require-target'], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_COMPUTER_SMOKE_CLI_PATH: cliPath,
|
||||
ORCA_COMPUTER_SMOKE_USER_DATA_PATH: path.join(root, 'user-data'),
|
||||
ORCA_COMPUTER_SMOKE_APPS: 'Edge,Notepad'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('computer-use smoke: Edge: skipped')
|
||||
expect(result.stdout).toContain('computer-use smoke: Notepad')
|
||||
})
|
||||
|
||||
it('uses cross-platform default app targets for smoke snapshots', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-computer-smoke-test-'))
|
||||
const cliPath = writeFakeSnapshotCli(root, [{ name: 'Notepad', bundleId: null }])
|
||||
|
||||
const result = spawnSync(process.execPath, [smokeScript], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_COMPUTER_SMOKE_CLI_PATH: cliPath,
|
||||
ORCA_COMPUTER_SMOKE_USER_DATA_PATH: path.join(root, 'user-data')
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('computer-use smoke: Notepad')
|
||||
})
|
||||
})
|
||||
|
||||
function writeFakeListAppsCli(root, apps) {
|
||||
const cliPath = path.join(root, 'fake-cli.cjs')
|
||||
writeFileSync(
|
||||
cliPath,
|
||||
[
|
||||
'const args = process.argv.slice(2);',
|
||||
'if (args.join(" ") === "computer list-apps --json") {',
|
||||
` console.log(JSON.stringify({ result: { apps: ${JSON.stringify(apps)} } }));`,
|
||||
'} else {',
|
||||
' console.error("unexpected args: " + args.join(" "));',
|
||||
' process.exit(1);',
|
||||
'}'
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(cliPath, 0o755)
|
||||
return cliPath
|
||||
}
|
||||
|
||||
function writeFakeSnapshotCli(root, apps, options = {}) {
|
||||
const cliPath = path.join(root, 'fake-cli.cjs')
|
||||
writeFileSync(
|
||||
cliPath,
|
||||
[
|
||||
'const args = process.argv.slice(2);',
|
||||
`const windowNotFoundApps = new Set(${JSON.stringify(options.windowNotFoundApps ?? [])});`,
|
||||
'if (args.join(" ") === "computer list-apps --json") {',
|
||||
` console.log(JSON.stringify({ result: { apps: ${JSON.stringify(apps)} } }));`,
|
||||
'} else if (args[0] === "computer" && args[1] === "get-app-state") {',
|
||||
' const appIndex = args.indexOf("--app");',
|
||||
' const app = appIndex >= 0 ? args[appIndex + 1] : "Unknown";',
|
||||
' if (windowNotFoundApps.has(app)) {',
|
||||
' console.log(JSON.stringify({ ok: false, error: { code: "window_not_found", message: `app \'${app}\' has no on-screen window` } }));',
|
||||
' process.exit(1);',
|
||||
' }',
|
||||
' console.log(JSON.stringify({ result: {',
|
||||
' snapshot: {',
|
||||
' app: { name: app },',
|
||||
' elementCount: 1,',
|
||||
' treeText: "[1] text area settable",',
|
||||
' window: { title: "Untitled" }',
|
||||
' },',
|
||||
' screenshot: null',
|
||||
' } }));',
|
||||
'} else {',
|
||||
' console.error("unexpected args: " + args.join(" "));',
|
||||
' process.exit(1);',
|
||||
'}'
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
chmodSync(cliPath, 0o755)
|
||||
return cliPath
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const API_VERSION = '2022-11-28'
|
||||
const MAX_RELEASE_BODY_LENGTH = 120_000
|
||||
const TRUNCATION_NOTICE =
|
||||
'\n\n---\nRelease notes were truncated because GitHub release bodies are limited to 125,000 characters.'
|
||||
const DESKTOP_RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/
|
||||
|
||||
export function parseDesktopReleaseTag(tag) {
|
||||
const match = DESKTOP_RELEASE_TAG_PATTERN.exec(tag)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
tag,
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
rc: match[4] === undefined ? null : Number(match[4])
|
||||
}
|
||||
}
|
||||
|
||||
function compareDesktopReleaseTags(a, b) {
|
||||
const versionDiff = a.major - b.major || a.minor - b.minor || a.patch - b.patch
|
||||
if (versionDiff !== 0) {
|
||||
return versionDiff
|
||||
}
|
||||
if (a.rc === b.rc) {
|
||||
return 0
|
||||
}
|
||||
if (a.rc === null) {
|
||||
return 1
|
||||
}
|
||||
if (b.rc === null) {
|
||||
return -1
|
||||
}
|
||||
return a.rc - b.rc
|
||||
}
|
||||
|
||||
export function latestPreviousPublishedDesktopReleaseTag(releases, tag) {
|
||||
const current = parseDesktopReleaseTag(tag)
|
||||
if (!current) {
|
||||
return ''
|
||||
}
|
||||
const previousReleases = releases
|
||||
.filter((release) => release?.draft === false && typeof release.tag_name === 'string')
|
||||
.map((release) => parseDesktopReleaseTag(release.tag_name))
|
||||
.filter((candidate) => candidate && candidate.tag !== current.tag)
|
||||
.filter((candidate) => compareDesktopReleaseTags(candidate, current) < 0)
|
||||
// Why: public changelogs should be bounded by releases users could see;
|
||||
// stable releases summarize since the prior stable, not the latest RC.
|
||||
.filter((candidate) => current.rc !== null || candidate.rc === null)
|
||||
.sort(compareDesktopReleaseTags)
|
||||
return previousReleases.at(-1)?.tag ?? ''
|
||||
}
|
||||
|
||||
function githubHeaders(token) {
|
||||
return {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-GitHub-Api-Version': API_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
async function githubJson(fetchImpl, url, token, options = {}) {
|
||||
const res = await fetchImpl(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...githubHeaders(token),
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function fetchRepoReleases(repo, token, fetchImpl) {
|
||||
const releases = []
|
||||
for (let page = 1; ; page += 1) {
|
||||
const pageReleases = await githubJson(
|
||||
fetchImpl,
|
||||
`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`,
|
||||
token
|
||||
)
|
||||
if (!Array.isArray(pageReleases)) {
|
||||
throw new Error(`GitHub releases response page ${page} for ${repo} was not an array`)
|
||||
}
|
||||
releases.push(...pageReleases)
|
||||
if (pageReleases.length < 100) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return releases
|
||||
}
|
||||
|
||||
export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) {
|
||||
if (body.length <= maxLength) {
|
||||
return body
|
||||
}
|
||||
|
||||
const availableLength = maxLength - TRUNCATION_NOTICE.length
|
||||
if (availableLength <= 0) {
|
||||
throw new Error('Release truncation notice is longer than the maximum release body length')
|
||||
}
|
||||
|
||||
return `${body.slice(0, availableLength).trimEnd()}${TRUNCATION_NOTICE}`
|
||||
}
|
||||
|
||||
export async function createDraftRelease({
|
||||
repo,
|
||||
tag,
|
||||
token,
|
||||
fetchImpl = fetch,
|
||||
log = console.log
|
||||
}) {
|
||||
if (!repo) {
|
||||
throw new Error('repo is required')
|
||||
}
|
||||
if (!tag) {
|
||||
throw new Error('tag is required')
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error('token is required')
|
||||
}
|
||||
|
||||
const previousTag = latestPreviousPublishedDesktopReleaseTag(
|
||||
await fetchRepoReleases(repo, token, fetchImpl),
|
||||
tag
|
||||
)
|
||||
const generateNotesBody = {
|
||||
tag_name: tag,
|
||||
target_commitish: tag,
|
||||
...(previousTag ? { previous_tag_name: previousTag } : {})
|
||||
}
|
||||
|
||||
// Why: GitHub's generate-notes baseline ignores draft releases, so pass the
|
||||
// previous public changelog boundary explicitly.
|
||||
const releaseNotes = await githubJson(
|
||||
fetchImpl,
|
||||
`https://api.github.com/repos/${repo}/releases/generate-notes`,
|
||||
token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(generateNotesBody)
|
||||
}
|
||||
)
|
||||
|
||||
const generatedBody = typeof releaseNotes.body === 'string' ? releaseNotes.body : ''
|
||||
const body = truncateReleaseBody(generatedBody)
|
||||
const name =
|
||||
typeof releaseNotes.name === 'string' && releaseNotes.name.length > 0 ? releaseNotes.name : tag
|
||||
const prerelease = tag.includes('-rc.')
|
||||
|
||||
// Why: GitHub's generated release notes can exceed the release body API
|
||||
// limit, so create with a bounded body. Omit target_commitish because the
|
||||
// release-cut tag already exists and GitHub rejects the tag name there.
|
||||
await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases`, token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
name,
|
||||
body,
|
||||
draft: true,
|
||||
prerelease
|
||||
})
|
||||
})
|
||||
|
||||
if (generatedBody.length !== body.length) {
|
||||
log(`Created draft release ${tag} with truncated generated notes (${body.length} chars).`)
|
||||
} else {
|
||||
log(`Created draft release ${tag} with generated notes (${body.length} chars).`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tag = process.argv[2]
|
||||
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
||||
const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca'
|
||||
await createDraftRelease({ repo, tag, token })
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createDraftRelease,
|
||||
latestPreviousPublishedDesktopReleaseTag,
|
||||
parseDesktopReleaseTag,
|
||||
truncateReleaseBody
|
||||
} from './create-draft-release.mjs'
|
||||
|
||||
function release(tag, options = {}) {
|
||||
return {
|
||||
draft: false,
|
||||
tag_name: tag,
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(body, init = {}) {
|
||||
return {
|
||||
ok: init.ok ?? true,
|
||||
status: init.status ?? 200,
|
||||
statusText: init.statusText ?? 'OK',
|
||||
json: vi.fn(async () => body),
|
||||
text: vi.fn(async () => (typeof body === 'string' ? body : JSON.stringify(body)))
|
||||
}
|
||||
}
|
||||
|
||||
describe('truncateReleaseBody', () => {
|
||||
it('leaves short release notes unchanged', () => {
|
||||
expect(truncateReleaseBody('short notes', 120_000)).toBe('short notes')
|
||||
})
|
||||
|
||||
it('caps long release notes and appends an explanation', () => {
|
||||
const body = truncateReleaseBody('a'.repeat(130_000), 1_000)
|
||||
|
||||
expect(body).toHaveLength(1_000)
|
||||
expect(body).toContain('Release notes were truncated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDesktopReleaseTag', () => {
|
||||
it('parses stable and rc desktop release tags only', () => {
|
||||
expect(parseDesktopReleaseTag('v1.4.36')).toMatchObject({
|
||||
tag: 'v1.4.36',
|
||||
major: 1,
|
||||
minor: 4,
|
||||
patch: 36,
|
||||
rc: null
|
||||
})
|
||||
expect(parseDesktopReleaseTag('v1.4.36-rc.2')).toMatchObject({
|
||||
tag: 'v1.4.36-rc.2',
|
||||
major: 1,
|
||||
minor: 4,
|
||||
patch: 36,
|
||||
rc: 2
|
||||
})
|
||||
expect(parseDesktopReleaseTag('mobile-v0.0.12')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('latestPreviousPublishedDesktopReleaseTag', () => {
|
||||
it('bounds stable notes to the previous stable release when rcs exist', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.35'), release('v1.4.36-rc.0'), release('v1.4.36')],
|
||||
'v1.4.36'
|
||||
)
|
||||
).toBe('v1.4.35')
|
||||
})
|
||||
|
||||
it('does not collapse a stable changelog to its rc-to-stable version bump', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[
|
||||
release('v1.4.120'),
|
||||
release('v1.4.121-rc.0'),
|
||||
release('v1.4.121-rc.6'),
|
||||
release('v1.4.121')
|
||||
],
|
||||
'v1.4.121'
|
||||
)
|
||||
).toBe('v1.4.120')
|
||||
})
|
||||
|
||||
it('bounds the first rc notes to the previous stable release', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.35'), release('v1.4.36-rc.0'), release('mobile-v0.0.12')],
|
||||
'v1.4.36-rc.0'
|
||||
)
|
||||
).toBe('v1.4.35')
|
||||
})
|
||||
|
||||
it('bounds later rc notes to the prior rc', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.36-rc.0'), release('v1.4.36-rc.1')],
|
||||
'v1.4.36-rc.1'
|
||||
)
|
||||
).toBe('v1.4.36-rc.0')
|
||||
})
|
||||
|
||||
it('ignores draft releases as public changelog boundaries', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.35'), release('v1.4.36-rc.0', { draft: true }), release('v1.4.36-rc.1')],
|
||||
'v1.4.36-rc.1'
|
||||
)
|
||||
).toBe('v1.4.35')
|
||||
})
|
||||
|
||||
it('returns empty string for the first desktop release when no earlier tag exists', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.36'), release('mobile-v0.0.12')],
|
||||
'v1.4.36'
|
||||
)
|
||||
).toBe('')
|
||||
expect(latestPreviousPublishedDesktopReleaseTag([], 'v1.4.36')).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when the current tag is not a desktop release tag', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.35'), release('v1.4.36')],
|
||||
'mobile-v0.0.12'
|
||||
)
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDraftRelease', () => {
|
||||
it('creates a draft release with bounded generated notes', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([release('v1.4.35'), release('v1.4.36')]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
|
||||
|
||||
await createDraftRelease({
|
||||
repo: 'stablyai/orca',
|
||||
tag: 'v1.4.36',
|
||||
token: 'token',
|
||||
fetchImpl,
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.github.com/repos/stablyai/orca/releases/generate-notes',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tag_name: 'v1.4.36',
|
||||
target_commitish: 'v1.4.36',
|
||||
previous_tag_name: 'v1.4.35'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'https://api.github.com/repos/stablyai/orca/releases',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(String)
|
||||
})
|
||||
)
|
||||
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
||||
expect(createBody).toMatchObject({
|
||||
tag_name: 'v1.4.36',
|
||||
name: 'v1.4.36',
|
||||
draft: true,
|
||||
prerelease: false
|
||||
})
|
||||
expect(createBody.body).toHaveLength(120_000)
|
||||
expect(createBody.body).toContain('Release notes were truncated')
|
||||
})
|
||||
|
||||
it('marks rc tags as prereleases', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('v1.4.36-rc.1')]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true }))
|
||||
|
||||
await createDraftRelease({
|
||||
repo: 'stablyai/orca',
|
||||
tag: 'v1.4.36-rc.1',
|
||||
token: 'token',
|
||||
fetchImpl,
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
||||
expect(createBody.prerelease).toBe(true)
|
||||
})
|
||||
|
||||
it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('mobile-v0.0.12')]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
|
||||
|
||||
await createDraftRelease({
|
||||
repo: 'stablyai/orca',
|
||||
tag: 'v1.4.36',
|
||||
token: 'token',
|
||||
fetchImpl,
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
|
||||
expect(generateNotesBody).toEqual({ tag_name: 'v1.4.36', target_commitish: 'v1.4.36' })
|
||||
expect(generateNotesBody).not.toHaveProperty('previous_tag_name')
|
||||
})
|
||||
|
||||
it('paginates through every release page before choosing the previous release', async () => {
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) => release(`mobile-v0.0.${index}`))
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(firstPage))
|
||||
.mockResolvedValueOnce(jsonResponse([release('v1.4.35')]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
|
||||
|
||||
await createDraftRelease({
|
||||
repo: 'stablyai/orca',
|
||||
tag: 'v1.4.36',
|
||||
token: 'token',
|
||||
fetchImpl,
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=2',
|
||||
expect.any(Object)
|
||||
)
|
||||
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
||||
expect(generateNotesBody.previous_tag_name).toBe('v1.4.35')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Boots the BUILT terminal daemon (out/main/daemon-entry.js) under plain Node —
|
||||
* the exact way production forks it (ELECTRON_RUN_AS_NODE = a plain-Node
|
||||
* process) — and asserts it starts, serves a real PTY, and stops.
|
||||
*
|
||||
* Why this exists: native-smoke CI (and packaging) went green while
|
||||
* v1.4.129-rc.1 shipped a daemon that exited code 1 at module load because an
|
||||
* electron `require` leaked into its bundle graph. Nothing executed the built
|
||||
* entry under plain Node, so the outage was invisible until an adopted old
|
||||
* daemon died in the field. This runs on every PR that touches the daemon.
|
||||
*
|
||||
* Hard assertions (fail the job):
|
||||
* - the daemon signals `{ type: 'ready' }` over IPC within the timeout, and
|
||||
* - it terminates when asked (no hang / zombie).
|
||||
* Best-effort (logged skip, never fails): an end-to-end `ptySpawnHealth` RPC,
|
||||
* because node-pty spawn can be flaky on constrained CI runners.
|
||||
*/
|
||||
import { fork } from 'node:child_process'
|
||||
import { connect } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js')
|
||||
|
||||
const READY_TIMEOUT_MS = 30_000
|
||||
const PTY_HEALTH_TIMEOUT_MS = 10_000
|
||||
const SHUTDOWN_TIMEOUT_MS = 10_000
|
||||
|
||||
function log(message) {
|
||||
process.stdout.write(`[daemon-boot-smoke] ${message}\n`)
|
||||
}
|
||||
|
||||
// Why: the daemon rejects a hello whose protocol version differs, so read the
|
||||
// current version from source rather than hardcoding a number that can drift.
|
||||
function readProtocolVersion() {
|
||||
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
|
||||
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
|
||||
if (!match) {
|
||||
throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts')
|
||||
}
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
function makeSocketPath(userDataDir) {
|
||||
// Why: Windows AF_UNIX-style IPC uses named pipes; POSIX uses a filesystem
|
||||
// socket kept under the scratch userData dir so cleanup removes it.
|
||||
if (process.platform === 'win32') {
|
||||
return `\\\\.\\pipe\\orca-daemon-smoke-${process.pid}-${randomUUID()}`
|
||||
}
|
||||
return join(userDataDir, 'daemon.sock')
|
||||
}
|
||||
|
||||
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
|
||||
// Connects a single control socket, completes the hello handshake, and calls
|
||||
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
|
||||
// true on success, false on any failure — never throws.
|
||||
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
return new Promise((resolveCheck) => {
|
||||
let settled = false
|
||||
let buffer = ''
|
||||
const socket = connect(socketPath)
|
||||
const finish = (ok, reason) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
if (!ok && reason) {
|
||||
log(`PTY spawn health check skipped (best-effort): ${reason}`)
|
||||
}
|
||||
resolveCheck(ok)
|
||||
}
|
||||
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
|
||||
|
||||
socket.on('error', (err) => finish(false, err.message))
|
||||
socket.on('connect', () => {
|
||||
const token = readFileSync(tokenPath, 'utf8').trim()
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
type: 'hello',
|
||||
version: protocolVersion,
|
||||
token,
|
||||
clientId: randomUUID(),
|
||||
role: 'control'
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8')
|
||||
let newlineIdx = buffer.indexOf('\n')
|
||||
while (newlineIdx !== -1) {
|
||||
const line = buffer.slice(0, newlineIdx)
|
||||
buffer = buffer.slice(newlineIdx + 1)
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(line)
|
||||
} catch {
|
||||
finish(false, 'invalid response line')
|
||||
return
|
||||
}
|
||||
if (msg.type === 'hello') {
|
||||
if (!msg.ok) {
|
||||
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
|
||||
return
|
||||
}
|
||||
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
|
||||
} else if (msg.id === 'health-1') {
|
||||
finish(
|
||||
msg.ok === true,
|
||||
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
|
||||
)
|
||||
return
|
||||
}
|
||||
newlineIdx = buffer.indexOf('\n')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
|
||||
const socketPath = makeSocketPath(userDataDir)
|
||||
const tokenPath = join(userDataDir, 'daemon.token')
|
||||
const protocolVersion = readProtocolVersion()
|
||||
|
||||
log(`forking ${entryPath} under plain Node (${process.execPath})`)
|
||||
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
|
||||
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
|
||||
// CI, and this is exactly the runtime where a leaked `require("electron")`
|
||||
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
|
||||
})
|
||||
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
process.stdout.write(chunk)
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise((resolveReady, rejectReady) => {
|
||||
const timer = setTimeout(() => {
|
||||
rejectReady(
|
||||
new Error(
|
||||
`daemon did not signal 'ready' within ${READY_TIMEOUT_MS}ms.\nstderr:\n${stderr}`
|
||||
)
|
||||
)
|
||||
}, READY_TIMEOUT_MS)
|
||||
child.on('message', (msg) => {
|
||||
if (msg && typeof msg === 'object' && msg.type === 'ready') {
|
||||
clearTimeout(timer)
|
||||
resolveReady()
|
||||
}
|
||||
})
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
rejectReady(new Error(`daemon fork errored: ${err.message}\nstderr:\n${stderr}`))
|
||||
})
|
||||
child.on('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
rejectReady(
|
||||
new Error(
|
||||
`daemon exited before 'ready' (code=${code}, signal=${signal}).\nstderr:\n${stderr}`
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
log('daemon signaled ready')
|
||||
|
||||
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
|
||||
if (ptyHealthy) {
|
||||
log('ptySpawnHealth OK — daemon spawned a real PTY end-to-end')
|
||||
}
|
||||
|
||||
await new Promise((resolveExit, rejectExit) => {
|
||||
const timer = setTimeout(() => {
|
||||
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
|
||||
}, SHUTDOWN_TIMEOUT_MS)
|
||||
child.on('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
log(`daemon exited after signal (code=${code}, signal=${signal})`)
|
||||
resolveExit()
|
||||
})
|
||||
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
|
||||
// Windows has no POSIX signal delivery, so Node maps this to process
|
||||
// termination. Either way the hard assertion is "it stops, no hang".
|
||||
child.kill('SIGTERM')
|
||||
})
|
||||
|
||||
log('PASS: daemon booted, served, and shut down under plain Node')
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[daemon-boot-smoke] FAIL: ${error.message}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch `pn dev` with a fresh, isolated userData profile so the app behaves
|
||||
# like a first-time install (onboarding overlay paints, no persisted repos,
|
||||
# no saved sessions). Your real `orca-dev` profile is left untouched.
|
||||
#
|
||||
# Usage:
|
||||
# ./config/scripts/dev-fresh-profile.sh # ephemeral temp profile, deleted on exit
|
||||
# ./config/scripts/dev-fresh-profile.sh --keep # keep the profile dir after exit
|
||||
# ORCA_FRESH_PROFILE_DIR=/some/path ./config/scripts/dev-fresh-profile.sh # use a fixed dir
|
||||
set -euo pipefail
|
||||
|
||||
KEEP=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--keep) KEEP=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0 ;;
|
||||
*)
|
||||
echo "Unknown argument: $arg" >&2
|
||||
echo "Usage: $0 [--keep] [--help]" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PROFILE_DIR="${ORCA_FRESH_PROFILE_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/orca-fresh-profile.XXXXXXXX")}"
|
||||
mkdir -p "$PROFILE_DIR"
|
||||
|
||||
cleanup() {
|
||||
if [[ "$KEEP" -eq 0 && -z "${ORCA_FRESH_PROFILE_DIR:-}" ]]; then
|
||||
# Guard rm -rf against accidental empty/unrelated PROFILE_DIR.
|
||||
[[ -n "${PROFILE_DIR:-}" && -d "$PROFILE_DIR" && "$PROFILE_DIR" == */orca-fresh-profile* ]] || return 0
|
||||
rm -rf "$PROFILE_DIR"
|
||||
echo "[dev-fresh-profile] removed $PROFILE_DIR"
|
||||
else
|
||||
echo "[dev-fresh-profile] kept $PROFILE_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[dev-fresh-profile] using userData=$PROFILE_DIR"
|
||||
# Don't exec — we need the EXIT trap to fire so the temp profile gets cleaned up.
|
||||
ORCA_DEV_USER_DATA_PATH="$PROFILE_DIR" ORCA_DEV_SHOW_FIRST_RUN_EDUCATION=1 pnpm dev
|
||||
@@ -0,0 +1,382 @@
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const electronBuilderConfig = require('../electron-builder.config.cjs')
|
||||
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
|
||||
const {
|
||||
createPackagedRuntimeNodeModuleResources,
|
||||
findAsarEntry,
|
||||
prunePackagedNodePty,
|
||||
prunePackagedParcelWatcher,
|
||||
prunePackagedSherpaOnnx,
|
||||
prunePackagedRuntimeTypeDeclarations,
|
||||
prunePackagedZodSources,
|
||||
verifyPackagedMainRuntimeDeps
|
||||
} = require('../packaged-runtime-node-modules.cjs')
|
||||
|
||||
describe('electron-builder config', () => {
|
||||
it('excludes repo-only source trees from app.asar', () => {
|
||||
expect(electronBuilderConfig.files).toEqual(
|
||||
expect.arrayContaining([
|
||||
'!src{,/**/*}',
|
||||
'!config{,/**/*}',
|
||||
'!docs{,/**/*}',
|
||||
'!mobile{,/**/*}',
|
||||
'!native{,/**/*}',
|
||||
'!skills{,/**/*}',
|
||||
'!tests{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
'!out/**/*.test.js'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps runtime resources available through extraResources', () => {
|
||||
expect(electronBuilderConfig.mac.extraResources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
from: 'native/computer-use-macos/.build/release/Orca Computer Use.app',
|
||||
to: 'Orca Computer Use.app'
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(electronBuilderConfig.linux.extraResources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
from: 'native/computer-use-linux/runtime.py',
|
||||
to: 'computer-use-linux/runtime.py'
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(electronBuilderConfig.win.extraResources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
from: 'native/computer-use-windows/runtime.ps1',
|
||||
to: 'computer-use-windows/runtime.ps1'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
from: 'native/windows-cli-launcher/.build/orca.exe',
|
||||
to: 'bin/orca.exe'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
|
||||
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
|
||||
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
|
||||
expect(electronBuilderConfig.mac.extraFiles).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
from: 'native/notification-status-macos/.build/release/orca-notification-status',
|
||||
to: 'MacOS/orca-notification-status'
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ to: 'orca-notification-status' })])
|
||||
)
|
||||
})
|
||||
|
||||
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
|
||||
)
|
||||
})
|
||||
|
||||
// Why: without the unpacked entry the watcher client silently falls back to
|
||||
// in-process @parcel/watcher, reintroducing the #7547 main-process crash.
|
||||
it('unpacks the forked parcel-watcher process entry', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/main/parcel-watcher-process-entry.js'])
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the multi-size icon source for Linux packages', () => {
|
||||
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
|
||||
})
|
||||
|
||||
it('matches the Linux desktop entry to Electron window class', () => {
|
||||
expect(electronBuilderConfig.linux.desktop.entry.StartupWMClass).toBe('orca')
|
||||
})
|
||||
|
||||
it('uses AppImage and deb as local Linux targets without changing existing artifact names', () => {
|
||||
expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb'])
|
||||
expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}')
|
||||
expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}')
|
||||
expect(electronBuilderConfig.rpm).toMatchObject({
|
||||
packageName: 'orca-ide',
|
||||
artifactName: 'orca-ide-${version}.${arch}.${ext}'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a distinct AppImage name for Linux arm64 release uploads', () => {
|
||||
const configPath = require.resolve('../electron-builder.config.cjs')
|
||||
const original = process.env.ORCA_LINUX_ARM64_RELEASE
|
||||
try {
|
||||
delete require.cache[configPath]
|
||||
process.env.ORCA_LINUX_ARM64_RELEASE = '1'
|
||||
expect(require('../electron-builder.config.cjs').appImage.artifactName).toBe(
|
||||
'orca-linux-arm64.${ext}'
|
||||
)
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.ORCA_LINUX_ARM64_RELEASE
|
||||
} else {
|
||||
process.env.ORCA_LINUX_ARM64_RELEASE = original
|
||||
}
|
||||
delete require.cache[configPath]
|
||||
require('../electron-builder.config.cjs')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
|
||||
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
|
||||
expect(electronBuilderConfig.npmRebuild).toBe(true)
|
||||
})
|
||||
|
||||
it('verifies packaged main runtime deps from Windows-style asar entries', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-'))
|
||||
try {
|
||||
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true })
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true })
|
||||
|
||||
const sources = new Map([
|
||||
['out\\main\\index.js', 'const z = require("zod")'],
|
||||
['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")']
|
||||
])
|
||||
const asar = {
|
||||
listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`),
|
||||
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
|
||||
}
|
||||
|
||||
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes host-specific asar entry separators', () => {
|
||||
expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe(
|
||||
'\\out\\main\\index.js'
|
||||
)
|
||||
expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
|
||||
})
|
||||
|
||||
it('prunes non-target node-pty prebuilds from packaged runtime resources', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
|
||||
try {
|
||||
const prebuildsDir = join(resourcesDir, 'node_modules', 'node-pty', 'prebuilds')
|
||||
await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
|
||||
await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party', 'conpty'), {
|
||||
recursive: true
|
||||
})
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps', 'winpty'), {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
prunePackagedNodePty(resourcesDir, 'darwin')
|
||||
|
||||
await expect(readdir(prebuildsDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'darwin-arm64',
|
||||
'darwin-x64'
|
||||
])
|
||||
await expect(
|
||||
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party'))
|
||||
).resolves.toEqual([])
|
||||
await expect(
|
||||
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))
|
||||
).resolves.toEqual([])
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
|
||||
for (const arch of ['x64', 'arm64']) {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
|
||||
try {
|
||||
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
|
||||
const releaseDir = join(nodePtyDir, 'build', 'Release')
|
||||
const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0')
|
||||
await mkdir(releaseDir, { recursive: true })
|
||||
await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8')
|
||||
for (const sourceArch of ['x64', 'arm64']) {
|
||||
const sourceDir = join(conptyRoot, `win10-${sourceArch}`)
|
||||
await mkdir(sourceDir, { recursive: true })
|
||||
await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8')
|
||||
await writeFile(
|
||||
join(sourceDir, 'OpenConsole.exe'),
|
||||
`console payload ${sourceArch}`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
prunePackagedNodePty(resourcesDir, 'win32', arch)
|
||||
|
||||
await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
|
||||
`dll payload ${arch}`
|
||||
)
|
||||
await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe(
|
||||
`console payload ${arch}`
|
||||
)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('includes @parcel/watcher in the packaged runtime closure', () => {
|
||||
// Why: the main process imports '@parcel/watcher' for filesystem change
|
||||
// events; if it is absent from the packaged closure the serve host silently
|
||||
// stops propagating file changes to clients (regression guard for #4851).
|
||||
const packaged = createPackagedRuntimeNodeModuleResources()
|
||||
const packagedTargets = packaged.map((resource) => resource.to)
|
||||
expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher'))
|
||||
expect(
|
||||
packagedTargets.some((target) =>
|
||||
target.startsWith(join('node_modules', '@parcel', 'watcher-'))
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('prunes non-target @parcel/watcher platform subpackages from packaged runtime resources', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
|
||||
try {
|
||||
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
|
||||
await mkdir(join(parcelDir, 'watcher'), { recursive: true })
|
||||
await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
|
||||
await mkdir(join(parcelDir, 'watcher-darwin-x64'), { recursive: true })
|
||||
await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
|
||||
await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
|
||||
await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
|
||||
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux')
|
||||
|
||||
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'watcher',
|
||||
'watcher-linux-arm64-glibc',
|
||||
'watcher-linux-x64-glibc'
|
||||
])
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves unrelated @parcel/* runtime deps untouched when pruning the watcher', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-unrelated-'))
|
||||
try {
|
||||
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
|
||||
await mkdir(join(parcelDir, 'watcher'), { recursive: true })
|
||||
await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
|
||||
await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
|
||||
// A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
|
||||
await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
|
||||
|
||||
prunePackagedParcelWatcher(resourcesDir, 'linux')
|
||||
|
||||
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'transformer-js',
|
||||
'watcher',
|
||||
'watcher-linux-x64-glibc'
|
||||
])
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('prunes type declaration artifacts from packaged runtime node_modules', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-'))
|
||||
try {
|
||||
const packageDir = join(resourcesDir, 'node_modules', 'example-package')
|
||||
await mkdir(join(packageDir, 'dist'), { recursive: true })
|
||||
await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8')
|
||||
await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8')
|
||||
await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8')
|
||||
await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8')
|
||||
|
||||
prunePackagedRuntimeTypeDeclarations(resourcesDir)
|
||||
|
||||
await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs'])
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-'))
|
||||
try {
|
||||
const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64')
|
||||
await mkdir(packageDir, { recursive: true })
|
||||
await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8')
|
||||
await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8')
|
||||
await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8')
|
||||
|
||||
prunePackagedSherpaOnnx(resourcesDir, 'darwin')
|
||||
|
||||
await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([
|
||||
'libonnxruntime.1.23.2.dylib',
|
||||
'sherpa-onnx.node'
|
||||
])
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('prunes zod TypeScript sources from packaged runtime resources', async () => {
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-'))
|
||||
try {
|
||||
const packageDir = join(resourcesDir, 'node_modules', 'zod')
|
||||
await mkdir(join(packageDir, 'src'), { recursive: true })
|
||||
await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8')
|
||||
await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8')
|
||||
|
||||
prunePackagedZodSources(resourcesDir)
|
||||
|
||||
await expect(readdir(packageDir)).resolves.toEqual(['index.cjs'])
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'marks packaged Unix CLI launchers executable',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
|
||||
try {
|
||||
const resourcesDir = join(root, 'linux-unpacked', 'resources')
|
||||
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
|
||||
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
|
||||
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
|
||||
// Why: afterPack now fails hard when the unpacked daemon entry is
|
||||
// missing, so the fixture must carry one like a real package layout.
|
||||
const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
|
||||
await mkdir(unpackedMainDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(unpackedMainDir, 'daemon-entry.js'),
|
||||
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n',
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
|
||||
|
||||
await electronBuilderConfig.afterPack({
|
||||
appOutDir: join(root, 'linux-unpacked'),
|
||||
electronPlatformName: 'linux'
|
||||
})
|
||||
|
||||
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { resolve } = require('node:path')
|
||||
|
||||
const projectDir = resolve(__dirname, '../..')
|
||||
|
||||
function electronBuilderNativeRebuild(context) {
|
||||
return runElectronBuilderNativeRebuild(context)
|
||||
}
|
||||
|
||||
function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
|
||||
const args = buildNativeRebuildArgs(context)
|
||||
if (readPlatformName(context?.platform) === 'win32') {
|
||||
runner(process.execPath, ['config/scripts/build-windows-cli-launcher.mjs'], {
|
||||
cwd: projectDir,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
}
|
||||
runner(process.execPath, args, {
|
||||
cwd: projectDir,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
// Why: returning false tells electron-builder that native deps were handled
|
||||
// externally, avoiding its all-module rebuild of optional cpu-features.
|
||||
return false
|
||||
}
|
||||
|
||||
function buildNativeRebuildArgs(context) {
|
||||
const platform = readPlatformName(context?.platform)
|
||||
const arch = readArchName(context?.arch)
|
||||
|
||||
return [
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
`--platform=${platform}`,
|
||||
`--arch=${arch}`,
|
||||
'--force'
|
||||
]
|
||||
}
|
||||
|
||||
function readPlatformName(platform) {
|
||||
const name = typeof platform === 'string' ? platform : platform?.nodeName
|
||||
if (!name) {
|
||||
throw new Error('electron-builder native rebuild context is missing platform.nodeName')
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
function readArchName(arch) {
|
||||
if (!arch || typeof arch !== 'string') {
|
||||
throw new Error('electron-builder native rebuild context is missing arch')
|
||||
}
|
||||
return arch
|
||||
}
|
||||
|
||||
module.exports = electronBuilderNativeRebuild
|
||||
module.exports.default = electronBuilderNativeRebuild
|
||||
module.exports.buildNativeRebuildArgs = buildNativeRebuildArgs
|
||||
module.exports.runElectronBuilderNativeRebuild = runElectronBuilderNativeRebuild
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
buildNativeRebuildArgs,
|
||||
runElectronBuilderNativeRebuild
|
||||
} = require('./electron-builder-native-rebuild.cjs')
|
||||
|
||||
describe('electron-builder native rebuild hook', () => {
|
||||
it('passes the target platform and arch to Orca native rebuild script', () => {
|
||||
expect(
|
||||
buildNativeRebuildArgs({
|
||||
platform: { nodeName: 'darwin' },
|
||||
arch: 'x64'
|
||||
})
|
||||
).toEqual([
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
'--platform=darwin',
|
||||
'--arch=x64',
|
||||
'--force'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns false so electron-builder skips its optional module rebuild pass', () => {
|
||||
const calls = []
|
||||
const result = runElectronBuilderNativeRebuild(
|
||||
{
|
||||
platform: { nodeName: 'linux' },
|
||||
arch: 'arm64'
|
||||
},
|
||||
(...args) => calls.push(args)
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
process.execPath,
|
||||
['config/scripts/rebuild-native-deps.mjs', '--platform=linux', '--arch=arm64', '--force'],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('builds the native CLI launcher before packaging Windows resources', () => {
|
||||
const calls = []
|
||||
const result = runElectronBuilderNativeRebuild(
|
||||
{
|
||||
platform: { nodeName: 'win32' },
|
||||
arch: 'x64'
|
||||
},
|
||||
(...args) => calls.push(args)
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs'],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
],
|
||||
[
|
||||
process.execPath,
|
||||
['config/scripts/rebuild-native-deps.mjs', '--platform=win32', '--arch=x64', '--force'],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects incomplete electron-builder contexts', () => {
|
||||
expect(() => buildNativeRebuildArgs({ arch: 'x64' })).toThrow(/platform/)
|
||||
expect(() => buildNativeRebuildArgs({ platform: { nodeName: 'linux' } })).toThrow(/arch/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { release } from 'node:os'
|
||||
import { basename, resolve } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const scriptPath = import.meta.filename
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const runtime = readRuntimeArg()
|
||||
|
||||
const NATIVE_MODULES = ['node-pty']
|
||||
const CHILD_CHECK_FLAG = '--check-only'
|
||||
|
||||
if (process.argv.includes(CHILD_CHECK_FLAG)) {
|
||||
const failures = collectNativeModuleFailures()
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
console.error(`${failure.moduleName}: ${failure.message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (runtime === 'node') {
|
||||
ensureNodeRuntime()
|
||||
} else if (runtime === 'electron') {
|
||||
ensureElectronRuntime()
|
||||
} else {
|
||||
console.error('Usage: node config/scripts/ensure-native-runtime.mjs --runtime=node|electron')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
function readRuntimeArg() {
|
||||
const inline = process.argv.find((arg) => arg.startsWith('--runtime='))
|
||||
if (inline) {
|
||||
return inline.slice('--runtime='.length)
|
||||
}
|
||||
|
||||
const runtimeIndex = process.argv.indexOf('--runtime')
|
||||
if (runtimeIndex >= 0) {
|
||||
return process.argv[runtimeIndex + 1]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function ensureNodeRuntime() {
|
||||
const initial = runNodeCheck()
|
||||
const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason()
|
||||
if (initial.ok && !patchedNodePtyRebuildReason) {
|
||||
return
|
||||
}
|
||||
|
||||
if (patchedNodePtyRebuildReason) {
|
||||
console.warn(`[native-runtime] ${patchedNodePtyRebuildReason}`)
|
||||
if (!initial.ok) {
|
||||
printCheckError(initial)
|
||||
}
|
||||
runPnpm(['rebuild', 'node-pty'])
|
||||
verifyNodeRuntimeAfterRebuild()
|
||||
return
|
||||
}
|
||||
|
||||
const failedModules = initial.failures.map((failure) => failure.moduleName)
|
||||
console.warn(
|
||||
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
|
||||
)
|
||||
printCheckError(initial)
|
||||
runPnpm(['rebuild', ...failedModules])
|
||||
verifyNodeRuntimeAfterRebuild()
|
||||
}
|
||||
|
||||
function verifyNodeRuntimeAfterRebuild() {
|
||||
const final = runNodeCheck()
|
||||
if (!final.ok) {
|
||||
console.error(
|
||||
`[native-runtime] Native modules still do not load for ${formatRuntimeLabel('node')}.`
|
||||
)
|
||||
printCheckError(final)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureElectronRuntime() {
|
||||
const initial = runElectronCheck()
|
||||
const patchedNodePtyRebuildReason = getPatchedNodePtyRebuildReason()
|
||||
if (initial.ok && !patchedNodePtyRebuildReason) {
|
||||
return
|
||||
}
|
||||
|
||||
if (patchedNodePtyRebuildReason) {
|
||||
console.warn(`[native-runtime] ${patchedNodePtyRebuildReason}`)
|
||||
if (!initial.ok) {
|
||||
printCheckError(initial)
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[native-runtime] ${formatRuntimeLabel('electron')} cannot load native modules; rebuilding native deps for Electron.`
|
||||
)
|
||||
printCheckError(initial)
|
||||
}
|
||||
runNodeScript(['config/scripts/rebuild-native-deps.mjs'])
|
||||
|
||||
const final = runElectronCheck()
|
||||
if (!final.ok) {
|
||||
console.error(
|
||||
`[native-runtime] Native modules still do not load for ${formatRuntimeLabel('electron')}.`
|
||||
)
|
||||
printCheckError(final)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
function runNodeCheck() {
|
||||
// Why: a failed native addon load can poison the current process, so the
|
||||
// post-rebuild verification must happen in a fresh Node process.
|
||||
const result = spawnSync(process.execPath, [scriptPath, CHILD_CHECK_FLAG], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
return parseChildCheckResult(result)
|
||||
}
|
||||
|
||||
function runElectronCheck() {
|
||||
const electronExecutable = resolveInstalledElectronExecutable()
|
||||
if (!electronExecutable.ok) {
|
||||
return { ok: false, error: electronExecutable.error }
|
||||
}
|
||||
|
||||
const result = spawnSync(electronExecutable.path, [scriptPath, CHILD_CHECK_FLAG], {
|
||||
cwd: projectDir,
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
},
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
return parseChildCheckResult(result)
|
||||
}
|
||||
|
||||
function resolveInstalledElectronExecutable() {
|
||||
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
|
||||
try {
|
||||
const electronVersion = JSON.parse(
|
||||
readFileSync(resolve(electronPackageDir, 'package.json'), 'utf8')
|
||||
).version
|
||||
const platformPath = getElectronPlatformPath()
|
||||
const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8')
|
||||
.trim()
|
||||
.replace(/^v/, '')
|
||||
if (installedVersion !== electronVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
`Electron package binary version ${installedVersion} does not match ${electronVersion}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8')
|
||||
if (installedPlatformPath !== platformPath) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
`Electron package path.txt points at ${installedPlatformPath}, expected ${platformPath}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
const electronPath = process.env.ELECTRON_OVERRIDE_DIST_PATH
|
||||
? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath)
|
||||
: resolve(electronPackageDir, 'dist', platformPath)
|
||||
if (!existsSync(electronPath)) {
|
||||
return { ok: false, error: new Error(`Electron executable is missing at ${electronPath}.`) }
|
||||
}
|
||||
return { ok: true, path: electronPath }
|
||||
} catch (error) {
|
||||
return { ok: false, error }
|
||||
}
|
||||
}
|
||||
|
||||
function getElectronPlatformPath() {
|
||||
const targetPlatform =
|
||||
process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || process.platform
|
||||
switch (targetPlatform) {
|
||||
case 'mas':
|
||||
case 'darwin':
|
||||
return 'Electron.app/Contents/MacOS/Electron'
|
||||
case 'freebsd':
|
||||
case 'openbsd':
|
||||
case 'linux':
|
||||
return 'electron'
|
||||
case 'win32':
|
||||
return 'electron.exe'
|
||||
default:
|
||||
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseChildCheckResult(result) {
|
||||
const failures = parseCheckFailures(result.stderr)
|
||||
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
status: result.status,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.error,
|
||||
failures
|
||||
}
|
||||
}
|
||||
|
||||
function parseCheckFailures(stderr) {
|
||||
const failures = []
|
||||
for (const line of (stderr ?? '').split(/\r?\n/)) {
|
||||
const match = /^([^:]+):\s*(.*)$/.exec(line)
|
||||
if (match && NATIVE_MODULES.includes(match[1])) {
|
||||
failures.push({ moduleName: match[1], message: match[2] })
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
function collectNativeModuleFailures() {
|
||||
const failures = []
|
||||
for (const moduleName of NATIVE_MODULES) {
|
||||
try {
|
||||
loadNativeModule(moduleName)
|
||||
} catch (cause) {
|
||||
failures.push({ moduleName, message: formatError(cause), cause })
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
function loadNativeModule(moduleName) {
|
||||
if (moduleName === 'node-pty') {
|
||||
loadNodePtyNativeModule()
|
||||
return
|
||||
}
|
||||
|
||||
require(moduleName)
|
||||
}
|
||||
|
||||
function loadNodePtyNativeModule() {
|
||||
require('node-pty')
|
||||
|
||||
const { loadNativeModule } = require('node-pty/lib/utils')
|
||||
const nativeName = getNodePtyNativeModuleName()
|
||||
// Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a
|
||||
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
|
||||
const native = loadNativeModule(nativeName)
|
||||
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native.dir)) {
|
||||
throw new Error(
|
||||
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getNodePtyNativeModuleName() {
|
||||
if (process.platform !== 'win32') {
|
||||
return 'pty'
|
||||
}
|
||||
|
||||
return getWindowsBuildNumber() >= 18309 ? 'conpty' : 'pty'
|
||||
}
|
||||
|
||||
function getPatchedNodePtyRebuildReason() {
|
||||
if (!requiresPatchedNodePtySourceBuild()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
|
||||
// patch only lands in the source-built build/Release artifacts.
|
||||
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
|
||||
|
||||
if (!missingArtifact) {
|
||||
return null
|
||||
}
|
||||
|
||||
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
||||
}
|
||||
|
||||
function requiresPatchedNodePtySourceBuild() {
|
||||
if (process.platform === 'win32') {
|
||||
return false
|
||||
}
|
||||
|
||||
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
|
||||
if (!existsSync(nodePtyPatchPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return existsSync(resolve(projectDir, 'node_modules', 'node-pty'))
|
||||
}
|
||||
|
||||
function isNodePtyReleaseBuildDir(nativeDir) {
|
||||
return typeof nativeDir === 'string' && nativeDir.replace(/\\/g, '/').includes('build/Release/')
|
||||
}
|
||||
|
||||
function getWindowsBuildNumber() {
|
||||
const match = /(\d+)\.(\d+)\.(\d+)/g.exec(release())
|
||||
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
|
||||
}
|
||||
|
||||
function runPnpm(args) {
|
||||
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: projectDir,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32'
|
||||
})
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
|
||||
if (result.error) {
|
||||
console.error(formatError(result.error))
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
function runNodeScript(args) {
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: projectDir,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
console.error(`[native-runtime] ${basename(process.execPath)} ${args.join(' ')} failed.`)
|
||||
if (result.error) {
|
||||
console.error(formatError(result.error))
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
function printCheckError(result) {
|
||||
for (const failure of result.failures ?? []) {
|
||||
console.warn(`[native-runtime] ${failure.moduleName}: ${failure.message}`)
|
||||
}
|
||||
if (result.error) {
|
||||
console.warn(`[native-runtime] ${formatError(result.error)}`)
|
||||
}
|
||||
if (result.stderr?.trim()) {
|
||||
console.warn(result.stderr.trim())
|
||||
}
|
||||
if (result.stdout?.trim()) {
|
||||
console.warn(result.stdout.trim())
|
||||
}
|
||||
if (
|
||||
result.status != null &&
|
||||
!result.error &&
|
||||
!result.stderr?.trim() &&
|
||||
!result.stdout?.trim() &&
|
||||
result.status !== 0
|
||||
) {
|
||||
console.warn(`[native-runtime] Native check exited with status ${result.status}.`)
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function formatRuntimeLabel(value) {
|
||||
if (value === 'electron') {
|
||||
return `Electron ${process.env.npm_package_devDependencies_electron ?? ''}`.trim()
|
||||
}
|
||||
return `Node ${process.versions.node}`
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
|
||||
|
||||
describe('ensure-native-runtime', () => {
|
||||
it('rechecks Node native modules in fresh child processes after rebuilding', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeFakeNativeModules(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log).toContain('pnpm rebuild node-pty\n')
|
||||
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
|
||||
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
|
||||
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
|
||||
])
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rebuilds patched node-pty artifacts even when the Node load check passes',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeLoadableNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).toContain(
|
||||
'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
||||
)
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rebuilds when patched artifacts exist but node-pty resolves to prebuilds',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeLoadableNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writePatchedNodePtyBuildArtifacts(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'keeps the fast path when the platform-specific patched artifacts exist',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' })
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writePatchedNodePtyBuildArtifacts(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function mkTempProject() {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-'))
|
||||
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
|
||||
return projectDir
|
||||
}
|
||||
|
||||
function envWithPrependedPath(binDir, extraEnv) {
|
||||
const pathKey =
|
||||
process.platform === 'win32'
|
||||
? (Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'Path')
|
||||
: 'PATH'
|
||||
return {
|
||||
...process.env,
|
||||
...extraEnv,
|
||||
[pathKey]: `${binDir}${delimiter}${process.env[pathKey] ?? ''}`
|
||||
}
|
||||
}
|
||||
|
||||
function writeFakeNativeModules(projectDir) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
join(nodePtyDir, 'lib', 'utils.js'),
|
||||
`
|
||||
const { appendFileSync, existsSync } = require('node:fs')
|
||||
|
||||
exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
const markerExists = existsSync(process.env.ORCA_NATIVE_TEST_MARKER)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`node-pty \${process.argv.includes('--check-only') ? 'child' : 'parent'} \${nativeName} marker=\${markerExists}\\n\`
|
||||
)
|
||||
if (!markerExists) {
|
||||
throw new Error('ABI mismatch sentinel')
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
join(nodePtyDir, 'lib', 'utils.js'),
|
||||
`
|
||||
const { appendFileSync, existsSync } = require('node:fs')
|
||||
|
||||
exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
const rebuilt = existsSync(process.env.ORCA_NATIVE_TEST_MARKER)
|
||||
const dir = ${JSON.stringify(nativeDir)} ??
|
||||
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
|
||||
return { dir, module: {} }
|
||||
}
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
function writeNodePtyPatchFile(projectDir) {
|
||||
mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true })
|
||||
writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n')
|
||||
}
|
||||
|
||||
function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
writeFileSync(join(buildDir, 'pty.node'), '')
|
||||
if (process.platform === 'darwin') {
|
||||
writeFileSync(join(buildDir, 'spawn-helper'), '')
|
||||
}
|
||||
}
|
||||
|
||||
function writeFakePnpm(binDir) {
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
const shimPath = join(binDir, 'pnpm-shim.cjs')
|
||||
writeFileSync(
|
||||
shimPath,
|
||||
`
|
||||
const { appendFileSync, writeFileSync } = require('node:fs')
|
||||
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
|
||||
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
|
||||
`
|
||||
)
|
||||
|
||||
const posixPnpmPath = join(binDir, 'pnpm')
|
||||
writeFileSync(posixPnpmPath, `#!/usr/bin/env node\nrequire(${JSON.stringify(shimPath)})\n`)
|
||||
chmodSync(posixPnpmPath, 0o755)
|
||||
writeFileSync(
|
||||
join(binDir, 'pnpm.cmd'),
|
||||
`@echo off\r\n"${process.execPath}" "%~dp0\\pnpm-shim.cjs" %*\r\n`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env node
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
rootDirs: 80,
|
||||
nestedDirsPerRoot: 20,
|
||||
filesPerNestedDir: 70,
|
||||
filesPerRoot: 20,
|
||||
windowSize: 80,
|
||||
passes: 80
|
||||
}
|
||||
|
||||
function parseOptions() {
|
||||
const options = { ...DEFAULT_OPTIONS }
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
const match = arg.match(/^--([^=]+)=(\d+)$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const [, key, value] = match
|
||||
if (key in options) {
|
||||
options[key] = Number(value)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function joinExplorerPath(parent, name) {
|
||||
return parent.endsWith('/') ? `${parent}${name}` : `${parent}/${name}`
|
||||
}
|
||||
|
||||
function relativePath(worktreePath, path) {
|
||||
return path.slice(worktreePath.length + 1).replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
function addChild(dirCache, parentPath, child) {
|
||||
const current = dirCache[parentPath] ?? { children: [], loading: false }
|
||||
current.children.push(child)
|
||||
dirCache[parentPath] = current
|
||||
}
|
||||
|
||||
function makeTreeFixture(options) {
|
||||
const worktreePath = '/repo'
|
||||
const dirCache = {
|
||||
[worktreePath]: { children: [], loading: false }
|
||||
}
|
||||
const expanded = new Set([worktreePath])
|
||||
const ignored = new Set()
|
||||
const canonicalPaths = []
|
||||
|
||||
for (let rootIndex = 0; rootIndex < options.rootDirs; rootIndex++) {
|
||||
const rootName = `root-${String(rootIndex).padStart(3, '0')}`
|
||||
const rootPath = joinExplorerPath(worktreePath, rootName)
|
||||
expanded.add(rootPath)
|
||||
canonicalPaths.push(`${rootName}/`)
|
||||
addChild(dirCache, worktreePath, {
|
||||
depth: 0,
|
||||
isDirectory: true,
|
||||
name: rootName,
|
||||
path: rootPath,
|
||||
relativePath: relativePath(worktreePath, rootPath)
|
||||
})
|
||||
dirCache[rootPath] = { children: [], loading: false }
|
||||
|
||||
for (let fileIndex = 0; fileIndex < options.filesPerRoot; fileIndex++) {
|
||||
const fileName = `root-file-${String(fileIndex).padStart(3, '0')}.ts`
|
||||
const filePath = joinExplorerPath(rootPath, fileName)
|
||||
const rel = relativePath(worktreePath, filePath)
|
||||
canonicalPaths.push(rel)
|
||||
addChild(dirCache, rootPath, {
|
||||
depth: 1,
|
||||
isDirectory: false,
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
relativePath: rel
|
||||
})
|
||||
}
|
||||
|
||||
for (let nestedIndex = 0; nestedIndex < options.nestedDirsPerRoot; nestedIndex++) {
|
||||
const nestedName = `nested-${String(nestedIndex).padStart(3, '0')}`
|
||||
const nestedPath = joinExplorerPath(rootPath, nestedName)
|
||||
const nestedRel = relativePath(worktreePath, nestedPath)
|
||||
expanded.add(nestedPath)
|
||||
canonicalPaths.push(`${nestedRel}/`)
|
||||
addChild(dirCache, rootPath, {
|
||||
depth: 1,
|
||||
isDirectory: true,
|
||||
name: nestedName,
|
||||
path: nestedPath,
|
||||
relativePath: nestedRel
|
||||
})
|
||||
dirCache[nestedPath] = { children: [], loading: false }
|
||||
|
||||
for (let fileIndex = 0; fileIndex < options.filesPerNestedDir; fileIndex++) {
|
||||
const fileName = `file-${String(fileIndex).padStart(3, '0')}.tsx`
|
||||
const filePath = joinExplorerPath(nestedPath, fileName)
|
||||
const rel = relativePath(worktreePath, filePath)
|
||||
canonicalPaths.push(rel)
|
||||
if (fileIndex % 23 === 0) {
|
||||
ignored.add(rel)
|
||||
}
|
||||
addChild(dirCache, nestedPath, {
|
||||
depth: 2,
|
||||
isDirectory: false,
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
relativePath: rel
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { canonicalPaths, dirCache, expanded, ignored, worktreePath }
|
||||
}
|
||||
|
||||
function flattenCurrent(worktreePath, dirCache, expanded) {
|
||||
const result = []
|
||||
const addChildren = (parentPath) => {
|
||||
const cached = dirCache[parentPath]
|
||||
if (!cached?.children) {
|
||||
return
|
||||
}
|
||||
for (const child of cached.children) {
|
||||
result.push(child)
|
||||
if (child.isDirectory && expanded.has(child.path)) {
|
||||
addChildren(child.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
addChildren(worktreePath)
|
||||
return result
|
||||
}
|
||||
|
||||
function getVisibleRows(flatRows, ignored, showGitIgnoredFiles) {
|
||||
if (showGitIgnoredFiles) {
|
||||
return flatRows
|
||||
}
|
||||
return flatRows.filter((row) => !ignored.has(row.relativePath))
|
||||
}
|
||||
|
||||
function runLegacyCurrentPass(fixture, showGitIgnoredFiles) {
|
||||
const flatRows = flattenCurrent(fixture.worktreePath, fixture.dirCache, fixture.expanded)
|
||||
const rowsByPath = new Map(flatRows.map((row) => [row.path, row]))
|
||||
const visibleRows = getVisibleRows(flatRows, fixture.ignored, showGitIgnoredFiles)
|
||||
const visibleRowsByPath = new Map(visibleRows.map((row) => [row.path, row]))
|
||||
const orderedPaths = visibleRows.map((row) => row.path)
|
||||
return {
|
||||
flatCount: flatRows.length,
|
||||
orderedPathCount: orderedPaths.length,
|
||||
rowsByPath,
|
||||
visibleCount: visibleRows.length,
|
||||
visibleRowsByPath
|
||||
}
|
||||
}
|
||||
|
||||
function collectIgnoredQueryRelativePaths(fixture) {
|
||||
const relativePaths = []
|
||||
const addChildren = (parentPath) => {
|
||||
const cached = fixture.dirCache[parentPath]
|
||||
if (!cached?.children) {
|
||||
return
|
||||
}
|
||||
for (const row of cached.children) {
|
||||
relativePaths.push(row.relativePath)
|
||||
if (row.isDirectory && fixture.expanded.has(row.path)) {
|
||||
addChildren(row.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
addChildren(fixture.worktreePath)
|
||||
return relativePaths
|
||||
}
|
||||
|
||||
function runDirectDirCacheProjectionPass(fixture, showGitIgnoredFiles) {
|
||||
const ignoredQueryRelativePaths = collectIgnoredQueryRelativePaths(fixture)
|
||||
const visibleRows = []
|
||||
const visibleRowsByPath = new Map()
|
||||
const addChildren = (parentPath) => {
|
||||
const cached = fixture.dirCache[parentPath]
|
||||
if (!cached?.children) {
|
||||
return
|
||||
}
|
||||
for (const row of cached.children) {
|
||||
if (!showGitIgnoredFiles && fixture.ignored.has(row.relativePath)) {
|
||||
continue
|
||||
}
|
||||
visibleRows.push(row)
|
||||
visibleRowsByPath.set(row.path, row)
|
||||
if (row.isDirectory && fixture.expanded.has(row.path)) {
|
||||
addChildren(row.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
addChildren(fixture.worktreePath)
|
||||
const selectedPath = visibleRows.at(-1)?.path ?? null
|
||||
const selectedNode = selectedPath ? visibleRowsByPath.get(selectedPath) : null
|
||||
return {
|
||||
ignoredQueryPathCount: ignoredQueryRelativePaths.length,
|
||||
selectedPath: selectedNode?.path ?? null,
|
||||
visibleCount: visibleRows.length,
|
||||
visibleRowsByPath
|
||||
}
|
||||
}
|
||||
|
||||
function buildIndexedProjection(fixture, showGitIgnoredFiles) {
|
||||
const rows = []
|
||||
const rowsByPath = new Map()
|
||||
const addChildren = (parentPath) => {
|
||||
const cached = fixture.dirCache[parentPath]
|
||||
if (!cached?.children) {
|
||||
return
|
||||
}
|
||||
for (const child of cached.children) {
|
||||
rowsByPath.set(child.path, child)
|
||||
if (showGitIgnoredFiles || !fixture.ignored.has(child.relativePath)) {
|
||||
rows.push(child)
|
||||
}
|
||||
if (child.isDirectory && fixture.expanded.has(child.path)) {
|
||||
addChildren(child.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
addChildren(fixture.worktreePath)
|
||||
return {
|
||||
getVisibleCount: () => rows.length,
|
||||
getVisibleSlice: (start, end) => rows.slice(start, end + 1),
|
||||
getRowByPath: (path) => rowsByPath.get(path) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function measure(label, passes, fn) {
|
||||
const durations = []
|
||||
let lastResult
|
||||
for (let index = 0; index < passes; index++) {
|
||||
const start = performance.now()
|
||||
lastResult = fn(index)
|
||||
durations.push(performance.now() - start)
|
||||
}
|
||||
durations.sort((a, b) => a - b)
|
||||
const sum = durations.reduce((total, value) => total + value, 0)
|
||||
return {
|
||||
label,
|
||||
maxMs: durations.at(-1),
|
||||
meanMs: sum / durations.length,
|
||||
minMs: durations[0],
|
||||
p50Ms: durations[Math.floor(durations.length * 0.5)],
|
||||
p95Ms: durations[Math.floor(durations.length * 0.95)],
|
||||
passes,
|
||||
sample: lastResult
|
||||
}
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Number(value.toFixed(3))
|
||||
}
|
||||
|
||||
function printMeasurement(measurement) {
|
||||
console.log(
|
||||
`${measurement.label}: mean=${round(measurement.meanMs)}ms p50=${round(
|
||||
measurement.p50Ms
|
||||
)}ms p95=${round(measurement.p95Ms)}ms max=${round(measurement.maxMs)}ms`
|
||||
)
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseOptions()
|
||||
const fixture = makeTreeFixture(options)
|
||||
const totalRows = fixture.canonicalPaths.length
|
||||
console.log(
|
||||
`fixture: canonicalPaths=${totalRows} roots=${options.rootDirs} nestedDirsPerRoot=${options.nestedDirsPerRoot} filesPerNestedDir=${options.filesPerNestedDir}`
|
||||
)
|
||||
|
||||
const legacyCurrent = measure('legacy-current-full-flatten+maps', options.passes, () =>
|
||||
runLegacyCurrentPass(fixture, false)
|
||||
)
|
||||
printMeasurement(legacyCurrent)
|
||||
|
||||
const directProjection = measure('direct-dir-cache-row-projection', options.passes, () =>
|
||||
runDirectDirCacheProjectionPass(fixture, false)
|
||||
)
|
||||
printMeasurement(directProjection)
|
||||
|
||||
const projectionBuild = measure('indexed-projection-rebuild', options.passes, () =>
|
||||
buildIndexedProjection(fixture, false)
|
||||
)
|
||||
printMeasurement(projectionBuild)
|
||||
|
||||
const projection = buildIndexedProjection(fixture, false)
|
||||
const indexedSlice = measure('indexed-visible-window-slice', options.passes, (index) => {
|
||||
const visibleCount = projection.getVisibleCount()
|
||||
const maxStart = Math.max(0, visibleCount - options.windowSize)
|
||||
const start = maxStart === 0 ? 0 : (index * 97) % maxStart
|
||||
const rows = projection.getVisibleSlice(start, start + options.windowSize - 1)
|
||||
const selected = projection.getRowByPath(rows.at(-1)?.path ?? '')
|
||||
return { selected: selected?.path ?? null, visibleWindowCount: rows.length }
|
||||
})
|
||||
printMeasurement(indexedSlice)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
fixture: {
|
||||
canonicalPaths: totalRows,
|
||||
expandedDirs: fixture.expanded.size,
|
||||
ignoredPaths: fixture.ignored.size
|
||||
},
|
||||
indexedSlice,
|
||||
legacyCurrent,
|
||||
directProjection,
|
||||
projectionBuild
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,483 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
budgetFailures,
|
||||
collectTerminalPerfRows,
|
||||
compareScenarios,
|
||||
escapeHtml,
|
||||
formatLargeValue,
|
||||
formatMs,
|
||||
readJsonReport,
|
||||
scenarioTitle
|
||||
} from './terminal-perf-report-rows.mjs'
|
||||
import { basename, dirname } from 'node:path'
|
||||
|
||||
const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html'
|
||||
|
||||
// Why: every tracked metric is lower-is-better, so delta coloring and the
|
||||
// regression table share one direction rule.
|
||||
const MS_METRICS = [
|
||||
{ key: 'medianMs', label: 'Typing median', chart: true },
|
||||
{ key: 'worstMs', label: 'Typing worst', chart: true },
|
||||
{ key: 'scrollMs', label: 'Active scroll', chart: true },
|
||||
{ key: 'restoreMs', label: 'Restore', chart: true },
|
||||
{ key: 'revisitMs', label: 'Revisit marker', chart: true },
|
||||
{ key: 'maxTimerDriftMs', label: 'Timer drift', chart: false }
|
||||
]
|
||||
|
||||
const COUNT_METRICS = [
|
||||
{ key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' },
|
||||
{ key: 'mainPeakInFlightChars', label: 'Main in-flight chars' },
|
||||
{ key: 'mainPeakPendingChars', label: 'Main pending chars' },
|
||||
{ key: 'hiddenSkippedChars', label: 'Hidden skipped chars' },
|
||||
{ key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' },
|
||||
// Why: parked-memory scenarios are table-only — heap/view counts have no
|
||||
// ms trend story, so they stay out of the charts.
|
||||
{ key: 'heapUsedMB', label: 'Renderer JS heap (MB)' },
|
||||
{ key: 'liveTerminals', label: 'Live xterm instances' },
|
||||
{ key: 'livePaneManagers', label: 'Live pane managers' }
|
||||
]
|
||||
|
||||
const SERIES_COLORS = {
|
||||
medianMs: '#2563eb',
|
||||
worstMs: '#dc2626',
|
||||
scrollMs: '#d97706',
|
||||
restoreMs: '#7c3aed',
|
||||
revisitMs: '#0d9488'
|
||||
}
|
||||
|
||||
const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/
|
||||
|
||||
export function parseHtmlReportArgs(argv, env = process.env) {
|
||||
const args = [...argv]
|
||||
if (args[0] === '--') {
|
||||
args.shift()
|
||||
}
|
||||
|
||||
const inputs = []
|
||||
let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]
|
||||
if (arg === '--output' || arg === '-o') {
|
||||
const next = args[index + 1]
|
||||
if (!next || next.startsWith('-')) {
|
||||
throw new Error(`${arg} requires a path`)
|
||||
}
|
||||
outputPath = next
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('--output=')) {
|
||||
outputPath = arg.slice('--output='.length)
|
||||
continue
|
||||
}
|
||||
const labeled = arg.match(LABELED_INPUT_RE)
|
||||
if (labeled) {
|
||||
inputs.push({ label: labeled[1], path: labeled[2] })
|
||||
} else {
|
||||
inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg })
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.length === 0) {
|
||||
throw new Error(
|
||||
'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]<playwright-json>... --output <report.html>'
|
||||
)
|
||||
}
|
||||
return { inputs, outputPath }
|
||||
}
|
||||
|
||||
// ── Trend data ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildMatrix(revisions) {
|
||||
const scenarios = new Map()
|
||||
for (const revision of revisions) {
|
||||
for (const row of revision.rows) {
|
||||
if (!scenarios.has(row.scenario)) {
|
||||
scenarios.set(row.scenario, new Map())
|
||||
}
|
||||
scenarios.get(row.scenario).set(revision.label, row)
|
||||
}
|
||||
}
|
||||
const orderedScenarios = [...scenarios.keys()].sort(compareScenarios)
|
||||
return { scenarios, orderedScenarios }
|
||||
}
|
||||
|
||||
function niceCeil(value) {
|
||||
if (value <= 0) {
|
||||
return 1
|
||||
}
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value))
|
||||
for (const step of [1, 2, 2.5, 5, 10]) {
|
||||
if (value <= step * magnitude) {
|
||||
return step * magnitude
|
||||
}
|
||||
}
|
||||
return 10 * magnitude
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
function renderTrendChart({ scenario, byRevision, revisions, title }) {
|
||||
const metrics = MS_METRICS.filter(
|
||||
(metric) =>
|
||||
metric.chart &&
|
||||
revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null)
|
||||
)
|
||||
if (metrics.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const width = 560
|
||||
const height = 230
|
||||
const pad = { left: 52, right: 14, top: 30, bottom: 38 }
|
||||
const plotW = width - pad.left - pad.right
|
||||
const plotH = height - pad.top - pad.bottom
|
||||
const maxValue = Math.max(
|
||||
1,
|
||||
...metrics.flatMap((metric) =>
|
||||
revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0)
|
||||
)
|
||||
)
|
||||
const yMax = niceCeil(maxValue * 1.15)
|
||||
const xFor = (index) =>
|
||||
pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1))
|
||||
const yFor = (value) => pad.top + plotH - (plotH * value) / yMax
|
||||
|
||||
const parts = []
|
||||
parts.push(
|
||||
`<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(title)}" class="trend-chart">`
|
||||
)
|
||||
parts.push(`<text x="${pad.left}" y="16" class="chart-title">${escapeHtml(title)}</text>`)
|
||||
// Horizontal gridlines + y labels
|
||||
const ticks = 4
|
||||
for (let tick = 0; tick <= ticks; tick += 1) {
|
||||
const value = (yMax * tick) / ticks
|
||||
const y = yFor(value)
|
||||
parts.push(
|
||||
`<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" class="gridline"/>`
|
||||
)
|
||||
parts.push(
|
||||
`<text x="${pad.left - 6}" y="${y + 3}" class="axis-label" text-anchor="end">${value % 1 === 0 ? value : value.toFixed(1)}</text>`
|
||||
)
|
||||
}
|
||||
// X labels
|
||||
revisions.forEach((revision, index) => {
|
||||
parts.push(
|
||||
`<text x="${xFor(index)}" y="${height - pad.bottom + 16}" class="axis-label" text-anchor="middle">${escapeHtml(revision.label)}</text>`
|
||||
)
|
||||
})
|
||||
// Series
|
||||
for (const metric of metrics) {
|
||||
const color = SERIES_COLORS[metric.key] ?? '#475569'
|
||||
const points = revisions
|
||||
.map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] }))
|
||||
.filter((point) => point.value != null)
|
||||
if (points.length === 0) {
|
||||
continue
|
||||
}
|
||||
const path = points
|
||||
.map(
|
||||
(point, order) =>
|
||||
`${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}`
|
||||
)
|
||||
.join(' ')
|
||||
parts.push(`<path d="${path}" fill="none" stroke="${color}" stroke-width="2"/>`)
|
||||
for (const point of points) {
|
||||
const x = xFor(point.index)
|
||||
const y = yFor(point.value)
|
||||
parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${color}"/>`)
|
||||
parts.push(
|
||||
`<text x="${x.toFixed(1)}" y="${(y - 7).toFixed(1)}" class="point-label" text-anchor="middle" fill="${color}">${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}</text>`
|
||||
)
|
||||
}
|
||||
}
|
||||
parts.push('</svg>')
|
||||
|
||||
const legend = metrics
|
||||
.map((metric) => {
|
||||
const color = SERIES_COLORS[metric.key] ?? '#475569'
|
||||
return `<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(metric.label)}</span>`
|
||||
})
|
||||
.join('')
|
||||
return `<figure class="chart-card" data-scenario="${escapeHtml(scenario)}">${parts.join('')}<figcaption class="legend">${legend} <span class="legend-unit">ms — lower is better</span></figcaption></figure>`
|
||||
}
|
||||
|
||||
function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) {
|
||||
if (baseline == null || latest == null) {
|
||||
return '<td class="delta">—</td>'
|
||||
}
|
||||
const diff = latest - baseline
|
||||
const pct = baseline === 0 ? null : (diff / baseline) * 100
|
||||
let cls = 'neutral'
|
||||
if (zeroBudget) {
|
||||
cls = latest > 0 ? 'worse' : 'better'
|
||||
} else if (pct != null && Math.abs(pct) >= 5) {
|
||||
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
|
||||
} else if (baseline === 0 && diff !== 0) {
|
||||
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
|
||||
}
|
||||
const pctLabel =
|
||||
pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%`
|
||||
const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}`
|
||||
return `<td class="delta ${cls}">${escapeHtml(pctLabel)} <span class="delta-abs">(${escapeHtml(diffLabel)})</span></td>`
|
||||
}
|
||||
|
||||
function renderScenarioTable({ scenario, byRevision, revisions, title }) {
|
||||
const metricRows = []
|
||||
const allMetrics = [...MS_METRICS, ...COUNT_METRICS]
|
||||
for (const metric of allMetrics) {
|
||||
const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key])
|
||||
if (values.every((value) => value == null)) {
|
||||
continue
|
||||
}
|
||||
const isMs = MS_METRICS.includes(metric)
|
||||
const format = isMs ? formatMs : formatLargeValue
|
||||
const cells = values
|
||||
.map((value) => `<td>${value == null ? '—' : escapeHtml(format(value))}</td>`)
|
||||
.join('')
|
||||
const baseline = values.find((value) => value != null)
|
||||
const latest = values.toReversed().find((value) => value != null)
|
||||
metricRows.push(
|
||||
`<tr><th scope="row">${escapeHtml(metric.label)}</th>${cells}${deltaCell(baseline, latest, {
|
||||
zeroBudget: metric.key === 'rendererDroppedBacklogs'
|
||||
})}</tr>`
|
||||
)
|
||||
}
|
||||
if (metricRows.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const headers = revisions.map((revision) => `<th>${escapeHtml(revision.label)}</th>`).join('')
|
||||
return `<section class="scenario-block">
|
||||
<h3>${escapeHtml(title)} <span class="scenario-id">${escapeHtml(scenario)}</span></h3>
|
||||
<table class="trend-table">
|
||||
<thead><tr><th>Metric</th>${headers}<th>Δ first → last</th></tr></thead>
|
||||
<tbody>${metricRows.join('')}</tbody>
|
||||
</table>
|
||||
</section>`
|
||||
}
|
||||
|
||||
function renderHeadline(revisions, matrix) {
|
||||
if (revisions.length < 2) {
|
||||
return ''
|
||||
}
|
||||
const first = revisions[0]
|
||||
const last = revisions.at(-1)
|
||||
const cards = []
|
||||
for (const scenario of matrix.orderedScenarios) {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const baseRow = byRevision.get(first.label)
|
||||
const lastRow = byRevision.get(last.label)
|
||||
if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) {
|
||||
continue
|
||||
}
|
||||
const diff = lastRow.medianMs - baseRow.medianMs
|
||||
const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100
|
||||
const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse'
|
||||
cards.push(`<div class="card ${cls}">
|
||||
<div class="card-title">${escapeHtml(scenarioTitle(scenario, lastRow))}</div>
|
||||
<div class="card-value">${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}</div>
|
||||
<div class="card-sub">typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)</div>
|
||||
</div>`)
|
||||
}
|
||||
if (cards.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return `<section><h2>Baseline vs latest</h2><div class="cards">${cards.join('')}</div></section>`
|
||||
}
|
||||
|
||||
function renderBudgets(latestRevision) {
|
||||
const failures = []
|
||||
for (const row of latestRevision.rows) {
|
||||
for (const failure of budgetFailures(row)) {
|
||||
failures.push(`${row.scenario}: ${failure}`)
|
||||
}
|
||||
}
|
||||
const status =
|
||||
failures.length === 0 ? '<span class="pass">Pass</span>' : '<span class="fail">Fail</span>'
|
||||
const failureList =
|
||||
failures.length === 0
|
||||
? ''
|
||||
: `<ul>${failures.map((failure) => `<li>${escapeHtml(failure)}</li>`).join('')}</ul>`
|
||||
return `<section><h2>Budget status — ${escapeHtml(latestRevision.label)}</h2>
|
||||
<p>${latestRevision.rows.length} scenario rows checked: ${status}</p>${failureList}</section>`
|
||||
}
|
||||
|
||||
function renderInputsMeta(revisions) {
|
||||
const items = revisions
|
||||
.map((revision) => {
|
||||
const stats = revision.stats
|
||||
const statsLabel = stats
|
||||
? ` — ${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky`
|
||||
: ''
|
||||
const failNote =
|
||||
stats && stats.unexpected > 0
|
||||
? ' <span class="meta-warn">(failed assertions at this revision; metrics still recorded)</span>'
|
||||
: ''
|
||||
return `<li><strong>${escapeHtml(revision.label)}</strong> — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}</li>`
|
||||
})
|
||||
.join('')
|
||||
return `<ol class="inputs">${items}</ol>`
|
||||
}
|
||||
|
||||
function renderRawDetails(revisions) {
|
||||
return revisions
|
||||
.map((revision) => {
|
||||
const rows = revision.rows
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr><td>${escapeHtml(row.scenario)}</td><td>${row.panes ?? '—'}</td><td>${escapeHtml(formatMs(row.medianMs))}</td><td>${escapeHtml(formatMs(row.worstMs))}</td><td>${escapeHtml(formatMs(row.scrollMs))}</td><td>${escapeHtml(formatMs(row.restoreMs))}</td><td>${escapeHtml(formatMs(row.revisitMs))}</td><td>${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}</td><td>${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}</td><td>${row.rendererDroppedBacklogs ?? '—'}</td></tr>`
|
||||
)
|
||||
.join('')
|
||||
return `<details><summary>Raw rows — ${escapeHtml(revision.label)}</summary>
|
||||
<table class="trend-table">
|
||||
<thead><tr><th>Scenario</th><th>Panes</th><th>Median</th><th>Worst</th><th>Scroll</th><th>Restore</th><th>Revisit</th><th>Renderer peak</th><th>Hidden skipped</th><th>Drops</th></tr></thead>
|
||||
<tbody>${rows}</tbody></table></details>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
const PAGE_CSS = `
|
||||
:root { color-scheme: light; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; }
|
||||
h1 { font-size: 24px; margin-bottom: 4px; }
|
||||
h2 { font-size: 18px; margin: 28px 0 10px; }
|
||||
h3 { font-size: 15px; margin: 18px 0 6px; }
|
||||
.meta { color: #64748b; font-size: 13px; }
|
||||
.inputs { font-size: 13px; color: #334155; padding-left: 20px; }
|
||||
.meta-warn { color: #b45309; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
|
||||
.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; }
|
||||
.card.better { border-left-color: #16a34a; }
|
||||
.card.worse { border-left-color: #dc2626; }
|
||||
.card.neutral { border-left-color: #94a3b8; }
|
||||
.card-title { font-size: 12px; color: #64748b; }
|
||||
.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; }
|
||||
.card-sub { font-size: 11px; color: #94a3b8; }
|
||||
.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; }
|
||||
.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; }
|
||||
.trend-chart { width: 100%; height: auto; }
|
||||
.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; }
|
||||
.gridline { stroke: #e2e8f0; stroke-width: 1; }
|
||||
.axis-label { font-size: 10px; fill: #64748b; }
|
||||
.point-label { font-size: 10px; font-weight: 600; }
|
||||
.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||
.legend-unit { color: #94a3b8; margin-left: auto; }
|
||||
.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; }
|
||||
.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; }
|
||||
table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; }
|
||||
table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; }
|
||||
table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; }
|
||||
table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; }
|
||||
td.delta.better { color: #15803d; font-weight: 600; }
|
||||
td.delta.worse { color: #b91c1c; font-weight: 600; }
|
||||
td.delta.neutral { color: #64748b; }
|
||||
.delta-abs { font-weight: 400; color: #94a3b8; }
|
||||
.pass { color: #15803d; font-weight: 700; }
|
||||
.fail { color: #b91c1c; font-weight: 700; }
|
||||
details { margin: 8px 0; }
|
||||
summary { cursor: pointer; font-size: 13px; color: #334155; }
|
||||
`
|
||||
|
||||
function renderHtml({ generatedAt, revisions }) {
|
||||
const matrix = buildMatrix(revisions)
|
||||
const charts =
|
||||
revisions.length >= 2
|
||||
? matrix.orderedScenarios
|
||||
.map((scenario) => {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const anyRow = [...byRevision.values()][0]
|
||||
return renderTrendChart({
|
||||
scenario,
|
||||
byRevision,
|
||||
revisions,
|
||||
title: scenarioTitle(scenario, anyRow)
|
||||
})
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
: ''
|
||||
const tables = matrix.orderedScenarios
|
||||
.map((scenario) => {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const anyRow = [...byRevision.values()][0]
|
||||
return renderScenarioTable({
|
||||
scenario,
|
||||
byRevision,
|
||||
revisions,
|
||||
title: scenarioTitle(scenario, anyRow)
|
||||
})
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Terminal Performance Over Time</title>
|
||||
<style>${PAGE_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Terminal Performance Over Time</h1>
|
||||
<p class="meta">Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.</p>
|
||||
${renderInputsMeta(revisions)}
|
||||
${renderHeadline(revisions, matrix)}
|
||||
${charts ? `<section><h2>Trends across revisions</h2><div class="charts">${charts}</div></section>` : ''}
|
||||
<section><h2>Metric detail by scenario</h2>${tables}</section>
|
||||
${renderBudgets(revisions.at(-1))}
|
||||
<section><h2>Raw data</h2>${renderRawDetails(revisions)}</section>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
export function generateTerminalPerfHtmlReport({
|
||||
inputs,
|
||||
inputPaths,
|
||||
outputPath,
|
||||
now = new Date()
|
||||
}) {
|
||||
// Why: older callers (the scale report gate) pass bare inputPaths.
|
||||
const resolvedInputs =
|
||||
inputs ??
|
||||
(inputPaths ?? []).map((path) => ({
|
||||
label: basename(path).replace(/\.json$/i, ''),
|
||||
path
|
||||
}))
|
||||
const revisions = resolvedInputs.map(({ label, path }) => {
|
||||
const report = readJsonReport(path)
|
||||
return {
|
||||
label,
|
||||
path,
|
||||
stats: report.stats ?? null,
|
||||
rows: collectTerminalPerfRows(report, label)
|
||||
}
|
||||
})
|
||||
const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0)
|
||||
if (totalRows === 0) {
|
||||
throw new Error('No opencode terminal perf annotations found in the provided reports')
|
||||
}
|
||||
const html = renderHtml({ generatedAt: now.toISOString(), revisions })
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
writeFileSync(outputPath, html)
|
||||
const latestFailures = revisions
|
||||
.at(-1)
|
||||
.rows.reduce((sum, row) => sum + budgetFailures(row).length, 0)
|
||||
return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures }
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.filename === process.argv[1]
|
||||
if (isMain) {
|
||||
try {
|
||||
const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2))
|
||||
const result = generateTerminalPerfHtmlReport({ inputs, outputPath })
|
||||
console.log(
|
||||
`Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
generateTerminalPerfHtmlReport,
|
||||
parseHtmlReportArgs
|
||||
} from './generate-terminal-perf-html-report.mjs'
|
||||
|
||||
const tempDirs = []
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-html-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function writeReport(
|
||||
annotationDescription,
|
||||
annotationType = 'opencode-scale-same-workspace-25',
|
||||
reportName = 'report.json'
|
||||
) {
|
||||
const dir = makeTempDir()
|
||||
const reportPath = join(dir, reportName)
|
||||
writeFileSync(
|
||||
reportPath,
|
||||
JSON.stringify({
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: annotationType,
|
||||
description: annotationDescription
|
||||
},
|
||||
{
|
||||
type: 'browser-unrelated',
|
||||
description: 'median=999.0ms'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
return reportPath
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('generate-terminal-perf-html-report', () => {
|
||||
it('parses labeled and bare input paths plus output flags', () => {
|
||||
expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({
|
||||
inputs: [
|
||||
{ label: 'a', path: 'a.json' },
|
||||
{ label: 'b', path: 'b.json' }
|
||||
],
|
||||
outputPath: 'out.html'
|
||||
})
|
||||
expect(parseHtmlReportArgs(['main=runs/0-main.json', '#5038 final=runs/4-final.json'])).toEqual(
|
||||
{
|
||||
inputs: [
|
||||
{ label: 'main', path: 'runs/0-main.json' },
|
||||
{ label: '#5038 final', path: 'runs/4-final.json' }
|
||||
],
|
||||
outputPath: 'test-results/terminal-perf-impact-report.html'
|
||||
}
|
||||
)
|
||||
expect(
|
||||
parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' })
|
||||
).toEqual({
|
||||
inputs: [{ label: 'a', path: 'a.json' }],
|
||||
outputPath: 'env.html'
|
||||
})
|
||||
expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path')
|
||||
expect(() => parseHtmlReportArgs([])).toThrow('Usage:')
|
||||
})
|
||||
|
||||
it('writes a single-run report with scenario tables and budget status', () => {
|
||||
const reportPath = writeReport(
|
||||
[
|
||||
'panes=25',
|
||||
'frames=60',
|
||||
'median=12.4ms',
|
||||
'worst=44.8ms',
|
||||
'revisit=28.6ms',
|
||||
'scroll=61.0ms',
|
||||
'restore=320.0ms',
|
||||
'maxTimerDrift=8.0ms',
|
||||
'rendererPeakQueuedChars=2048',
|
||||
'mainPeakInFlightChars=4096',
|
||||
'heldAckChars=1024',
|
||||
'hiddenSkippedChars=512',
|
||||
'rendererDroppedBacklogs=0'
|
||||
].join(' ')
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({
|
||||
inputPaths: [reportPath],
|
||||
outputPath,
|
||||
now: new Date('2026-06-09T10:00:00.000Z')
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result).toEqual({ budgetFailureCount: 0, outputPath, rowCount: 1 })
|
||||
expect(html).toContain('<!doctype html>')
|
||||
expect(html).toContain('Terminal Performance Over Time')
|
||||
expect(html).toContain('2026-06-09T10:00:00.000Z')
|
||||
expect(html).toContain('Same workspace panes — 25 panes')
|
||||
expect(html).toContain('opencode-scale-same-workspace-25')
|
||||
expect(html).toContain('28.6ms')
|
||||
expect(html).toContain('Pass')
|
||||
// Why: one run has no over-time story; the trend section must not render.
|
||||
expect(html).not.toContain('Trends across revisions')
|
||||
expect(html).not.toContain('browser-unrelated')
|
||||
})
|
||||
|
||||
it('renders parked-memory heap and live view counts as table metrics', () => {
|
||||
const reportPath = writeReport(
|
||||
'panes=8 parkedTabs=8 heapUsedMB=142.5 liveTerminals=1 livePaneManagers=1',
|
||||
'opencode-parked-memory'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
// Why: heapUsedMB has no budget — a memory row alone must not fail gates.
|
||||
expect(result.budgetFailureCount).toBe(0)
|
||||
expect(html).toContain('Parked hidden terminal memory — 8 panes')
|
||||
expect(html).toContain('Renderer JS heap (MB)')
|
||||
expect(html).toContain('142.5')
|
||||
expect(html).toContain('Live xterm instances')
|
||||
expect(html).toContain('Live pane managers')
|
||||
})
|
||||
|
||||
it('marks over-budget rows as failures for the latest run', () => {
|
||||
const reportPath = writeReport(
|
||||
[
|
||||
'panes=100',
|
||||
'median=80.0ms',
|
||||
'worst=301.0ms',
|
||||
'revisit=301.0ms',
|
||||
'rendererPeakQueuedChars=2097153',
|
||||
'rendererDroppedBacklogs=1'
|
||||
].join(' '),
|
||||
'opencode-scale-cross-workspace-100'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result.budgetFailureCount).toBe(5)
|
||||
expect(html).toContain('Fail')
|
||||
expect(html).toContain('medianMs 80 > 75')
|
||||
expect(html).toContain('Cross-workspace hidden panes')
|
||||
})
|
||||
|
||||
it('renders ordered revisions with trend charts and baseline deltas', () => {
|
||||
const mainReport = writeReport(
|
||||
'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'main.json'
|
||||
)
|
||||
const middleReport = writeReport(
|
||||
'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'backpressure.json'
|
||||
)
|
||||
const finalReport = writeReport(
|
||||
'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'final.json'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({
|
||||
inputs: [
|
||||
{ label: 'main', path: mainReport },
|
||||
{ label: 'backpressure', path: middleReport },
|
||||
{ label: 'final', path: finalReport }
|
||||
],
|
||||
outputPath
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result.rowCount).toBe(3)
|
||||
expect(html).toContain('Baseline vs latest')
|
||||
expect(html).toContain('Trends across revisions')
|
||||
expect(html).toContain('trend-chart')
|
||||
expect(html).toContain('>main<')
|
||||
expect(html).toContain('>backpressure<')
|
||||
expect(html).toContain('>final<')
|
||||
// Why: median 50 -> 20 is a 60% improvement and must read as better.
|
||||
expect(html).toContain('delta better')
|
||||
expect(html).toContain('-60%')
|
||||
expect(html).toContain('50.0ms → 20.0ms')
|
||||
})
|
||||
|
||||
it('renders missing scenarios at older revisions as gaps, not zeros', () => {
|
||||
const mainReport = writeReport(
|
||||
'panes=25 median=50.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'main.json'
|
||||
)
|
||||
const finalReport = makeTempDir()
|
||||
const finalPath = join(finalReport, 'final.json')
|
||||
writeFileSync(
|
||||
finalPath,
|
||||
JSON.stringify({
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-scale-same-workspace-25',
|
||||
description: 'panes=25 median=40.0ms rendererDroppedBacklogs=0'
|
||||
},
|
||||
{
|
||||
type: 'opencode-revisit-pressure',
|
||||
description: 'panes=19 median=3.0ms revisit=4.4ms rendererDroppedBacklogs=0'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
generateTerminalPerfHtmlReport({
|
||||
inputs: [
|
||||
{ label: 'main', path: mainReport },
|
||||
{ label: 'final', path: finalPath }
|
||||
],
|
||||
outputPath
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(html).toContain('Revisit under pressure')
|
||||
expect(html).toContain('<td>—</td>')
|
||||
})
|
||||
|
||||
it('fails when reports contain no terminal perf annotations', () => {
|
||||
const reportPath = writeReport('median=12.0ms', 'browser-unrelated')
|
||||
|
||||
expect(() =>
|
||||
generateTerminalPerfHtmlReport({
|
||||
inputPaths: [reportPath],
|
||||
outputPath: join(makeTempDir(), 'report.html')
|
||||
})
|
||||
).toThrow('No opencode terminal perf annotations found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('Git binary compatibility PR gate', () => {
|
||||
it('runs the real-binary contract at each compatibility boundary', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const step = workflow.jobs.verify.steps.find(
|
||||
(candidate) => candidate.name === 'Verify Git binary compatibility matrix'
|
||||
)
|
||||
|
||||
expect(step?.run).toContain('git-2.25.5.tar.gz')
|
||||
expect(step?.run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf')
|
||||
expect(step?.run).toContain('ORCA_GIT_COMPAT_BINARY="$source/git"')
|
||||
expect(step?.run).toContain('alpine/git:edge-2.38.1|2.38.1')
|
||||
expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1')
|
||||
expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"')
|
||||
expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: renderer-heap growth of the agent-hibernation output-epoch map
|
||||
// across terminal open/close cycles.
|
||||
//
|
||||
// recordAgentHibernationPaneOutput() adds one entry per pane (keyed by
|
||||
// `tabId:leafId`, leafId a fresh UUID each open) on every PTY output chunk.
|
||||
// Before the fix nothing purged those entries on permanent pane/worktree close,
|
||||
// so the module-level Map grew for the renderer's whole lifetime. This script
|
||||
// simulates N open→emit→close cycles and reports retained Map size with the
|
||||
// purge disabled vs enabled.
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import v8 from 'node:v8'
|
||||
|
||||
const CYCLES = Number.parseInt(process.env.ORCA_EPOCH_BENCH_CYCLES ?? '20000', 10)
|
||||
const PANES_PER_TAB = Number.parseInt(process.env.ORCA_EPOCH_BENCH_PANES ?? '2', 10)
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_EPOCH_BENCH_CYCLES', CYCLES],
|
||||
['ORCA_EPOCH_BENCH_PANES', PANES_PER_TAB]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror of the module under test (agent-hibernation-output-activity.ts): a
|
||||
// module-level Map of paneKey -> epoch, plus the tab-scoped purge the fix adds.
|
||||
function makeActivity() {
|
||||
const outputEpochByPaneKey = new Map()
|
||||
return {
|
||||
map: outputEpochByPaneKey,
|
||||
record(paneKey) {
|
||||
outputEpochByPaneKey.set(paneKey, (outputEpochByPaneKey.get(paneKey) ?? 0) + 1)
|
||||
},
|
||||
forgetTab(tabId) {
|
||||
const prefix = `${tabId}:`
|
||||
for (const paneKey of outputEpochByPaneKey.keys()) {
|
||||
if (paneKey.startsWith(prefix)) {
|
||||
outputEpochByPaneKey.delete(paneKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A v4 UUID-shaped string; varied by index so each pane open mints a fresh key
|
||||
// exactly like the real leafId allocation.
|
||||
function leafId(index) {
|
||||
const hex = index.toString(16).padStart(12, '0').slice(-12)
|
||||
return `00000000-0000-4000-8000-${hex}`
|
||||
}
|
||||
|
||||
function runCycles({ purge }) {
|
||||
const activity = makeActivity()
|
||||
let peak = 0
|
||||
for (let cycle = 0; cycle < CYCLES; cycle += 1) {
|
||||
const tabId = `tab-${cycle}`
|
||||
for (let pane = 0; pane < PANES_PER_TAB; pane += 1) {
|
||||
const paneKey = `${tabId}:${leafId(cycle * PANES_PER_TAB + pane)}`
|
||||
// Simulate a burst of PTY output chunks for this pane.
|
||||
for (let chunk = 0; chunk < 8; chunk += 1) {
|
||||
activity.record(paneKey)
|
||||
}
|
||||
}
|
||||
peak = Math.max(peak, activity.map.size)
|
||||
if (purge) {
|
||||
activity.forgetTab(tabId)
|
||||
}
|
||||
}
|
||||
return { retained: activity.map.size, peak }
|
||||
}
|
||||
|
||||
function approxRetainedBytes(entries) {
|
||||
// Rough lower bound: a paneKey string (~48 UTF-16 chars ≈ 96 B + header) plus a
|
||||
// small-int value and Map node overhead. ~110 B/entry is conservative for V8.
|
||||
return entries * 110
|
||||
}
|
||||
|
||||
console.log('Hibernation output-epoch map leak benchmark')
|
||||
console.log(`cycles=${CYCLES} panes/tab=${PANES_PER_TAB} (open → emit 8 chunks/pane → close)\n`)
|
||||
|
||||
const t0 = performance.now()
|
||||
const before = runCycles({ purge: false })
|
||||
const afterMs = performance.now()
|
||||
const after = runCycles({ purge: true })
|
||||
const doneMs = performance.now()
|
||||
|
||||
const fmtBytes = (n) =>
|
||||
n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(2)} MiB` : `${(n / 1024).toFixed(1)} KiB`
|
||||
|
||||
console.log(' variant │ retained entries │ approx retained heap │ wall time')
|
||||
console.log(' ────────────────┼──────────────────┼──────────────────────┼──────────')
|
||||
console.log(
|
||||
` before (leak) │ ${String(before.retained).padStart(16)} │ ${fmtBytes(approxRetainedBytes(before.retained)).padStart(20)} │ ${(afterMs - t0).toFixed(0)} ms`
|
||||
)
|
||||
console.log(
|
||||
` after (purge) │ ${String(after.retained).padStart(16)} │ ${fmtBytes(approxRetainedBytes(after.retained)).padStart(20)} │ ${(doneMs - afterMs).toFixed(0)} ms`
|
||||
)
|
||||
console.log(
|
||||
`\nWithout the purge, retained entries grow unbounded with open/close cycles` +
|
||||
` (peak ${before.peak} ≈ ${fmtBytes(approxRetainedBytes(before.retained))}).` +
|
||||
`\nWith the purge, the map returns to ~0 after each tab closes — bounded by` +
|
||||
` the live pane count, not session lifetime.`
|
||||
)
|
||||
|
||||
// Belt-and-suspenders: confirm v8 can serialize a representative entry so the
|
||||
// per-entry estimate is grounded, not invented.
|
||||
const sampleBytes = v8.serialize([`tab-0:${leafId(0)}`, 8]).byteLength
|
||||
console.log(`\n(v8-serialized size of one [paneKey, epoch] entry: ${sampleBytes} bytes)`)
|
||||
@@ -0,0 +1,44 @@
|
||||
export async function installSyntheticVisibleSpinners(page, count, animation, steps) {
|
||||
if (count <= 0) {
|
||||
return
|
||||
}
|
||||
const animationTiming =
|
||||
animation === 'steps' ? `1s steps(${steps}, end) infinite` : '1s linear infinite'
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
@keyframes orca-idle-bench-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.orca-idle-bench-spinner-host {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 2147483647;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.orca-idle-bench-spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid rgb(234 179 8);
|
||||
border-top-color: transparent;
|
||||
border-radius: 9999px;
|
||||
animation: orca-idle-bench-spin ${animationTiming};
|
||||
}
|
||||
`
|
||||
})
|
||||
await page.evaluate((spinnerCount) => {
|
||||
document.querySelector('[data-orca-idle-bench-spinners]')?.remove()
|
||||
const host = document.createElement('div')
|
||||
host.className = 'orca-idle-bench-spinner-host'
|
||||
host.setAttribute('data-orca-idle-bench-spinners', String(spinnerCount))
|
||||
for (let index = 0; index < spinnerCount; index += 1) {
|
||||
const spinner = document.createElement('div')
|
||||
spinner.className = 'orca-idle-bench-spinner'
|
||||
host.appendChild(spinner)
|
||||
}
|
||||
document.body.appendChild(host)
|
||||
}, count)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
// Symlinks the orca-dev wrapper into /usr/local/bin so the dev CLI is
|
||||
// available globally after `pnpm run build:cli`.
|
||||
import { existsSync, lstatSync, readlinkSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
|
||||
const scriptDir = import.meta.dirname
|
||||
const source = path.join(scriptDir, 'orca-dev.mjs')
|
||||
|
||||
const commandPath =
|
||||
process.platform === 'darwin' || process.platform === 'linux' ? '/usr/local/bin/orca-dev' : null
|
||||
|
||||
if (!commandPath) {
|
||||
console.log('[orca-dev] Skipping global symlink (unsupported platform).')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
function isOwnedByUs(target) {
|
||||
try {
|
||||
if (!lstatSync(target).isSymbolicLink()) {
|
||||
return false
|
||||
}
|
||||
return readlinkSync(target) === source
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(commandPath)) {
|
||||
if (isOwnedByUs(commandPath)) {
|
||||
console.log(`[orca-dev] ${commandPath} already points to dev CLI.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(
|
||||
`[orca-dev] ${commandPath} exists but is not our symlink. Remove it manually if you want the dev CLI installed globally.`
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('ln', ['-s', source, commandPath], { stdio: 'inherit' })
|
||||
console.log(`[orca-dev] Symlinked ${commandPath} → ${source}`)
|
||||
} catch {
|
||||
console.log(
|
||||
`[orca-dev] Could not create ${commandPath} (permission denied). Run once with:\n` +
|
||||
` sudo ln -s ${source} ${commandPath}`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { platform as osPlatform, tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
|
||||
const electronRequire = createRequire(resolve(electronPackageDir, 'package.json'))
|
||||
const { version: electronVersion } = electronRequire('./package.json')
|
||||
const { downloadArtifact } = electronRequire('@electron/get')
|
||||
const targetPlatform = getElectronTargetPlatform()
|
||||
const targetArch = getElectronTargetArch()
|
||||
const platformPath = getElectronPlatformPath(targetPlatform)
|
||||
|
||||
try {
|
||||
// Why: Electron's own install.js can exit 0 while an async extract promise is
|
||||
// still unsettled, leaving a partial dist/. Top-level await makes that fail.
|
||||
await main()
|
||||
} catch (error) {
|
||||
console.error('[electron-package] Failed to install Electron package binary.')
|
||||
console.error(error)
|
||||
logElectronInstallDiagnostics()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (electronPackageIsUsable()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: PR tests run under system Node after native modules are rebuilt for
|
||||
// Node. Install only Electron's npm package binary here; do not run the full
|
||||
// Electron native-module rebuild path, which would undo the Node ABI rebuild.
|
||||
console.log('[electron-package] Electron package binary is missing; running Electron install.')
|
||||
resetPartialElectronInstall()
|
||||
await installElectronPackageBinary()
|
||||
|
||||
repairElectronPathFile()
|
||||
|
||||
if (!electronPackageIsUsable()) {
|
||||
logElectronInstallDiagnostics()
|
||||
console.error('[electron-package] Electron package is still unavailable after install.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
function electronPackageIsUsable() {
|
||||
try {
|
||||
const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8')
|
||||
.trim()
|
||||
.replace(/^v/, '')
|
||||
const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8')
|
||||
return (
|
||||
installedVersion === electronVersion &&
|
||||
installedPlatformPath === platformPath &&
|
||||
existsSync(getElectronExecutablePath())
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getElectronExecutablePath() {
|
||||
return process.env.ELECTRON_OVERRIDE_DIST_PATH
|
||||
? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath)
|
||||
: resolve(electronPackageDir, 'dist', platformPath)
|
||||
}
|
||||
|
||||
function resetPartialElectronInstall() {
|
||||
rmSync(resolve(electronPackageDir, 'dist'), { recursive: true, force: true })
|
||||
rmSync(resolve(electronPackageDir, 'path.txt'), { force: true })
|
||||
}
|
||||
|
||||
function repairElectronPathFile() {
|
||||
const electronExecutable = resolve(electronPackageDir, 'dist', platformPath)
|
||||
if (!existsSync(electronExecutable)) {
|
||||
return
|
||||
}
|
||||
|
||||
const pathFile = resolve(electronPackageDir, 'path.txt')
|
||||
let currentPath = ''
|
||||
try {
|
||||
currentPath = readFileSync(pathFile, 'utf8')
|
||||
} catch {
|
||||
// Missing path.txt is the common CI failure this script repairs.
|
||||
}
|
||||
|
||||
if (currentPath !== platformPath) {
|
||||
writeFileSync(pathFile, platformPath)
|
||||
console.log(`[electron-package] Repaired Electron path.txt -> ${platformPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function installElectronPackageBinary() {
|
||||
const electronDistDir = resolve(electronPackageDir, 'dist')
|
||||
const tempDir = mkdtempSync(resolve(tmpdir(), 'orca-electron-'))
|
||||
const cacheRoot = join(tempDir, 'cache')
|
||||
const extractDir = join(tempDir, 'extract')
|
||||
|
||||
try {
|
||||
const zipPath = await downloadArtifact({
|
||||
version: electronVersion,
|
||||
artifactName: 'electron',
|
||||
platform: targetPlatform,
|
||||
arch: targetArch,
|
||||
cacheRoot,
|
||||
force: true,
|
||||
tempDirectory: tempDir,
|
||||
...(shouldUseRemoteChecksums() ? {} : { checksums: electronRequire('./checksums.json') })
|
||||
})
|
||||
|
||||
// Why: CI has observed partial extracts directly under node_modules/electron
|
||||
// that leave only dist/locales. Verify in temp before replacing package dist.
|
||||
extractElectronArchive(zipPath, extractDir)
|
||||
const extractedExecutable = resolve(extractDir, platformPath)
|
||||
if (!existsSync(extractedExecutable)) {
|
||||
console.error('[electron-package] Electron archive extract did not contain executable.')
|
||||
console.error(` platformPath=${platformPath}`)
|
||||
console.error(` extractDir=${extractDir}`)
|
||||
console.error(` extractEntries=${safeReaddir(extractDir).join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
moveExtractedElectronDist(extractDir, electronDistDir)
|
||||
|
||||
const srcTypeDefPath = resolve(electronDistDir, 'electron.d.ts')
|
||||
if (existsSync(srcTypeDefPath)) {
|
||||
renameSync(srcTypeDefPath, resolve(electronPackageDir, 'electron.d.ts'))
|
||||
}
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function extractElectronArchive(zipPath, extractDir) {
|
||||
mkdirSync(extractDir, { recursive: true })
|
||||
// Why: extract-zip/Electron install.js can leave Node 24 with an unsettled
|
||||
// promise and no active handles on CI. Host unzip tools fail synchronously.
|
||||
const command = getExtractorCommand(zipPath, extractDir)
|
||||
const result = spawnSync(command.file, command.args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(formatExtractorFailure(command, result))
|
||||
}
|
||||
}
|
||||
|
||||
function moveExtractedElectronDist(extractDir, electronDistDir) {
|
||||
rmSync(electronDistDir, { recursive: true, force: true })
|
||||
try {
|
||||
// Why: macOS Electron archives rely on framework symlinks. Moving the
|
||||
// verified tree preserves them exactly; copying has broken them in CI.
|
||||
renameSync(extractDir, electronDistDir)
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (err?.code !== 'EXDEV') {
|
||||
throw err
|
||||
}
|
||||
cpSync(extractDir, electronDistDir, {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
verbatimSymlinks: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getExtractorCommand(zipPath, extractDir) {
|
||||
if (process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR) {
|
||||
return {
|
||||
file: process.execPath,
|
||||
args: [process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR, zipPath, extractDir],
|
||||
label: `node ${process.env.ORCA_ELECTRON_PACKAGE_EXTRACTOR}`
|
||||
}
|
||||
}
|
||||
|
||||
if (osPlatform() === 'win32') {
|
||||
return {
|
||||
file: process.env.ORCA_POWERSHELL_BIN || 'powershell',
|
||||
args: [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
[
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
`Expand-Archive -LiteralPath ${quotePowerShellLiteral(zipPath)} -DestinationPath ${quotePowerShellLiteral(extractDir)} -Force`
|
||||
].join('; ')
|
||||
],
|
||||
label: 'powershell Expand-Archive'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file: process.env.ORCA_UNZIP_BIN || 'unzip',
|
||||
args: ['-q', zipPath, '-d', extractDir],
|
||||
label: 'unzip'
|
||||
}
|
||||
}
|
||||
|
||||
function quotePowerShellLiteral(value) {
|
||||
return `'${String(value).replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
function formatExtractorFailure(command, result) {
|
||||
return [
|
||||
`[electron-package] ${command.label} failed with status ${result.status}.`,
|
||||
result.stdout ? `stdout:\n${result.stdout.trim()}` : '',
|
||||
result.stderr ? `stderr:\n${result.stderr.trim()}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function shouldUseRemoteChecksums() {
|
||||
return Boolean(
|
||||
process.env.electron_use_remote_checksums ||
|
||||
process.env.npm_config_electron_use_remote_checksums
|
||||
)
|
||||
}
|
||||
|
||||
function logElectronInstallDiagnostics() {
|
||||
const electronDistDir = resolve(electronPackageDir, 'dist')
|
||||
const pathFile = resolve(electronPackageDir, 'path.txt')
|
||||
console.error('[electron-package] Electron install diagnostics:')
|
||||
console.error(` packageDir=${electronPackageDir} exists=${existsSync(electronPackageDir)}`)
|
||||
console.error(` distDir=${electronDistDir} exists=${existsSync(electronDistDir)}`)
|
||||
console.error(` pathFile=${pathFile} exists=${existsSync(pathFile)}`)
|
||||
console.error(` platformPath=${platformPath}`)
|
||||
if (existsSync(electronDistDir)) {
|
||||
console.error(` distEntries=${safeReaddir(electronDistDir).join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function safeReaddir(targetPath) {
|
||||
try {
|
||||
return readdirSync(targetPath).slice(0, 40)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getElectronTargetPlatform() {
|
||||
return process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || osPlatform()
|
||||
}
|
||||
|
||||
function getElectronTargetArch() {
|
||||
return process.env.ELECTRON_INSTALL_ARCH || process.env.npm_config_arch || process.arch
|
||||
}
|
||||
|
||||
function getElectronPlatformPath(targetPlatform) {
|
||||
switch (targetPlatform) {
|
||||
case 'mas':
|
||||
case 'darwin':
|
||||
return 'Electron.app/Contents/MacOS/Electron'
|
||||
case 'freebsd':
|
||||
case 'openbsd':
|
||||
case 'linux':
|
||||
return 'electron'
|
||||
case 'win32':
|
||||
return 'electron.exe'
|
||||
default:
|
||||
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(
|
||||
new URL('./install-electron-package-binary.mjs', import.meta.url)
|
||||
)
|
||||
|
||||
describe('install-electron-package-binary', () => {
|
||||
it('installs Electron from an isolated cache and repairs path.txt', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toMatch(
|
||||
/cacheRoot=.*orca-electron-.*cache/
|
||||
)
|
||||
expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe(
|
||||
'electron'
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(
|
||||
lstatSync(
|
||||
join(projectDir, 'node_modules', 'electron', 'dist', 'version-link')
|
||||
).isSymbolicLink()
|
||||
).toBe(true)
|
||||
}
|
||||
expect(result.stdout).toContain('Repaired Electron path.txt -> electron')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses Electron 42 install env vars before npm config platform flags', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, {
|
||||
ELECTRON_INSTALL_PLATFORM: 'win32',
|
||||
ELECTRON_INSTALL_ARCH: 'arm64',
|
||||
npm_config_platform: 'linux',
|
||||
npm_config_arch: 'x64'
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toContain(
|
||||
'platform=win32 arch=arm64'
|
||||
)
|
||||
expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe(
|
||||
'electron.exe'
|
||||
)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not trigger Electron 42 lazy require downloads while checking install state', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir, { lazyRequireMarker: 'lazy-require.marker' })
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(existsSync(join(projectDir, 'lazy-require.marker'))).toBe(false)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails instead of silently accepting a partial Electron extract', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: false })
|
||||
mkdirSync(join(projectDir, 'node_modules', 'electron', 'dist', 'locales'), {
|
||||
recursive: true
|
||||
})
|
||||
writeFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'stale-path')
|
||||
|
||||
const result = runInstallScript(projectDir)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('Electron archive extract did not contain executable')
|
||||
expect(result.stderr).toContain('extractEntries=locales')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not exit successfully when Electron download never settles', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir, { downloadNeverSettles: true })
|
||||
writeFakeExtractor(projectDir, { createExecutable: false })
|
||||
|
||||
const result = runInstallScript(projectDir)
|
||||
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stderr).toContain('Detected unsettled top-level await')
|
||||
expect(existsSync(join(projectDir, 'node_modules', 'electron', 'path.txt'))).toBe(false)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function mkTempProject() {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'orca-install-electron-'))
|
||||
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
|
||||
copyFileSync(
|
||||
sourceScriptPath,
|
||||
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
|
||||
)
|
||||
return projectDir
|
||||
}
|
||||
|
||||
function runInstallScript(projectDir, extraEnv = {}) {
|
||||
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_platform: 'linux',
|
||||
npm_config_arch: 'x64',
|
||||
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
|
||||
...extraEnv
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function writeFakeElectronPackage(projectDir, { lazyRequireMarker = null } = {}) {
|
||||
const electronDir = join(projectDir, 'node_modules', 'electron')
|
||||
mkdirSync(electronDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(electronDir, 'package.json'),
|
||||
JSON.stringify({ name: 'electron', version: '41.5.0' })
|
||||
)
|
||||
writeFileSync(join(electronDir, 'checksums.json'), '{}')
|
||||
writeFileSync(
|
||||
join(electronDir, 'index.js'),
|
||||
`
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''}
|
||||
const pathFile = path.join(__dirname, 'path.txt')
|
||||
if (!fs.existsSync(pathFile)) {
|
||||
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
|
||||
}
|
||||
module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) {
|
||||
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
|
||||
mkdirSync(getDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(getDir, 'index.js'),
|
||||
`
|
||||
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
appendFileSync(
|
||||
'electron-get.log',
|
||||
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + '\\n'
|
||||
)
|
||||
if (${JSON.stringify(downloadNeverSettles)}) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
mkdirSync(details.cacheRoot, { recursive: true })
|
||||
const artifactPath = join(details.cacheRoot, 'electron.zip')
|
||||
writeFileSync(artifactPath, 'fake zip')
|
||||
return artifactPath
|
||||
}
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeExtractor(projectDir, { createExecutable }) {
|
||||
writeFileSync(
|
||||
join(projectDir, 'fake-extractor.cjs'),
|
||||
`
|
||||
const { mkdirSync, symlinkSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const extractDir = process.argv[3]
|
||||
mkdirSync(join(extractDir, 'locales'), { recursive: true })
|
||||
if (${JSON.stringify(createExecutable)}) {
|
||||
writeFileSync(join(extractDir, 'electron'), '')
|
||||
writeFileSync(join(extractDir, 'electron.exe'), '')
|
||||
writeFileSync(join(extractDir, 'version'), 'v41.5.0')
|
||||
if (process.platform !== 'win32') {
|
||||
symlinkSync('version', join(extractDir, 'version-link'))
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const API_VERSION = '2022-11-28'
|
||||
const DESKTOP_STABLE_TAG_PATTERN = /^v([0-9]+)\.([0-9]+)\.([0-9]+)$/
|
||||
|
||||
export function parseDesktopStableTag(tag) {
|
||||
const match = DESKTOP_STABLE_TAG_PATTERN.exec(tag)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
tag,
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3])
|
||||
}
|
||||
}
|
||||
|
||||
export function latestStableDesktopReleaseTag(releases) {
|
||||
const stableTags = releases
|
||||
.filter((release) => release?.draft !== true)
|
||||
.map((release) => parseDesktopStableTag(release?.tag_name ?? release?.tagName ?? ''))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.major - b.major || a.minor - b.minor || a.patch - b.patch)
|
||||
|
||||
return stableTags.at(-1)?.tag ?? ''
|
||||
}
|
||||
|
||||
async function githubJson(fetchImpl, url, token) {
|
||||
const res = await fetchImpl(url, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-GitHub-Api-Version': API_VERSION
|
||||
}
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchReleases(repo, token, fetchImpl = fetch) {
|
||||
if (!repo) {
|
||||
throw new Error('repo is required')
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error('token is required')
|
||||
}
|
||||
|
||||
const releases = []
|
||||
for (let page = 1; ; page += 1) {
|
||||
const pageReleases = await githubJson(
|
||||
fetchImpl,
|
||||
`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`,
|
||||
token
|
||||
)
|
||||
if (!Array.isArray(pageReleases)) {
|
||||
throw new Error(`GitHub releases response page ${page} for ${repo} was not an array`)
|
||||
}
|
||||
|
||||
releases.push(...pageReleases)
|
||||
if (pageReleases.length < 100) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return releases
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
||||
const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca'
|
||||
const releases = await fetchReleases(repo, token)
|
||||
process.stdout.write(latestStableDesktopReleaseTag(releases))
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user