diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml new file mode 100644 index 0000000000..f0a83cab6b --- /dev/null +++ b/.github/workflows/package-smoke.yml @@ -0,0 +1,63 @@ +name: Package Smoke + +on: + pull_request: + paths: + - 'package.json' + - 'pnpm-lock.yaml' + - 'tsdown.config.ts' + - 'scripts/build-android-helper.sh' + - 'scripts/build-xcuitest-apple.sh' + - 'scripts/package-android-helper.sh' + - 'scripts/package-apple-runner-source.mjs' + - 'scripts/patch-xcuitest-runner-icon.ts' + - 'scripts/sync-mcp-metadata.mjs' + - 'scripts/write-xcuitest-cache-metadata.mjs' + - 'apple/macos-helper/**' + - 'apple/runner/**' + - 'android/ime-helper/**' + - 'android/snapshot-helper/**' + - '.github/actions/setup-node-pnpm/**' + - '.github/workflows/package-smoke.yml' + push: + branches: + - main + paths: + - 'package.json' + - 'pnpm-lock.yaml' + - 'tsdown.config.ts' + - 'scripts/build-android-helper.sh' + - 'scripts/build-xcuitest-apple.sh' + - 'scripts/package-android-helper.sh' + - 'scripts/package-apple-runner-source.mjs' + - 'scripts/patch-xcuitest-runner-icon.ts' + - 'scripts/sync-mcp-metadata.mjs' + - 'scripts/write-xcuitest-cache-metadata.mjs' + - 'apple/macos-helper/**' + - 'apple/runner/**' + - 'android/ime-helper/**' + - 'android/snapshot-helper/**' + - '.github/actions/setup-node-pnpm/**' + - '.github/workflows/package-smoke.yml' + +permissions: + contents: read + +concurrency: + group: package-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + package: + name: Build npm package + runs-on: macos-26 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Build complete npm package + run: pnpm prepack diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml index 8ce31e9c08..a25d51ef19 100644 --- a/.github/workflows/perf-nightly.yml +++ b/.github/workflows/perf-nightly.yml @@ -68,8 +68,7 @@ jobs: - name: Run iOS command perf benchmark run: | - pnpm perf \ - --platform ios \ + pnpm perf --platform ios \ --device "iPhone 17 Pro" \ --n "$PERF_ROUNDS" --warmup 1 \ --out-dir "$GITHUB_WORKSPACE/perf-results" @@ -112,7 +111,7 @@ jobs: adb -s emulator-5554 shell settings put global window_animation_scale 0 || true; adb -s emulator-5554 shell settings put global transition_animation_scale 0 || true; adb -s emulator-5554 shell settings put global animator_duration_scale 0 || true; - pnpm perf:android --serial emulator-5554 --n "$PERF_ROUNDS" --warmup 1 --out-dir "$GITHUB_WORKSPACE/perf-results" + pnpm perf --platform android --serial emulator-5554 --n "$PERF_ROUNDS" --warmup 1 --out-dir "$GITHUB_WORKSPACE/perf-results" - name: Upload Android perf report if: always() diff --git a/.github/workflows/test-app-build-cache.yml b/.github/workflows/test-app-build-cache.yml index 990a76bc3b..0108cf980c 100644 --- a/.github/workflows/test-app-build-cache.yml +++ b/.github/workflows/test-app-build-cache.yml @@ -45,6 +45,9 @@ jobs: - name: Setup test app dependencies uses: ./.github/actions/setup-test-app-dependencies + - name: Typecheck test app + run: pnpm test-app:typecheck + - name: Resolve native fingerprint id: fingerprint env: diff --git a/AGENTS.md b/AGENTS.md index 5b01b172d4..2019165037 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,9 @@ prose in this repo, including this file. shipped before this rule. - Unreleased API surface dies free. Before treating a field as wire-compat, check `git tag --contains `; if it never shipped, delete it now. -- Push only behind `&&`-chained gates: `format:check && typecheck && lint && vitest && git push`. A - push that can run after a failed gate eventually will. +- Push only behind an `&&`-chained affected gate: + `pnpm check:affected --run && git push`. A push that can run after a failed gate eventually will; + GitHub remains authoritative for reported device/toolchain lanes. ## Derived registries — read the declaration site, not prose @@ -159,9 +160,10 @@ one question so `rg` → read-whole-file stays one cheap bounded read. the only exclusion list is `.oxfmtrc.json` `ignorePatterns` — Markdown, the Maestro conformance corpus, and generated baselines. Run `pnpm format`, never `oxfmt `: a path argument reformats a subset and hides whatever else drifted. -- Before pushing, the aggregate is **`pnpm check`** (`check:tooling && check:fallow && - check:unit`). `pnpm check:tooling` is a *subset*: it stops before the Fallow audit, so dead - exports and complexity findings your diff introduces still fail CI after it passes clean. +- Before pushing, default to **`pnpm check:affected --run`**. It selects the relevant local gates and + reports native/device checks left to GitHub. Use **`pnpm check`** + (`check:tooling && check:fallow && check:unit`) for broad refactors or an explicitly requested full + deterministic core gate. Neither command claims to reproduce every GitHub job. Fallow's baselines are keyed by path, so a change that RENAMES a file must move that file's entry in `fallow-baselines/health.json` — regenerating the baselines would silently accept every other outstanding finding too. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6eaf4d82c1..57ef5f71b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,199 +1,178 @@ # Contributing -Thanks for your interest in contributing to agent-device. +Thanks for helping improve agent-device. This guide is the shortest path from a fresh checkout to a +reviewable change. Detailed testing and device procedures live in the linked focused guides. -## Development +## Set up the repository Requirements: -- Node.js 22+ -- pnpm, activated from `package.json` `packageManager` (`corepack enable pnpm`) -- Android SDK tools (`adb`) for Android support -- Xcode (`simctl`/`devicectl`) for iOS support - -Setup: +- Node.js 22 or newer +- pnpm at the version pinned in `package.json` +- Android SDK tools (`adb`) for Android work +- Xcode (`simctl`/`devicectl`) for Apple-platform work ```bash -corepack enable pnpm pnpm install +pnpm build ``` -`package.json` `packageManager` is the single source of truth for the pnpm version. Corepack -activates that exact version locally, and `.github/actions/setup-node-pnpm` reads the same field -and fails the job when the installed `pnpm --version` disagrees — so the CI and local package -managers cannot drift apart silently, and the "Ignoring...pnpm" drift warning stays quiet locally. +`package.json`'s `packageManager` field is the source of truth for pnpm, and CI rejects a different +version. Node distributions that include Corepack can activate it with `corepack enable pnpm`. +Newer Node distributions may not bundle Corepack; in that case, install the pinned pnpm version +using your Node version manager or the [pnpm installation guide](https://pnpm.io/installation), then +confirm it with `pnpm --version` before installing dependencies. -Build all CLIs and Xcode projects: +The root install does not install the much larger Expo test-app dependency graph. If your change +touches `examples/test-app`, install it separately: ```bash -pnpm build:all +pnpm test-app:install +pnpm test-app:typecheck ``` -Apple XCTest builds now share a common helper script. `pnpm build:xcuitest:ios` and -`pnpm build:xcuitest:tvos` keep their existing cleanup behavior, while -`pnpm build:xcuitest:macos` reuses the existing DerivedData by default for faster local -iteration. Set `AGENT_DEVICE_IOS_CLEAN_DERIVED=1` when you need a clean macOS runner rebuild. +## Build the surface you changed + +`pnpm build` compiles the TypeScript CLI and library. If a running development daemon must pick up +that build, use `pnpm rebuild:cli`; it builds and then stops the worktree-scoped daemon. -Before pushing, run the aggregate gate: +Build only the Apple runner target you changed: ```bash -pnpm check +pnpm build:xcuitest:ios +pnpm build:xcuitest:macos +pnpm build:xcuitest:tvos +pnpm build:xcuitest:visionos ``` -That is `check:tooling && check:fallow && check:unit`, and it is the only command that covers -every non-device CI job. **`pnpm check:tooling` on its own is not the gate** — it stops before -`check:fallow`, so a dead export or a complexity finding your diff introduces still fails CI -after a clean `check:tooling` run. `pnpm test` likewise runs the unit projects only. What -`pnpm check` cannot cover is the device/smoke matrix, which needs real devices. +Append `:clean` to any platform build when DerivedData may be stale, for example +`pnpm build:xcuitest:macos:clean`. `pnpm build:xcuitest` remains the shared iOS-and-macOS gate for +changes that affect both runners; it is not an all-platform build. -Run tests: +Android and macOS helper builds remain separate because they require their native toolchains: ```bash -pnpm test +pnpm build:android +pnpm build:macos-helper ``` -Targeted checks, while iterating: +There is intentionally no catch-all development build. Native toolchains are expensive and +independent, so agents and contributors should run the command for the surface they changed. +Use `pnpm build:macos-helper:clean` if a Swift cache was created in another worktree. -```bash -pnpm check:quick -pnpm check:unit -pnpm exec vitest run src/compat/maestro/__tests__/replay-flow.test.ts src/compat/__tests__/replay-input.test.ts -``` +## Prepare the npm package -Code quality (fallow): CI runs `pnpm check:fallow --base "$FALLOW_BASE"`, a diff-based -audit of dead code, duplication, and complexity in the files your PR changes, compared -against the grandfathered baselines in `fallow-baselines/`. Locally, `pnpm fallow` runs -the same kind of audit against `origin/main` and is expected to pass on a clean tree; -`pnpm fallow:all` shows the full-project picture, including known legacy findings that -the baselines grandfather, so it reporting issues is normal. CRAP scores depend on -estimated test coverage, so a finding can occasionally be exposed — not introduced — by -your change. Run `pnpm fallow:baseline` to regenerate the baselines only when you are -intentionally accepting a finding. - -- `pnpm fallow` — diff-based audit vs `origin/main` (what CI runs, with CI picking the PR base) -- `pnpm fallow:all` — full-tree summary, includes grandfathered legacy findings -- `pnpm fallow:baseline` — regenerate baselines (only to intentionally accept a finding) - -Code quality (production exports): `pnpm check:production-exports` runs Fallow's native -production graph, which excludes test/story/dev files, and fails when a new export has no -production consumer. This includes the test-only-export bug class that shipped in #1199's first -revision, while also catching exports that are unreachable from every graph. It is intentionally -baseline-free: there is no grandfather file, so a new unused production export fails loudly. Fallow's -`ignoreExportsUsedInFile` option in the gate's inherited config keeps exports with a real -same-file consumer out of this report without weakening the general Fallow audit. - -Fix a finding by wiring the export into production or removing the unnecessary export/code. For -an intentional test seam or other non-production consumer, add a JSDoc `@internal` tag with a short -justification beside the declaration. An inline -`// fallow-ignore-next-line unused-export` is not suitable here: the general test-inclusive graph -sees the test consumer and correctly reports that suppression as stale. Production usage reached -only through dynamic property access remains invisible to a static import graph, so register those -exports in `.fallowrc.json` `ignoreExports` instead (as with the daemon route handlers loaded -through `typeof import()`). - -Optional device selectors for tests: - -- `ANDROID_DEVICE=Pixel_9_Pro_XL` or `ANDROID_SERIAL=emulator-5554` -- `IOS_DEVICE="iPhone 17 Pro"` or `IOS_UDID=` - -## Test App and Maestro Compatibility - -The Expo test app lives in `examples/test-app`. Install its dependencies once: +`pnpm publish` and package-manager pack commands run `prepack`, which first checks synchronized MCP +metadata and then runs `pnpm package:npm`. This is the one completeness-oriented aggregate: it +builds the TypeScript distribution and all four Apple runner targets, clean-builds the macOS helper, +packages the Apple runner source, and rebuilds both Android helper APKs. Any failed build stops +packaging. It deliberately does not stop the worktree's development daemon; use `pnpm rebuild:cli` +when a running daemon needs to pick up a new TypeScript build. -```bash -pnpm test-app:install -``` +`pnpm package:npm` is a release guard, not a routine development command. Use the specific commands +above while iterating. -For Maestro compatibility, we currently have 15 parser/compat unit tests and one -top-level test-app Maestro flow, `examples/test-app/maestro/checkout-form.yaml`, -which includes `examples/test-app/maestro/helpers/open-checkout-form.yaml`. +## Validate a change -Run only the parser/compat tests: +Use the smallest trustworthy loop while editing: ```bash -pnpm exec vitest run src/compat/maestro/__tests__/replay-flow.test.ts src/compat/__tests__/replay-input.test.ts +pnpm check:quick # lint + TypeScript +pnpm test:maestro-compat # example of a focused family suite +pnpm exec vitest run path/to/file.test.ts ``` -Run the Expo test-app flow on iOS: +Before pushing a normal code change, let the repository derive the required gates: ```bash -pnpm test-app:ios -- --device "iPhone 17 Pro" -pnpm ad --session test-app-maestro open "Agent Device Tester" --platform ios --device "iPhone 17 Pro" -pnpm ad --session test-app-maestro wait "Agent Device Tester" 30000 --platform ios --device "iPhone 17 Pro" -pnpm test-app:maestro:ios -- --session test-app-maestro -- --device "iPhone 17 Pro" +pnpm check:affected --run ``` -`pnpm test-app:ios` keeps Metro in the foreground after launching the app. Leave -that terminal running and run the `agent-device` and Maestro commands from a -separate terminal. +The selector combines the committed diff with staged, unstaged, and untracked files. Unknown, +workflow, lockfile, and selector-owning changes fail open to the full local set. It reports +device/toolchain checks that remain GitHub-authoritative instead of trying to run them implicitly. -When targeting a specific Android emulator or device, build and install the -development client on that same target before running Maestro: +For broad refactors or when explicitly requested, run the deterministic core aggregate: ```bash -pnpm test-app:android -- --device "$ANDROID_DEVICE" -pnpm test-app:maestro:android -- --session test-app-maestro -- --device "$ANDROID_DEVICE" +pnpm check ``` -## Guidelines +`pnpm check` covers formatting, lint, typechecking, layering, dependency-graph parity, production +exports, MCP metadata, the distributable build, bundle ownership, Fallow, unit tests, and local smoke +tests. It is intentionally not a simulation of every CI job: coverage, provider integration, +history-backed compatibility, specialized toolchains, and live device/browser lanes remain separate. +GitHub CI is authoritative. -- Keep dependencies minimal. -- Preserve the CLI’s agent-friendly JSON output. -- Ensure tests open and close sessions explicitly. -- Add/adjust integration tests when introducing new commands. -- Prefer built-in Node APIs over new packages. +Useful direct entry points: -### Conservative Code Comments +- `pnpm test` or `pnpm test:unit` — root unit projects +- `pnpm test:coverage` — coverage plus coverage-only projects +- `pnpm test:integration` — Node and provider-backed integration suites +- `pnpm perf --platform ios` or `pnpm perf --platform android` — device performance harness +- `pnpm check:fallow --base origin/main` — changed-code quality gate +- `pnpm fallow:all` — full-tree audit, including grandfathered baseline findings +- `pnpm fallow:baseline` — intentionally regenerate both reviewed Fallow baselines -When code deliberately chooses a slower or more conservative path, leave a short inline comment at -the decision site. The comment should name: +See [`docs/agents/testing.md`](docs/agents/testing.md) for gate ownership, shared test utilities, +mutation/fuzz lanes, contention policy, and test-speed rules. For real devices, follow +[`docs/agents/device-verification.md`](docs/agents/device-verification.md); a fixture-backed test does +not prove that a native path was active. -1. the failure or regression the conservative path prevents; and -2. the condition that should trigger a revisit. +## Test app and Maestro compatibility -Use a grep-able `CONSERVATIVE:` prefix when the choice is expected to outlive the current change. -This applies to defensive fallbacks, temporary guards, disabled fast paths, serialization, retries, -over-preservation, and teardown-to-be-safe behavior. +The Expo fixture app owns its setup, simulator/device, Metro, replay, and Maestro instructions in +[`examples/test-app/README.md`](examples/test-app/README.md). -Examples: +The stable compatibility entry points are: -```ts -// CONSERVATIVE: Keep the preflight for non-allowlisted runner commands because only the -// allowlist has proven healthy-mutation recovery. Revisit when lifecycle status coverage can -// distinguish every mutating command's terminal state. +```bash +pnpm test:maestro-compat +pnpm maestro:conformance +pnpm test-app:maestro:ios +pnpm test-app:maestro:android ``` +The first two are deterministic and device-free. The test-app suites need the app, Metro when +applicable, and a real simulator or emulator. + +## Contribution guidelines + +- Keep dependencies minimal and prefer built-in Node APIs. +- Preserve the CLI's compact, agent-friendly JSON output. +- Open and close sessions explicitly in tests and manual verification. +- Add or adjust integration coverage when introducing a command or changing a wire response. +- Run the focused gate that owns the behavior; do not replace missing coverage with a broad, + assertion-free test. + +### Conservative code comments + +When code deliberately chooses a slower or more conservative path, leave a short comment at the +decision site naming the prevented failure and the condition for revisiting the choice. Use the +grep-able `CONSERVATIVE:` prefix when the decision is expected to outlive the current change. + ```ts // CONSERVATIVE: Preserve external runner artifacts because the checkout does not own their cache // root. Revisit only if external artifacts get an ownership marker that makes cleanup safe. ``` -## Dependency Updates - -Renovate (`.github/renovate.json`) proposes dependency updates: weekly lockfile maintenance, one -grouped PR for devDependencies, one PR per runtime dependency, and digest bumps for GitHub Actions. -Security updates are enabled explicitly (`vulnerabilityAlerts` plus `osvVulnerabilityAlerts`) and -are the only updates exempt from the 7-day minimum release age. - -Renovate PRs are gated exactly like human PRs: `automerge` is off everywhere, so every branch needs -a green CI run and a human review before merge. Review one the way you would review any dependency -change — read the release notes in the PR body, and check that the affected-check plan for the diff -(`pnpm check:affected --base origin/main --run`, which fails open to the full set for lockfile and -workflow changes) is green. A green Renovate PR is a merge candidate, not a merge: no rubber-stamp -automerge path exists, and none should be added without also deciding which gate is trusted to -replace the reviewer. +## Dependency updates -## Issue Labels +Renovate proposes weekly lockfile maintenance, grouped development-dependency updates, individual +runtime-dependency updates, and GitHub Action digest bumps. Automerge is disabled: dependency PRs +need green CI and human review. -Issue labels describe workflow state, not who will do the work. See -`docs/agents/triage-labels.md` for the label meanings and state flow. +Read the release notes, inspect the affected-check plan, and treat a green update as a merge +candidate rather than an automatic merge: -## Reporting issues +```bash +pnpm check:affected --run +``` -Please include: +## Issues -- OS and Node version -- Xcode/Android SDK versions (if relevant) -- Exact command and output +Issue labels describe workflow state, not ownership. See +[`docs/agents/triage-labels.md`](docs/agents/triage-labels.md). -Thanks for helping improve agent-device. +When reporting a problem, include the OS and Node version, relevant Xcode or Android SDK versions, +and the exact command and output. diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 57392cb060..4a5e862fa1 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -2,20 +2,28 @@ ## Which gates a change needs -Default for code changes: `pnpm check:affected --base origin/main --run`. It derives the gate set -from repository sources of truth, so prefer it over interpreting the table below by hand. GitHub CI -stays authoritative. +Use three validation tiers: + +1. **While editing:** run a focused test or `pnpm check:quick`. +2. **Before pushing:** run `pnpm check:affected --run`. It derives the relevant local gates from + repository sources of truth and reports checks that need CI or a native toolchain. +3. **For broad refactors or an explicitly requested full local gate:** run `pnpm check`. + +`pnpm check` is the deterministic core aggregate, not a local reproduction of every GitHub job. +Coverage, provider integration, history-backed compatibility, specialized toolchains, and live +device/browser lanes remain separate. GitHub CI stays authoritative. The mapping it encodes, for when you need to run a gate directly or reason about coverage: | Change | Gate | | --- | --- | | Any TypeScript | `pnpm typecheck` or `pnpm check:quick` | +| Expo test app (`examples/test-app/**/*.{ts,tsx,js,jsx,json}`) | Root lint and format plus `pnpm test-app:typecheck`; the affected selector runs lint/format locally and reports the CI-owned typecheck without installing the isolated Expo dependency graph | | Daemon handler / shared module | `pnpm check:unit` | | Tooling/config (`package.json`, `tsconfig*.json`, `.oxlintrc.json`, `.oxfmtrc.json`) | `pnpm check:tooling` | | Platform/device response — anything emitting `platform`/`appleOs` on the wire, or shaping a daemon response | `pnpm test:integration:provider` **and** `pnpm test:coverage` | | Cross-platform behavior | `pnpm test:integration` | -| iOS runner / Swift | `pnpm build:xcuitest` | +| Apple runner / Swift | Build the changed target with `pnpm build:xcuitest:`; use `pnpm build:xcuitest` only for shared iOS/macOS changes | | CLI help/guidance (`src/cli/parser/cli-help.ts`, `src/cli-schema/`) | `pnpm exec vitest run src/cli/parser/__tests__ src/cli-schema/command-schema-guards.test.ts scripts/__tests__` — the `scripts/__tests__` gates enforce help-topic benchmark coverage and pin the bench's quoted CLI samples to the real renderers | | Help benchmark cases (`scripts/help-conformance-*.mjs`) | `pnpm exec vitest run scripts/__tests__` (deterministic gates); model-backed: `pnpm bench:help-conformance` (paid LLM calls, local only) | | `.ad` grammar (`src/replay/script.ts`, gesture arity, replay vars) | `pnpm exec vitest run --project unit-core test/replay-compat` — the frozen replay-compat corpus asserts which released script surfaces still parse; a flipped verdict is edited in `test/replay-compat/manifest.ts`, never in the script. Adding or re-pinning a corpus entry also runs `pnpm check:replay-compat`, which re-derives each entry from its release tag in git history | @@ -29,6 +37,8 @@ Two traps worth naming: Internal `apple` must never reach a command response — project through `publicPlatformString`. - Fallow CI failures reproduce with `pnpm check:fallow --base origin/main`. Do not estimate complexity or dead-code impact by hand. +- `pnpm fallow:all` audits the entire repository and can report grandfathered baseline findings. Use + it to inspect repository-wide debt, not as the changed-code gate. Docs/skills-only and non-TS changes with no behavior impact need no tests. Test-only DI seam CI failures are enforced by the workflow — do not add optional `typeof` DI params to production code to @@ -73,11 +83,13 @@ advisory**: existing GitHub CI stays authoritative and required, and this only narrows the *local* feedback loop. ```sh -pnpm check:affected --base origin/main --run # default agent loop: plan + run -pnpm check:affected --base origin/main # human-readable plan only -pnpm check:affected --base origin/main --json # machine-readable plan only +pnpm check:affected --run # default agent loop: plan + run +pnpm check:affected # human-readable plan only +pnpm check:affected --json # machine-readable plan only ``` +The default base is `origin/main`; pass `--base ` only when comparing against another ref. + The selection is derived from repository sources of truth rather than a hand-maintained path map: @@ -88,7 +100,10 @@ hand-maintained path map: analysis; GitHub's authoritative full suites still cover that boundary. - **Non-Vitest suites** retain explicit ownership. Root `test/integration/*.ts` files use the Node integration lane, and - platform/build tools keep their native gates. + platform/build tools keep their native gates. Test-app source selects root + lint and format plus its isolated typecheck; the typecheck is reported but + left to CI by `--run` so a root checkout never installs Expo dependencies + implicitly. - **Always-on gates** (`lint`, `typecheck`, `layering`, `fallow`, `format`) fire for their input categories and are never silently skipped. Platform source also selects the provider-integration and coverage gates required by the diff --git a/package.json b/package.json index f599b5c8cb..5413709f9c 100644 --- a/package.json +++ b/package.json @@ -83,11 +83,12 @@ "clean:xcuitest:macos": "node scripts/clean-xcuitest-derived.mjs macos", "clean:xcuitest:tvos": "node scripts/clean-xcuitest-derived.mjs tvos", "clean:xcuitest:visionos": "node scripts/clean-xcuitest-derived.mjs visionos", - "build:node": "pnpm build && pnpm clean:daemon", + "rebuild:cli": "pnpm build && pnpm clean:daemon", "build:xcuitest": "pnpm build:xcuitest:ios && pnpm build:xcuitest:macos", "build:xcuitest:ios": "AGENT_DEVICE_XCUITEST_PLATFORM=ios sh ./scripts/build-xcuitest-apple.sh", "build:xcuitest:ios:clean": "pnpm clean:xcuitest:ios && pnpm build:xcuitest:ios", "build:xcuitest:macos": "AGENT_DEVICE_XCUITEST_PLATFORM=macos sh ./scripts/build-xcuitest-apple.sh", + "build:xcuitest:macos:clean": "pnpm clean:xcuitest:macos && pnpm build:xcuitest:macos", "build:xcuitest:tvos": "AGENT_DEVICE_XCUITEST_PLATFORM=tvos sh ./scripts/build-xcuitest-apple.sh", "build:xcuitest:tvos:clean": "pnpm clean:xcuitest:tvos && pnpm build:xcuitest:tvos", "build:xcuitest:visionos": "AGENT_DEVICE_XCUITEST_PLATFORM=visionos sh ./scripts/build-xcuitest-apple.sh", @@ -101,7 +102,8 @@ "package:android-ime-helper": "AGENT_DEVICE_ANDROID_HELPER=ime sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") .tmp/android-ime-helper", "package:android-ime-helper:npm": "rm -rf android/ime-helper/dist && AGENT_DEVICE_ANDROID_HELPER=ime sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") android/ime-helper/dist", "build:macos-helper": "swift build -c release --package-path apple/macos-helper", - "build:all": "pnpm build:node && pnpm build:xcuitest", + "build:macos-helper:clean": "swift package --package-path apple/macos-helper clean && pnpm build:macos-helper", + "package:npm": "pnpm build && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm package:apple-runner:npm && pnpm build:android", "ad": "node bin/agent-device.mjs", "bench:help-conformance": "node scripts/help-conformance-bench.mjs", "maestro:conformance": "node --experimental-strip-types --test scripts/maestro-conformance/verify.test.ts scripts/maestro-conformance/differential/run.test.ts scripts/maestro-conformance/differential/invariants.test.ts", @@ -110,10 +112,7 @@ "prototype:program-boundary": "node --experimental-strip-types scripts/prototypes/daemon-boundaries/program-boundary.ts", "prototype:session-boundary": "node --experimental-strip-types scripts/prototypes/daemon-boundaries/session-boundary.ts", "size": "node scripts/size-report.mjs", - "size:markdown": "node scripts/size-report.mjs --json .tmp/size-report.json --markdown .tmp/size-report.md", "perf": "node --experimental-strip-types scripts/perf/run.ts", - "perf:ios": "node --experimental-strip-types scripts/perf/run.ts --platform ios", - "perf:android": "node --experimental-strip-types scripts/perf/run.ts --platform android", "mutation:run": "node --experimental-strip-types scripts/mutation/run.ts", "mutation:baseline": "node --experimental-strip-types scripts/mutation/run.ts --update", "mutation:check": "node --experimental-strip-types scripts/mutation/run.ts --no-run", @@ -123,9 +122,8 @@ "format": "node ./node_modules/oxfmt/bin/oxfmt --write", "format:check": "node ./node_modules/oxfmt/bin/oxfmt --check", "fuzz:parsers": "node --experimental-strip-types scripts/fuzz/run.ts", - "fallow": "fallow audit --base origin/main", "fallow:all": "fallow --summary", - "fallow:baseline": "(fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary || true) && (fallow health --save-baseline fallow-baselines/health.json --summary || true)", + "fallow:baseline": "fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary && fallow health --report-only --save-baseline fallow-baselines/health.json --summary", "check:fallow": "fallow audit", "check:affected": "node --experimental-strip-types scripts/check-affected/run.ts", "check:affected:test": "node --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts", @@ -142,11 +140,11 @@ "check:quick": "pnpm lint && pnpm typecheck", "sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs", "check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check", - "version": "node scripts/sync-mcp-metadata.mjs && git add server.json", - "check:tooling": "pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:production-exports && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files", + "version": "pnpm sync:mcp-metadata && git add server.json", + "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:production-exports && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files", "check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", - "prepack": "pnpm check:mcp-metadata && pnpm build:all && pnpm package:apple-runner:npm && pnpm package:android-snapshot-helper:npm && pnpm package:android-ime-helper:npm", + "prepack": "pnpm check:mcp-metadata && pnpm package:npm", "typecheck": "tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", "test-app:install": "pnpm install --dir examples/test-app", "test-app:start": "pnpm --dir examples/test-app start", @@ -156,10 +154,11 @@ "test-app:replay:ios": "pnpm ad test examples/test-app/replays --platform ios --artifacts-dir .tmp/test-app-replay/ios", "test-app:replay:android": "pnpm ad test examples/test-app/replays --platform android --artifacts-dir .tmp/test-app-replay/android", "test-app:maestro": "node scripts/run-test-app-maestro-suite.mjs", - "test-app:maestro:ios": "node scripts/run-test-app-maestro-suite.mjs --platform ios", - "test-app:maestro:android": "node scripts/run-test-app-maestro-suite.mjs --platform android", - "test": "vitest run --project unit-core --project subprocess-stub", + "test-app:maestro:ios": "pnpm test-app:maestro --platform ios", + "test-app:maestro:android": "pnpm test-app:maestro --platform android", + "test": "pnpm test:unit", "test:unit": "vitest run --project unit-core --project subprocess-stub", + "test:maestro-compat": "vitest run --project unit-core src/compat/maestro src/compat/__tests__/replay-input.test.ts", "test:coverage": "vitest run --coverage", "test:coverage:ci": "node --experimental-strip-types scripts/lib/contention-retry-run.ts --coverage", "check:contention-retry": "node --experimental-strip-types --test scripts/lib/contention-retry-policy.test.ts", diff --git a/scripts/check-affected/checks.ts b/scripts/check-affected/checks.ts index a26ef0ce19..be9950d89f 100644 --- a/scripts/check-affected/checks.ts +++ b/scripts/check-affected/checks.ts @@ -43,6 +43,15 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ ciJobs: ['Typecheck'], localRunnable: true, }, + { + id: 'test-app-typecheck', + label: 'Expo test app typecheck', + kind: { type: 'script', script: 'test-app:typecheck' }, + ciJobs: ['Resolve native fingerprint'], + // The test app intentionally owns a separate Expo dependency graph. Do + // not make every root-checkout validation install it implicitly. + localRunnable: false, + }, { id: 'layering', label: 'Import-direction layering guard', diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 9b75a13701..e1db1cbd17 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -100,8 +100,14 @@ test('docs-only change selects no checks and records the docs paths', () => { assert.equal(result.docsOnlyPaths.length, 3); }); +test('test app source selects root lint and format plus its isolated typecheck', () => { + const result = plan(['examples/test-app/app/index.tsx']); + assert.equal(result.failOpen, false); + assert.deepEqual(result.checks, ['format', 'lint', 'test-app-typecheck']); +}); + test('unknown path fails open to the full check set', () => { - const result = plan(['examples/test-app/App.tsx']); + const result = plan(['fixtures/unknown.data']); assert.equal(result.failOpen, true); assert.deepEqual(result.checks, [...ALL_CHECKS]); assert.equal(result.failOpenReasons[0]?.rule, 'unknown-path'); @@ -168,6 +174,7 @@ test('every catalog command resolves against package scripts', () => { 'format:check': 'x', lint: 'x', typecheck: 'x', + 'test-app:typecheck': 'x', 'check:layering': 'x', 'check:fallow': 'x', 'check:mcp-metadata': 'x', diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index b216c92988..3aee60e380 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -23,6 +23,7 @@ export type CheckId = | 'format' | 'lint' | 'typecheck' + | 'test-app-typecheck' | 'layering' | 'fallow' | 'mcp-metadata' @@ -45,6 +46,7 @@ export const ALL_CHECKS: readonly CheckId[] = [ 'format', 'lint', 'typecheck', + 'test-app-typecheck', 'layering', 'fallow', 'mcp-metadata', @@ -219,6 +221,21 @@ const nodeIntegrationOwnership: OwnershipRule = ({ file }) => ? [reason('integration-node', file, 'node-integration', 'node --test integration smoke')] : []; +const testAppOwnership: OwnershipRule = ({ file }) => { + if (!file.startsWith('examples/test-app/')) return []; + if (!/\.(?:[cm]?[jt]sx?|json)$/.test(file)) return []; + return [ + reason('format', file, 'gate:format', 'oxfmt covers the Expo test app'), + reason('lint', file, 'gate:lint', 'oxlint covers the Expo test app'), + reason( + 'test-app-typecheck', + file, + 'own:test-app', + 'the Expo test app has an isolated TypeScript dependency graph', + ), + ]; +}; + // The frozen replay-compat corpus (#1417). `.ad` fixture data would otherwise // fail open on its extension: its only consumer is the unit-lane corpus test. // Any corpus change — script or manifest — also runs the history-backed @@ -298,6 +315,7 @@ const OWNERSHIP_RULES: readonly OwnershipRule[] = [ srcProdGate, vitestRelatedOwnership, nodeIntegrationOwnership, + testAppOwnership, replayCompatOwnership, buildOwnership, ]; diff --git a/scripts/check-affected/run.test.ts b/scripts/check-affected/run.test.ts index 02a63f490c..915d8f86a4 100644 --- a/scripts/check-affected/run.test.ts +++ b/scripts/check-affected/run.test.ts @@ -91,6 +91,7 @@ const ALL_SCRIPTS: Record = { 'format:check': 'x', lint: 'x', typecheck: 'x', + 'test-app:typecheck': 'x', 'check:layering': 'x', 'check:fallow': 'x', 'check:mcp-metadata': 'x', diff --git a/scripts/depgraph/README.md b/scripts/depgraph/README.md index 82e5fe6e1c..4719b709ab 100644 --- a/scripts/depgraph/README.md +++ b/scripts/depgraph/README.md @@ -95,18 +95,6 @@ module imported both lazily and for its types would drop out. If they ever disagree, the gate is right and the baseline or the tree is wrong. -### Notes - - -`pnpm check:layering` is. This reads the same model, so the numbers should agree — its R6 -count matching `TYPE_INVERSION_BASELINE` is a useful self-check — but if they ever diverge, the -gate is right and the graph is stale. - -One thing here DOES run in CI, and only one: the Layering Guard job runs -`scripts/depgraph/model.test.ts`, whose parity test asserts this report's inversion count equals -`TYPE_INVERSION_BASELINE`. Nothing else here gates a merge — the report itself is an instrument, not -a rule, and no finding it produces is enforced. - ## Why it reuses the layering gate The graph is extracted with `scripts/layering/model.ts`, the same module diff --git a/scripts/explain-command.ts b/scripts/explain-command.ts index 973a573e5b..003ef59e50 100644 --- a/scripts/explain-command.ts +++ b/scripts/explain-command.ts @@ -1,33 +1,62 @@ import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { explainCommand, formatCommandExplanation } from '../src/commands/command-explain.ts'; import { getDaemonRouteOwnerFiles } from '../src/daemon/route-owner-files.ts'; const repoRoot = path.resolve(import.meta.dirname, '..'); -const args = process.argv.slice(2); -const json = args.includes('--json'); -const full = args.includes('--full'); -const query = args.find((arg) => arg !== '--json' && arg !== '--full'); +export type ExplainCommandCliResult = { + status: number; + stdout: string; + stderr: string; +}; -if (!query) { - process.stderr.write('Usage: pnpm explain:command [--json] [--full]\n'); - process.exitCode = 1; -} else { +/** @internal Exported so tests can exercise CLI behavior without starting a subprocess. */ +export function runExplainCommandCli(args: string[], root = repoRoot): ExplainCommandCliResult { + const json = args.includes('--json'); + const full = args.includes('--full'); + const query = args.find((arg) => arg !== '--json' && arg !== '--full'); + + if (!query) { + return { + status: 1, + stdout: '', + stderr: 'Usage: pnpm explain:command [--json] [--full]\n', + }; + } const daemonRouteOwnerFiles = getDaemonRouteOwnerFiles(); const result = explainCommand(query, { - fileExists: (file) => fs.existsSync(path.join(repoRoot, file)), + fileExists: (file) => fs.existsSync(path.join(root, file)), daemonRouteOwnerFiles, }); if (!result.found) { const suffix = result.suggestions.length === 0 ? '' : ` Did you mean: ${result.suggestions.join(', ')}?`; - process.stderr.write(`Unknown command "${result.query}".${suffix}\n`); - process.exitCode = 1; - } else { - process.stdout.write( - json - ? `${JSON.stringify(result.explanation, null, 2)}\n` - : `${formatCommandExplanation(result.explanation, { detail: full ? 'full' : 'compact' })}\n`, - ); + return { + status: 1, + stdout: '', + stderr: `Unknown command "${result.query}".${suffix}\n`, + }; } + + return { + status: 0, + stdout: json + ? `${JSON.stringify(result.explanation, null, 2)}\n` + : `${formatCommandExplanation(result.explanation, { detail: full ? 'full' : 'compact' })}\n`, + stderr: '', + }; +} + +function isMainModule(): boolean { + return Boolean( + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url), + ); +} + +if (isMainModule()) { + const result = runExplainCommandCli(process.argv.slice(2)); + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + process.exitCode = result.status; } diff --git a/scripts/maestro-conformance/README.md b/scripts/maestro-conformance/README.md index d6df8e2220..0bb2506052 100644 --- a/scripts/maestro-conformance/README.md +++ b/scripts/maestro-conformance/README.md @@ -111,7 +111,6 @@ failure, not a pass. ```sh pnpm maestro:conformance -# node --test scripts/maestro-conformance/verify.test.ts differential/run.test.ts ``` The verifier fails on any **undeclared** divergence: a flow our engine parses diff --git a/src/__tests__/daemon-entrypoint.test.ts b/src/__tests__/daemon-entrypoint.test.ts index 81279ffa3d..3e3c69f354 100644 --- a/src/__tests__/daemon-entrypoint.test.ts +++ b/src/__tests__/daemon-entrypoint.test.ts @@ -104,7 +104,12 @@ test('daemon runtime starts HTTP transport in-process and shuts down cleanly', a await waitForHttpOk(`http://127.0.0.1:${runtime?.httpPort}/health`, 2_000); const artifactResponse = await fetch( `http://127.0.0.1:${runtime?.httpPort}/artifacts/${encodeURIComponent(artifactId)}`, - { headers: { authorization: `Bearer ${runtime?.token}` } }, + { + headers: { + authorization: `Bearer ${runtime?.token}`, + connection: 'close', + }, + }, ); assert.equal(artifactResponse.status, 200); assert.equal(await artifactResponse.text(), 'runtime-artifact'); diff --git a/src/__tests__/npm-package-scripts.test.ts b/src/__tests__/npm-package-scripts.test.ts new file mode 100644 index 0000000000..c3ed16dda6 --- /dev/null +++ b/src/__tests__/npm-package-scripts.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'vitest'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { + scripts: Record; +}; +const perfNightlyWorkflow = fs.readFileSync( + path.join(repoRoot, '.github', 'workflows', 'perf-nightly.yml'), + 'utf8', +); +const packageSmokeWorkflow = fs.readFileSync( + path.join(repoRoot, '.github', 'workflows', 'package-smoke.yml'), + 'utf8', +); + +function script(name: string): string { + const value = packageJson.scripts[name]; + assert.ok(value, `package.json must define "${name}"`); + return value; +} + +test('prepack builds the complete package without stopping the development daemon', () => { + assert.equal(script('prepack'), 'pnpm check:mcp-metadata && pnpm package:npm'); + assert.doesNotMatch(script('package:npm'), /clean:daemon|rebuild:cli/); + assert.equal(packageJson.scripts['build:dev'], undefined); + assert.match(packageSmokeWorkflow, /run: pnpm prepack/); + for (const input of [ + 'scripts/patch-xcuitest-runner-icon.ts', + 'scripts/sync-mcp-metadata.mjs', + 'scripts/write-xcuitest-cache-metadata.mjs', + '.github/actions/setup-node-pnpm/**', + ]) { + assert.ok(packageSmokeWorkflow.includes(`- '${input}'`)); + } +}); + +test('perf uses one platform-selectable entry point', () => { + assert.equal(script('perf'), 'node --experimental-strip-types scripts/perf/run.ts'); + assert.equal(packageJson.scripts['perf:ios'], undefined); + assert.equal(packageJson.scripts['perf:android'], undefined); + assert.doesNotMatch(perfNightlyWorkflow, /pnpm perf:/); + assert.match(perfNightlyWorkflow, /pnpm perf --platform ios/); + assert.match(perfNightlyWorkflow, /pnpm perf --platform android/); +}); + +test('Fallow exposes one changed-code gate and an explicit full-tree audit', () => { + assert.equal(packageJson.scripts.fallow, undefined); + assert.equal(script('check:fallow'), 'fallow audit'); + assert.equal(script('fallow:all'), 'fallow --summary'); +}); + +test('the npm package build covers every package-owned build output', () => { + assert.deepEqual(script('package:npm').split(' && '), [ + 'pnpm build', + 'pnpm build:xcuitest:ios', + 'pnpm build:xcuitest:macos', + 'pnpm build:xcuitest:tvos', + 'pnpm build:xcuitest:visionos', + 'pnpm build:macos-helper:clean', + 'pnpm package:apple-runner:npm', + 'pnpm build:android', + ]); + + assert.deepEqual(script('build:android').split(' && '), [ + 'pnpm package:android-snapshot-helper:npm', + 'pnpm package:android-ime-helper:npm', + ]); +}); diff --git a/src/commands/__tests__/command-explain.test.ts b/src/commands/__tests__/command-explain.test.ts index 226a3c3e0f..6f6f1dc11a 100644 --- a/src/commands/__tests__/command-explain.test.ts +++ b/src/commands/__tests__/command-explain.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { describe, expect, test } from 'vitest'; +import { runExplainCommandCli } from '../../../scripts/explain-command.ts'; import { commandDescriptors } from '../../core/command-descriptor/registry.ts'; import { ownerFilesForCommand } from '../../core/command-descriptor/owner-files.ts'; import { getDaemonRouteOwnerFiles } from '../../daemon/route-owner-files.ts'; @@ -23,6 +24,10 @@ const explainCommand = ( }); function runExplainCli(args: string[]): { status: number; stdout: string; stderr: string } { + return runExplainCommandCli(args, repoRoot); +} + +function runExplainCliProcess(args: string[]): { status: number; stdout: string; stderr: string } { try { const stdout = execFileSync( process.execPath, @@ -207,7 +212,7 @@ describe('explainCommand table-driven coverage', () => { describe('explain:command CLI', () => { test('prints the formatted explanation to stdout and exits 0', () => { - const { status, stdout, stderr } = runExplainCli(['open']); + const { status, stdout, stderr } = runExplainCliProcess(['open']); expect(status).toBe(0); expect(stderr).toBe(''); expect(stdout).toContain('open [public]'); diff --git a/src/core/__tests__/dispatch-resolve.test.ts b/src/core/__tests__/dispatch-resolve.test.ts index 9a8c8f581b..6e7c52a8d4 100644 --- a/src/core/__tests__/dispatch-resolve.test.ts +++ b/src/core/__tests__/dispatch-resolve.test.ts @@ -13,30 +13,45 @@ const { mockListAndroidDevices: vi.fn(), })); -vi.mock('../../platforms/apple/core/devices.ts', async (importOriginal) => { - const actual = await importOriginal(); +// Keep these as full mocks: the real device listers invoke host subprocesses, +// which would turn this resolver unit suite into environment-dependent integration coverage. +vi.mock('../../platforms/apple/core/devices.ts', () => { return { - ...actual, findBootableIosSimulator: mockFindBootableIosSimulator, listAppleDevices: mockListAppleDevices, }; }); -vi.mock('../../platforms/android/devices.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../platforms/android/devices.ts', () => { return { - ...actual, listAndroidDevices: mockListAndroidDevices, }; }); -vi.mock('../../platforms/apple/core/apps.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../platforms/apple/core/apps.ts', () => { return { - ...actual, findIosSimulatorInstalledApp: mockFindIosSimulatorInstalledApp, }; }); +vi.mock('../../platforms/apple/os/macos/devices.ts', () => ({ + listMacosDevices: vi.fn(async () => [ + { + platform: 'apple', + id: 'host-macos-local', + name: 'Test Mac', + kind: 'device', + target: 'desktop', + appleOs: 'macos', + booted: true, + }, + ]), +})); +vi.mock('../../platforms/vega/devices.ts', () => ({ + listVegaDevices: vi.fn(async () => []), +})); +vi.mock('../../platforms/linux/devices.ts', () => ({ + listLinuxDevices: vi.fn(async () => []), +})); import { resolveTargetDevice, diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 7e5c3cd8d6..b9b5902e7c 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -5,6 +5,10 @@ import path from 'node:path'; import { getResolveTargetDeviceMock } from './request-router-dispatch-mocks.ts'; vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('../../utils/host-process.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readProcessStartTime: vi.fn(() => 'test-process-start') }; +}); import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler } from '../request-router.ts'; @@ -401,11 +405,18 @@ test('router serializes same-device open requests before first session creation new AppError('DEVICE_NOT_FOUND', 'device discovery is still warming up'), sameDevice, ]; + let resolutionCalls = 0; + let markSecondPreflightFinished: (() => void) | undefined; + const secondPreflightFinished = new Promise((resolve) => { + markSecondPreflightFinished = resolve; + }); mockResolveTargetDevice.mockImplementation(async () => { + resolutionCalls += 1; const next = resolutionPlan.shift(); if (!next) { throw new Error('Unexpected resolveTargetDevice call'); } + if (resolutionCalls === 3) markSecondPreflightFinished?.(); if (next instanceof AppError) { throw next; } @@ -416,11 +427,16 @@ test('router serializes same-device open requests before first session creation let activeEnsures = 0; let maxActiveEnsures = 0; let releaseFirstEnsure: (() => void) | undefined; + let markFirstEnsureStarted: (() => void) | undefined; + const firstEnsureStarted = new Promise((resolve) => { + markFirstEnsureStarted = resolve; + }); mockEnsureDeviceReady.mockImplementation(async () => { ensureCalls += 1; activeEnsures += 1; maxActiveEnsures = Math.max(maxActiveEnsures, activeEnsures); if (ensureCalls === 1) { + markFirstEnsureStarted?.(); await new Promise((resolve) => { releaseFirstEnsure = () => { activeEnsures -= 1; @@ -436,15 +452,13 @@ test('router serializes same-device open requests before first session creation const firstOpen = handler(openRequest('session-a', { platform: 'ios' }, 'req-open-1')); - await vi.waitFor(() => { - expect(ensureCalls).toBe(1); - }); + await firstEnsureStarted; const secondOpen = handler( openRequest('session-b', { platform: 'ios', udid: 'SIM-001' }, 'req-open-2'), ); - await new Promise((resolve) => setTimeout(resolve, 20)); + await secondPreflightFinished; expect(ensureCalls).toBe(1); expect(maxActiveEnsures).toBe(1); @@ -478,10 +492,15 @@ test('router allows pre-open requests for different devices to proceed concurren let activeEnsures = 0; let maxActiveEnsures = 0; const releases: Array<() => void> = []; + let markBothEnsuresStarted: (() => void) | undefined; + const bothEnsuresStarted = new Promise((resolve) => { + markBothEnsuresStarted = resolve; + }); mockEnsureDeviceReady.mockImplementation(async () => { ensureCalls += 1; activeEnsures += 1; maxActiveEnsures = Math.max(maxActiveEnsures, activeEnsures); + if (ensureCalls === 2) markBothEnsuresStarted?.(); await new Promise((resolve) => { releases.push(() => { activeEnsures -= 1; @@ -499,10 +518,9 @@ test('router allows pre-open requests for different devices to proceed concurren openRequest('session-b', { platform: 'ios', udid: 'SIM-002' }, 'req-open-b'), ); - await vi.waitFor(() => { - expect(ensureCalls).toBe(2); - }); + await bothEnsuresStarted; + expect(ensureCalls).toBe(2); expect(maxActiveEnsures).toBe(2); releases.splice(0).forEach((release) => release()); diff --git a/src/daemon/handlers/__tests__/session-open-runtime.test.ts b/src/daemon/handlers/__tests__/session-open-runtime.test.ts index 7028eacdcf..14b3ed9027 100644 --- a/src/daemon/handlers/__tests__/session-open-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-open-runtime.test.ts @@ -48,6 +48,10 @@ vi.mock('../../../platforms/android/ime-lifecycle.ts', async (importOriginal) => await importOriginal(); return { ...actual, activateAndroidTestIme: vi.fn(async () => ({ activated: false })) }; }); +vi.mock('../../../utils/host-process.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readProcessStartTime: vi.fn(() => 'test-process-start') }; +}); import { handleSessionCommands } from '../session.ts'; import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts index c76f2903ea..d5b3a6b584 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts @@ -50,8 +50,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { Worker } from 'node:worker_threads'; import { PNG } from '../../../utils/png.ts'; -import { runCmdBackground, type ExecBackgroundResult } from '../../../utils/exec.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types.ts'; import type { CommandFlags } from '../../../core/dispatch.ts'; import { SessionStore } from '../../session-store.ts'; @@ -149,34 +149,6 @@ function fixtureReplayRequest(params: { }; } -async function readFirstStdoutLine(process: ExecBackgroundResult): Promise { - return await new Promise((resolve, reject) => { - let stdout = ''; - const cleanup = (): void => { - clearTimeout(timer); - process.child.stdout?.off('data', onData); - process.child.off('exit', onExit); - }; - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Timed out waiting for child process stdout.')); - }, 5000); - const onData = (chunk: Buffer | string): void => { - stdout += String(chunk); - const lineEnd = stdout.indexOf('\n'); - if (lineEnd === -1) return; - cleanup(); - resolve(stdout.slice(0, lineEnd)); - }; - const onExit = (): void => { - cleanup(); - reject(new Error('Child process exited before writing stdout.')); - }; - process.child.stdout?.on('data', onData); - process.child.on('exit', onExit); - }); -} - test('--update no longer rejects Maestro compat flow controls (the guard existed only for rewrite safety)', async () => { const { response } = await runReplayFixture({ label: 'maestro-replay-update-flow-control', @@ -469,30 +441,32 @@ output.result = SERVER_PATH + ':' + json(res.body).appviewDid }); test('runReplayScriptFile supports successful Maestro runScript http.post calls', async () => { - const serverScript = path.join( - fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runscript-http-')), - 'server.cjs', - ); - fs.writeFileSync( - serverScript, + const server = new Worker( ` const http = require('node:http'); +const { parentPort } = require('node:worker_threads'); const server = http.createServer((req, res) => { let body = ''; req.setEncoding('utf8'); req.on('data', chunk => { body += chunk; }); req.on('end', () => { res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({method: req.method, body})); + res.end(JSON.stringify({ method: req.method, body })); }); }); server.listen(0, '127.0.0.1', () => { - process.stdout.write(String(server.address().port) + '\\n'); + parentPort.postMessage(server.address().port); }); `, + { eval: true }, ); - const server = runCmdBackground(process.execPath, [serverScript], { allowFailure: true }); - const port = await readFirstStdoutLine(server); + const port = await new Promise((resolve, reject) => { + server.once('message', (value) => resolve(Number(value))); + server.once('error', reject); + server.once('exit', (code) => { + if (code !== 0) reject(new Error(`HTTP fixture worker exited with code ${code}`)); + }); + }); try { const { response, calls } = await runReplayFixture({ @@ -524,8 +498,7 @@ output.result = parsed.method + ':' + json(parsed.body).ok ], ); } finally { - server.child.kill(); - await server.wait.catch(() => undefined); + await server.terminate(); } }); diff --git a/src/platforms/__tests__/install-source.test.ts b/src/platforms/__tests__/install-source.test.ts index 67ae975086..4a94f52397 100644 --- a/src/platforms/__tests__/install-source.test.ts +++ b/src/platforms/__tests__/install-source.test.ts @@ -6,6 +6,7 @@ import fsSync from 'node:fs'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { withCommandExecutorOverride } from '../../utils/exec.ts'; import { ARCHIVE_EXTENSIONS, isBlockedIpAddress, @@ -17,6 +18,10 @@ import { import * as androidManifest from '../android/manifest.ts'; import { prepareAndroidInstallArtifact } from '../android/install-artifact.ts'; import { prepareIosInstallArtifact } from '../apple/core/install-artifact.ts'; +import { + createLocalAppleToolProvider, + withAppleToolProvider, +} from '../apple/core/tool-provider.ts'; test('validateDownloadSourceUrl rejects localhost and private literal addresses by default', async () => { await assert.rejects( @@ -136,6 +141,35 @@ test.sequential('materializeInstallablePath extracts zip archives without ditto' } }); +test('materializeInstallablePath extracts tar.gz archives', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-install-source-tar-')); + const archivePath = path.join(tempRoot, 'bundle.tar.gz'); + const payloadDir = path.join(tempRoot, 'payload'); + const apkPath = path.join(payloadDir, 'Sample.apk'); + + try { + await fs.mkdir(payloadDir); + await fs.writeFile(apkPath, 'placeholder apk', 'utf8'); + execFileSync('tar', ['-czf', archivePath, '-C', payloadDir, 'Sample.apk']); + + const result = await materializeInstallablePath({ + source: { kind: 'path', path: archivePath }, + isInstallablePath: (candidatePath, stat) => stat.isFile() && candidatePath.endsWith('.apk'), + installableLabel: 'Android installable (.apk or .aab)', + allowArchiveExtraction: true, + }); + + try { + assert.equal(path.basename(result.installablePath), 'Sample.apk'); + assert.equal(await fs.readFile(result.installablePath, 'utf8'), 'placeholder apk'); + } finally { + await result.cleanup(); + } + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + test('prepareIosInstallArtifact rejects untrusted URL sources', async () => { await assert.rejects( async () => @@ -256,154 +290,186 @@ test('prepareAndroidInstallArtifact cleans URL materialization when identity ins }); test('prepareAndroidInstallArtifact extracts trusted GitHub artifact ZIP containing one APK', async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-github-apk-')); - const archivePath = path.join(tempRoot, 'artifact.zip'); - const apkPath = path.join(tempRoot, 'app.apk'); - await fs.writeFile( - path.join(tempRoot, 'AndroidManifest.xml'), - '', - 'utf8', - ); - execFileSync('zip', ['-q', apkPath, 'AndroidManifest.xml'], { cwd: tempRoot }); - execFileSync('zip', ['-q', archivePath, 'app.apk'], { cwd: tempRoot }); - - await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/123/zip', - }); + await withArchiveFixture( + { + extractions: [ + { + command: 'unzip', + populate: async (outputPath) => { + await fs.writeFile(path.join(outputPath, 'app.apk'), 'apk fixture'); + }, + }, + ], + zipEntries: { + 'AndroidManifest.xml': + '', + }, + }, + async () => { + await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { + const result = await prepareAndroidInstallArtifact({ + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/123/zip', + }); - try { - assert.equal(path.basename(result.installablePath), 'app.apk'); - assert.equal(result.packageName, 'io.example.githubapk'); - } finally { - await result.cleanup(); - } - }); - await fs.rm(tempRoot, { recursive: true, force: true }); + try { + assert.equal(path.basename(result.installablePath), 'app.apk'); + assert.equal(result.packageName, 'io.example.githubapk'); + } finally { + await result.cleanup(); + } + }); + }, + ); }); test('prepareAndroidInstallArtifact extracts trusted GitHub artifact ZIP containing one AAB', async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-github-aab-')); - const archivePath = path.join(tempRoot, 'artifact.zip'); - const manifestDir = path.join(tempRoot, 'base', 'manifest'); - const aabPath = path.join(tempRoot, 'app.aab'); - await fs.mkdir(manifestDir, { recursive: true }); - await fs.writeFile( - path.join(manifestDir, 'AndroidManifest.xml'), - '', - 'utf8', - ); - await fs.writeFile(path.join(tempRoot, 'BundleConfig.pb'), 'bundle-config', 'utf8'); - execFileSync('zip', ['-qr', aabPath, 'BundleConfig.pb', 'base'], { cwd: tempRoot }); - execFileSync('zip', ['-q', archivePath, 'app.aab'], { cwd: tempRoot }); - - await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { - const result = await prepareAndroidInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/456/zip', - }); + await withArchiveFixture( + { + extractions: [ + { + command: 'unzip', + populate: async (outputPath) => { + await fs.writeFile(path.join(outputPath, 'app.aab'), 'aab fixture'); + }, + }, + ], + zipEntries: { + 'base/manifest/AndroidManifest.xml': + '', + }, + }, + async () => { + await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { + const result = await prepareAndroidInstallArtifact({ + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/456/zip', + }); - try { - assert.equal(path.basename(result.installablePath), 'app.aab'); - assert.equal(result.packageName, 'io.example.githubaab'); - } finally { - await result.cleanup(); - } - }); - await fs.rm(tempRoot, { recursive: true, force: true }); + try { + assert.equal(path.basename(result.installablePath), 'app.aab'); + assert.equal(result.packageName, 'io.example.githubaab'); + } finally { + await result.cleanup(); + } + }); + }, + ); }); test('prepareIosInstallArtifact extracts trusted GitHub artifact ZIP containing nested app tar', async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-github-app-tar-')); - const payloadDir = path.join(tempRoot, 'payload'); - const appDir = path.join(payloadDir, 'Demo.app'); - const tarPath = path.join(tempRoot, 'Demo.app.tar.gz'); - const archivePath = path.join(tempRoot, 'artifact.zip'); - await fs.mkdir(appDir, { recursive: true }); - await writeIosInfoPlist(appDir, { - bundleId: 'com.example.githubtar', - appName: 'GitHub Tar', - }); - execFileSync('tar', ['-czf', tarPath, '-C', payloadDir, 'Demo.app']); - execFileSync('zip', ['-q', archivePath, 'Demo.app.tar.gz'], { cwd: tempRoot }); - - await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { - const result = await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/789/zip', - }); - - try { - assert.equal(path.basename(result.installablePath), 'Demo.app'); - assert.equal(result.bundleId, 'com.example.githubtar'); - assert.equal(result.appName, 'GitHub Tar'); - } finally { - await result.cleanup(); - } - }); - await fs.rm(tempRoot, { recursive: true, force: true }); + await withArchiveFixture( + { + extractions: [ + { + command: 'unzip', + populate: async (outputPath) => { + await fs.writeFile(path.join(outputPath, 'Demo.app.tar.gz'), 'tar fixture'); + }, + }, + { + command: 'tar', + populate: async (outputPath) => { + await fs.mkdir(path.join(outputPath, 'Demo.app')); + }, + }, + ], + }, + async () => { + await withIosBundleInfo('com.example.githubtar', 'GitHub Tar', async () => { + await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { + const result = await prepareIosInstallArtifact({ + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/789/zip', + }); + + try { + assert.equal(path.basename(result.installablePath), 'Demo.app'); + assert.equal(result.bundleId, 'com.example.githubtar'); + assert.equal(result.appName, 'GitHub Tar'); + } finally { + await result.cleanup(); + } + }); + }); + }, + ); }); test('prepareIosInstallArtifact extracts trusted GitHub artifact ZIP containing one IPA', async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-github-ipa-')); - const payloadAppDir = path.join(tempRoot, 'Payload', 'Demo.app'); - const ipaPath = path.join(tempRoot, 'Demo.ipa'); - const archivePath = path.join(tempRoot, 'artifact.zip'); - await fs.mkdir(payloadAppDir, { recursive: true }); - await writeIosInfoPlist(payloadAppDir, { - bundleId: 'com.example.githubipa', - appName: 'GitHub IPA', - }); - execFileSync('zip', ['-qr', ipaPath, 'Payload'], { cwd: tempRoot }); - execFileSync('zip', ['-q', archivePath, 'Demo.ipa'], { cwd: tempRoot }); - - await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { - const result = await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/987/zip', - }); - - try { - assert.equal(path.basename(result.installablePath), 'Demo.app'); - assert.equal(result.bundleId, 'com.example.githubipa'); - assert.equal(result.appName, 'GitHub IPA'); - } finally { - await result.cleanup(); - } - }); - await fs.rm(tempRoot, { recursive: true, force: true }); + await withArchiveFixture( + { + extractions: [ + { + command: 'unzip', + populate: async (outputPath) => { + await fs.writeFile(path.join(outputPath, 'Demo.ipa'), 'ipa fixture'); + }, + }, + { + command: 'unzip', + populate: async (outputPath) => { + await fs.mkdir(path.join(outputPath, 'Payload', 'Demo.app'), { recursive: true }); + }, + }, + ], + }, + async () => { + await withIosBundleInfo('com.example.githubipa', 'GitHub IPA', async () => { + await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { + const result = await prepareIosInstallArtifact({ + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/987/zip', + }); + + try { + assert.equal(path.basename(result.installablePath), 'Demo.app'); + assert.equal(result.bundleId, 'com.example.githubipa'); + assert.equal(result.appName, 'GitHub IPA'); + } finally { + await result.cleanup(); + } + }); + }); + }, + ); }); test('prepareIosInstallArtifact cleans URL materialization when IPA payload resolution fails', async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-multi-ipa-')); - const payloadDir = path.join(tempRoot, 'Payload'); - const oneAppDir = path.join(payloadDir, 'One.app'); - const twoAppDir = path.join(payloadDir, 'Two.app'); - const ipaPath = path.join(tempRoot, 'Multi.ipa'); - const archivePath = path.join(tempRoot, 'artifact.zip'); - await fs.mkdir(oneAppDir, { recursive: true }); - await fs.mkdir(twoAppDir, { recursive: true }); - execFileSync('zip', ['-qr', ipaPath, 'Payload'], { cwd: tempRoot }); - execFileSync('zip', ['-q', archivePath, 'Multi.ipa'], { cwd: tempRoot }); - - try { - await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { - await withIsolatedInstallTempRoot(async (materializeTempRoot) => { - await assert.rejects( - async () => - await prepareIosInstallArtifact({ - kind: 'url', - url: 'https://api.github.com/repos/acme/app/actions/artifacts/988/zip', - }), - /found 2 .app bundles/, - ); - assert.deepEqual(await fs.readdir(materializeTempRoot), []); + await withArchiveFixture( + { + extractions: [ + { + command: 'unzip', + populate: async (outputPath) => { + await fs.writeFile(path.join(outputPath, 'Multi.ipa'), 'ipa fixture'); + }, + }, + { + command: 'unzip', + populate: async (outputPath) => { + await fs.mkdir(path.join(outputPath, 'Payload', 'One.app'), { recursive: true }); + await fs.mkdir(path.join(outputPath, 'Payload', 'Two.app'), { recursive: true }); + }, + }, + ], + }, + async () => { + await withMockedInstallSourceFetch(Buffer.from('artifact fixture'), async () => { + await withIsolatedInstallTempRoot(async (materializeTempRoot) => { + await assert.rejects( + async () => + await prepareIosInstallArtifact({ + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/988/zip', + }), + /found 2 .app bundles/, + ); + assert.deepEqual(await fs.readdir(materializeTempRoot), []); + }); }); - }); - } finally { - await fs.rm(tempRoot, { recursive: true, force: true }); - } + }, + ); }); test('prepareAndroidInstallArtifact rejects trusted artifact archives with multiple installables', async () => { @@ -470,6 +536,57 @@ function findExecutableInPath(command: string): string | undefined { return undefined; } +type ArchiveExtractionFixture = { + command: 'unzip' | 'tar'; + populate: (outputPath: string) => Promise; +}; + +async function withArchiveFixture( + fixture: { + extractions: ArchiveExtractionFixture[]; + zipEntries?: Record; + }, + run: () => Promise, +): Promise { + let extractionIndex = 0; + await withCommandExecutorOverride((command, args) => { + if (command === 'unzip' && args[0] === '-p') { + const contents = fixture.zipEntries?.[String(args[2])]; + return Promise.resolve({ + exitCode: contents === undefined ? 1 : 0, + stdout: '', + stderr: '', + stdoutBuffer: contents === undefined ? Buffer.alloc(0) : Buffer.from(contents), + }); + } + if (command !== 'unzip' && command !== 'tar') return undefined; + + const extraction = fixture.extractions[extractionIndex]; + assert.ok(extraction, `Unexpected extra ${command} extraction`); + assert.equal(command, extraction.command); + extractionIndex += 1; + const outputPath = String(args[3]); + return extraction.populate(outputPath).then(() => ({ exitCode: 0, stdout: '', stderr: '' })); + }, run); + assert.equal(extractionIndex, fixture.extractions.length); +} + +async function withIosBundleInfo( + bundleId: string, + appName: string, + run: () => Promise, +): Promise { + const provider = createLocalAppleToolProvider({ + plist: { + readJson: async () => ({ + CFBundleIdentifier: bundleId, + CFBundleDisplayName: appName, + }), + }, + }); + await withAppleToolProvider(provider, run); +} + async function withMockedInstallSourceFetch( bytes: Buffer, run: () => Promise, @@ -512,21 +629,3 @@ async function withIsolatedInstallTempRoot( await fs.rm(tempRoot, { recursive: true, force: true }); } } - -async function writeIosInfoPlist( - appDir: string, - params: { bundleId: string; appName: string }, -): Promise { - const plist = ` - - - - CFBundleIdentifier - ${params.bundleId} - CFBundleDisplayName - ${params.appName} - - -`; - await fs.writeFile(path.join(appDir, 'Info.plist'), plist, 'utf8'); -} diff --git a/src/platforms/android/__tests__/perf.test.ts b/src/platforms/android/__tests__/perf.test.ts index 140fc8f216..d27a8473f4 100644 --- a/src/platforms/android/__tests__/perf.test.ts +++ b/src/platforms/android/__tests__/perf.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs, { promises as fsPromises } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import type { AndroidAdbExecutor } from '../adb-executor.ts'; import { parseAndroidFramePerfSample } from '../perf-frame-parser.ts'; import { @@ -408,6 +408,7 @@ test('stopAndroidSimpleperfProfile pulls the profile artifact and reports compac }); test('stopAndroidSimpleperfProfile fails before pull when remote artifact never stabilizes', async () => { + vi.useFakeTimers(); const tmpDir = await fsPromises.mkdtemp( path.join(os.tmpdir(), 'agent-device-simpleperf-missing-test-'), ); @@ -434,14 +435,18 @@ test('stopAndroidSimpleperfProfile fails before pull when remote artifact never throw new Error(`Unexpected adb call: ${args.join(' ')}`); }; - await assert.rejects( - stopAndroidSimpleperfProfile(ANDROID_EMULATOR, session, session.outPath, { adb }), - /artifact is not ready/, - ); - assert.equal( - calls.some((args) => args[0] === 'pull'), - false, - ); + try { + const stop = stopAndroidSimpleperfProfile(ANDROID_EMULATOR, session, session.outPath, { adb }); + const rejection = assert.rejects(stop, /artifact is not ready/); + await vi.advanceTimersByTimeAsync(6_000); + await rejection; + assert.equal( + calls.some((args) => args[0] === 'pull'), + false, + ); + } finally { + vi.useRealTimers(); + } }); test('cleanupAndroidNativePerfSession stops profiler and removes remote artifact without pulling', async () => { diff --git a/src/platforms/android/perf-native-artifacts.ts b/src/platforms/android/perf-native-artifacts.ts index 7664596aaf..287998d251 100644 --- a/src/platforms/android/perf-native-artifacts.ts +++ b/src/platforms/android/perf-native-artifacts.ts @@ -1,8 +1,8 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; import type { DeviceInfo } from '../../kernel/device.ts'; import { AppError } from '../../kernel/errors.ts'; +import { sleep } from '../../utils/timeouts.ts'; import type { AndroidAdbExecutor } from './adb-executor.ts'; import { resolveAndroidAdbExecutor } from './adb-executor.ts'; import { annotateAndroidNativePerfError } from './perf-native-errors.ts'; @@ -180,7 +180,7 @@ async function waitForAndroidNativeArtifact( return; } previousSize = size; - await delay(ANDROID_NATIVE_ARTIFACT_POLL_INTERVAL_MS); + await sleep(ANDROID_NATIVE_ARTIFACT_POLL_INTERVAL_MS); } throw new AppError('COMMAND_FAILED', `Android ${session.kind} artifact is not ready to pull`, { package: session.packageName, diff --git a/src/platforms/apple/core/__tests__/runner-adoption.test.ts b/src/platforms/apple/core/__tests__/runner-adoption.test.ts index c08549ed5e..51d3c00f35 100644 --- a/src/platforms/apple/core/__tests__/runner-adoption.test.ts +++ b/src/platforms/apple/core/__tests__/runner-adoption.test.ts @@ -17,10 +17,6 @@ import { } from '../runner/runner-adoption.ts'; import { sendRunnerCommandOnce } from '../runner/runner-transport.ts'; import { isProcessAlive } from '../../../../utils/host-process.ts'; -import { - resolveExpectedRunnerCacheMetadata, - resolveRunnerDerivedPath, -} from '../runner/runner-xctestrun.ts'; vi.mock('../runner/runner-transport.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -28,7 +24,19 @@ vi.mock('../runner/runner-transport.ts', async (importOriginal) => { }); vi.mock('../../../../utils/host-process.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, isProcessAlive: vi.fn(() => false) }; + return { + ...actual, + isProcessAlive: vi.fn(() => false), + readProcessStartTime: vi.fn(() => 'test-process-start'), + }; +}); +vi.mock('../runner/runner-xctestrun.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveExpectedRunnerCacheMetadata: vi.fn(() => ({})), + resolveRunnerDerivedPath: vi.fn(() => expectedDerived), + }; }); const mockSendRunnerCommandOnce = vi.mocked(sendRunnerCommandOnce); @@ -70,10 +78,7 @@ function writeStaleLease(overrides: Partial = {}): RunnerLease { beforeEach(() => { leaseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-lease-test-')); process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = leaseDir; - expectedDerived = resolveRunnerDerivedPath( - simulator, - resolveExpectedRunnerCacheMetadata(simulator), - ); + expectedDerived = path.join(leaseDir, 'derived'); mockSendRunnerCommandOnce.mockReset(); mockIsProcessAlive.mockReset(); mockIsProcessAlive.mockReturnValue(false); diff --git a/src/platforms/apple/core/__tests__/runner-cache-probe.test.ts b/src/platforms/apple/core/__tests__/runner-cache-probe.test.ts index a49a7756aa..0a7c0e83f7 100644 --- a/src/platforms/apple/core/__tests__/runner-cache-probe.test.ts +++ b/src/platforms/apple/core/__tests__/runner-cache-probe.test.ts @@ -1,7 +1,17 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, beforeEach, expect, test } from 'vitest'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +const { mockRunCmdSync } = vi.hoisted(() => ({ mockRunCmdSync: vi.fn() })); + +vi.mock('../../../../utils/exec.ts', async () => { + const actual = await vi.importActual( + '../../../../utils/exec.ts', + ); + return { ...actual, runCmdSync: mockRunCmdSync }; +}); + import type { DeviceInfo } from '../../../../kernel/device.ts'; import { hasCachedAppleRunnerArtifact } from '../runner/runner-xctestrun.ts'; @@ -17,6 +27,16 @@ const simulator: DeviceInfo = { let derivedDir: string; beforeEach(() => { + mockRunCmdSync.mockImplementation((command: string, args: string[]) => { + if (command === 'xcodebuild') { + return { exitCode: 0, stdout: 'Xcode 26.2\nBuild version 17C52\n', stderr: '' }; + } + return { + exitCode: 0, + stdout: args.includes('--show-sdk-build-version') ? '23C53\n' : '26.2\n', + stderr: '', + }; + }); derivedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cache-probe-')); process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = derivedDir; }); diff --git a/src/platforms/apple/core/__tests__/runner-client.test.ts b/src/platforms/apple/core/__tests__/runner-client.test.ts index 21c8f7b737..23a91e9964 100644 --- a/src/platforms/apple/core/__tests__/runner-client.test.ts +++ b/src/platforms/apple/core/__tests__/runner-client.test.ts @@ -5,10 +5,13 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const { mockRunCmdStreaming, mockRepairMacOsRunnerProductsIfNeeded } = vi.hoisted(() => ({ - mockRunCmdStreaming: vi.fn(), - mockRepairMacOsRunnerProductsIfNeeded: vi.fn(), -})); +const { mockRunCmdStreaming, mockRunCmdSync, mockRepairMacOsRunnerProductsIfNeeded } = vi.hoisted( + () => ({ + mockRunCmdStreaming: vi.fn(), + mockRunCmdSync: vi.fn(), + mockRepairMacOsRunnerProductsIfNeeded: vi.fn(), + }), +); vi.mock('../../../../utils/exec.ts', async () => { const actual = await vi.importActual( @@ -17,6 +20,7 @@ vi.mock('../../../../utils/exec.ts', async () => { return { ...actual, runCmdStreaming: mockRunCmdStreaming, + runCmdSync: mockRunCmdSync, }; }); @@ -30,6 +34,11 @@ vi.mock('../runner/runner-macos-products.ts', async () => { }; }); +vi.mock('../../../../utils/host-process.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readProcessStartTime: vi.fn(() => 'test-process-start') }; +}); + import type { DeviceInfo } from '../../../../kernel/device.ts'; import { type RequestProgressEvent, @@ -204,10 +213,6 @@ async function makeProjectTmpDir(): Promise { return tmpDir; } -async function waitMs(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - function writeXctestrunFixture( xctestrunPath: string, options: { projectRoot: string; productRelativePaths: string[] }, @@ -311,6 +316,18 @@ async function makeCachedRunnerXctestrun(): Promise<{ beforeEach(() => { vi.resetAllMocks(); mockRunCmdStreaming.mockResolvedValue(undefined); + mockRunCmdSync.mockImplementation((command: string, args: string[]) => { + if (command === 'xcodebuild' && args[0] === '-version') { + return { exitCode: 0, stdout: 'Xcode 26.2\nBuild version 17C52\n', stderr: '' }; + } + if (command === 'xcrun' && args.includes('--show-sdk-version')) { + return { exitCode: 0, stdout: '26.2\n', stderr: '' }; + } + if (command === 'xcrun' && args.includes('--show-sdk-build-version')) { + return { exitCode: 0, stdout: '23C53\n', stderr: '' }; + } + throw new Error(`Unexpected Apple fingerprint command: ${command} ${args.join(' ')}`); + }); mockRepairMacOsRunnerProductsIfNeeded.mockResolvedValue(undefined); }); @@ -1073,6 +1090,10 @@ test('resolveRunnerDerivedPath reuses cache path for identical runner source fin }); test('acquireRunnerXctestrunCacheLock serializes cache access across acquirers', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); const tmpDir = await makeTmpDir(); const derivedPath = path.join(tmpDir, 'derived'); const releaseFirst = await acquireRunnerXctestrunCacheLock(derivedPath); @@ -1082,9 +1103,9 @@ test('acquireRunnerXctestrunCacheLock serializes cache access across acquirers', await releaseSecond(); }); - await waitMs(50); assert.equal(secondAcquired, false); await releaseFirst(); + await vi.advanceTimersByTimeAsync(100); await second; assert.equal(secondAcquired, true); }); diff --git a/src/platforms/apple/core/__tests__/runner-xctestrun.test.ts b/src/platforms/apple/core/__tests__/runner-xctestrun.test.ts index 5b907bb950..b3f01f922d 100644 --- a/src/platforms/apple/core/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/apple/core/__tests__/runner-xctestrun.test.ts @@ -5,6 +5,23 @@ import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +const { mockExecFileSync, mockRunCmdSync } = vi.hoisted(() => ({ + mockExecFileSync: vi.fn(), + mockRunCmdSync: vi.fn(), +})); + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { ...actual, execFileSync: mockExecFileSync }; +}); + +vi.mock('../../../../utils/exec.ts', async () => { + const actual = await vi.importActual( + '../../../../utils/exec.ts', + ); + return { ...actual, runCmdSync: mockRunCmdSync }; +}); + import type { DeviceInfo } from '../../../../kernel/device.ts'; import { withCommandExecutorOverride } from '../../../../utils/exec.ts'; import { findXctestrun, scoreXctestrunCandidate } from '../runner/runner-artifact.ts'; @@ -44,6 +61,24 @@ type RedirectPaths = { const runnerPortEnv = { AGENT_DEVICE_RUNNER_PORT: '12345' }; +function appleToolFingerprintOutput(command: string, args: readonly string[]): string { + if (command === 'xcodebuild' && args[0] === '-version') { + return 'Xcode 26.2\nBuild version 17C52\n'; + } + if (command === 'xcrun' && args.includes('--show-sdk-version')) return '26.2\n'; + if (command === 'xcrun' && args.includes('--show-sdk-build-version')) return '23C53\n'; + throw new Error(`Unexpected Apple fingerprint command: ${command} ${args.join(' ')}`); +} + +mockExecFileSync.mockImplementation((command: string, args: readonly string[]) => + appleToolFingerprintOutput(command, args), +); +mockRunCmdSync.mockImplementation((command: string, args: string[]) => ({ + exitCode: 0, + stdout: appleToolFingerprintOutput(command, args), + stderr: '', +})); + async function withTempDir(prefix: string, fn: (root: string) => Promise | T): Promise { const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); try { @@ -67,14 +102,6 @@ function makeScopedSimulator(paths: RedirectPaths): DeviceInfo { return { ...iosSimulator, simulatorSetPath: paths.requestedSetPath }; } -function restoreEnvVar(name: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[name]; - return; - } - process.env[name] = value; -} - async function acquireRedirect( paths: RedirectPaths, options: Partial[1]> = {}, @@ -212,8 +239,6 @@ test('setup metadata script matches expected iOS simulator cache metadata', asyn const scriptPath = path.join(repoRoot, 'scripts', 'write-xcuitest-cache-metadata.mjs'); const projectRoot = path.join(root, 'project'); const derivedRoot = path.join(root, 'derived'); - const binDir = path.join(root, 'bin'); - fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(derivedRoot, { recursive: true }); fs.mkdirSync(path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner'), { recursive: true, @@ -223,44 +248,19 @@ test('setup metadata script matches expected iOS simulator cache metadata', asyn path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner', 'Runner.swift'), 'final class Runner {}\n', ); - writeExecutable( - path.join(binDir, 'xcodebuild'), - ['#!/bin/sh', 'printf "Xcode 26.2\\nBuild version 17C52\\n"'].join('\n'), - ); - writeExecutable( - path.join(binDir, 'xcrun'), - [ - '#!/bin/sh', - 'case "$*" in', - ' *"--show-sdk-version"*) printf "26.2\\n" ;;', - ' *"--show-sdk-build-version"*) printf "23C53\\n" ;;', - ' *) exit 1 ;;', - 'esac', - ].join('\n'), + const { writeXcuitestCacheMetadata } = await import( + `${pathToFileURL(scriptPath).href}?case=${Date.now()}` ); - const previousPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`; - - try { - const { writeXcuitestCacheMetadata } = await import( - `${pathToFileURL(scriptPath).href}?case=${Date.now()}` - ); - writeXcuitestCacheMetadata( - ['ios', derivedRoot, 'generic/platform=iOS Simulator'], - projectRoot, - ); + writeXcuitestCacheMetadata(['ios', derivedRoot, 'generic/platform=iOS Simulator'], projectRoot); - const actual = JSON.parse( - fs.readFileSync(path.join(derivedRoot, '.agent-device-runner-cache.json'), 'utf8'), - ); - const { artifacts: _actualArtifacts, ...actualComparable } = actual; - const { artifacts: _expectedArtifacts, ...expectedComparable } = - resolveExpectedRunnerCacheMetadata(iosSimulator, projectRoot); + const actual = JSON.parse( + fs.readFileSync(path.join(derivedRoot, '.agent-device-runner-cache.json'), 'utf8'), + ); + const { artifacts: _actualArtifacts, ...actualComparable } = actual; + const { artifacts: _expectedArtifacts, ...expectedComparable } = + resolveExpectedRunnerCacheMetadata(iosSimulator, projectRoot); - assert.deepEqual(actualComparable, expectedComparable); - } finally { - restoreEnvVar('PATH', previousPath); - } + assert.deepEqual(actualComparable, expectedComparable); }); }, 15_000); @@ -291,10 +291,6 @@ test('runner cache key ignores package version but honors toolchain and SDK chan ); }); -function writeExecutable(filePath: string, contents: string): void { - fs.writeFileSync(filePath, `${contents}\n`, { mode: 0o755 }); -} - test('prepareXctestrunWithEnv avoids XCTest screen recordings for nested and legacy targets', async () => { await withTempDir('runner-xctestrun-policy-', async (root) => { const xctestrunPath = path.join(root, 'AgentDeviceRunner.xctestrun'); diff --git a/src/platforms/apple/core/__tests__/screenshot-density.test.ts b/src/platforms/apple/core/__tests__/screenshot-density.test.ts index c2b9231f3d..5726239c3a 100644 --- a/src/platforms/apple/core/__tests__/screenshot-density.test.ts +++ b/src/platforms/apple/core/__tests__/screenshot-density.test.ts @@ -6,13 +6,16 @@ import path from 'node:path'; import type { DeviceInfo } from '../../../../kernel/device.ts'; import { PNG } from '../../../../utils/png.ts'; import { screenshotIos } from '../screenshot.ts'; +import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts'; + +function startsWithArgs(args: string[], expected: string[]): boolean { + return expected.every((value, index) => args[index] === value); +} test('screenshotIos caches simulator screen scale per device', async () => { const tmpDir = await fs.mkdtemp( path.join(os.tmpdir(), 'agent-device-ios-screenshot-scale-cache-'), ); - const xcrunPath = path.join(tmpDir, 'xcrun'); - const commandLogPath = path.join(tmpDir, 'commands.log'); const outPath = path.join(tmpDir, 'screen.png'); const sourcePngPath = path.join(tmpDir, 'source.png'); const device: DeviceInfo = { @@ -24,62 +27,50 @@ test('screenshotIos caches simulator screen scale per device', async () => { }; await fs.writeFile(sourcePngPath, PNG.sync.write(new PNG({ width: 1206, height: 2622 }))); - await fs.writeFile( - xcrunPath, - [ - '#!/bin/sh', - 'echo "__XCRUN__ $*" >> "$AGENT_DEVICE_TEST_COMMAND_LOG"', - 'if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then', - ' echo \'{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-scale-cache","state":"Booted"}]}}\'', - ' exit 0', - 'fi', - 'if [ "$1" = "simctl" ] && [ "$2" = "getenv" ] && [ "$3" = "sim-scale-cache" ] && [ "$4" = "SIMULATOR_MAINSCREEN_SCALE" ]; then', - ' echo "3"', - ' exit 0', - 'fi', - 'if [ "$1" = "simctl" ] && [ "$2" = "io" ] && [ "$3" = "sim-scale-cache" ] && [ "$4" = "screenshot" ]; then', - ' cp "$AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE" "$5"', - ' exit 0', - 'fi', - 'echo "unexpected xcrun args: $*" >&2', - 'exit 1', - '', - ].join('\n'), - 'utf8', - ); - await fs.chmod(xcrunPath, 0o755); - - const previousPath = process.env.PATH; - const previousCommandLog = process.env.AGENT_DEVICE_TEST_COMMAND_LOG; - const previousScreenshotSourceFile = process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE; - process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; - process.env.AGENT_DEVICE_TEST_COMMAND_LOG = commandLogPath; - process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE = sourcePngPath; + const calls: string[][] = []; + const provider = createLocalAppleToolProvider({ + simctl: { + run: async (args) => { + calls.push(args); + if (startsWithArgs(args, ['list', 'devices', '-j'])) { + return { + exitCode: 0, + stdout: + '{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-scale-cache","state":"Booted"}]}}', + stderr: '', + }; + } + if (startsWithArgs(args, ['getenv', device.id, 'SIMULATOR_MAINSCREEN_SCALE'])) { + return { exitCode: 0, stdout: '3\n', stderr: '' }; + } + if (startsWithArgs(args, ['io', device.id, 'screenshot'])) { + await fs.copyFile(sourcePngPath, String(args[3])); + return { exitCode: 0, stdout: '', stderr: '' }; + } + throw new Error(`Unexpected simctl call: ${args.join(' ')}`); + }, + }, + }); try { - await screenshotIos(device, outPath); - await screenshotIos(device, outPath); + await withAppleToolProvider(provider, async () => { + await screenshotIos(device, outPath); + await screenshotIos(device, outPath); + }); - const logLines = (await fs.readFile(commandLogPath, 'utf8')).trim().split('\n').filter(Boolean); assert.equal( - logLines.filter( - (line) => line === '__XCRUN__ simctl getenv sim-scale-cache SIMULATOR_MAINSCREEN_SCALE', + calls.filter((args) => + startsWithArgs(args, ['getenv', device.id, 'SIMULATOR_MAINSCREEN_SCALE']), ).length, 1, ); assert.equal( - logLines.filter( - (line) => line === '__XCRUN__ simctl io sim-scale-cache screenshot ' + outPath, + calls.filter( + (args) => startsWithArgs(args, ['io', device.id, 'screenshot']) && args[3] === outPath, ).length, 2, ); } finally { - process.env.PATH = previousPath; - if (previousCommandLog === undefined) delete process.env.AGENT_DEVICE_TEST_COMMAND_LOG; - else process.env.AGENT_DEVICE_TEST_COMMAND_LOG = previousCommandLog; - if (previousScreenshotSourceFile === undefined) - delete process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE; - else process.env.AGENT_DEVICE_TEST_SCREENSHOT_SOURCE_FILE = previousScreenshotSourceFile; await fs.rm(tmpDir, { recursive: true, force: true }); } }); diff --git a/src/recording/__tests__/recording-scripts.test.ts b/src/recording/__tests__/recording-scripts.test.ts index 06495e100f..45e7b5a36c 100644 --- a/src/recording/__tests__/recording-scripts.test.ts +++ b/src/recording/__tests__/recording-scripts.test.ts @@ -1,4 +1,4 @@ -import { test } from 'vitest'; +import { beforeAll, test } from 'vitest'; import assert from 'node:assert/strict'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -12,60 +12,76 @@ const recordingScriptsDir = path.resolve( ); const recordingTestSupportDir = path.resolve(__dirname, '../../../test/integration/support'); const SWIFT_TYPECHECK_TIMEOUT_MS = 60_000; +let swiftCompilerPath = 'swiftc'; +let swiftSdkPath = ''; async function assertSwiftScriptTypechecks(scriptPath: string): Promise { - const result = await runCmd('xcrun', ['swiftc', '-typecheck', scriptPath], { + const result = await runCmd(swiftCompilerPath, ['-sdk', swiftSdkPath, '-typecheck', scriptPath], { allowFailure: true, }); - assert.equal(result.exitCode, 0, `${path.basename(scriptPath)} should typecheck`); + assert.equal( + result.exitCode, + 0, + `${path.basename(scriptPath)} should typecheck\n${result.stderr}`, + ); } +beforeAll(async () => { + if (process.platform !== 'darwin') return; + const [compilerResult, sdkResult] = await Promise.all([ + runCmd('xcrun', ['--find', 'swiftc']), + runCmd('xcrun', ['--show-sdk-path', '--sdk', 'macosx']), + ]); + swiftCompilerPath = compilerResult.stdout.trim(); + swiftSdkPath = sdkResult.stdout.trim(); +}); + test( - 'recording overlay Swift script typechecks', + 'recording trim Swift script typechecks', async (t) => { if (process.platform !== 'darwin') { t.skip('Swift recording scripts are only validated on macOS'); } - await assertSwiftScriptTypechecks(path.join(recordingScriptsDir, 'recording-overlay.swift')); + await assertSwiftScriptTypechecks(path.join(recordingScriptsDir, 'recording-trim.swift')); }, SWIFT_TYPECHECK_TIMEOUT_MS, ); test( - 'recording trim Swift script typechecks', + 'recording resize Swift script typechecks', async (t) => { if (process.platform !== 'darwin') { t.skip('Swift recording scripts are only validated on macOS'); } - await assertSwiftScriptTypechecks(path.join(recordingScriptsDir, 'recording-trim.swift')); + await assertSwiftScriptTypechecks(path.join(recordingScriptsDir, 'recording-resize.swift')); }, SWIFT_TYPECHECK_TIMEOUT_MS, ); test( - 'recording resize Swift script typechecks', + 'recording inspect Swift script typechecks', async (t) => { if (process.platform !== 'darwin') { t.skip('Swift recording scripts are only validated on macOS'); } - await assertSwiftScriptTypechecks(path.join(recordingScriptsDir, 'recording-resize.swift')); + await assertSwiftScriptTypechecks( + path.join(recordingTestSupportDir, 'recording-inspect.swift'), + ); }, SWIFT_TYPECHECK_TIMEOUT_MS, ); test( - 'recording inspect Swift script typechecks', + 'recording overlay Swift script typechecks', async (t) => { if (process.platform !== 'darwin') { t.skip('Swift recording scripts are only validated on macOS'); } - await assertSwiftScriptTypechecks( - path.join(recordingTestSupportDir, 'recording-inspect.swift'), - ); + await assertSwiftScriptTypechecks(path.join(recordingScriptsDir, 'recording-overlay.swift')); }, SWIFT_TYPECHECK_TIMEOUT_MS, ); diff --git a/src/screenshot-diff/__tests__/screenshot-diff.test.ts b/src/screenshot-diff/__tests__/screenshot-diff.test.ts index d4df57e567..9e8ea8c674 100644 --- a/src/screenshot-diff/__tests__/screenshot-diff.test.ts +++ b/src/screenshot-diff/__tests__/screenshot-diff.test.ts @@ -1,8 +1,24 @@ -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; + +vi.mock('../../utils/png-worker-client.ts', async () => { + const [{ PNG }, { decodePng }, { computeScreenshotDiffPixels }] = await Promise.all([ + import('../../utils/png-codec.ts'), + import('../../utils/png.ts'), + import('../../utils/screenshot-diff-pixels.ts'), + ]); + return { + decodePngAsync: async (buffer: Buffer, label: string) => decodePng(buffer, label), + encodePngAsync: async (png: InstanceType) => PNG.sync.write(png), + computeScreenshotDiffPixelsAsync: async ( + job: Parameters[0], + ) => computeScreenshotDiffPixels(job), + }; +}); + import { PNG } from '../../utils/png.ts'; import { compareScreenshots } from '../screenshot-diff.ts';