chore(deps): Renovate config, packageManager-derived pnpm in CI, repo-wide format (#1444)

* chore(deps): add Renovate config and enforce packageManager pnpm version in CI

Refs #1422

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: bump pnpm to 11.17.0 and format the whole repo with oxfmt

format/format:check drop their hand-maintained path list: oxfmt already skips
node_modules and honors .gitignore, so the only exclusion list is
.oxfmtrc.json ignorePatterns.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(mutation): accept either quote style in the affected-lane path filter

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(deps): keep fixture-app runtime deps as individual Renovate PRs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot]
2026-07-28 13:50:39 +02:00
committed by GitHub
parent e89ef9e69b
commit edca35d122
52 changed files with 569 additions and 297 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
[features]
collaboration_modes = true
collaboration_modes = true
+9 -33
View File
@@ -33,16 +33,12 @@
"scripts/write-xcuitest-cache-metadata.mjs",
"scripts/help-conformance-sample-outputs.d.mts"
],
"ignoreDependencies": [
"@theme"
],
"ignoreDependencies": ["@theme"],
"ignoreExports": [
{
"comment": "Authoritative supported-command list; sole consumer is the conformance oracle (scripts/maestro-conformance, an ignored tooling tree).",
"file": "src/compat/maestro/program-ir-command-parser.ts",
"exports": [
"SUPPORTED_MAESTRO_COMMAND_NAMES"
]
"exports": ["SUPPORTED_MAESTRO_COMMAND_NAMES"]
},
{
"comment": "Daemon route handlers are reached only through the dynamic `import()` table in request-handler-chain.ts, which --production analysis cannot follow to a consumer.",
@@ -60,16 +56,12 @@
{
"comment": "Published package surface (see package.json#exports); consumed by SDK users, not internally.",
"file": "src/sdk/*.ts",
"exports": [
"*"
]
"exports": ["*"]
},
{
"comment": "Tool config default exports, loaded by the tool rather than imported.",
"file": "{tsdown.config.ts,vitest.mutation.config.ts,website/rspress.config.ts,test/skillgym/skillgym.config.ts,test/skillgym/suites/*.ts}",
"exports": [
"default"
]
"exports": ["default"]
},
{
"comment": "Type-level `AssertTrue<...>` registry-totality guards. Exported only so `noUnusedLocals` keeps them alive; a consumer would defeat the point.",
@@ -84,34 +76,20 @@
{
"comment": "Mutation-lane seam: readTestScope is consumed by vitest.mutation.config.ts, a tool config outside --production analysis.",
"file": "scripts/mutation/test-scope.ts",
"exports": [
"readTestScope"
]
"exports": ["readTestScope"]
},
{
"comment": "Mutation-lane completeness gate: ownedTestFiles enumerates the whole derived ownership map, which only the assertion in ownership.test.ts consumes — a production consumer would defeat the point.",
"file": "scripts/mutation/ownership.ts",
"exports": [
"ownedTestFiles"
]
"exports": ["ownedTestFiles"]
},
{
"comment": "Help-benchmark conformance seams: helpTopicIds feeds the topic-coverage gate and PRIVATE_AX_RECOVERY_SAMPLE feeds the skillgym suite; both consumers are test-tree files outside --production analysis.",
"file": "{src/cli/parser/cli-help.ts,scripts/help-conformance-sample-outputs.mjs}",
"exports": [
"helpTopicIds",
"PRIVATE_AX_RECOVERY_SAMPLE"
]
"exports": ["helpTopicIds", "PRIVATE_AX_RECOVERY_SAMPLE"]
}
],
"usedClassMembers": [
"name",
"listActiveLeases",
"delete",
"values",
"elapsedMs",
"isExpired"
],
"usedClassMembers": ["name", "listActiveLeases", "delete", "values", "elapsedMs", "isExpired"],
"rules": {
"unused-types": "error",
"duplicate-exports": "off"
@@ -119,9 +97,7 @@
"production": {
"dupes": true
},
"publicPackages": [
"agent-device"
],
"publicPackages": ["agent-device"],
"audit": {
"deadCodeBaseline": "fallow-baselines/dead-code.json",
"healthBaseline": "fallow-baselines/health.json"
@@ -1,26 +1,26 @@
name: "Boot iOS Test Simulator"
description: "Resolve and boot the preferred iOS simulator for replay tests"
name: 'Boot iOS Test Simulator'
description: 'Resolve and boot the preferred iOS simulator for replay tests'
inputs:
runtime-version:
description: "Preferred iOS runtime version"
description: 'Preferred iOS runtime version'
required: true
preferred-device-name:
description: "Preferred simulator device name"
description: 'Preferred simulator device name'
required: false
default: "iPhone 17 Pro"
default: 'iPhone 17 Pro'
boot-timeout-seconds:
description: "Maximum time to wait for simulator bootstatus"
description: 'Maximum time to wait for simulator bootstatus'
required: false
default: "300"
default: '300'
outputs:
simulator-udid:
description: "UDID of the booted iOS simulator"
description: 'UDID of the booted iOS simulator'
value: ${{ steps.boot.outputs.simulator-udid }}
runs:
using: "composite"
using: 'composite'
steps:
- name: Resolve and boot iOS test simulator
id: boot
@@ -1,13 +1,13 @@
name: "Setup Android Replay Host"
description: "Prepare the Android CI host for replay tests and expose the agent-device home path"
name: 'Setup Android Replay Host'
description: 'Prepare the Android CI host for replay tests and expose the agent-device home path'
outputs:
agent-home-dir:
description: "Resolved agent-device home directory"
description: 'Resolved agent-device home directory'
value: ${{ steps.agent-home.outputs.dir }}
runs:
using: "composite"
using: 'composite'
steps:
- name: Resolve agent-device home
id: agent-home
+16 -16
View File
@@ -1,43 +1,43 @@
name: "Setup Apple Replay"
description: "Resolve shared Apple replay cache/build state and expose the agent-device home path"
name: 'Setup Apple Replay'
description: 'Resolve shared Apple replay cache/build state and expose the agent-device home path'
inputs:
derived-path:
description: "Derived data path used by the replay runner"
description: 'Derived data path used by the replay runner'
required: true
cache-key-prefix:
description: "Prefix for the cache key"
description: 'Prefix for the cache key'
required: true
cache-key-suffix:
description: "Optional suffix for the cache key"
description: 'Optional suffix for the cache key'
required: false
default: ""
default: ''
build-command:
description: "Command used to refresh replay artifacts before use"
description: 'Command used to refresh replay artifacts before use'
required: true
xcuitest-platform:
description: "Optional AGENT_DEVICE_XCUITEST_PLATFORM value"
description: 'Optional AGENT_DEVICE_XCUITEST_PLATFORM value'
required: false
default: ""
default: ''
xcuitest-destination:
description: "Optional AGENT_DEVICE_XCUITEST_DESTINATION value"
description: 'Optional AGENT_DEVICE_XCUITEST_DESTINATION value'
required: false
default: ""
default: ''
build-before-use:
description: "Whether this setup action should refresh replay artifacts after restoring the cache"
description: 'Whether this setup action should refresh replay artifacts after restoring the cache'
required: false
default: "true"
default: 'true'
outputs:
agent-home-dir:
description: "Resolved agent-device home directory"
description: 'Resolved agent-device home directory'
value: ${{ steps.agent-home.outputs.dir }}
cache-hit:
description: "Whether a replay cache was restored before any build refresh"
description: 'Whether a replay cache was restored before any build refresh'
value: ${{ steps.restore-prebuilt.outputs.cache-hit }}
runs:
using: "composite"
using: 'composite'
steps:
- name: Resolve Xcode cache key
id: xcode
+8 -8
View File
@@ -1,5 +1,5 @@
name: "Setup Fixture App"
description: "Install a ready Release build of examples/test-app on the booted iOS simulator, from the shared build cache"
name: 'Setup Fixture App'
description: 'Install a ready Release build of examples/test-app on the booted iOS simulator, from the shared build cache'
# Any job that needs a controlled app to drive can use this instead of an Apple
# system app. It fetches the Release binary that test-app-build-cache.yml
@@ -18,17 +18,17 @@ description: "Install a ready Release build of examples/test-app on the booted i
inputs:
device-name:
description: "Simulator device name used for the inline-build fallback"
description: 'Simulator device name used for the inline-build fallback'
required: false
default: "iPhone 17 Pro"
default: 'iPhone 17 Pro'
install:
description: "Install the app onto the booted simulator"
description: 'Install the app onto the booted simulator'
required: false
default: "true"
default: 'true'
outputs:
app-path:
description: "Path to the ready .app bundle"
description: 'Path to the ready .app bundle'
value: ${{ steps.locate.outputs.app-path }}
app-id:
description: "Bundle identifier read from the app's Info.plist"
@@ -38,7 +38,7 @@ outputs:
value: ${{ steps.fetch.outputs.source }}
runs:
using: "composite"
using: 'composite'
steps:
# Installed even on a hit, because the fingerprint is derived from the
# dependency graph and cannot be computed without it.
+34 -5
View File
@@ -6,10 +6,6 @@ inputs:
description: 'Node.js version'
required: false
default: '24.13'
pnpm-version:
description: 'pnpm version'
required: false
default: '11.1.2'
install-deps:
description: 'Whether to install dependencies with pnpm install --frozen-lockfile'
required: false
@@ -18,10 +14,28 @@ inputs:
runs:
using: 'composite'
steps:
# package.json `packageManager` is the single source of truth for the pnpm
# version. This action carries no default of its own: a second default is
# how local (corepack) and CI pnpm versions drift apart silently.
- name: Resolve pnpm version from packageManager
id: package-manager
run: |
set -euo pipefail
spec="$(node -p "require('$GITHUB_WORKSPACE/package.json').packageManager ?? ''")"
case "$spec" in
pnpm@[0-9]*) ;;
*)
echo "package.json packageManager must be pnpm@<version>, got '${spec}'" >&2
exit 1
;;
esac
echo "pnpm-version=${spec#pnpm@}" >> "$GITHUB_OUTPUT"
shell: bash
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: ${{ inputs.pnpm-version }}
version: ${{ steps.package-manager.outputs.pnpm-version }}
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -32,6 +46,21 @@ runs:
# miss (any lockfile-changing PR) the post-job cache save fails the job.
cache: ${{ inputs.install-deps == 'true' && 'pnpm' || '' }}
# Corepack resolves the same `packageManager` field locally, so a mismatch
# here means CI is running a pnpm nobody develops against.
- name: Assert pnpm matches packageManager
env:
EXPECTED_PNPM_VERSION: ${{ steps.package-manager.outputs.pnpm-version }}
run: |
set -euo pipefail
actual="$(pnpm --version)"
if [ "$actual" != "$EXPECTED_PNPM_VERSION" ]; then
echo "pnpm ${actual} is installed but package.json pins pnpm@${EXPECTED_PNPM_VERSION}" >&2
exit 1
fi
echo "pnpm ${actual} matches package.json packageManager"
shell: bash
- name: Install dependencies
if: ${{ inputs.install-deps == 'true' }}
run: pnpm install --frozen-lockfile
@@ -1,20 +1,20 @@
name: "Upload Agent Device Artifacts"
description: "Upload shared agent-device diagnostics, session logs, and replay artifacts"
name: 'Upload Agent Device Artifacts'
description: 'Upload shared agent-device diagnostics, session logs, and replay artifacts'
inputs:
artifact-name:
description: "Artifact name"
description: 'Artifact name'
required: true
agent-home-dir:
description: "Resolved agent-device daemon state directory"
description: 'Resolved agent-device daemon state directory'
required: true
runner-derived-path:
description: "Optional Apple runner derived data path to upload cache metadata and Xcode logs"
description: 'Optional Apple runner derived data path to upload cache metadata and Xcode logs'
required: false
default: ".agent-device-no-runner-derived"
default: '.agent-device-no-runner-derived'
runs:
using: "composite"
using: 'composite'
steps:
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+64
View File
@@ -0,0 +1,64 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":semanticCommitTypeAll(chore)",
"helpers:pinGitHubActionDigests"
],
"timezone": "Etc/UTC",
"schedule": ["before 6am on monday"],
"labels": ["dependencies"],
"dependencyDashboard": true,
"automerge": false,
"prConcurrentLimit": 5,
"prHourlyLimit": 2,
"rebaseWhen": "conflicted",
"minimumReleaseAge": "7 days",
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 6am on monday"],
"automerge": false,
"commitMessageAction": "refresh"
},
"vulnerabilityAlerts": {
"enabled": true,
"labels": ["dependencies", "security"],
"schedule": ["at any time"],
"minimumReleaseAge": null,
"automerge": false
},
"osvVulnerabilityAlerts": true,
"packageRules": [
{
"matchDepTypes": ["devDependencies"],
"groupName": "devDependencies",
"groupSlug": "dev-dependencies",
"automerge": false
},
{
"matchDepTypes": ["dependencies", "optionalDependencies", "peerDependencies"],
"groupName": null,
"automerge": false
},
{
"matchManagers": ["github-actions"],
"groupName": "github actions",
"groupSlug": "github-actions",
"pinDigests": true,
"automerge": false
},
{
"matchFileNames": ["examples/test-app/package.json"],
"matchDepTypes": ["devDependencies"],
"groupName": "test-app fixture devDependencies",
"groupSlug": "test-app-dev",
"automerge": false
},
{
"matchDepTypes": ["packageManager"],
"groupName": "pnpm (packageManager)",
"groupSlug": "package-manager",
"automerge": false
}
]
}
+7 -7
View File
@@ -3,13 +3,13 @@ name: Android
on:
pull_request:
paths-ignore:
- "docs/**"
- "website/**"
- "README.md"
- ".github/actions/build-docs/action.yml"
- ".github/workflows/deploy.yml"
- ".github/workflows/pr-preview.yml"
- ".github/workflows/pr-preview-cleanup.yml"
- 'docs/**'
- 'website/**'
- 'README.md'
- '.github/actions/build-docs/action.yml'
- '.github/workflows/deploy.yml'
- '.github/workflows/pr-preview.yml'
- '.github/workflows/pr-preview-cleanup.yml'
push:
branches:
- main
+10 -10
View File
@@ -3,13 +3,13 @@ name: CI
on:
pull_request:
paths-ignore:
- "docs/**"
- "website/**"
- "README.md"
- ".github/actions/build-docs/action.yml"
- ".github/workflows/deploy.yml"
- ".github/workflows/pr-preview.yml"
- ".github/workflows/pr-preview-cleanup.yml"
- 'docs/**'
- 'website/**'
- 'README.md'
- '.github/actions/build-docs/action.yml'
- '.github/workflows/deploy.yml'
- '.github/workflows/pr-preview.yml'
- '.github/workflows/pr-preview-cleanup.yml'
push:
branches:
- main
@@ -182,7 +182,7 @@ jobs:
- name: Setup Node.js 22.12
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: "22.12"
node-version: '22.12'
- name: Install and run global CLI on Node.js 22.12
run: |
@@ -328,7 +328,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
AGENT_DEVICE_WEB_E2E: "1"
AGENT_DEVICE_WEB_E2E: '1'
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -336,7 +336,7 @@ jobs:
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
with:
node-version: "24.13"
node-version: '24.13'
- name: Run live web smoke
run: |
@@ -15,11 +15,11 @@ name: Conformance Differential
on:
schedule:
- cron: "0 5 * * *"
- cron: '0 5 * * *'
workflow_dispatch:
inputs:
only:
description: "Single scenario id to run (default: all)"
description: 'Single scenario id to run (default: all)'
required: false
permissions:
@@ -32,11 +32,11 @@ concurrency:
cancel-in-progress: true
env:
AGENT_DEVICE_CLI: "--experimental-strip-types src/bin.ts"
AGENT_DEVICE_CLI: '--experimental-strip-types src/bin.ts'
DIFFERENTIAL_ONLY: ${{ github.event.inputs.only || '' }}
# CI should not phone home, and it keeps `maestro --version` to just the
# version instead of prefixing an analytics notice.
MAESTRO_CLI_NO_ANALYTICS: "1"
MAESTRO_CLI_NO_ANALYTICS: '1'
jobs:
differential-ios:
@@ -44,7 +44,7 @@ jobs:
runs-on: macos-26
timeout-minutes: 90
env:
IOS_RUNTIME_VERSION: "26.2"
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
steps:
+2 -2
View File
@@ -15,7 +15,7 @@ name: Conformance Regenerate Verify
on:
schedule:
- cron: "0 6 * * *"
- cron: '0 6 * * *'
workflow_dispatch:
permissions:
@@ -44,7 +44,7 @@ jobs:
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: temurin
java-version: "17"
java-version: '17'
# Verifies the pinned jar SHA-256s against pinned-upstream.json, runs the
# harness over the corpus, and rewrites fixtures/ + corpus/manifest.json.
+10 -10
View File
@@ -3,13 +3,13 @@ name: Linux
on:
pull_request:
paths-ignore:
- "docs/**"
- "website/**"
- "README.md"
- ".github/actions/build-docs/action.yml"
- ".github/workflows/deploy.yml"
- ".github/workflows/pr-preview.yml"
- ".github/workflows/pr-preview-cleanup.yml"
- 'docs/**'
- 'website/**'
- 'README.md'
- '.github/actions/build-docs/action.yml'
- '.github/workflows/deploy.yml'
- '.github/workflows/pr-preview.yml'
- '.github/workflows/pr-preview-cleanup.yml'
push:
branches:
- main
@@ -29,12 +29,12 @@ jobs:
env:
# Force X11 mode (Xvfb) — no Wayland on CI.
XDG_SESSION_TYPE: x11
DISPLAY: ":99"
DISPLAY: ':99'
# Headless GTK: avoid dconf issues, enable accessibility bridge.
GSETTINGS_BACKEND: memory
NO_AT_BRIDGE: "0"
NO_AT_BRIDGE: '0'
GTK_A11Y: atspi
GTK_MODULES: "gail:atk-bridge"
GTK_MODULES: 'gail:atk-bridge'
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+7 -7
View File
@@ -3,13 +3,13 @@ name: macOS
on:
pull_request:
paths-ignore:
- "docs/**"
- "website/**"
- "README.md"
- ".github/actions/build-docs/action.yml"
- ".github/workflows/deploy.yml"
- ".github/workflows/pr-preview.yml"
- ".github/workflows/pr-preview-cleanup.yml"
- 'docs/**'
- 'website/**'
- 'README.md'
- '.github/actions/build-docs/action.yml'
- '.github/workflows/deploy.yml'
- '.github/workflows/pr-preview.yml'
- '.github/workflows/pr-preview-cleanup.yml'
push:
branches:
- main
+11 -11
View File
@@ -25,17 +25,17 @@ on:
# Kernel sources, every src test (ownership is derived, so any test may own
# a kernel — `select` decides, not this filter), and the lane's own tooling.
# scripts/mutation/workflow.test.ts asserts this covers the registry.
- "src/kernel/errors.ts"
- "src/daemon/ref-frame.ts"
- "src/commands/interaction/runtime/settle.ts"
- "src/utils/scroll-edge-state.ts"
- "src/selectors/**"
- "src/**/*.test.ts"
- "scripts/mutation/**"
- "scripts/lib/**"
- "stryker.config.json"
- "mutation-baselines/**"
- ".github/workflows/mutation-affected.yml"
- 'src/kernel/errors.ts'
- 'src/daemon/ref-frame.ts'
- 'src/commands/interaction/runtime/settle.ts'
- 'src/utils/scroll-edge-state.ts'
- 'src/selectors/**'
- 'src/**/*.test.ts'
- 'scripts/mutation/**'
- 'scripts/lib/**'
- 'stryker.config.json'
- 'mutation-baselines/**'
- '.github/workflows/mutation-affected.yml'
permissions:
contents: read
+1 -1
View File
@@ -19,7 +19,7 @@ name: Mutation Weekly
on:
schedule:
# Sundays 05:00 UTC — after the nightly lanes, before the working week.
- cron: "0 5 * * 0"
- cron: '0 5 * * 0'
workflow_dispatch:
permissions:
+6 -6
View File
@@ -8,13 +8,13 @@ name: Perf Nightly
on:
schedule:
- cron: "0 4 * * *"
- cron: '0 4 * * *'
workflow_dispatch:
inputs:
rounds:
description: "Measured rounds per command (samples)"
description: 'Measured rounds per command (samples)'
required: false
default: "5"
default: '5'
permissions:
contents: read
@@ -24,7 +24,7 @@ concurrency:
cancel-in-progress: true
env:
AGENT_DEVICE_PERF_CLI: "--experimental-strip-types src/bin.ts"
AGENT_DEVICE_PERF_CLI: '--experimental-strip-types src/bin.ts'
PERF_ROUNDS: ${{ github.event.inputs.rounds || '5' }}
jobs:
@@ -33,10 +33,10 @@ jobs:
runs-on: macos-26
timeout-minutes: 80
env:
IOS_RUNTIME_VERSION: "26.2"
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS: "420000"
AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS: '420000'
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+7 -7
View File
@@ -7,13 +7,13 @@ on:
- reopened
- synchronize
paths:
- "website/**"
- ".github/actions/build-docs/action.yml"
- ".github/actions/setup-node-pnpm/action.yml"
- ".github/workflows/pr-preview.yml"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- 'website/**'
- '.github/actions/build-docs/action.yml'
- '.github/actions/setup-node-pnpm/action.yml'
- '.github/workflows/pr-preview.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
concurrency: preview-${{ github.ref }}
+7 -7
View File
@@ -3,13 +3,13 @@ name: Size
on:
pull_request:
paths-ignore:
- "docs/**"
- "website/**"
- "README.md"
- ".github/actions/build-docs/action.yml"
- ".github/workflows/deploy.yml"
- ".github/workflows/pr-preview.yml"
- ".github/workflows/pr-preview-cleanup.yml"
- 'docs/**'
- 'website/**'
- 'README.md'
- '.github/actions/build-docs/action.yml'
- '.github/workflows/deploy.yml'
- '.github/workflows/pr-preview.yml'
- '.github/workflows/pr-preview-cleanup.yml'
permissions:
contents: read
+3 -3
View File
@@ -20,9 +20,9 @@ on:
- main
pull_request:
paths:
- "examples/test-app/**"
- ".github/workflows/test-app-build-cache.yml"
- ".github/actions/setup-fixture-app/**"
- 'examples/test-app/**'
- '.github/workflows/test-app-build-cache.yml'
- '.github/actions/setup-fixture-app/**'
workflow_dispatch:
permissions:
+9 -1
View File
@@ -4,5 +4,13 @@
"trailingComma": "all",
"printWidth": 100,
"sortPackageJson": false,
"ignorePatterns": ["dist/**", "node_modules/**", "**/.skillgym-results/**"]
"ignorePatterns": [
"dist/**",
"node_modules/**",
"**/.skillgym-results/**",
"**/*.md",
"scripts/maestro-conformance/corpus/**",
"fallow-baselines/**",
"mutation-baselines/**"
]
}
+4 -3
View File
@@ -155,9 +155,10 @@ one question so `rg` → read-whole-file stays one cheap bounded read.
`tsconfig.lib.json` (it needs an explicit `rootDir: "./src"`) and `tsdown.config.ts` first, and run
`pnpm check:tooling` for any build-tooling edit.
- Prefer the aggregate `package.json` scripts; they encode the expected validation bundles better
than ad hoc command lists — including which paths get formatted: run `pnpm format`, never
`oxfmt <path>`, or you will reformat files the repo deliberately leaves alone (`scripts/`,
every `.md`).
than ad hoc command lists. `pnpm format` formats the whole repo (`oxfmt` with no path list), and
the only exclusion list is `.oxfmtrc.json` `ignorePatterns` — Markdown, the Maestro conformance
corpus, and generated baselines. Run `pnpm format`, never `oxfmt <path>`: 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.
+22 -1
View File
@@ -7,16 +7,22 @@ Thanks for your interest in contributing to agent-device.
Requirements:
- Node.js 22+
- pnpm
- pnpm, activated from `package.json` `packageManager` (`corepack enable pnpm`)
- Android SDK tools (`adb`) for Android support
- Xcode (`simctl`/`devicectl`) for iOS support
Setup:
```bash
corepack enable pnpm
pnpm install
```
`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.
Build all CLIs and Xcode projects:
```bash
@@ -162,6 +168,21 @@ Examples:
// 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.
## Issue Labels
Issue labels describe workflow state, not who will do the work. See
@@ -86,7 +86,10 @@ export function AudioScreen() {
function playNativeSample() {
stopNativeSample();
try {
const player = createAudioPlayer({ uri: createNativeBeepDataUri(), name: 'Agent Device beep' });
const player = createAudioPlayer({
uri: createNativeBeepDataUri(),
name: 'Agent Device beep',
});
nativePlayerRef.current = player;
player.play();
setPlaybackState('playing');
@@ -304,7 +307,8 @@ function base64Encode(bytes: Uint8Array): string {
const third = bytes[index + 2];
output += alphabet[first >> 2];
output += alphabet[((first & 0x03) << 4) | ((second ?? 0) >> 4)];
output += index + 1 < bytes.length ? alphabet[((second & 0x0f) << 2) | ((third ?? 0) >> 6)] : '=';
output +=
index + 1 < bytes.length ? alphabet[((second & 0x0f) << 2) | ((third ?? 0) >> 6)] : '=';
output += index + 2 < bytes.length ? alphabet[(third ?? 0) & 0x3f] : '=';
}
return output;
@@ -36,8 +36,8 @@ export function InertScreen() {
Tapping this text changes nothing on screen.
</Text>
<Text style={styles.footnote}>
Nothing here reacts to touch, loads, or animates, so a tap leaves the
hierarchy byte-identical and retryTapIfNoChange must re-tap.
Nothing here reacts to touch, loads, or animates, so a tap leaves the hierarchy
byte-identical and retryTapIfNoChange must re-tap.
</Text>
</View>
);
+3 -3
View File
@@ -14,7 +14,7 @@
"url": "https://github.com/callstack/agent-device/issues"
},
"type": "module",
"packageManager": "pnpm@11.1.2",
"packageManager": "pnpm@11.17.0",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"exports": {
@@ -114,8 +114,8 @@
"mutation:affected": "node --experimental-strip-types scripts/mutation/run.ts --affected",
"mutation:test": "node --experimental-strip-types --test scripts/mutation/*.test.ts",
"lint": "oxlint . --deny-warnings",
"format": "node ./node_modules/oxfmt/bin/oxfmt --write src test skills scripts/fuzz scripts/mutation scripts/lib scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
"format:check": "node ./node_modules/oxfmt/bin/oxfmt --check src test skills scripts/fuzz scripts/mutation scripts/lib scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
"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",
+1 -1
View File
@@ -1,5 +1,5 @@
packages:
- "website"
- 'website'
# Keep non-interactive checks from downloading tooling or reinstalling dependencies implicitly.
pmOnFail: warn
verifyDepsBeforeRun: warn
+3 -1
View File
@@ -47,7 +47,9 @@ for (const tag of REPLAY_COMPAT_RELEASED_TAGS) {
for (const entry of REPLAY_COMPAT_CORPUS) {
if (!REPLAY_COMPAT_RELEASED_TAGS.includes(entry.recordedBy)) {
failures.push(`${entry.id}: recordedBy ${entry.recordedBy} is not in REPLAY_COMPAT_RELEASED_TAGS`);
failures.push(
`${entry.id}: recordedBy ${entry.recordedBy} is not in REPLAY_COMPAT_RELEASED_TAGS`,
);
continue;
}
if (entry.provenance.kind !== 'mined') continue;
+2 -4
View File
@@ -37,7 +37,7 @@ type Payload = {
lvl: number;
cyc: number;
}[];
/**
/**
* `[fromIndex, toIndex, kind, flags]`; kind 0=value 1=type 2=dynamic,
* flags bit0=R5 back-edge, bit1=target also reachable at distance >= 2, bit2=R6 type inversion.
*/
@@ -71,9 +71,7 @@ function edgeKindCode(kind: GraphData['edges'][number]['kind']): number {
*/
function edgeFlags(edge: GraphData['edges'][number]): number {
return (
(edge.backEdge ? 1 : 0) |
(edge.transitivelyReachable ? 2 : 0) |
(edge.typeInversion ? 4 : 0)
(edge.backEdge ? 1 : 0) | (edge.transitivelyReachable ? 2 : 0) | (edge.typeInversion ? 4 : 0)
);
}
+9 -2
View File
@@ -213,7 +213,11 @@ test('no tracked TypeScript source contains a raw NUL byte', () => {
// output path, JSON shape or summary without anything failing. These run it as a subprocess, which
// is the only way to cover argument handling and the file it actually writes.
function runBuild(args: readonly string[]): { status: number | null; stdout: string; stderr: string } {
function runBuild(args: readonly string[]): {
status: number | null;
stdout: string;
stderr: string;
} {
const result = spawnSync(
process.execPath,
['--experimental-strip-types', 'scripts/depgraph/build.ts', ...args],
@@ -243,7 +247,10 @@ test('build.ts writes the default path and a summary consistent with the JSON',
// The printed summary must agree with the payload it was derived from.
const inversions = Object.values(payload.typeInversions).reduce((sum, n) => sum + n, 0);
assert.match(stdout, new RegExp(`${payload.generated.files} files, ${payload.generated.edges} edges`));
assert.match(
stdout,
new RegExp(`${payload.generated.files} files, ${payload.generated.edges} edges`),
);
assert.match(stdout, new RegExp(`type-only spine inversions \\(R6\\): ${inversions}`));
const reachable = payload.edges.filter(([, , , flags]) => (flags & 2) !== 0).length;
assert.match(stdout, new RegExp(`reachable at distance >= 2: ${reachable}`));
+1 -4
View File
@@ -66,7 +66,6 @@ export type GraphData = {
typeInversions: Record<string, number>;
};
function countLines(source: string): number {
let lines = 1;
for (let index = 0; index < source.length; index++) {
@@ -317,9 +316,7 @@ function aggregateZones(nodes: ReadonlyMap<string, GraphNode>): GraphData['zones
* but the count feeding a CI equality check must not be able to drift for a reason unrelated to
* layering.
*/
export function typeInversionsByPair(
edges: readonly ResolvedImportEdge[],
): Record<string, number> {
export function typeInversionsByPair(edges: readonly ResolvedImportEdge[]): Record<string, number> {
const seen = new Set<string>();
const byPair = new Map<string, number>();
for (const edge of edges) {
+8 -5
View File
@@ -83,7 +83,9 @@ function readSources(files: readonly string[]): Map<string, string> {
}
function fileExists(file: string): boolean {
return fs.existsSync(path.join(repoRoot, file)) && fs.statSync(path.join(repoRoot, file)).isFile();
return (
fs.existsSync(path.join(repoRoot, file)) && fs.statSync(path.join(repoRoot, file)).isFile()
);
}
function readSourceOrNull(file: string): string | null {
@@ -443,7 +445,10 @@ function checkSessionStateOwnership(sources: ReadonlyMap<string, string>): Viola
// `node_modules` is always present — which is why this needs a gate rather than a convention.
function checkZeroDepJobs(): Violation[] {
const workflows = readSources(
execFileSync('git', ['ls-files', '.github/workflows/*.yml'], { cwd: repoRoot, encoding: 'utf8' })
execFileSync('git', ['ls-files', '.github/workflows/*.yml'], {
cwd: repoRoot,
encoding: 'utf8',
})
.split('\n')
.filter(Boolean),
);
@@ -480,9 +485,7 @@ function checkZeroDepJobs(): Violation[] {
}
function sessionStateFieldCount(): number {
return (
Object.keys(SESSION_STATE_FIELD_OWNERS).length + STORE_OWNED_SESSION_STATE_FIELDS.size
);
return Object.keys(SESSION_STATE_FIELD_OWNERS).length + STORE_OWNED_SESSION_STATE_FIELDS.size;
}
/** R9 is growth-only, so a shrunk tree is reported rather than failed — see checkTypeCycleGrowth. */
+34 -19
View File
@@ -395,11 +395,18 @@ test("the repo's own zero-dep jobs resolve without node_modules", () => {
};
const exists = (file: string): boolean => read(file) !== null;
const jobs = zeroDepJobs(new Map([['.github/workflows/ci.yml', read('.github/workflows/ci.yml')!]]), exists);
const jobs = zeroDepJobs(
new Map([['.github/workflows/ci.yml', read('.github/workflows/ci.yml')!]]),
exists,
);
assert.ok(jobs.length > 0, 'expected ci.yml to still declare at least one zero-dep job');
for (const job of jobs) {
assert.ok(job.entries.length > 0, `${job.job} must name an entry script`);
assert.deepEqual(uninstallableImports(job, read, exists), [], `${job.job} must reach no package`);
assert.deepEqual(
uninstallableImports(job, read, exists),
[],
`${job.job} must reach no package`,
);
}
});
@@ -475,20 +482,24 @@ test('classification drift is reported in all three directions', () => {
test('largestTypeCycleSize counts type-only cycles and ignores dynamic ones', () => {
// Acyclic: every component is a single file, so the largest is 1 (not 0).
const acyclic = resolveImportEdges(
new Map(Object.entries({
'src/core/a.ts': "import type { B } from '../contracts/b.ts';",
'src/contracts/b.ts': 'export type B = 1;',
})),
new Map(
Object.entries({
'src/core/a.ts': "import type { B } from '../contracts/b.ts';",
'src/contracts/b.ts': 'export type B = 1;',
}),
),
);
assert.equal(largestTypeCycleSize(acyclic), 1);
// A three-file loop closed by type-only imports is exactly what R4 permits and R9 measures.
const typeCycle = resolveImportEdges(
new Map(Object.entries({
'src/core/a.ts': "import type { B } from './b.ts';",
'src/core/b.ts': "import type { C } from './c.ts';\nexport type B = 1;",
'src/core/c.ts': "import type { A } from './a.ts';\nexport type C = 1;",
})),
new Map(
Object.entries({
'src/core/a.ts': "import type { B } from './b.ts';",
'src/core/b.ts': "import type { C } from './c.ts';\nexport type B = 1;",
'src/core/c.ts': "import type { A } from './a.ts';\nexport type C = 1;",
}),
),
);
assert.equal(largestTypeCycleSize(typeCycle), 3);
@@ -496,20 +507,24 @@ test('largestTypeCycleSize counts type-only cycles and ignores dynamic ones', ()
// comprehension barrier, and R3 relies on dynamic imports existing. With no non-dynamic edge at
// all no file enters the walk, so the floor here is 0 rather than 1 — specified, not incidental.
const dynamicCycle = resolveImportEdges(
new Map(Object.entries({
'src/core/a.ts': "void import('./b.ts');",
'src/core/b.ts': "void import('./a.ts');",
})),
new Map(
Object.entries({
'src/core/a.ts': "void import('./b.ts');",
'src/core/b.ts': "void import('./a.ts');",
}),
),
);
assert.equal(largestTypeCycleSize(dynamicCycle), 0);
// A value cycle counts too — R4 rejects it separately, so R9 must not be the thing that
// notices, but it must not under-report either.
const valueCycle = resolveImportEdges(
new Map(Object.entries({
'src/core/a.ts': "export { b } from './b.ts';",
'src/core/b.ts': "export { a } from './a.ts';",
})),
new Map(
Object.entries({
'src/core/a.ts': "export { b } from './b.ts';",
'src/core/b.ts': "export { a } from './a.ts';",
}),
),
);
assert.equal(largestTypeCycleSize(valueCycle), 2);
});
+1 -3
View File
@@ -134,9 +134,7 @@ export type FieldClassificationDrift = {
* Where the two ownership tables disagree with `SessionState` itself. Empty means every declared
* field is classified exactly once and neither table names a field that no longer exists.
*/
export function fieldClassificationDrift(
fields: readonly string[],
): FieldClassificationDrift[] {
export function fieldClassificationDrift(fields: readonly string[]): FieldClassificationDrift[] {
const declared = new Set(fields);
const owned = new Set(Object.keys(SESSION_STATE_FIELD_OWNERS));
const drift: FieldClassificationDrift[] = [];
+5 -2
View File
@@ -54,7 +54,9 @@ function runBlockEntries(run: string): string[] {
function stepsOf(job: unknown): Record<string, unknown>[] {
if (job === null || typeof job !== 'object') return [];
const steps = (job as Record<string, unknown>)['steps'];
return Array.isArray(steps) ? steps.filter((step) => step !== null && typeof step === 'object') : [];
return Array.isArray(steps)
? steps.filter((step) => step !== null && typeof step === 'object')
: [];
}
function skipsInstall(step: Record<string, unknown>): boolean {
@@ -96,7 +98,8 @@ export function zeroDepJobs(
}
}
return jobs.sort(
(left, right) => left.workflow.localeCompare(right.workflow) || left.job.localeCompare(right.job),
(left, right) =>
left.workflow.localeCompare(right.workflow) || left.job.localeCompare(right.job),
);
}
+32 -15
View File
@@ -17,23 +17,37 @@ const UPSTREAM_REPO_DIR = 'maestro-test/src/test/resources';
const NOTES = {
'bug-classes/percent-decimal-swipe': 'Bug class 1: decimal percentage rejection at parse.',
'bug-classes/target-swipe-missing-direction': 'Bug class 2: target swipe requires an explicit direction.',
'bug-classes/retry-over-cap': 'Bug class 3: retry maxRetries parses verbatim; the clamp is a layer-2 vector.',
'bug-classes/settle-after-tap': 'Bug class 4: plain tap; settle ordering is a layer-3 differential.',
'bug-classes/target-swipe-missing-direction':
'Bug class 2: target swipe requires an explicit direction.',
'bug-classes/retry-over-cap':
'Bug class 3: retry maxRetries parses verbatim; the clamp is a layer-2 vector.',
'bug-classes/settle-after-tap':
'Bug class 4: plain tap; settle ordering is a layer-3 differential.',
'authored/runscript': 'Coverage: runScript file command (no self-contained upstream flow).',
'authored/runflow-main': 'Coverage: runFlow file include + provenance.',
'authored/runflow-child': 'Include target for runflow-main (not parsed as a top-level flow).',
'authored/doubletap': 'Coverage: doubleTapOn (upstream 101 uses the unsupported retryTapIfNoChange option).',
'authored/scroll-until-visible': 'Coverage: scrollUntilVisible (upstream 079 uses unsupported speed/visibilityPercentage).',
'authored/extended-wait': 'Coverage: extendedWaitUntil (upstream 042 interpolates ${TIMEOUT} from a flow env block).',
'authored/repeat': 'Coverage: repeat.times (upstream 053 also uses the unsupported evalScript command).',
'authored/presskey': 'Coverage: pressKey supported keys (upstream 034 exercises many unsupported keycodes).',
'authored/numeric-variable-tap': 'Coverage: tapOn repeat/delay/index numeric option fields accept ${VAR} tokens (upstream rejects repeat/delay as integer-typed; agent-device is lenient).',
'authored/numeric-variable-doubletap': 'Coverage: doubleTapOn delay accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-swipe': 'Coverage: swipe duration accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-erase': 'Coverage: eraseText charactersToErase accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-tap-index': 'Coverage: tapOn index accepts a ${VAR} token and projects identically through the canonical model.',
'authored/numeric-variable-wait': 'Coverage: waitForAnimationToEnd timeout accepts a ${VAR} token and projects identically through the canonical model.',
'authored/doubletap':
'Coverage: doubleTapOn (upstream 101 uses the unsupported retryTapIfNoChange option).',
'authored/scroll-until-visible':
'Coverage: scrollUntilVisible (upstream 079 uses unsupported speed/visibilityPercentage).',
'authored/extended-wait':
'Coverage: extendedWaitUntil (upstream 042 interpolates ${TIMEOUT} from a flow env block).',
'authored/repeat':
'Coverage: repeat.times (upstream 053 also uses the unsupported evalScript command).',
'authored/presskey':
'Coverage: pressKey supported keys (upstream 034 exercises many unsupported keycodes).',
'authored/numeric-variable-tap':
'Coverage: tapOn repeat/delay/index numeric option fields accept ${VAR} tokens (upstream rejects repeat/delay as integer-typed; agent-device is lenient).',
'authored/numeric-variable-doubletap':
'Coverage: doubleTapOn delay accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-swipe':
'Coverage: swipe duration accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-erase':
'Coverage: eraseText charactersToErase accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-tap-index':
'Coverage: tapOn index accepts a ${VAR} token and projects identically through the canonical model.',
'authored/numeric-variable-wait':
'Coverage: waitForAnimationToEnd timeout accepts a ${VAR} token and projects identically through the canonical model.',
'invalid/bad-swipe-direction': 'Lenient-guard: unknown SwipeDirection enum value.',
'invalid/unknown-command': 'Lenient-guard: unknown command name (tapOn typo).',
'invalid/malformed-selector': 'Lenient-guard: selector given as a sequence.',
@@ -46,7 +60,10 @@ const sha256 = (file) => createHash('sha256').update(fs.readFileSync(file)).dige
const yamlIn = (dir) =>
fs.existsSync(path.join(CORPUS_DIR, dir))
? fs.readdirSync(path.join(CORPUS_DIR, dir)).filter((n) => n.endsWith('.yaml')).sort()
? fs
.readdirSync(path.join(CORPUS_DIR, dir))
.filter((n) => n.endsWith('.yaml'))
.sort()
: [];
function entriesFor(dir, kind) {
@@ -41,7 +41,10 @@ test('a tap that burns the full settle budget violates the invariant (bug class
});
test('the invariant reports the slowest matching step, not the first', () => {
const result = evaluateInvariant([stop('tapOn', 300, 1), stop('tapOn', 2117, 2)], SETTLE_INVARIANT);
const result = evaluateInvariant(
[stop('tapOn', 300, 1), stop('tapOn', 2117, 2)],
SETTLE_INVARIANT,
);
assert.equal(result.status, 'violated');
assert.match(result.detail, /2117ms/);
});
@@ -22,7 +22,10 @@ test('every differential scenario references an existing corpus flow with a uniq
test('the settle-loop bug class (4) is covered by a differential scenario', () => {
const settle = DIFFERENTIAL_SCENARIOS.find((scenario) => scenario.bugClass === 4);
assert.ok(settle, 'bug class 4 (settle ordering) has no reflectable constant; it must be a differential scenario');
assert.ok(
settle,
'bug class 4 (settle ordering) has no reflectable constant; it must be a differential scenario',
);
assert.equal(settle?.id, 'settle-after-tap');
});
@@ -75,14 +78,22 @@ test('every knownDivergence carries a tracking issue', () => {
// swallow upstream regressing or a different invariant breaking — hiding the
// next bug behind the last one.
describe('knownDivergence signature matching', () => {
const sig: DivergenceSignature = { maestro: 'pass', agentDevice: 'fail', invariants: ['no-data'] };
const sig: DivergenceSignature = {
maestro: 'pass',
agentDevice: 'fail',
invariants: ['no-data'],
};
const engine = (outcome: 'pass' | 'fail') => ({
engine: 'maestro' as const,
outcome,
exitCode: outcome === 'pass' ? 0 : 1,
});
const inv = (status: 'held' | 'violated' | 'no-data') =>
({ invariant: { kind: 'stepDurationBelow', command: 'tapOn', maxMs: 1, because: 'x' }, status, detail: '' }) as never;
({
invariant: { kind: 'stepDurationBelow', command: 'tapOn', maxMs: 1, because: 'x' },
status,
detail: '',
}) as never;
test('matches the declared failure exactly', () => {
assert.equal(matchesSignature(sig, engine('pass'), engine('fail'), [inv('no-data')]), true);
@@ -113,7 +124,10 @@ describe('knownDivergence signature matching', () => {
for (const scenario of DIFFERENTIAL_SCENARIOS) {
const declared = scenario.knownDivergence;
if (!declared) continue;
assert.ok(declared.expected, `${scenario.id}: knownDivergence must declare an expected signature`);
assert.ok(
declared.expected,
`${scenario.id}: knownDivergence must declare an expected signature`,
);
assert.ok(
['pass', 'fail'].includes(declared.expected.maestro) &&
['pass', 'fail'].includes(declared.expected.agentDevice),
@@ -68,17 +68,18 @@ export function validateScenarios(): void {
if (ids.has(scenario.id)) throw new Error(`Duplicate scenario id: ${scenario.id}`);
ids.add(scenario.id);
const flowPath = path.join(CONFORMANCE_DIR, scenario.flow);
if (!fs.existsSync(flowPath)) throw new Error(`Scenario ${scenario.id} flow not found: ${scenario.flow}`);
if (!fs.existsSync(flowPath))
throw new Error(`Scenario ${scenario.id} flow not found: ${scenario.flow}`);
}
}
type EngineResult = { engine: 'maestro' | 'agent-device'; outcome: 'pass' | 'fail'; exitCode: number };
type EngineResult = {
engine: 'maestro' | 'agent-device';
outcome: 'pass' | 'fail';
exitCode: number;
};
function runEngine(
engine: EngineResult['engine'],
command: string,
args: string[],
): EngineResult {
function runEngine(engine: EngineResult['engine'], command: string, args: string[]): EngineResult {
const [bin = '', ...rest] = command.split(' ').filter(Boolean);
const result = spawnSync(bin, [...rest, ...args], { stdio: 'inherit', cwd: process.cwd() });
const exitCode = result.status ?? 1;
@@ -255,7 +256,9 @@ function main(argv: readonly string[]): void {
// Keep declared gaps visible: a green run must still say what it is not proving.
const known = reports.filter((report) => report.status === 'known-divergence');
if (known.length > 0) {
console.log(`\n${known.length} declared divergence(s), not enforced: ${known.map((r) => r.id).join(', ')}`);
console.log(
`\n${known.length} declared divergence(s), not enforced: ${known.map((r) => r.id).join(', ')}`,
);
}
const failed = reports.filter((report) => report.failed);
@@ -32,7 +32,8 @@ export const FLOW_DIVERGENCES: Record<string, FlowDivergence> = {
},
'upstream/062_copy_paste_text': {
classification: 'we-reject',
reason: 'Clipboard commands are unsupported; pasteText is de-advertised (takes clipboard, not inline text).',
reason:
'Clipboard commands are unsupported; pasteText is de-advertised (takes clipboard, not inline text).',
unsupported: ['copyTextFrom', 'pasteText'],
},
'upstream/067_assertTrue_pass': {
@@ -58,7 +59,8 @@ export const FLOW_DIVERGENCES: Record<string, FlowDivergence> = {
},
'upstream/053_repeat_times': {
classification: 'we-reject',
reason: 'repeat is supported, but the flow also uses evalScript and a ${output.list.length} times expression.',
reason:
'repeat is supported, but the flow also uses evalScript and a ${output.list.length} times expression.',
unsupported: ['evalScript'],
},
// --- Deliberately stricter than upstream ---
@@ -83,23 +85,27 @@ export const FLOW_DIVERGENCES: Record<string, FlowDivergence> = {
},
'upstream/076_optional_assertion': {
classification: 'we-reject',
reason: 'assertTrue is outside the supported subset; optional is now supported on scrollUntilVisible and extendedWaitUntil.',
reason:
'assertTrue is outside the supported subset; optional is now supported on scrollUntilVisible and extendedWaitUntil.',
unsupported: ['assertTrue'],
tracking: 'https://github.com/callstack/agent-device/issues/1295',
},
'upstream/079_scroll_until_visible': {
classification: 'we-reject',
reason: 'scrollUntilVisible is supported; the flow sets the unsupported speed and visibilityPercentage options.',
reason:
'scrollUntilVisible is supported; the flow sets the unsupported speed and visibilityPercentage options.',
unsupported: ['scrollUntilVisible.speed', 'scrollUntilVisible.visibilityPercentage'],
},
'upstream/101_doubleTapOn': {
classification: 'we-reject',
reason: 'doubleTapOn is supported; the flow sets the unsupported retryTapIfNoChange option on it.',
reason:
'doubleTapOn is supported; the flow sets the unsupported retryTapIfNoChange option on it.',
unsupported: ['doubleTapOn.retryTapIfNoChange'],
},
'upstream/119_retry_commands': {
classification: 'we-reject',
reason: 'retry is supported; the flow uses the unsupported per-command waitToSettleTimeoutMs option on a nested tapOn.',
reason:
'retry is supported; the flow uses the unsupported per-command waitToSettleTimeoutMs option on a nested tapOn.',
unsupported: ['tapOn.waitToSettleTimeoutMs'],
},
// --- agent-device supports ${VAR} in integer-typed numeric option fields that the pinned upstream parser rejects ---
+58 -12
View File
@@ -33,7 +33,12 @@ export type CanonicalTarget = {
export type CanonicalGesture =
| { mode: 'direction'; direction: string; duration?: number | string }
| { mode: 'coordinates'; start?: CanonicalPoint; end?: CanonicalPoint; duration?: number | string }
| {
mode: 'coordinates';
start?: CanonicalPoint;
end?: CanonicalPoint;
duration?: number | string;
}
| { mode: 'element'; from: CanonicalSelector; direction?: string; duration?: number | string };
export type CanonicalCommand =
@@ -42,14 +47,31 @@ export type CanonicalCommand =
// COUNT is the canonical field on both sides rather than a `double` variant on
// one — that keeps our distinct tapOn/doubleTapOn kinds comparable to upstream
// and preserves conformance signal for tapOn.repeat/delay.
| { kind: 'tap'; longPress: boolean; repeat: number | string; delay?: number | string; target: CanonicalTarget }
| { kind: 'assert'; mode: 'visible' | 'notVisible'; timed: boolean; timeout?: number | string; selector?: CanonicalSelector }
| {
kind: 'tap';
longPress: boolean;
repeat: number | string;
delay?: number | string;
target: CanonicalTarget;
}
| {
kind: 'assert';
mode: 'visible' | 'notVisible';
timed: boolean;
timeout?: number | string;
selector?: CanonicalSelector;
}
| { kind: 'swipe'; gesture: CanonicalGesture }
| { kind: 'inputText'; text?: string }
| { kind: 'eraseText'; count?: number | string }
| { kind: 'openLink'; link?: string }
| { kind: 'scroll' }
| { kind: 'scrollUntilVisible'; direction?: string; selector?: CanonicalSelector; timeout?: number | string }
| {
kind: 'scrollUntilVisible';
direction?: string;
selector?: CanonicalSelector;
timeout?: number | string;
}
| { kind: 'pressKey'; key: string }
| { kind: 'back' }
| { kind: 'hideKeyboard' }
@@ -141,7 +163,10 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand
case 'InputTextCommand':
return dropUndefined({ kind: 'inputText', text: str(f.text) });
case 'EraseTextCommand':
return dropUndefined({ kind: 'eraseText', count: numLike(f.charactersToErase) ?? str(f.charactersToErase) });
return dropUndefined({
kind: 'eraseText',
count: numLike(f.charactersToErase) ?? str(f.charactersToErase),
});
case 'OpenLinkCommand':
return dropUndefined({ kind: 'openLink', link: str(f.link) });
case 'PressKeyCommand':
@@ -153,13 +178,19 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand
case 'TakeScreenshotCommand':
return { kind: 'takeScreenshot' };
case 'WaitForAnimationToEndCommand':
return dropUndefined({ kind: 'waitForAnimationToEnd', timeout: numLike(f.timeout) ?? str(f.timeout) });
return dropUndefined({
kind: 'waitForAnimationToEnd',
timeout: numLike(f.timeout) ?? str(f.timeout),
});
case 'StopAppCommand':
return { kind: 'stopApp' };
case 'RepeatCommand':
return { kind: 'repeat', times: numLike(f.times) ?? str(f.times) ?? '' };
case 'RetryCommand':
return dropUndefined({ kind: 'retry', maxRetries: numLike(f.maxRetries) ?? str(f.maxRetries) });
return dropUndefined({
kind: 'retry',
maxRetries: numLike(f.maxRetries) ?? str(f.maxRetries),
});
case 'RunFlowCommand':
return { kind: 'runFlow', source: f.sourceDescription != null ? 'file' : 'commands' };
case 'RunScriptCommand':
@@ -335,7 +366,9 @@ function canonicalizeAgentCommand(
return canonicalTap({
longPress: false,
repeat,
delay: repeatIsNumber ? (numLike(command.delay) ?? AGENT_REPEAT_DELAY_MS) : numLike(command.delay),
delay: repeatIsNumber
? (numLike(command.delay) ?? AGENT_REPEAT_DELAY_MS)
: numLike(command.delay),
target: agentTarget(command.target, numLike(command.index), command.childOf),
});
}
@@ -403,7 +436,10 @@ function canonicalizeAgentCommand(
case 'repeat':
return { kind: 'repeat', times: numLike(command.times) ?? str(command.times) };
case 'retry':
return dropUndefined({ kind: 'retry', maxRetries: numLike(command.maxRetries) ?? str(command.maxRetries) });
return dropUndefined({
kind: 'retry',
maxRetries: numLike(command.maxRetries) ?? str(command.maxRetries),
});
case 'runFlow':
return { kind: 'runFlow', source: command.include.kind === 'file' ? 'file' : 'commands' };
case 'runScript':
@@ -429,7 +465,9 @@ function agentTarget(
}),
};
}
return { point: { x: target.x, y: target.y, unit: target.space === 'percent' ? 'percent' : 'px' } };
return {
point: { x: target.x, y: target.y, unit: target.space === 'percent' ? 'percent' : 'px' },
};
}
function agentSelector(
@@ -468,6 +506,14 @@ function agentGesture(gesture: MaestroSwipeGesture): CanonicalGesture {
}
}
function agentPoint(coordinate: { space: 'absolute' | 'percent'; x: number; y: number }): CanonicalPoint {
return { x: coordinate.x, y: coordinate.y, unit: coordinate.space === 'percent' ? 'percent' : 'px' };
function agentPoint(coordinate: {
space: 'absolute' | 'percent';
x: number;
y: number;
}): CanonicalPoint {
return {
x: coordinate.x,
y: coordinate.y,
unit: coordinate.space === 'percent' ? 'percent' : 'px',
};
}
+18 -9
View File
@@ -37,12 +37,16 @@ function gradle(args, options = {}) {
const [cmd, baseArgs] = override
? [override, []]
: [process.platform === 'win32' ? 'gradlew.bat' : './gradlew', []];
return execFileSync(cmd, [...baseArgs, '-p', HARNESS_DIR, '--no-daemon', '--console=plain', '-q', ...args], {
cwd: HARNESS_DIR,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
...options,
});
return execFileSync(
cmd,
[...baseArgs, '-p', HARNESS_DIR, '--no-daemon', '--console=plain', '-q', ...args],
{
cwd: HARNESS_DIR,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
...options,
},
);
}
function sha256(filePath) {
@@ -58,7 +62,8 @@ function verifyArtifacts(pin) {
}
for (const artifact of pin.artifacts) {
const jarPath = resolved.get(artifact.coordinate);
if (!jarPath) throw new Error(`Pinned artifact ${artifact.coordinate} was not resolved by Gradle.`);
if (!jarPath)
throw new Error(`Pinned artifact ${artifact.coordinate} was not resolved by Gradle.`);
const actual = sha256(jarPath);
if (actual !== artifact.sha256) {
throw new Error(
@@ -92,7 +97,9 @@ function writeFixture(name, pin, content) {
function main() {
const pin = readPin();
console.log(`Regenerating Maestro ${pin.version} conformance fixtures (${pin.commit.slice(0, 12)}).`);
console.log(
`Regenerating Maestro ${pin.version} conformance fixtures (${pin.commit.slice(0, 12)}).`,
);
// Refresh corpus provenance first so a newly added flow is picked up.
const manifest = writeManifest(pin);
@@ -110,7 +117,9 @@ function main() {
} finally {
fs.rmSync(outDir, { recursive: true, force: true });
}
console.log('Done. Review the diff, then run `node --experimental-strip-types scripts/maestro-conformance/verify.test.ts`.');
console.log(
'Done. Review the diff, then run `node --experimental-strip-types scripts/maestro-conformance/verify.test.ts`.',
);
}
main();
+31 -10
View File
@@ -63,11 +63,16 @@ test('the seal rejects an edited capture (proof the check has teeth)', () => {
test('fixtures pin the reviewed upstream Maestro artifacts', () => {
for (const fixture of [loadLayer1(), loadLayer2()] as Array<{ upstream?: unknown }>) {
const upstream = (fixture as { upstream: { version: string; commit: string; artifacts: unknown } })
.upstream;
const upstream = (
fixture as { upstream: { version: string; commit: string; artifacts: unknown } }
).upstream;
assert.equal(upstream.version, PIN.version, 'fixture must pin the reviewed version');
assert.equal(upstream.commit, PIN.commit, 'fixture must pin the reviewed commit');
assert.deepEqual(upstream.artifacts, PIN.artifacts, 'fixture jar SHAs must match pinned-upstream.json');
assert.deepEqual(
upstream.artifacts,
PIN.artifacts,
'fixture jar SHAs must match pinned-upstream.json',
);
}
});
@@ -104,21 +109,33 @@ test('layer 1: every divergence is declared (no silent drift)', () => {
for (const flow of flows) {
const declared = FLOW_DIVERGENCES[flow.id];
if (flow.classification === 'identical' || flow.classification === 'both-reject') {
if (declared) problems.push(`${flow.id}: declared divergence but classified ${flow.classification}`);
if (declared)
problems.push(`${flow.id}: declared divergence but classified ${flow.classification}`);
continue;
}
if (!declared) {
problems.push(`${flow.id}: undeclared ${flow.classification}${flow.detail ? `\n ${flow.detail}` : ''}`);
problems.push(
`${flow.id}: undeclared ${flow.classification}${flow.detail ? `\n ${flow.detail}` : ''}`,
);
continue;
}
if (declared.classification !== flow.classification) {
problems.push(`${flow.id}: declared ${declared.classification} but classified ${flow.classification}`);
problems.push(
`${flow.id}: declared ${declared.classification} but classified ${flow.classification}`,
);
}
if (flow.classification === 'we-reject' && !(declared.unsupported && declared.unsupported.length > 0)) {
if (
flow.classification === 'we-reject' &&
!(declared.unsupported && declared.unsupported.length > 0)
) {
problems.push(`${flow.id}: we-reject entries must list the unsupported command(s)/option(s)`);
}
}
assert.deepEqual(problems, [], `Undeclared or mismatched divergences:\n ${problems.join('\n ')}`);
assert.deepEqual(
problems,
[],
`Undeclared or mismatched divergences:\n ${problems.join('\n ')}`,
);
});
test('layer 1: no stale divergence declarations', () => {
@@ -138,7 +155,9 @@ test('layer 2: generated semantic vectors match live engine constants', () => {
// Every reference-only vector must be an on-the-record deviation.
for (const result of results) {
if (result.status === 'reference-only') {
const documented = DOCUMENTED_DEVIATIONS.some((d) => d.description.includes(result.id) || LAYER2_REFERENCE_ONLY.has(result.id));
const documented = DOCUMENTED_DEVIATIONS.some(
(d) => d.description.includes(result.id) || LAYER2_REFERENCE_ONLY.has(result.id),
);
assert.ok(documented, `reference-only vector ${result.id} must be a documented deviation`);
}
}
@@ -181,7 +200,9 @@ test('bug class 4: settle default parses identically; ordering is a layer-3 diff
// sleep-after-capture ordering is verified by the layer-3 differential scenario.
const layer2 = loadLayer2();
assert.ok(
!layer2.constants.some((constant) => /settle/i.test(constant.id) && constant.id !== 'iosScreenSettleTimeoutMs'),
!layer2.constants.some(
(constant) => /settle/i.test(constant.id) && constant.id !== 'iosScreenSettleTimeoutMs',
),
'no upstream settle-loop constant exists to cross-check; keep this as layer 3',
);
});
+17 -4
View File
@@ -112,7 +112,10 @@ function agentParseProgram(file: string): MaestroProgram | null {
}
}
function agentParse(file: string): { status: 'parsed' | 'rejected'; commands?: CanonicalCommand[] } {
function agentParse(file: string): {
status: 'parsed' | 'rejected';
commands?: CanonicalCommand[];
} {
const program = agentParseProgram(file);
if (!program) return { status: 'rejected' };
return { status: 'parsed', commands: canonicalizeAgentCommands(program) };
@@ -121,7 +124,12 @@ function agentParse(file: string): { status: 'parsed' | 'rejected'; commands?: C
function classifyFlow(fixtureFlow: Layer1Fixture['flows'][number]): FlowResult {
const agent = agentParse(fixtureFlow.file);
const upstreamStatus = fixtureFlow.status;
const base = { id: fixtureFlow.id, file: fixtureFlow.file, upstreamStatus, agentStatus: agent.status };
const base = {
id: fixtureFlow.id,
file: fixtureFlow.file,
upstreamStatus,
agentStatus: agent.status,
};
if (upstreamStatus === 'rejected') {
return {
@@ -208,7 +216,10 @@ export function agentKindsByCorpus(): Set<string> {
return kinds;
}
function collectKinds(commands: Array<{ kind: string; commands?: unknown }>, into: Set<string>): void {
function collectKinds(
commands: Array<{ kind: string; commands?: unknown }>,
into: Set<string>,
): void {
for (const command of commands) {
into.add(command.kind);
const nested = (command as { commands?: Array<{ kind: string }> }).commands;
@@ -246,7 +257,9 @@ function report(): void {
}
console.log('\n### layer 2');
for (const result of checkLayer2()) {
console.log(` ${result.status.padEnd(14)} ${result.id} upstream=${result.upstream} agent=${result.agent ?? '-'}`);
console.log(
` ${result.status.padEnd(14)} ${result.id} upstream=${result.upstream} agent=${result.agent ?? '-'}`,
);
}
console.log('\n### coverage gaps');
for (const result of checkCoverage()) {
+5 -3
View File
@@ -56,9 +56,11 @@ test('no mutation shard is allowed to exceed the 30-minute budget', () => {
});
test('every kernel path a PR can touch selects the affected mutation job', () => {
const paths = [...workflow('mutation-affected.yml').matchAll(/^ {6}- "(?<glob>[^"]+)"$/gm)].map(
(match) => match.groups!.glob,
);
// Quote style is the formatter's business (oxfmt formats the workflow tree), so
// accept either spelling of the same scalar rather than pinning this gate to it.
const paths = [
...workflow('mutation-affected.yml').matchAll(/^ {6}- (?<q>['"])(?<glob>[^'"]+)\k<q>$/gm),
].map((match) => match.groups!.glob);
for (const module of KERNEL_MODULES) {
for (const owned of module.owns) {
const selected = paths.some(
+9 -3
View File
@@ -33,12 +33,16 @@ function toMarkdown(run: RunResult): string {
lines.push(`- **Finished**: ${run.finishedAt}`);
lines.push('');
lines.push('All times in milliseconds. `wall-clock` includes process spawn + socket overhead;');
lines.push('`daemon` is the batch step round-trip (spawn overhead ≈ wall-median daemon-median).');
lines.push(
'`daemon` is the batch step round-trip (spawn overhead ≈ wall-median daemon-median).',
);
lines.push('`elements` = node count in the snapshot payload (tree-size proxy).');
lines.push('An untimed warmup interaction runs after each open/relaunch, so measured commands');
lines.push('do not pay the one-time iOS-runner startup or post-relaunch first-AX-query cost.');
lines.push('');
lines.push('| command | cli | mode | n | wall min | wall median | wall p95 | wall max | daemon median | elements | notes |');
lines.push(
'| command | cli | mode | n | wall min | wall median | wall p95 | wall max | daemon median | elements | notes |',
);
lines.push('|---|---|---|---|---|---|---|---|---|---|---|');
for (const m of run.measurements) lines.push(measurementRow(m));
lines.push('');
@@ -49,7 +53,9 @@ function toMarkdown(run: RunResult): string {
lines.push('');
for (const m of failed) {
const sample = m.samples.find((s) => !s.ok);
lines.push(`- **${m.label}** — ${m.notes.join('; ')}${sample?.errorMessage ? `${sample.errorMessage}` : ''}`);
lines.push(
`- **${m.label}** — ${m.notes.join('; ')}${sample?.errorMessage ? `${sample.errorMessage}` : ''}`,
);
}
lines.push('');
}
+6 -1
View File
@@ -1,7 +1,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseConfig, REPO_ROOT, usesSourceCli } from './config.ts';
import { runScenario, setupIsolation, teardownIsolation, type IsolationContext } from './harness.ts';
import {
runScenario,
setupIsolation,
teardownIsolation,
type IsolationContext,
} from './harness.ts';
import { writeReports } from './report.ts';
import type { RunResult } from './types.ts';
+4 -1
View File
@@ -19,7 +19,10 @@ if (typeof expectedName !== 'string' || expectedName.length === 0) {
if (typeof expectedVersion !== 'string' || expectedVersion.length === 0) {
fail('package.json must define version.');
}
if (typeof server.description === 'string' && server.description.length > registryDescriptionMaxLength) {
if (
typeof server.description === 'string' &&
server.description.length > registryDescriptionMaxLength
) {
fail(`server.json description must be ${registryDescriptionMaxLength} characters or fewer.`);
}
+2 -5
View File
@@ -53,10 +53,7 @@ export function classifySlowTest(params: {
}
/** Render the gate outcome; returns true when the run must fail. */
export function reportSlowTests(
offenders: Offender[],
write: (message: string) => void,
): boolean {
export function reportSlowTests(offenders: Offender[], write: (message: string) => void): boolean {
if (offenders.length === 0) return false;
const sorted = [...offenders].sort((a, b) => b.durationMs - a.durationMs);
const line = (o: Offender): string =>
@@ -71,7 +68,7 @@ export function reportSlowTests(
}
if (failing.length === 0) return false;
write(
`\nSlow-test gate: ${failing.length} test(s) exceeded ${ENFORCE_FACTOR}x the wall-clock budget.\n` +
`\nSlow-test gate: ${failing.length} test(s) exceeded ${ENFORCE_FACTOR}x the wall-clock budget.\n` +
`Tests must not wait real time — inject the timeout/poll budget or assert the budget is\n` +
`wired instead of waiting it out (docs/agents/testing.md). If the runtime cost is genuinely\n` +
`irreducible, document the reason in the owning test or move it out of the unit lane.\n` +
+1 -1
View File
@@ -1 +1 @@
runtime: "typescript"
runtime: 'typescript'
+3 -2
View File
@@ -23,8 +23,9 @@ export default withCallstackPreset(
theme: {
content: {
outlineCTAHeadline: 'Curious about developing mobile apps with AI agents?',
outlineCTADescription: 'We can help you take your agentic workflows to the next level and ship faster.',
outlineCTAButtonText: "Book a call",
outlineCTADescription:
'We can help you take your agentic workflows to the next level and ship faster.',
outlineCTAButtonText: 'Book a call',
},
},
},