diff --git a/.github/pr_run_ios_contacts_trails.sh b/.github/pr_run_ios_contacts_trails.sh index 37c187ad..63f0cf6f 100755 --- a/.github/pr_run_ios_contacts_trails.sh +++ b/.github/pr_run_ios_contacts_trails.sh @@ -23,6 +23,13 @@ echo "Installing TypeScript SDK devDependencies (esbuild)..." (cd sdks/typescript && bun install --frozen-lockfile) \ || { echo "ERROR: bun install failed in sdks/typescript"; TEST_FAILED=true; } +# Export config dir before the first Gradle invocation so the Gradle daemon +# starts with it in its environment. JavaExec subprocesses inherit the daemon's +# environment, not the caller's shell, so the export must precede the daemon's +# first start (triggered by :trailblaze-desktop:jar below). +export TRAILBLAZE_CONFIG_DIR="$(pwd)/examples/ios-contacts/trails/config" +echo "TRAILBLAZE_CONFIG_DIR=$TRAILBLAZE_CONFIG_DIR" + # Pre-compile the Trailblaze desktop module so the daemon starts within the # 110s port-ready window below. if [ "$TEST_FAILED" != "true" ]; then @@ -75,6 +82,10 @@ if [ "$TEST_FAILED" != "true" ]; then ./trailblaze trail -d ios \ trails/ios-contacts/test-search-by-first-name/ios-iphone.trail.yaml \ || TEST_FAILED=true + + ./trailblaze trail -d ios \ + trails/ios-contacts/test-search-no-results/ios-iphone.trail.yaml \ + || TEST_FAILED=true else echo "Skipping test execution because setup failed" fi diff --git a/.github/pr_run_wikipedia_trails.sh b/.github/pr_run_wikipedia_trails.sh index 5a772063..4a9a6c5d 100755 --- a/.github/pr_run_wikipedia_trails.sh +++ b/.github/pr_run_wikipedia_trails.sh @@ -22,6 +22,13 @@ echo "Installing TypeScript SDK devDependencies (esbuild)..." (cd sdks/typescript && bun install --frozen-lockfile) \ || { echo "ERROR: bun install failed in sdks/typescript"; TEST_FAILED=true; } +# Export config dir before the first Gradle invocation so the Gradle daemon +# starts with it in its environment. JavaExec subprocesses inherit the daemon's +# environment, not the caller's shell, so the export must precede the daemon's +# first start (triggered by :trailblaze-desktop:jar below). +export TRAILBLAZE_CONFIG_DIR="$(pwd)/examples/wikipedia/trails/config" +echo "TRAILBLAZE_CONFIG_DIR=$TRAILBLAZE_CONFIG_DIR" + # Pre-compile the Trailblaze desktop module so the daemon starts within the # 110s port-ready window below. Without this, `./trailblaze app …` does a cold # Kotlin compile (4+ min on CI) inside the backgrounded process, the wait loop @@ -33,6 +40,17 @@ if [ "$TEST_FAILED" != "true" ]; then ./gradlew :trailblaze-desktop:jar || { echo "ERROR: Failed to build Trailblaze desktop"; TEST_FAILED=true; } fi +# Pre-install Playwright Chromium so the browser is cached before the trail +# runs. The JVM Playwright library (version 1.50.0) and the npm package use the +# same ~/.cache/ms-playwright browser cache, so pre-installing via bunx avoids +# the download happening inside DaemonClient's hardcoded 1800s poll window. +if [ "$TEST_FAILED" != "true" ]; then + echo "Pre-installing Playwright Chromium..." + bunx playwright@1.59.0 install chromium \ + && echo "✓ Playwright Chromium pre-installed" \ + || echo "WARNING: Playwright pre-install failed — download will happen during trail execution" +fi + if [ "$TEST_FAILED" != "true" ]; then # Start the Trailblaze daemon in the background. `app --foreground --headless` # blocks the process, so we background it with `&` and poll /ping until ready. diff --git a/.github/workflows/ios-contacts-trails.yml b/.github/workflows/ios-contacts-trails.yml index bafadab6..24e1ffe4 100644 --- a/.github/workflows/ios-contacts-trails.yml +++ b/.github/workflows/ios-contacts-trails.yml @@ -27,7 +27,7 @@ jobs: # Xcode 16 which includes iOS 18 simulator runtimes. ios-contacts-trails: runs-on: macos-15 - timeout-minutes: 45 + timeout-minutes: 60 env: # Recordings-only — LLM is never invoked. A non-empty sentinel value is # required to satisfy the provider/client wiring. diff --git a/.github/workflows/wikipedia-trails.yml b/.github/workflows/wikipedia-trails.yml index 6fc79632..4e4c58db 100644 --- a/.github/workflows/wikipedia-trails.yml +++ b/.github/workflows/wikipedia-trails.yml @@ -22,7 +22,7 @@ jobs: # pre-recorded tool sequences. wikipedia-trails: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 env: # Recordings-only — LLM is never invoked. A non-empty sentinel value is # required to satisfy the provider/client wiring (see android-tests-host-rpc @@ -52,7 +52,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ - libasound2 \ + libasound2t64 \ libatk-bridge2.0-0 \ libatk1.0-0 \ libcups2 \ diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/project/TrailblazeWorkspaceConfigResolver.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/project/TrailblazeWorkspaceConfigResolver.kt index b6156944..6997ad32 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/project/TrailblazeWorkspaceConfigResolver.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/config/project/TrailblazeWorkspaceConfigResolver.kt @@ -27,9 +27,14 @@ object TrailblazeWorkspaceConfigResolver { if (envOverride != null) { val envDir = File(envOverride) if (envDir.isDirectory) { + // When the configDir has its own trailblaze.yaml, prefer it as the config file so + // pack targets declared there (e.g. ios-contacts/packs/contacts) are loaded instead + // of the workspace-root config's targets (which may point to a different pack of the + // same id with different tools). Falls back to the walk-up configFile when absent. + val envConfigFile = File(envDir, TrailblazeConfigPaths.CONFIG_FILENAME).takeIf { it.isFile } return ResolvedTrailblazeWorkspaceConfig( workspaceRoot = workspaceRoot, - configFile = configFile, + configFile = envConfigFile ?: configFile, configDir = envDir, ) } diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/DaemonClient.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/DaemonClient.kt index 16f492f9..9175c17d 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/DaemonClient.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/cli/DaemonClient.kt @@ -490,8 +490,8 @@ class DaemonClient( /** Poll interval when waiting for daemon */ const val POLL_INTERVAL_MS = 500L - /** Overall timeout for polling a run to completion (30 minutes) */ - const val RUN_POLL_TIMEOUT_MS = 30 * 60 * 1000L + /** Overall timeout for polling a run to completion (60 minutes) */ + const val RUN_POLL_TIMEOUT_MS = 60 * 60 * 1000L /** * Max consecutive poll errors before falling back to a /ping health check. diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/scripting/DaemonScriptedToolBundler.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/scripting/DaemonScriptedToolBundler.kt index fdac0317..2da1bd01 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/scripting/DaemonScriptedToolBundler.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/scripting/DaemonScriptedToolBundler.kt @@ -535,6 +535,18 @@ class DaemonScriptedToolBundler( appendLine(" return result;") appendLine(" },") appendLine("};") + // Attach a `tools` Proxy so scripted tools that use the `client.tools.(args)` + // authoring surface (the SDK's TrailblazeClient.tools property) work correctly at + // runtime. Without this, `client.tools` is undefined and any `client.tools.X()` + // call throws "cannot read property 'X' of undefined". The Proxy maps any string + // property access to a callable that delegates to `__client.callTool(prop, args)`, + // mirroring the SDK's own `createToolsProxy` implementation. + appendLine("__client.tools = new Proxy({}, {") + appendLine(" get: function(target, prop) {") + appendLine(" if (typeof prop !== \"string\") return undefined;") + appendLine(" return function(args) { return __client.callTool(prop, args); };") + appendLine(" }") + appendLine("});") appendLine() // Normalize user return values into the `{content: [...]}` envelope `QuickJsToolHost` // parses. Authors typically `return "Launched X."` from their handlers; the host diff --git a/trailblaze-host/src/test/java/xyz/block/trailblaze/scripting/DaemonScriptedToolBundlerTest.kt b/trailblaze-host/src/test/java/xyz/block/trailblaze/scripting/DaemonScriptedToolBundlerTest.kt index 77443dd5..68b0c927 100644 --- a/trailblaze-host/src/test/java/xyz/block/trailblaze/scripting/DaemonScriptedToolBundlerTest.kt +++ b/trailblaze-host/src/test/java/xyz/block/trailblaze/scripting/DaemonScriptedToolBundlerTest.kt @@ -588,6 +588,24 @@ class DaemonScriptedToolBundlerTest { ) } + @Test + fun `synthesized wrapper exposes client_tools proxy for client_tools_X authoring surface`() { + // Pins the fix for the runtime failure "cannot read property 'X' of undefined" + // that occurs when a scripted tool uses `client.tools.launchApp(...)` instead of + // `client.callTool("launchApp", ...)`. The wrapper's `__client` shim must expose + // a `tools` Proxy that maps property accesses to `callTool` dispatches. + val src = writeTinyTs("tools-proxy-source.ts", exportName = "doSomething") + val wrapper = bundler.synthesizeWrapper(src, toolName = "doSomething") + assertTrue( + wrapper.contains("__client.tools = new Proxy"), + "expected wrapper to attach client.tools Proxy; got:\n$wrapper", + ) + assertTrue( + wrapper.contains("return function(args) { return __client.callTool(prop, args); }"), + "expected tools Proxy to delegate to __client.callTool; got:\n$wrapper", + ) + } + // --- helpers --- private fun writeTinyTs( diff --git a/trails/ios-contacts/test-search-no-results/ios-iphone.trail.yaml b/trails/ios-contacts/test-search-no-results/ios-iphone.trail.yaml index 0f9f5924..d1a1cc17 100644 --- a/trails/ios-contacts/test-search-no-results/ios-iphone.trail.yaml +++ b/trails/ios-contacts/test-search-no-results/ios-iphone.trail.yaml @@ -10,20 +10,5 @@ - contacts_ios_searchContacts: query: ZzZzNoSuchContact openFirstResult: false - - tapOnElementBySelector: - reason: To search for 'ZzZzNoSuchContact', I need to tap the 'Search' field (q410), which is visible at the bottom of the screen. - selector: - textRegex: Search - nodeSelector: - iosMaestro: - hintTextRegex: Search - - inputText: - text: ZzZzNoSuchContact - reasoning: Now that the search field is focused, I will input the search term 'ZzZzNoSuchContact' into the search field to trigger the search and look for 'No Results'. - - assertVisibleBySelector: - reason: The screen displays 'No Results for “ZzZzNoSuchContact”', which verifies 'No Results' is visible as required by the objective. - selector: - textRegex: No Results for “ZzZzNoSuchContact” - nodeSelector: - iosMaestro: - accessibilityTextRegex: No Results for “ZzZzNoSuchContact” + - assertVisibleWithAccessibilityText: + accessibilityText: "No Results"