diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= index 84b268b..8e5cefc 100755 --- a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= +++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= @@ -3,6 +3,6 @@ # This file should be checked into version control along with the pnpm-lock.yaml file. package.json=1645056029 packages/core/package.json=-1407934132 -packages/vscode/package.json=985935702 -pnpm-lock.yaml=136557580 +packages/vscode/package.json=1052087398 +pnpm-lock.yaml=1115513514 pnpm-workspace.yaml=-1673009026 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fa8ca0..b04d1c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,7 @@ everything you need to build, test, and submit changes. by the local-only integration test. - `samples/workspace` — an Elide workspace for the integration test's `workspace` scenario. - `tools/deploy.sh` — release/deploy helper script. +- `tools/build-database-inspector.ts` — rebuilds the Android database inspector's DEX (see below). - `tools/bazel` — repository-local Bazel rules (the hermetic Bun used by `bun_test`). ## Dev loop @@ -39,6 +40,14 @@ bazel build //packages/vscode:extension # the extension, at bazel-bin/ bazel build //packages/vscode:vsix # bazel-bin/packages/vscode/elide.vsix ``` +Unit tests, including Komodo session and Android process-lifecycle coverage, use local protocol +stand-ins and fake executables; they do not require Komodo, Elide, an Android SDK or a device. +Keep these tests enabled in CI. Subprocess readiness checks observe persistent marker contents, +not directory-watch notifications, which can be coalesced before a marker write completes. +Cancellation checks observe actual child-process exit, not a signal-handler marker: a forcibly +terminated child cannot write one. +Tests requiring real optional components belong in the explicitly invoked integration scenarios below. + Outputs land in `bazel-bin/`, never in the source tree. To run the extension interactively, open this repo in VS Code and start the `Run Extension` launch configuration: it builds `//packages/vscode:extension` and opens `samples/ktjvm` in an Extension Development Host loading `bazel-bin/packages/vscode`. @@ -99,24 +108,70 @@ managed Komodo component, including its compiler plugins and JDK inputs. Install `elide komodo install --yes --json` or **Elide: Install Komodo**; ordinary activation and sync never install it. An older CLI reports an upgrade requirement, without changing the selected language server. For an unpublished local candidate, explicitly authorize the producer's development catalog with -`elide komodo install --catalog /absolute/catalog.json --yes --json` in an isolated component store. -Then run without executable/JDK overrides to exercise managed resolution. +`ELIDE_COMPONENTS_HOME=/absolute/temp/components elide komodo install --catalog /absolute/catalog.json --yes --json`. +Set `ELIDE_TEST_COMPONENTS_HOME` to that same absolute store when launching integration scenarios. +The runner forwards it to the extension host; omit executable/JDK overrides to exercise managed resolution. For developer builds only, `ELIDE_KOMODO_LSP` names an absolute executable in an intact native bundle, `ELIDE_KOMODO_JAVA_HOME` its JDK inputs, and `ELIDE_KOMODO_PLUGINS` its compiler plugin directory. Build the standalone bundle in Komodo; do not copy it into an Elide distribution. +`ELIDE_KOMODO_SAMPLE=android` opens +the built Elide Android sample beside this repository (or `ELIDE_ANDROID_SAMPLE`) in place instead (Compose, workspace +members, Room's kapt output); its files are only edited unsaved and checked unchanged at the end. `ELIDE_KOMODO_TRACE=1` records the protocol in the `Elide: Komodo` output channel's log, and `ELIDE_KOMODO_COEXIST=1` keeps the JetBrains extension enabled and logs which `kotlin` grammar VS Code applies: ```sh -ELIDE_TEST_SCENARIO=komodo \ +ELIDE_TEST_SCENARIO=komodo ELIDE_KOMODO_SAMPLE=android \ ELIDE_TEST_HOME="${ELIDE_TEST_HOME:?set to an Elide distribution with managed Komodo support}" \ bazel run //packages/vscode:integration_test ``` +`ELIDE_TEST_SCENARIO=compose-komodo` opens the Compose sample (as `compose` does, below) with the `komodo` preview +engine and checks that every card renders live in the language server process, with no JVM child. + +`ELIDE_TEST_SCENARIO=compose-agent` opens the same Compose sample and drives the Compose preview MCP tools over HTTP, as an +agent does: composables without `@Preview` (placeholders, arguments, a private one), an existing preview, reference and +wrapper compile errors, and a live session that follows an unsaved edit. `ELIDE_COMPOSE_ENGINE=komodo` selects the +`komodo` engine (and Komodo's language server, as `compose-komodo` does), adding taps and typing into the live card and the +preview panel's pause while the agent's session is open; it needs a Komodo with session appendices. The sample's files are +only edited unsaved and checked unchanged at the end. + `ELIDE_TEST_SCENARIO=komodo-debug` takes the same `ELIDE_KOMODO_*` variables and debugs a copy of `samples/ktjvm` through Komodo's debug adapter (`komodo --dap`): `Elide: Run (debug)` stops at a Kotlin line breakpoint, reads a local, evaluates, steps and runs the program to its end. +`ELIDE_TEST_SCENARIO=komodo-android-debug` exercises the real `elide-android` launch provider with Komodo's +`komodo-kotlin` adapter on a ready, disposable emulator. It uses a clean copy of `elide-android-sample`, creates +its debug signing key, and lets the launch build/install/start the APK; no prebuilt APK or changes to the original +sample are needed. It checks the forwarded JDWP socket's device and PID, a Kotlin `onCreate` breakpoint in the +copied source, locals/evaluation, stepping from the DAO assignment to `setContent`, and continuation into a +focused Activity window. It then detaches and removes only the app it installed, leaving the emulator running. +The application ID must match the sample manifest and must **not** already be installed on that emulator; +the scenario refuses to overwrite an existing installation. +The scenario reports attach-to-breakpoint time and fails a first stop slower than 30 s: before Komodo limited its +loaded-class scan to the project's packages, ART's ~30,000 framework classes took about a minute to bind through adb. +It is a correctness bound, not a debugger latency benchmark. + +Install Android SDK platform/build tools for API 36, boot a dedicated API 36 emulator headlessly, wait for boot +completion, and dismiss its keyguard before starting. Select an Android-capable Elide distribution using +`ELIDE_TEST_HOME`; explicitly install Komodo through that CLI before running. Developer-only builds may use +`ELIDE_KOMODO_LSP` and `ELIDE_KOMODO_JAVA_HOME` as above. Other installed extensions are disabled. With `emulator-5580` already ready: + +```sh +ELIDE_TEST_SCENARIO=komodo-android-debug \ +ELIDE_TEST_HOME="${ELIDE_TEST_HOME:?set to an Android-capable Elide distribution with Komodo}" \ +ELIDE_ANDROID_APP_SAMPLE="$HOME/workspaces/elide-android-sample" \ +ELIDE_ANDROID_APP_MEMBER=app \ +ELIDE_ANDROID_APP_ARTIFACT=app \ +ELIDE_ANDROID_APP_ID=sample.app \ +ELIDE_ANDROID_DEVICE=emulator-5580 \ +bazel run //packages/vscode:integration_test +``` + +`ANDROID_HOME` or `ANDROID_SDK_ROOT` must identify that SDK. `KEEP_TEST_DIRS=1` retains the copied workspace, +generated APK, and isolated VS Code logs for diagnosis; it does not retain the app on the emulator. Do not run +two instances against the same emulator/application ID at once. + Android app verification has no default fixture. Select an Elide Android application explicitly, including its workspace-relative member directory, APK artifact name, and application ID: @@ -126,12 +181,44 @@ ELIDE_ANDROID_APP_SAMPLE=/path/to/android-workspace \ ELIDE_ANDROID_APP_MEMBER=app \ ELIDE_ANDROID_APP_ARTIFACT=app-debug \ ELIDE_ANDROID_APP_ID=com.example.app \ -bun run test:integration +bazel run //packages/vscode:integration_test ``` -The runner builds a clean temporary copy, then installs and launches it on an attached device or headless emulator. +The runner builds a clean temporary copy, creates the configured debug signing key through **Create Android +Signing Keystore**, then builds, installs and launches the APK on an attached device or headless emulator. +The fixture must declare a disposable debug keystore under `.dev` inside its copied workspace; `.dev` and the +keys it contains are not copied. Use a dedicated application ID that is not already installed with another key. +The scenario does not uninstall an existing application or delete its data to bypass a signing mismatch. Compose verification separately uses `ELIDE_TEST_SCENARIO=compose` with `ELIDE_ANDROID_SAMPLE` pointing to a built -Compose sample; its default is the sibling `elide-android-sample` checkout. +Compose sample; its default is the sibling `elide-android-sample` checkout. Leave its VS Code window unminimized +while it runs: the scenario times paints by the preview's `requestAnimationFrame` acknowledgements, which Chromium +suspends in a minimized window (`timed out waiting for first paint`) and can delay when the window is activated or +deactivated mid-step (an optimistic paint acknowledged only after confirmation). + +Run latency scenarios without concurrent builds or other preview scenarios. The 1,500 ms optimistic-paint checks +remain distinct from annotation-processing confirmation, which can take several seconds. Record a timing miss +and the surrounding load rather than increasing the bound or treating a later functional pass as a performance fix. + +### Android database inspector helper + +`packages/vscode/media/database/inspector.dex` ships with the extension; the Bazel build does not require an Android +SDK or compile Java. After changing `DatabaseInspector.java`, rebuild and include the dex, running the helper on the +pinned Bun (it writes next to its source, so pass the script's absolute path): + +```sh +bazel run //tools/bazel/bun -- "$PWD/tools/build-database-inspector.ts" +``` + +Requires a JDK and an Android SDK platform API 28+ with build-tools/d8. Set `ANDROID_HOME` or `ANDROID_SDK_ROOT` +when the SDK is not in a conventional location. `ANDROID_DATABASE_PLATFORM` (for example `android-36`) and +`ANDROID_DATABASE_BUILD_TOOLS` (for example `37.0.0`) pin the build inputs; pin the JDK too for reproducibility. +The helper targets device API 26+. Keep the source and generated dex together when updating the protocol. + +For device smoke verification, use an isolated debuggable app with a file-backed SQLite/Room database in WAL mode. +Verify paging, NULL/BLOB/64-bit values, live app commits after opening the panel, read-only rejection of writes, +confirmed writes and foreign-key rollback, cancellation and disconnects. Do not run destructive SQL against a +developer's existing app data. Unit tests cover transport/lifecycle boundaries; they do not replace device or +real-webview verification. ## Code style diff --git a/README.md b/README.md index 8d80a65..fd1b13c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ VS Code integration for the [Elide](https://elide.dev) build tool. The extension turns an `elide.pkl` project into a model the [Kotlin LSP by JetBrains](https://marketplace.visualstudio.com/items?itemName=JetBrains.kotlin-server) can import — resolved Maven dependencies, source roots, JDK — and adds Elide build/run/test tasks and JDWP debugging. +Android projects get deployment, Logcat, database and Compose preview tooling. Komodo, the native Kotlin language +server, debugger and live Compose preview engine shipped with Elide, is available as an experimental opt-in +([details](packages/vscode/README.md#komodo-language-server-experimental)). ## Install @@ -116,20 +119,23 @@ anything you set by hand stays. | `Elide: Sync Project(s)` | Re-run the sync. | | `Elide: Show Menu` | Quick pick with the Elide actions (what the status bar item opens). | | `Elide: Run Elide Command…` | Pick `build`, `test`, `install`, or `run ` and run it as a task. | -| `Elide: Open generated Kotlin LSP workspace` | Open `workspace.json`. | +| `Elide: Open Generated Kotlin LSP Workspace` | Open `workspace.json`. | | `Elide: Show Output` | Open the `Elide` output channel (CLI output, sync log). | | `Elide: Show Activity` | Reveal the **Activity** view: each sync and preview build, its steps and their durations. | | `Elide: Open Activity Log` | Open the latest operation's output, with timestamps, in an editor. | | `Elide: Select Android Deployment Target…` | Choose and remember the project, APK and device shared by Run and Debug. | | `Elide: Check Android Environment` | Inspect local tools, SDK and devices, even before sync succeeds; select a finding for remediation. | | `Elide: Refresh Android Devices` | Refresh the sidebar device list without resyncing the project. | +| `Elide: Pair Android Device over Wi-Fi…` | Pair with a QR or six-digit code, or connect an already-paired device by address. | | `Elide: Run Android App…` | Build an APK and install and launch it on a device or the emulator. | | `Elide: Debug Android App…` | Build an APK, install and launch it waiting for a debugger, and attach over JDWP. | -| `Elide: Start Android Emulator`, `Elide: Stop Android Emulators`, `Elide: Create Android Virtual Device` | Drive the project's virtual device. | +| `Elide: Run Android Instrumented Tests…` | Install a test APK on the deployment target and run its instrumented tests in the Test Explorer. | +| `Elide: Start Android Emulator`, `Elide: Stop Android Emulator`, `Elide: Create Android Virtual Device` | Drive the project's virtual device. | | `Elide: Install Android SDK Components` | `elide build android-sdk --install-sdk`. | | `Elide: Create Android Signing Keystore` | `elide build android-keystore-`. | -| `Elide: Open Compose Preview` | Render Compose previews locally with Android layoutlib; refresh live while editing. No device or emulator. | +| `Elide: Open Compose Preview` | Render Compose previews locally, without a device or emulator: layoutlib snapshots by default, live cards with the experimental Komodo engine; refresh live while editing. | | `Elide: Show Android Logcat` | Stream device logs with application, priority and text filters. | +| `Elide: Explore Android Databases` | Browse and query a debuggable app's Room / SQLite databases on a connected device. | ## Tasks @@ -166,7 +172,7 @@ Every task reports through the `$elide` problem matcher, so compiler errors and the offending line. It matches the two-line kotlinc shape Elide prints — `[212ms] error: kotlinc: ` followed by `In file: [:[:]]` — and resolves the path by searching the workspace folder (skipping `.dev`, `.git` and `node_modules`), so relative paths reported from a project root in a subdirectory still resolve. Paths -containing spaces are supported in regular task terminals. `javac` diagnostics +containing spaces are supported in both regular task terminals and Compose preview builds. `javac` diagnostics interleave `symbol:`/`location:` lines between the two and are shown in the terminal only. ## Run and debug code lenses @@ -322,17 +328,20 @@ once before building the model; without it the language server would be handed j The SDK is located the way a build locates it: `android.sdk` in the manifest, then `$ANDROID_HOME`, then `$ANDROID_SDK_ROOT`, then the platform's own install location (`~/Library/Android/sdk`, `~/Android/Sdk`, -`%LOCALAPPDATA%\Android\Sdk`). `elide.android.sdk` overrides it for extension-side device listing and logcat. +`%LOCALAPPDATA%\Android\Sdk`). `elide.android.sdk` overrides it for extension-side device tooling, including wireless pairing and logcat. A build reports a missing SDK itself; `Elide: Install Android SDK Components` runs `elide build android-sdk --install-sdk`, which is how the component licenses are accepted. -The sidebar's **Android** section lists the artifacts, the devices `adb devices` reports (serial, and whether it is -an emulator or in a state that cannot take an install), and the project-wide device targets: +Each project's sidebar **Android** section lists its artifacts. The devices `adb devices` reports (serial, and +whether it is an emulator or in a state that cannot take an install) and the device tools — deployment target, +pairing, environment check, logcat, databases, and the emulator and AVD of each application's `android.device` — +appear once per workspace, in the top-level project's **Android devices & tools** section, however many Android +members it has. Library-only members get no emulator rows. The artifacts' device targets: | Action | Invocation | | --- | --- | | Run an APK | `elide build android-run-` with the selected `--device=`, or `android-run-emulator-` for the explicitly selected project AVD. | -| Debug an APK | `elide build android-debug-`, which installs it, launches it waiting for a debugger and forwards its JDWP socket; the extension attaches to the port the target announces. The APK must carry `android:debuggable="true"` — nothing injects it, and the target reports an APK that does not. | +| Debug an APK | `elide build android-debug-`, which installs it, launches it waiting for a debugger and forwards its JDWP socket; the extension attaches to the port the target announces. The APK must be debuggable (`android:debuggable="true"`): newer Elide releases add it to a `release = false` build, and Debug is offered only for an APK Elide lists `android-debug-` for. | | Build an APK, AAB or AAR | `elide build ` → `.dev/artifacts/.apk` / `.aab` / `.aar` | | Build a dynamic-feature module | `elide build android-module-` → `.dev/android//.zip`; build its owning AAB to publish the application. | | Open the R8 mapping | A `minify = true` artifact gets a mapping row beside it, which opens `.dev/artifacts/-mapping.txt` — publishing it with `elide build android-mapping-` when it is not there, since building the artifact alone does not — the only way to read a stack trace from a shrunk build. | @@ -354,9 +363,11 @@ owning the active file takes precedence; focusing a library does not make that l Run and Debug save dirty file-backed documents first and stop if saving fails. Choosing the project emulator uses Elide's existing emulator targets. A selected emulator that has stopped is booted before Run or Debug, through `android-emulator` for the project's AVD or the SDK emulator for any other, and is found again by AVD -identity; ambiguous instances are refused. `elide-android` launch configurations also validate their APK/device -and accept `flags`, `options` and `env`, layered over workspace defaults. The selected device takes precedence -over build options. +identity; ambiguous instances are refused, and a serial reused by another AVD cannot redirect a remembered +deployment. Cancelling startup prevents deployment even if an adb readiness reply arrives afterward. +Late picker, device-discovery and startup replies cannot replace a newer deployment selection. +`elide-android` launch configurations also validate their APK/device and accept `flags`, `options` and `env`, +layered over workspace defaults. The selected device takes precedence over build options. **Check Android Environment** is available from the command palette, Elide menu and project context menu, including when sync has failed. It checks Elide, a usable JDK, SDK/platform-tools, devices and Kotlin extension @@ -369,6 +380,40 @@ Use the offered **Open canonical folder in a new window** action for source debu The device tree refreshes after Elide tasks and through **Refresh Android Devices**; no background adb polling. +Android commands, menu entries and context-menu actions appear only when a synced project declares an `android` +block (the `elide.hasAndroidProjects` context key); **Check Android Environment** also appears while a project is not +synced yet. Invoked anyway, they warn that no synced project declares an `android` block. + +### Wireless Android devices + +Open **Elide: Pair Android Device over Wi-Fi…** from the Command Palette or Elide menu, or **Pair device over +Wi-Fi** in the sidebar's **Android devices & tools** section. Requires Android 11+, current SDK Platform-Tools, and the device +and extension host on the same reachable network; no USB connection or Android Studio installation is needed. + +1. Enable **Developer options → Wireless debugging** on the device. +2. Choose **Generate QR code** in the panel and **Pair device with QR code** on the device. Scan using the + Wireless debugging scanner. Alternatively, choose **Six-digit code**, open **Pair device with pairing code** + on the device, refresh discovery, and select its address (or enter it manually) and six-digit code. +3. ADB normally connects automatically after pairing. If the device is missing, use the panel's **Connect an + already-paired device** section with the **IP address & port** from the main Wireless debugging screen. + This is a different port from the pairing dialog. Refresh Android Devices if auto-connection arrives later. +4. Select the connected device with **Select Android Deployment Target…** for Run and Debug. Wireless devices + also appear in the existing Logcat and database device pickers; pairing does not replace a pinned target. + +Discovery uses mDNS; QR pairing waits for its exact generated service for two minutes. On networks that block +mDNS, use manual pairing and connection addresses instead. Check VPNs, firewalls and guest-network isolation +if connections fail. In Remote SSH or containers, the SDK and network are those of the extension host, not the +local VS Code window. The extension does not enable legacy unencrypted `adb tcpip` debugging. + +Failures quote what adb reported. **Connect** first checks, for up to 5 seconds, that the address accepts a +connection, so an unreachable or refused address fails promptly with the likely cause instead of waiting for adb. +The pairing port is not probed that way, since a stray connection could count as a failed pairing attempt. + +Pairing codes are not saved or logged, are passed to ADB through stdin, and are masked wherever adb echoes them in +an error. Closing the panel or cancelling stops its work and discards the QR code, but cannot undo pairing already +accepted by the device. To revoke trust, select this workstation under the device's **Paired devices** and choose +**Forget**. + ### Compose component preview **Elide: Open Compose Preview** renders locally using Google's standalone Compose renderer and Android layoutlib. @@ -376,24 +421,53 @@ It requires no device, emulator, Android Studio installation, signing keystore, builds JVM classes and linked Android resources, not DEX or an installable application. Enable `kotlin.features.compose`, include Compose `ui-tooling`, sync, and open a Kotlin file containing -`@Preview` / `@Composable` functions. The gallery renders the file's compiled previews, including annotation -configuration, member functions, multi-preview annotations and `@PreviewParameter` providers. Kotlin/Java edits, -including unsaved changes, use persistent incremental compilers and renderers. Resource/configuration saves -rebuild the resource context, chosen automatically as the application packaging the file or its library. +`@Preview` / `@Composable` functions; the editor-title button appears on such files of an Android application. The +gallery renders the file's compiled previews, including annotation configuration, member functions, multi-preview +annotations and `@PreviewParameter` providers. Kotlin/Java edits, including unsaved changes, use persistent +incremental compilers and renderers. Resource/configuration saves rebuild the resource context, chosen automatically +as the application packaging the file or its library; a rebuild that changes only resources or R classes keeps the +warm compiler. + +Kapt processors such as Room can live in the preview-bearing module. The live worker uses that module's +recorded Kotlin toolchain, compiles against private generated inputs, then verifies kapt stubs in the background. +Body-only changes can confirm without reprocessing. Declaration changes rerun processors, but retain warm +compiler state and the displayed images when generated sources, classes and resources are unchanged—for +example, when adding a composable beside Room entities. Changed generated output is recompiled and repainted. +Processor errors remain visible instead of confirming stale images, and only confirmed galleries are cached. This requires +Elide's `kotlinc.toolchain.json`, `kapt.args`, and `kapt.generated.json` records; rebuild after upgrading Elide. The themed gallery retains its last good images through compilation errors and swaps decoded updates without -blanking the canvas. Enable **Inspect** for measured layout outlines; select a component and open its source, -including workspace-library call sites. Source navigation pauses while the displayed preview is out of date. +blanking the canvas. Enable **Inspect** for measured layout outlines, selection and dimensions, and source navigation +to each component's call site, including workspace-library call sites; navigation pauses while its preview is out of +date. +A failed preview build must succeed on a later refresh before the gallery is marked current; a Kotlin edit alone +cannot bypass failed resource preparation. Closing a preview stops its build, including pending preparation. + +The experimental `komodo` engine (`elide.android.composePreview.engine` = `komodo`, with `elide.kotlinLsp.server` = +`komodo`) renders every card live inside the workspace's Komodo language server, from its own analysis of unsaved +Kotlin edits: clicks run handlers, compile errors land in Problems, and **Inspect** selects composable calls by their +measured bounds and opens their call sites, also after live edits. Click or Tab to a live card to send keys only to that card: text +input, modifiers, Backspace/Delete, arrows, Enter and Tab between Compose controls. Press **F6** or **Shift+F6** to +leave; ordinary Escape still reaches Compose. Leaving the canvas, switching cards or enabling **Inspect** releases +held keys. IME composition and clipboard paste are not supported. There is no fallback to `layoutlib`. + +Native previews are effectively disabled while Komodo is missing or incompatible, without changing your engine +preference or installing anything automatically. Opening, prewarming and agent rendering check compatibility before +building or preparing preview resources. Losing availability closes native panels, cancels in-flight work and retires +agent sessions and warm native backends; layoutlib and source-only agent tools remain available. Install Komodo +explicitly with **Elide: Install Komodo**, correct its settings or sync to recheck compatibility, then reopen the preview +or start a new agent render. A rejected native session protocol stays disabled until that recheck, without build loops. The first preview downloads a pinned, SHA-256-verified renderer/runtime from Google Maven into extension storage. Subsequent previews reuse it offline. A matching-architecture JDK 17+ is required; supported extension hosts are -macOS arm64/x64, Linux x64 and Windows x64. These are rendered snapshots, not interactive device mirroring or -in-process Live Edit. See [the extension guide](packages/vscode/README.md#compose-component-preview) for setup +macOS arm64/x64, Linux x64 and Windows x64. The default `layoutlib` engine produces snapshots; neither engine is +interactive device mirroring or in-process Live Edit. See [the extension guide](packages/vscode/README.md#compose-component-preview) for setup and runtime limitations. ### Dagger and Hilt -The experimental Elide `feat/android` branch supports kapt and Hilt Android entry-point transformation. +Elide builds with Android support (currently the internal release's distribution; not yet in a public Elide +release) support kapt and Hilt Android entry-point transformation. Declare processors in `dependencies.maven.processors`, use a managed/external Kotlin toolchain with kapt, and enable `android.hilt` for Hilt. Sync discovers the project's actual compile targets, generates processor sources and imports the Java/kapt output roots without importing kapt stubs. Workspace AARs retain generated @@ -412,7 +486,7 @@ Features are buildable modules, not independently runnable APKs. Delivery is `on-demand` by default or `install-time`. Install-time features remain removable splits, preserving their identity when another feature depends on them. Elide packages base/shared classes once and gives each feature its own resources, assets and dex. Install an AAB through bundletool or Play; the APK Run/Debug commands -do not perform Play delivery. These capabilities require the experimental `feat/android` Elide branch. +do not perform Play delivery. These capabilities require an Elide build with Android support. ### Logcat @@ -426,15 +500,59 @@ Records are aligned into time, PID-TID, tag, priority and message columns, with messages indented under one header and priorities, tags, exceptions and application frames color-coded by the theme. Each project owns an output channel and adb stream. **Filter**, **Stop**, and **Restart Android Logcat** control the stream; **Clear Android Logcat Output** clears only the editor, not the device's shared log buffers. -Restart and filter changes replay available device logs. Package filtering uses the installed UID, surviving -process restarts and including secondary processes and any apps sharing that UID. It requires `logcat --uid`. -Streams stop when their project is removed or the extension closes. +**Filter Android Logcat** is one input holding the current filter, e.g. `package:com.example.app level:warn Exception`: +`package:` names the application (leave it out for all), `level:` the minimum priority (`verbose`, `debug`, `info`, +`warn`, `error`, `fatal` or `V`…`F`; Info when absent), and every other word is case-insensitive literal text; +`"quote"` text with spaces or colons. A new, restarted or refiltered stream does not replay the device's whole +buffer: filtered to a running application, it starts at that application's current process start; otherwise it +starts now. Package filtering uses the installed UID, surviving process restarts and including secondary processes +and any apps sharing that UID. On Android 12+ it uses `logcat --uid`; on Android 7–11 it follows only the app's running main process (`--pid`), so restart logcat after the app restarts. +Streams and pending device/application discovery stop when their project is removed or the extension closes; +cancelled discovery cannot open a late application picker. + +The startup header reports the requested device-clock timestamp, not reader readiness. Android 16 emulator +qualification found records emitted immediately after starting a timestamp-filtered stream missing from live +`adb logcat` output but present in a later dump; direct adb reproduces this without the extension. For startup +diagnosis, retain `adb -s logcat -d -v threadtime` output as well. The extension does not treat a fixed delay as proof of readiness. Ctrl/Cmd-click a Kotlin or Java application stack frame to open its source line. Navigation indexes the synced source roots across workspace modules and uses both package and source filename, including nested classes and Kotlin file facades. Native/unknown frames, out-of-range lines and ambiguous same-package filenames remain unlinked. Obfuscated release traces still require the matching R8 mapping; this does not perform retracing. +### Room / SQLite database explorer (experimental) + +Run **Elide: Explore Android Databases**, or choose **Explore databases** in the sidebar's Android devices & tools +section or a device's context menu. Select an installed application on the connected device. Requires Android 8.0 / +API 26 or newer, a debuggable app that permits `adb run-as`, and a trusted, synced Android project. The application +picker asks `run-as` about every installed application first: debuggable ones are listed as openable, and the rest +appear under **Cannot be opened** with the reason, e.g. a release build that is not debuggable. + +- Browse databases, tables, views, indexes and trigger definitions; inspect rows in 100-row pages. +- Run one read-only SQL query, with exact 64-bit integers, NULLs and Base64 BLOBs. Use `ORDER BY` for predictable paging. +- Refresh manually or every 2 / 5 seconds while the panel is visible. Refresh re-runs the last successful read, + not an edited SQL draft. A failed or cancelled query clears that read; Refresh then updates only the catalog + until another query succeeds, rather than showing older rows under the failed or cancelled SQL. +- **Execute write…** confirms the app, database, device and SQL before committing a single statement with foreign + keys enabled. Reads never implicitly become writes. Cancellation during a write leaves its outcome unknown; + refresh before retrying. + +A bundled Android helper opens the app's actual SQLite files, including committed WAL data; no database copy, +root access, device `sqlite3` executable or app dependency is required. One helper process runs per open panel and +serves all of its requests over its pipes, so a query costs tens of milliseconds rather than a VM start; a helper +that fails or is cancelled is stopped, on the device too, and the next request starts a fresh one. It is scoped to +the foreground Android user recorded by the package picker, and refuses access if that user changes—even before +the first query. Select the application again under the new profile. Closing the panel stops its requests and +helper and removes its temporary DEX on a best-effort basis. + +This is not Android Studio's in-process inspector: external writes **do not notify Room observers**, and pages do +not share a retained snapshot. It inspects on-disk SQLite files under the app's `databases/` directory, not +in-memory, encrypted, device-protected or custom-location databases. `PRAGMA` statements, `ATTACH`, +transaction-control/maintenance commands, multiple statements and compound `CREATE TRIGGER` bodies are not +supported; read what a pragma reports through its table-valued function, e.g. +`SELECT * FROM pragma_table_info('items')`. Results are bounded to 4 MiB, 256 columns and 256 KiB per cell; oversized +values fail explicitly rather than being silently truncated. Select smaller values or fewer columns when needed. + ## Settings @@ -460,9 +578,7 @@ unlinked. Obfuscated release traces still require the matching R8 mapping; this | `elide.android.emulatorWindow` | `true` | Boot the emulator with a window; off passes `--no-window`. | | `elide.android.composePreview.prewarm` | `true` | Start the Compose preview backend when a file with `@Preview` functions is focused. | | `elide.android.composePreview.keepAliveMinutes` | `10` | Minutes an unused Compose preview backend stays warm; `0` stops it with its panel. | -| `elide.android.composePreview.engine` | `layoutlib` | `komodo` (opt-in, experimental) adds a `komodo-preview` daemon: cards it hosts are live, the rest keep their layoutlib snapshot. | -| `elide.android.composePreview.komodo.path` | `""` | The `komodo-preview` daemon; empty resolves it beside the Elide binary, then on `PATH`. | -| `elide.android.composePreview.komodo.fallback` | `true` | Cards Komodo cannot render keep their layoutlib snapshot; off shows the reason on the card. | +| `elide.android.composePreview.engine` | `"layoutlib"` | `komodo` (experimental): live cards in the workspace's Komodo language server; requires `elide.kotlinLsp.server` = `komodo`. | ## Repository layout diff --git a/packages/core/BUILD.bazel b/packages/core/BUILD.bazel index 9a08674..1d36a37 100644 --- a/packages/core/BUILD.bazel +++ b/packages/core/BUILD.bazel @@ -13,7 +13,10 @@ ts_project( srcs = [ "src/android.ts", "src/androidBinaryXml.ts", + "src/androidDatabase.ts", "src/androidLogcat.ts", + "src/androidTests.ts", + "src/androidWireless.ts", "src/artifacts.ts", "src/buildOutput.ts", "src/buildTasks.ts", diff --git a/packages/core/README.md b/packages/core/README.md index 91296b2..c145a65 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -19,9 +19,12 @@ is referenced anywhere in this package. | `pkl.ts` | Read and edit `elide.pkl` as text, for what the exported project model cannot answer (artifact source positions, the workspace above a folder). | | `kotlinLsp.ts` | Emit the JSON workspace (`workspace.json`) consumed by the JetBrains Kotlin language server. | | `komodoLsp.ts` | Emit the Komodo project manifest (`project.properties`) that Komodo's language server (`komodo`) analyzes, with the compiler plugins it needs. | -| `android.ts` | Android build targets, published and staged artifact paths, SDK resolution (`android.sdk`, `ANDROID_HOME`, the conventional location) and the attached devices `adb` reports. | +| `android.ts` | Android build targets — which device targets (run, debug, instrumented tests) a task listing offers an APK, and which take `--device` — published and staged artifact paths, SDK resolution (`android.sdk`, `ANDROID_HOME`, the conventional location) and the attached devices `adb` reports. | +| `androidTests.ts` | Per-test results of `android-test-` (pass, failure with its stack trace, skip) read from its progress records. | +| `androidWireless.ts` | Android 11+ wireless pairing: mDNS service discovery, validated endpoints, QR credentials, and bounded, cancellable ADB pairing/connection with stdin-only secrets. | | `androidBinaryXml.ts` | The application ID an APK installs as, read from its compiled manifest (after `--rename-manifest-package` and placeholders). | | `androidLogcat.ts` | Abortable adb log streams, application UID resolution, priority and literal-text filtering, and explicit stop/restart lifecycle. | +| `androidDatabase.ts` | Scoped, cancellable `adb run-as` sessions for live SQLite discovery, schemas, bounded queries and explicit writes; exact integer/BLOB values and foreground-user checks. Requires the bundled Android inspector dex supplied by the caller. | | `composeInputs.ts` | Host Compose preview build targets and postbuild JVM/runtime inputs: declared source-set/workspace closure, linked resources and merged-manifest namespace, and the library versions the APK ships (newest across members, as Elide dexes them), without dexing or packaging an app. | | `mavenVersion.ts` | Maven version ordering (maven-resolver's `GenericVersionScheme`), which picks the shipped version of a library resolved at several. | diff --git a/packages/core/src/android.ts b/packages/core/src/android.ts index 670055e..6a25349 100644 --- a/packages/core/src/android.ts +++ b/packages/core/src/android.ts @@ -1,8 +1,9 @@ import { execFile } from "node:child_process"; -import { existsSync, statSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; +import { unqualifiedTaskName, type BuildTaskInfo } from "./buildTasks.js"; import { OUTPUT_DIR } from "./elide.js"; import type { AndroidArtifact, AndroidArtifactKind } from "./manifest.js"; import { normalizePath } from "./sourceRoots.js"; @@ -26,7 +27,10 @@ export const ANDROID_AVD_TASK = "android-avd"; /** Boot the project's virtual device and wait for `sys.boot_completed`. */ export const ANDROID_EMULATOR_TASK = "android-emulator"; -/** Shut down every running emulator, through `adb emu kill`. */ +/** + * Stop the emulator `--device=` names, or else the one running the project's own AVD — the device + * `android-emulator` boots — leaving every other emulator running. + */ export const ANDROID_EMULATOR_STOP_TASK = "android-emulator-stop"; /** @@ -40,6 +44,9 @@ export const ANDROID_LIBRARIES_TASK = "android-libraries"; /** `--device=`: pin an Android target to one attached device or emulator. */ export const ANDROID_DEVICE_OPTION = "device"; +/** `--tests=[#]`: the instrumented tests `android-test-` runs, every one when absent. */ +export const ANDROID_TESTS_OPTION = "tests"; + /** `--install-sdk`: install the SDK components a target is missing, accepting their licenses. */ export const ANDROID_INSTALL_SDK_OPTION = "install-sdk"; @@ -84,6 +91,169 @@ export function androidDebugTask(artifact: string): string { return `android-debug-${artifact}`; } +/** The target that installs `artifact` on a device and runs the instrumented tests it carries. */ +export function androidTestTask(artifact: string): string { + return `android-test-${artifact}`; +} + +/** + * Whether `task` installs onto one device, which `--device=` names: a run, debug or instrumented-test target. + * Without the option Elide picks the device itself, so whoever runs it passes the deployment target's serial. + * + * `android-emulator-stop` takes the option too, but stops rather than deploys: without it, it stops the project's + * emulator, which is what running it bare asks for. + */ +export function isAndroidDeviceTask(task: BuildTaskInfo): boolean { + return unqualifiedTaskName(task) !== ANDROID_EMULATOR_STOP_TASK + && task.options.some((option) => option.option === `--${ANDROID_DEVICE_OPTION}`); +} + +/** + * What the device targets of a project's task listing let an editor do with APK `artifact`, given what its own + * declarations say (`declared`). An action is offered only when Elide lists its target, never assumed from the + * artifact's kind: Elide leaves out the targets that cannot succeed for an APK. + * + * Elide 1.6.1+43bf7ad8d and older list `android-test-` for every APK, so the test target counts only for an + * APK that carries instrumentation — Pkl `instrumentation`, or an `` element in its manifest — and + * list `android-debug-` even for an APK whose manifest sets `android:debuggable="false"`. + */ +export function androidDeviceTargets(tasks: readonly BuildTaskInfo[], artifact: string, declared: AndroidDeclaredTargets = {}): AndroidDeviceTargets { + const listed = new Set(tasks.map(unqualifiedTaskName)); + return { + runnable: listed.has(androidRunTask(artifact)), + debuggable: declared.debuggable !== false && listed.has(androidDebugTask(artifact)), + instrumented: declared.instrumentation === true && listed.has(androidTestTask(artifact)), + }; +} + +/** What an APK's declarations say about its device targets, beyond what Elide lists: see {@link androidDeviceTargets}. */ +export interface AndroidDeclaredTargets { + /** The APK carries instrumented tests: Pkl `instrumentation`, or `` in its manifest. */ + instrumentation?: boolean; + /** `android:debuggable` as its manifest sets it, when it does. */ + debuggable?: boolean; +} + +/** {@link androidDeviceTargets}: which of Run, Debug and the instrumented tests an APK offers. */ +export interface AndroidDeviceTargets { + /** `android-run-` installs and launches it. */ + runnable: boolean; + /** `android-debug-` installs it and attaches a debugger. */ + debuggable: boolean; + /** `android-test-` runs the instrumented tests it declares. */ + instrumented: boolean; +} + +/** What an Android manifest declares that decides what an editor offers for the APK built from it. */ +export interface AndroidManifestFacts { + /** `android:debuggable` of ``, when it is set to a literal rather than a placeholder. */ + debuggable?: boolean; + /** The `` element of a test APK: the runner it names and the package it instruments. */ + instrumentation?: { runner?: string; targetPackage?: string }; +} + +/** `` and ``: a quoted value may hold `>` without ending the tag. */ +const APPLICATION_ELEMENT = /]|"[^"]*"|'[^']*')*)>/; +const INSTRUMENTATION_ELEMENT = /]|"[^"]*"|'[^']*')*)>/; +const MANIFEST_ATTRIBUTE = /([\w.:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; + +/** Attributes of an element by local name: `android:debuggable` and `a:debuggable` are both `debuggable`. */ +function manifestAttributes(text: string): Map { + const attributes = new Map(); + for (const match of text.matchAll(MANIFEST_ATTRIBUTE)) { + attributes.set(match[1]!.slice(match[1]!.lastIndexOf(":") + 1), match[2] ?? match[3] ?? ""); + } + return attributes; +} + +/** + * What a text `AndroidManifest.xml` declares: a source manifest, or the merged one Elide stages and links. Comments + * are ignored; a value that is not a literal (`${debuggable}`) says nothing. + */ +export function parseAndroidManifest(xml: string): AndroidManifestFacts { + const text = xml.replace(//g, ""); + const application = APPLICATION_ELEMENT.exec(text); + const debuggable = application ? manifestAttributes(application[1]!).get("debuggable") : undefined; + const instrumentation = INSTRUMENTATION_ELEMENT.exec(text); + const declared = instrumentation ? manifestAttributes(instrumentation[1]!) : undefined; + const runner = declared?.get("name"); + const targetPackage = declared?.get("targetPackage"); + return { + ...(debuggable === "true" || debuggable === "false" ? { debuggable: debuggable === "true" } : {}), + ...(declared ? { instrumentation: { ...(runner ? { runner } : {}), ...(targetPackage ? { targetPackage } : {}) } } : {}), + }; +} + +/** {@link parseAndroidManifest} of a manifest on disk; `undefined` when it cannot be read. */ +export function readAndroidManifest(file: string): AndroidManifestFacts | undefined { + try { + return parseAndroidManifest(readFileSync(file, "utf8")); + } catch { + return undefined; + } +} + +/** The merged manifest Elide stages for an artifact and links its APK from: `.dev/android//AndroidManifest.xml`. */ +export function androidMergedManifest(root: string, artifact: string): string { + return path.join(root, OUTPUT_DIR, ANDROID_STAGE_DIR, artifact, "AndroidManifest.xml"); +} + +/** + * Whether the APK the last build of `artifact` produced is debuggable, by the merged manifest it was linked from: only + * `android:debuggable="true"` on `` makes it so. Newer Elide releases add that attribute to a + * `release = false` build; 1.6.1+43bf7ad8d and older never do, yet list `android-debug-` all the same. + * + * `undefined` when no build staged a merged manifest, or `sourceManifest` changed after it was merged: the next + * build decides then. + */ +export function stagedAndroidDebuggable(root: string, artifact: string, sourceManifest: string): boolean | undefined { + const merged = androidMergedManifest(root, artifact); + let mergedAt: number; + try { + mergedAt = statSync(merged).mtimeMs; + } catch { + return undefined; + } + try { + if (statSync(sourceManifest).mtimeMs > mergedAt) return undefined; + } catch { + // A merged manifest without its source: the stage is all there is to go by. + } + const facts = readAndroidManifest(merged); + return facts ? facts.debuggable === true : undefined; +} + +/** + * What an APK offers now: the device targets of the last sync, without Debug once its latest build turned out not + * to be debuggable ({@link stagedAndroidDebuggable}). A Debug of such a build fails on the device after the whole + * build; the build after a manifest change offers it again. + */ +export function currentAndroidDeviceTargets( + root: string, + target: { name: string; manifestFile: string; device?: AndroidDeviceTargets }, +): AndroidDeviceTargets | undefined { + const device = target.device; + if (!device?.debuggable || stagedAndroidDebuggable(root, target.name, target.manifestFile) !== false) return device; + return { ...device, debuggable: false }; +} + +/** + * The artifact whose keystore an Elide hint asks to create: `Create one with: elide build android-keystore-app` names + * `app`. A workspace may qualify the target with its member (`mobile:android-keystore-app`). + */ +export function androidKeystoreHintArtifact(hint: string): string | undefined { + return /(?:^|[\s:`'"])android-keystore-([\w.-]*[\w-])/.exec(hint)?.[1]; +} + +/** + * The application an install could not update because the copy on the device is signed with another key: + * `Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: Existing package sample.app signatures do not match newer version; + * ignoring!]` names `sample.app` (older Android says `Package sample.app signatures do not match …`). + */ +export function androidIncompatibleUpdatePackage(text: string): string | undefined { + return /INSTALL_FAILED_UPDATE_INCOMPATIBLE\b[^\]\n]*?\b[Pp]ackage ([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)/.exec(text)?.[1]; +} + /** A dynamic feature stages a bundle module, not a standalone published/installable archive. */ export function androidFeatureTask(artifact: string): string { return `android-module-${artifact}`; diff --git a/packages/core/src/androidDatabase.ts b/packages/core/src/androidDatabase.ts new file mode 100644 index 0000000..0c17afe --- /dev/null +++ b/packages/core/src/androidDatabase.ts @@ -0,0 +1,739 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import type { Readable } from "node:stream"; + +/** SQLite integers outside JavaScript's exact range and binary values are never coerced to numbers/text. */ +export type AndroidDatabaseCell = null | string | number | { type: "integer"; value: string } | { type: "blob"; base64: string }; + +export interface AndroidDatabaseObject { + name: string; + type: "table" | "view" | "index" | "trigger"; + sql: string | null; +} + +export interface AndroidDatabaseResult { + columns: string[]; + rows: AndroidDatabaseCell[][]; + hasMore: boolean; +} + +export interface AndroidDatabaseOptions { + adb: string; + device: string; + packageName: string; + /** Packaged, precompiled inspector.dex; streamed directly into the application's private cache. */ + helperDex: string; + /** If supplied, must match the explicitly resolved current Android user. */ + user?: number; +} + +/** One inspector request; the session adds the id that its response must echo. */ +export type AndroidDatabaseRequest = { action: "databases" } + | { action: "schema"; database: string } + | { action: "query"; database: string; sql: string; limit: number; offset: number } + | { action: "execute"; database: string; sql: string }; + +const PACKAGE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/; +const MAX_OUTPUT = 4 * 1024 * 1024; +const MAX_CELL = 256 * 1024; +const MAX_SQL = 64 * 1024; +const MAX_INPUT = 512 * 1024; +const MAX_DEX = 16 * 1024 * 1024; +const MAX_COLUMNS = 256; +const MAX_OFFSET = 1_000_000; +const REQUEST_TIMEOUT = 20_000; + +function abortError(): Error { + const error = new Error("The Android database operation was cancelled; the session may have been disposed."); + error.name = "AbortError"; + return error; +} + +function checkAbort(signal?: AbortSignal): void { + if (signal?.aborted) throw abortError(); +} + +function nonempty(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || !value.trim() || value.includes("\0")) throw new Error(`Invalid ${label}.`); +} + +function validateConnection(adb: string, device: string): void { + nonempty(adb, "adb executable path"); + nonempty(device, "Android device serial"); +} + +function validUser(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 21474; +} + +function validateDatabase(database: unknown): asserts database is string { + if (typeof database !== "string" || !database || Buffer.byteLength(database, "utf8") > 4096 + || /[\u0000-\u001f\u007f\\]/.test(database) || database.startsWith("/") + || database.split("/").some((part) => !part || part === "." || part === "..")) { + throw new Error("Invalid database path: select a database relative to the application's databases directory, without traversal."); + } +} + +function validateSql(sql: unknown): asserts sql is string { + if (typeof sql !== "string" || !sql.trim() || sql.includes("\0") || Buffer.byteLength(sql, "utf8") > MAX_SQL) { + throw new Error("SQL must be nonempty, contain no NUL, and fit within 64 KiB of UTF-8."); + } +} + +/** adb shell joins remote argv verbatim even though the host never invokes a shell. */ +function quote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function remoteArgs(device: string, command: string[]): string[] { + // Shell-v2 without a PTY preserves binary bytes and forwards stdin EOF; exec-out does not forward EOF. + return ["-s", device, "shell", "-T", ...command.map(quote)]; +} + +/** How to get past `run-as: package not debuggable`, the refusal a release build of an app always meets. */ +export const ANDROID_DATABASE_DEBUGGABLE_HINT = "run-as only opens debuggable builds: install a build whose merged manifest sets android:debuggable=\"true\" (an Elide APK with `release = false` on an Elide release that marks such builds debuggable, or a Gradle debug build)."; + +const HELPER_MISSING = "ELIDE_DATABASE_MISSING"; + +function diagnostic(context: string, detail: string): Error { + const text = detail.trim().slice(-16 * 1024); + let advice = ""; + if (/not debuggable/i.test(text)) advice = ` ${ANDROID_DATABASE_DEBUGGABLE_HINT}`; + else if (/unknown package|not installed|package .* unknown/i.test(text)) advice = " Install this application for the selected Android user."; + else if (/device offline|device .*not found|no devices|device disconnected/i.test(text)) advice = " Reconnect the selected device and ensure adb reports it online."; + else if (/unauthorized/i.test(text)) advice = " Unlock the device and authorize USB debugging."; + else if (/run-as.*(?:usage|unknown option|invalid option)|usage: run-as|(?:unknown|invalid) option.*--user|exec.*--user/i.test(text)) { + advice = " This device must support run-as PACKAGE --user USER; database inspection never falls back to the owner profile."; + } + return new Error(`${context}: ${text || "adb returned no diagnostic"}.${advice}`); +} + +interface ProcessOptions { + adb: string; + args: string[]; + context: string; + signal?: AbortSignal; + input?: string | Readable; + timeout?: number; + maxOutput?: number; +} + +/** Owns all pipes and cancellation. stderr is a bounded diagnostic tail, not part of the JSON protocol. */ +function runAdb(options: ProcessOptions): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + if (options.signal?.aborted) { + if (typeof options.input !== "string") options.input?.destroy(); + reject(abortError()); + return promise; + } + const source = typeof options.input === "string" ? undefined : options.input; + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(options.adb, options.args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, shell: false }); + } catch (error) { + source?.destroy(); + reject(diagnostic(options.context, error instanceof Error ? error.message : String(error))); + return promise; + } + const stdin = child.stdin; + const output: Buffer[] = []; + let outputBytes = 0; + let inputBytes = 0; + let stderr = Buffer.alloc(0); + let finished = false; + let inputError: Error | undefined; + const stop = () => { + source?.unpipe(stdin); + source?.destroy(); + stdin.destroy(); + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill(); + const force = setTimeout(() => child.kill("SIGKILL"), 500); + force.unref(); + child.once("close", () => clearTimeout(force)); + }; + const finish = (error?: Error, value?: string) => { + if (finished) return; + finished = true; + clearTimeout(timer); + options.signal?.removeEventListener("abort", cancelled); + if (error) { + stop(); + reject(error); + } else { + source?.destroy(); + resolve(value ?? ""); + } + }; + const cancelled = () => finish(abortError()); + const timer = setTimeout(() => finish(new Error(`${options.context} timed out. Check the device connection or simplify the SQL; a timed-out write may already have committed.`)), options.timeout ?? REQUEST_TIMEOUT); + timer.unref(); + options.signal?.addEventListener("abort", cancelled, { once: true }); + child.once("error", (error) => finish(diagnostic(options.context, error.message))); + child.stdout.on("data", (chunk: Buffer) => { + if (finished) return; + outputBytes += chunk.length; + if (outputBytes > (options.maxOutput ?? MAX_OUTPUT)) { + finish(new Error(`${options.context} exceeded the bounded response size (${options.maxOutput ?? MAX_OUTPUT} bytes). Select fewer rows/columns or smaller values; data was not silently truncated.`)); + return; + } + output.push(chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + if (finished) return; + stderr = Buffer.concat([stderr, chunk]).subarray(-16 * 1024); + }); + // EPIPE often means run-as rejected the app. Wait for its useful diagnostic instead of masking it. + stdin.on("error", (error: Error) => { inputError = error; }); + child.once("close", (code, signal) => { + if (finished) return; + let stdout: string; + try { + stdout = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(output, outputBytes)); + } catch { + finish(new Error(`${options.context} returned invalid UTF-8 instead of the inspector protocol.`)); + return; + } + if (code !== 0 || inputError) { + finish(diagnostic(options.context, stderr.toString("utf8") || stdout || inputError?.message || `adb exited with ${signal ?? code}`)); + } else { + finish(undefined, stdout); + } + }); + if (source) { + source.on("error", (error) => finish(diagnostic("Reading the bundled database inspector", error.message))); + source.on("data", (chunk: Buffer) => { + inputBytes += chunk.length; + if (inputBytes > MAX_DEX) finish(new Error("The bundled database inspector exceeds the 16 MiB helper limit.")); + }); + source.pipe(stdin); + } else { + stdin.end(options.input); + } + // Closing the check/listener race also prevents an already-disposed session from retaining a process. + if (options.signal?.aborted) cancelled(); + return promise; +} + +async function currentUser(adb: string, device: string, signal?: AbortSignal): Promise { + const output = await runAdb({ adb, args: remoteArgs(device, ["am", "get-current-user"]), signal, context: "Resolving the current Android user", maxOutput: 64 * 1024 }); + const text = output.trim(); + const user = /^\d+$/.test(text) ? Number(text) : NaN; + if (!validUser(user)) { + throw diagnostic("Cannot safely resolve the current Android user; this device must support am get-current-user (no owner-profile fallback)", text); + } + return user; +} + +/** An installed application, and whether run-as lets the database explorer act as it. */ +export interface AndroidDatabasePackage { + packageName: string; + /** Android user whose package list and run-as access were checked; pin it when opening the session. */ + user: number; + /** run-as's refusal for this package and user, e.g. `run-as: package not debuggable: …`; absent when it can be opened. */ + refusal?: string; + /** The refusal is the one a non-debuggable (release) build meets. */ + notDebuggable?: boolean; +} + +/** Package names per probe command, keeping each adb command line far below old adbd limits. */ +const PROBE_COMMAND_BYTES = 2_048; + +/** + * Asks run-as itself, on the device and in one adb round trip per batch, whether it would run as each package: it + * is the authority on debuggability, so the picker never offers an application that only fails once opened. + */ +async function probeRunAs(adb: string, device: string, user: number, packages: readonly string[], signal?: AbortSignal): Promise> { + const script = `for p in "$@"; do if e=$(run-as "$p" --user ${user} true 2>&1 PROBE_COMMAND_BYTES && batches.at(-1)!.length) { + batches.push([]); + bytes = 0; + } + batches.at(-1)!.push(packageName); + bytes += packageName.length + 3; + } + const results = new Map(); + for (const batch of batches) { + if (!batch.length) continue; + const output = await runAdb({ adb, args: remoteArgs(device, ["sh", "-c", script, "sh", ...batch]), signal, context: "Checking which applications run-as can open", maxOutput: 1024 * 1024 }); + for (const line of output.split(/\r?\n/)) { + if (!line.trim()) continue; + const match = /^(ok|no) (\S+)(?: (.*))?$/.exec(line.trim()); + if (!match || !batch.includes(match[2]!) || results.has(match[2]!)) throw diagnostic("The run-as check returned an unsupported response", line); + results.set(match[2]!, match[1] === "ok" ? undefined : match[3]?.trim() || "run-as refused this application"); + } + if (batch.some((packageName) => !results.has(packageName))) throw new Error("The run-as check did not answer for every application. Reconnect the device and try again."); + } + return results; +} + +/** + * Third-party packages for one explicitly resolved foreground user, never a cross-profile package list, each with + * whether run-as can open it for that user. + */ +export async function listAndroidDatabasePackages(adb: string, device: string, signal?: AbortSignal): Promise { + validateConnection(adb, device); + checkAbort(signal); + const user = await currentUser(adb, device, signal); + const output = await runAdb({ adb, args: remoteArgs(device, ["cmd", "package", "list", "packages", "-3", "--user", String(user)]), signal, context: "Listing installed Android applications" }); + const packages = new Set(); + for (const line of output.split(/\r?\n/)) { + if (!line.trim()) continue; + const match = /^package:([^\s]+)$/.exec(line.trim()); + if (!match || !PACKAGE_NAME.test(match[1]!)) throw diagnostic("Android package discovery returned an unsupported response", line); + packages.add(match[1]!); + } + const sorted = [...packages].sort(); + const refusals = await probeRunAs(adb, device, user, sorted, signal); + if (await currentUser(adb, device, signal) !== user) throw new Error("The current Android user changed during package discovery. Select an application again."); + checkAbort(signal); + return sorted.map((packageName) => { + const refusal = refusals.get(packageName); + return refusal === undefined ? { packageName, user } : { packageName, user, refusal, ...(/not debuggable/i.test(refusal) ? { notDebuggable: true } : {}) }; + }); +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function textCell(value: unknown): value is string { + return typeof value === "string" && Buffer.byteLength(value, "utf8") <= MAX_CELL; +} + +function validCell(value: unknown): value is AndroidDatabaseCell { + if (value === null || textCell(value)) return true; + // A large SQLite REAL may be integer-valued in JS; only the device cursor can distinguish it from INT64. + if (typeof value === "number") return Number.isFinite(value); + if (!record(value)) return false; + if (value.type === "integer" && typeof value.value === "string" && /^-?(?:0|[1-9]\d{0,18})$/.test(value.value)) { + const integer = BigInt(value.value); + return integer >= -9223372036854775808n && integer <= 9223372036854775807n; + } + if (value.type === "blob" && typeof value.base64 === "string" && value.base64.length <= 4 * Math.ceil(MAX_CELL / 3) + && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.base64)) { + const bytes = Buffer.from(value.base64, "base64"); + return bytes.length <= MAX_CELL && bytes.toString("base64") === value.base64; + } + return false; +} + +/** The protocol the bundled helper speaks; a mismatch means the DEX and the extension disagree. */ +export const ANDROID_DATABASE_PROTOCOL = 2; + +/** + * Splits the helper's stdout into newline-terminated protocol lines. A line longer than `maxBytes` is never + * buffered whole: the helper either answers within its bound or the session is broken. + */ +export class AndroidDatabaseLineReader { + private pending: Buffer[] = []; + private pendingBytes = 0; + + constructor(private readonly maxBytes: number) {} + + push(chunk: Buffer): Buffer[] { + const lines: Buffer[] = []; + let start = 0; + for (let newline = chunk.indexOf(10, start); newline >= 0; newline = chunk.indexOf(10, start)) { + this.append(chunk.subarray(start, newline)); + lines.push(Buffer.concat(this.pending, this.pendingBytes)); + this.pending = []; + this.pendingBytes = 0; + start = newline + 1; + } + if (start < chunk.length) this.append(chunk.subarray(start)); + return lines; + } + + private append(part: Buffer): void { + this.pendingBytes += part.length; + if (this.pendingBytes > this.maxBytes) throw new Error(`exceeded the bounded response size (${this.maxBytes} bytes)`); + if (part.length) this.pending.push(part); + } +} + +/** One request line. Requests never contain a raw newline: JSON escapes it inside strings. */ +export function encodeAndroidDatabaseRequest(id: number, request: AndroidDatabaseRequest): string { + const line = `${JSON.stringify({ id, ...request })}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_INPUT) throw new Error("The encoded inspector request exceeds 512 KiB."); + return line; +} + +/** A response line as JSON. Undecodable output or another request's id means the stream is out of step. */ +function decodeLine(line: Buffer): Record { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(line); + } catch { + throw new ProtocolError("The Android database inspector returned invalid UTF-8 instead of its protocol."); + } + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new ProtocolError(diagnostic("The Android database inspector returned invalid JSON", text.slice(0, 4096)).message); + } + if (!record(value)) throw new ProtocolError("The Android database inspector returned an invalid response envelope."); + return value; +} + +/** The helper's first line: its protocol version and PID, which a cancelled request's cleanup signals. */ +export function parseAndroidDatabaseHello(line: Buffer): number { + const hello = decodeLine(line); + if (hello.ready !== true || hello.protocol !== ANDROID_DATABASE_PROTOCOL) { + throw new ProtocolError(`The Android database inspector speaks protocol ${String(hello.protocol)}, not ${ANDROID_DATABASE_PROTOCOL}. Reinstall the extension.`); + } + const pid = hello.pid; + if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid < 1) throw new ProtocolError("The Android database inspector announced an invalid process ID."); + return pid; +} + +/** Raised when the helper's stream cannot be trusted any more; the session restarts the helper. */ +class ProtocolError extends Error {} + +export function parseAndroidDatabaseResponse(line: Buffer, id: number, request: AndroidDatabaseRequest): Record { + const response = decodeLine(line); + if (typeof response.ok !== "boolean") throw new ProtocolError("The Android database inspector returned an invalid response envelope."); + if (response.id !== id) throw new ProtocolError(`The Android database inspector answered request ${String(response.id)} instead of ${id}.`); + if (!response.ok) { + if (!textCell(response.error) || !response.error) throw new Error("The Android database inspector returned an invalid error response."); + throw new Error(response.error); + } + let valid = false; + switch (request.action) { + case "databases": + valid = Array.isArray(response.databases) && response.databases.every((database) => { + try { validateDatabase(database); return true; } catch { return false; } + }); + break; + case "schema": + valid = Array.isArray(response.objects) && response.objects.every((object: unknown) => record(object) + && textCell(object.name) && typeof object.type === "string" && ["table", "view", "index", "trigger"].includes(object.type) + && (object.sql === null || textCell(object.sql))); + break; + case "query": { + const columns = response.columns; + valid = Array.isArray(columns) && columns.length <= MAX_COLUMNS && columns.every(textCell) + && typeof response.hasMore === "boolean" && Array.isArray(response.rows) && response.rows.length <= request.limit + && response.rows.every((row: unknown) => Array.isArray(row) && row.length === columns.length && row.every(validCell)); + break; + } + case "execute": + valid = typeof response.changes === "number" && Number.isSafeInteger(response.changes) && response.changes >= 0; + break; + } + if (!valid) throw new Error(`The Android database inspector returned a malformed or oversized ${request.action} response; no partial data was accepted.`); + return response; +} + +/** + * One running helper: `adb shell` → `run-as` → `app_process`, speaking newline-framed JSON over its pipes. Lines + * are consumed strictly in order; any failure ends the process, and the session starts another for the next request. + */ +class InspectorProcess { + private readonly child: ChildProcessWithoutNullStreams; + private readonly reader = new AndroidDatabaseLineReader(MAX_OUTPUT); + private readonly lines: Buffer[] = []; + private waiter: { resolve: (line: Buffer) => void; reject: (error: Error) => void } | undefined; + private stderr = Buffer.alloc(0); + private failure: Error | undefined; + private exited = false; + /** The helper's PID on the device, once it said hello. */ + pid: number | undefined; + + constructor(adb: string, args: string[], private readonly context: string) { + this.child = spawn(adb, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, shell: false }); + this.child.stdout.on("data", (chunk: Buffer) => { + if (this.failure) return; + let lines: Buffer[]; + try { + lines = this.reader.push(chunk); + } catch (error) { + this.fail(new Error(`${this.context} ${error instanceof Error ? error.message : String(error)}. Select fewer rows/columns or smaller values; data was not silently truncated.`)); + return; + } + for (const line of lines) { + const waiter = this.waiter; + this.waiter = undefined; + if (waiter) waiter.resolve(line); + else this.lines.push(line); + } + }); + this.child.stderr.on("data", (chunk: Buffer) => { + this.stderr = Buffer.concat([this.stderr, chunk]).subarray(-16 * 1024); + }); + // EPIPE often means run-as rejected the app. Wait for its useful diagnostic instead of masking it. + this.child.stdin.on("error", () => {}); + this.child.once("error", (error) => { + this.exited = true; + this.fail(diagnostic(this.context, error.message)); + }); + this.child.once("close", (code, signal) => { + this.exited = true; + this.fail(diagnostic(this.context, this.stderr.toString("utf8") || `the database inspector exited with ${signal ?? code}`)); + }); + } + + get alive(): boolean { + return this.failure === undefined; + } + + send(line: string): void { + if (this.failure) throw this.failure; + this.child.stdin.write(line); + } + + /** The next protocol line, or the reason the process can produce none. */ + next(signal: AbortSignal, timeout: number, timedOut: string): Promise { + const queued = this.lines.shift(); + if (queued) return Promise.resolve(queued); + if (this.failure) return Promise.reject(this.failure); + if (signal.aborted) return Promise.reject(abortError()); + const { promise, resolve, reject } = Promise.withResolvers(); + const settle = (error?: Error, line?: Buffer) => { + clearTimeout(timer); + signal.removeEventListener("abort", cancelled); + this.waiter = undefined; + if (error) reject(error); + else resolve(line!); + }; + const cancelled = () => settle(abortError()); + const timer = setTimeout(() => settle(new Error(timedOut)), timeout); + timer.unref(); + signal.addEventListener("abort", cancelled, { once: true }); + this.waiter = { resolve: (line) => settle(undefined, line), reject: (error) => settle(error) }; + return promise; + } + + /** + * End of input ends an idle helper by itself; the adb client is signalled after a grace period. Resolves once + * the client is gone, or after a bounded wait for one that never reports closing. + */ + async close(grace = 1_000): Promise { + this.fail(abortError()); + if (this.exited) return; + const { promise, resolve } = Promise.withResolvers(); + this.child.once("close", () => resolve()); + this.child.stdin.end(); + const terminate = setTimeout(() => this.child.kill(), grace); + const force = setTimeout(() => { this.child.kill("SIGKILL"); resolve(); }, grace + 1_000); + terminate.unref(); + force.unref(); + await promise; + clearTimeout(terminate); + clearTimeout(force); + } + + private fail(error: Error): void { + if (this.failure) return; + this.failure = error; + const waiter = this.waiter; + this.waiter = undefined; + waiter?.reject(error); + } +} + +/** + * Serialized, live SQLite access as one debuggable app and one pinned Android user. No database is copied. + * One helper process serves every request of the session, so a request costs a pipe round trip rather than a VM + * start; a helper that fails, times out or is cancelled is ended and the next request starts a fresh one. + * dispose aborts running/queued requests, ends the helper and makes a bounded best-effort attempt to remove only this + * session's DEX. Explicit writes do not notify in-process Room observers; cancellation cannot undo a committed write. + */ +export class AndroidDatabaseSession { + private readonly options: AndroidDatabaseOptions; + private readonly controller = new AbortController(); + private requests = new AbortController(); + private readonly token = randomBytes(16).toString("hex"); + private readonly directory = `cache/elide-database-${this.token}`; + private readonly helper = `${this.directory}/inspector.dex`; + private queue: Promise = Promise.resolve(); + private readonly pending = new Set<(error: Error) => void>(); + private user: number | undefined; + private installed = false; + private installationAttempted = false; + private process: InspectorProcess | undefined; + private nextId = 0; + + constructor(options: AndroidDatabaseOptions) { + validateConnection(options.adb, options.device); + if (typeof options.packageName !== "string" || !PACKAGE_NAME.test(options.packageName)) throw new Error("Enter an Android application ID, such as com.example.app."); + nonempty(options.helperDex, "bundled inspector.dex path"); + if (options.user !== undefined && !validUser(options.user)) throw new Error("Invalid Android user ID."); + this.options = { ...options }; + } + + async databases(): Promise { + const response = await this.request({ action: "databases" }); + return response.databases as string[]; + } + + async schema(database: string): Promise { + validateDatabase(database); + const response = await this.request({ action: "schema", database }); + return response.objects as AndroidDatabaseObject[]; + } + + async query(database: string, sql: string, limit = 100, offset = 0): Promise { + validateDatabase(database); + validateSql(sql); + if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error("Query page size must be an integer between 1 and 500."); + if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) throw new Error("Query offset must be an integer between 0 and 1,000,000."); + const response = await this.request({ action: "query", database, sql, limit, offset }); + return { columns: response.columns as string[], rows: response.rows as AndroidDatabaseCell[][], hasMore: response.hasMore as boolean }; + } + + async execute(database: string, sql: string): Promise<{ changes: number }> { + validateDatabase(database); + validateSql(sql); + const response = await this.request({ action: "execute", database, sql }); + return { changes: response.changes as number }; + } + + private runAs(script: string): string[] { + if (this.user === undefined) throw new Error("Resolve the Android user before using run-as."); + return remoteArgs(this.options.device, ["run-as", this.options.packageName, "--user", String(this.user), "sh", "-c", script]); + } + + private async prepare(signal: AbortSignal): Promise { + const { adb, device, helperDex } = this.options; + const current = await currentUser(adb, device, signal); + checkAbort(signal); + if (this.user === undefined) { + if (this.options.user !== undefined && this.options.user !== current) throw new Error(`Selected Android user ${this.options.user} is not the current user ${current}; select the current profile explicitly.`); + this.user = current; + } else if (current !== this.user) { + throw new Error(`The current Android user changed from ${this.user} to ${current}. Close this database session and select the application again.`); + } + if (this.installed) return; + const file = await stat(helperDex); + checkAbort(signal); + if (!file.isFile() || file.size === 0 || file.size > MAX_DEX) throw new Error("The bundled inspector.dex must be a nonempty regular file no larger than 16 MiB. Reinstall the extension."); + // mkdir and noclobber prevent replacing existing code. chmod occurs before writing via the already-open fd, + // as required by Android 14's dynamic-code-loading policy; the path is never made writable again. + // A directory left behind by an install whose file the system later trimmed is emptied, never followed. + const script = `set -eu; umask 077; d=${quote(this.directory)}; f=${quote(this.helper)}; if [ -L cache ] || [ -L "$d" ]; then printf '%s\\n' 'Refusing a symlinked app cache' >&2; exit 1; fi; mkdir -p cache; if [ -d "$d" ]; then rm -f "$f"; rmdir "$d"; fi; mkdir "$d"; trap 'rm -f "$f"; rmdir "$d"' EXIT; set -C; exec 3>"$f"; chmod 400 "$f"; cat >&3; exec 3>&-; trap - EXIT; printf '%s\\n' ${quote(`ELIDE_DATABASE_READY_${this.token}`)}`; + this.installationAttempted = true; + const output = await runAdb({ adb, args: this.runAs(script), signal, input: createReadStream(helperDex), context: `Installing the database inspector for ${this.options.packageName}`, maxOutput: 64 * 1024 }); + checkAbort(signal); + if (output.trim() !== `ELIDE_DATABASE_READY_${this.token}`) throw diagnostic(`Could not install the database inspector for ${this.options.packageName}`, output); + this.installed = true; + // Installation is a separate process: do not launch the first query after a profile switch during streaming. + if (await currentUser(adb, device, signal) !== this.user) throw new Error("The current Android user changed while installing the inspector. Close this database session and select the application again."); + checkAbort(signal); + } + + /** The running helper, or a new one once it said hello. */ + private async inspector(signal: AbortSignal, reinstall = true): Promise { + if (this.process?.alive) return this.process; + const context = `Inspecting ${this.options.packageName} databases`; + // Clearing the app's cache or storage deletes the helper; say so instead of letting app_process fail obscurely. + const started = new InspectorProcess(this.options.adb, + this.runAs(`[ -f ${quote(this.helper)} ] || { printf '%s\\n' ${HELPER_MISSING} >&2; exit 3; }; CLASSPATH=${quote(this.helper)} exec app_process / dev.elide.inspector.DatabaseInspector`), context); + this.process = started; + try { + started.pid = parseAndroidDatabaseHello(await started.next(signal, REQUEST_TIMEOUT, `${context} timed out while starting the helper. Check the device connection.`)); + } catch (error) { + this.stop(started, false); + if (!reinstall || !(error instanceof Error) || !error.message.includes(HELPER_MISSING)) throw error; + this.installed = false; + await this.prepare(signal); + checkAbort(signal); + return this.inspector(signal, false); + } + return started; + } + + /** + * Ends a helper that cannot be trusted with another request. One still executing SQL does not read its input, + * so it is signalled on the device; an idle one exits at end of input. + */ + private stop(helper: InspectorProcess, busy: boolean): Promise { + if (this.process === helper) this.process = undefined; + const closed = helper.close(); + const pid = helper.pid; + if (!busy || pid === undefined || this.user === undefined) return closed; + // Signal only the helper: a recycled PID that runs something else of the app is never touched. + const kill = runAdb({ + adb: this.options.adb, + args: this.runAs(`grep -q dev.elide.inspector.DatabaseInspector /proc/${pid}/cmdline 2>/dev/null && kill ${pid}; true`), + context: "Stopping the database inspector", timeout: 3_000, maxOutput: 16 * 1024, + }).then(() => undefined, () => undefined); + return Promise.all([closed, kill]).then(() => undefined); + } + + private request(request: AndroidDatabaseRequest): Promise> { + const { promise, resolve, reject } = Promise.withResolvers>(); + if (this.controller.signal.aborted) { + reject(abortError()); + return promise; + } + const signal = AbortSignal.any([this.controller.signal, this.requests.signal]); + this.pending.add(reject); + const operation = this.queue.then(async () => { + checkAbort(signal); + await this.prepare(signal); + checkAbort(signal); + const id = ++this.nextId; + const line = encodeAndroidDatabaseRequest(id, request); + const helper = await this.inspector(signal); + checkAbort(signal); + let response: Buffer; + try { + helper.send(line); + response = await helper.next(signal, REQUEST_TIMEOUT, + `Inspecting ${this.options.packageName} databases timed out. Check the device connection or simplify the SQL; a timed-out write may already have committed.`); + } catch (error) { + await this.stop(helper, true); + throw error; + } + try { + return parseAndroidDatabaseResponse(response, id, request); + } catch (error) { + if (error instanceof ProtocolError) await this.stop(helper, false); + throw error; + } + }); + this.queue = operation.then((response) => { + this.pending.delete(reject); + resolve(response); + }, (error: unknown) => { + this.pending.delete(reject); + reject(error); + }); + return promise; + } + + /** + * Abandons the running and queued requests and stops the helper; the session stays open and the next request + * starts a new helper. A write already sent may still commit. + */ + cancel(): void { + if (this.controller.signal.aborted) return; + this.requests.abort(); + this.requests = new AbortController(); + for (const reject of this.pending) reject(abortError()); + this.pending.clear(); + } + + dispose(): void { + if (this.controller.signal.aborted) return; + this.controller.abort(); + for (const reject of this.pending) reject(abortError()); + this.pending.clear(); + if (!this.installationAttempted || this.user === undefined) return; + // Cleanup deliberately targets the original user, even after a profile switch, and never executes the helper. + void this.queue.then(async () => { + const helper = this.process; + if (helper) await this.stop(helper, false); + try { + await runAdb({ adb: this.options.adb, args: this.runAs(`rm -f ${quote(this.helper)}; rmdir ${quote(this.directory)}`), context: "Removing the private database inspector", timeout: 3_000, maxOutput: 16 * 1024 }); + } catch { /* Device disconnects and app removal make remote cleanup best-effort. */ } + }); + } +} diff --git a/packages/core/src/androidLogcat.ts b/packages/core/src/androidLogcat.ts index 278a1c3..994aada 100644 --- a/packages/core/src/androidLogcat.ts +++ b/packages/core/src/androidLogcat.ts @@ -8,7 +8,7 @@ export interface AndroidLogcatOptions { /** Absolute path of the selected SDK's adb executable. */ adb: string; device: string; - /** Absent means all applications. UID filtering also includes the app's secondary processes. */ + /** Absent means all applications. UID filtering also includes the app's secondary processes; before Android 12 only its main process is followed. */ packageName?: string; priority: AndroidLogcatPriority; /** Case-insensitive literal substring, not a shell expression or regular expression. */ @@ -17,6 +17,12 @@ export interface AndroidLogcatOptions { const run = promisify(execFile); const PACKAGE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/; +/** AOSP logcat gained `--uid` in Android 12 and `--pid` in Android 7.0. */ +const LOGCAT_UID_API = 31; +const LOGCAT_PID_API = 24; + +/** What narrows logcat to one application: its UID, or on devices whose logcat lacks `--uid`, its main process. */ +export type AndroidLogcatScope = { uid: number } | { pid: number }; /** Match the exact package, not another package returned by Android's substring search. */ export function parseAndroidPackageUid(output: string, packageName: string): number | undefined { @@ -29,14 +35,145 @@ export function parseAndroidPackageUid(output: string, packageName: string): num return undefined; } -/** Arguments are passed directly to adb, never through the host shell. */ -export function androidLogcatArgs(options: AndroidLogcatOptions, uid?: number): string[] { +/** + * Arguments are passed directly to adb, never through the host shell. `since` is a `logcat -T` epoch time + * (`seconds.millis`): the stream starts there instead of replaying the device's whole buffer. + */ +export function androidLogcatArgs(options: AndroidLogcatOptions, scope?: AndroidLogcatScope, since?: string): string[] { if (!options.device) throw new Error("Select an Android device for logcat."); if (!ANDROID_LOGCAT_PRIORITIES.includes(options.priority)) throw new Error("Invalid logcat priority."); if (options.packageName && !PACKAGE_NAME.test(options.packageName)) throw new Error("Enter an Android application ID, such as com.example.app."); - if (options.packageName && uid === undefined) throw new Error("Resolve the application's installed UID before starting logcat."); - if (uid !== undefined && (!Number.isSafeInteger(uid) || uid < 0)) throw new Error("Invalid Android application UID."); - return ["-s", options.device, "logcat", "-v", "threadtime", ...(uid === undefined ? [] : [`--uid=${uid}`]), `*:${options.priority}`]; + if (options.packageName && scope === undefined) throw new Error("Resolve the application's installed UID or running process before starting logcat."); + if (scope && "uid" in scope && (!Number.isSafeInteger(scope.uid) || scope.uid < 0)) throw new Error("Invalid Android application UID."); + if (scope && "pid" in scope && (!Number.isSafeInteger(scope.pid) || scope.pid <= 0)) throw new Error("Invalid Android process ID."); + if (since !== undefined && !/^\d+\.\d{3}$/.test(since)) throw new Error("Invalid logcat start time."); + return ["-s", options.device, "logcat", "-v", "threadtime", ...(since === undefined ? [] : ["-T", since]), + ...(scope === undefined ? [] : "uid" in scope ? [`--uid=${scope.uid}`] : [`--pid=${scope.pid}`]), `*:${options.priority}`]; +} + +/** + * Device-side script printing the device clock and API level and, when `packageName` runs, its main process's `stat` + * line, so the stream can start at that process's start: everything the running app logged, nothing from earlier + * processes. + */ +export function androidLogcatClockScript(packageName?: string): string { + if (packageName !== undefined && !PACKAGE_NAME.test(packageName)) throw new Error("Enter an Android application ID, such as com.example.app."); + const clock = "echo \"now $(date +%s.%N)\"; echo \"uptime $(cat /proc/uptime)\"; echo \"tck $(getconf CLK_TCK 2>/dev/null)\"; echo \"sdk $(getprop ro.build.version.sdk)\""; + return packageName === undefined ? clock + : `${clock}; p=$(pidof -s ${packageName} 2>/dev/null); if [ -n "$p" ]; then echo "stat $(cat /proc/$p/stat 2>/dev/null)"; fi`; +} + +function scriptField(output: string, name: string): string | undefined { + return new RegExp(`^${name} (.*)$`, "m").exec(output)?.[1]?.trim(); +} + +/** + * How a device whose logcat lacks `--uid` (before Android 12, API 31) follows `packageName`: by its running main + * process, read from `androidLogcatClockScript(packageName)`'s output. Undefined on newer devices, or when the API + * level is unreadable: resolve the UID instead. Throws when the device cannot filter the application now. + */ +export function androidLogcatProcessFallback(output: string, packageName: string): { pid: number; sdk: number } | undefined { + const sdk = Number(scriptField(output, "sdk")); + if (!Number.isSafeInteger(sdk) || sdk <= 0 || sdk >= LOGCAT_UID_API) return undefined; + if (sdk < LOGCAT_PID_API) { + throw new Error(`Logcat on Android API ${sdk} cannot filter by application (needs API ${LOGCAT_PID_API}). Remove package: with Elide: Filter Android Logcat to stream all applications.`); + } + const pid = Number(/^(\d+) \(/.exec(scriptField(output, "stat") ?? "")?.[1]); + if (!Number.isSafeInteger(pid) || pid <= 0) { + throw new Error(`${packageName} is not running. Logcat on Android API ${sdk} can only follow a running app process (application filtering needs API ${LOGCAT_UID_API}): start the app, then restart logcat.`); + } + return { pid, sdk }; +} + +/** Where a new stream starts, as a `logcat -T` epoch time: the running app's process start, or the device's now. */ +export interface AndroidLogcatStart { + since: string; + from: "process" | "now"; +} + +/** A running stream. */ +export interface AndroidLogcatStarted { + /** Absent when the device clock was unreadable: the stream replays the device's buffer. */ + start?: AndroidLogcatStart; + /** Set when the device's logcat lacks `--uid`: the stream follows only this process of the application. */ + process?: { pid: number; sdk: number }; +} + +/** + * The `logcat -T` time to start from, read from `androidLogcatClockScript`'s output: the app process's start when it + * runs, else the device's current time. Undefined when the device clock is unreadable. + */ +export function androidLogcatStart(output: string): AndroidLogcatStart | undefined { + const now = Number(/^\d+(?:\.\d+)?/.exec(scriptField(output, "now") ?? "")?.[0]); + if (!Number.isFinite(now) || now <= 0) return undefined; + const seconds = (value: number) => (Math.floor(value * 1000) / 1000).toFixed(3); + const uptime = Number(scriptField(output, "uptime")?.split(/\s+/)[0]); + const tck = Number(scriptField(output, "tck") || 100); + // Field 22 (starttime, in clock ticks since boot) follows the parenthesized command name, which may contain spaces. + const stat = scriptField(output, "stat"); + const fields = stat && stat.includes(")") ? stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/) : []; + const started = Number(fields[19]); + if (Number.isFinite(uptime) && uptime > 0 && Number.isInteger(tck) && tck > 0 && Number.isSafeInteger(started) && started >= 0) { + const start = now - uptime + started / tck; + if (start > 0 && start <= now) return { since: seconds(start), from: "process" }; + } + return { since: seconds(now), from: "now" }; +} + +/** What **Filter Android Logcat** edits: one line of `package:` and `level:` terms plus literal text. */ +export type AndroidLogcatFilter = Pick; + +const PRIORITY_NAMES: Record = { + v: "V", verbose: "V", d: "D", debug: "D", i: "I", info: "I", w: "W", warn: "W", warning: "W", e: "E", error: "E", f: "F", fatal: "F", a: "F", assert: "F", +}; +const PRIORITY_KEYWORDS: Record = { V: "verbose", D: "debug", I: "info", W: "warn", E: "error", F: "fatal" }; +const FILTER_TERM = /^(package|app|level|priority):(.*)$/i; + +/** + * Parses `package:com.example.app level:warn some text`. `package:` (or `app:`) names the application, absent for + * all of them; `level:` (or `priority:`) the minimum priority, Info when absent; everything else is case-insensitive + * literal text, with `"…"` quoting text that has a colon term, quotes or significant spaces. + */ +export function parseAndroidLogcatFilter(input: string): AndroidLogcatFilter | { error: string } { + let packageName: string | undefined; + let priority: AndroidLogcatPriority | undefined; + const text: string[] = []; + const token = /"((?:[^"\\]|\\.)*)"|(\S+)/g; + for (let match = token.exec(input); match; match = token.exec(input)) { + if (match[1] !== undefined) { + if (match[1]) text.push(match[1].replace(/\\(.)/g, "$1")); + continue; + } + const word = match[2]!; + if (word.startsWith("\"")) return { error: "Close the quoted text with \"." }; + const term = FILTER_TERM.exec(word); + if (!term) { + text.push(word); + continue; + } + const key = term[1]!.toLowerCase(); + const value = term[2]!; + if (key === "package" || key === "app") { + if (packageName !== undefined) return { error: "Name one application: `package:` appears twice." }; + if (!PACKAGE_NAME.test(value)) return { error: `\`${word}\` is not an application ID such as package:com.example.app; leave package: out for all applications.` }; + packageName = value; + } else { + if (priority !== undefined) return { error: "Give one minimum priority: `level:` appears twice." }; + priority = Object.hasOwn(PRIORITY_NAMES, value.toLowerCase()) ? PRIORITY_NAMES[value.toLowerCase()] : undefined; + if (!priority) return { error: `\`${word}\` is not a priority; use level:verbose, debug, info, warn, error or fatal (or V/D/I/W/E/F).` }; + } + } + return { ...(packageName ? { packageName } : {}), priority: priority ?? "I", text: text.join(" ") }; +} + +/** The filter line `parseAndroidLogcatFilter` reads back as the same filter. */ +export function formatAndroidLogcatFilter(filter: AndroidLogcatFilter): string { + const terms = [...(filter.packageName ? [`package:${filter.packageName}`] : []), `level:${PRIORITY_KEYWORDS[filter.priority]}`]; + if (filter.text) { + const plain = filter.text.split(" ").every((word) => word && !word.includes("\"") && !FILTER_TERM.test(word)) && !/\s/.test(filter.text.replaceAll(" ", "")); + terms.push(plain ? filter.text : `"${filter.text.replace(/["\\]/g, "\\$&")}"`); + } + return terms.join(" "); } /** One `threadtime` record. Multi-line messages arrive as one record per line, each with the same header. */ @@ -120,7 +257,8 @@ export class AndroidLogcatSession { return this.process !== undefined || this.pending !== undefined; } - async start(options: AndroidLogcatOptions): Promise { + /** Resolves after spawning adb, not after device-side reader attachment; undefined once superseded. */ + async start(options: AndroidLogcatOptions): Promise { if (this.disposed) throw new Error("The logcat session has been disposed."); this.stop(); const generation = this.generation; @@ -128,26 +266,35 @@ export class AndroidLogcatSession { if (options.packageName && !PACKAGE_NAME.test(options.packageName)) throw new Error("Enter an Android application ID, such as com.example.app."); const controller = new AbortController(); this.pending = controller; - let uid: number | undefined; + let scope: AndroidLogcatScope | undefined; + const started: AndroidLogcatStarted = {}; try { + // Read-only probes need no shutdown grace; execFile otherwise leaves a SIGTERM-resistant adb alive. + const shell = (command: string[]) => run(options.adb, ["-s", options.device, "shell", ...command], + { encoding: "utf8", signal: controller.signal, timeout: 15_000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, windowsHide: true }); + // Without the device clock the stream still runs, as before, from the start of the buffer. + const probe = await shell([androidLogcatClockScript(options.packageName)]).then(({ stdout }) => stdout, + (error: unknown) => { if (controller.signal.aborted) throw error; return undefined; }); + started.start = probe === undefined ? undefined : androidLogcatStart(probe); if (options.packageName) { - const { stdout } = await run( - options.adb, - ["-s", options.device, "shell", "cmd", "package", "list", "packages", "--user", "current", "-U", options.packageName], - { encoding: "utf8", signal: controller.signal, timeout: 15_000, maxBuffer: 1024 * 1024, windowsHide: true }, - ); - uid = parseAndroidPackageUid(stdout, options.packageName); - if (uid === undefined) throw new Error(`Application ${options.packageName} is not installed for the current Android user, or package UID discovery is unsupported.`); + started.process = probe === undefined ? undefined : androidLogcatProcessFallback(probe, options.packageName); + if (started.process) scope = { pid: started.process.pid }; + else { + const { stdout } = await shell(["cmd", "package", "list", "packages", "--user", "current", "-U", options.packageName]); + const uid = parseAndroidPackageUid(stdout, options.packageName); + if (uid === undefined) throw new Error(`Application ${options.packageName} is not installed for the current Android user, or package UID discovery is unsupported.`); + scope = { uid }; + } } } catch (error) { - if (generation !== this.generation) return; + if (generation !== this.generation) return undefined; throw error; } finally { if (this.pending === controller) this.pending = undefined; } - if (generation !== this.generation) return; + if (generation !== this.generation) return undefined; - const child = spawn(options.adb, androidLogcatArgs(options, uid), { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); + const child = spawn(options.adb, androidLogcatArgs(options, scope, started.start?.since), { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); this.process = child; const text = options.text.toLocaleLowerCase(); let remainder = ""; @@ -183,6 +330,7 @@ export class AndroidLogcatSession { child.once("close", (code, signal) => { finish(code === 0 ? undefined : new Error(stderr.trim() || `adb logcat exited with ${signal ? `signal ${signal}` : `code ${code}`}.`)); }); + return started; } stop(): void { diff --git a/packages/core/src/androidTests.ts b/packages/core/src/androidTests.ts new file mode 100644 index 0000000..136963a --- /dev/null +++ b/packages/core/src/androidTests.ts @@ -0,0 +1,79 @@ +import type { ElideRecord } from "./buildOutput.js"; + +/** + * What `elide build android-test-` reports about the instrumented tests it ran. + * + * The target has no structured result form: it reports each finished test as a message of its own — `# + * passed` or `… skipped` as output, `… failed` as an error followed by an error carrying the stack trace — then a + * ` passed, failed, skipped` summary, and an error of its own when the run did not finish or ran nothing. + * Under `ELIDE_PROGRESS=json` each message is one record, which keeps a multi-line stack trace together. + */ + +export type AndroidTestOutcome = "passed" | "failed" | "skipped"; + +export interface AndroidTestResult { + /** Binary name of the test class: `sample.DeviceTest`, `sample.Outer$Inner`. */ + className: string; + /** Test method; Android allows spaces in a method name from API 30. */ + method: string; + outcome: AndroidTestOutcome; + /** Stack trace of a failed test, as the runner reported it. */ + detail?: string; +} + +/** `sample.DeviceTest#verifiesApi passed`: a class name holds neither `#` nor whitespace, a method name may. */ +const RESULT = /^(?:error:\s+)?([^\s#]+)#(.+) (passed|failed|skipped)$/; +/** `2 passed, 0 failed, 0 skipped`. */ +const SUMMARY = /^\d+ passed, \d+ failed, \d+ skipped$/; + +/** The test result one message reports, if it reports one. */ +export function parseAndroidTestResult(text: string): AndroidTestResult | undefined { + const match = RESULT.exec(text.trim()); + return match ? { className: match[1]!, method: match[2]!, outcome: match[3] as AndroidTestOutcome } : undefined; +} + +/** + * Reads the message records of one `android-test-` run. A failed test is reported once the message after + * it says whether it carries the stack trace; every other error the target reports is a problem of the run itself. + */ +export class AndroidTestResultParser { + private pending: AndroidTestResult | undefined; + + constructor( + /** Unqualified name of the test target, whose messages are the ones read. */ + private readonly task: string, + private readonly onResult: (result: AndroidTestResult) => void, + /** An error message that is not a test's: no device, no instrumentation, no test ran, the run did not finish. */ + private readonly onProblem: (text: string) => void, + ) {} + + accept(record: ElideRecord): void { + if (record.type !== "message" || !this.ownTask(record.task)) return; + const text = record.text.replace(/^error:\s+/, ""); + const result = parseAndroidTestResult(text); + if (!result && record.level === "error" && this.pending) { + this.onResult({ ...this.pending, detail: text }); + this.pending = undefined; + return; + } + this.flush(); + if (result?.outcome === "failed") this.pending = result; + else if (result) this.onResult(result); + else if (record.level === "error" && !SUMMARY.test(text.trim())) this.onProblem(text); + } + + /** The run ended: a failure still waiting for its stack trace is reported without one. */ + end(): void { + this.flush(); + } + + private flush(): void { + if (this.pending) this.onResult(this.pending); + this.pending = undefined; + } + + /** A workspace qualifies the task with its project (`app:android-test-app`); a record naming none is the build's. */ + private ownTask(task: string | undefined): boolean { + return task === undefined || task === this.task || task.endsWith(`:${this.task}`); + } +} diff --git a/packages/core/src/androidWireless.ts b/packages/core/src/androidWireless.ts new file mode 100644 index 0000000..d1b232b --- /dev/null +++ b/packages/core/src/androidWireless.ts @@ -0,0 +1,211 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { connect as connectTcp, isIP } from "node:net"; + +export interface AndroidWirelessService { + name: string; + type: "pairing" | "connect"; + endpoint: string; +} + +/** Requires the distinct pairing/connection port shown by Wireless debugging, never adb's default port. */ +export function validateAndroidWirelessEndpoint(value: string): string | undefined { + const invalid = "Enter a hostname, IPv4 address, or bracketed IPv6 address with an explicit port (1–65535)."; + if (typeof value !== "string" || /[\s\u0000-\u001f\u007f]/.test(value)) return invalid; + const match = /^(\[[^\]]+\]|[^:]+):([0-9]{1,5})$/.exec(value); + if (!match || Number(match[2]) < 1 || Number(match[2]) > 65535) return invalid; + const host = match[1]!; + if (host.startsWith("[")) return isIP(host.slice(1, -1)) === 6 ? undefined : invalid; + if (/^[0-9.]+$/.test(host)) return isIP(host) === 4 ? undefined : invalid; + const hostname = host.endsWith(".") ? host.slice(0, -1) : host; + if (hostname.length > 253 || !hostname.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label))) return invalid; + return undefined; +} + +/** Hex is alphanumeric, cryptographically random, and needs no escaping in the Android WIFI payload. */ +export function createAndroidWirelessQr(): { name: string; secret: string; payload: string } { + const name = `studio-${randomBytes(16).toString("hex")}`; + const secret = randomBytes(16).toString("hex"); + return { name, secret, payload: `WIFI:T:ADB;S:${name};P:${secret};;` }; +} + +function abortError(): Error { + const error = new Error("Android wireless operation cancelled."); + error.name = "AbortError"; + return error; +} + +const MAX_OUTPUT = 256 * 1024; +const DISCOVERY_TIMEOUT = 10_000; +const PAIR_TIMEOUT = 30_000; +const CONNECT_TIMEOUT = 15_000; +const REACH_TIMEOUT = 5_000; + +/** + * What adb printed, for an error message: its pairing prompt dropped and the pairing code masked wherever adb (or a + * wrapper) echoed it, so the code never reaches the UI. + */ +export function androidWirelessDiagnostic(output: string, secret?: string): string { + let text = output.replace(/Enter pairing code:\s*/gi, ""); + if (secret) text = text.replaceAll(secret, "******"); + return text.replace(/\s+/g, " ").trim().slice(0, 2048); +} + +interface AdbOptions { + /** Written to stdin, never to argv, the environment or an error. */ + secret?: string; + timeout: number; + timedOut: string; +} + +function runAdb(adb: string, args: string[], signal: AbortSignal, options: AdbOptions): Promise { + if (signal.aborted) return Promise.reject(abortError()); + if (!adb || /[\u0000-\u001f\u007f]/.test(adb)) return Promise.reject(new Error("Invalid adb executable path.")); + const { promise, resolve, reject } = Promise.withResolvers(); + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(adb, args, { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }); + } catch { + return Promise.reject(new Error("Could not run adb for wireless debugging. Check the Android SDK Platform-Tools installation.")); + } + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let bytes = 0; + let settled = false; + let inputFailed = false; + const finish = (error?: Error, value = "") => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal.removeEventListener("abort", cancelled); + if (error) { + child.stdin.destroy(); + if (child.exitCode === null && child.signalCode === null) { + child.kill(); + const force = setTimeout(() => child.kill("SIGKILL"), 500); + force.unref(); + child.once("close", () => clearTimeout(force)); + } + reject(error); + } else resolve(value); + }; + const cancelled = () => finish(abortError()); + const timer = setTimeout(() => finish(new Error(options.timedOut)), options.timeout); + timer.unref(); + signal.addEventListener("abort", cancelled, { once: true }); + const collect = (chunks: Buffer[], chunk: Buffer) => { + if (settled) return; + bytes += chunk.length; + if (bytes > MAX_OUTPUT) finish(new Error("adb wireless response exceeded the size limit.")); + else chunks.push(chunk); + }; + child.stdout.on("data", (chunk: Buffer) => collect(stdout, chunk)); + child.stderr.on("data", (chunk: Buffer) => collect(stderr, chunk)); + child.stdin.on("error", () => { inputFailed = true; }); + child.once("error", () => finish(new Error("Could not run adb for wireless debugging. Check the Android SDK Platform-Tools installation."))); + child.once("close", (code) => { + if (settled) return; + const output = Buffer.concat(stdout).toString("utf8"); + const diagnostic = Buffer.concat(stderr).toString("utf8"); + // adb connect can exit successfully while reporting a connection failure. + const failure = /(?:^|\n|:\s)\s*(?:adb:\s*)?(?:error\b|failed\b|failure\b|cannot\b|can't\b|could not\b|unable\b|unknown command\b|usage:)/i; + if (code !== 0 || inputFailed || failure.test(output) || failure.test(diagnostic)) { + const detail = androidWirelessDiagnostic(`${diagnostic}\n${output}`, options.secret); + finish(new Error(`adb ${args.join(" ")} failed${detail ? `: ${detail}` : ` with exit code ${code}`}`)); + } else finish(undefined, output); + }); + child.stdin.end(options.secret === undefined ? undefined : `${options.secret}\n`); + if (signal.aborted) cancelled(); + return promise; +} + +/** + * Whether anything accepts a TCP connection at `endpoint`, within a few seconds: adb itself waits far longer for + * an unreachable address, and only says "failed" when it gives up. + */ +function reachable(endpoint: string, signal: AbortSignal): Promise { + const match = /^\[?(.*?)\]?:(\d+)$/.exec(endpoint)!; + const host = match[1]!; + const port = Number(match[2]); + const { promise, resolve, reject } = Promise.withResolvers(); + if (signal.aborted) return Promise.reject(abortError()); + const socket = connectTcp({ host, port }); + const finish = (error?: Error) => { + clearTimeout(timer); + signal.removeEventListener("abort", cancelled); + socket.destroy(); + if (error) reject(error); + else resolve(); + }; + const cancelled = () => finish(abortError()); + const timer = setTimeout(() => finish(new Error(`Nothing answered at ${endpoint} within ${REACH_TIMEOUT / 1000} s. Check that the device is on the same network as this computer (not a guest network with client isolation, or behind a VPN or firewall) and that the address matches the main Wireless debugging screen.`)), REACH_TIMEOUT); + signal.addEventListener("abort", cancelled, { once: true }); + socket.once("connect", () => finish()); + socket.once("error", (error: NodeJS.ErrnoException) => { + const reason = error.code === "ECONNREFUSED" ? `${endpoint} refused the connection: Wireless debugging is off, or its port changed (it changes whenever Wireless debugging restarts). Use the IP address & port on the main Wireless debugging screen.` + : error.code === "ENOTFOUND" || error.code === "EAI_AGAIN" ? `Cannot resolve ${host}. Enter the device's IP address instead.` + : error.code === "EHOSTUNREACH" || error.code === "ENETUNREACH" ? `${host} is unreachable from this computer (${error.code}). Check that the device is on the same network.` + : `Cannot connect to ${endpoint}: ${error.message}.`; + finish(new Error(reason)); + }); + return promise; +} + +export async function discoverAndroidWireless(adb: string, signal: AbortSignal): Promise { + const output = await runAdb(adb, ["mdns", "services"], signal, { + timeout: DISCOVERY_TIMEOUT, + timedOut: `adb mdns services did not answer within ${DISCOVERY_TIMEOUT / 1000} s. Restart the adb server (adb kill-server), or enter the address manually.`, + }); + if (!/^List of discovered mdns services\s*$/im.test(output)) { + throw new Error("adb did not return an mDNS service list. Update Android SDK Platform-Tools or enter the endpoint manually."); + } + const services: AndroidWirelessService[] = []; + const seen = new Set(); + for (const line of output.split(/\r?\n/)) { + const match = /^([^\s\u0000-\u001f\u007f]+)\s+_adb-tls-(pairing|connect)\._tcp\.?\s+(\S+)\s*$/.exec(line.trim()); + if (!match) continue; + // DNS may print a terminating dot. Never prefix-match names when selecting a QR service. + const name = match[1]!.replace(/\.$/, ""); + const type = match[2] as AndroidWirelessService["type"]; + const endpoint = match[3]!; + if (!name || validateAndroidWirelessEndpoint(endpoint)) continue; + const key = JSON.stringify([name, type, endpoint]); + if (seen.has(key)) continue; + seen.add(key); + services.push({ name, type, endpoint }); + } + return services; +} + +export async function pairAndroidWireless(adb: string, endpoint: string, secret: string, signal: AbortSignal): Promise { + const invalid = validateAndroidWirelessEndpoint(endpoint); + if (invalid) throw new Error(invalid); + if (!/^[a-z0-9]{6,128}$/i.test(secret)) throw new Error("Enter the six-digit pairing code or use a newly generated QR code."); + // The pairing service is not probed first: a bare connection could count as a failed pairing attempt. + const advice = "Check the pairing code and the pairing port in ‘Pair device with pairing code’ (both change each time that dialog opens), and that the device is on the same network."; + let output: string; + try { + output = await runAdb(adb, ["pair", endpoint], signal, { secret, timeout: PAIR_TIMEOUT, timedOut: `adb pair ${endpoint} did not finish within ${PAIR_TIMEOUT / 1000} s. ${advice}` }); + } catch (error) { + if (error instanceof Error && error.name !== "AbortError" && !error.message.includes("did not finish")) error.message = `${error.message}. ${advice}`; + throw error; + } + if (!/\bSuccessfully paired to\s+\S+/i.test(output)) { + const detail = androidWirelessDiagnostic(output, secret); + throw new Error(`adb did not confirm pairing${detail ? ` (it said: ${detail})` : ""}. ${advice}`); + } +} + +export async function connectAndroidWireless(adb: string, endpoint: string, signal: AbortSignal): Promise { + const invalid = validateAndroidWirelessEndpoint(endpoint); + if (invalid) throw new Error(invalid); + await reachable(endpoint, signal); + const output = await runAdb(adb, ["connect", endpoint], signal, { + timeout: CONNECT_TIMEOUT, + timedOut: `adb connect ${endpoint} did not finish within ${CONNECT_TIMEOUT / 1000} s although the address answers. Check that the device is paired with this computer, then retry.`, + }); + if (!/^(?:already )?connected to\s+\S+/im.test(output)) { + const detail = androidWirelessDiagnostic(output); + throw new Error(`adb did not confirm the connection${detail ? ` (it said: ${detail})` : ""}. Use the connection port from the main Wireless debugging screen, not the pairing port.`); + } +} diff --git a/packages/core/src/composeInputs.ts b/packages/core/src/composeInputs.ts index 2ed6815..77ee8a1 100644 --- a/packages/core/src/composeInputs.ts +++ b/packages/core/src/composeInputs.ts @@ -58,6 +58,18 @@ export function composePreviewBuildTargets(model: ProjectModel, target: AndroidT return [...new Set([`${scope}${androidRTask(target.name)}`, ...modules.flatMap(({ module }) => module.compilationTargets!)])]; } +/** + * Compose preview annotations in Kotlin source text: annotations whose simple name contains `Preview`, qualified or + * not (`@Preview`, `@PreviewLightDark`, multipreview annotations such as `@DevicePreviews`), outside comments and + * string literals. A text check that never builds, so a multipreview annotation whose name lacks `Preview` is not + * recognized. Zero: the file declares none. + */ +export function composePreviewAnnotations(text: string): number { + // One left-to-right scan: whichever of a comment or literal starts first hides the other's delimiters. + const code = text.replace(/\/\*[\s\S]*?(?:\*\/|$)|\/\/[^\n]*|"""[\s\S]*?(?:"""|$)|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/g, " "); + return code.match(/@(?:[A-Za-z_]\w*\.)*\w*Preview/g)?.length ?? 0; +} + /** * Android applications that can supply the resource context for previews of `file`: those whose preview closure * (packaged source sets and their workspace runtime dependencies) compiles it. The nearest consumer comes first, by @@ -127,9 +139,11 @@ export async function composePreviewInputs(model: ProjectModel, target: AndroidT const runtimeEntries = modules.flatMap(({ module }) => module.runtimeClassPath!); // Each member resolves its own graph, so one library can arrive at several versions. Render what ships: Elide // dexes the newest, a tie going to the consumer's own copy, and never an empty stand-in without its provider. + // Library roots are normalized (forward slashes); so must the consumer's be, or Windows never sees its own copy. + const ownRoot = normalizePath(path.resolve(model.root)); const libraries = runtimeEntries.flatMap((entry) => { const library = publishedLibrary(entry); - return library ? [{ entry, ...library, own: library.root === model.root }] : []; + return library ? [{ entry, ...library, own: library.root === ownRoot }] : []; }); const shippedJars = new Set(libraries.filter((library) => !library.archive).map((library) => library.name)); const candidates = Map.groupBy(libraries, (library) => library.identity); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d80462f..3c2278e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export { export * from "./android.js"; export * from "./androidBinaryXml.js"; export * from "./androidLogcat.js"; +export * from "./androidTests.js"; export * from "./composeInputs.js"; export * from "./artifacts.js"; export * from "./buildTasks.js"; @@ -51,3 +52,5 @@ export * from "./workspace.js"; export * from "./timeline.js"; export * from "./buildOutput.js"; export * from "./commandTimeline.js"; +export * from "./androidDatabase.js"; +export * from "./androidWireless.js"; diff --git a/packages/core/src/manifest.ts b/packages/core/src/manifest.ts index 63f73d9..ae6c2da 100644 --- a/packages/core/src/manifest.ts +++ b/packages/core/src/manifest.ts @@ -110,6 +110,8 @@ export interface AndroidArtifact { jniLibs: string[]; /** `minify`: the artifact is shrunk and obfuscated by R8, which also publishes an obfuscation mapping. */ minify: boolean; + /** An APK declaring `instrumentation`: a test APK whose instrumented tests `android-test-` runs. */ + instrumentation?: boolean; /** Dynamic feature artifact keys included by an app bundle. */ features?: string[]; /** Base app bundle artifact for a dynamic feature. */ @@ -256,6 +258,7 @@ function decodeAndroidTargets(v: unknown): Record { assets: strings(artifact.assets), jniLibs: strings(artifact.jniLibs), minify: artifact.minify === true, + ...(kind === "apk" && isObject(artifact.instrumentation) ? { instrumentation: true } : {}), ...(kind === "aab" ? { features: strings(artifact.features) } : {}), ...(kind === "feature" ? { ...(typeof artifact.base === "string" ? { base: artifact.base } : {}), diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index b495792..ca00106 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -5,12 +5,15 @@ import { DEFAULT_AVD_NAME, androidArtifactFile, androidContentDirs, + androidDeviceTargets, androidMappingFile, androidRClasses, androidRClassesArtifact, androidRTask, androidSdkRoot, explodedAndroidLibrary, + readAndroidManifest, + type AndroidDeviceTargets, } from "./android.js"; import { buildTasksOf, unqualifiedTaskName, type BuildTaskInfo } from "./buildTasks.js"; import { ElideCli, MANIFEST_NAME, isLockfileCurrent, type ClasspathQuery, type RunOptions } from "./elide.js"; @@ -125,6 +128,8 @@ export interface AndroidTargetModel { base?: string; delivery?: "install-time" | "on-demand"; featureDependencies?: string[]; + /** What Elide's task listing offers for an APK — Run, Debug, instrumented tests; absent for every other kind. */ + device?: AndroidDeviceTargets; } export interface BuildModelOptions { @@ -704,6 +709,7 @@ function assembleProject(project: ProjectSpec, answers: ProjectAnswers, ctx: Bui ...(declared.base ? { base: declared.base } : {}), ...(declared.delivery ? { delivery: declared.delivery } : {}), ...(declared.featureDependencies ? { featureDependencies: declared.featureDependencies } : {}), + ...(declared.kind === "apk" ? { device: apkDeviceTargets(buildTasksOf(ctx.previewTasks ?? [], name), key, declared.instrumentation, path.resolve(root, declared.manifestFile)) } : {}), })), }; } @@ -728,6 +734,18 @@ function assembleProject(project: ProjectSpec, answers: ProjectAnswers, ctx: Bui }; } +/** + * Device targets of an APK, from the targets Elide lists and what its source manifest declares: an `` + * element makes it a test APK as Pkl `instrumentation` does, and `android:debuggable="false"` withdraws Debug. + */ +function apkDeviceTargets(tasks: readonly BuildTaskInfo[], artifact: string, instrumentation: boolean | undefined, manifestFile: string): AndroidDeviceTargets { + const facts = readAndroidManifest(manifestFile); + return androidDeviceTargets(tasks, artifact, { + instrumentation: instrumentation === true || facts?.instrumentation !== undefined, + ...(facts?.debuggable !== undefined ? { debuggable: facts.debuggable } : {}), + }); +} + /** * Wire every artifact one project of a workspace consumes from another as dependencies on the modules packaged into * it, exported so that a module depending on the consumer sees them too, the way the consumer's JAR carries them. diff --git a/packages/core/test/android.test.ts b/packages/core/test/android.test.ts index 78a6d16..16a403a 100644 --- a/packages/core/test/android.test.ts +++ b/packages/core/test/android.test.ts @@ -1,26 +1,38 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import { androidArtifactFile, androidClassesJar, androidContentDirs, androidDebugTask, + androidDeviceTargets, + androidIncompatibleUpdatePackage, + androidKeystoreHintArtifact, androidKeystoreTask, androidMappingFile, + androidMergedManifest, androidPlatformApiLevel, androidRClasses, androidRClassesArtifact, androidRunTask, androidSdkRoot, + currentAndroidDeviceTargets, decodeManifest, explodedAndroidLibrary, isAndroidAab, isAndroidAar, isAndroidApk, isAndroidArtifact, + isAndroidDeviceTask, parseAdbDevices, + parseAndroidManifest, parseManifestArtifacts, + readAndroidManifest, + stagedAndroidDebuggable, type AndroidArtifact, + type BuildTaskInfo, } from "../src/index.js"; /** The single-module APK fixture of Elide's own smoke suite, trimmed to what the parser reads. */ @@ -169,6 +181,143 @@ describe("android build targets", () => { expect(androidKeystoreTask("app")).toBe("android-keystore-app"); expect(androidDebugTask("app")).toBe("android-debug-app"); }); + + const task = (name: string, options: string[] = [], project?: string): BuildTaskInfo => ({ + name: project ? `${project}:${name}` : name, + description: "", + options: options.map((option) => ({ option, description: "" })), + ...(project ? { project } : {}), + }); + const deviceOptions = ["--device", "--window", "--install-sdk"]; + + test("an APK offers Run, Debug and instrumented tests only as far as the listing names their targets", () => { + const signed = [task("app"), task("android-run-app", deviceOptions), task("android-debug-app", ["--debug", ...deviceOptions]), + task("android-test-app", ["--tests", ...deviceOptions])]; + expect(androidDeviceTargets(signed, "app")).toEqual({ runnable: true, debuggable: true, instrumented: false }); + expect(androidDeviceTargets(signed, "app", { instrumentation: true })).toEqual({ runnable: true, debuggable: true, instrumented: true }); + // A manifest setting `android:debuggable="false"` cannot be debugged, whatever an older Elide lists. + expect(androidDeviceTargets(signed, "app", { debuggable: false })).toEqual({ runnable: true, debuggable: false, instrumented: false }); + expect(androidDeviceTargets(signed, "app", { debuggable: true })).toEqual({ runnable: true, debuggable: true, instrumented: false }); + // An APK Elide cannot install is listed with its packaging targets only. + expect(androidDeviceTargets([task("app"), task("android-sign-app")], "app", { instrumentation: true, debuggable: true })) + .toEqual({ runnable: false, debuggable: false, instrumented: false }); + // Another APK's targets say nothing about this one. + expect(androidDeviceTargets([task("android-run-other", deviceOptions)], "app")).toEqual({ runnable: false, debuggable: false, instrumented: false }); + }); + + test("a workspace listing names a member's device targets with the member's scope", () => { + const listing = [task("android-run-app", deviceOptions, "mobile"), task("android-test-app", deviceOptions, "mobile")]; + expect(androidDeviceTargets(listing, "app", { instrumentation: true })).toEqual({ runnable: true, debuggable: false, instrumented: true }); + }); + + test("a target taking --device installs onto one device; stopping an emulator does not deploy", () => { + expect(isAndroidDeviceTask(task("android-test-app", ["--tests", ...deviceOptions]))).toBe(true); + expect(isAndroidDeviceTask(task("android-run-emulator-app", deviceOptions))).toBe(true); + expect(isAndroidDeviceTask(task("android-emulator-stop", ["--device"]))).toBe(false); + expect(isAndroidDeviceTask(task("android-emulator-stop", ["--device"], "mobile"))).toBe(false); + expect(isAndroidDeviceTask(task("android-emulator", ["--window", "--install-sdk"]))).toBe(false); + expect(isAndroidDeviceTask(task("compile-kotlin-main"))).toBe(false); + }); + + test("only an APK declaring instrumentation is a test APK", () => { + const manifest = decodeManifest(JSON.stringify({ + name: "tests", + android: { compileSdk: 36 }, + artifacts: { + tests: { "@type": "elide.android.AndroidApk", instrumentation: {} }, + app: { "@type": "elide.android.AndroidApk" }, + ui: { "@type": "elide.android.AndroidAar", instrumentation: {} }, + }, + })); + expect(manifest.androidTargets.tests?.instrumentation).toBe(true); + expect(manifest.androidTargets.app?.instrumentation).toBeUndefined(); + expect(manifest.androidTargets.ui?.instrumentation).toBeUndefined(); + }); + + test("a manifest declares instrumentation and debuggability the way aapt2 reads them", () => { + // A project's `apps/android-tests`: a self-instrumenting APK whose Pkl declares no `instrumentation`. + const tests = ` + + + + + + +`; + expect(parseAndroidManifest(tests)).toEqual({ + instrumentation: { runner: "androidx.test.runner.AndroidJUnitRunner", targetPackage: "app.acme.android.test" }, + }); + expect(parseAndroidManifest(``)).toEqual({ debuggable: true }); + expect(parseAndroidManifest(``)).toEqual({ debuggable: false }); + // A placeholder is resolved while merging: the source manifest says nothing yet. + expect(parseAndroidManifest(``)).toEqual({}); + expect(parseAndroidManifest(``)).toEqual({}); + expect(parseAndroidManifest(``)).toEqual({ instrumentation: {} }); + expect(readAndroidManifest("/nonexistent/AndroidManifest.xml")).toBeUndefined(); + }); + + test("an Elide hint names the artifact whose keystore to create", () => { + expect(androidKeystoreHintArtifact("Create one with: elide build android-keystore-app")).toBe("app"); + expect(androidKeystoreHintArtifact("Create one with: elide build mobile:android-keystore-acme-dev.")).toBe("acme-dev"); + expect(androidKeystoreHintArtifact("Set android:debuggable=\"true\"")).toBeUndefined(); + }); + + test("a refused update names the application whose installed copy is signed with another key", () => { + expect(androidIncompatibleUpdatePackage("adb failed with exit code 1: adb: failed to install /work/.dev/artifacts/app.apk: " + + "Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: Existing package sample.app signatures do not match newer version; ignoring!]")).toBe("sample.app"); + expect(androidIncompatibleUpdatePackage("Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: Package com.example.app_2 signatures do not match the previously installed version; ignoring!]")) + .toBe("com.example.app_2"); + expect(androidIncompatibleUpdatePackage("Failure [INSTALL_FAILED_VERSION_DOWNGRADE: Package sample.app new version 1 < old version 2]")).toBeUndefined(); + }); +}); + +describe("the debuggability of the latest build", () => { + const dirs: string[] = []; + afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + }); + const project = () => { + const root = mkdtempSync(path.join(tmpdir(), "elide-android-stage-")); + dirs.push(root); + const source = path.join(root, "src", "main", "AndroidManifest.xml"); + mkdirSync(path.dirname(source), { recursive: true }); + writeFileSync(source, ``); + const stage = (application: string, at: number) => { + const merged = androidMergedManifest(root, "dev"); + mkdirSync(path.dirname(merged), { recursive: true }); + writeFileSync(merged, `${application}`); + utimesSync(merged, at, at); + }; + utimesSync(source, 1_000, 1_000); + return { root, source, stage }; + }; + const listed = { runnable: true, debuggable: true, instrumented: false }; + + test("a merged manifest without android:debuggable=\"true\" withdraws Debug until the source manifest changes", () => { + const { root, source, stage } = project(); + const target = { name: "dev", manifestFile: source, device: listed }; + expect(androidMergedManifest(root, "dev")).toBe(path.join(root, ".dev", "android", "dev", "AndroidManifest.xml")); + // Never built: the listing decides. + expect(stagedAndroidDebuggable(root, "dev", source)).toBeUndefined(); + expect(currentAndroidDeviceTargets(root, target)).toEqual(listed); + // Built by an Elide that injects nothing for `release = false`. + stage(``, 2_000); + expect(stagedAndroidDebuggable(root, "dev", source)).toBe(false); + expect(currentAndroidDeviceTargets(root, target)).toEqual({ ...listed, debuggable: false }); + // The user edited the source manifest since: the next build decides. + utimesSync(source, 3_000, 3_000); + expect(currentAndroidDeviceTargets(root, target)).toEqual(listed); + // Built by an Elide that injects it. + stage(``, 4_000); + expect(stagedAndroidDebuggable(root, "dev", source)).toBe(true); + expect(currentAndroidDeviceTargets(root, target)).toEqual(listed); + // Nothing to withdraw from an APK Elide lists no debug target for. + expect(currentAndroidDeviceTargets(root, { ...target, device: { ...listed, debuggable: false } })).toEqual({ ...listed, debuggable: false }); + expect(currentAndroidDeviceTargets(root, { name: "dev", manifestFile: source })).toBeUndefined(); + }); }); describe("android output paths", () => { diff --git a/packages/core/test/androidDatabase.test.ts b/packages/core/test/androidDatabase.test.ts new file mode 100644 index 0000000..db7a4df --- /dev/null +++ b/packages/core/test/androidDatabase.test.ts @@ -0,0 +1,439 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, unwatchFile, watchFile, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + AndroidDatabaseLineReader, AndroidDatabaseSession, encodeAndroidDatabaseRequest, listAndroidDatabasePackages, parseAndroidDatabaseHello, + parseAndroidDatabaseResponse, type AndroidDatabaseOptions, +} from "../src/androidDatabase.js"; + +const roots: string[] = []; +const sessions: AndroidDatabaseSession[] = []; + +afterEach(async () => { + for (const session of sessions.splice(0)) session.dispose(); + for (const root of roots.splice(0)) { + const calls = path.join(root, "calls"); + if (existsSync(calls)) { + // One cleanup per session, however often it reinstalled its helper. + const installs = new Set(readFileSync(calls, "utf8").trim().split("\n").map((line) => /ELIDE_DATABASE_READY_[a-f0-9]+/.exec((JSON.parse(line) as string[]).at(-1) ?? "")?.[0]).filter(Boolean)).size; + if (installs) await waitForFile(path.join(root, "cleaned"), installs); + } + rmSync(root, { recursive: true, force: true }); + } +}); + +interface Fixture { + root: string; + options: AndroidDatabaseOptions; + calls(): string[][]; + requests(): { action: string; database?: string; sql?: string }[]; + session(overrides?: Partial): AndroidDatabaseSession; + response(value: unknown): void; +} + +/** + * Real child-process pipes and signals, with POSIX parsing of adb's joined remote argv and the actual installer + * script. Only Android framework commands/the inspector are simulated; this is not a SQL evaluator. + */ +function fixture(): Fixture { + const root = mkdtempSync(path.join(tmpdir(), "elide database 'quoted'-")); + roots.push(root); + mkdirSync(path.join(root, "app")); + const adb = path.join(root, "fake adb.cjs"); + const helperDex = path.join(root, "inspector.dex"); + writeFileSync(helperDex, Buffer.from([0x64, 0x65, 0x78, 0x0a, 0, 255, 13, 10, 128])); + writeFileSync(path.join(root, "user"), "10"); + writeFileSync(adb, String.raw`#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const { spawn, spawnSync } = require("node:child_process"); +const root = __dirname; +const args = process.argv.slice(2); +fs.appendFileSync(path.join(root, "calls"), JSON.stringify(args) + "\n"); +if (args[1] === "offline") { console.error("error: device offline"); process.exit(1); } +if (args[2] !== "shell" || args[3] !== "-T") throw new Error("Binary stdin needs shell-v2 without a PTY"); +const decoded = spawnSync("/bin/sh", ["-c", "set -- " + args.slice(4).join(" ") + "; printf '%s\\0' \"$@\""], { encoding: "utf8", cwd: root }); +if (decoded.status !== 0) { console.error(decoded.stderr); process.exit(1); } +const command = decoded.stdout.split("\0").slice(0, -1); +const behavior = fs.existsSync(path.join(root, "behavior")) ? JSON.parse(fs.readFileSync(path.join(root, "behavior"), "utf8")) : {}; +if (command[0] === "am") { + if (behavior.hangUser) { fs.writeFileSync(path.join(root, "waiting-user"), String(process.pid)); fs.watch(root, () => {}); } + else console.log(fs.readFileSync(path.join(root, "user"), "utf8")); +} else if (command[0] === "cmd") { + if (command.join(" ") !== "cmd package list packages -3 --user 10") throw new Error("Unsafe package discovery user"); + console.log(fs.existsSync(path.join(root, "packages")) ? fs.readFileSync(path.join(root, "packages"), "utf8") : "package:com.example.z\npackage:com.example.app\npackage:com.example.z"); + if (behavior.switchUser) fs.writeFileSync(path.join(root, "user"), "11"); +} else if (command[0] === "sh") { + // The run-as probe: one answer per package argument, as the device-side loop prints them. + if (command[1] !== "-c" || !command[2].includes('run-as "$p" --user 10 true') || command[3] !== "sh") throw new Error("Unexpected probe " + JSON.stringify(command)); + for (const name of command.slice(4)) { + if (behavior.probe === "garbage") console.log("maybe " + name); + else if (name.endsWith(".release")) console.log("no " + name + " run-as: package not debuggable: " + name); + else console.log("ok " + name); + } +} else if (command[0] === "run-as") { + if (command[2] !== "--user" || command[3] !== "10" || command[4] !== "sh" || command[5] !== "-c") throw new Error("Unsafe run-as user/command"); + const script = command[6]; + if (script.startsWith("rm -f ")) { + const result = spawnSync("/bin/sh", ["-c", script], { cwd: path.join(root, "app"), encoding: "utf8" }); + fs.appendFileSync(path.join(root, "cleaned"), "yes\n"); + process.exit(result.status || 0); + } + if (script.startsWith("grep -q dev.elide.inspector.DatabaseInspector /proc/4242/cmdline")) { + fs.appendFileSync(path.join(root, "remote-killed"), "yes\n"); + process.exit(0); + } + if (command[1] === "com.example.release") { console.error("run-as: package not debuggable: com.example.release"); process.exit(1); } + if (command[1] === "com.example.missing") { console.error("run-as: unknown package: com.example.missing"); process.exit(1); } + if (command[1] === "com.example.unsupported") { console.log("usage: run-as []"); process.exit(0); } + if (script.startsWith("set -eu;")) { + const child = spawn("/bin/sh", ["-c", script], { cwd: path.join(root, "app"), stdio: ["inherit", "pipe", "pipe"] }); + child.stdout.pipe(process.stdout); + child.stderr.pipe(process.stderr); + child.on("close", code => process.exit(code ?? 1)); + } else { + const match = /^\[ -f '(cache\/elide-database-[a-f0-9]{32}\/inspector\.dex)' \] \|\| \{ printf '%s\\n' ELIDE_DATABASE_MISSING >&2; exit 3; \}; CLASSPATH='\1' exec app_process \/ dev\.elide\.inspector\.DatabaseInspector$/.exec(script); + if (!match) throw new Error("Unexpected helper shell command: " + script); + const helper = path.join(root, "app", match[1]); + if (!fs.existsSync(helper)) { console.error("ELIDE_DATABASE_MISSING"); process.exit(3); } + if ((fs.statSync(helper).mode & 0o222) !== 0) throw new Error("Writable DEX"); + // One long-lived helper: hello, then one response line per request line, until end of input. + fs.appendFileSync(path.join(root, "helpers"), "started\n"); + process.stdout.write(JSON.stringify({ ready: true, protocol: behavior.protocol ?? 2, pid: 4242 }) + "\n"); + let buffered = ""; + let busy = false; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", chunk => { + buffered += chunk; + for (let newline = buffered.indexOf("\n"); newline >= 0; newline = buffered.indexOf("\n")) { + const { id, ...request } = JSON.parse(buffered.slice(0, newline)); + buffered = buffered.slice(newline + 1); + fs.appendFileSync(path.join(root, "requests"), JSON.stringify(request) + "\n"); + fs.appendFileSync(path.join(root, "ids"), id + "\n"); + const now = fs.existsSync(path.join(root, "behavior")) ? JSON.parse(fs.readFileSync(path.join(root, "behavior"), "utf8")) : {}; + // A helper stuck in SQL: it neither answers nor exits at end of input. + if (now.hang) { busy = true; fs.watch(root, () => {}); fs.writeFileSync(path.join(root, "waiting-query"), String(process.pid)); return; } + if (now.flood) { process.stdout.write("x".repeat(4 * 1024 * 1024 + 1)); return; } + if (now.noisy) process.stderr.write("linker: harmless warning\n".repeat(10000)); + if (now.garbage) { process.stdout.write("not json\n"); continue; } + if (now.wrongId) { process.stdout.write(JSON.stringify({ ok: true, id: id + 1, databases: [] }) + "\n"); continue; } + if (fs.existsSync(path.join(root, "response"))) { + process.stdout.write(fs.readFileSync(path.join(root, "response"), "utf8").replace("{", '{"id":' + id + ",") + "\n"); + continue; + } + const defaults = { + databases: { ok: true, databases: ["app.db"] }, + schema: { ok: true, objects: [{ name: "items", type: "table", sql: "CREATE TABLE items(id INTEGER)" }] }, + query: { ok: true, columns: ["id"], rows: [[1]], hasMore: false }, + execute: { ok: true, changes: 1 }, + }; + process.stdout.write(JSON.stringify({ ...defaults[request.action], id }) + "\n"); + } + }); + process.stdin.on("end", () => { if (!busy) process.exit(0); }); + } +} else throw new Error("Unexpected remote command " + JSON.stringify(command)); +`); + chmodSync(adb, 0o755); + const options = { adb, device: "serial;$(touch serial-injected)", packageName: "com.example.app", helperDex }; + return { + root, + options, + calls: () => existsSync(path.join(root, "calls")) ? readFileSync(path.join(root, "calls"), "utf8").trim().split("\n").map((line) => JSON.parse(line) as string[]) : [], + requests: () => existsSync(path.join(root, "requests")) ? readFileSync(path.join(root, "requests"), "utf8").trim().split("\n").map((line) => JSON.parse(line)) : [], + session(overrides) { + const session = new AndroidDatabaseSession({ ...options, ...overrides }); + sessions.push(session); + return session; + }, + response(value) { writeFileSync(path.join(root, "response"), JSON.stringify(value)); }, + }; +} + +async function waitForFile(file: string, count = 1): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + const check = () => { + if (existsSync(file) && readFileSync(file, "utf8").trim().split("\n").filter(Boolean).length >= count) resolve(); + }; + // Directory watch events can arrive before a marker's contents or be coalesced by Bun on Linux. + // Poll the durable marker instead, including append-only markers that count completed cleanups. + const watcher = watchFile(file, { interval: 10 }, check); + watcher.on("error", reject); + // Keep the same bounded failure deadline; polling does not extend a broken fixture's lifetime. + const deadline = AbortSignal.timeout(3_000); + const expired = () => reject(new Error(`Timed out waiting for ${file}`)); + deadline.addEventListener("abort", expired, { once: true }); + try { + check(); + await promise; + } finally { + deadline.removeEventListener("abort", expired); + unwatchFile(file, check); + } +} + +async function waitForExit(pid: number): Promise { + const deadline = Date.now() + 3_000; + while (true) { + try { process.kill(pid, 0); } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + throw error; + } + if (Date.now() >= deadline) throw new Error(`adb process ${pid} survived cancellation`); + // Observe OS reaping, not a SIGTERM handler that need not run before forced termination. + const { promise, resolve } = Promise.withResolvers(); + setImmediate(resolve); + await promise; + } +} + +describe.skipIf(process.platform === "win32")("Android database transport safety", () => { + test("streams a fresh read-only DEX and keeps SQL, database names and serials out of shell evaluation", async () => { + const fake = fixture(); + const session = fake.session(); + const database = "odd ' $(touch database-injected).db"; + const sql = "SELECT '$(touch sql-injected); `id`', '\"quoted\"'"; + await session.query(database, sql); + expect(fake.requests()).toEqual([{ action: "query", database, sql, limit: 100, offset: 0 }]); + const firstCalls = fake.calls(); + expect(firstCalls.every((args) => args[1] === fake.options.device)).toBe(true); + expect(firstCalls.some((args) => args.join(" ").includes(sql) || args.join(" ").includes(database))).toBe(false); + const cache = path.join(fake.root, "app", "cache"); + const firstDirectory = readdirSync(cache)[0]!; + const helper = path.join(cache, firstDirectory, "inspector.dex"); + expect(readFileSync(helper)).toEqual(readFileSync(fake.options.helperDex)); + expect(statSync(helper).mode & 0o777).toBe(0o400); + await session.execute("app.db", "UPDATE items SET id = 2"); + expect(fake.calls().filter((args) => args.at(-1)?.includes("ELIDE_DATABASE_READY_"))).toHaveLength(1); + const second = fake.session(); + await second.databases(); + expect(readdirSync(cache)).toHaveLength(2); + expect(existsSync(path.join(fake.root, "serial-injected"))).toBe(false); + expect(existsSync(path.join(fake.root, "database-injected"))).toBe(false); + expect(existsSync(path.join(fake.root, "sql-injected"))).toBe(false); + session.dispose(); + await waitForFile(path.join(fake.root, "cleaned")); + expect(existsSync(helper)).toBe(false); + expect(readdirSync(cache)).toHaveLength(1); + }); + + test("rejects unsafe identifiers, traversal and oversized requests before starting adb", async () => { + const fake = fixture(); + expect(() => fake.session({ packageName: "com.example;touch bad" })).toThrow("application ID"); + expect(() => fake.session({ user: -1 })).toThrow("user"); + const session = fake.session(); + for (const database of ["../secret.db", "nested/../../secret.db", "/data/private.db", "x\\secret.db", "a\0.db", "a//b"]) { + await expect(session.schema(database)).rejects.toThrow("database path"); + } + await expect(session.query("app.db", " ")).rejects.toThrow("SQL"); + await expect(session.query("app.db", "€".repeat(22_000))).rejects.toThrow("64 KiB"); + await expect(session.query("app.db", "SELECT 1", 501)).rejects.toThrow("page size"); + await expect(session.query("app.db", "SELECT 1", 100, 1_000_001)).rejects.toThrow("offset"); + expect(fake.calls()).toEqual([]); + }); + + test("pins a secondary user, refuses mismatched selection and rejects a later profile switch", async () => { + const fake = fixture(); + await expect(fake.session({ user: 0 }).databases()).rejects.toThrow("not the current user 10"); + expect(fake.calls().some((args) => args.at(-1)?.includes("ELIDE_DATABASE_READY_"))).toBe(false); + const session = fake.session({ user: 10 }); + await session.databases(); + writeFileSync(path.join(fake.root, "user"), "11"); + await expect(session.query("app.db", "SELECT 1")).rejects.toThrow("changed from 10 to 11"); + expect(fake.requests()).toHaveLength(1); + }); + + test("discovers only third-party packages for the current user and rejects malformed/user-racing discovery", async () => { + const fake = fixture(); + expect(await listAndroidDatabasePackages(fake.options.adb, fake.options.device)).toEqual([{ packageName: "com.example.app", user: 10 }, { packageName: "com.example.z", user: 10 }]); + writeFileSync(path.join(fake.root, "packages"), "package:com.example.app;id"); + await expect(listAndroidDatabasePackages(fake.options.adb, fake.options.device)).rejects.toThrow("unsupported response"); + writeFileSync(path.join(fake.root, "packages"), "package:com.example.app"); + writeFileSync(path.join(fake.root, "behavior"), JSON.stringify({ switchUser: true })); + await expect(listAndroidDatabasePackages(fake.options.adb, fake.options.device)).rejects.toThrow("changed during package discovery"); + }); + + test("keeps the discovered package's user pinned while the application picker is open", async () => { + const fake = fixture(); + const [selected] = await listAndroidDatabasePackages(fake.options.adb, fake.options.device); + expect(selected?.user).toBe(10); + writeFileSync(path.join(fake.root, "user"), "11"); + const session = fake.session({ packageName: selected!.packageName, user: selected!.user }); + await expect(session.databases()).rejects.toThrow("Selected Android user 10 is not the current user 11"); + expect(fake.calls().some((args) => args.at(-1)?.includes("ELIDE_DATABASE_READY_"))).toBe(false); + expect(fake.requests()).toEqual([]); + }); + + test("asks run-as which applications it opens, in batches, and never offers a release build as openable", async () => { + const fake = fixture(); + const many = Array.from({ length: 150 }, (_, index) => `com.example.generated${String(index).padStart(3, "0")}`); + writeFileSync(path.join(fake.root, "packages"), ["com.example.app", "com.example.release", ...many].map((name) => `package:${name}`).join("\n")); + const packages = await listAndroidDatabasePackages(fake.options.adb, fake.options.device); + expect(packages).toHaveLength(152); + expect(packages.find((item) => item.packageName === "com.example.release")).toEqual({ + packageName: "com.example.release", user: 10, refusal: "run-as: package not debuggable: com.example.release", notDebuggable: true, + }); + expect(packages.filter((item) => item.refusal)).toHaveLength(1); + const probes = fake.calls().filter((args) => args[4] === "'sh'"); + expect(probes.length).toBeGreaterThan(1); + expect(probes.every((args) => args.join(" ").length < 4096)).toBe(true); + writeFileSync(path.join(fake.root, "behavior"), JSON.stringify({ probe: "garbage" })); + await expect(listAndroidDatabasePackages(fake.options.adb, fake.options.device)).rejects.toThrow("run-as check returned an unsupported response"); + }); + + test("reports debuggability, missing applications, unsupported run-as and offline devices", async () => { + const fake = fixture(); + for (const [packageName, message] of [["com.example.release", "debuggable build"], ["com.example.missing", "Install this application"], ["com.example.unsupported", "never falls back"]]) { + await expect(fake.session({ packageName }).databases()).rejects.toThrow(message); + } + await expect(fake.session({ device: "offline" }).databases()).rejects.toThrow("Reconnect"); + writeFileSync(path.join(fake.root, "user"), "Current user: unknown"); + await expect(fake.session().databases()).rejects.toThrow("no owner-profile fallback"); + }); + + test("preserves lossless cells and accepts noisy stderr without accepting malformed result shapes", async () => { + const fake = fixture(); + const session = fake.session(); + writeFileSync(path.join(fake.root, "behavior"), JSON.stringify({ noisy: true })); + const cells = [null, "text €", 3.5, { type: "integer", value: "9223372036854775807" }, { type: "blob", base64: "AP8=" }, 1e20]; + fake.response({ ok: true, columns: ["a", "b", "c", "d", "e", "f"], rows: [cells], hasMore: true }); + expect(await session.query("app.db", "SELECT * FROM items", 1)).toEqual({ columns: ["a", "b", "c", "d", "e", "f"], rows: [cells], hasMore: true }); + for (const value of [{ type: "integer", value: "9223372036854775808" }, { type: "blob", base64: "A===" }, "x".repeat(256 * 1024 + 1), true]) { + fake.response({ ok: true, columns: ["value"], rows: [[value]], hasMore: false }); + await expect(session.query("app.db", "SELECT value")).rejects.toThrow("malformed or oversized query"); + } + writeFileSync(path.join(fake.root, "response"), '{"ok":true,"columns":["value"],"rows":[[1e400]],"hasMore":false}'); + await expect(session.query("app.db", "SELECT value")).rejects.toThrow("malformed"); + fake.response({ ok: true, columns: ["value"], rows: [[1, 2]], hasMore: false }); + await expect(session.query("app.db", "SELECT value")).rejects.toThrow("malformed"); + fake.response({ ok: true, columns: ["value"], rows: [[1], [2]], hasMore: false }); + await expect(session.query("app.db", "SELECT value", 1)).rejects.toThrow("malformed"); + fake.response({ ok: true, databases: ["../private.db"] }); + await expect(session.databases()).rejects.toThrow("malformed"); + fake.response({ ok: false, error: "SQLite database is locked; retry after the app transaction completes" }); + await expect(session.schema("app.db")).rejects.toThrow("database is locked"); + }); + + test("caps stdout instead of parsing a partial or unbounded response", async () => { + const fake = fixture(); + writeFileSync(path.join(fake.root, "behavior"), JSON.stringify({ flood: true })); + await expect(fake.session().databases()).rejects.toThrow("bounded response size"); + }); + + test("one helper serves every request; a helper out of step is replaced, an application error is not", async () => { + const fake = fixture(); + const session = fake.session(); + await session.databases(); + await session.schema("app.db"); + await session.query("app.db", "SELECT 1"); + const helpers = () => readFileSync(path.join(fake.root, "helpers"), "utf8").trim().split("\n").length; + expect(helpers()).toBe(1); + expect(readFileSync(path.join(fake.root, "ids"), "utf8").trim().split("\n")).toEqual(["1", "2", "3"]); + fake.response({ ok: false, error: "no such table: missing" }); + await expect(session.query("app.db", "SELECT * FROM missing")).rejects.toThrow("no such table"); + expect(helpers()).toBe(1); + rmSync(path.join(fake.root, "response")); + for (const [behavior, message] of [[{ garbage: true }, "invalid JSON"], [{ wrongId: true }, "answered request 7 instead of 6"]] as const) { + writeFileSync(path.join(fake.root, "behavior"), JSON.stringify(behavior)); + await expect(session.databases()).rejects.toThrow(message); + } + writeFileSync(path.join(fake.root, "behavior"), "{}"); + expect(await session.databases()).toEqual(["app.db"]); + expect(helpers()).toBe(3); + expect(fake.calls().filter((args) => args.at(-1)?.includes("ELIDE_DATABASE_READY_"))).toHaveLength(1); + }); + + test("refuses a helper speaking another protocol", async () => { + const fake = fixture(); + writeFileSync(path.join(fake.root, "behavior"), JSON.stringify({ protocol: 1 })); + await expect(fake.session().databases()).rejects.toThrow("protocol 1, not 2"); + }); + + test("cancel stops a helper stuck in SQL on the device, keeps the DEX and serves the next request", async () => { + const fake = fixture(); + writeFileSync(path.join(fake.root, "behavior"), JSON.stringify({ hang: true })); + const session = fake.session(); + const outcomes = Promise.allSettled([session.query("app.db", "SELECT 1"), session.schema("app.db")]); + await waitForFile(path.join(fake.root, "waiting-query")); + const pid = Number(readFileSync(path.join(fake.root, "waiting-query"), "utf8")); + session.cancel(); + for (const result of await outcomes) expect(result.status === "rejected" && result.reason.name).toBe("AbortError"); + await waitForFile(path.join(fake.root, "remote-killed")); + await waitForExit(pid); + writeFileSync(path.join(fake.root, "behavior"), "{}"); + expect(await session.databases()).toEqual(["app.db"]); + expect(fake.calls().filter((args) => args.at(-1)?.includes("ELIDE_DATABASE_READY_"))).toHaveLength(1); + expect(existsSync(path.join(fake.root, "cleaned"))).toBe(false); + }); + + test("reinstalls a helper the system deleted from the app cache when it next starts one", async () => { + const fake = fixture(); + const session = fake.session(); + await session.databases(); + const cache = path.join(fake.root, "app", "cache"); + // Clear cache can leave the directory behind; the reinstall must tolerate it. + rmSync(path.join(cache, readdirSync(cache)[0]!, "inspector.dex")); + writeFileSync(path.join(fake.root, "behavior"), JSON.stringify({ garbage: true })); + await expect(session.databases()).rejects.toThrow("invalid JSON"); + writeFileSync(path.join(fake.root, "behavior"), "{}"); + expect(await session.databases()).toEqual(["app.db"]); + expect(fake.calls().filter((args) => args.at(-1)?.includes("ELIDE_DATABASE_READY_"))).toHaveLength(2); + }); + + test("frames requests and responses as single lines with bounded size", () => { + const reader = new AndroidDatabaseLineReader(8); + expect(reader.push(Buffer.from("ab"))).toEqual([]); + expect(reader.push(Buffer.from("c\n\nde\nf")).map(String)).toEqual(["abc", "", "de"]); + expect(reader.push(Buffer.from("\n")).map(String)).toEqual(["f"]); + expect(() => reader.push(Buffer.from("123456789"))).toThrow("bounded response size (8 bytes)"); + const line = encodeAndroidDatabaseRequest(7, { action: "query", database: "a.db", sql: "SELECT 'x\ny'", limit: 1, offset: 0 }); + expect(line.indexOf("\n")).toBe(line.length - 1); + expect(JSON.parse(line)).toEqual({ id: 7, action: "query", database: "a.db", sql: "SELECT 'x\ny'", limit: 1, offset: 0 }); + expect(() => encodeAndroidDatabaseRequest(1, { action: "execute", database: "a.db", sql: "x".repeat(512 * 1024) })).toThrow("512 KiB"); + expect(parseAndroidDatabaseHello(Buffer.from('{"ready":true,"protocol":2,"pid":31}'))).toBe(31); + expect(() => parseAndroidDatabaseHello(Buffer.from('{"ready":true,"protocol":2,"pid":-1}'))).toThrow("process ID"); + expect(() => parseAndroidDatabaseHello(Buffer.from([0xff]))).toThrow("invalid UTF-8"); + expect(parseAndroidDatabaseResponse(Buffer.from('{"ok":true,"id":3,"changes":2}'), 3, { action: "execute", database: "a.db", sql: "DELETE FROM t" })).toMatchObject({ changes: 2 }); + expect(() => parseAndroidDatabaseResponse(Buffer.from('{"ok":true,"id":2,"changes":2}'), 3, { action: "execute", database: "a.db", sql: "DELETE FROM t" })).toThrow("instead of 3"); + }); + + test("dispose aborts running and queued requests, kills the child, removes its DEX and never restarts", async () => { + const fake = fixture(); + writeFileSync(path.join(fake.root, "behavior"), JSON.stringify({ hang: true })); + const session = fake.session(); + const operations = [session.query("app.db", "SELECT 1"), session.execute("app.db", "DELETE FROM items"), session.schema("app.db")]; + const outcomes = Promise.allSettled(operations); + await waitForFile(path.join(fake.root, "waiting-query")); + const pid = Number(readFileSync(path.join(fake.root, "waiting-query"), "utf8")); + expect(fake.requests()).toHaveLength(1); + session.dispose(); + for (const result of await outcomes) { + expect(result.status).toBe("rejected"); + if (result.status === "rejected") expect(result.reason.name).toBe("AbortError"); + } + await waitForExit(pid); + await waitForFile(path.join(fake.root, "cleaned")); + expect(readdirSync(path.join(fake.root, "app", "cache"))).toHaveLength(0); + await expect(session.databases()).rejects.toThrow("disposed"); + expect(fake.requests()).toHaveLength(1); + session.dispose(); + }); + + test("cancels pending user discovery and never installs a helper after disposal", async () => { + const fake = fixture(); + writeFileSync(path.join(fake.root, "behavior"), JSON.stringify({ hangUser: true })); + const session = fake.session(); + const outcomes = Promise.allSettled([session.databases(), session.databases()]); + await waitForFile(path.join(fake.root, "waiting-user")); + const pid = Number(readFileSync(path.join(fake.root, "waiting-user"), "utf8")); + session.dispose(); + expect((await outcomes).every((result) => result.status === "rejected")).toBe(true); + await waitForExit(pid); + expect(fake.calls()).toHaveLength(1); + const controller = new AbortController(); + controller.abort(); + await expect(listAndroidDatabasePackages(fake.options.adb, fake.options.device, controller.signal)).rejects.toThrow("cancelled"); + expect(fake.calls()).toHaveLength(1); + }); +}); diff --git a/packages/core/test/androidLogcat.test.ts b/packages/core/test/androidLogcat.test.ts index a939dca..f677f64 100644 --- a/packages/core/test/androidLogcat.test.ts +++ b/packages/core/test/androidLogcat.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, unwatchFile, watchFile, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { setTimeout as sleep } from "node:timers/promises"; -import { AndroidLogcatFormatter, AndroidLogcatSession, androidLogcatArgs, parseAndroidLogcatLine, parseAndroidPackageUid, type AndroidLogcatOptions } from "../src/androidLogcat.js"; +import { + AndroidLogcatFormatter, AndroidLogcatSession, androidLogcatArgs, androidLogcatClockScript, androidLogcatProcessFallback, androidLogcatStart, + formatAndroidLogcatFilter, parseAndroidLogcatFilter, parseAndroidLogcatLine, parseAndroidPackageUid, type AndroidLogcatOptions, +} from "../src/androidLogcat.js"; const defaults: AndroidLogcatOptions = { adb: "/sdk with spaces/platform-tools/adb", device: "emulator-5554", priority: "I", text: "" }; @@ -12,13 +14,21 @@ describe("logcat arguments and package identity", () => { expect(androidLogcatArgs({ ...defaults, device: "serial; echo bad", text: "$(touch /tmp/bad)" })).toEqual([ "-s", "serial; echo bad", "logcat", "-v", "threadtime", "*:I", ]); - expect(androidLogcatArgs({ ...defaults, packageName: "com.example.app", priority: "W" }, 10123)).toContain("--uid=10123"); + expect(androidLogcatArgs({ ...defaults, packageName: "com.example.app", priority: "W" }, { uid: 10123 })).toContain("--uid=10123"); + expect(androidLogcatArgs({ ...defaults, packageName: "com.example.app" }, { pid: 4242 })).toEqual([ + "-s", "emulator-5554", "logcat", "-v", "threadtime", "--pid=4242", "*:I", + ]); + expect(androidLogcatArgs(defaults, undefined, "1792192635.684")).toEqual([ + "-s", "emulator-5554", "logcat", "-v", "threadtime", "-T", "1792192635.684", "*:I", + ]); + expect(() => androidLogcatArgs(defaults, undefined, "1; id")).toThrow("start time"); }); test("remote-shell package arguments are restricted before execution", () => { - expect(() => androidLogcatArgs({ ...defaults, packageName: "com.example;id" }, 123)).toThrow("application ID"); + expect(() => androidLogcatArgs({ ...defaults, packageName: "com.example;id" }, { uid: 123 })).toThrow("application ID"); expect(() => androidLogcatArgs({ ...defaults, packageName: "com.example.app" })).toThrow("UID"); - expect(() => androidLogcatArgs(defaults, Number.NaN)).toThrow("UID"); + expect(() => androidLogcatArgs(defaults, { uid: Number.NaN })).toThrow("UID"); + expect(() => androidLogcatArgs(defaults, { pid: 0 })).toThrow("process ID"); expect(() => androidLogcatArgs({ ...defaults, device: "" })).toThrow("device"); }); @@ -29,6 +39,57 @@ describe("logcat arguments and package identity", () => { expect(parseAndroidPackageUid("package:com.example.app uid:garbage", "com.example.app")).toBeUndefined(); expect(parseAndroidPackageUid("package:com.example.app uid:9007199254740992", "com.example.app")).toBeUndefined(); }); + + test("devices whose logcat lacks --uid (before API 31) follow the app's running process, or explain why not", () => { + const stat = "stat 4242 (com.example.app) S 1 2 3"; + expect(androidLogcatClockScript()).toContain("getprop ro.build.version.sdk"); + expect(androidLogcatProcessFallback(`now 2000\nsdk 31\n${stat}\n`, "com.example.app")).toBeUndefined(); + // An unreadable level keeps UID discovery, which reports its own failure. + expect(androidLogcatProcessFallback(`now 2000\nsdk \n${stat}\n`, "com.example.app")).toBeUndefined(); + expect(androidLogcatProcessFallback(`now 2000\nsdk 30\n${stat}\n`, "com.example.app")).toEqual({ pid: 4242, sdk: 30 }); + expect(androidLogcatProcessFallback(`sdk 24\n${stat}`, "com.example.app")).toEqual({ pid: 4242, sdk: 24 }); + expect(() => androidLogcatProcessFallback("now 2000\nsdk 28\n", "com.example.app")).toThrow("com.example.app is not running"); + expect(() => androidLogcatProcessFallback("now 2000\nsdk 28\nstat garbage\n", "com.example.app")).toThrow("not running"); + expect(() => androidLogcatProcessFallback(`sdk 23\n${stat}`, "com.example.app")).toThrow("needs API 24"); + }); + + test("a new stream starts at the running app's process start, else now, never at the buffer's start", () => { + expect(() => androidLogcatClockScript("com.example;id")).toThrow("application ID"); + expect(androidLogcatClockScript()).not.toContain("pidof"); + expect(androidLogcatClockScript("com.example.app")).toContain("pidof -s com.example.app"); + // Booted 1000 s ago; the process started 900 s after boot (90000 ticks at 100 Hz). Its name contains spaces. + const stat = "4242 (com.exa mple) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 90000 19"; + expect(androidLogcatStart(`now 2000.5\nuptime 1000.25 3000.00\ntck 100\nstat ${stat}\n`)).toEqual({ since: "1900.250", from: "process" }); + expect(androidLogcatStart("now 2000.123456789\nuptime 1000.25 3000.00\ntck 100\n")).toEqual({ since: "2000.123", from: "now" }); + // Missing CLK_TCK means Android's 100 Hz; a malformed stat falls back to now. + expect(androidLogcatStart(`now 2000\nuptime 1000 1\ntck \nstat ${stat}`)?.since).toBe("1900.000"); + expect(androidLogcatStart("now 2000\nuptime 1000 1\ntck 100\nstat garbage")).toEqual({ since: "2000.000", from: "now" }); + expect(androidLogcatStart("now %s.%N\n")).toBeUndefined(); + }); + + test("one filter line carries the application, minimum priority and literal text", () => { + expect(parseAndroidLogcatFilter("package:com.example.app level:warn Fatal Exception")).toEqual({ packageName: "com.example.app", priority: "W", text: "Fatal Exception" }); + expect(parseAndroidLogcatFilter("")).toEqual({ priority: "I", text: "" }); + expect(parseAndroidLogcatFilter("APP:com.example.app priority:E")).toEqual({ packageName: "com.example.app", priority: "E", text: "" }); + expect(parseAndroidLogcatFilter(`level:v "package:not.a.filter x" http://host "say \\"hi\\""`)).toEqual({ priority: "V", text: `package:not.a.filter x http://host say "hi"` }); + for (const [input, error] of [["package:com.example;id", "application ID"], ["level:loud", "not a priority"], + ["level:constructor", "not a priority"], ["priority:__proto__", "not a priority"], ["package:a.b package:c.d", "twice"], + ["level:e level:w", "twice"], ["\"unclosed text", "Close"]]) { + expect(parseAndroidLogcatFilter(input!)).toEqual({ error: expect.stringContaining(error!) }); + } + }); + + test("the formatted filter reads back as the same filter", () => { + const filters = [ + { priority: "I" as const, text: "" }, + { packageName: "com.example.app", priority: "D" as const, text: "two words" }, + { priority: "F" as const, text: "level:error" }, + { priority: "W" as const, text: " padded " }, + { priority: "E" as const, text: "quote \" and back\\slash" }, + ]; + for (const filter of filters) expect(parseAndroidLogcatFilter(formatAndroidLogcatFilter(filter))).toEqual(filter); + expect(formatAndroidLogcatFilter({ packageName: "com.example.app", priority: "W", text: "Exception" })).toBe("package:com.example.app level:warn Exception"); + }); }); describe("logcat formatting", () => { @@ -85,8 +146,18 @@ const path = require("node:path"); const root = __dirname; const args = process.argv.slice(2); fs.appendFileSync(path.join(root, "calls"), JSON.stringify(args) + "\\n"); -if (args.includes("shell")) { - if (args.at(-1) === "com.example.slow") { +process.on("SIGTERM", () => { + if (args.at(-1) === "com.example.stubborn") return; + process.exit(0); +}); +if (args.includes("shell") && args.at(-1).includes("date +%s")) { + console.log("now 2000.5"); + console.log("uptime 1000.25 3000.00"); + console.log("tck 100"); + console.log("sdk " + ((/^api(\\d+)$/.exec(args[1]) || [])[1] || 34)); + if (args.at(-1).includes("pidof -s com.example.app")) console.log("stat 4242 (com.example.app) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 90000 19"); +} else if (args.includes("shell")) { + if (args.at(-1) === "com.example.slow" || args.at(-1) === "com.example.stubborn") { fs.writeFileSync(path.join(root, "pending"), String(process.pid)); fs.watch(root, () => {}); } else { @@ -125,8 +196,8 @@ async function waitForFile(file: string): Promise { const contents = readFileSync(file, "utf8"); if (contents) resolve(contents); }; - const watcher = watch(path.dirname(file), check); - watcher.on("error", reject); + // Marker contents persist; directory events can be coalesced before the write completes. + watchFile(file, { interval: 10 }, check); const deadline = AbortSignal.timeout(3_000); const expired = () => reject(new Error(`Timed out waiting for ${file}`)); deadline.addEventListener("abort", expired, { once: true }); @@ -134,7 +205,7 @@ async function waitForFile(file: string): Promise { check(); return await promise; } finally { - watcher.close(); + unwatchFile(file, check); deadline.removeEventListener("abort", expired); } } @@ -164,21 +235,46 @@ describe.skipIf(process.platform === "win32")("owned adb streams", () => { const { promise: completion, resolve: ended } = Promise.withResolvers(); const session = new AndroidLogcatSession((line) => lines.push(line), ended); sessions.push(session); - await session.start({ ...defaults, adb: fake.adb, packageName: "com.example.app", text: "needle" }); + expect(await session.start({ ...defaults, adb: fake.adb, packageName: "com.example.app", text: "needle" })).toEqual({ start: { since: "1900.250", from: "process" } }); expect(await completion).toBeUndefined(); expect(lines).toEqual(["NEEDLE €", "Needle final"]); expect(fake.calls()).toEqual([ + ["-s", defaults.device, "shell", androidLogcatClockScript("com.example.app")], ["-s", defaults.device, "shell", "cmd", "package", "list", "packages", "--user", "current", "-U", "com.example.app"], - ["-s", defaults.device, "logcat", "-v", "threadtime", "--uid=10123", "*:I"], + ["-s", defaults.device, "logcat", "-v", "threadtime", "-T", "1900.250", "--uid=10123", "*:I"], ]); }); + test("before API 31 a running app is followed by its process ID, never by an unsupported --uid", async () => { + const fake = fixture(); + const { promise: completion, resolve: ended } = Promise.withResolvers(); + const session = new AndroidLogcatSession(() => {}, ended); + sessions.push(session); + const options = { ...defaults, adb: fake.adb, device: "api30", packageName: "com.example.app" }; + expect(await session.start(options)).toEqual({ start: { since: "1900.250", from: "process" }, process: { pid: 4242, sdk: 30 } }); + expect(await completion).toBeUndefined(); + expect(fake.calls()).toEqual([ + ["-s", "api30", "shell", androidLogcatClockScript("com.example.app")], + ["-s", "api30", "logcat", "-v", "threadtime", "-T", "1900.250", "--pid=4242", "*:I"], + ]); + }); + + test("before API 31 a stopped app, and before API 24 any app, fails with the reason instead of streaming", async () => { + const fake = fixture(); + const session = new AndroidLogcatSession(() => { throw new Error("Unexpected log"); }, () => {}); + sessions.push(session); + await expect(session.start({ ...defaults, adb: fake.adb, device: "api30", packageName: "com.example.missing" })).rejects.toThrow("com.example.missing is not running"); + await expect(session.start({ ...defaults, adb: fake.adb, device: "api23", packageName: "com.example.app" })).rejects.toThrow("needs API 24"); + expect(fake.calls().every((call) => call.includes("shell"))).toBe(true); + expect(session.active).toBe(false); + }); + test("an absent app fails instead of leaking all-device logs", async () => { const fake = fixture(); const session = new AndroidLogcatSession(() => { throw new Error("Unexpected log"); }, () => {}); sessions.push(session); await expect(session.start({ ...defaults, adb: fake.adb, packageName: "com.example.missing" })).rejects.toThrow("not installed"); - expect(fake.calls()).toHaveLength(1); + expect(fake.calls()).toHaveLength(2); }); test("reports device failures and executable launch errors", async () => { @@ -214,15 +310,23 @@ describe.skipIf(process.platform === "win32")("owned adb streams", () => { await expect(session.start(options)).rejects.toThrow("disposed"); }); - test("stop cancels pending UID discovery and never launches a stale stream", async () => { - const fake = fixture(); - const session = new AndroidLogcatSession(() => {}, () => { throw new Error("Unexpected end notification"); }); - sessions.push(session); - const pending = session.start({ ...defaults, adb: fake.adb, packageName: "com.example.slow" }); - const pid = await waitForFile(path.join(fake.root, "pending")); - session.stop(); - await pending; - await waitForExit(Number(pid)); - expect(fake.calls()).toHaveLength(1); + test("stop and disposal cancel UID discovery even when adb ignores SIGTERM", async () => { + for (const packageName of ["com.example.slow", "com.example.stubborn"]) { + const fake = fixture(); + const session = new AndroidLogcatSession(() => {}, () => { throw new Error("Unexpected end notification"); }); + sessions.push(session); + const pending = session.start({ ...defaults, adb: fake.adb, packageName }); + const pid = Number(await waitForFile(path.join(fake.root, "pending"))); + try { + if (packageName.endsWith("stubborn")) session.dispose(); + else session.stop(); + expect(await pending).toBeUndefined(); + await waitForExit(pid); + expect(session.active).toBe(false); + expect(fake.calls()).toHaveLength(2); + } finally { + try { process.kill(pid, "SIGKILL"); } catch { /* Already reaped. */ } + } + } }); }); diff --git a/packages/core/test/androidTests.test.ts b/packages/core/test/androidTests.test.ts new file mode 100644 index 0000000..c63dc47 --- /dev/null +++ b/packages/core/test/androidTests.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { AndroidTestResultParser, parseElideRecord, type AndroidTestResult, type ElideRecord } from "../src/index.js"; + +/** Messages as `ELIDE_PROGRESS=json elide build android-test-app` reports them. */ +const message = (level: "output" | "error", text: string, task = "android-test-app"): ElideRecord => + parseElideRecord(JSON.stringify({ type: "message", level, text, task, time: 1 }))!; + +function parse(records: ElideRecord[], task = "android-test-app"): { results: AndroidTestResult[]; problems: string[] } { + const results: AndroidTestResult[] = []; + const problems: string[] = []; + const parser = new AndroidTestResultParser(task, (result) => results.push(result), (text) => problems.push(text)); + for (const record of records) parser.accept(record); + parser.end(); + return { results, problems }; +} + +describe("instrumented test results", () => { + test("each finished test maps to its class and method, a failure keeping the stack trace reported after it", () => { + const stack = "java.lang.AssertionError: expected 36\n\tat smoke.DeviceTest.verifiesApi(DeviceTest.java:15)"; + const { results, problems } = parse([ + message("output", "smoke.DeviceTest#verifiesPackage passed"), + message("error", "smoke.DeviceTest#verifiesApi failed"), + message("error", stack), + message("output", "smoke.DeviceTest#ignored skipped"), + message("output", "1 passed, 1 failed, 1 skipped"), + ]); + expect(results).toEqual([ + { className: "smoke.DeviceTest", method: "verifiesPackage", outcome: "passed" }, + { className: "smoke.DeviceTest", method: "verifiesApi", outcome: "failed", detail: stack }, + { className: "smoke.DeviceTest", method: "ignored", outcome: "skipped" }, + ]); + expect(problems).toEqual([]); + }); + + test("a failure followed by another result, or by the end of the run, carries no stack trace", () => { + const { results } = parse([ + message("error", "smoke.A#first failed"), + message("error", "smoke.A#second failed"), + ]); + expect(results).toEqual([ + { className: "smoke.A", method: "first", outcome: "failed" }, + { className: "smoke.A", method: "second", outcome: "failed" }, + ]); + }); + + test("nested classes and method names with spaces survive", () => { + const { results } = parse([message("output", "smoke.Outer$Inner#reads the label passed")]); + expect(results).toEqual([{ className: "smoke.Outer$Inner", method: "reads the label", outcome: "passed" }]); + }); + + test("the run's own errors are problems; other tasks' messages are not read", () => { + const { results, problems } = parse([ + message("error", "error: kotlinc: Unresolved reference 'x'.", "compile-kotlin-main"), + message("error", "No tests matched smoke.Missing"), + message("output", "0 passed, 0 failed, 0 skipped"), + ]); + expect(results).toEqual([]); + expect(problems).toEqual(["No tests matched smoke.Missing"]); + }); + + test("a workspace member's test target is read under its qualified name", () => { + const { results } = parse([message("output", "smoke.A#works passed", "mobile:android-test-app")]); + expect(results).toEqual([{ className: "smoke.A", method: "works", outcome: "passed" }]); + expect(parse([message("output", "smoke.A#works passed", "mobile:android-test-other")]).results).toEqual([]); + }); +}); diff --git a/packages/core/test/androidWireless.test.ts b/packages/core/test/androidWireless.test.ts new file mode 100644 index 0000000..bd5af9b --- /dev/null +++ b/packages/core/test/androidWireless.test.ts @@ -0,0 +1,242 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, unwatchFile, watchFile, writeFileSync } from "node:fs"; +import { createServer, type AddressInfo, type Server } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + androidWirelessDiagnostic, connectAndroidWireless, createAndroidWirelessQr, discoverAndroidWireless, pairAndroidWireless, + validateAndroidWirelessEndpoint, +} from "../src/androidWireless.js"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +/** `adb connect` first checks that the address accepts TCP; a local listener stands in for the device. */ +let device: Server; +let reachable = ""; +beforeAll(async () => { + device = createServer((socket) => socket.destroy()); + const { promise, resolve } = Promise.withResolvers(); + device.listen(0, "127.0.0.1", () => resolve()); + await promise; + reachable = `127.0.0.1:${(device.address() as AddressInfo).port}`; +}); +afterAll(() => { device.close(); }); + +interface Behavior { + stdout?: string; + stderr?: string; + exit?: number; + echoSecret?: boolean; + hang?: boolean; + flood?: boolean; +} + +function fixture(behavior: Behavior = {}) { + const root = mkdtempSync(path.join(tmpdir(), "elide wireless 'quoted' ")); + roots.push(root); + const adb = path.join(root, "fake adb.cjs"); + writeFileSync(path.join(root, "behavior"), JSON.stringify(behavior)); + writeFileSync(adb, String.raw`#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const args = process.argv.slice(2); +const config = JSON.parse(fs.readFileSync(path.join(__dirname, "behavior"), "utf8")); +fs.writeFileSync(path.join(__dirname, "args"), JSON.stringify(args)); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", chunk => { input += chunk; }); +process.stdin.on("end", () => { + fs.writeFileSync(path.join(__dirname, "input"), input); + if (config.hang) { fs.writeFileSync(path.join(__dirname, "ready"), String(process.pid)); fs.watch(__dirname, () => {}); return; } + if (config.flood) { process.stdout.write("x".repeat(300000)); return; } + if (config.echoSecret) { console.error("error: rejected " + input.trim()); console.log(input.trim()); } + const defaults = args[0] === "pair" ? "Enter pairing code: Successfully paired to " + args[1] + " [guid=adb-example]\n" + : args[0] === "connect" ? "connected to " + args[1] + "\n" : "List of discovered mdns services\n"; + process.stdout.write(config.stdout ?? defaults); + process.stderr.write(config.stderr ?? ""); + process.exitCode = config.exit ?? 0; +}); +`); + chmodSync(adb, 0o755); + return { root, adb }; +} + +async function waitForFile(file: string): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + const check = () => { if (existsSync(file) && readFileSync(file, "utf8")) resolve(); }; + // Marker contents persist; directory events can be coalesced before the write completes. + watchFile(file, { interval: 10 }, check); + const deadline = AbortSignal.timeout(3000); + const expired = () => reject(new Error(`Timed out waiting for ${file}`)); + deadline.addEventListener("abort", expired, { once: true }); + try { + check(); + await promise; + } finally { + deadline.removeEventListener("abort", expired); + unwatchFile(file, check); + } +} + +async function waitForExit(pid: number): Promise { + const deadline = Date.now() + 3_000; + while (true) { + try { process.kill(pid, 0); } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + throw error; + } + if (Date.now() >= deadline) throw new Error(`adb process ${pid} survived cancellation`); + // Await real OS reaping rather than requiring an incidental SIGTERM handler to run. + const { promise, resolve } = Promise.withResolvers(); + setImmediate(resolve); + await promise; + } +} + +const endpoint = "192.168.1.2:37123"; + +describe("wireless endpoints and QR payloads", () => { + test("accepts explicit IPv4, DNS and bracketed IPv6 ports", () => { + for (const value of [endpoint, "phone.local:1", "phone.local.:65535", "localhost:5555", "[::1]:5555", "[2001:db8::42]:12345", "[fe80::1%en0]:12345"]) { + expect(validateAndroidWirelessEndpoint(value)).toBeUndefined(); + } + }); + test("rejects missing ports, flags, malformed addresses and shell/control characters", () => { + for (const value of ["", "phone", "phone:", "phone:0", "phone:65536", "phone:-1", "phone:1e3", "phone:123456", "phone:55 extra", " phone:55", "phone:55\n", "--help:55", "-s:55", "a..b:55", "a-.b:55", "999.1.1.1:55", "1.2.3:55", "::1:55", "[hello]:55", "[::1]", "phone;id:55", "$(id):55", "phone\u0000:55", "phone\u007f:55", "http://phone:55", "user@phone:55"]) { + expect(validateAndroidWirelessEndpoint(value)).toBeString(); + } + }); + test("creates fresh Android-compatible credentials with no payload delimiters", () => { + const first = createAndroidWirelessQr(); + const second = createAndroidWirelessQr(); + expect(first.name).toMatch(/^studio-[a-z0-9]+$/i); + expect(first.secret).toMatch(/^[a-z0-9]{16,}$/i); + expect(first.payload).toBe(`WIFI:T:ADB;S:${first.name};P:${first.secret};;`); + expect(first.name).not.toBe(second.name); + expect(first.secret).not.toBe(second.secret); + }); +}); + +describe.skipIf(process.platform === "win32")("wireless adb subprocess behavior", () => { + test("parses both TLS services, suffix periods and IPv6 while ignoring malformed and legacy rows", async () => { + const fake = fixture({ stdout: [ + "List of discovered mdns services", + `adb-device _adb-tls-pairing._tcp ${endpoint}`, + "studio-exact.\t_adb-tls-pairing._tcp.\tphone.local:1234", + "studio-exact-other _adb-tls-pairing._tcp phone.local:1235", + "adb-device _adb-tls-connect._tcp [::1]:4567", + `adb-device _adb-tls-pairing._tcp ${endpoint}`, + "legacy _adb._tcp 192.168.1.2:5555", + "wrong _adb-tls-pairing._tcp.local 192.168.1.2:5555", + "invalid _adb-tls-connect._tcp --help:5555", + "invalid _adb-tls-connect._tcp 192.168.1.2:0", + "missing _adb-tls-connect._tcp", + "extra _adb-tls-connect._tcp phone:5555 trailing", + "bad\u0000name _adb-tls-connect._tcp phone:5555", + ". _adb-tls-connect._tcp phone:5555", + ].join("\r\n") }); + expect(await discoverAndroidWireless(fake.adb, new AbortController().signal)).toEqual([ + { name: "adb-device", type: "pairing", endpoint }, + { name: "studio-exact", type: "pairing", endpoint: "phone.local:1234" }, + { name: "studio-exact-other", type: "pairing", endpoint: "phone.local:1235" }, + { name: "adb-device", type: "connect", endpoint: "[::1]:4567" }, + ]); + expect(JSON.parse(readFileSync(path.join(fake.root, "args"), "utf8"))).toEqual(["mdns", "services"]); + }); + test("distinguishes empty discovery from an unsupported or failing mDNS command", async () => { + const empty = fixture(); + expect(await discoverAndroidWireless(empty.adb, new AbortController().signal)).toEqual([]); + for (const stdout of ["", "unknown command mdns", "error: mDNS unavailable", "List of discovered mdns services\nerror: backend failed"]) { + const fake = fixture({ stdout }); + await expect(discoverAndroidWireless(fake.adb, new AbortController().signal)).rejects.toThrow(); + } + }); + test("sends manual and QR secrets only through stdin and confirms successful pairing", async () => { + for (const secret of ["012345", createAndroidWirelessQr().secret]) { + const fake = fixture(); + await pairAndroidWireless(fake.adb, endpoint, secret, new AbortController().signal); + expect(JSON.parse(readFileSync(path.join(fake.root, "args"), "utf8"))).toEqual(["pair", endpoint]); + expect(readFileSync(path.join(fake.root, "input"), "utf8")).toBe(`${secret}\n`); + } + }); + test("never leaks an echoed secret on exit-zero or nonzero pairing failures, but reports what adb said", async () => { + for (const exit of [0, 1]) { + const secret = createAndroidWirelessQr().secret; + const fake = fixture({ echoSecret: true, exit }); + const error = await pairAndroidWireless(fake.adb, endpoint, secret, new AbortController().signal).catch((error: unknown) => error); + expect(error).toBeInstanceOf(Error); + expect(String(error)).not.toContain(secret); + expect(String(error)).toContain("error: rejected ******"); + } + expect(androidWirelessDiagnostic("Enter pairing code: 123456\nFailed: Wrong password or connection was dropped.\n", "123456")) + .toBe("****** Failed: Wrong password or connection was dropped."); + }); + test("rejects exit-zero pairing refusal and unrecognized success output, quoting adb", async () => { + for (const stdout of ["Failed to pair: wrong password", "Enter pairing code: error: protocol fault", "", "done"]) { + const fake = fixture({ stdout }); + const error = await pairAndroidWireless(fake.adb, endpoint, "123456", new AbortController().signal).catch((error: unknown) => error); + expect(error).toBeInstanceOf(Error); + if (stdout) expect(String(error)).toContain(stdout.replace("Enter pairing code: ", "")); + expect(String(error)).toContain("pairing port"); + } + }); + test("accepts connected and already-connected responses but rejects exit-zero failures", async () => { + for (const stdout of [`connected to ${reachable}`, `already connected to ${reachable}`]) { + const fake = fixture({ stdout }); + await connectAndroidWireless(fake.adb, reachable, new AbortController().signal); + expect(readFileSync(path.join(fake.root, "input"), "utf8")).toBe(""); + } + for (const stdout of ["failed to connect: Connection refused", "cannot connect to device", "", "done"]) { + const fake = fixture({ stdout }); + await expect(connectAndroidWireless(fake.adb, reachable, new AbortController().signal)).rejects.toThrow(); + } + const fake = fixture({ stderr: "error: connection lost" }); + await expect(connectAndroidWireless(fake.adb, reachable, new AbortController().signal)).rejects.toThrow("error: connection lost"); + }); + test("an address that refuses connections fails at once, without running adb connect", async () => { + const closed = createServer(); + const { promise, resolve } = Promise.withResolvers(); + closed.listen(0, "127.0.0.1", () => resolve()); + await promise; + const refused = `127.0.0.1:${(closed.address() as AddressInfo).port}`; + closed.close(); + const fake = fixture(); + await expect(connectAndroidWireless(fake.adb, refused, new AbortController().signal)).rejects.toThrow("refused the connection"); + expect(existsSync(path.join(fake.root, "args"))).toBe(false); + }); + test("rejects invalid inputs before starting adb", async () => { + const fake = fixture(); + await expect(pairAndroidWireless(fake.adb, "--help", "123456", new AbortController().signal)).rejects.toThrow(); + await expect(connectAndroidWireless(fake.adb, "phone:0", new AbortController().signal)).rejects.toThrow(); + await expect(pairAndroidWireless(fake.adb, endpoint, "123456\nmore", new AbortController().signal)).rejects.toThrow(); + expect(existsSync(path.join(fake.root, "args"))).toBe(false); + }); + test("pre-cancelled operations never spawn and active cancellation kills each subprocess", async () => { + for (const operation of [ + (adb: string, signal: AbortSignal) => discoverAndroidWireless(adb, signal), + (adb: string, signal: AbortSignal) => pairAndroidWireless(adb, endpoint, "123456", signal), + (adb: string, signal: AbortSignal) => connectAndroidWireless(adb, reachable, signal), + ]) { + const fake = fixture({ hang: true }); + const stopped = new AbortController(); + stopped.abort(); + await expect(operation(fake.adb, stopped.signal)).rejects.toMatchObject({ name: "AbortError" }); + expect(existsSync(path.join(fake.root, "args"))).toBe(false); + const active = new AbortController(); + const pending = operation(fake.adb, active.signal).catch((error: unknown) => error); + await waitForFile(path.join(fake.root, "ready")); + const pid = Number(readFileSync(path.join(fake.root, "ready"), "utf8")); + active.abort(); + expect(await pending).toMatchObject({ name: "AbortError" }); + await waitForExit(pid); + } + }); + test("bounds subprocess output and reports unavailable executables without secrets", async () => { + const fake = fixture({ flood: true }); + await expect(discoverAndroidWireless(fake.adb, new AbortController().signal)).rejects.toThrow("size limit"); + await expect(pairAndroidWireless(path.join(fake.root, "missing-adb"), endpoint, "123456", new AbortController().signal)).rejects.toThrow("Could not run adb"); + }); +}); diff --git a/packages/core/test/composeInputs.test.ts b/packages/core/test/composeInputs.test.ts index a832860..7485eba 100644 --- a/packages/core/test/composeInputs.test.ts +++ b/packages/core/test/composeInputs.test.ts @@ -2,7 +2,7 @@ import { afterAll, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { composePreviewApplications, composePreviewBuildTargets, composePreviewInputs, decodeManifest, packagedSourceSets, type AndroidTargetModel, type ModuleModel, type ProjectModel } from "../src/index.js"; +import { composePreviewAnnotations, composePreviewApplications, composePreviewBuildTargets, composePreviewInputs, decodeManifest, packagedSourceSets, type AndroidTargetModel, type ModuleModel, type ProjectModel } from "../src/index.js"; const directories: string[] = []; afterAll(() => { for (const directory of directories) rmSync(directory, { recursive: true, force: true }); }); @@ -76,6 +76,29 @@ describe("host Compose preview inputs", () => { } }); + test("counts preview annotations, qualified and multipreview, but not preview-named functions or imports alone", () => { + for (const text of [ + "@Preview\n@Composable\nfun Card() {}", + "@Preview(showBackground = true, device = Devices.PIXEL_7) @Composable fun Card() {}", + "@androidx.compose.ui.tooling.preview.Preview\n@Composable fun Card() {}", + "@PreviewLightDark\n@Composable fun Card() {}", + "@DevicePreviews\n@Composable fun Card() {}", + ]) expect(composePreviewAnnotations(text)).toBe(1); + expect(composePreviewAnnotations("@Preview @Composable fun A() {}\n@Preview(name = \"dark\")\n@Composable fun B() {}")).toBe(2); + // Delimiters inside the other construct: a URL's `//` in a string, a quote in a comment, a quoted `"`. + expect(composePreviewAnnotations("val url = \"https://example.com\"\n// it's \"quoted\"\nval quote = '\"'\n@Preview @Composable fun A() {}")).toBe(1); + for (const text of [ + "package sample\nclass MainActivity : ComponentActivity()", + "@Composable\nfun CardPreview() {}", + "import androidx.compose.ui.tooling.preview.Preview\n@Composable fun Card() {}", + "@Composable fun Card(device: String = androidx.compose.ui.tooling.preview.Devices.PIXEL) {}", + "// @Preview\n@Composable fun Card() {}", + "/* @Preview\n@Composable */ fun Card() {}", + "/** Annotate with @Preview to render. */\n@Composable fun Card() {}", + "val hint = \"Add @Preview\"\nval doc = \"\"\"\n @Preview\n\"\"\"", + ]) expect(composePreviewAnnotations(text)).toBe(0); + }); + test("reads merged namespace and fresh original classes, excludes stubs and stale AAR versions", async () => { const { app, library, target, stage, models } = fixture(); const kotlin = path.join(app.root, ".dev/jvm/classes/screen/kotlin"); diff --git a/packages/core/test/workspace.test.ts b/packages/core/test/workspace.test.ts index ddd57e6..cd85e08 100644 --- a/packages/core/test/workspace.test.ts +++ b/packages/core/test/workspace.test.ts @@ -446,6 +446,32 @@ cli expect(models[1]!.modules[0]!.compilationTargets).toEqual(["model:compile-kotlin-main"]); }); + test("an APK whose manifest declares is a test APK; android:debuggable=\"false\" withdraws Debug", async () => { + const { ws, cliAtRoot } = workspace(); + const manifest = JSON.parse(readFileSync(path.join(ws, ".fake", "manifest.json"), "utf8")); + manifest.android = { compileSdk: 36 }; + manifest.artifacts = { + ...manifest.artifacts, + tests: { "@type": "elide.android.AndroidApk", sources: ["main"] }, + release: { "@type": "elide.android.AndroidApk", sources: ["main"], manifestFile: "release/AndroidManifest.xml" }, + }; + writeFileSync(path.join(ws, ".fake", "manifest.json"), JSON.stringify(manifest)); + mkdirSync(path.join(ws, "src", "main"), { recursive: true }); + writeFileSync(path.join(ws, "src", "main", "AndroidManifest.xml"), ` + + +`); + mkdirSync(path.join(ws, "release")); + writeFileSync(path.join(ws, "release", "AndroidManifest.xml"), ``); + // Elide 1.6.1+43bf7ad8d lists every device target for both, whatever their manifests say. + const listed = ["tests", "release"].flatMap((apk) => ["run", "debug", "test"].map((use) => ` logstat:android-${use}-${apk} ${use}`)); + writeFileSync(path.join(ws, ".fake", "build-inspect.txt"), `6 tasks across 1 project:\nlogstat\n${listed.join("\n")}\n`); + const [root] = await build(cliAtRoot); + const device = (name: string) => root!.android?.targets.find((target) => target.name === name)?.device; + expect(device("tests")).toEqual({ runnable: true, debuggable: true, instrumented: true }); + expect(device("release")).toEqual({ runnable: true, debuggable: false, instrumented: false }); + }); + test("the workspace names every library once, whichever member resolved it", async () => { const { cliAtRoot, stdlib } = workspace(); const models = await build(cliAtRoot); diff --git a/packages/vscode/BUILD.bazel b/packages/vscode/BUILD.bazel index 5dddb73..ef34926 100644 --- a/packages/vscode/BUILD.bazel +++ b/packages/vscode/BUILD.bazel @@ -16,6 +16,8 @@ ts_project( srcs = [ "src/activity.ts", "src/android.ts", + "src/androidDatabase.ts", + "src/androidDatabaseHtml.ts", "src/androidDebug.ts", "src/androidDeployment.ts", "src/androidDeploymentState.ts", @@ -23,12 +25,19 @@ ts_project( "src/androidReadiness.ts", "src/androidStackLinks.ts", "src/androidStackSource.ts", + "src/androidTesting.ts", + "src/androidWireless.ts", + "src/androidWirelessHtml.ts", "src/codelens.ts", + "src/composeAgent.ts", + "src/composeAgentMcp.ts", + "src/composeAgentSource.ts", "src/composeBackend.ts", "src/composeCompiler.ts", "src/composeKomodo.ts", "src/composePreview.ts", "src/composePreviewHtml.ts", + "src/composeProcessing.ts", "src/composeRender.ts", "src/composeRuntime.ts", "src/composeSource.ts", @@ -64,8 +73,10 @@ ts_project( deps = [ ":node_modules/@elide/ide-core", ":node_modules/@types/node", + ":node_modules/@types/qrcode", ":node_modules/@types/vscode", ":node_modules/@types/yauzl", + ":node_modules/qrcode", ":node_modules/vscode-languageclient", ":node_modules/yauzl", ], @@ -101,6 +112,7 @@ esbuild( tsconfig = "tsconfig.json", deps = [ ":node_modules/@elide/ide-core", + ":node_modules/qrcode", ":node_modules/vscode-languageclient", ":node_modules/yauzl", ], @@ -167,6 +179,7 @@ bun_test( ) + [ "package.json", ":node_modules/@elide/ide-core", + ":node_modules/qrcode", ":node_modules/yauzl", ], ) diff --git a/packages/vscode/README.md b/packages/vscode/README.md index f8e4ba7..30a6d13 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -52,7 +52,8 @@ test, and debug commands are available from the editor. `res/` and `assets/` of each artifact become resource roots of the source sets it packages, and a sibling Android library's classes are resolved as a module dependency rather than an opaque jar, so navigation works across an application and the libraries it consumes. Running an APK installs and launches it on a device you pick. - Compose component previews render locally with layoutlib, without a device or emulator, and refresh while editing. Logcat streams have + Compose component previews render locally without a device or emulator, as layoutlib snapshots or, with the + experimental Komodo engine, as live cards, and refresh while editing. Logcat streams have application, priority and text filters. Dagger/Hilt generated sources are included in editor synchronization. - **Debugging.** Launch `elide run --debugger` and attach with breakpoints in Kotlin and Java, debug an Android application on the device it was installed on, or build a Native Image artifact and debug the binary it produced @@ -100,7 +101,7 @@ accept); add it to `.gitignore` instead to cover every clone. | --- | --- | | `Elide: Sync Project(s)` | Re-run the sync. | | `Elide: Show Menu` | Quick pick with the Elide actions (what the status bar item opens). | -| `Elide: Run Elide Command…` | Pick `build`, `test`, `install`, or `run ` and run it as a task. | +| `Elide: Run Elide Command…` | Pick `build`, `test`, `install` or `run ` and run it as a task, or run, debug or test an Android APK on the deployment target. | | `Elide: New Project…` | Create a project from an Elide template in a directory you pick. | | `Elide: Add Workspace Member…` | Create a project inside an existing one and declare it in `workspace.members`. | | `Elide: Open Generated Kotlin LSP Workspace` | Open the generated `workspace.json`. | @@ -110,14 +111,19 @@ accept); add it to `.gitignore` instead to cover every clone. | `Elide: Select Android Deployment Target…` | Choose the remembered project, APK and device shared by Run and Debug. | | `Elide: Check Android Environment` | Inspect local tools and device readiness, including before sync succeeds; select a finding for fixes. | | `Elide: Refresh Android Devices` | Refresh devices without a project resync. | +| `Elide: Pair Android Device over Wi-Fi…` | Pair with a QR or six-digit code; discover wireless devices or connect an already-paired device by address. | | `Elide: Run Android App…` | Build an APK and install and launch it on a device or the emulator. | | `Elide: Debug Android App…` | Build an APK, install and launch it waiting for a debugger, and attach over JDWP. | -| `Elide: Start Android Emulator` / `Elide: Stop Android Emulators` | Boot the project's AVD, or shut running emulators down. | +| `Elide: Run Android Instrumented Tests…` | Install a test APK on the deployment target's device and run its instrumented tests, with results in the Test Explorer. | +| `Elide: Start Android Emulator` / `Elide: Stop Android Emulator` | Boot the project's AVD, or shut it down; a device row's **Stop** stops that emulator only. | | `Elide: Create Android Virtual Device` | Create the AVD `android.device` declares, without booting it. | | `Elide: Install Android SDK Components` | Install the SDK components the pipeline is missing (`--install-sdk` accepts their licenses). | -| `Elide: Create Android Signing Keystore` | Mint the keystore an APK signs with, using the Android debug conventions. | -| `Elide: Open Compose Preview` | Render the Kotlin file's Compose previews beside the source using host-side layoutlib. | +| `Elide: Create Android Signing Keystore` | Mint the keystore an APK or app bundle signs with, using the Android debug conventions. | +| `Elide: Open Compose Preview` | Render the Kotlin file's Compose previews beside the source: layoutlib snapshots by default, live cards with the experimental Komodo engine. | +| `Elide: Copy Compose Preview MCP Server Configuration` | Copy the JSON an MCP client needs to reach this window's [Compose preview tools for agents](#compose-previews-for-ai-agents-mcp) (URL and access token). | +| `Elide: Close Agent Compose Preview Sessions` | Cancel in-flight agent renders and end live sessions agents left open; the preview panel resumes. | | `Elide: Show Android Logcat` | Open a filtered device log stream; Filter, Clear, Stop and Restart commands manage it. | +| `Elide: Explore Android Databases` | Browse and query a debuggable app's Room / SQLite databases on a connected device. | ## Settings @@ -145,9 +151,8 @@ accept); add it to `.gitignore` instead to cover every clone. | `elide.android.emulatorWindow` | `true` | Boot the emulator with a window; off passes `--no-window`. | | `elide.android.composePreview.prewarm` | `true` | Start the Compose preview backend when a file with `@Preview` functions is focused. | | `elide.android.composePreview.keepAliveMinutes` | `10` | Minutes an unused Compose preview backend stays warm; `0` stops it with its panel. | -| `elide.android.composePreview.engine` | `layoutlib` | `komodo` (opt-in, experimental) adds a `komodo-preview` daemon: cards it hosts are live, the rest keep their layoutlib snapshot. | -| `elide.android.composePreview.komodo.path` | `""` | The `komodo-preview` daemon; empty resolves it beside the Elide binary, then on `PATH`. | -| `elide.android.composePreview.komodo.fallback` | `true` | Cards Komodo cannot render keep their layoutlib snapshot; off shows the reason on the card. | +| `elide.android.composePreview.engine` | `"layoutlib"` | `komodo` (experimental) renders live cards inside the workspace's Komodo language server: unsaved Kotlin edits, clicks, keyboard input, compile errors in Problems, and **Inspect** for runtime layout bounds. Requires `elide.kotlinLsp.server` = `komodo`; no fallback to `layoutlib`. See [Komodo Compose preview engine](#komodo-compose-preview-engine-experimental). | +| `elide.android.composePreview.mcp.enabled` | `true` | Serve the Compose preview to AI agents over MCP (loopback HTTP, token-protected). See [Compose previews for AI agents](#compose-previews-for-ai-agents-mcp). | In a multi-root workspace each folder can set its own value of every setting except `elide.sync.showProgressNotification`, `elide.android.emulatorWindow` and the `elide.android.composePreview.*` @@ -165,11 +170,12 @@ The extension also maintains two Kotlin LSP settings, and explicit values you se ### Komodo language server (experimental) -Komodo is an optional native Kotlin language server and debugger managed by Elide. It is +Komodo is an optional native Kotlin language server, debugger and live Compose preview engine managed by Elide. It is opt-in; the JetBrains Kotlin LSP stays the default. To use it, set: ```jsonc -"elide.kotlinLsp.server": "komodo" // per folder: Kotlin language server and debugger +"elide.kotlinLsp.server": "komodo", // per folder: Kotlin language server and debugger +"elide.android.composePreview.engine": "komodo" // optional, window-wide: live Compose previews ``` Leave executable, plugin and JDK overrides empty for managed use. The selected Elide CLI runs @@ -249,6 +255,50 @@ also attach to any JDWP port (`hostName`, `port`, `timeout`, `sourceRoots`) or l A folder opened through a symlinked path is not matched to the manifest's paths. +### Komodo Compose preview engine (experimental) + +With `elide.android.composePreview.engine` set to `komodo` (and the folder on Komodo's language server), **Elide: +Open Compose Preview** renders every card live inside that same `komodo` process, compiled from the language +server's own analysis: no separate preview compiler or renderer JVM. It uses the same verified layoutlib runtime the +`layoutlib` engine downloads on first use. Unsaved Kotlin edits of the +previewed file and the files it uses reach the cards as you type; clicks run their handlers; `@PreviewParameter` +providers expand into one card per value; compile errors appear in the Problems view and on the cards, which recover +once the code compiles again. A card Komodo cannot render says why. There is no fallback to the `layoutlib` engine: +a `komodo` too old for the engine says so. The project's resources and library classes come from the last Elide +build, which the panel runs as with `layoutlib`; Java and resource changes need a save. A save that rebuilds the +project starts a new session over the new build's classes. + +Missing or incompatible Komodo automatically disables native previews **at runtime**, leaving engine and language +server settings unchanged. Opening a panel, prewarming and MCP rendering check availability before project builds or +compiler/runtime preparation; a native session also validates its protocol handshake before those operations. +Availability loss closes native panels, cancels pending renders and retires live agent sessions and idle native +backends, so obsolete frames or queued interactions cannot be returned. Layoutlib previews and ordinary project +builds remain independent. There is no automatic Komodo download or renderer fallback. + +A protocol/capability incompatibility stays disabled rather than retrying builds on each edit. Use **Elide: Install +Komodo**, fix the executable/configuration or sync to recheck it. Once compatible, reopen the panel or render a new +agent session without reloading the window; old session IDs are intentionally retired. + +**Inspect** outlines each card's composable calls (`Text`, `Column`, your own composables) by their measured runtime +layouts; select them by pointer or arrow keys, read their size and image-pixel position, and choose **Open source**, +double-click or press Enter to open the call in the file Komodo compiled. Only calls located in the project's sources +are offered; library internals are omitted. Bounds and call sites follow each frame through edits and recovery, +including calls an edit above them moved. The **Parent** control selects the enclosing call by containment of +measured bounds. Calls inside another workspace module come from Elide's build of it and are located only when that +build recorded Compose source information, which Elide does for modules a debug Android app (`release = false`) +uses and leaves out of release builds. Rebuild after switching; calls without a location still show their bounds. + +**Keyboard input.** Click or Tab to a live card's canvas to focus it, then type into its Compose controls. Only that +card receives keys (with Shift/Ctrl/Alt/Meta and repeat): text input, Backspace/Delete, arrows, Enter, and Tab between +Compose controls. Press **F6** or **Shift+F6** (VS Code's **Focus Next/Previous Part**) or click outside the canvas to +leave; Escape still reaches Compose. Leaving the canvas or window, switching cards, enabling **Inspect**, or closing +the panel releases held keys. IME composition and clipboard paste are not supported, and only characters Android's +hardware-key input can represent can be typed. + +Known limits: the project's own Kotlin compiler arguments and compiler plugin versions are not applied (Komodo's +pinned Kotlin and bundled plugins are), and code Komodo cannot compile yet shows as "the program does not compile" +with Komodo's message. + ## Tasks and debugging Task type `elide` takes `command` (`build` | `run` | `test` | `install`), `project` (project root relative to the @@ -327,17 +377,20 @@ Elide builds Android applications and libraries without Gradle: every step is an | Row | Target | | --- | --- | -| An `AndroidApk` artifact | `elide build ` to package it; **Run** installs and launches it with `elide build android-run-`, pinned to the picked device with `--device=`; **Debug** does the same through `android-debug-` and attaches over JDWP. | +| An `AndroidApk` artifact | `elide build ` to package it; **Run** installs and launches it with `elide build android-run-`, pinned to the picked device with `--device=`; **Debug** does the same through `android-debug-` and attaches over JDWP, for a debuggable APK (see below); **Run Instrumented Tests** runs `android-test-` on the deployment target for an APK carrying instrumentation (`instrumentation` in `elide.pkl`, or `` in its manifest). Each action is offered only when Elide lists its target: none is listed for an APK Elide cannot install, such as an unsigned one, whose row and code lens then only build it. | | An `AndroidAab` artifact | `elide build `, which publishes `.dev/artifacts/.aab`. | | An `AndroidAar` artifact | `elide build `, which publishes `.dev/artifacts/.aar`. | | An `AndroidFeature` module | `elide build android-module-`, staging `.dev/android//.zip`. Its row identifies the base bundle, delivery mode and required features; build the owning AAB to publish it. | | The mapping of a minifying artifact | A row beside an artifact with `minify = true`, which opens the `.dev/artifacts/-mapping.txt` R8 published, publishing it with `android-mapping-` first when it is not there: without it a stack trace from a shrunk build cannot be read. | -| **Devices** | `adb devices` of the resolved SDK; a row in the `device` state runs the project's APK on that serial. | -| **Start emulator** / **Stop emulators** / **Create AVD** | `android-emulator` (with `--no-window` when `elide.android.emulatorWindow` is off), `android-emulator-stop`, `android-avd`. | +| **Devices** | `adb devices` of the resolved SDK; a row in the `device` state runs an APK of that workspace on that serial — the active file's app, else the selected one, else its first — and an emulator's row stops that emulator (`android-emulator-stop --device=`). | +| **Start emulator** / **Stop emulator** / **Create AVD** | `android-emulator` (with `--no-window` when `elide.android.emulatorWindow` is off), `android-emulator-stop`, `android-avd`. **Stop emulator** stops the emulator running the project's AVD and leaves every other emulator running. | | **Install Android SDK components** | `android-sdk --install-sdk`, shown when no SDK was found. | Every other Android step — `android-resources-*`, `android-link-*`, `android-dex-*`, `android-r8-*`, -`android-sign-*`, `android-bundle-*` — is listed under **Build targets** like any other target of the project. +`android-sign-*`, `android-bundle-*` — is listed under **Build targets** like any other target of the project. A +target there that installs onto a device (it takes `--device`: `android-run-*`, `android-debug-*`, `android-test-*`) +runs on the deployment target's device, resolved as **Run** resolves it, rather than on whichever device Elide would +pick. Targets Elide gives no description, its internal ones, are grouped under **Other targets**. A published `.aar` dependency appears under **Dependencies** named by its coordinate, with the sources of the archive attached: Elide unpacks each one into `.dev/android/libraries////` and compiles @@ -352,9 +405,74 @@ generated again the same way rather than left out. An Android application is debugged on the device: an `elide-android` launch configuration builds the APK, installs it, launches it waiting for a debugger and attaches to the JDWP socket the target forwards. The APK has to be -debuggable — `android:debuggable="true"` on ``, which nothing in the build injects — and the target -says so and stops when it is not. Unit tests of an Android project are plain JVM tests and run in the Test Explorer -as usual. +debuggable — `android:debuggable="true"` on `` of the merged manifest. Newer Elide releases add it to a +`release = false` build, as Gradle does, and list `android-debug-` only for an APK that ends up +debuggable; older Elide builds with Android support add nothing and list it anyway. Debug is therefore also withheld for an +APK whose manifest sets `android:debuggable="false"`, and for one whose latest build staged a merged manifest +(`.dev/android//AndroidManifest.xml`) without `android:debuggable="true"`, until the source manifest +changes. Unit tests of an Android project are plain JVM tests and run in the Test Explorer as usual. + +Instrumented tests run on a device. An `AndroidApk` carrying instrumentation is a test APK: one declaring +`instrumentation` in `elide.pkl`, or an `` element in its manifest, and for which Elide lists +`android-test-`. Its tests, found in the source sets it packages, appear in the Test Explorer under +**Elide Android Instrumented Tests**. Running them — +from there, the APK's row or code lens, or **Elide: Run Android Instrumented Tests…** — installs the APK on the +deployment target's device with `elide build android-test- --device=`, narrowed to the selected +classes and methods with `--tests`, and reports each test's pass, failure (with its stack trace) or skip. There is +no Debug profile: the target attaches no debugger. +Excluding every selected test runs nothing: it never falls back to the whole APK. Cancelling preparation or +closing the extension prevents a late device reply from starting a test process. + +A failed Android Run, Build (APK or app bundle) or Debug says why in a notification: Elide's first error and its +hint, also logged to the Elide output channel, with **Show Terminal** for the whole output. When the hint names a +missing keystore (`Create one with: elide build android-keystore-`), the notification offers **Create +Android Signing Keystore**. When a Run or Debug cannot install because the app on the device is signed with another +key (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`, as after a new debug keystore), it offers **Uninstall and Retry**: after a +confirmation naming the device, since uninstalling erases the app's data there, it runs `adb uninstall` for that +application ID and the same Run or Debug again. For a pinned AVD, the extension rechecks its identity after +confirmation and refuses deletion if the serial now belongs to another AVD or its identity cannot be read. +These tasks run in a terminal the extension feeds, with Elide's plain-text progress (`ELIDE_PROGRESS=plain`); +the `$elide` problem matcher reads it as before. + +For Android workspaces, Elide starts the JetBrains Kotlin server with synchronous JDI metadata queries. In +JetBrains Kotlin 0.0.13, a cold asynchronous renderer-selection future can remain pending after a Kotlin expression +has already evaluated, leaving even a string literal or `this.getPackageName()` waiting for its presentation. +The compatibility default changes metadata transport, not expression evaluation or its timeout; synchronous +queries can be slower on high-latency device connections. It is inherited through the JetBrains launcher's +`IJ_JAVA_OPTIONS` in this VS Code window only, like the Compose plugin path, without writing user/workspace JVM +settings. An explicit `-Ddebugger.async.jdi=…` in inherited launcher options or `intellij.additionalJvmArgs` +takes precedence. Sync restarts an already-running server when needed, and Android attach waits for the new +daemon's debugger to initialize. The inherited options are restored when the last Android project leaves the +window or Elide deactivates. + +The JetBrains Kotlin debugger cannot bind breakpoints in a folder opened through a symlink (macOS `/tmp` is +`/private/tmp`): it resolves each breakpoint by the path VS Code sends, logs `Could not find virtual file for +path`, and the breakpoint silently never binds. Every JVM and Android debug launch from such a folder warns and +offers **Reopen Folder at Real Path**. + +#### Wireless pairing + +Use **Elide: Pair Android Device over Wi-Fi…** from the Command Palette or Elide menu, or **Pair device over +Wi-Fi** in a synced project's Android tree. Requires Android 11+, current SDK Platform-Tools and the device +and extension host on the same reachable network. No USB cable or Android Studio installation is required. + +- Enable **Developer options → Wireless debugging** on the device. +- **QR code:** generate a code in the panel, then use the device's **Pair device with QR code** scanner. +- **Six-digit code:** open **Pair device with pairing code** on the device, refresh discovery, select its address + (or enter it manually), and enter the six-digit code. +- ADB normally connects automatically. If it does not, use **Connect an already-paired device** with the + **IP address & port** from the main Wireless debugging screen, not the pairing dialog's port. +- Refresh Android Devices if necessary, then **Select Android Deployment Target…** for Run and Debug. + Wireless devices also work with the existing Logcat and database pickers; pairing never replaces a pinned target. + +QR discovery expires after two minutes and requires mDNS. Manual pairing/connection addresses work when +mDNS is blocked; VPNs, firewalls or guest-network isolation can still prevent connections. With Remote SSH +or containers, ADB runs on the extension host and needs network access to the device from there. +`elide.android.sdk` overrides the project's SDK for this tooling. Legacy unencrypted `adb tcpip` is not enabled. + +Codes are neither saved nor logged and reach ADB through stdin, not process arguments. Cancel or close the +panel to stop its operations and discard the QR code. This cannot undo pairing already accepted by the device; +choose **Forget** under **Paired devices** on the phone to revoke the workstation's access. #### Deployment target and environment @@ -363,13 +481,18 @@ shared by Run and Debug. The selection persists in workspace state. Explicit tre otherwise a runnable app owning the active file takes precedence over the remembered app. Library files do not accidentally turn their library into a deployment target. -Each deployment validates the artifact against the synced model and checks fresh adb state. If the selected +Each deployment validates the artifact against the synced model — Run needs the `android-run-` target Elide +lists for it, Debug `android-debug-` — and checks fresh adb state. If the selected physical device is disconnected, offline or unauthorized, deployment stops rather than silently choosing another device. A selected emulator that is not running is booted first: the target remembers the AVD it ran, finds it -again by that identity (its serial may change), and re-pins the new serial. Run and Debug save dirty file-backed -documents before building and stop if saving fails. Emulator startup identifies the exact AVD, is cancellable and -bounded, and rejects ambiguous instances. Android launch configurations also validate their target; `flags`, -`options` and `env` layer over workspace defaults, with the selected device taking precedence. +again by that identity (its serial may change), and re-pins the new serial. A ready serial reused by another AVD +does not count as the selected emulator. Run and Debug save dirty file-backed documents before building and stop +if saving fails. Emulator startup identifies the exact AVD, is cancellable and bounded, and rejects ambiguous +instances; cancellation during an adb readiness check prevents deployment. Android launch configurations also +validate their target; `flags`, `options` and `env` layer over workspace defaults, with the selected device taking precedence. +Late picker, device-discovery and emulator-startup replies are discarded when a newer selection supersedes them. +A Run or Debug whose emulator is still starting goes on through a dismissed target picker or a sync; when another +target is selected meanwhile, it deploys nothing and says so. **Check Android Environment** works before a successful sync. It reports Elide, JDK, SDK/platform-tools, connected devices and Kotlin extension availability; synced declarations additionally enable compile-SDK, @@ -384,9 +507,15 @@ Device rows refresh after Elide tasks or **Refresh Android Devices**, without ba #### Compose component preview -Requires the experimental Elide `feat/android` branch with persistent compiler workers, -transactional incremental compilation, `--warm-incremental`, `--report-abi`, and recorded `kotlinc.args`/`javac.args`, -an Android SDK for compilation/resource linking, and a JDK 17+ matching the extension +This section describes the default `layoutlib` engine (`elide.android.composePreview.engine`). The experimental +live engine is described under [Komodo Compose preview engine](#komodo-compose-preview-engine-experimental). + +Requires an Elide build with Android support (currently the internal release's distribution; not yet in a public +Elide release) with persistent compiler workers, +transactional incremental compilation, `--warm-incremental`, `--report-abi`, recorded `kotlinc.args`/`javac.args`, +and `kotlinc.toolchain.json`. Kapt modules additionally need `kapt.args` and `kapt.generated.json`. +These versioned records identify the actual compiler and processor inputs; rebuild after upgrading Elide. +An Android SDK is required for compilation/resource linking, with a JDK 17+ matching the extension host's architecture. **No device, emulator, Android Studio, signing keystore, debuggable application or `PreviewActivity` is required.** @@ -396,10 +525,16 @@ host's architecture. **No device, emulator, Android Studio, signing keystore, de annotations, not the renderer's Compose adapter. Keep tooling dependencies out of production artifacts. 3. Declare an `AndroidApk` artifact as the resource context. The preview builds only its resource/R-class targets and the relevant JVM compilation targets; it does not package, sign, install or launch that APK. -4. Sync, open a Kotlin file and invoke **Elide: Open Compose Preview** from the Command Palette or editor title. +4. Sync, open a Kotlin file with `@Preview` functions and invoke **Elide: Open Compose Preview** from the Command + Palette or editor title. The editor-title button appears only on Kotlin files that declare previews (annotations + whose name contains `Preview`, multi-preview annotations included) and that an Android application of the + workspace packages; on a file without them the command says so instead of building. Run while the folder's first + sync is still running, it waits for that sync (cancellable) and reports the sync's error if it fails. Run again + for the file already previewed, it brings that panel forward rather than building again. The resource context is chosen automatically: the `AndroidApk` that packages the file's source set, or else the nearest application depending on its workspace library; equally near applications go by workspace and artifact - declaration order. + declaration order. The panel follows the previewed file when it, or a folder containing it, is renamed or moved, + and reopens after **Developer: Reload Window**. The extension uses [Google's standalone Compose renderer](https://android.googlesource.com/platform/tools/base/+/HEAD/standalone-render/compose-cli/README.md) and layoutlib—the host rendering engine used by Android Studio—not a JavaScript approximation. @@ -412,51 +547,104 @@ error rather than falling back to a device. Discovery reads compiled annotations, so constant annotation values, repeated `@Preview`, member functions, multi-preview meta-annotations and `@PreviewParameter` providers reach the renderer. The gallery displays each configuration/provider value, respecting options such as dimensions, background, locale, font scale and UI mode. -Rendering errors expose diagnostics on the affected card while retaining its last successful image. +Rendering errors expose diagnostics on the affected card while retaining its last successful image. A card's +**Details** show the exception and its stack trace once, with frames in the project's sources linked to the line the +preview compiled. Kotlin/Java changes refresh live, including unsaved editor buffers. Persistent compiler workers incrementally compile private source snapshots; persistent layoutlib workers render the new classes. The gallery keeps its previous images while updating, publishes only the latest edit, and reports elapsed time from change detection through webview paint. Compiler failures retain the gallery, scroll position and selection, with diagnostics in **Details** rather than replacing the images. Fresh PNGs decode before publication; unchanged pixels are reused. -Preview compilation does not overwrite workspace sources or build outputs. -Large galleries use up to six renderer JVMs, limited to half the host's available CPUs. +Preview compilation does not overwrite workspace sources or build outputs. Concurrent requests sharing a backend +render their own source snapshot, even when another request publishes new classes while they wait. +Each renderer JVM holds layoutlib and the application's classes, several hundred MB, so a backend runs about one per +four previews of the gallery it renders, at most four and half the host's available CPUs. A larger gallery starts +more in the background, joining the next render; JVMs a smaller gallery leaves idle stop after a minute. Source ownership follows the synced manifest's source selections. Adding the first Java source to a source set without recorded Java compiler inputs requires a save and full rebuild. +Room and other kapt processors can share the preview's module. The worker uses the build's exact managed +Kotlin compiler classpath or external Kotlin installation, including version-matched Compose/kapt plugins. +Live compilation first uses a private copy of the last generated sources and paints with *verifying annotation +processing…*. The project's warm Kotlin worker also generates stubs from the same unsaved source snapshot. Unchanged stubs +and processing inputs confirm the paint without rerunning processors; changed declarations, handwritten Java, +or processing dependencies run kapt, replace generated sources/resources, recompile, and repaint. +The first verification of a compiler session runs processing to establish an authoritative baseline. +Processor errors remain visible and never confirm or persist a stale gallery. Only confirmed galleries are +stored, so reverting a schema edit or invalid query can reuse the previous gallery. + +Generated files are not editable live inputs. Absolute/path-named processor options, including Room's +`room.schemaLocation`, are redirected to private copies; preview processing never updates the project's schema +exports. Processors must declare their inputs through the compiler classpath or those options: arbitrary +undeclared filesystem/network inputs cannot be tracked. Hilt Android bytecode transformation is a separate +build step and still requires saving and rebuilding; it is not silently skipped during live compilation. + Opening a preview builds JVM classes and linked resources, but does not wait for that build to paint: - **Prewarm.** Focusing a Kotlin file with `@Preview` functions in a trusted, synced Android project starts the renderer JVMs and compiler workers in the background and renders that file from the last build's classes, so - **Elide: Open Compose Preview** typically paints in well under a second. Prewarming never builds and never - downloads the renderer runtime; it is off when `elide.android.composePreview.prewarm` is disabled. + **Elide: Open Compose Preview** typically paints in well under a second. The status bar shows the warm-up and its + current phase; a large project's first one can take a minute. Opening the preview meanwhile joins it rather than + starting another. Prewarming never builds and never downloads the renderer runtime; it is off when + `elide.android.composePreview.prewarm` is disabled. A prewarm that fails is reported in the `Elide` output channel. - **Keep-alive.** Closing the panel keeps its renderer and warm compilers for `elide.android.composePreview.keepAliveMinutes` (default 10; `0` stops them with the panel). Reopening reuses them. The backend holds several GB of memory while warm; at most one unused backend is kept. - **Build outputs as the first generation.** When the last build's classes match the current sources, they are - displayed directly instead of a cold compile; the incremental compiler warms in the background afterwards, - adding source information for layout inspection without delaying the first image. -- **Stored galleries.** Each gallery is stored under its exact inputs (build fingerprint, source text, renderer and - JDK), so reopening an unchanged file, even after restarting VS Code, or undoing an edit paints immediately. + displayed directly instead of waiting for a cold compile. Before confirming readiness for live edits, the backend + initializes one instrumented private baseline for the app and its workspace libraries, then verifies processing + against those final dependencies while initializing their renderer. Initialization belongs to that private + classpath: rendering an earlier build candidate does not warm its replacement. Cached pixels skip rendering + disposable seeded classes. Workspace members compiling with managed Compose or the compiler plugins bundled with + Elide share one compiler worker, which also runs their kapt; custom plugins retain project-local workers. The + initial pixels remain visible throughout initialization. +- **Stored galleries.** Each gallery is stored under its exact inputs (build fingerprint, source text, renderer, + JDK and published instrumentation provenance), so reopening an unchanged file, even after restarting VS Code, + or undoing an edit paints immediately. A cold backend can replay an instrumented gallery while initializing. A + gallery with a failed card is neither stored nor replayed: it renders again. Until the build finishes, the status reads *verifying build…*; if the build changes resources, dependencies or -compiler settings, the gallery re-renders. Saving `.xml` or `.pkl` files, changing the project model, or pressing -**Rebuild** performs that build again. Projects using generated annotation-processor sources require a save -and full rebuild; unsaved changes show a save prompt and retain the last successful preview. A first preview of a -project that was never built, or whose sources changed since its last build without a prewarmed backend, waits -for the build and a cold renderer. +compiler settings, the gallery re-renders. A build that changes only input content (linked resources, R classes, +libraries) keeps the warm compiler: unchanged classes re-render against the new resources at once, and members +whose classpath changed recompile incrementally. Only changed compiler records or toolchains start a new compiler, +which initializes again. Reverting such a change reuses its stored gallery once the build confirms it. Saving +`.xml` or `.pkl` files, changing the project model, or pressing **Rebuild** performs that build again. Opening a +preview does not save unsaved editor buffers, whatever +`task.saveBeforeRun` says: the build runs as a background process rather than a VS Code task and supplies the disk +baseline, then the live compiler applies the editor snapshot. A first preview of a project that was +never built, or whose sources changed since its last build without a prewarmed backend, waits for the build +and a cold renderer. + +Elide records canonical paths; a workspace opened through a symlink (such as macOS `/tmp`) previews the same +files. A build record that names another checkout or generated files that no longer exist, as the build cache +can restore, is reported as stale with **Rebuild without cache**, which rebuilds the preview inputs with +`--no-cache` to rewrite it. + +A failed or cancelled build leaves the gallery unverified; the next refresh retries build/resource preparation +before confirming it. **Cancel** on the preview's **Activity** row stops whichever step runs, from the runtime +download to compiler preparation, and **Rebuild** during a build stops that build and starts a new one. Closing the +panel stops its build and prevents pending runtime preparation from starting another build after the panel closes. +Backend shutdown cancels its workers and waits for pending setup/compilation cleanup before removing temporary +files; a later window removes the temporary workspace of a window that closed, reloaded or crashed first. Renderer +failures retain the originating diagnostic rather than a sibling worker's shutdown error. While the preview builds, the status line names the tasks Elide has finished so far, the most recent one with its duration, and counts the build's elapsed time. **Build log** opens the build's output inside the panel as it arrives; **Open in editor** shows the complete log with timestamps, and **Timing** reveals the build in the -**Activity** view. A failed build quotes the compiler errors under **Details** and lists them in the Problems view. +**Activity** view; the `Elide` output channel receives the same output. A failed build or live compile shows its +first compiler error, with file and line, in the status line and the complete compiler output under **Details**; a +failed build's errors are also listed in the Problems view until the next preview build or until the panel closes. Once a gallery paints, the status line breaks its time down by phase (build, compile, render). Builds do not steal -editor focus; the build's task terminal stays available in the Terminal panel. +editor focus. The gallery follows VS Code's light, dark and high-contrast themes. **Fit** constrains images to their cards; toggle it for actual pixel size. -**Inspect** overlays Compose's measured layout bounds. Click a component to select it, then choose **Open source**, +**Inspect**, in the toolbar, overlays Compose's measured layout bounds and adds an inspector below each card (the +`layoutlib` engine's inspection is described here; the Komodo engine's is above); with it +off, cards show only the image and its size, and one line above the gallery points to it. Click a component to +select it, then choose **Open source**, double-click, or press Enter to navigate to its call site, including calls in workspace libraries. The component picker and **Parent** control select enclosing components. With a preview focused, arrow keys cycle components, Home/End select the first/last, and Alt+Up selects an enclosing component. @@ -465,7 +653,9 @@ Inspection uses real tooling bounds and compiler UTF-16 source ranges, not image matches. Only locations uniquely resolved in the preview's compiler inputs are selectable; dependency internals and ambiguous source files are omitted. Bounds are rectangular measured layouts, not arbitrary painted contours. Private preview compilation enables Compose source information without changing the application's build. -When displaying last-build classes, outlines may arrive after the background compiler warms. +**Inspect** requests instrumented app and workspace-library classes even for saved sources; it never treats an +image-only build gallery as an inspected render. Outlines may arrive after the initial cached/build image while +the compiler initializes. Unsupported bytecode post-processing is reported rather than silently claiming bounds. Source navigation pauses during edits or failed builds so retained images cannot open stale source ranges. These are design-time snapshots, not interactive animation/click simulation or in-process Live Edit. Each render @@ -488,6 +678,57 @@ If opening a source file has already started companion activation, sync waits fo the relaunch, so the first server cannot keep outdated plugin arguments. With no open source file, activation remains lazy. Other functions with capitalized names are still reported. +#### Compose previews for AI agents (MCP) + +The extension serves the Compose preview to AI agents as [MCP](https://modelcontextprotocol.io) tools, so an agent +can look at the UI it writes. The server is a Streamable HTTP endpoint on `127.0.0.1`, protected by a bearer token +kept per workspace (port and token stay the same across reloads when the port is free). VS Code's own agents (chat +agent mode) find it as **Elide Compose Preview** in the MCP server list; agents started in the integrated terminal +read `ELIDE_COMPOSE_PREVIEW_MCP_URL` and `ELIDE_COMPOSE_PREVIEW_MCP_TOKEN`; for any other client, **Elide: Copy +Compose Preview MCP Server Configuration** copies an `mcpServers` entry with both (for example +`claude mcp add --transport http elide-compose-preview "$ELIDE_COMPOSE_PREVIEW_MCP_URL" --header "Authorization: +Bearer $ELIDE_COMPOSE_PREVIEW_MCP_TOKEN"`). `elide.android.composePreview.mcp.enabled` turns it off. Rendering runs +the project's code, as the preview panel does: the tools refuse to run in an untrusted workspace. +Malformed HTTP request targets receive HTTP 400 without interrupting other requests. Closing the extension clears +its terminal URL/token contributions, including when startup is still persisting the server's port. +Native render/refresh/interact operations follow the same availability checks as the panel. Losing compatible Komodo +cancels native requests and closes their sessions, but does not shut down this MCP server: composable listing and +layoutlib rendering still work. Recovery requires a new native render, not reuse of a retired session ID. + +| Tool | What it does | +| --- | --- | +| `compose_preview_render` | Renders up to 12 composables and returns each card's PNG and layout tree. `live: true` keeps a session open. | +| `compose_preview_interact` | Taps, long presses, drags, scrolls and types into a live card; returns its settled frame and layout. | +| `compose_preview_refresh` | Renders a session again from the current source, after the agent edited it. | +| `compose_preview_close` | Ends a live session. | +| `compose_preview_list` | Lists a file's or the workspace's top-level composables with their parameters and references. | + +An agent names a composable by **code reference** (`package.Function`, `Function`, or `path/to/File.kt#Function`, +optionally `:line` for an overload), never by pasting code, and it does not need a `@Preview`: the extension +generates a public wrapper that calls it and appends it to the composable's own file **for the preview compile +only** — it is never saved, and with the Komodo engine the language server never sees it (Komodo applies it to its +buffer for that compile alone). Because the wrapper lives in the same file, private composables, the file's imports +and extension receivers (`ColumnScope`, `RowScope`, `BoxScope`, hosted in a `Column`, `Row` or `Box`) work. +Parameters without defaults get placeholders where the type allows (strings, numbers, booleans, `Modifier`, colors, +`Dp`, collections, nullable types, `Unit`-returning lambdas); the agent passes others, or better values, as Kotlin +expressions in `arguments`, which compile in that file. A configuration (`widthDp`, `heightDp`, `darkTheme`, +`fontScale`, `locale`, `device`, `backgroundColor`, `showSystemUi`, `apiLevel`; `showBackground` defaults to on) and a +`theme` composable to wrap each one in are optional. A composable that already has `@Preview` and is given neither +renders its own preview configurations. A compile error in a wrapper is reported with the wrapper's numbered lines. + +Each card's layout is a tree of composable calls with their bounds in image pixels and their call sites +(`file:line:column`), as the preview's **Inspect** locates them; actions can target a node of it (`n3`) instead of +coordinates. The tools use the configured `elide.android.composePreview.engine`: `layoutlib` renders snapshots +(sessions only refresh), `komodo` renders live cards in the workspace's Komodo language server and needs a Komodo +that supports session appendices. Komodo hosts one preview session per window: while an agent renders, or keeps a +live session open, the preview panel's cards show that they are paused and resume when it ends (**Elide: Close Agent +Compose Preview Sessions**, or ten minutes unused); opening the panel meanwhile waits the same way. The close +command also cancels renders still opening a session, so a late result cannot leave a new live session behind. +One-shot renders keep the agent's Komodo session warm between calls; the panel takes it over whenever it previews. A render uses the +last build's resources and classes for code outside the previewed files, building first when the project was never +built; `rebuild: true` builds before rendering (after resource changes). Progress and failures show in the **Activity** +view and the `Elide` output channel. + #### Dagger and Hilt Requires an Elide build with Android support. Put `dagger-compiler` or `hilt-compiler` in @@ -519,7 +760,7 @@ The base and features must have disjoint source sets and distinct manifest packa The base owns shared Maven/workspace libraries; feature dex and resources stay in their own bundle modules. Install-time features remain removable splits so dependent features can reference them. Install the AAB with bundletool or Play Feature Delivery: a feature module is not a standalone runnable APK, and APK Run/Debug -does not download on-demand splits. Requires the experimental `feat/android` Elide branch. +does not download on-demand splits. Requires an Elide build with Android support. #### Logcat @@ -539,9 +780,17 @@ URLs and quoted strings. **Filter Android Logcat**, **Stop Android Logcat**, and **Restart Android Logcat** control that stream. **Clear Android Logcat Output** clears only the editor channel, never the device's shared log buffers. -Restarting or changing filters replays the device's available buffer. Application filtering uses the installed -UID, so it follows process restarts and secondary processes; apps sharing that UID also share the filtered logs. -The device must support `logcat --uid`. Removing the project or closing the extension stops its adb clients. +A new, restarted or refiltered stream requests records since the application's current process start when it is +running, otherwise since the sampled device clock. Application filtering uses the installed UID, so it follows +process restarts and secondary processes; apps sharing that UID also share the filtered logs. +On Android 12+ it uses `logcat --uid`; on Android 7–11 it follows only the app's running main process (`--pid`), so restart logcat after the app restarts. Removing the project or its Android model, or closing the extension, +stops its adb clients and invalidates pending discovery/filter replies, including application discovery before +its picker opens. Stop also cancels a pending restart. + +The startup header is not a device-side readiness acknowledgement. Android 16 emulator qualification found +immediate startup records missing from timestamp-filtered live streams but present in later dumps, including +with direct adb. For startup diagnosis, retain `adb -s logcat -d -v threadtime` output as well; the +extension does not mask that boundary with an arbitrary delay. Ctrl/Cmd-click an application stack frame to open its Kotlin/Java source line, including workspace-library frames, nested classes and Kotlin file facades. Resolution uses the synced source roots, package and filename; @@ -549,6 +798,46 @@ unknown/native frames, out-of-range lines and ambiguous same-package filenames r caches are invalidated when projects or source files change. Obfuscated release traces still need their R8 mapping; the links do not retrace obfuscated names. +#### Room / SQLite database explorer (experimental) + +**Elide: Explore Android Databases** opens a connected app's on-disk Room / SQLite databases. It is also available +from the Elide menu and as **Explore databases** in the Android tree and in Android/device context menus. Choose an installed application; +the deployment target's device is preferred, and an unavailable selected device never silently falls back to another. + +Requires a trusted, synced Android project, Android 8.0 / API 26+, and a debuggable app with working `adb run-as`. +Launch the app and exercise the screen that creates its database before inspecting it. The picker lists installed +third-party apps for the foreground Android user; apps `run-as` refuses (release or otherwise non-debuggable builds) +are listed apart with its reason and cannot be opened. The selection retains that user: switching profiles +while the picker is open requires selecting the application again, before any database access. + +The panel provides: + +- Database and schema browsing, including tables, views, indexes and trigger SQL. +- 100-row pages, read-only SQL queries, exact 64-bit integers, NULLs and Base64 BLOBs. +- Manual refresh and optional 2 / 5 second refresh while visible. Refresh re-runs the last successful read without + overwriting an edited SQL draft. Failed or cancelled queries clear that read; Refresh then updates only the catalog + until another query succeeds. Pages are independent reads; add `ORDER BY` for predictable paging. +- **Execute write…**, requiring confirmation of the app/database/device and statement. Each write is a transaction + with foreign keys enabled. Cancel stops pending work; a cancelled in-flight write may already have committed, + so refresh before retrying. + +The bundled helper uses Android's SQLite APIs directly on the live files, including committed WAL contents. +It does not require root, a device `sqlite3` binary, app instrumentation or database copies. Removing the project +or closing the panel cancels its requests and attempts to remove the temporary helper from the app cache. +If Android clears the cached helper, the next helper start reinstalls it within the same panel/session. + +Unlike Android Studio's in-process inspector, these external writes **do not invalidate Room observers**. Re-query +or reopen the relevant app screen to see changes. Only SQLite files under the app's credential-protected +`databases/` directory are discovered; in-memory, encrypted, device-protected and custom-location databases are +not supported. Changing the foreground Android user requires reopening the explorer. + +Each request uses a fresh connection; temporary tables and other connection-local state are not retained. +SQL is one statement per request. Query supports `SELECT`, `WITH` and `EXPLAIN`; writes support +`INSERT`, `UPDATE`, `DELETE`, `REPLACE`, `CREATE`, `ALTER`, `DROP` and `WITH`. No `PRAGMA`, `ATTACH`, transaction control, +maintenance commands or compound `CREATE TRIGGER` bodies. Results are limited to 4 MiB, 256 columns and 256 KiB +per text/BLOB cell; the panel reports oversized results rather than truncating them. Change counts include trigger +and foreign-key effects. On API 26–27, writes preserve WAL but Android may normalize a non-WAL journal mode to DELETE. + ## Links diff --git a/packages/vscode/media/compose/RenderServer.java b/packages/vscode/media/compose/RenderServer.java index 0f6f505..a1d7db5 100644 --- a/packages/vscode/media/compose/RenderServer.java +++ b/packages/vscode/media/compose/RenderServer.java @@ -55,7 +55,6 @@ class RenderServer { private DefaultModuleClassLoader dependencies; private DefaultModuleClassLoader generationLoader; private List reloadableClassPath; - private Map previousOwners = Map.of(); private final Field sharedLoaders; private final Field renderServiceField; private final Field baseConfigurationField; @@ -65,9 +64,14 @@ class RenderServer { private final ImageWriteParam pngParameters = pngWriter.getDefaultWriteParam(); record Inputs(List classPath, List projectClassPath, String namespace, String resourceApkPath) {} + /** + * `affinity`: the shard that last rendered each preview, the same for every shard; JVMs join the pool later. + * `methods`: the preview functions to render (an agent's selection), null for every preview of the file. + */ record Request(long id, List classPath, List projectClassPath, String namespace, String resourceApkPath, String sourceFile, String packageName, String outputFolder, - int shardIndex, int shardCount, Map weights) {} + int shardIndex, int shardCount, Map weights, Map affinity, + List methods) {} record LayoutSource(String fileName, int packageHash, int lineNumber, int offset, int length) {} record LayoutNode(String id, String name, int x, int y, int width, int height, LayoutSource source) {} @@ -302,7 +306,7 @@ private static void disposeComposeViews(List renderPreview(ComposeScreenshot screenshot, String outputFolder) throws Exception { + private List renderPreview(ComposeScreenshot screenshot, String outputFolder, Runnable progress) throws Exception { var source = renderer.getModule(); var element = screenshot.toPreviewElement(source); var layouts = element.resolveXmlLayouts().iterator(); @@ -374,41 +378,46 @@ private List renderPreview(ComposeScreenshot screenshot, String outp else module.dispose(); } } + progress.run(); } return results; } - private JsonObject render(Request request) throws Exception { + /** `progress` runs after each image, so the host can tell a long shard from a hung JVM. */ + private JsonObject render(Request request, Runnable progress) throws Exception { if (request.shardCount() < 1 || request.shardCount() > 6 || request.shardIndex() < 0 || request.shardIndex() >= request.shardCount()) throw new IllegalArgumentException("Invalid renderer shard."); - var previews = DiscoverPreviews.discover(new DiscoverPreviews.Settings(request.classPath(), + var discovered = DiscoverPreviews.discover(new DiscoverPreviews.Settings(request.classPath(), request.projectClassPath(), request.sourceFile(), request.packageName())); + var previews = request.methods() == null ? discovered + : discovered.stream().filter((preview) -> request.methods().contains(preview.methodFQN())).toList(); var results = new ArrayList(); - // Small files need only one layoutlib. Balance larger galleries by the previous images' - // provider expansion counts and pixel area; adding a preview never changes gallery order. - int shards = previews.size() < 6 ? 1 : request.shardCount(); + // The host sizes the pool to the gallery; never more shards than previews. Balance by the previous + // images' provider expansion counts and pixel area; adding a preview never changes gallery order. + int shards = Math.max(1, Math.min(request.shardCount(), previews.size())); + Map affinity = request.affinity() == null ? Map.of() : request.affinity(); var weighted = new ArrayList<>(previews); weighted.sort(Comparator.comparingDouble((DiscoverPreviews.Preview preview) -> request.weights().getOrDefault(preview.previewId(), 1.0)).reversed()); var owners = new HashMap(); var loads = new double[shards]; for (var preview : weighted) { - int owner = previousOwners.getOrDefault(preview.previewId(), 0); + int owner = affinity.getOrDefault(preview.previewId(), 0); if (owner >= shards) owner = 0; // Equal loads retain affinity, including provider/device initialization caches. for (int index = 0; index < shards; index++) if (loads[index] < loads[owner]) owner = index; owners.put(preview.previewId(), owner); loads[owner] += request.weights().getOrDefault(preview.previewId(), 1.0); } - previousOwners = owners; if (!previews.isEmpty() && request.shardIndex() < shards) { try { prepare(new Inputs(request.classPath(), request.projectClassPath(), request.namespace(), request.resourceApkPath())); + progress.run(); for (var preview : previews) { if (owners.get(preview.previewId()) != request.shardIndex()) continue; results.addAll(renderPreview(new ComposeScreenshot(preview.methodFQN(), preview.methodParams(), - preview.previewParams(), preview.previewId(), null), request.outputFolder())); + preview.previewParams(), preview.previewId(), null), request.outputFolder(), progress)); } } finally { // alpha16 dispose() is currently a no-op; dropping the cache's strong reference is @@ -446,7 +455,9 @@ public static void main(String[] args) throws Exception { while ((line = readLine(input)) != null) { Request request = JSON.fromJson(line, Request.class); try { - String response = JSON.toJson(server.render(request)); + // A progress line carries only the request's id and `"progress": true`; the response follows it. + String progress = "{\"id\":" + request.id() + ",\"progress\":true}"; + String response = JSON.toJson(server.render(request, () -> protocol.println(progress))); if (response.length() > MAX_LINE) throw new IOException("Compose renderer response exceeds protocol limit."); protocol.println(response); } catch (Throwable failure) { @@ -459,7 +470,13 @@ public static void main(String[] args) throws Exception { return; } } - server.closeRenderer(); - server.pngWriter.dispose(); + // End of input: the host closed the pipe, possibly without killing us (its extension host exited). + // Layoutlib leaves non-daemon pool threads behind, so returning from main would keep the JVM alive. + try { + server.closeRenderer(); + server.pngWriter.dispose(); + } finally { + System.exit(0); + } } } diff --git a/packages/vscode/media/database/DatabaseInspector.java b/packages/vscode/media/database/DatabaseInspector.java new file mode 100644 index 0000000..2b8e0bb --- /dev/null +++ b/packages/vscode/media/database/DatabaseInspector.java @@ -0,0 +1,469 @@ +package dev.elide.inspector; + +import android.database.Cursor; +import android.database.DatabaseErrorHandler; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.os.Build; +import android.util.Base64; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.PrintStream; +import java.nio.ByteBuffer; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +/** + * One long-lived session per open explorer, running as the selected app UID. The host writes one JSON request per + * line and reads exactly one JSON response line per request, echoing its {@code id}; the first line announces the + * protocol and this process's PID. End of input ends the session. + */ +public final class DatabaseInspector { + private static final int PROTOCOL = 2; + private static final int MAX_INPUT = 512 * 1024; + private static final int MAX_SQL = 64 * 1024; + private static final int MAX_OUTPUT = 4 * 1024 * 1024; + private static final int MAX_CELL = 256 * 1024; + private static final int MAX_COLUMNS = 256; + private static final int MAX_FILES = 4096; + private static final long MAX_SAFE_INTEGER = 9007199254740991L; + private static final byte[] SQLITE_HEADER = "SQLite format 3\u0000".getBytes(StandardCharsets.US_ASCII); + private static final String[] READS = {"SELECT", "WITH", "EXPLAIN"}; + private static final String[] WRITES = {"INSERT", "UPDATE", "DELETE", "REPLACE", "CREATE", "ALTER", "DROP", "WITH"}; + // Android's default corruption handler deletes database files, even on a failed open. + private static final DatabaseErrorHandler CORRUPTION_HANDLER = new DatabaseErrorHandler() { + @Override + public void onCorruption(SQLiteDatabase database) { + throw new SQLiteException("Database corruption detected; inspector will not repair or delete app data."); + } + }; + + private DatabaseInspector() {} + + public static void main(String[] args) { + // Only protocol lines may reach stdout; anything a framework class prints goes to stderr instead. + PrintStream protocol = System.out; + System.setOut(System.err); + InputStream input = new BufferedInputStream(System.in, 64 * 1024); + try { + JSONObject hello = new JSONObject().put("ready", true).put("protocol", PROTOCOL) + .put("pid", android.os.Process.myPid()); + if (!write(protocol, hello.toString().getBytes(StandardCharsets.UTF_8))) return; + } catch (Exception impossible) { + return; + } + while (true) { + byte[] line; + try { + line = readLine(input); + } catch (Exception exception) { + // An oversized or unreadable request cannot be resynchronized: answer once and end the session. + write(protocol, errorResponse(exception, JSONObject.NULL)); + return; + } + if (line == null) return; + Object id = JSONObject.NULL; + byte[] response; + try { + JSONObject request = decodeRequest(line); + id = request.opt("id"); + if (!(id instanceof Number)) { + id = JSONObject.NULL; + throw new IllegalArgumentException("Request id must be a number."); + } + JSONObject result = dispatch(request); + result.put("ok", true).put("id", id); + response = encodeResponse(result); + } catch (Exception exception) { + response = errorResponse(exception, id); + } + // The host may have cancelled the request and closed its pipe. + if (!write(protocol, response)) return; + } + } + + private static boolean write(PrintStream protocol, byte[] line) { + protocol.write(line, 0, line.length); + protocol.write('\n'); + protocol.flush(); + return !protocol.checkError(); + } + + /** One request line without its newline; null at end of input. */ + private static byte[] readLine(InputStream input) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + int next; + while ((next = input.read()) != -1) { + if (next == '\n') return bytes.toByteArray(); + if (bytes.size() >= MAX_INPUT) throw new IllegalArgumentException("Request exceeds 512 KiB."); + bytes.write(next); + } + return bytes.size() == 0 ? null : bytes.toByteArray(); + } + + private static JSONObject decodeRequest(byte[] bytes) throws Exception { + String json = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)).toString(); + return new JSONObject(json); + } + + private static JSONObject dispatch(JSONObject request) throws Exception { + String action = string(request, "action"); + if ("databases".equals(action)) { + File root = databaseRoot(); + JSONArray databases = new JSONArray(); + if (root.exists()) { + if (!root.isDirectory()) throw new IllegalArgumentException("databases is not a directory."); + enumerate(root, root, databases, new HashSet(), new int[] {0}, 0); + } + return new JSONObject().put("databases", databases); + } + if (!"schema".equals(action) && !"query".equals(action) && !"execute".equals(action)) { + throw new IllegalArgumentException("Unknown action: " + action); + } + File database = resolveDatabase(string(request, "database")); + if ("schema".equals(action)) return schema(database); + String sql = string(request, "sql"); + String keyword = validateSql(sql); + if ("query".equals(action)) { + if (!oneOf(keyword, READS)) { + throw new IllegalArgumentException(oneOf(keyword, WRITES) + ? "Run read-only query accepts SELECT, WITH or EXPLAIN; use Execute write… for " + keyword + "." + : unsupported(keyword)); + } + int limit = integer(request, "limit", 1, 500); + int offset = integer(request, "offset", 0, 1000000); + return query(database, sql, limit, offset); + } + if (!oneOf(keyword, WRITES)) { + throw new IllegalArgumentException(oneOf(keyword, READS) + ? "Execute write… accepts INSERT, UPDATE, DELETE, REPLACE, CREATE, ALTER, DROP or WITH; use Run read-only query for " + keyword + "." + : unsupported(keyword)); + } + return execute(database, sql); + } + + private static File databaseRoot() throws Exception { + File appRoot = new File(".").getCanonicalFile(); + File expected = new File(appRoot, "databases"); + File actual = expected.getCanonicalFile(); + if (!actual.equals(expected)) { + throw new IllegalArgumentException("The databases directory must not be a symbolic link."); + } + return actual; + } + + private static File resolveDatabase(String name) throws Exception { + if (name.isEmpty() || name.indexOf('\u0000') >= 0 || name.indexOf('\\') >= 0 + || new File(name).isAbsolute()) { + throw new IllegalArgumentException("Database must be a relative path inside databases/."); + } + for (String part : name.split("/", -1)) { + if (part.isEmpty() || ".".equals(part) || "..".equals(part)) { + throw new IllegalArgumentException("Database path traversal is not allowed."); + } + } + File root = databaseRoot(); + File file = new File(root, name).getCanonicalFile(); + requireContained(root, file); + if (!file.isFile() || !isDatabase(file)) { + throw new IllegalArgumentException("Database does not exist or has no SQLite header: " + name); + } + return file; + } + + private static void requireContained(File root, File file) { + if (!file.getPath().startsWith(root.getPath() + File.separator)) { + throw new IllegalArgumentException("Database path escapes databases/ (including symbolic links)."); + } + } + + private static boolean isDatabase(File file) throws Exception { + String name = file.getName(); + if (name.endsWith("-wal") || name.endsWith("-shm") || name.endsWith("-journal")) return false; + byte[] header = new byte[SQLITE_HEADER.length]; + try (FileInputStream stream = new FileInputStream(file)) { + int read = 0; + while (read < header.length) { + int count = stream.read(header, read, header.length - read); + if (count < 0) return false; + read += count; + } + } + return Arrays.equals(header, SQLITE_HEADER); + } + + private static void enumerate(File root, File directory, JSONArray result, + Set visited, int[] count, int depth) throws Exception { + if (depth > 16) throw new IllegalArgumentException("Database directory nesting exceeds 16 levels."); + if (!visited.add(directory.getCanonicalPath())) return; + File[] children = directory.listFiles(); + if (children == null) throw new IllegalArgumentException("Cannot read database directory."); + Arrays.sort(children); + for (File child : children) { + if (++count[0] > MAX_FILES) throw new IllegalArgumentException("Database discovery exceeds 4096 filesystem entries."); + File canonical = child.getCanonicalFile(); + requireContained(root, canonical); + if (canonical.isDirectory()) { + enumerate(root, canonical, result, visited, count, depth + 1); + } else if (canonical.isFile() && isDatabase(canonical)) { + result.put(canonical.getPath().substring(root.getPath().length() + 1)); + } + } + } + + private static SQLiteDatabase openReadOnly(File file) { + SQLiteDatabase database = SQLiteDatabase.openDatabase(file.getPath(), null, + SQLiteDatabase.OPEN_READONLY, CORRUPTION_HANDLER); + try { + database.execSQL("PRAGMA query_only=ON"); + return database; + } catch (RuntimeException exception) { + database.close(); + throw exception; + } + } + + private static JSONObject schema(File file) throws Exception { + JSONArray objects = new JSONArray(); + int budget = 128; + try (SQLiteDatabase database = openReadOnly(file); + Cursor cursor = database.rawQuery("SELECT name,type,sql FROM sqlite_master " + + "WHERE type IN ('table','view','index','trigger') ORDER BY type,name", null)) { + while (cursor.moveToNext()) { + JSONObject object = new JSONObject().put("name", boundedText(cursor.getString(0))) + .put("type", cursor.getString(1)) + .put("sql", cursor.isNull(2) ? JSONObject.NULL : boundedText(cursor.getString(2))); + budget = addBudget(budget, object.toString()); + objects.put(object); + } + } + return new JSONObject().put("objects", objects); + } + + private static JSONObject query(File file, String sql, int limit, int offset) throws Exception { + JSONArray columns = new JSONArray(); + JSONArray rows = new JSONArray(); + boolean hasMore = false; + int budget = 128; + try (SQLiteDatabase database = openReadOnly(file); + Cursor cursor = database.rawQuery(sql, null)) { + if (cursor.getColumnCount() > MAX_COLUMNS) { + throw new IllegalArgumentException("Query exceeds 256 columns."); + } + for (String column : cursor.getColumnNames()) columns.put(boundedText(column)); + budget = addBudget(budget, columns.toString()); + // Cursor pagination preserves SQL semantics without interpolating or rewriting it. + if (cursor.moveToPosition(offset)) { + do { + if (rows.length() == limit) { + hasMore = true; + break; + } + JSONArray row = new JSONArray(); + for (int column = 0; column < cursor.getColumnCount(); column++) { + row.put(cell(cursor, column)); + } + budget = addBudget(budget, row.toString()); + rows.put(row); + } while (cursor.moveToNext()); + } + } + return new JSONObject().put("columns", columns).put("rows", rows).put("hasMore", hasMore); + } + + private static Object cell(Cursor cursor, int column) throws Exception { + switch (cursor.getType(column)) { + case Cursor.FIELD_TYPE_NULL: + return JSONObject.NULL; + case Cursor.FIELD_TYPE_INTEGER: + long value = cursor.getLong(column); + if (value > MAX_SAFE_INTEGER || value < -MAX_SAFE_INTEGER) { + return new JSONObject().put("type", "integer").put("value", Long.toString(value)); + } + return value; + case Cursor.FIELD_TYPE_FLOAT: + double real = cursor.getDouble(column); + if (Double.isInfinite(real) || Double.isNaN(real)) { + throw new IllegalArgumentException("Query returned a non-finite floating-point number that JSON cannot represent."); + } + return real; + case Cursor.FIELD_TYPE_BLOB: + byte[] bytes = cursor.getBlob(column); + if (bytes.length > MAX_CELL) throw new IllegalArgumentException("Blob exceeds 256 KiB; select a smaller value explicitly."); + return new JSONObject().put("type", "blob") + .put("base64", Base64.encodeToString(bytes, Base64.NO_WRAP)); + case Cursor.FIELD_TYPE_STRING: + return boundedText(cursor.getString(column)); + default: + throw new IllegalArgumentException("Unsupported SQLite cell type."); + } + } + + private static JSONObject execute(File file, String sql) throws Exception { + // Determine the existing mode using a genuinely read-only connection. In particular, + // never open an existing WAL database with Android's default DELETE journal mode. + String journalMode; + try (SQLiteDatabase database = openReadOnly(file); + Cursor cursor = database.rawQuery("PRAGMA journal_mode", null)) { + if (!cursor.moveToFirst()) throw new IllegalArgumentException("Cannot determine database journal mode."); + journalMode = cursor.getString(0); + } + int flags = SQLiteDatabase.OPEN_READWRITE; + if ("wal".equalsIgnoreCase(journalMode)) flags |= SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING; + SQLiteDatabase database; + if (Build.VERSION.SDK_INT >= 28) { + database = SQLiteDatabase.openDatabase(file, new SQLiteDatabase.OpenParams.Builder() + .setOpenFlags(flags).setJournalMode(journalMode) + .setErrorHandler(CORRUPTION_HANDLER).build()); + } else { + database = SQLiteDatabase.openDatabase(file.getPath(), null, flags, CORRUPTION_HANDLER); + } + try (SQLiteDatabase connection = database) { + connection.setForeignKeyConstraintsEnabled(true); + connection.beginTransactionNonExclusive(); + long changes; + try { + long before = totalChanges(connection); + connection.execSQL(sql); + changes = totalChanges(connection) - before; + connection.setTransactionSuccessful(); + } finally { + connection.endTransaction(); + } + return new JSONObject().put("changes", changes); + } + } + + private static long totalChanges(SQLiteDatabase database) { + try (Cursor cursor = database.rawQuery("SELECT total_changes()", null)) { + if (!cursor.moveToFirst()) throw new IllegalStateException("SQLite did not return a change count."); + return cursor.getLong(0); + } + } + + private static String boundedText(String value) { + if (value.length() > MAX_CELL || value.getBytes(StandardCharsets.UTF_8).length > MAX_CELL) { + throw new IllegalArgumentException("Text exceeds 256 KiB; select a smaller value explicitly."); + } + return value; + } + + private static int addBudget(int current, String json) { + int next = current + json.getBytes(StandardCharsets.UTF_8).length + 1; + if (next > MAX_OUTPUT - 1) throw new IllegalArgumentException("Response exceeds 4 MiB; request fewer rows or smaller values."); + return next; + } + + private static byte[] encodeResponse(JSONObject response) { + byte[] bytes = response.toString().getBytes(StandardCharsets.UTF_8); + if (bytes.length >= MAX_OUTPUT) throw new IllegalArgumentException("Response exceeds 4 MiB; request fewer rows or smaller values."); + return bytes; + } + + /** Neither button runs it; say what each does run, and how to read what a PRAGMA reports. */ + private static String unsupported(String keyword) { + return keyword + " statements are not supported. Run read-only query accepts SELECT, WITH or EXPLAIN; " + + "Execute write… accepts one INSERT, UPDATE, DELETE, REPLACE, CREATE, ALTER, DROP or WITH statement. " + + "PRAGMA, ATTACH, transaction control and maintenance commands are not available" + + ("PRAGMA".equals(keyword) ? "; read a PRAGMA through its table-valued function, e.g. " + + "SELECT * FROM pragma_table_info('table_name')." : "."); + } + + private static byte[] errorResponse(Exception exception, Object id) { + String message = exception.getMessage(); + if (message == null || message.isEmpty()) message = exception.getClass().getSimpleName(); + if (message.length() > 2048) message = message.substring(0, 2048) + " (error detail shortened)"; + try { + return new JSONObject().put("ok", false).put("id", id).put("error", message).toString().getBytes(StandardCharsets.UTF_8); + } catch (Exception impossible) { + return "{\"ok\":false,\"error\":\"Could not encode SQLite error.\"}".getBytes(StandardCharsets.UTF_8); + } + } + + private static String string(JSONObject request, String key) throws Exception { + Object value = request.get(key); + if (!(value instanceof String)) throw new IllegalArgumentException(key + " must be a string."); + return (String) value; + } + + private static int integer(JSONObject request, String key, int minimum, int maximum) throws Exception { + Object value = request.get(key); + if (!(value instanceof Number)) throw new IllegalArgumentException(key + " must be an integer."); + double number = ((Number) value).doubleValue(); + if (number < minimum || number > maximum || number != Math.rint(number)) { + throw new IllegalArgumentException(key + " must be an integer from " + minimum + " through " + maximum + "."); + } + return (int) number; + } + + private static boolean oneOf(String value, String... choices) { + for (String choice : choices) if (choice.equals(value)) return true; + return false; + } + + /** Reject a second statement without mistaking quoted strings/comments for SQL tokens. */ + private static String validateSql(String sql) { + if (sql.isEmpty() || sql.indexOf('\u0000') >= 0 || sql.getBytes(StandardCharsets.UTF_8).length > MAX_SQL) { + throw new IllegalArgumentException("SQL must be nonempty, contain no NUL, and be at most 64 KiB UTF-8."); + } + String keyword = null; + boolean terminated = false; + int index = 0; + while (index < sql.length()) { + char c = sql.charAt(index); + if (Character.isWhitespace(c)) { index++; continue; } + if (c == '-' && index + 1 < sql.length() && sql.charAt(index + 1) == '-') { + index += 2; + while (index < sql.length() && sql.charAt(index) != '\n' && sql.charAt(index) != '\r') index++; + continue; + } + if (c == '/' && index + 1 < sql.length() && sql.charAt(index + 1) == '*') { + int end = sql.indexOf("*/", index + 2); + if (end < 0) throw new IllegalArgumentException("SQL contains an unterminated comment."); + index = end + 2; + continue; + } + if (terminated) throw new IllegalArgumentException("Only one SQL statement is allowed; compound CREATE TRIGGER bodies are not supported."); + if (c == ';') { terminated = true; index++; continue; } + if (keyword == null) { + int start = index; + while (index < sql.length() && ((sql.charAt(index) >= 'A' && sql.charAt(index) <= 'Z') + || (sql.charAt(index) >= 'a' && sql.charAt(index) <= 'z'))) index++; + if (index == start) throw new IllegalArgumentException("SQL must begin with a supported statement keyword."); + keyword = sql.substring(start, index).toUpperCase(Locale.ROOT); + continue; + } + if (c == '\'' || c == '"' || c == '`' || c == '[') { + char endQuote = c == '[' ? ']' : c; + index++; + boolean closed = false; + while (index < sql.length()) { + if (sql.charAt(index++) == endQuote) { + if (c != '[' && index < sql.length() && sql.charAt(index) == endQuote) { index++; continue; } + closed = true; + break; + } + } + if (!closed) throw new IllegalArgumentException("SQL contains an unterminated quoted value or identifier."); + continue; + } + index++; + } + if (keyword == null) throw new IllegalArgumentException("SQL contains no statement."); + return keyword; + } +} diff --git a/packages/vscode/media/database/inspector.dex b/packages/vscode/media/database/inspector.dex new file mode 100644 index 0000000..9e72f58 Binary files /dev/null and b/packages/vscode/media/database/inspector.dex differ diff --git a/packages/vscode/media/walkthrough/run.md b/packages/vscode/media/walkthrough/run.md index 5e88448..947f097 100644 --- a/packages/vscode/media/walkthrough/run.md +++ b/packages/vscode/media/walkthrough/run.md @@ -14,8 +14,10 @@ Start with **Elide: Check Android Environment** to inspect local tools and devic Select a finding for its settings or repair action; inspection does not install SDK components or accept licenses. After syncing, use **Elide: Select Android Deployment Target…** to choose an APK and device. The status bar shows -the remembered selection used by **Run Android App…** and **Debug Android App…**. A disconnected selection -stops deployment instead of silently switching devices. Debugging requires `android:debuggable="true"` in the APK. +the remembered selection used by **Run Android App…**, **Debug Android App…**, **Run Android Instrumented Tests…** +and the device-bound build targets. A disconnected selection stops deployment instead of silently switching +devices. Debugging requires `android:debuggable="true"` in the APK; Run and Debug are offered only for an APK Elide +can install. **Open Compose Preview** renders locally without a device. **Show Android Logcat** streams application logs; Ctrl/Cmd-click a resolvable application stack frame to open its source line. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index c42ee95..f59d5ca 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -61,12 +61,20 @@ "workspaceContains:**/elide.pkl", "onDebugResolve:elide", "onDebugResolve:elide-native", + "onDebugResolve:elide-android", "onDebugResolve:komodo-kotlin", "onTaskType:elide", "onView:elide.projects", - "onView:elide.activity" + "onView:elide.activity", + "onWebviewPanel:elide.composePreview" ], "contributes": { + "mcpServerDefinitionProviders": [ + { + "id": "elide.composePreview", + "label": "Elide Compose Preview" + } + ], "languages": [ { "id": "elide-android-logcat", @@ -280,33 +288,28 @@ "type": "boolean", "scope": "window", "default": true, - "markdownDescription": "Start the Compose preview renderer and compiler in the background when a Kotlin file with `@Preview` functions is focused in a trusted Android project, so **Elide: Open Compose Preview** opens in about a second. Runs the file's previews from the last build's classes; never builds, and never downloads the renderer runtime. Requires `#elide.android.composePreview.keepAliveMinutes#` above 0." + "markdownDescription": "Start the Compose preview backend in the background when a Kotlin file with `@Preview` functions is focused in a trusted Android project, so **Elide: Open Compose Preview** opens in about a second. Runs the file's previews from the last build's classes; never builds, and never downloads the renderer runtime. Requires `#elide.android.composePreview.keepAliveMinutes#` above 0." }, "elide.android.composePreview.keepAliveMinutes": { "type": "number", "scope": "window", "default": 10, "minimum": 0, - "markdownDescription": "Minutes an unused Compose preview backend (render JVMs and compiler workers, several GB of memory) stays warm after its panel closes or after prewarming. `0` stops it with its panel and disables prewarming." + "markdownDescription": "Minutes an unused Compose preview backend (snapshot workers, or a renderer session in the Komodo language server) stays warm after its panel closes or after prewarming. `0` closes the renderer session with its panel and disables prewarming; Komodo's language server remains available." }, "elide.android.composePreview.engine": { "type": "string", "scope": "window", "enum": ["layoutlib", "komodo"], - "enumDescriptions": ["Design-time snapshots rendered by layoutlib.", "Opt-in, experimental: live cards rendered by a Komodo preview daemon, with layoutlib snapshots for the cards it cannot render."], + "enumDescriptions": ["Design-time snapshots rendered by layoutlib.", "Experimental: every card is live, rendered by the workspace's Komodo language server; a card it cannot render shows why."], "default": "layoutlib", - "markdownDescription": "Engine of the Compose preview. `komodo` runs `komodo-preview` beside layoutlib: cards it hosts are live (pushed frames, unsaved edits applied in place, compile errors in Problems); the others keep their layoutlib snapshot (see `#elide.android.composePreview.komodo.fallback#`). Takes effect when a preview panel opens." + "markdownDescription": "Engine of the Compose preview. `layoutlib` (the default) renders design-time snapshots, with source navigation from **Inspect**. `komodo` (experimental) renders live cards inside the workspace's Komodo language server: unsaved Kotlin edits, clicks, keyboard input into a focused card, compile errors in Problems, and **Inspect** for composable calls by their measured bounds, with source navigation to their call sites. It requires `#elide.kotlinLsp.server#` = `komodo`, with a compatible component installed explicitly through Elide; there is no fallback to `layoutlib`. Takes effect when a preview panel opens." }, - "elide.android.composePreview.komodo.path": { - "type": "string", - "default": "", - "scope": "machine-overridable", - "markdownDescription": "The `komodo-preview` daemon for the `komodo` engine. Empty resolves `komodo-preview` beside the Elide binary, then on `PATH`." - }, - "elide.android.composePreview.komodo.fallback": { + "elide.android.composePreview.mcp.enabled": { "type": "boolean", + "scope": "window", "default": true, - "markdownDescription": "With the `komodo` engine, a card Komodo cannot render keeps its layoutlib snapshot (the reason is in the Elide output). Off shows the reason on the card instead." + "markdownDescription": "Serve the Compose preview to AI agents over MCP: a token-protected HTTP server on `127.0.0.1`, offered to VS Code's agents (chat agent mode) and to agents in the integrated terminal (`ELIDE_COMPOSE_PREVIEW_MCP_URL`, `ELIDE_COMPOSE_PREVIEW_MCP_TOKEN`); **Elide: Copy Compose Preview MCP Server Configuration** gives it to other clients. Agents render composables by code reference, `@Preview` or not, with the configured `#elide.android.composePreview.engine#`, and get each card's image and layout; with `komodo`, live sessions take taps and typing. Rendering runs the project's code: trusted workspaces only." }, "elide.flags": { "type": "array", @@ -535,6 +538,12 @@ "title": "Refresh Android Devices", "icon": "$(refresh)" }, + { + "command": "elide.androidPairWireless", + "category": "Elide", + "title": "Pair Android Device over Wi-Fi…", + "icon": "$(radio-tower)" + }, { "command": "elide.androidRun", "category": "Elide", @@ -545,8 +554,21 @@ "command": "elide.androidDebug", "category": "Elide", "title": "Debug Android App…", + "enablement": "elide.androidDebugAvailable", "icon": "$(debug-alt)" }, + { + "command": "elide.androidTest", + "category": "Elide", + "title": "Run Android Instrumented Tests…", + "icon": "$(beaker)" + }, + { + "command": "elide.androidRunDeviceTask", + "category": "Elide", + "title": "Run on Android Deployment Target", + "icon": "$(play)" + }, { "command": "elide.androidStartEmulator", "category": "Elide", @@ -556,7 +578,7 @@ { "command": "elide.androidStopEmulator", "category": "Elide", - "title": "Stop Android Emulators", + "title": "Stop Android Emulator", "icon": "$(vm-outline)" }, { @@ -587,14 +609,31 @@ "command": "elide.androidComposePreview", "category": "Elide", "title": "Open Compose Preview", + "enablement": "config.elide.android.composePreview.engine != komodo || elide.komodoPreviewAvailable", "icon": "$(preview)" }, + { + "command": "elide.composePreviewMcp.copyConfiguration", + "category": "Elide", + "title": "Copy Compose Preview MCP Server Configuration" + }, + { + "command": "elide.composePreviewMcp.closeSessions", + "category": "Elide", + "title": "Close Agent Compose Preview Sessions" + }, { "command": "elide.androidLogcat", "category": "Elide", "title": "Show Android Logcat", "icon": "$(output)" }, + { + "command": "elide.androidDatabase", + "category": "Elide", + "title": "Explore Android Databases", + "icon": "$(database)" + }, { "command": "elide.androidLogcatFilter", "category": "Elide", @@ -625,7 +664,7 @@ "editor/title": [ { "command": "elide.androidComposePreview", - "when": "editorLangId == kotlin && elide.hasProjects", + "when": "editorLangId == kotlin && elide.composePreviewAvailable", "group": "navigation" } ], @@ -658,33 +697,93 @@ "command": "elide.revealLibrary", "when": "false" }, + { + "command": "elide.androidSelectTarget", + "when": "elide.hasAndroidProjects" + }, + { + "command": "elide.androidDoctor", + "when": "elide.hasAndroidProjects || elide.hasUnsyncedProjects" + }, + { + "command": "elide.androidRefreshDevices", + "when": "elide.hasAndroidProjects" + }, + { + "command": "elide.androidPairWireless", + "when": "elide.hasAndroidProjects" + }, { "command": "elide.androidRun", - "when": "elide.hasProjects" + "when": "elide.hasAndroidProjects" }, { "command": "elide.androidDebug", - "when": "elide.hasProjects" + "when": "elide.hasAndroidProjects && elide.androidDebugAvailable" + }, + { + "command": "elide.androidTest", + "when": "elide.hasAndroidProjects" + }, + { + "command": "elide.androidRunDeviceTask", + "when": "false" }, { "command": "elide.androidStartEmulator", - "when": "elide.hasProjects" + "when": "elide.hasAndroidProjects" }, { "command": "elide.androidStopEmulator", - "when": "elide.hasProjects" + "when": "elide.hasAndroidProjects" }, { "command": "elide.androidCreateAvd", - "when": "elide.hasProjects" + "when": "elide.hasAndroidProjects" }, { "command": "elide.androidInstallSdk", - "when": "elide.hasProjects" + "when": "elide.hasAndroidProjects" }, { "command": "elide.androidCreateKeystore", - "when": "elide.hasProjects" + "when": "elide.hasAndroidProjects" + }, + { + "command": "elide.androidComposePreview", + "when": "elide.hasAndroidProjects && (config.elide.android.composePreview.engine != komodo || elide.komodoPreviewAvailable)" + }, + { + "command": "elide.composePreviewMcp.copyConfiguration", + "when": "elide.hasAndroidProjects" + }, + { + "command": "elide.composePreviewMcp.closeSessions", + "when": "elide.hasAndroidProjects" + }, + { + "command": "elide.androidLogcat", + "when": "elide.hasAndroidProjects" + }, + { + "command": "elide.androidLogcatFilter", + "when": "elide.hasAndroidProjects" + }, + { + "command": "elide.androidLogcatClear", + "when": "elide.hasAndroidProjects" + }, + { + "command": "elide.androidLogcatStop", + "when": "elide.hasAndroidProjects" + }, + { + "command": "elide.androidLogcatRestart", + "when": "elide.hasAndroidProjects" + }, + { + "command": "elide.androidDatabase", + "when": "elide.hasAndroidProjects" }, { "command": "elide.androidOpenMapping", @@ -743,6 +842,11 @@ "when": "view == elide.projects && viewItem == buildTarget", "group": "inline@1" }, + { + "command": "elide.androidRunDeviceTask", + "when": "view == elide.projects && viewItem == buildTargetDevice", + "group": "inline@1" + }, { "command": "elide.executeTask", "when": "view == elide.projects && viewItem == buildTargetDebuggable", @@ -770,22 +874,27 @@ }, { "command": "elide.androidRun", - "when": "view == elide.projects && viewItem == androidApk", + "when": "view == elide.projects && viewItem =~ /^androidApk\\b.*\\.run\\b/", "group": "inline@1" }, { "command": "elide.androidDebug", - "when": "view == elide.projects && viewItem == androidApk", + "when": "view == elide.projects && viewItem =~ /^androidApk\\b.*\\.debug\\b/", "group": "inline@2" }, { - "command": "elide.build", - "when": "view == elide.projects && viewItem == androidApk", + "command": "elide.androidTest", + "when": "view == elide.projects && viewItem =~ /^androidApk\\b.*\\.test\\b/", "group": "inline@3" }, + { + "command": "elide.build", + "when": "view == elide.projects && viewItem =~ /^androidApk\\b/", + "group": "inline@4" + }, { "command": "elide.androidCreateKeystore", - "when": "view == elide.projects && viewItem == androidApk" + "when": "view == elide.projects && viewItem =~ /^(androidApk|androidAab)\\b/" }, { "command": "elide.build", @@ -799,14 +908,29 @@ }, { "command": "elide.androidLogcat", - "when": "view == elide.projects && viewItem =~ /^(androidDevice|androidApk|android)$/", + "when": "view == elide.projects && viewItem =~ /^(androidDevice|androidEmulator|androidApk|android|androidTools)(\\.|$)/", "group": "android@3" }, + { + "command": "elide.androidDatabase", + "when": "view == elide.projects && viewItem =~ /^(androidDevice|androidEmulator|androidApk|android|androidTools)(\\.|$)/", + "group": "android@4" + }, + { + "command": "elide.androidPairWireless", + "when": "view == elide.projects && viewItem =~ /^(android|androidTools|androidDevices)$/", + "group": "android@2" + }, { "command": "elide.androidRun", - "when": "view == elide.projects && viewItem == androidDevice", + "when": "view == elide.projects && viewItem =~ /^android(Device|Emulator)$/", "group": "inline@1" }, + { + "command": "elide.androidStopEmulator", + "when": "view == elide.projects && viewItem =~ /^androidEmulator/", + "group": "inline@2" + }, { "command": "elide.sync", "when": "view == elide.projects && viewItem =~ /^project/", @@ -824,7 +948,7 @@ }, { "command": "elide.androidDoctor", - "when": "view == elide.projects && viewItem =~ /^project/", + "when": "view == elide.projects && viewItem =~ /^project/ && (elide.hasAndroidProjects || elide.hasUnsyncedProjects)", "group": "android@1" }, { @@ -1219,20 +1343,26 @@ }, "flags": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "markdownDescription": "Build flags (`-f NAME[=VALUE]`) layered over `elide.flags`." }, "options": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean", "array"], - "items": { "type": ["string", "number"] } + "items": { + "type": ["string", "number"] + } }, "markdownDescription": "CLI build options layered over `elide.build.options`. Use `device`, not an option, to select the deployment device." }, "env": { "type": "object", - "additionalProperties": { "type": "string" }, + "additionalProperties": { + "type": "string" + }, "description": "Extra environment variables for the Elide build process." }, "port": { @@ -1420,7 +1550,7 @@ { "id": "android", "title": "Prepare Android development", - "description": "Check your SDK and devices even before sync succeeds. Then choose a persistent APK and device for Run and Debug.\n[Check Android Environment](command:elide.androidDoctor)\n[Select Deployment Target…](command:elide.androidSelectTarget)\n[Open Compose Preview](command:elide.androidComposePreview)", + "description": "Check your SDK and devices even before sync succeeds. Then choose a persistent APK and device for Run and Debug.\n[Check Android Environment](command:elide.androidDoctor)\n[Select Android Deployment Target…](command:elide.androidSelectTarget)\n[Open Compose Preview](command:elide.androidComposePreview)", "media": { "markdown": "media/walkthrough/run.md" }, @@ -1456,11 +1586,13 @@ }, "dependencies": { "@elide/ide-core": "workspace:*", + "qrcode": "^1.5.4", "vscode-languageclient": "^9.0.1", "yauzl": "^3.2.0" }, "devDependencies": { "@types/node": "^22.15.0", + "@types/qrcode": "^1.5.6", "@types/vscode": "^1.105.0", "@types/yauzl": "^2.10.3", "@vscode/test-electron": "^3.1.0", diff --git a/packages/vscode/src/android.ts b/packages/vscode/src/android.ts index 8a2bdd6..7a2e51f 100644 --- a/packages/vscode/src/android.ts +++ b/packages/vscode/src/android.ts @@ -17,15 +17,18 @@ import { adbExecutable, parseAdbDevices, type AndroidDevice, + type AndroidTargetModel, + type ElideInvocationOptions, } from "@elide/ide-core"; import * as vscode from "vscode"; import { ELIDE_ANDROID_DEBUG_TYPE } from "./androidDebug.js"; import type { AndroidDeploymentController } from "./androidDeployment.js"; -import { androidApks } from "./androidDeploymentState.js"; +import { NO_ANDROID_PROJECT, androidApks } from "./androidDeploymentState.js"; import { readConfig } from "./config.js"; +import { androidFailureActions, showElideFailure } from "./failures.js"; import type { ElideUi } from "./output.js"; import type { ElideProject, ElideWorkspace } from "./projects.js"; -import { executeElideTask, taskExitCode } from "./tasks.js"; +import { executeElideTask, executeReportedElideTask, taskExitCode } from "./tasks.js"; /** * The Android side of the extension. @@ -74,7 +77,7 @@ async function resolve(workspace: ElideWorkspace, value: unknown): Promise project.model?.android); if (candidates.length === 0) { - void vscode.window.showWarningMessage("Elide: no synced project declares an `android` block."); + void vscode.window.showWarningMessage(NO_ANDROID_PROJECT); return undefined; } const only = candidates.length === 1 ? candidates[0] : undefined; @@ -97,29 +100,51 @@ export function androidSdkFor(project: ElideProject): string | undefined { return project.model?.android?.sdkRoot ?? androidSdkRoot(); } +/** Artifacts signed with a keystore of their own: the APKs, and the bundles a store takes. */ +function signedArtifacts(project: ElideProject): AndroidTargetModel[] { + return (project.model?.android?.targets ?? []).filter((target) => target.kind === "apk" || target.kind === "aab"); +} + /** - * The APK a command acts on: the one it names, else the project's only one, else whichever the user picks. A - * project declaring none has nothing to run. + * The signed artifact a command acts on: the one it names, else the project's only one, else whichever the user + * picks. A project declaring none has nothing to sign. */ -async function pickApk(project: ElideProject, named: string | undefined): Promise { - const apks = androidApks(project); +async function pickSignedArtifact(project: ElideProject, named: string | undefined): Promise { + const signed = signedArtifacts(project); if (named) { - if (apks.some((apk) => apk.name === named)) return named; - void vscode.window.showErrorMessage(`Elide: '${named}' is not an Android APK in this project's current model. Sync the project or select another APK.`); + if (signed.some((artifact) => artifact.name === named)) return named; + void vscode.window.showErrorMessage(`Elide: '${named}' is not an Android APK or app bundle in this project's current model. Sync the project or select another.`); return undefined; } - if (apks.length === 1) return apks[0]?.name; - if (apks.length === 0) { - void vscode.window.showWarningMessage("Elide: this project declares no Android application package."); + if (signed.length === 1) return signed[0]?.name; + if (signed.length === 0) { + void vscode.window.showWarningMessage("Elide: this project declares no Android application package or app bundle."); return undefined; } const pick = await vscode.window.showQuickPick( - apks.map((apk) => ({ label: apk.name, description: apk.output })), - { placeHolder: "Android application to run" }, + signed.map((artifact) => ({ label: artifact.name, description: artifact.output })), + { placeHolder: "Android artifact to create the signing keystore of" }, ); return pick?.label; } +/** + * Run an Android build target as a task whose failure is reported in Elide's words — its error, its hint, and the + * fix the hint names — rather than in the terminal only. `reinstall` is how a run that installs an app recovers from + * a copy on the device signed with another key (see {@link androidFailureActions}). + */ +export async function executeAndroidTask(workspace: ElideWorkspace, ui: ElideUi, project: ElideProject, invocation: ElideInvocationOptions, reinstall?: (application: string) => Promise): Promise { + return await executeReportedElideTask(workspace, project, "build", invocation, ({ name, exitCode, failure, reveal }) => + showElideFailure(ui, failure, `${name} failed${exitCode === undefined ? "" : ` (exit ${exitCode})`}.`, reveal, + androidFailureActions([project, ...workspace.projects], failure, reinstall))); +} + +/** Whether `elide build ` builds something Android: an artifact of the project's `android` block, or an `android-*` target. */ +export function isAndroidBuild(project: ElideProject, args: readonly string[]): boolean { + const targets = project.model?.android?.targets ?? []; + return args.some((arg) => /(?:^|:)android-/.test(arg) || targets.some((target) => target.name === arg)); +} + /** Devices attached to the project's SDK; missing platform-tools returns undefined, adb failures are surfaced. */ export async function attachedDevices(project: ElideProject): Promise { const sdk = androidSdkFor(project); @@ -128,25 +153,42 @@ export async function attachedDevices(project: ElideProject): Promise { + const sdk = androidSdkFor(project); + if (!sdk || !existsSync(adbExecutable(sdk))) throw new Error("Android SDK platform-tools (adb) are unavailable."); + const { stdout } = await promisify(execFile)(adbExecutable(sdk), ["-s", serial, "uninstall", application], { encoding: "utf8", timeout: 60_000 }); + // Older adb exits 0 on a refused uninstall: only its `Success` line says it happened. + if (!/^Success\b/m.test(stdout)) throw new Error(stdout.trim() || "adb printed no result."); +} + /** * Build, install and launch the selected APK without letting adb implicitly choose another device. The project - * emulator is booted by the run target itself; a pinned emulator that stopped is booted again before the build. + * emulator is booted by the run target itself, headless when `elide.android.emulatorWindow` is off; a pinned emulator + * that stopped is booted again before the build. */ -export async function runAndroid(workspace: ElideWorkspace, deployments: AndroidDeploymentController, value: unknown): Promise { +export async function runAndroid(workspace: ElideWorkspace, ui: ElideUi, deployments: AndroidDeploymentController, value: unknown): Promise { if (!await vscode.workspace.saveAll(false)) return; - const deployment = await deployments.resolve(value); + const deployment = await deployments.resolve(value, "run"); if (!deployment) return; const { project, artifact, destination } = deployment; + const reinstall = (serial?: string) => async (application: string) => { + if (await deployments.uninstall(deployment, application, serial)) await runAndroid(workspace, ui, deployments, value); + }; if (destination.kind === "emulator") { - await executeElideTask(workspace, project, "build", { args: [androidRunTask(artifact, "emulator")] }); + const windowed = readConfig(project.folder).androidEmulatorWindow; + await executeAndroidTask(workspace, ui, project, { + args: [androidRunTask(artifact, "emulator")], + ...(windowed ? {} : { options: { [ANDROID_NO_WINDOW_OPTION]: true } }), + }, reinstall()); return; } const serial = await deployments.readyDevice(deployment); if (!serial) return; - await executeElideTask(workspace, project, "build", { + await executeAndroidTask(workspace, ui, project, { args: [androidRunTask(artifact)], options: { [ANDROID_DEVICE_OPTION]: serial }, - }); + }, reinstall(serial)); } /** @@ -155,7 +197,7 @@ export async function runAndroid(workspace: ElideWorkspace, deployments: Android */ export async function debugAndroid(_workspace: ElideWorkspace, deployments: AndroidDeploymentController, value: unknown): Promise { if (!await vscode.workspace.saveAll(false)) return; - const deployment = await deployments.resolve(value); + const deployment = await deployments.resolve(value, "debug"); if (!deployment) return; const { project, artifact, destination } = deployment; const rel = path.relative(project.folder.uri.fsPath, project.root); @@ -165,7 +207,8 @@ export async function debugAndroid(_workspace: ElideWorkspace, deployments: Andr name: `Elide: Debug ${artifact}`, artifact, ...(destination.kind === "device" ? { device: destination.serial } : {}), - ...(rel ? { project: rel } : {}), + // Named even at the folder root: without it the configuration would choose among every project declaring the APK. + project: rel || ".", }); } @@ -181,18 +224,42 @@ export async function startAndroidEmulator(workspace: ElideWorkspace, value: unk }); } -/** Run one Android build target that takes no artifact and no option of its own. */ -async function runProjectTask(workspace: ElideWorkspace, value: unknown, task: string): Promise { +/** + * Stop an emulator: the one a device row names, or else the one running the project's own AVD. Elide leaves every + * other emulator running, so stopping the project's never takes an unrelated one down with it. + */ +export async function stopAndroidEmulator(workspace: ElideWorkspace, value: unknown): Promise { const invocation = await resolve(workspace, value); - if (invocation) await executeElideTask(workspace, invocation.project, "build", { args: [task] }); + if (!invocation) return; + const { device } = invocation.target; + await executeElideTask(workspace, invocation.project, "build", { + args: [ANDROID_EMULATOR_STOP_TASK], + ...(device ? { options: { [ANDROID_DEVICE_OPTION]: device } } : {}), + }); } -export async function stopAndroidEmulator(workspace: ElideWorkspace, value: unknown): Promise { - await runProjectTask(workspace, value, ANDROID_EMULATOR_STOP_TASK); +export async function createAndroidAvd(workspace: ElideWorkspace, value: unknown): Promise { + const invocation = await resolve(workspace, value); + if (invocation) await executeElideTask(workspace, invocation.project, "build", { args: [ANDROID_AVD_TASK] }); } -export async function createAndroidAvd(workspace: ElideWorkspace, value: unknown): Promise { - await runProjectTask(workspace, value, ANDROID_AVD_TASK); +/** + * Run a build target that installs onto one device — a Build targets row of `android-test-`, say — on the + * deployment target's device. Without `--device` Elide would pick an attached device itself, which with several + * attached is not the one the user selected; a selected device that is gone is refused, never swapped for another. + */ +export async function runAndroidDeviceTask(workspace: ElideWorkspace, ui: ElideUi, deployments: AndroidDeploymentController, value: unknown): Promise { + const task = typeof value === "object" && value !== null && "args" in value && Array.isArray(value.args) ? value.args[0] : undefined; + const target = toAndroidTarget(value); + if (typeof task !== "string" || !target) return; + if (!await vscode.workspace.saveAll(false)) return; + const deployment = await deployments.resolve({ root: target.root }); + if (!deployment) return; + const serial = await deployments.readyDevice(deployment); + if (!serial) return; + await executeAndroidTask(workspace, ui, deployment.project, { args: [task], options: { [ANDROID_DEVICE_OPTION]: serial } }, async (application) => { + if (await deployments.uninstall(deployment, application, serial)) await runAndroidDeviceTask(workspace, ui, deployments, value); + }); } /** @@ -214,15 +281,15 @@ export async function installAndroidSdk(workspace: ElideWorkspace, value: unknow }); } -/** Mint the keystore an APK signs with, once, using the Android debug conventions. */ +/** Mint the keystore an APK or app bundle signs with, once, using the Android debug conventions. */ export async function createAndroidKeystore(workspace: ElideWorkspace, ui: ElideUi, value: unknown): Promise { const invocation = await resolve(workspace, value); if (!invocation) return; const { project, target } = invocation; - const artifact = await pickApk(project, target.artifact); + const artifact = await pickSignedArtifact(project, target.artifact); if (!artifact) return; ui.log(`creating the signing keystore of ${artifact}`); - await executeElideTask(workspace, project, "build", { args: [androidKeystoreTask(artifact)] }); + await executeAndroidTask(workspace, ui, project, { args: [androidKeystoreTask(artifact)] }); } /** diff --git a/packages/vscode/src/androidDatabase.ts b/packages/vscode/src/androidDatabase.ts new file mode 100644 index 0000000..5e01680 --- /dev/null +++ b/packages/vscode/src/androidDatabase.ts @@ -0,0 +1,282 @@ +import { existsSync } from "node:fs"; +import { + ANDROID_DATABASE_DEBUGGABLE_HINT, adbExecutable, AndroidDatabaseSession, listAndroidDatabasePackages, + type AndroidDatabaseObject, type AndroidDatabasePackage, type AndroidDatabaseResult, +} from "@elide/ide-core"; +import * as vscode from "vscode"; +import { androidSdkFor, attachedDevices, toAndroidTarget } from "./android.js"; +import type { AndroidDeploymentTarget } from "./androidDeploymentState.js"; +import { NO_ANDROID_PROJECT, preferredAndroidProject } from "./androidDeploymentState.js"; +import { databaseHtml } from "./androidDatabaseHtml.js"; +import type { ElideProject, ElideWorkspace } from "./projects.js"; + +type SessionOptions = ConstructorParameters[0]; +const PAGE_SIZE = 100; +const quoteIdentifier = (name: string) => `"${name.replaceAll('"', '""')}"`; +const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); + +/** One panel pins one app and serial. No device switch can redirect a pending SQL write. */ +class DatabasePanel implements vscode.Disposable { + private readonly panel: vscode.WebviewPanel; + private readonly subscriptions: vscode.Disposable[] = []; + private session: AndroidDatabaseSession; + private disposed = false; + private revision = 0; + private busy = false; + private databases: string[] = []; + private database: string | undefined; + private objects: AndroidDatabaseObject[] = []; + private lastRead: { sql: string; offset: number } | undefined; + + constructor(private readonly options: SessionOptions, private readonly valid: () => boolean, onClose: () => void) { + this.session = new AndroidDatabaseSession(options); + this.panel = vscode.window.createWebviewPanel("elide.androidDatabase", `Databases: ${options.packageName}`, vscode.ViewColumn.Active, + { enableScripts: true, localResourceRoots: [], retainContextWhenHidden: true }); + this.subscriptions.push( + this.panel.onDidDispose(() => { this.dispose(); onClose(); }), + this.panel.webview.onDidReceiveMessage((message: unknown) => { void this.receive(message); }), + ); + this.panel.webview.html = databaseHtml(); + } + + private post(message: object): void { + if (!this.disposed) void this.panel.webview.postMessage(message); + } + + private current(revision: number): boolean { + return !this.disposed && revision === this.revision && this.valid() && vscode.workspace.isTrusted; + } + + private async catalog(revision: number, requested?: string): Promise { + const databases = await this.session.databases(); + if (!this.current(revision)) return; + this.databases = databases; + const next = requested && databases.includes(requested) ? requested : databases[0]; + if (next !== this.database) this.lastRead = undefined; + this.database = next; + const objects = next ? await this.session.schema(next) : []; + if (!this.current(revision)) return; + this.objects = objects; + this.post({ type: "catalog", databases, database: next, objects }); + } + + private async read(sql: string, offset: number, revision: number, updateEditor = true): Promise { + if (!this.database) throw new Error("Select a database first. Launch the app and open a screen that creates its Room database, then refresh."); + let result: AndroidDatabaseResult; + try { + result = await this.session.query(this.database, sql, PAGE_SIZE, offset); + } catch (error) { + // The webview drops its rows on error; a later refresh must not repaint an older query under this SQL. + if (this.current(revision)) this.lastRead = undefined; + throw error; + } + if (!this.current(revision)) return; + this.lastRead = { sql, offset }; + this.post({ type: "result", ...result, sql, offset, updateEditor, pageSize: PAGE_SIZE, at: new Date().toLocaleTimeString() }); + } + + private async receive(value: unknown): Promise { + if (!value || typeof value !== "object" || !("type" in value) || typeof value.type !== "string" || this.disposed) return; + const message = value as Record; + if (message.type === "cancel") { + if (!this.busy) return; + this.revision++; + // Cancellation drops the displayed rows, including a query whose replacement never completed. + this.lastRead = undefined; + // The helper is stopped; the session and its installed DEX stay for the next request. + this.session.cancel(); + this.busy = false; + this.post({ type: "error", message: "Request cancelled. If a write was in flight, its outcome is unknown; refresh before retrying." }); + this.post({ type: "busy", busy: false }); + return; + } + if (this.busy || !this.current(this.revision)) return; + if (message.type === "refresh" && message.automatic === true && !this.panel.visible) return; + if (!["ready", "refresh", "database", "browse", "query", "execute", "page"].includes(message.type as string)) return; + this.busy = true; + const revision = ++this.revision; + this.post({ type: "busy", busy: true }); + try { + switch (message.type) { + case "ready": + this.post({ type: "identity", device: this.options.device, packageName: this.options.packageName }); + await this.catalog(revision); + break; + case "refresh": + await this.catalog(revision, this.database); + if (this.current(revision) && this.lastRead) await this.read(this.lastRead.sql, this.lastRead.offset, revision, false); + break; + case "database": + if (typeof message.database !== "string" || !this.databases.includes(message.database)) throw new Error("Database is no longer available. Refresh the database list."); + await this.catalog(revision, message.database); + break; + case "browse": { + const object = this.objects.find((item) => item.name === message.name && (item.type === "table" || item.type === "view")); + if (!object) throw new Error("Table or view is no longer available. Refresh the schema."); + await this.read(`SELECT * FROM ${quoteIdentifier(object.name)}`, 0, revision); + break; + } + case "page": { + if (!this.lastRead || (message.direction !== 1 && message.direction !== -1)) return; + const offset = Math.max(0, this.lastRead.offset + message.direction * PAGE_SIZE); + await this.read(this.lastRead.sql, offset, revision, false); + break; + } + case "query": + case "execute": { + if (typeof message.sql !== "string" || !message.sql.trim() || Buffer.byteLength(message.sql, "utf8") > 65_536) throw new Error("Enter one SQL statement (at most 64 KiB)."); + if (message.type === "query") { + await this.read(message.sql, 0, revision); + break; + } + if (!this.database) throw new Error("Select a database first."); + const database = this.database; + const answer = await vscode.window.showWarningMessage( + `Modify ${this.options.packageName} / ${database} on ${this.options.device}?`, + { modal: true, detail: `This writes directly to the connected app's database. Room observers are not notified. Back up important data first.\n\n${message.sql.slice(0, 2_000)}${message.sql.length > 2_000 ? "\n… (statement continues in editor)" : ""}` }, + "Execute write", + ); + if (answer !== "Execute write" || !this.current(revision)) { + if (this.current(revision)) this.post({ type: "notice", message: "Write cancelled; no SQL was sent." }); + break; + } + const result = await this.session.execute(database, message.sql); + if (!this.current(revision)) return; + // A completed write invalidates both rows and schema, never silently re-executes editor SQL. + this.lastRead = undefined; + try { + await this.catalog(revision, database); + } catch (error) { + throw new Error(`Write committed (${result.changes} rows changed), but schema refresh failed: ${errorText(error)}`); + } + if (this.current(revision)) this.post({ type: "written", changes: result.changes }); + break; + } + } + } catch (error) { + if (this.current(revision)) this.post({ type: "error", message: errorText(error) }); + } finally { + if (this.current(revision)) { + this.busy = false; + this.post({ type: "busy", busy: false }); + } + } + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.revision++; + this.session.dispose(); + this.panel.dispose(); + for (const subscription of this.subscriptions) subscription.dispose(); + } +} + +/** Palette, Android tree and device menu share selection and owned panel lifetimes. */ +export function registerAndroidDatabase( + workspace: ElideWorkspace, + context: vscode.ExtensionContext, + deploymentTarget: () => Readonly | undefined, +): vscode.Disposable { + const panels = new Map(); + let disposed = false; + let generation = 0; + let discovery: AbortController | undefined; + let openingRoot: string | undefined; + const valid = (root: string) => !disposed && vscode.workspace.isTrusted && !!workspace.projectAt(root)?.model?.android; + const fresh = (root: string, revision: number) => valid(root) && revision === generation; + const cancelDiscovery = () => { generation++; discovery?.abort(); discovery = undefined; openingRoot = undefined; }; + + const open = async (value: unknown): Promise => { + if (!vscode.workspace.isTrusted) { + void vscode.window.showWarningMessage("Elide: trust this workspace before inspecting Android databases."); + return; + } + cancelDiscovery(); + const revision = generation; + const target = toAndroidTarget(value); + try { + const projects = workspace.projects.filter((project) => project.model?.android); + const editor = vscode.window.activeTextEditor; + const owner = editor?.document.uri.scheme === "file" ? workspace.projectFor(editor.document.uri.fsPath) : undefined; + const deployed = deploymentTarget(); + let project: ElideProject | undefined = target ? workspace.projectAt(target.root) + : preferredAndroidProject(projects, owner, deployed?.root) ?? (projects.length === 1 ? projects[0] : undefined); + if (!project && !target && projects.length) { + project = (await vscode.window.showQuickPick(projects.map((project) => ({ label: project.model?.name ?? project.root, description: project.root, project })), + { placeHolder: "Android project for database inspection" }))?.project; + } + if (!project?.model?.android) { + if (target || !projects.length) void vscode.window.showWarningMessage(NO_ANDROID_PROJECT); + return; + } + const root = project.root; + if (!fresh(root, revision)) return; + openingRoot = root; + const sdk = androidSdkFor(project); + if (!sdk || !existsSync(adbExecutable(sdk))) throw new Error("Android platform-tools are missing. Install the SDK or set elide.android.sdk."); + const devices = await attachedDevices(project); + if (!fresh(root, revision)) return; + const ready = devices?.filter((device) => device.ready) ?? []; + const preferred = target?.device ?? (deployed?.root === root && deployed.destination.kind === "device" ? deployed.destination.serial : undefined); + let device = ready.find((device) => device.serial === preferred); + if (preferred && !device) throw new Error(`Device ${preferred} is ${devices?.find((item) => item.serial === preferred)?.state ?? "disconnected"}. Reconnect it or select a different deployment target.`); + if (!ready.length) throw new Error("No ready Android device. Connect and authorize a device or start an emulator."); + if (!device) device = ready.length === 1 ? ready[0] : (await vscode.window.showQuickPick(ready.map((device) => ({ label: device.serial, description: device.emulator ? "Emulator" : "Device", device })), + { placeHolder: "Connected device to inspect" }))?.device; + if (!device || !fresh(root, revision)) return; + const controller = new AbortController(); + discovery = controller; + const packages = await listAndroidDatabasePackages(adbExecutable(sdk), device.serial, controller.signal); + if (!fresh(root, revision)) return; + if (!packages.length) throw new Error("No third-party applications installed for the current Android user. Install and launch a debuggable app first."); + const openable = packages.filter((item) => item.refusal === undefined); + const refused = packages.filter((item) => item.refusal !== undefined); + if (!openable.length) { + throw new Error(refused.every((item) => item.notDebuggable) + ? `None of the ${refused.length} installed applications on ${device.serial} is debuggable. ${ANDROID_DATABASE_DEBUGGABLE_HINT}` + : `run-as cannot open any installed application on ${device.serial}: ${refused.map((item) => `${item.packageName}: ${item.refusal}`).slice(0, 5).join("; ")}.`); + } + type Item = vscode.QuickPickItem & { item?: AndroidDatabasePackage }; + const items: Item[] = openable.map((item) => ({ label: item.packageName, item })); + if (refused.length) { + items.push({ label: "Cannot be opened", kind: vscode.QuickPickItemKind.Separator }); + items.push(...refused.map((item) => ({ + label: `$(circle-slash) ${item.packageName}`, + description: item.notDebuggable ? "not debuggable" : "run-as refused", + detail: item.notDebuggable ? "run-as only opens debuggable builds" : item.refusal, + item, + }))); + } + const picked = (await vscode.window.showQuickPick(items, { title: `Database explorer · ${device.serial}`, placeHolder: "Installed debuggable application to inspect", matchOnDescription: true }))?.item; + if (!picked || !fresh(root, revision)) return; + if (picked.refusal !== undefined) { + throw new Error(`${picked.packageName} cannot be opened: ${picked.refusal}.${picked.notDebuggable ? ` ${ANDROID_DATABASE_DEBUGGABLE_HINT}` : ""}`); + } + const options = { adb: adbExecutable(sdk), device: device.serial, packageName: picked.packageName, user: picked.user, helperDex: context.asAbsolutePath("media/database/inspector.dex") }; + panels.get(root)?.dispose(); + const panel = new DatabasePanel(options, () => valid(root), () => { if (panels.get(root) === panel) panels.delete(root); }); + panels.set(root, panel); + } catch (error) { + if (!disposed && revision === generation) void vscode.window.showErrorMessage(`Elide database explorer: ${errorText(error)}`); + } finally { + if (revision === generation) { discovery = undefined; openingRoot = undefined; } + } + }; + + const subscriptions = [ + vscode.commands.registerCommand("elide.androidDatabase", open), + workspace.onDidChange(() => { + if (openingRoot && !valid(openingRoot)) cancelDiscovery(); + for (const [root, panel] of panels) if (!valid(root)) { panel.dispose(); panels.delete(root); } + }), + ]; + return new vscode.Disposable(() => { + disposed = true; + cancelDiscovery(); + for (const panel of panels.values()) panel.dispose(); + panels.clear(); + for (const subscription of subscriptions) subscription.dispose(); + }); +} diff --git a/packages/vscode/src/androidDatabaseHtml.ts b/packages/vscode/src/androidDatabaseHtml.ts new file mode 100644 index 0000000..4a95d66 --- /dev/null +++ b/packages/vscode/src/androidDatabaseHtml.ts @@ -0,0 +1,156 @@ +import { randomBytes } from "node:crypto"; + +/** All device-controlled content is assigned with textContent, never interpolated into HTML. */ +export function databaseHtml(): string { + const nonce = randomBytes(18).toString("base64"); + return String.raw` + + +Android database explorer + +

Database explorer Experimental

Connecting to the application…
+
+
+
+ +
Ctrl/⌘ Enter to query
+

Live SQLite access, including committed WAL data. Writes require confirmation and do not notify Room observers. No in-memory or encrypted databases.

+ +
+ +

100 rows per page. Each page is a new read, not a retained snapshot; use ORDER BY for predictable paging. BLOBs display as Base64; 64-bit integers retain their exact values.

+
+`; +} diff --git a/packages/vscode/src/androidDebug.ts b/packages/vscode/src/androidDebug.ts index be0bddd..7adaf34 100644 --- a/packages/vscode/src/androidDebug.ts +++ b/packages/vscode/src/androidDebug.ts @@ -4,7 +4,9 @@ import { ANDROID_DEVICE_OPTION, androidDebugTask, elideInvocationArgs, elideInvo import * as vscode from "vscode"; import type { AndroidDeploymentController } from "./androidDeployment.js"; import { configuredInvocation, readConfig } from "./config.js"; -import { launchWithJdwp, resolveAttachType } from "./debug.js"; +import { launchWithJdwp, resolveAttachType, warnSymlinkedFolder } from "./debug.js"; +import { androidFailureActions } from "./failures.js"; +import { isJetBrainsDebugger } from "./jetbrains.js"; import type { ElideUi } from "./output.js"; import type { ElideWorkspace } from "./projects.js"; @@ -74,6 +76,10 @@ export class ElideAndroidDebugConfigurationProvider implements vscode.DebugConfi const candidates = this.workspace.projectsIn(targetFolder).filter((candidate) => candidate.model?.android?.targets.some((target) => target.kind === "apk" && target.name === launch.artifact), ); + if (candidates.length === 0) { + void vscode.window.showErrorMessage(`Elide: '${launch.artifact}' is not an Android APK in any synced project in this folder.`); + return undefined; + } const editor = vscode.window.activeTextEditor; const owner = editor?.document.uri.scheme === "file" ? this.workspace.projectFor(editor.document.uri.fsPath) : undefined; root = candidates.find((candidate) => candidate === owner)?.root @@ -82,7 +88,6 @@ export class ElideAndroidDebugConfigurationProvider implements vscode.DebugConfi candidates.map((candidate) => ({ label: candidate.model?.name ?? candidate.root, description: candidate.root, root: candidate.root })), { placeHolder: `Android project declaring APK '${launch.artifact}'` }, ))?.root; - if (candidates.length === 0) void vscode.window.showErrorMessage(`Elide: '${launch.artifact}' is not an Android APK in any synced project in this folder.`); if (!root) return undefined; } const project = this.workspace.projectAt(root); @@ -94,10 +99,14 @@ export class ElideAndroidDebugConfigurationProvider implements vscode.DebugConfi const settings = readConfig(targetFolder); const attachType = await resolveAttachType(settings, targetFolder, this.workspace); if (!attachType) return undefined; + const jetBrains = isJetBrainsDebugger(attachType); + if (jetBrains) await this.workspace.prepareAndroidDebugger(); const name = typeof launch.name === "string" && launch.name ? launch.name : `Elide: Debug ${launch.artifact}`; if (launch.port !== undefined) { // Nothing to build: the port is already forwarded, so the attach session is the whole of it. + if (jetBrains && !await this.workspace.kotlinDebuggerReady(project.root, new AbortController().signal)) return undefined; + warnSymlinkedFolder(targetFolder, attachType); this.ui.log(`android debug: attaching with ${attachType} to port ${launch.port}`); const attached = await vscode.debug.startDebugging(targetFolder, { type: attachType, @@ -105,6 +114,9 @@ export class ElideAndroidDebugConfigurationProvider implements vscode.DebugConfi name, hostName: "127.0.0.1", port: launch.port, + // The attach session is the whole launch, so VS Code runs the configuration's tasks around it. + ...(launch.preLaunchTask !== undefined ? { preLaunchTask: launch.preLaunchTask } : {}), + ...(launch.postDebugTask !== undefined ? { postDebugTask: launch.postDebugTask } : {}), }); if (!attached) void vscode.window.showErrorMessage(`Elide: could not attach ${attachType} debugger to port ${launch.port}.`); return undefined; @@ -115,7 +127,7 @@ export class ElideAndroidDebugConfigurationProvider implements vscode.DebugConfi root: project.root, artifact: launch.artifact, ...(launch.device ? { device: launch.device } : {}), - }); + }, "debug"); if (!deployment) return undefined; let dist; @@ -134,7 +146,20 @@ export class ElideAndroidDebugConfigurationProvider implements vscode.DebugConfi ); const argv = elideInvocationArgs("build", invocation); this.ui.log(`android debug: ${dist.bin} ${argv.join(" ")} (cwd ${project.root})`); - launchWithJdwp({ dist: dist.bin, argv, cwd: project.root, name, folder: targetFolder, attachType, ...(invocation.env ? { env: invocation.env } : {}) }, this.ui, this.sessions); + launchWithJdwp({ + dist: dist.bin, + argv, + cwd: project.root, + name, + folder: targetFolder, + attachType, + ...(invocation.env ? { env: invocation.env } : {}), + ...(jetBrains ? { ready: (signal: AbortSignal) => this.workspace.kotlinDebuggerReady(project.root, signal) } : {}), + ...(launch.postDebugTask !== undefined ? { postDebugTask: launch.postDebugTask } : {}), + failureActions: (failure) => androidFailureActions([project, ...this.workspace.projects], failure, async (application) => { + if (await this.deployments.uninstall(deployment, application, device)) await vscode.debug.startDebugging(targetFolder, config); + }), + }, this.ui, this.sessions); // The real session is the attach started once the device's JVM is listening. return undefined; } diff --git a/packages/vscode/src/androidDeployment.ts b/packages/vscode/src/androidDeployment.ts index d5e99b0..2ae4318 100644 --- a/packages/vscode/src/androidDeployment.ts +++ b/packages/vscode/src/androidDeployment.ts @@ -5,9 +5,9 @@ import { setTimeout as sleep } from "node:timers/promises"; import { promisify } from "node:util"; import { ANDROID_EMULATOR_TASK, ANDROID_NO_WINDOW_OPTION, adbExecutable, type AndroidDevice } from "@elide/ide-core"; import * as vscode from "vscode"; -import { androidSdkFor, attachedDevices, toAndroidTarget } from "./android.js"; -import { androidApks, androidAvdToBoot, androidDeviceProblem, preferredAndroidProject, restoredAndroidTarget } from "./androidDeploymentState.js"; -import type { AndroidDeploymentDestination, AndroidDeploymentTarget } from "./androidDeploymentState.js"; +import { androidSdkFor, attachedDevices, toAndroidTarget, uninstallAndroidApp } from "./android.js"; +import { NO_ANDROID_PROJECT, androidApks, androidAvdToBoot, androidDeviceProblem, androidApkOffers, androidUseTask, deployableApks, hasAndroidProjects, preferredAndroidProject, restoredAndroidTarget } from "./androidDeploymentState.js"; +import type { AndroidDeploymentDestination, AndroidDeploymentTarget, AndroidDeploymentUse } from "./androidDeploymentState.js"; import { readConfig } from "./config.js"; import type { ElideProject, ElideWorkspace } from "./projects.js"; import { executeElideTask, taskExitCode } from "./tasks.js"; @@ -16,22 +16,38 @@ const STATE_KEY = "elide.android.deploymentTarget"; const SELECT_COMMAND = "elide.androidSelectTarget"; const BOOT_TIMEOUT_MS = 120_000; const BOOT_POLL_MS = 2_000; +const UNINSTALL = "Uninstall"; const exec = promisify(execFile); export interface AndroidDeployment extends AndroidDeploymentTarget { project: ElideProject; } -function runnable(project: ElideProject): boolean { - return project.model?.android?.targets.some((target) => target.kind === "apk") ?? false; +function deployable(project: ElideProject, use?: AndroidDeploymentUse): boolean { + return deployableApks(project, use).length > 0; } +function sameTarget(a: AndroidDeploymentTarget | undefined, b: AndroidDeploymentTarget): boolean { + if (a?.root !== b.root || a.artifact !== b.artifact || a.destination.kind !== b.destination.kind) return false; + return a.destination.kind === "emulator" || b.destination.kind === "emulator" + || (a.destination.serial === b.destination.serial && a.destination.avd === b.destination.avd); +} + +/** What each use is called in a refusal; a device-only action needs any device target. */ +const USE_LABELS: Record = { run: "Run", debug: "Debug", test: "instrumented-test" }; +/** Why Elide lists no device target for an APK, in the case a user can do something about. */ +const INSTALLABLE_HINT = "Elide lists none for an APK it cannot install, such as an unsigned one: check its `signing` and sync again."; +/** Why an installable APK offers no Debug: its manifest, or the build it was merged into, leaves it undebuggable. */ +const DEBUGGABLE_HINT = "Build it with `release = false` (with an Elide that marks such builds debuggable), or set `android:debuggable=\"true\"` on `` in its manifest, then build it again."; + /** One workspace's Run/Debug target. Device discovery is demand-driven, never a background poll. */ export class AndroidDeploymentController implements vscode.Disposable { private selected: AndroidDeploymentTarget | undefined; private readonly status = vscode.window.createStatusBarItem("elide.androidTarget", vscode.StatusBarAlignment.Left, 90); private readonly subscriptions: vscode.Disposable[]; private disposed = false; + /** Bumped whenever a different selection is persisted: a resolution that began before it is stale. */ + private revision = 0; private readonly bootTasks = new Set(); constructor(private readonly workspace: ElideWorkspace, private readonly state: vscode.Memento) { @@ -56,33 +72,57 @@ export class AndroidDeploymentController implements vscode.Disposable { } async select(value?: unknown): Promise { - await this.resolve(value, true); + await this.resolve(value, undefined, true); } - /** Explicit invocation, active runnable owner, remembered app, then a choice among runnable apps. */ - async resolve(value?: unknown, choose = false): Promise { + /** + * Explicit invocation, active deployable owner, remembered app, then a choice among deployable apps. `use` names + * what the caller deploys for — its APK must offer that target; without it, any device target will do, which is + * what an action needing only the device asks for. + */ + async resolve(value?: unknown, use?: AndroidDeploymentUse, choose = false): Promise { if (this.disposed) return undefined; + // Only another selection persisted meanwhile supersedes this one; a picker dismissed elsewhere does not. + const revision = this.revision; const explicit = toAndroidTarget(value); - const candidates = this.workspace.projects.filter(runnable); + const candidates = this.workspace.projects.filter((project) => deployable(project, use)); + const label = use ? USE_LABELS[use] : "device"; + if (!hasAndroidProjects(this.workspace.projects)) { + void vscode.window.showWarningMessage(NO_ANDROID_PROJECT); + return undefined; + } + // Selecting a target only helps when there is another one to select, and never from the selection itself. + const selectable = !choose && candidates.length > 0; + const editor = vscode.window.activeTextEditor; + const owner = editor?.document.uri.scheme === "file" ? this.workspace.projectFor(editor.document.uri.fsPath) : undefined; let project: ElideProject | undefined; if (explicit) { project = this.workspace.projectAt(explicit.root); - if (!project || !runnable(project)) { - await this.problem(`Project ${explicit.root} has no synced Android application package. Sync the project and select an APK target.`); + if (explicit.device && !explicit.artifact && !choose) { + // A device row names only the device. Its root is the project its tree's tools act for, not an app the user + // picked: the app is the one the tree would deploy without a target, the active or the remembered one. + const top = project?.workspaceRoot ? this.workspace.projectAt(project.workspaceRoot) : project; + const tree = top ? [top, ...this.workspace.membersOf(top.root)] : []; + project = preferredAndroidProject(tree, owner && tree.includes(owner) ? owner : undefined, this.selected?.root, use) ?? project; + } + if (!project || !deployable(project, use)) { + this.problem(project && androidApks(project).length > 0 + ? `No APK of ${project.model?.name ?? project.root} has a ${label} target. ${INSTALLABLE_HINT}` + : `Project ${explicit.root} has no synced Android application package. Sync the project and select an APK target.`, selectable); return undefined; } } else if (!choose) { - const editor = vscode.window.activeTextEditor; - const owner = editor?.document.uri.scheme === "file" ? this.workspace.projectFor(editor.document.uri.fsPath) : undefined; - project = preferredAndroidProject(this.workspace.projects, owner, this.selected?.root); + project = preferredAndroidProject(this.workspace.projects, owner, this.selected?.root, use); if (!project && this.selected) { - await this.problem(`Remembered Android project ${this.selected.root} is no longer runnable. Select a current deployment target.`); + this.problem(`Remembered Android project ${this.selected.root} has no APK with a ${label} target. Select a current deployment target.`, selectable); return undefined; } } if (!project) { if (candidates.length === 0) { - await this.problem("No synced project declares an Android application package. Sync an Android app project first."); + this.problem(this.workspace.projects.some((candidate) => androidApks(candidate).length > 0) + ? `No synced Android APK has a ${label} target. ${INSTALLABLE_HINT}` + : "No synced Android project declares an application package (APK); its projects build only libraries or bundles.", false); return undefined; } project = candidates.length === 1 ? candidates[0] : (await vscode.window.showQuickPick( @@ -90,12 +130,25 @@ export class AndroidDeploymentController implements vscode.Disposable { { placeHolder: "Android deployment project" }, ))?.project; } - if (!project) return undefined; + if (!project || this.disposed || revision !== this.revision) return undefined; + const root = project.root; + // A sync gives the project a new model, and a workspace member a new project: what counts is that it is still Android. + const current = () => !this.disposed && revision === this.revision && this.workspace.projectAt(root)?.model?.android !== undefined; + if (!current()) return undefined; + project = this.workspace.projectAt(root)!; + const android = project.model!.android!; const remembered = this.selected?.root === project.root ? this.selected : undefined; - const apks = androidApks(project); + const apks = deployableApks(project, use); let artifact = explicit?.artifact ?? (choose ? undefined : remembered?.artifact); - if (artifact && !apks.some((apk) => apk.name === artifact)) { - await this.problem(`Android APK '${artifact}' is no longer declared by ${project.model?.name ?? project.root}. Select a current deployment target.`); + const declared = artifact === undefined ? undefined : androidApks(project).find((apk) => apk.name === artifact); + if (artifact && !declared) { + this.problem(`Android APK '${artifact}' is no longer declared by ${project.model?.name ?? project.root}. Select a current deployment target.`, selectable); + return undefined; + } + if (artifact && declared && !androidApkOffers(declared, use)) { + // An installable APK without Debug is one that cannot be debugged, not one Elide cannot install. + this.problem(use === "debug" && declared.device?.runnable ? `'${artifact}' is not debuggable. ${DEBUGGABLE_HINT}` + : `Elide lists no ${use ? androidUseTask(use, artifact) : "device"} target for '${artifact}'. ${INSTALLABLE_HINT} Or select a different deployment target.`, selectable); return undefined; } if (!artifact) { @@ -103,19 +156,21 @@ export class AndroidDeploymentController implements vscode.Disposable { apks.map((apk) => ({ label: apk.name, description: apk.output })), { placeHolder: "Android application package" }, ))?.label; } - if (!artifact) return undefined; + if (!artifact || !current()) return undefined; const devices = await this.devices(project); - if (!devices) return undefined; - const avd = project.model!.android!.avd; + if (!devices || !current()) return undefined; + const avd = android.avd; let destination: AndroidDeploymentDestination | undefined = explicit?.device ? (remembered?.destination.kind === "device" && remembered.destination.serial === explicit.device ? remembered.destination : { kind: "device", serial: explicit.device }) : choose ? undefined : remembered?.destination; // A stopped emulator is booted at deployment time; only a physical device needs the user to bring it back. - if (destination?.kind === "device" && !androidAvdToBoot(devices, destination, avd) && !await this.validateDevice(devices, destination.serial)) return undefined; + if (destination?.kind === "device" && !androidAvdToBoot(devices, destination, avd) && !this.validateDevice(devices, destination.serial, selectable)) return undefined; + if (!current()) return undefined; if (!destination) { const ready = devices.filter((device) => device.ready); const sdk = androidSdkFor(project); const avds = await Promise.all(ready.map((device) => device.emulator && sdk ? this.avdOf(sdk, device.serial) : undefined)); + if (!current()) return undefined; const items: (vscode.QuickPickItem & { destination: AndroidDeploymentDestination })[] = ready.map((device, index) => { const running = avds[index]; return { @@ -129,68 +184,149 @@ export class AndroidDeploymentController implements vscode.Disposable { destination = (await vscode.window.showQuickPick(items, { placeHolder: unavailable.length ? `Deploy ${artifact}; unavailable: ${unavailable.join(", ")}. Authorize USB debugging or reconnect.` : `Deploy ${artifact} on`, }))?.destination; + if (!current()) return undefined; if (destination?.kind === "device") { const refreshed = await this.devices(project); - if (!refreshed || !await this.validateDevice(refreshed, destination.serial)) return undefined; + if (!refreshed || !current() || !this.validateDevice(refreshed, destination.serial, false)) return undefined; } } - if (!destination || this.disposed) return undefined; + if (!current()) return undefined; + if (destination?.kind === "device" && !destination.avd) { + const serial = destination.serial; + const device = devices.find((candidate) => candidate.serial === serial); + const sdk = device?.emulator && device.ready ? androidSdkFor(project) : undefined; + const identity = sdk ? await this.avdOf(sdk, serial) : undefined; + if (identity) destination = { ...destination, avd: identity }; + } + if (!destination || !current()) return undefined; const selection = { root: project.root, artifact, destination }; - if (!await this.remember(selection)) return undefined; - return { ...selection, project }; + // Persisting a different selection supersedes every resolution begun before it, this one excepted. + if (!await this.remember(selection, revision)) return undefined; + const deployed = this.workspace.projectAt(root); + return deployed?.model?.android ? { ...selection, project: deployed } : undefined; } /** * The serial a deployment installs on, booting its emulator first when it is not running. A pinned emulator is * resolved again by AVD identity, since a reboot may give it another port; a physical device is never swapped. + * The deployment must stay the selection meanwhile: when another target is selected while its emulator starts, + * nothing is deployed, and the user is told so. */ async readyDevice(deployment: AndroidDeployment): Promise { + // Re-pinning a rebooted emulator's serial replaces the selection this deployment has to remain. + const pin = { target: { root: deployment.root, artifact: deployment.artifact, destination: deployment.destination } }; + const serial = await this.prepareDevice(pin); if (this.disposed) return undefined; - const { project, destination } = deployment; - const projectAvd = project.model!.android!.avd; - if (destination.kind === "emulator") return this.bootAvd(project, projectAvd); + if (!sameTarget(this.selected, pin.target)) { + void vscode.window.showWarningMessage(`Elide: the Android deployment target changed while the device for ${deployment.artifact} was getting ready, so nothing was deployed. Start it again to deploy to the new target.`); + return undefined; + } + if (!this.declares(pin.target)) { + this.problem(`Android APK '${deployment.artifact}' is no longer declared by ${deployment.root}. Select a current deployment target.`); + return undefined; + } + return serial; + } + + private async prepareDevice(pin: { target: AndroidDeploymentTarget }): Promise { + const current = () => !this.disposed && sameTarget(this.selected, pin.target) && this.declares(pin.target); + const { root, artifact, destination } = pin.target; + const project = this.workspace.projectAt(root); + const projectAvd = project?.model?.android?.avd; + if (!project || !projectAvd || !current()) return undefined; + if (destination.kind === "emulator") return await this.bootAvd(project, projectAvd, current); const devices = await this.devices(project); - if (!devices) return undefined; - const avd = androidAvdToBoot(devices, destination, projectAvd); - if (!avd) return await this.validateDevice(devices, destination.serial) ? destination.serial : undefined; - const serial = await this.bootAvd(project, avd); - if (!serial) return undefined; + if (!devices || !current()) return undefined; + const sdk = androidSdkFor(project); + const currentAvd = destination.avd && sdk ? await this.avdOf(sdk, destination.serial) : undefined; + if (!current()) return undefined; + const avd = androidAvdToBoot(devices, destination, projectAvd, currentAvd); + if (!avd) return this.validateDevice(devices, destination.serial) ? destination.serial : undefined; + const serial = await this.bootAvd(project, avd, current); + if (!serial || !current()) return undefined; if (serial !== destination.serial || destination.avd !== avd) { - const remembered = await this.remember({ root: deployment.root, artifact: deployment.artifact, destination: { kind: "device", serial, avd } }); - if (!remembered) return undefined; + const repinned: AndroidDeploymentTarget = { root, artifact, destination: { kind: "device", serial, avd } }; + if (!await this.remember(repinned, this.revision)) return undefined; + pin.target = repinned; } return serial; } + /** Whether the live model still declares the target's APK; a sync may have removed it. */ + private declares(target: AndroidDeploymentTarget): boolean { + return this.workspace.projectAt(target.root)?.model?.android?.targets.some((apk) => apk.kind === "apk" && apk.name === target.artifact) ?? false; + } + + /** + * Uninstall `application` from the device `deployment` installs on, once the user has confirmed that this erases + * its data: a Run or Debug refused with `INSTALL_FAILED_UPDATE_INCOMPATIBLE` can then install its own build. + * `serial` is the device it installed on; the project emulator, which Elide boots itself, is found again. + */ + async uninstall(deployment: AndroidDeployment, application: string, serial?: string): Promise { + const device = serial ?? await this.readyDevice(deployment); + if (!device || this.disposed) return false; + const avd = deployment.destination.kind === "emulator" ? this.workspace.projectAt(deployment.root)?.model?.android?.avd : deployment.destination.avd; + const name = avd ? `${device} (${avd})` : device; + const confirmed = await vscode.window.showWarningMessage(`Uninstall ${application} from ${name}?`, { + modal: true, + detail: `Android refuses to update it: the copy on the device is signed with a different key. Uninstalling erases ${application}'s data on ${name}, then installs the app again.`, + }, UNINSTALL); + if (confirmed !== UNINSTALL || this.disposed) return false; + try { + // A stopped emulator's serial can be reused while the confirmation is open. The user authorized erasing + // this AVD's data, never another emulator that has since acquired its port. + if (avd) { + const sdk = androidSdkFor(deployment.project); + const currentAvd = sdk ? await this.avdOf(sdk, device) : undefined; + if (this.disposed) return false; + if (currentAvd !== avd) { + throw new Error(`Cannot confirm that ${device} still runs AVD '${avd}'${currentAvd ? ` (it now runs '${currentAvd}')` : ""}. Nothing was uninstalled; select the intended device and retry.`); + } + } + await uninstallAndroidApp(deployment.project, device, application); + return true; + } catch (error) { + this.problem(`Could not uninstall ${application} from ${name}: ${error instanceof Error ? error.message : String(error)}`, false); + return false; + } + } + /** * Boot `avd` unless it already runs, and return its serial once Android finished booting. The project's AVD goes * through Elide, which also creates it; any other AVD is launched with the SDK's emulator, which Elide cannot name. + * A failure is reported without waiting for the user to dismiss it, so the startup progress closes with it. */ - private async bootAvd(project: ElideProject, avd: string): Promise { + private async bootAvd(project: ElideProject, avd: string, current: () => boolean): Promise { + const projectAvd = project.model?.android?.avd; + if (!projectAvd || !current()) return undefined; const sdk = androidSdkFor(project); if (!sdk) { - await this.problem("No Android SDK is configured. Set elide.android.sdk or install the Android SDK components, then retry."); + this.problem("No Android SDK is configured. Set elide.android.sdk or install the Android SDK components, then retry."); return undefined; } try { const running = await this.findEmulator(project, sdk, avd); - if (running?.ready && await this.bootCompleted(sdk, running.serial)) return running.serial; + if (!current()) return undefined; + if (running?.ready && await this.bootCompleted(sdk, running.serial)) return current() ? running.serial : undefined; + if (!current()) return undefined; const windowed = readConfig(project.folder).androidEmulatorWindow; - const booted = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Elide: starting Android emulator '${avd}'`, cancellable: true }, async (_progress, token) => { - if (token.isCancellationRequested || this.disposed) return false; - if (avd === project.model!.android!.avd) return this.bootThroughElide(project, windowed, token); - return this.bootThroughSdk(project, sdk, avd, windowed, running !== undefined, token); + return await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Elide: starting Android emulator '${avd}'`, cancellable: true }, async (_progress, token) => { + if (token.isCancellationRequested || !current()) return undefined; + const booted = avd === projectAvd + ? await this.bootThroughElide(project, windowed, token) + : await this.bootThroughSdk(project, sdk, avd, windowed, running !== undefined, token); + if (token.isCancellationRequested || !current()) return undefined; + if (!booted) { + this.problem(`Emulator '${avd}' startup failed or exceeded two minutes. Check the Elide task terminal or the emulator, then retry.`); + return undefined; + } + const serial = await this.findEmulator(project, sdk, avd); + if (token.isCancellationRequested || !current()) return undefined; + if (!serial?.ready) this.problem(`AVD '${avd}' did not appear as a ready device after startup. Check the emulator and select its device serial.`); + return serial?.ready ? serial.serial : undefined; }); - if (this.disposed) return undefined; - if (!booted) { - await this.problem(`Emulator '${avd}' startup was cancelled, failed, or exceeded two minutes. Check the Elide task terminal or the emulator, then retry.`); - return undefined; - } - const serial = await this.findEmulator(project, sdk, avd); - if (!serial?.ready) await this.problem(`AVD '${avd}' did not appear as a ready device after startup. Check the emulator and select its device serial.`); - return serial?.ready ? serial.serial : undefined; } catch (error) { - await this.problem(`Could not start Android emulator '${avd}': ${error instanceof Error ? error.message : String(error)} Check adb and the emulator, then retry.`); + this.problem(`Could not start Android emulator '${avd}': ${error instanceof Error ? error.message : String(error)} Check adb and the emulator, then retry.`); return undefined; } } @@ -237,7 +373,11 @@ export class AndroidDeploymentController implements vscode.Disposable { while (!token.isCancellationRequested && !this.disposed && Date.now() < deadline) { if (failure) throw failure; const device = await this.findEmulator(project, sdk, avd); - if (device?.ready && await this.bootCompleted(sdk, device.serial)) { child = undefined; return true; } + if (device?.ready && await this.bootCompleted(sdk, device.serial)) { + if (token.isCancellationRequested || this.disposed) return false; + child = undefined; + return true; + } await sleep(BOOT_POLL_MS); } return false; @@ -277,45 +417,60 @@ export class AndroidDeploymentController implements vscode.Disposable { } } - /** Persist the selection; false once disposed, so a late answer never resurrects a torn-down controller. */ - private async remember(selection: AndroidDeploymentTarget): Promise { - await this.state.update(STATE_KEY, selection); + /** + * Persist `selection` unless another one was persisted since `revision`; an equal one is already the selection. + * It becomes the selection at once, so a slow Memento write cannot bring back an older target after a newer one. + */ + private async remember(selection: AndroidDeploymentTarget, revision: number): Promise { if (this.disposed) return false; + if (sameTarget(this.selected, selection)) return true; + if (revision !== this.revision) return false; + const generation = ++this.revision; this.selected = selection; this.updateStatus(); - return true; + await this.state.update(STATE_KEY, selection); + return !this.disposed && generation === this.revision; } private async devices(project: ElideProject): Promise { try { const devices = await attachedDevices(project); - if (!devices) await this.problem("Android SDK platform-tools (adb) are unavailable. Set elide.android.sdk or install the Android SDK components, then retry."); + if (!devices) this.problem("Android SDK platform-tools (adb) are unavailable. Set elide.android.sdk or install the Android SDK components, then retry.", false); return devices; } catch (error) { - await this.problem(`Android device discovery failed: ${error instanceof Error ? error.message : String(error)} Check the SDK's adb installation and daemon, then retry.`); + this.problem(`Android device discovery failed: ${error instanceof Error ? error.message : String(error)} Check the SDK's adb installation and daemon, then retry.`, false); return undefined; } } - private async validateDevice(devices: AndroidDevice[], serial: string): Promise { + private validateDevice(devices: AndroidDevice[], serial: string, selectable = true): boolean { const state = androidDeviceProblem(devices, serial); if (!state) return true; const action = state === "unauthorized" ? "Unlock the device and approve its USB debugging authorization." : "Reconnect the device or start its emulator."; - await this.problem(`Android target ${serial} is ${state}. ${action} Select a different deployment target explicitly to switch devices.`); + this.problem(`Android target ${serial} is ${state}. ${action}${selectable ? " Select a different deployment target explicitly to switch devices." : ""}`, selectable); return false; } - private async problem(message: string): Promise { - const action = await vscode.window.showErrorMessage(`Elide: ${message}`, "Select Android Deployment Target"); - if (action) void vscode.commands.executeCommand(SELECT_COMMAND); + /** + * `selectable` offers the selection only where choosing another target can fix the problem. The deployment that + * failed ends without waiting for the notification to be dismissed. + */ + private problem(message: string, selectable = true): void { + if (!selectable) { + void vscode.window.showErrorMessage(`Elide: ${message}`); + return; + } + void vscode.window.showErrorMessage(`Elide: ${message}`, "Select Android Deployment Target").then((action) => { + if (action) void vscode.commands.executeCommand(SELECT_COMMAND); + }); } private updateStatus(): void { if (this.disposed) return; - if (!this.workspace.projects.some(runnable)) { this.status.hide(); return; } + if (!this.workspace.projects.some((candidate) => deployable(candidate))) { this.status.hide(); return; } const selected = this.selected; const project = selected ? this.workspace.projectAt(selected.root) : undefined; - const valid = project?.model?.android?.targets.some((target) => target.kind === "apk" && target.name === selected?.artifact); + const valid = project ? deployableApks(project).some((apk) => apk.name === selected?.artifact) : false; if (selected && valid) { const destination = selected.destination.kind === "device" ? selected.destination.avd ?? selected.destination.serial : project!.model!.android!.avd; this.status.text = `$(device-mobile) ${selected.artifact} → ${destination}`; diff --git a/packages/vscode/src/androidDeploymentState.ts b/packages/vscode/src/androidDeploymentState.ts index 0ec9e17..80a506b 100644 --- a/packages/vscode/src/androidDeploymentState.ts +++ b/packages/vscode/src/androidDeploymentState.ts @@ -1,4 +1,4 @@ -import { isEmulatorSerial, type AndroidDevice } from "@elide/ide-core"; +import { androidDebugTask, androidRunTask, androidTestTask, currentAndroidDeviceTargets, isEmulatorSerial, type AndroidDevice, type AndroidTargetModel } from "@elide/ide-core"; import type { ElideProject } from "./projects.js"; /** A device pinned by serial; an emulator also records the AVD it ran, so a stopped one can be booted again. */ @@ -9,9 +9,74 @@ export interface AndroidDeploymentTarget { destination: AndroidDeploymentDestination; } -/** Only APKs are runnable, even when the active editor belongs to an Android library. */ -export function androidApks(project: ElideProject) { - return (project.model?.android?.targets ?? []).filter((target) => target.kind === "apk"); +/** + * Every APK of the live model, deployable or not: libraries and bundles are never installed. Each offers what it + * offers now: a build that turned out not to be debuggable withdraws Debug ({@link currentAndroidDeviceTargets}). + */ +export function androidApks(project: ElideProject): AndroidTargetModel[] { + return (project.model?.android?.targets ?? []).filter((target) => target.kind === "apk").map((target) => { + const device = currentAndroidDeviceTargets(project.root, target); + return device === target.device ? target : { ...target, ...(device ? { device } : {}) }; + }); +} + +/** + * Whether any synced project declares an `android` block, application or library: the `elide.hasAndroidProjects` + * context key that shows the Android commands and menu entries. + */ +export function hasAndroidProjects(projects: readonly Pick[]): boolean { + return projects.some((project) => project.model?.android !== undefined); +} + +/** What every Android command says, as a warning, when invoked in a workspace without an Android project. */ +export const NO_ANDROID_PROJECT = "Elide: no synced project declares an `android` block, so there is no Android application or library to act on."; + +/** + * The project the shared Android devices & tools rows of a workspace tree act for, given its projects in tree order: + * the first that builds an APK, else the first with an `android` block. A library's SDK finds the same devices. + */ +export function androidToolsProject(projects: readonly ElideProject[]): ElideProject | undefined { + return projects.find((project) => androidApks(project).length > 0) ?? projects.find((project) => project.model?.android !== undefined); +} + +/** + * The emulators a workspace tree offers to start, stop and create: one per AVD its APK projects declare, owned by the + * first such project. Library-only projects build nothing to install, so they own no emulator. + */ +export function androidEmulatorOwners(projects: readonly ElideProject[]): { avd: string; project: ElideProject }[] { + const owners = new Map(); + for (const project of projects) { + const avd = project.model?.android?.avd; + if (avd && androidApks(project).length > 0 && !owners.has(avd)) owners.set(avd, project); + } + return [...owners].map(([avd, project]) => ({ avd, project })); +} + +/** What a deployment does with its APK, which decides the build target Elide has to offer for it. */ +export type AndroidDeploymentUse = "run" | "debug" | "test"; + +const USES: Record boolean; task: (artifact: string) => string }> = { + run: { offered: (apk) => apk.device?.runnable === true, task: (artifact) => androidRunTask(artifact) }, + debug: { offered: (apk) => apk.device?.debuggable === true, task: androidDebugTask }, + test: { offered: (apk) => apk.device?.instrumented === true, task: androidTestTask }, +}; + +/** Whether Elide lists the target `use` needs for `apk`; without `use`, whether it lists any of them. */ +export function androidApkOffers(apk: AndroidTargetModel, use?: AndroidDeploymentUse): boolean { + return use ? USES[use].offered(apk) : Object.values(USES).some((entry) => entry.offered(apk)); +} + +/** The build target `use` runs for `artifact`. */ +export function androidUseTask(use: AndroidDeploymentUse, artifact: string): string { + return USES[use].task(artifact); +} + +/** + * APKs a deployment can use: those Elide lists the needed device target for. An APK it cannot install — unsigned, + * say — has none, so it is never offered for a Run or Debug that would only fail after the whole build. + */ +export function deployableApks(project: ElideProject, use?: AndroidDeploymentUse): AndroidTargetModel[] { + return androidApks(project).filter((apk) => androidApkOffers(apk, use)); } /** Persistence is untrusted until both its shape and, at deployment time, the live model/devices are checked. */ @@ -29,9 +94,9 @@ export function restoredAndroidTarget(value: unknown): AndroidDeploymentTarget | } /** An active library must not outrank the selected application. Explicit invocations bypass this preference. */ -export function preferredAndroidProject(projects: readonly ElideProject[], owner: ElideProject | undefined, rememberedRoot: string | undefined): ElideProject | undefined { - if (owner && androidApks(owner).length > 0) return owner; - return projects.find((project) => project.root === rememberedRoot && androidApks(project).length > 0); +export function preferredAndroidProject(projects: readonly ElideProject[], owner: ElideProject | undefined, rememberedRoot: string | undefined, use?: AndroidDeploymentUse): ElideProject | undefined { + if (owner && deployableApks(owner, use).length > 0) return owner; + return projects.find((project) => project.root === rememberedRoot && deployableApks(project, use).length > 0); } /** A ready alternative never makes an unavailable pinned serial valid. */ @@ -41,11 +106,12 @@ export function androidDeviceProblem(devices: readonly AndroidDevice[], serial: } /** - * The AVD to boot for a pinned emulator that is not ready, or undefined when the serial is usable or is a physical - * device (which only the user can reconnect or authorize). A selection that predates AVD recording falls back to - * the project's own AVD, the only one the build itself creates. + * The AVD to boot for an unavailable pinned emulator, or one whose serial now belongs to a different AVD. + * A recorded identity must match the live console response before reusing a ready serial. Legacy selections + * without an identity retain the project's AVD fallback when disconnected. */ -export function androidAvdToBoot(devices: readonly AndroidDevice[], destination: { serial: string; avd?: string }, projectAvd: string): string | undefined { - if (!androidDeviceProblem(devices, destination.serial) || !isEmulatorSerial(destination.serial)) return undefined; +export function androidAvdToBoot(devices: readonly AndroidDevice[], destination: { serial: string; avd?: string }, projectAvd: string, currentAvd?: string): string | undefined { + if (!isEmulatorSerial(destination.serial)) return undefined; + if (!androidDeviceProblem(devices, destination.serial) && (!destination.avd || destination.avd === currentAvd)) return undefined; return destination.avd ?? projectAvd; } diff --git a/packages/vscode/src/androidLogcat.ts b/packages/vscode/src/androidLogcat.ts index 81e9a0f..720163b 100644 --- a/packages/vscode/src/androidLogcat.ts +++ b/packages/vscode/src/androidLogcat.ts @@ -5,20 +5,22 @@ import { buffer } from "node:stream/consumers"; import { promisify } from "node:util"; import * as yauzl from "yauzl"; import { - ANDROID_LOGCAT_PRIORITIES, AndroidLogcatFormatter, AndroidLogcatSession, adbExecutable, binaryManifestPackage, + formatAndroidLogcatFilter, listAndroidDevices, + parseAndroidLogcatFilter, parseAndroidPackageUid, type AndroidTargetModel, + type AndroidLogcatFilter, type AndroidLogcatOptions, type AndroidLogcatPriority, } from "@elide/ide-core"; import * as vscode from "vscode"; import { androidSdkFor, toAndroidTarget } from "./android.js"; -import { androidApks, preferredAndroidProject, type AndroidDeploymentTarget } from "./androidDeploymentState.js"; +import { NO_ANDROID_PROJECT, androidApks, preferredAndroidProject, type AndroidDeploymentTarget } from "./androidDeploymentState.js"; import { ANDROID_LOGCAT_LANGUAGE, registerAndroidStackLinks } from "./androidStackLinks.js"; import type { ElideProject, ElideWorkspace } from "./projects.js"; @@ -90,12 +92,12 @@ async function applicationId(artifact: AndroidTargetModel): Promise, candidates: readonly string[]): Promise { +async function installedOf(device: Pick, candidates: readonly string[], signal: AbortSignal): Promise { try { const { stdout } = await promisify(execFile)( device.adb, ["-s", device.device, "shell", "cmd", "package", "list", "packages", "--user", "current", "-U"], - { encoding: "utf8", timeout: 15_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true }, + { encoding: "utf8", signal, timeout: 15_000, killSignal: "SIGKILL", maxBuffer: 16 * 1024 * 1024, windowsHide: true }, ); return candidates.filter((candidate) => parseAndroidPackageUid(stdout, candidate) !== undefined); } catch { @@ -121,7 +123,7 @@ export function registerAndroidLogcat(workspace: ElideWorkspace, deploymentTarge discovery = undefined; }; const usable = (root: string, revision: number) => - !disposed && vscode.workspace.isTrusted && revision === generation && workspace.projectAt(root) !== undefined; + !disposed && vscode.workspace.isTrusted && revision === generation && workspace.projectAt(root)?.model?.android !== undefined; const trusted = () => { if (vscode.workspace.isTrusted) return true; void vscode.window.showWarningMessage("Elide: trust this workspace before running Android logcat."); @@ -136,12 +138,12 @@ export function registerAndroidLogcat(workspace: ElideWorkspace, deploymentTarge const target = toAndroidTarget(value); if (target) { const project = workspace.projectAt(target.root); - if (!project?.model?.android) void vscode.window.showWarningMessage("Elide: sync an Android project before opening logcat."); + if (!project?.model?.android) void vscode.window.showWarningMessage(NO_ANDROID_PROJECT); return project?.model?.android ? project : undefined; } const projects = workspace.projects.filter((project) => project.model?.android); if (!projects.length) { - void vscode.window.showWarningMessage("Elide: no synced project declares an `android` block."); + void vscode.window.showWarningMessage(NO_ANDROID_PROJECT); return undefined; } if (projects.length === 1) return projects[0]; @@ -216,42 +218,45 @@ export function registerAndroidLogcat(workspace: ElideWorkspace, deploymentTarge return picked ? { adb: adbExecutable(sdk), device: picked.serial } : undefined; }; - const filters = async (initial: Pick): Promise | undefined> => { - const packageName = await vscode.window.showInputBox({ - title: "Android Logcat: application", - prompt: "Application ID (blank for all applications). Includes every process using the app UID.", - value: initial.packageName ?? "", - validateInput: (value) => !value.trim() || PACKAGE_NAME.test(value.trim()) ? undefined : "Enter an application ID such as com.example.app, or leave blank.", + /** One input: the current filter as `package:… level:… text`, edited in place and checked as it is typed. */ + const filters = async (initial: AndroidLogcatFilter): Promise => { + const value = await vscode.window.showInputBox({ + title: "Filter Android Logcat", + prompt: "package: (leave out for all applications) · level:verbose|debug|info|warn|error|fatal (default info) · any other words: case-insensitive text; \"quote\" text with spaces or colons", + value: formatAndroidLogcatFilter(initial), + placeHolder: "package:com.example.app level:warn Exception", + validateInput: (input) => { + const parsed = parseAndroidLogcatFilter(input); + return "error" in parsed ? parsed.error : undefined; + }, ignoreFocusOut: true, }); - if (packageName === undefined) return undefined; - const priorities = [initial.priority, ...ANDROID_LOGCAT_PRIORITIES.filter((priority) => priority !== initial.priority)]; - const priority = await vscode.window.showQuickPick( - priorities.map((value) => ({ label: `${value}: ${PRIORITIES[value]}`, description: value === initial.priority ? "current" : undefined, value })), - { placeHolder: "Minimum log priority" }, - ); - if (!priority) return undefined; - const text = await vscode.window.showInputBox({ - title: "Android Logcat: text filter", - prompt: "Case-insensitive literal text to match in each log line (blank for all lines)", - value: initial.text, - ignoreFocusOut: true, - }); - return text === undefined ? undefined : { ...(packageName.trim() ? { packageName: packageName.trim() } : {}), priority: priority.value, text }; + if (value === undefined) return undefined; + const parsed = parseAndroidLogcatFilter(value); + return "error" in parsed ? undefined : parsed; }; const startView = async (view: LogcatView) => { view.output.show(true); const { device, packageName, priority, text } = view.options; notice(view, `${device} · ${packageName ?? "all applications"} · ${PRIORITIES[priority]} and above${text ? ` · text "${text}"` : ""} (Elide: Filter Android Logcat to change)`); - await view.stream.start(view.options); + const started = await view.stream.start(view.options); + if (started?.start) { + notice(view, started.start.from === "process" + ? `Requested logs since ${packageName}'s current process start (${new Date(Number(started.start.since) * 1000).toLocaleTimeString()}); earlier processes' logs are not replayed.` + : `Requested logs since ${new Date(Number(started.start.since) * 1000).toLocaleTimeString()} (device clock)${packageName ? `; ${packageName} is not running` : ""}. Earlier device logs are not replayed.`); + notice(view, "adb reader startup is asynchronous; this header does not confirm device-side readiness."); + } + if (started?.process) { + notice(view, `Logcat on Android API ${started.process.sdk} cannot filter by application (needs API 31): following only ${packageName}'s main process ${started.process.pid}. Restart Android Logcat after the app restarts.`); + } }; /** * The application to filter when no stream exists yet: explicit, deployed, the only APK, the one ID every APK * shares, or the only one installed on the device. Several installed variants are a genuine choice. */ - const defaultPackage = async (project: ElideProject, explicit: string | undefined, device: Pick): Promise<{ packageName?: string } | undefined> => { + const defaultPackage = async (project: ElideProject, explicit: string | undefined, device: Pick, signal: AbortSignal): Promise<{ packageName?: string } | undefined> => { const apks = project.model?.android?.targets.filter((artifact) => artifact.kind === "apk") ?? []; if (explicit) { const artifact = apks.find((apk) => apk.name === explicit); @@ -266,9 +271,11 @@ export function registerAndroidLogcat(workspace: ElideWorkspace, deploymentTarge return packageName ? { packageName } : {}; } const ids = await Promise.all(apks.map(applicationId)); + if (signal.aborted) return undefined; const candidates = [...new Set(ids.filter((id): id is string => id !== undefined))]; if (candidates.length <= 1) return candidates[0] ? { packageName: candidates[0] } : {}; - const installed = await installedOf(device, candidates); + const installed = await installedOf(device, candidates, signal); + if (signal.aborted) return undefined; if (installed?.length === 1) return { packageName: installed[0] }; const offered = installed?.length ? installed : candidates; const picked = await vscode.window.showQuickPick( @@ -294,9 +301,16 @@ export function registerAndroidLogcat(workspace: ElideWorkspace, deploymentTarge const deployedSerial = deployed?.root === project.root && deployed.destination.kind === "device" ? deployed.destination.serial : undefined; const device = await deviceOptions(project, target?.device, [existing?.options.device, deployedSerial]); if (!device || !usable(project.root, revision)) return; - const application = existing && !target?.artifact - ? (existing.options.packageName ? { packageName: existing.options.packageName } : {}) - : await defaultPackage(project, target?.artifact, device); + const controller = new AbortController(); + discovery = { root: project.root, controller }; + let application: { packageName?: string } | undefined; + try { + application = existing && !target?.artifact + ? (existing.options.packageName ? { packageName: existing.options.packageName } : {}) + : await defaultPackage(project, target?.artifact, device, controller.signal); + } finally { + if (discovery?.controller === controller) discovery = undefined; + } if (!application || !usable(project.root, revision)) return; const options: AndroidLogcatOptions = { ...device, ...application, priority: existing?.options.priority ?? "I", text: existing?.options.text ?? "" }; if (existing?.stream.active && sameOptions(existing.options, options)) { @@ -337,7 +351,8 @@ export function registerAndroidLogcat(workspace: ElideWorkspace, deploymentTarge } else { const project = workspace.projectAt(view.root); if (!project) return; - const device = await deviceOptions(project, view.options.device); + // Prefer the stream's device, but follow it to a new serial (Wi-Fi port change, emulator on another port). + const device = await deviceOptions(project, undefined, [view.options.device]); if (!device || !usable(view.root, revision)) return; const chosen = action === "filter" ? await filters(view.options) : view.options; if (!chosen || !usable(view.root, revision)) return; @@ -357,10 +372,11 @@ export function registerAndroidLogcat(workspace: ElideWorkspace, deploymentTarge register("elide.androidLogcatStop", (value) => control("stop", value)); register("elide.androidLogcatRestart", (value) => control("restart", value)); registrations.push(workspace.onDidChange(() => { - if (discovery && !workspace.projectAt(discovery.root)) cancelPending(); + if (discovery && (!workspace.projectAt(discovery.root)?.model?.android || !vscode.workspace.isTrusted)) cancelPending(); for (const [root, view] of views) { if (workspace.projectAt(root)?.model?.android && vscode.workspace.isTrusted) continue; view.stream.dispose(); + cancelPending(); view.output.dispose(); views.delete(root); } diff --git a/packages/vscode/src/androidReadiness.ts b/packages/vscode/src/androidReadiness.ts index eecb58e..1130208 100644 --- a/packages/vscode/src/androidReadiness.ts +++ b/packages/vscode/src/androidReadiness.ts @@ -1,15 +1,17 @@ import { execFile } from "node:child_process"; -import { realpathSync, statSync } from "node:fs"; +import { statSync } from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; import { adbExecutable, androidPlatformApiLevel, decodeManifest, isSupportedElideVersion, MIN_ELIDE_VERSION, - parseAdbDevices, parseElideVersion, resolveElideDistribution, resolveJdk, + looksLikeJdk, parseAdbDevices, parseElideVersion, resolveElideDistribution, resolveJdk, type ElideDistribution, } from "@elide/ide-core"; import * as vscode from "vscode"; import { androidSdkFor, toAndroidTarget } from "./android.js"; +import { androidApks } from "./androidDeploymentState.js"; import { readConfig } from "./config.js"; +import { symlinkedFolderTarget } from "./debug.js"; import { JETBRAINS_EXTENSION_ID, jetBrainsExtension } from "./jetbrains.js"; import type { ElideUi } from "./output.js"; import type { ElideProject, ElideWorkspace } from "./projects.js"; @@ -100,11 +102,10 @@ export async function inspectAndroidReadiness( : "No synced model. Local tools can still be checked; project SDK declarations, Java target, compile SDK, AVD and artifact kinds are unknown until sync.", [manifest, sync, output]); await check("workspacePath", "Workspace source path", [output], async () => { const opened = path.resolve(project.folder.uri.fsPath); - const canonical = realpathSync(opened); - const same = process.platform === "win32" ? opened.toLowerCase() === canonical.toLowerCase() : opened === canonical; - add("workspacePath", same ? "ok" : "warning", "Workspace source path", same ? canonical : - `${opened} resolves to ${canonical}. The JetBrains debugger can report "Source file not found" for breakpoints opened through a symlink. Open the canonical folder for source debugging.`, - same ? [] : [{ label: "Open canonical folder in a new window", command: "vscode.openFolder", arguments: [vscode.Uri.file(canonical), true] }]); + const real = symlinkedFolderTarget(project.folder); + add("workspacePath", real ? "warning" : "ok", "Workspace source path", real ? + `${opened} resolves to ${real}. The JetBrains debugger cannot bind breakpoints in files opened through a symlink. Open the canonical folder for source debugging.` + : opened, real ? [{ label: "Open canonical folder in a new window", command: "vscode.openFolder", arguments: [vscode.Uri.file(real), true] }] : []); }, "warning"); let distribution: ElideDistribution | undefined; await check("elide", "Elide CLI", [settings("elide.home"), output], async () => { @@ -130,7 +131,8 @@ export async function inspectAndroidReadiness( const java = path.join(jdk.home, "bin", process.platform === "win32" ? "java.exe" : "java"); const version = await probe(java, ["-version"], true); add("jdk", "ok", "JDK for Kotlin analysis", `${jdk.home}\n${version}\n${project.model ? "Uses configured or last-synced JDK, then the existing resolver." : "Environment candidate only; manifest Java home/target not checked until sync."}`, [settings("elide.jdk.home"), sync]); - if (config.jdkHome && path.resolve(jdk.home) !== path.resolve(config.jdkHome)) { + // The resolver returns homes with symlinks resolved: a usable override comes back under its real path. + if (config.jdkHome && !looksLikeJdk(config.jdkHome, (file) => isPath(file))) { add("jdkOverride", "warning", "Configured JDK was not usable", `${config.jdkHome}\nThe existing resolver selected ${jdk.home} instead.`, [settings("elide.jdk.home")]); } }); @@ -181,10 +183,13 @@ export async function inspectAndroidReadiness( !kotlin ? `${JETBRAINS_EXTENSION_ID} is not installed. Install/enable it for Kotlin analysis.` : `${JETBRAINS_EXTENSION_ID}: installed, ${kotlin.isActive ? "active" : "not active; open a Kotlin or Java file to activate it"}.`, [{ label: "Show JetBrains Kotlin Extension", command: "extension.open", arguments: [JETBRAINS_EXTENSION_ID] }, sync]); - const apks = android?.targets.filter((artifact) => artifact.kind === "apk") ?? []; - add("artifacts", !project.model ? "unknown" : apks.length ? "ok" : "info", "Runnable Android artifacts", + const apks = androidApks(project); + const offered = (artifact: (typeof apks)[number]) => [artifact.device?.runnable ? "Run" : "", artifact.device?.debuggable ? "Debug" : "", + artifact.device?.instrumented ? "instrumented tests" : ""].filter(Boolean).join(", "); + add("artifacts", !project.model ? "unknown" : apks.some((artifact) => offered(artifact)) ? "ok" : apks.length ? "warning" : "info", "Runnable Android artifacts", !project.model ? "Unknown until sync; the doctor does not evaluate elide.pkl." - : apks.length ? apks.map((artifact) => `${artifact.name}: APK → ${artifact.output}`).join("\n") + "\nDeclarations only; APK build/install readiness is not checked." + : apks.length ? apks.map((artifact) => `${artifact.name}: APK → ${artifact.output} · ${offered(artifact) || "Elide lists no run, debug or test target for it; check its `signing`"}`).join("\n") + + "\nTargets as Elide lists them; APK build/install readiness is not checked." : android?.targets.length ? `No runnable APK. Declared artifacts: ${android.targets.map((artifact) => `${artifact.name} (${artifact.kind})`).join(", ")}. Libraries, bundles and features are not standalone run targets.` : "No Android APK is declared in the last synced model.", [manifest, sync]); report.cancelled = signal.aborted; diff --git a/packages/vscode/src/androidStackSource.ts b/packages/vscode/src/androidStackSource.ts index 7a84f8a..c192a14 100644 --- a/packages/vscode/src/androidStackSource.ts +++ b/packages/vscode/src/androidStackSource.ts @@ -13,7 +13,8 @@ export interface AndroidStackFrame { /** SourceFile attributes, not binary class names, locate nested classes and Kotlin FileKt facades. */ export function parseAndroidStackFrame(text: string): AndroidStackFrame | undefined { - const match = /(?:^|\s)at\s+(?:[\w.$@-]+\/)?([\p{ID_Start}_$][\p{ID_Continue}.$]*)\.[^\s.()]+\(([^\s():/\\]+\.(?:kt|java)):([0-9]+)\)/u.exec(text); + // Kotlin and Compose binary names carry `-` (`ComposableSingletons$MainKt$lambda-1$1`, `$lambda$-1868395445$1`). + const match = /(?:^|\s)at\s+(?:[\w.$@-]+\/)?([\p{ID_Start}_$][\p{ID_Continue}.$-]*)\.[^\s.()]+\(([^\s():/\\]+\.(?:kt|java)):([0-9]+)\)/u.exec(text); if (!match) return undefined; const className = match[1]!; const fileName = match[2]!; diff --git a/packages/vscode/src/androidTesting.ts b/packages/vscode/src/androidTesting.ts new file mode 100644 index 0000000..5fd6303 --- /dev/null +++ b/packages/vscode/src/androidTesting.ts @@ -0,0 +1,374 @@ +import path from "node:path"; +import { + ANDROID_DEVICE_OPTION, + ANDROID_TESTS_OPTION, + AndroidTestResultParser, + ElideCli, + ElideCommandFailedError, + androidTestTask, + elideInvocationArgs, + isPathUnder, + mergeElideInvocationOptions, + normalizePath, + resolveElideDistribution, + scanJvmTestSource, + type AndroidTargetModel, + type AndroidTestResult, +} from "@elide/ide-core"; +import * as vscode from "vscode"; +import { toAndroidTarget } from "./android.js"; +import type { AndroidDeploymentController } from "./androidDeployment.js"; +import { androidApks } from "./androidDeploymentState.js"; +import { configuredInvocation, readConfig } from "./config.js"; +import type { ElideUi } from "./output.js"; +import type { ElideProject, ElideWorkspace } from "./projects.js"; + +const SOURCE_GLOB = "**/*.{kt,java}"; +const JVM_SOURCE = /\.(?:kt|java)$/; +/** A stack frame of a failure: `at sample.DeviceTest.works(DeviceTest.java:15)`. */ +const FRAME_LOCATION = /\((\w+\.(?:kt|java)):(\d+)\)/; +const RESCAN_DEBOUNCE_MS = 300; +const STDERR_TAIL_LINES = 20; + +/** The test APK one top-level item stands for, with its tests by the `#` name the runner reports. */ +interface Suite { + root: string; + artifact: string; + item: vscode.TestItem; + /** Source roots of the source sets the APK packages: where its instrumented tests are declared. */ + dirs: string[]; + tests: Map; +} + +/** + * Test Explorer for the instrumented tests of Android test APKs: an APK declaring `instrumentation` in Pkl or an + * `` element in its manifest, whose tests `elide build android-test-` installs and runs on + * a device. + * + * The tests are found as the JVM ones are, statically, in the source sets the APK packages. A run goes to the + * deployment target's device, passed as `--device`, narrowed to the selected classes and methods with `--tests`, and + * each result the target reports is mapped back onto its item. There is no Debug profile: the target attaches no + * debugger. + */ +export class ElideAndroidTestController implements vscode.Disposable { + private readonly controller = vscode.tests.createTestController("elide-android", "Elide Android Instrumented Tests"); + private readonly profile: vscode.TestRunProfile; + private readonly suites = new Map(); + private readonly subscriptions: vscode.Disposable[]; + private readonly rescans = new Map(); + private queue: Promise = Promise.resolve(); + /** The `android-test-*` processes of the runs in flight: the CLI is detached, so it would outlive the window. */ + private readonly running = new Set(); + private disposed = false; + + constructor( + private readonly workspace: ElideWorkspace, + private readonly ui: ElideUi, + private readonly deployments: AndroidDeploymentController, + ) { + this.controller.refreshHandler = () => this.refresh(); + this.profile = this.controller.createRunProfile("Run on Android Deployment Target", vscode.TestRunProfileKind.Run, + (request, token) => void this.run(request, token), true); + const watcher = vscode.workspace.createFileSystemWatcher(SOURCE_GLOB); + this.subscriptions = [ + watcher, + watcher.onDidCreate((uri) => this.scheduleRescan(uri)), + watcher.onDidChange((uri) => this.scheduleRescan(uri)), + watcher.onDidDelete((uri) => this.scheduleRescan(uri)), + vscode.workspace.onDidChangeTextDocument((e) => { + if (e.contentChanges.length > 0) this.scheduleRescan(e.document.uri); + }), + // A new model can declare, drop or re-target a test APK, or change what Elide lists for it. + workspace.onDidChange(() => void this.refresh()), + vscode.commands.registerCommand("elide.androidTest", (target: unknown) => this.runCommand(target)), + ]; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const controller of this.running) controller.abort(new Error("extension disposed")); + this.running.clear(); + for (const timer of this.rescans.values()) clearTimeout(timer); + this.rescans.clear(); + for (const subscription of this.subscriptions) subscription.dispose(); + this.controller.dispose(); + } + + /** `elide.androidTest`: run every instrumented test of the APK a row or lens names, or of the one picked. */ + private async runCommand(value: unknown): Promise { + await this.refresh(); + if (this.disposed) return; + const target = toAndroidTarget(value); + const suites = [...this.suites.values()].filter((suite) => !target || (suite.root === target.root && (!target.artifact || suite.artifact === target.artifact))); + if (suites.length === 0) { + void vscode.window.showWarningMessage(target?.artifact + ? `Elide: '${target.artifact}' has no instrumented tests to run: it declares no \`instrumentation\` and its manifest no \`\`, or Elide lists no ${androidTestTask(target.artifact)} target for it.` + : "Elide: no synced Android APK declares instrumentation (Pkl `instrumentation` or a manifest ``) with an `android-test-` target Elide lists."); + return; + } + const suite = suites.length === 1 ? suites[0] : (await vscode.window.showQuickPick( + suites.map((candidate) => ({ label: candidate.artifact, description: this.workspace.projectAt(candidate.root)?.model?.name ?? candidate.root, suite: candidate })), + { placeHolder: "Android test APK to run on the deployment target" }, + ))?.suite; + if (!suite) return; + const cancellation = new vscode.CancellationTokenSource(); + try { + await this.run(new vscode.TestRunRequest([suite.item], undefined, this.profile), cancellation.token); + } finally { + cancellation.dispose(); + } + } + + // --- discovery ------------------------------------------------------------------------------------------------- + + refresh(): Promise { + this.queue = this.queue.then(async () => { + try { + await this.refreshNow(); + } catch (e) { + this.ui.log(`instrumented test discovery failed: ${describe(e)}`); + } + }); + return this.queue; + } + + private async refreshNow(): Promise { + const live = new Set(); + for (const project of this.workspace.projects) { + for (const apk of androidApks(project)) { + if (!apk.device?.instrumented) continue; + const suite = this.suite(project, apk); + live.add(suite.item.id); + suite.item.children.replace([]); + suite.tests.clear(); + const files = new Set(); + for (const dir of suite.dirs) { + for (const uri of await vscode.workspace.findFiles(new vscode.RelativePattern(vscode.Uri.file(dir), SOURCE_GLOB))) files.add(uri.fsPath); + } + for (const file of [...files].sort()) await this.scan(suite, vscode.Uri.file(file)); + } + } + for (const id of [...this.suites.keys()]) { + if (live.has(id)) continue; + this.suites.delete(id); + this.controller.items.delete(id); + } + } + + private suite(project: ElideProject, apk: AndroidTargetModel): Suite { + const id = `a:${project.root}:${apk.name}`; + const sets = new Set(apk.sourceSets ?? []); + const dirs = (project.model?.modules ?? []) + .filter((module) => sets.has(module.sourceSet)) + .flatMap((module) => module.contentRoots.flatMap((content) => content.sourceRoots)) + .filter((root) => root.kind === "source" || root.kind === "test") + .map((root) => normalizePath(root.path)); + const existing = this.suites.get(id); + if (existing) { + existing.dirs = dirs; + return existing; + } + const item = this.controller.createTestItem(id, apk.name); + item.description = `${project.model?.name ?? path.basename(project.root)} · on device`; + this.controller.items.add(item); + const suite: Suite = { root: project.root, artifact: apk.name, item, dirs, tests: new Map() }; + this.suites.set(id, suite); + return suite; + } + + private async scan(suite: Suite, uri: vscode.Uri): Promise { + for (const [id, child] of suite.item.children) if (child.uri?.fsPath === uri.fsPath) suite.item.children.delete(id); + for (const [key, test] of suite.tests) if (test.uri?.fsPath === uri.fsPath) suite.tests.delete(key); + const text = await readSource(uri); + if (text === undefined) return; + const language = path.extname(uri.fsPath) === ".java" ? "java" : "kotlin"; + for (const cls of scanJvmTestSource(text, language).classes) { + if (cls.methods.length === 0) continue; + // Flat by class: the runner names a nested class by its binary name, which is also what `--tests` takes. + const item = this.controller.createTestItem(`${suite.item.id}/${cls.binaryName}`, cls.binaryName.slice(cls.binaryName.lastIndexOf(".") + 1), uri); + item.range = new vscode.Range(cls.line, 0, cls.line, 0); + suite.item.children.add(item); + for (const method of cls.methods) { + const name = `${cls.binaryName}#${method.name}`; + const child = this.controller.createTestItem(`${suite.item.id}/${name}`, method.name, uri); + child.range = new vscode.Range(method.line, 0, method.line, 0); + item.children.add(child); + suite.tests.set(name, child); + } + } + } + + private scheduleRescan(uri: vscode.Uri): void { + if (uri.scheme !== "file" || !JVM_SOURCE.test(uri.fsPath)) return; + const file = normalizePath(uri.fsPath); + const owners = [...this.suites.values()].filter((suite) => suite.dirs.some((dir) => isPathUnder(file, dir))); + if (owners.length === 0) return; + clearTimeout(this.rescans.get(uri.fsPath)); + this.rescans.set(uri.fsPath, setTimeout(() => { + this.rescans.delete(uri.fsPath); + for (const suite of owners) void this.scan(suite, uri); + }, RESCAN_DEBOUNCE_MS)); + } + + // --- running --------------------------------------------------------------------------------------------------- + + private async run(request: vscode.TestRunRequest, token: vscode.CancellationToken): Promise { + if (this.disposed) return; + const run = this.controller.createTestRun(request); + try { + const excluded = new Set((request.exclude ?? []).map((item) => item.id)); + const selected = request.include ?? [...this.suites.values()].map((suite) => suite.item); + const bySuite = new Map(); + for (const item of selected) { + let top = item; + while (top.parent) top = top.parent; + const suite = this.suites.get(top.id); + if (!suite || excluded.has(item.id)) continue; + bySuite.set(suite, [...(bySuite.get(suite) ?? []), item]); + } + for (const [suite, items] of bySuite) { + if (token.isCancellationRequested) break; + await this.runSuite(run, suite, items, excluded, token); + } + } finally { + run.end(); + } + } + + private async runSuite(run: vscode.TestRun, suite: Suite, items: readonly vscode.TestItem[], excluded: ReadonlySet, token: vscode.CancellationToken): Promise { + const leaves: vscode.TestItem[] = []; + for (const item of items) collectLeaves(item, excluded, leaves); + // No remaining methods means no device operation, never an unfiltered run of the entire APK. + if (!leaves.length || this.disposed || token.isCancellationRequested) return; + for (const leaf of leaves) run.enqueued(leaf); + const errored = (message: string) => { + for (const leaf of leaves) run.errored(leaf, new vscode.TestMessage(message)); + }; + + const project = this.workspace.projectAt(suite.root); + if (!project) return errored(`Project ${suite.root} is not synced; run 'Elide: Sync Project(s)'.`); + const saved = await vscode.workspace.saveAll(false); + if (this.disposed || token.isCancellationRequested) return; + if (!saved) return errored("Saving the open files failed, so the tests did not run."); + const deployment = await this.deployments.resolve({ root: suite.root }); + if (this.disposed || token.isCancellationRequested) return; + const serial = deployment ? await this.deployments.readyDevice(deployment) : undefined; + if (this.disposed || token.isCancellationRequested) return; + if (!serial) return errored("No Android deployment target device: select one with 'Elide: Select Android Deployment Target…'."); + + const settings = readConfig(project.folder); + let dist; + try { + dist = resolveElideDistribution({ explicitHome: settings.home }); + } catch (e) { + return errored(describe(e)); + } + // A selection that leaves nothing out runs the whole suite; anything narrower names exactly what is still in. + const whole = items.includes(suite.item) && ![...excluded].some((id) => id.startsWith(`${suite.item.id}/`)); + const filters = whole ? [] : excluded.size > 0 ? leaves.map((leaf) => leaf.id.slice(suite.item.id.length + 1)) : items.map((item) => item.id.slice(suite.item.id.length + 1)); + const task = androidTestTask(suite.artifact); + const invocation = mergeElideInvocationOptions(configuredInvocation(settings, "build"), { + args: [task], + options: { [ANDROID_DEVICE_OPTION]: serial, ...(filters.length > 0 ? { [ANDROID_TESTS_OPTION]: filters } : {}) }, + }); + const argv = elideInvocationArgs("build", invocation); + this.ui.log(`android test: ${dist.bin} ${argv.join(" ")} (cwd ${project.root})`); + for (const leaf of leaves) run.started(leaf); + + const reported = new Set(); + const problems: string[] = []; + const parser = new AndroidTestResultParser(task, (result) => { + reported.add(`${result.className}#${result.method}`); + this.settle(run, suite, result); + }, (text) => problems.push(text)); + const stderrTail: string[] = []; + const abort = new AbortController(); + this.running.add(abort); + const cancel = token.onCancellationRequested(() => abort.abort(new Error("test run cancelled"))); + let failure: string | undefined; + try { + await new ElideCli(dist, project.root).run(argv, { + signal: abort.signal, + ...(invocation.env ? { env: { ...invocation.env } } : {}), + onCommand: () => ({ record: (record) => parser.accept(record), end: () => undefined }), + onLine: (line, stderr) => { + run.appendOutput(`${line}\r\n`); + if (!stderr) return; + stderrTail.push(line); + if (stderrTail.length > STDERR_TAIL_LINES) stderrTail.shift(); + }, + }); + } catch (e) { + if (!abort.signal.aborted) { + failure = e instanceof ElideCommandFailedError ? `elide build ${task} exited with ${e.exitCode ?? "a signal"}` : describe(e); + } + } finally { + cancel.dispose(); + parser.end(); + this.running.delete(abort); + } + if (token.isCancellationRequested || failure === undefined) return; + // A failing test already fails the target; tests the run never reached (a crash, a lost device) get its reason. + const message = new vscode.TestMessage([...(problems.length > 0 ? problems : stderrTail), failure].join("\n")); + // Leaves are methods, or the suite itself when nothing was discovered: then any result counts. + for (const leaf of leaves) { + const name = leaf.id.slice(suite.item.id.length + 1); + if (name === "" ? reported.size === 0 : !reported.has(name)) run.errored(leaf, message); + } + } + + private settle(run: vscode.TestRun, suite: Suite, result: AndroidTestResult): void { + const name = `${result.className}#${result.method}`; + let item = suite.tests.get(name); + if (!item) { + // A test the static scan did not find — declared in a library, or generated — still gets its result. + item = this.controller.createTestItem(`${suite.item.id}/${name}`, name); + suite.item.children.add(item); + suite.tests.set(name, item); + } + if (result.outcome === "passed") run.passed(item); + else if (result.outcome === "skipped") run.skipped(item); + else run.failed(item, failureMessage(result.detail, item)); + } +} + +/** Runnable descendants of a selected item: its test methods, or the item itself when nothing was discovered in it. */ +function collectLeaves(item: vscode.TestItem, excluded: ReadonlySet, out: vscode.TestItem[]): void { + if (excluded.has(item.id)) return; + if (item.children.size === 0) { + out.push(item); + return; + } + for (const [, child] of item.children) collectLeaves(child, excluded, out); +} + +/** A failure with its stack trace, located at the first frame in the test's own file. */ +function failureMessage(detail: string | undefined, item: vscode.TestItem): vscode.TestMessage { + const message = new vscode.TestMessage(detail ?? "Test failed"); + const uri = item.uri; + if (!uri || !detail) return message; + const base = path.basename(uri.fsPath); + for (const line of detail.split("\n")) { + const frame = FRAME_LOCATION.exec(line); + if (frame?.[1] !== base) continue; + message.location = new vscode.Location(uri, new vscode.Position(Math.max(0, Number(frame[2]) - 1), 0)); + break; + } + return message; +} + +/** Text of a file, preferring the editor's buffer so unsaved declarations are discovered too. */ +async function readSource(uri: vscode.Uri): Promise { + const open = vscode.workspace.textDocuments.find((document) => document.uri.fsPath === uri.fsPath); + if (open) return open.getText(); + try { + return new TextDecoder().decode(await vscode.workspace.fs.readFile(uri)); + } catch { + return undefined; + } +} + +function describe(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} diff --git a/packages/vscode/src/androidWireless.ts b/packages/vscode/src/androidWireless.ts new file mode 100644 index 0000000..e7f2530 --- /dev/null +++ b/packages/vscode/src/androidWireless.ts @@ -0,0 +1,208 @@ +import { existsSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; +import { + adbExecutable, connectAndroidWireless, createAndroidWirelessQr, discoverAndroidWireless, + pairAndroidWireless, validateAndroidWirelessEndpoint, type AndroidWirelessService, +} from "@elide/ide-core"; +import QRCode from "qrcode"; +import * as vscode from "vscode"; +import { androidSdkFor, toAndroidTarget } from "./android.js"; +import { NO_ANDROID_PROJECT, preferredAndroidProject, type AndroidDeploymentTarget } from "./androidDeploymentState.js"; +import { wirelessHtml } from "./androidWirelessHtml.js"; +import type { ElideProject, ElideWorkspace } from "./projects.js"; + +/** One operation per panel; cancellation revokes every outstanding discovery and pairing result. */ +class WirelessPanel implements vscode.Disposable { + private readonly panel: vscode.WebviewPanel; + private readonly subscriptions: vscode.Disposable[] = []; + private operation: AbortController | undefined; + private disposed = false; + private services: AndroidWirelessService[] = []; + private status = "Enable Wireless debugging on an Android 11+ device on the same Wi-Fi network."; + private error = false; + private qr: string | undefined; + + constructor(readonly adb: string, private readonly valid: () => boolean, closed: () => void) { + this.panel = vscode.window.createWebviewPanel("elide.androidWireless", "Pair Android Device over Wi-Fi", vscode.ViewColumn.Active, + { enableScripts: true, localResourceRoots: [], retainContextWhenHidden: true }); + this.subscriptions.push( + this.panel.onDidDispose(() => { this.dispose(); closed(); }), + this.panel.webview.onDidReceiveMessage((message: unknown) => { void this.receive(message); }), + ); + this.panel.webview.html = wirelessHtml(); + } + + reveal(): void { this.panel.reveal(); } + + private publish(): void { + if (!this.disposed) void this.panel.webview.postMessage({ type: "state", busy: !!this.operation, + services: this.services, status: this.status, error: this.error, qr: this.qr }); + } + + private cancel(): void { + this.operation?.abort(); + this.operation = undefined; + this.qr = undefined; + } + + private async receive(value: unknown): Promise { + if (this.disposed || !this.valid() || !value || typeof value !== "object" || !("type" in value)) return; + const message = value as Record; + if (message.type === "cancel") { + this.cancel(); + this.status = "Cancelled. Pairing already accepted by the device is not undone; use Forget on the device to revoke it."; + this.error = false; + this.publish(); + return; + } + if (this.operation) { if (message.type === "ready") this.publish(); return; } + if (!["ready", "refresh", "qr", "pair", "connect"].includes(String(message.type))) return; + const controller = new AbortController(); + this.operation = controller; + const current = () => !this.disposed && this.valid() && this.operation === controller && !controller.signal.aborted; + const signal = controller.signal; + this.error = false; + this.status = message.type === "qr" ? "Preparing QR code…" : message.type === "pair" ? "Pairing…" + : message.type === "connect" ? "Connecting…" : "Discovering wireless devices…"; + this.publish(); + try { + if (message.type === "ready" || message.type === "refresh") { + const services = await discoverAndroidWireless(this.adb, signal); + if (!current()) return; + this.services = services; + this.status = services.length ? "Select a discovered device, or enter its address manually." + : "No wireless services found. Open ‘Pair device with pairing code’ on the phone, then refresh. Manual pairing also works when mDNS is blocked."; + } else if (message.type === "qr") { + const credentials = createAndroidWirelessQr(); + const image = await QRCode.toDataURL(credentials.payload, { width: 280, margin: 4, errorCorrectionLevel: "M" }); + if (!current()) return; + this.qr = image; + this.status = "On the device, open Wireless debugging → Pair device with QR code and scan. Waiting up to two minutes…"; + this.publish(); + const deadline = Date.now() + 120_000; + let endpoint: string | undefined; + while (current() && Date.now() < deadline) { + const services = await discoverAndroidWireless(this.adb, signal); + if (!current()) return; + this.services = services; + endpoint = services.find((service) => service.type === "pairing" && service.name === credentials.name)?.endpoint; + if (endpoint) break; + await delay(1_000, undefined, { signal }); + } + if (!current()) return; + if (!endpoint) throw new Error("QR code expired. Try again, or use a pairing code and manual address if your network blocks mDNS."); + this.qr = undefined; + this.status = "Device found. Pairing…"; + this.publish(); + await pairAndroidWireless(this.adb, endpoint, credentials.secret, signal); + if (!current()) return; + await this.paired(); + } else { + const endpoint = typeof message.endpoint === "string" ? message.endpoint.trim() : ""; + const invalid = validateAndroidWirelessEndpoint(endpoint); + if (invalid) throw new Error(invalid); + if (message.type === "pair") { + const code = typeof message.code === "string" ? message.code.trim() : ""; + if (!/^\d{6}$/.test(code)) throw new Error("Enter the six-digit code shown in ‘Pair device with pairing code’."); + await pairAndroidWireless(this.adb, endpoint, code, signal); + if (!current()) return; + await this.paired(); + } else { + await connectAndroidWireless(this.adb, endpoint, signal); + if (!current()) return; + this.status = `Connected to ${endpoint}. Use Select Android Deployment Target to choose it for Run and Debug.`; + await vscode.commands.executeCommand("elide.androidRefreshDevices"); + } + } + } catch (error) { + if (current()) { + this.error = true; + this.status = error instanceof Error ? error.message : "Wireless pairing failed."; + } + } finally { + if (this.operation === controller) { + this.operation = undefined; + this.qr = undefined; + this.publish(); + } + } + } + + private async paired(): Promise { + this.status = "Paired successfully. ADB normally connects automatically. Refresh Devices, then select your deployment target. If it is missing, connect below using the IP address and port on the main Wireless debugging screen (not the pairing port)."; + await vscode.commands.executeCommand("elide.androidRefreshDevices"); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.cancel(); + for (const subscription of this.subscriptions) subscription.dispose(); + this.panel.dispose(); + } +} + +export function registerAndroidWireless( + workspace: ElideWorkspace, + deploymentTarget: () => Readonly | undefined, +): vscode.Disposable { + const panels = new Map(); + let disposed = false; + let generation = 0; + const valid = (root: string) => !disposed && vscode.workspace.isTrusted && !!workspace.projectAt(root)?.model?.android; + const subscriptions = [ + vscode.commands.registerCommand("elide.androidPairWireless", async (value: unknown) => { + if (disposed || !vscode.workspace.isTrusted) return; + const revision = ++generation; + const target = toAndroidTarget(value); + const projects = workspace.projects.filter((project) => project.model?.android); + const editor = vscode.window.activeTextEditor; + const owner = editor?.document.uri.scheme === "file" ? workspace.projectFor(editor.document.uri.fsPath) : undefined; + let project: ElideProject | undefined = target ? workspace.projectAt(target.root) + : preferredAndroidProject(projects, owner, deploymentTarget()?.root) ?? (projects.length === 1 ? projects[0] : undefined); + if (!project && !target && projects.length) { + project = (await vscode.window.showQuickPick(projects.map((project) => ({ label: project.model?.name ?? project.root, description: project.root, project })), + { placeHolder: "Android project whose SDK will pair the device" }))?.project; + } + if (project) project = workspace.projectAt(project.root); + if (disposed || revision !== generation) return; + if (!project?.model?.android) { + if (target || !projects.length) void vscode.window.showWarningMessage(NO_ANDROID_PROJECT); + return; + } + const root = project.root; + if (!valid(root)) return; + const sdk = androidSdkFor(project); + if (!sdk || !existsSync(adbExecutable(sdk))) { + void vscode.window.showErrorMessage("Elide: Android platform-tools are missing. Install current SDK platform-tools or set elide.android.sdk."); + return; + } + const adb = adbExecutable(sdk); + const existing = panels.get(root); + if (existing?.adb === adb) { existing.reveal(); return; } + existing?.dispose(); + const panel = new WirelessPanel(adb, () => valid(root), () => { if (panels.get(root) === panel) panels.delete(root); }); + panels.set(root, panel); + }), + workspace.onDidChange(() => { + for (const [root, panel] of panels) { + const project = workspace.projectAt(root); + const sdk = project ? androidSdkFor(project) : undefined; + if (!valid(root) || !sdk || panel.adb !== adbExecutable(sdk)) { panel.dispose(); panels.delete(root); } + } + }), + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("elide.android.sdk")) { + for (const panel of panels.values()) panel.dispose(); + panels.clear(); + } + }), + ]; + return new vscode.Disposable(() => { + disposed = true; + generation++; + for (const panel of panels.values()) panel.dispose(); + panels.clear(); + for (const subscription of subscriptions) subscription.dispose(); + }); +} diff --git a/packages/vscode/src/androidWirelessHtml.ts b/packages/vscode/src/androidWirelessHtml.ts new file mode 100644 index 0000000..63a2ad7 --- /dev/null +++ b/packages/vscode/src/androidWirelessHtml.ts @@ -0,0 +1,188 @@ +import { randomBytes } from "node:crypto"; + +/** Device-controlled content is assigned with textContent, never interpolated into HTML. */ +export function wirelessHtml(): string { + const nonce = randomBytes(18).toString("base64"); + return String.raw` + + +Pair Android over Wi-Fi + +

Pair Android over Wi-Fi

Android 11 or newer · Connect your device and computer to the same Wi-Fi network.

+
+
+

1. Pair your device

+

On your device, open Developer options → Wireless debugging and turn it on.

+
Pairing method
+ + +
+
+

Choose Pair device with QR code on your device, then scan the code generated here. Keep this panel open while pairing.

+ + +
+ +
+
+

2. Connect an already-paired device

+

If your device is paired but not connected, use IP address & port on the main Wireless debugging screen. The connection port is different from the pairing port.

+
+
+
+
Ready to pair.
+
Troubleshooting
    +
  • No devices found? Keep the device unlocked with its pairing dialog open, then refresh. Discovery uses mDNS; if your network blocks it, enter the address and port manually.
  • +
  • Pairing and connection use different ports. Copy the current port from the appropriate screen; ports can change when Wireless debugging restarts.
  • +
  • Check that both devices are on the same network. Guest Wi-Fi isolation, VPNs, and firewalls can block discovery or connections.
  • +
  • If the code expires, reopen the pairing-code dialog. For QR pairing, cancel and generate a new QR code.
  • +
  • Wireless debugging requires Android 11 or newer and Android SDK Platform-Tools with wireless pairing support.
  • +
  • In Remote SSH or containers, ADB runs on the extension host. That host needs SDK Platform-Tools and network access to the device.
  • +
+
+`; +} diff --git a/packages/vscode/src/codelens.ts b/packages/vscode/src/codelens.ts index ba95178..190d174 100644 --- a/packages/vscode/src/codelens.ts +++ b/packages/vscode/src/codelens.ts @@ -8,11 +8,15 @@ import { isPathUnder, normalizePath, parseManifestArtifacts, + type AndroidDeviceTargets, type ManifestArtifact, } from "@elide/ide-core"; import * as vscode from "vscode"; +import { androidApks } from "./androidDeploymentState.js"; import { readConfig } from "./config.js"; +import { onDidEndDebugRun } from "./debug.js"; import type { ElideProject, ElideWorkspace } from "./projects.js"; +import { ELIDE_TASK_TYPE } from "./tasks.js"; /** Kotlin `fun main(`, including the `@JvmStatic fun main(` form used inside an `object`. */ const KOTLIN_MAIN = /^\s*(?:@JvmStatic\s+)?(?:public\s+)?fun\s+main\s*\(/; @@ -48,6 +52,11 @@ export class ElideCodeLensProvider implements vscode.CodeLensProvider, vscode.Di // A new model can add source roots or change `jvm.main`, both of which decide the lenses below. workspace.onDidChange(() => this.changed.fire()), workspace.onDidChangeKomodoAvailability(() => this.changed.fire()), + // A build can turn out an APK that is not debuggable, which withdraws its Debug lens; a debug run builds too. + vscode.tasks.onDidEndTask((event) => { + if (event.execution.task.source === ELIDE_TASK_TYPE) this.changed.fire(); + }), + onDidEndDebugRun(() => this.changed.fire()), vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("elide.codeLens") || e.affectsConfiguration("elide.debug.adapter")) this.changed.fire(); }), @@ -65,8 +74,9 @@ export class ElideCodeLensProvider implements vscode.CodeLensProvider, vscode.Di const manifest = path.basename(fsPath) === MANIFEST_NAME; const project = manifest ? this.workspace.projectAt(path.dirname(fsPath)) : this.workspace.projectFor(fsPath); if (!project) return []; - const lenses = manifest ? manifestLenses(document, project.root) : sourceLenses(document, project); - return this.workspace.komodoDebugAvailable(project.folder) ? lenses : lenses.filter((lens) => lens.command?.command !== "elide.debug"); + const lenses = manifest ? manifestLenses(document, project) : sourceLenses(document, project); + return this.workspace.komodoDebugAvailable(project.folder) ? lenses + : lenses.filter((lens) => lens.command?.command !== "elide.debug" && lens.command?.command !== "elide.androidDebug"); } } @@ -84,10 +94,11 @@ function runLenses(line: number, length: number, root: string, args: string[], q /** * Lenses of one `artifacts` entry: a Native Image binary is also runnable and debuggable, an Android application - * package is runnable on a device, and the rest only build. Run and Debug are named as they are on an entrypoint — - * the build they need first is part of doing either, not a separate decision. + * package offers what Elide lists a device target for — Run, Debug, its instrumented tests — and the rest only + * build. Run and Debug are named as they are on an entrypoint — the build they need first is part of doing either, + * not a separate decision. */ -function artifactLenses(line: number, length: number, root: string, artifact: ManifestArtifact): vscode.CodeLens[] { +function artifactLenses(line: number, length: number, root: string, artifact: ManifestArtifact, device: AndroidDeviceTargets | undefined): vscode.CodeLens[] { const range = new vscode.Range(line, 0, line, length); const build = new vscode.CodeLens(range, { title: "$(package) Build", @@ -96,17 +107,13 @@ function artifactLenses(line: number, length: number, root: string, artifact: Ma }); if (isAndroidApk(artifact)) { // An APK is installed and launched by a build target of its own, and debugged on the device it was installed on. - const run = new vscode.CodeLens(range, { - title: "$(play) Run", - command: "elide.androidRun", - arguments: [{ root, artifact: artifact.name }], - }); - const debug = new vscode.CodeLens(range, { - title: "$(debug-alt) Debug", - command: "elide.androidDebug", - arguments: [{ root, artifact: artifact.name }], - }); - return [run, debug, build]; + const target = { root, artifact: artifact.name }; + return [ + ...(device?.runnable ? [new vscode.CodeLens(range, { title: "$(play) Run", command: "elide.androidRun", arguments: [target] })] : []), + ...(device?.debuggable ? [new vscode.CodeLens(range, { title: "$(debug-alt) Debug", command: "elide.androidDebug", arguments: [target] })] : []), + ...(device?.instrumented ? [new vscode.CodeLens(range, { title: "$(beaker) Run Instrumented Tests", command: "elide.androidTest", arguments: [target] })] : []), + build, + ]; } if (!isNativeImageBinary(artifact)) return [build]; const run = new vscode.CodeLens(range, { @@ -161,8 +168,12 @@ function entryArgs(document: vscode.TextDocument, project: ElideProject, java: b } /** Entrypoints, scripts and artifacts of `elide.pkl`, found by tracking which block each line sits in. */ -function manifestLenses(document: vscode.TextDocument, root: string): vscode.CodeLens[] { +function manifestLenses(document: vscode.TextDocument, project: ElideProject): vscode.CodeLens[] { + const root = project.root; const lenses: vscode.CodeLens[] = []; + // What Elide lists for each APK comes from the synced model, an APK added since offers only Build until a sync; its + // latest build can withdraw Debug. + const devices = new Map(androidApks(project).map((target) => [target.name, target.device] as const)); const stack: { name: string; depth: number }[] = []; // Artifacts carry settings of their own (the image name, the image type), which the core parser reads. const artifacts = new Map(parseManifestArtifacts(document.getText()).map((a) => [a.line, a] as const)); @@ -172,7 +183,7 @@ function manifestLenses(document: vscode.TextDocument, root: string): vscode.Cod const block = stack[stack.length - 1]?.name; const artifact = artifacts.get(i); if (artifact) { - lenses.push(...artifactLenses(i, text.length, root, artifact)); + lenses.push(...artifactLenses(i, text.length, root, artifact, devices.get(artifact.name))); } else if (block === "jvm" && MANIFEST_MAIN.test(text)) { lenses.push(...runLenses(i, text.length, root, [])); } else if (block === "entrypoint") { diff --git a/packages/vscode/src/composeAgent.ts b/packages/vscode/src/composeAgent.ts new file mode 100644 index 0000000..944a765 --- /dev/null +++ b/packages/vscode/src/composeAgent.ts @@ -0,0 +1,1012 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { composePreviewBuildTargets, observeCommands, type AndroidTargetModel, type ProjectModel, type TimelineNode } from "@elide/ide-core"; +import * as vscode from "vscode"; +import { + composableDeclarations, jvmFacade, parseReference, previewWrapper, + type ComposableDeclaration, type PreviewConfiguration, type PreviewWrapper, +} from "./composeAgentSource.js"; +import type { CompileMessage, ComposeBackend, ComposeBackends, PreviewGallery, PreviewKeyEvent, PreviewRequest } from "./composeBackend.js"; +import { KomodoBackend, type KomodoSessionArbiter } from "./composeKomodo.js"; +import { backendKey, buildErrors, javaFor, ownedBy, previewApp, previewBuild } from "./composePreview.js"; +import { StaleBuildRecordError } from "./composeProcessing.js"; +import type { CompiledPreview, PreviewImage, PreviewLayoutNode } from "./composeRender.js"; +import { ensureComposeRuntime } from "./composeRuntime.js"; +import { inspectableNodes, layoutTree, type PreviewSourceTarget } from "./composeSource.js"; +import { readConfig } from "./config.js"; +import type { McpContent } from "./mcpServer.js"; +import type { ElideUi } from "./output.js"; +import type { ElideProject, ElideWorkspace } from "./projects.js"; +import { runObservedElide } from "./tasks.js"; + +/** What the Compose preview shares with the agent service: its backend pool and the window's Komodo session arbiter. */ +export interface AgentPreviewHost { + backends: ComposeBackends; + arbiter: KomodoSessionArbiter; + cacheRoot: string; +} + +export interface AgentPreviewItem { + reference: string; + /** Kotlin expressions by parameter name, compiled in the composable's file. */ + arguments?: Readonly>; + preview?: PreviewConfiguration; +} + +export interface AgentRenderOptions { + composables: readonly AgentPreviewItem[]; + /** Defaults for every item's configuration. */ + preview?: PreviewConfiguration; + /** A theme composable every generated wrapper calls the composable in. */ + theme?: string; + live?: boolean; + rebuild?: boolean; + /** Report each card's layout tree (default true). */ + layout?: boolean; +} + +export type AgentActionType = "tap" | "longPress" | "drag" | "scroll" | "type" | "key" | "wait"; + +export interface AgentAction { + type: AgentActionType; + x?: number; + y?: number; + /** A layout node id of the card's last report (`n3`): its center. */ + node?: string; + toX?: number; + toY?: number; + toNode?: string; + dx?: number; + dy?: number; + text?: string; + key?: string; + shift?: boolean; + ctrl?: boolean; + alt?: boolean; + meta?: boolean; + durationMs?: number; +} + +/** A report line for a node of a card's layout tree. */ +interface AgentNode { + id: string; + node: PreviewLayoutNode; + depth: number; + location?: string; +} + +/** One composable placed in a group: the previews (methods) rendering it, and its generated wrapper if any. */ +interface Placement { + reference: string; + /** Its position in the agent's request: cards are reported in that order. */ + order: number; + declaration: ComposableDeclaration; + methods: Set; + wrapper?: PreviewWrapper; +} + +/** One backend's share of a session: the previews it renders over one application's resources. */ +interface Group { + key: string; + app: { project: ElideProject; target: AndroidTargetModel }; + backend: ComposeBackend; + /** The agent's Komodo backend (live cards); undefined for layoutlib snapshots. */ + komodo: KomodoBackend | undefined; + sourceFile: string; + files: string[]; + appendices: Map; + previews: CompiledPreview[]; + placements: Placement[]; + /** The compiler inputs of the last render, for layout source locations. */ + units: readonly ReadonlyMap[]; + /** Original text length per appended file: a location past it is in the generated wrapper. */ + originals: Map; + /** Program texts the backend was last given (live engines push what changed). */ + texts: Map; + latest: Map; + frameAt: Map; + compileFailure?: CompileMessage[]; + lost: boolean; + subscriptions: (() => void)[]; + released: boolean; +} + +interface AgentCard { + id: string; + group: Group; + imageId: string; + placement: Placement; + nodes: AgentNode[]; +} + +interface AgentSession { + id: string; + live: boolean; + groups: Group[]; + cards: Map; + idle?: NodeJS.Timeout; + closed: boolean; + queue: Promise; + /** Aborted by the close: stops the session's queued and running calls. */ + closing: AbortController; +} + +/** Live sessions an agent leaves unused this long are closed: the Komodo session is the window's one. */ +const SESSION_IDLE_MS = 10 * 60_000; +/** Pushed frames count as settled after this long without another. */ +const SETTLE_QUIET_MS = 300; +/** How long input waits for the frame it causes. */ +const INPUT_FRAME_MS = 2_000; +const DEFAULT_SETTLE_MS = 3_000; +/** An edit pushed to a live session compiles within this long (Komodo: 0.3-4.4 s measured). */ +const COMPILE_WAIT_MS = 45_000; +const LAYOUT_NODES = 400; +const SOURCE_EXCLUDES = "{**/.dev/**,**/build/**,**/node_modules/**,**/.git/**,**/bazel-*/**}"; +const PAUSED_PANELS = "An agent is using the Komodo preview session"; + +/** Named keys an agent can press, in the browser `KeyboardEvent` vocabulary the live engine takes. */ +const NAMED_KEYS = new Set(["Backspace", "Delete", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Enter", "Tab", "Escape", + "Home", "End", "PageUp", "PageDown", "Insert", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]); + +function sleep(ms: number, signal?: AbortSignal): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + if (signal?.aborted) { + reject(signal.reason); + return promise; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); + return promise; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** 1-based line and column of `offset` in `text`. */ +function position(text: string, offset: number): { line: number; column: number } { + let line = 1; + let start = 0; + for (let index = text.indexOf("\n"); index >= 0 && index < offset; index = text.indexOf("\n", index + 1)) { + line++; + start = index + 1; + } + return { line, column: offset - start + 1 }; +} + +/** `text` with 1-based line numbers from `first`. */ +function numbered(text: string, first: number): string { + return text.split("\n").map((line, index) => `${String(first + index).padStart(5)} ${line}`).join("\n"); +} + +/** Browser `code` of a printable character on a US layout, as the live engine maps physical keys; empty when it has none. */ +function keyCode(character: string): string { + if (/^[a-z]$/i.test(character)) return `Key${character.toUpperCase()}`; + if (/^[0-9]$/.test(character)) return `Digit${character}`; + if (character === " ") return "Space"; + return ""; +} + +/** + * Renders composables for AI agents (the Compose preview MCP tools): a composable is named by code reference; one without + * `@Preview`, or one given arguments or a configuration, is called from a generated wrapper appended to its own file for the + * preview compile only. Snapshot cards come from the shared layoutlib backend; live cards from an agent-owned Komodo backend, + * kept warm between calls and holding the window's one Komodo session while it renders or a live session is open. + */ +export class ComposeAgentPreviews implements vscode.Disposable { + private readonly sessions = new Map(); + private readonly rendering = new Map(); + private readonly groups = new Set(); + private readonly availability: vscode.Disposable; + private readonly idleKomodo = new Map(); + private readonly queues = new Map>(); + private readonly diagnostics = vscode.languages.createDiagnosticCollection("elide-compose-agent"); + private nextSession = 1; + private nextCard = 1; + private files: { at: number; files: Promise } | undefined; + private disposed = false; + + constructor(private readonly workspace: ElideWorkspace, private readonly ui: ElideUi, private readonly host: AgentPreviewHost) { + this.availability = workspace.onDidChangeKomodoAvailability(() => { + if (workspace.komodoPreviewAvailable) return; + for (const [controller, native] of this.rendering) { + if (native) controller.abort(new Error("Komodo previews became unavailable; sync or install a compatible Komodo before rendering again.")); + } + for (const session of this.sessions.values()) if (session.groups.some((group) => group.komodo)) this.close(session); + for (const group of this.groups) if (group.komodo) { + group.backend.dispose(); + this.release(group); + } + for (const { backend, timer } of this.idleKomodo.values()) { + clearTimeout(timer); + backend.dispose(); + } + this.idleKomodo.clear(); + }); + } + + /** Live sessions, for the close command. */ + get liveSessions(): number { + return [...this.sessions.values()].filter((session) => !session.closed).length; + } + + async render(options: AgentRenderOptions, signal: AbortSignal, progress: (text: string) => void): Promise { + this.ready(); + const controller = new AbortController(); + const cancel = AbortSignal.any([signal, controller.signal]); + const engine = readConfig().composePreviewEngine; + this.rendering.set(controller, engine === "komodo"); + try { + cancel.throwIfAborted(); + return await this.renderSession(options, engine, controller, cancel, progress); + } finally { + this.rendering.delete(controller); + } + } + + private async renderSession(options: AgentRenderOptions, engine: "komodo" | "layoutlib", controller: AbortController, cancel: AbortSignal, + progress: (text: string) => void): Promise { + if (!options.composables.length) throw new Error("Name at least one composable."); + const theme = options.theme ? await this.theme(options.theme) : undefined; + const resolved = await Promise.all(options.composables.map(async (item, order) => ({ item, order, ...await this.declaration(item.reference) }))); + const byGroup = new Map(); + for (const entry of resolved) { + if (engine === "komodo") await this.workspace.requireKomodoPreview(entry.declaration.file); + cancel.throwIfAborted(); + const app = await this.application(entry.declaration.file, cancel); + const key = backendKey(app.project, app.target); + const groupKey = engine === "komodo" ? key : `${key}\0${entry.declaration.file}`; + const group = byGroup.get(groupKey) ?? { app, items: [] }; + group.items.push(entry); + byGroup.set(groupKey, group); + } + if (engine === "komodo" && byGroup.size > 1) { + throw new Error("The komodo engine hosts one application's composables per session; these need several. Render each application's composables in its own call."); + } + const session: AgentSession = { id: `s${this.nextSession++}`, live: options.live === true, groups: [], cards: new Map(), closed: false, + queue: Promise.resolve(), closing: new AbortController() }; + const names = resolved.map((entry) => entry.declaration.name).join(", "); + const trace = this.ui.activity.start(`Agent Compose preview: ${names}`, { detail: engine, cancel: () => controller.abort() }); + try { + for (const [, { app, items }] of byGroup) { + cancel.throwIfAborted(); + session.groups.push(await this.group(app, engine === "komodo", items.map((entry) => ({ + reference: entry.item.reference, order: entry.order, declaration: entry.declaration, text: entry.text, + arguments: entry.item.arguments ?? {}, configuration: { ...options.preview, ...entry.item.preview }, + })), theme, cancel)); + } + for (const group of session.groups) await this.renderGroup(group, options.rebuild === true, cancel, trace, progress); + const content = await this.report(session, this.allCards(session), options.layout !== false); + cancel.throwIfAborted(); + trace.end("succeeded"); + if (session.live) { + this.sessions.set(session.id, session); + this.touch(session); + } else this.close(session); + return content; + } catch (error) { + for (const group of session.groups) if (group.komodo && !group.backend.disposed) group.komodo.preempt("The agent preview was cancelled or failed."); + trace.end(cancel.aborted ? "cancelled" : "failed", message(error).split("\n")[0]); + this.close(session); + throw error; + } + } + + /** Pointer and keyboard input to a live card, then its settled frame. */ + async interact(sessionId: string, cardId: string, actions: readonly AgentAction[], settleMs = DEFAULT_SETTLE_MS, signal: AbortSignal): Promise { + const session = this.session(sessionId); + return this.serialized(session, signal, async (signal) => { + const card = session.cards.get(cardId); + if (!card) throw new Error(`Session ${sessionId} has no card ${cardId}; its cards: ${[...session.cards.keys()].join(", ")}.`); + const group = card.group; + if (!group.komodo) { + throw new Error("This card is a snapshot: the layoutlib engine renders no live cards. Set elide.android.composePreview.engine to komodo for interactive previews."); + } + let image = group.latest.get(card.imageId); + if (group.lost || !image?.live || image.error) { + // Taken over by a preview panel, or its session closed: render it again first. + await this.renderGroup(group, false, signal, undefined, () => {}); + image = group.latest.get(card.imageId); + if (!image?.live || image.error) throw new Error(`Card ${cardId} is not live: ${image?.error ?? "it has no frame"}`); + } + // A close during that render released the group: it must not hold the arbiter again. + signal.throwIfAborted(); + this.host.arbiter.setActive(group.komodo, true); + const backend = group.backend; + const point = (x: number | undefined, y: number | undefined, node: string | undefined, what: string) => { + if (node !== undefined) { + const found = card.nodes.find((candidate) => candidate.id === node); + if (!found) throw new Error(`Card ${cardId} has no layout node ${node} in its last report.`); + return { x: Math.round(found.node.x + found.node.width / 2), y: Math.round(found.node.y + found.node.height / 2) }; + } + if (typeof x !== "number" || typeof y !== "number") throw new Error(`${what} needs x and y (image pixels) or a node.`); + return { x, y }; + }; + const pointer = (action: "down" | "up" | "move" | "scroll", x: number, y: number, dx?: number, dy?: number) => { + if (!backend.pointer(card.imageId, action, x, y, dx, dy)) throw new Error(`Card ${cardId} no longer takes input (its session was replaced); call compose_preview_refresh.`); + }; + const key = (name: string, modifiers: AgentAction) => { + for (const action of ["down", "up"] as const) { + const event: PreviewKeyEvent = { action, key: name, code: NAMED_KEYS.has(name) ? name : keyCode(name), + shiftKey: modifiers.shift === true || /^[A-Z]$/.test(name), ctrlKey: modifiers.ctrl === true, altKey: modifiers.alt === true, + metaKey: modifiers.meta === true, repeat: false }; + if (!backend.keyboard(card.imageId, event)) throw new Error(`Card ${cardId} takes no keyboard input (an older Komodo, or it lost focus); tap a text field first.`); + } + }; + const inputAt = performance.now(); + for (const action of actions) { + signal.throwIfAborted(); + switch (action.type) { + case "tap": { + const at = point(action.x, action.y, action.node, "tap"); + pointer("down", at.x, at.y); + await sleep(40, signal); + pointer("up", at.x, at.y); + break; + } + case "longPress": { + const at = point(action.x, action.y, action.node, "longPress"); + pointer("down", at.x, at.y); + await sleep(action.durationMs ?? 700, signal); + pointer("up", at.x, at.y); + break; + } + case "drag": { + const from = point(action.x, action.y, action.node, "drag"); + const to = point(action.toX, action.toY, action.toNode, "drag's end (toX/toY or toNode)"); + const duration = action.durationMs ?? 300; + const steps = Math.max(2, Math.round(duration / 16)); + pointer("down", from.x, from.y); + for (let step = 1; step <= steps; step++) { + await sleep(duration / steps, signal); + pointer("move", Math.round(from.x + (to.x - from.x) * step / steps), Math.round(from.y + (to.y - from.y) * step / steps)); + } + pointer("up", to.x, to.y); + break; + } + case "scroll": { + const at = point(action.x, action.y, action.node, "scroll"); + pointer("scroll", at.x, at.y, action.dx ?? 0, action.dy ?? 0); + break; + } + case "type": + if (!action.text) throw new Error("type needs text."); + for (const character of action.text) key(character === "\n" ? "Enter" : character, action); + break; + case "key": + if (!action.key || !(NAMED_KEYS.has(action.key) || [...action.key].length === 1)) { + throw new Error(`key needs a single character or one of ${[...NAMED_KEYS].join(", ")}.`); + } + key(action.key, action); + break; + case "wait": + await sleep(action.durationMs ?? 500, signal); + break; + default: + throw new Error(`Unknown action ${String(action.type)}.`); + } + } + await this.settle(group, [card.imageId], settleMs, signal, inputAt); + return this.report(session, [card], true); + }); + } + + /** Renders the session's composables again from the current source text (an agent's edits on disk or in editors). */ + async refresh(sessionId: string, signal: AbortSignal, progress: (text: string) => void): Promise { + const session = this.session(sessionId); + return this.serialized(session, signal, async (signal) => { + for (const group of session.groups) { + group.placements.forEach((placement) => this.revalidate(placement)); + await this.renderGroup(group, false, signal, undefined, progress); + } + return this.report(session, this.allCards(session), true); + }); + } + + closeSession(sessionId: string): string { + const session = this.session(sessionId); + this.close(session); + return `Closed session ${sessionId}.`; + } + + /** Live sessions and renders still opening one: the close-sessions command, or a window going away. */ + closeAll(): number { + const pending = this.rendering.size; + for (const controller of this.rendering.keys()) controller.abort(new Error("The agent preview render was closed.")); + const sessions = [...this.sessions.values()]; + for (const session of sessions) this.close(session); + return sessions.length + pending; + } + + /** Top-level composables of a file or the workspace, with the reference that names each. */ + async list(file: string | undefined, query: string | undefined): Promise { + const files = file ? [await this.file(file)] : await this.kotlinFiles(); + const needle = query?.toLowerCase(); + const lines: string[] = []; + let total = 0; + for (const candidate of files) { + const text = await this.text(candidate); + if (!text.includes("Composable")) continue; + for (const declaration of composableDeclarations(text, candidate)) { + if (needle && !declaration.name.toLowerCase().includes(needle) && !declaration.packageName.toLowerCase().includes(needle)) continue; + total++; + if (lines.length >= 200) continue; + const parameters = declaration.parameters.map((parameter) => `${parameter.vararg ? "vararg " : ""}${parameter.name}: ${parameter.type}${parameter.defaultValue !== undefined ? " = …" : ""}`); + const reference = declaration.packageName ? `${declaration.packageName}.${declaration.name}` : declaration.name; + lines.push(`- ${reference} — ${vscode.workspace.asRelativePath(candidate, false)}:${declaration.line}${declaration.visibility === "public" ? "" : ` (${declaration.visibility})`}` + + `${declaration.previews ? " @Preview" : ""}\n ${declaration.receiver ? `${declaration.receiver}.` : ""}${declaration.name}(${parameters.join(", ")})`); + } + } + if (!total) return `No top-level @Composable functions${needle ? ` matching "${query}"` : ""} in ${file ?? "the workspace"}.`; + return `${total} composable${total === 1 ? "" : "s"}${total > lines.length ? ` (first ${lines.length} shown; narrow with query or file)` : ""}:\n${lines.join("\n")}`; + } + + dispose(): void { + this.disposed = true; + this.availability.dispose(); + this.closeAll(); + for (const { backend, timer } of this.idleKomodo.values()) { + clearTimeout(timer); + backend.dispose(); + } + this.idleKomodo.clear(); + this.diagnostics.dispose(); + } + + private ready(): void { + if (this.disposed) throw new Error("The Elide extension is shutting down."); + if (!vscode.workspace.isTrusted) throw new Error("Trust this workspace before rendering Compose previews: they run the project's code."); + } + + private session(id: string): AgentSession { + const session = this.sessions.get(id); + if (!session || session.closed) throw new Error(`No live preview session ${id}; render with live: true to open one.`); + return session; + } + + /** Runs a call's `work` after the session's earlier ones, with `signal` also aborted by the close; the session idles from its end. */ + private serialized(session: AgentSession, signal: AbortSignal, work: (signal: AbortSignal) => Promise): Promise { + const run = session.queue.then(async () => { + if (session.closed) throw new Error(`No live preview session ${session.id}; it was closed.`); + clearTimeout(session.idle); + try { + const cancel = AbortSignal.any([signal, session.closing.signal]); + for (const group of session.groups) if (group.komodo) await this.workspace.requireKomodoPreview(group.sourceFile); + cancel.throwIfAborted(); + const result = await work(cancel); + cancel.throwIfAborted(); + return result; + } finally { + if (!session.closed) this.touch(session); + } + }); + session.queue = run.catch(() => {}); + return run; + } + + private touch(session: AgentSession): void { + clearTimeout(session.idle); + session.idle = setTimeout(() => this.close(session), SESSION_IDLE_MS); + session.idle.unref(); + } + + private close(session: AgentSession): void { + if (session.closed) return; + session.closed = true; + session.closing.abort(new Error(`Session ${session.id} was closed.`)); + clearTimeout(session.idle); + this.sessions.delete(session.id); + for (const group of session.groups) this.release(group); + } + + /** A composable's file, its text and declaration, from a reference such as `pkg.Name`, `Name` or `src/…/File.kt#Name`. */ + private async declaration(reference: string): Promise<{ declaration: ComposableDeclaration; text: string }> { + const parsed = parseReference(reference); + const files = parsed.file ? [await this.file(parsed.file)] : await this.kotlinFiles(); + const found: { declaration: ComposableDeclaration; text: string }[] = []; + for (const file of files) { + const text = await this.text(file); + if (!text.includes(parsed.name)) continue; + for (const declaration of composableDeclarations(text, file)) { + if ((parsed.name && declaration.name !== parsed.name) || (parsed.packageName !== undefined && declaration.packageName !== parsed.packageName) + || (parsed.line !== undefined && declaration.line !== parsed.line)) continue; + found.push({ declaration, text }); + } + } + if (!found.length) { + throw new Error(`No top-level @Composable function ${reference} in ${parsed.file ?? "this workspace's Kotlin sources"}. ` + + "Name it as package.Function, Function, or path/to/File.kt#Function (compose_preview_list shows the composables)."); + } + if (found.length > 1) { + throw new Error(`${reference} names ${found.length} composables; name one:\n${found.map(({ declaration }) => + `- ${vscode.workspace.asRelativePath(declaration.file, false)}#${declaration.name}:${declaration.line}`).join("\n")}`); + } + return found[0]!; + } + + /** + * The theme to wrap composables in: a workspace composable, or (not in the workspace's sources, such as a library's + * `androidx.compose.material3.MaterialTheme`) called by its qualified name with the content as its trailing lambda. + */ + private async theme(reference: string): Promise { + try { + return (await this.declaration(reference)).declaration; + } catch (error) { + const parsed = parseReference(reference); + if (parsed.file || !parsed.packageName) throw error; + return { name: parsed.name, packageName: parsed.packageName, file: "", offset: 0, line: 0, visibility: "public", typeParameters: [], + parameters: [{ name: "content", type: "@Composable () -> Unit", vararg: false, annotations: [] }], previews: false, annotations: ["Composable"] }; + } + } + + /** Absolute path of a file named relative to a workspace folder (or absolute). */ + private async file(name: string): Promise { + const candidates = path.isAbsolute(name) ? [name] : (vscode.workspace.workspaceFolders ?? []).map((folder) => path.join(folder.uri.fsPath, name)); + for (const candidate of candidates) { + if (await stat(candidate).then((stats) => stats.isFile(), () => false)) return path.normalize(candidate); + } + throw new Error(`${name} is not a file in this workspace.`); + } + + private async kotlinFiles(): Promise { + let listed = this.files; + if (!listed || performance.now() - listed.at > 10_000) { + const files = Promise.resolve(vscode.workspace.findFiles("**/*.kt", SOURCE_EXCLUDES)).then((uris) => uris.map((uri) => uri.fsPath) + .filter((file) => this.workspace.projectFor(file) !== undefined).sort()); + files.catch(() => {}); + listed = this.files = { at: performance.now(), files }; + } + return listed.files; + } + + /** Editor text of a file, else its saved text. */ + private async text(file: string): Promise { + return vscode.workspace.textDocuments.find((document) => document.uri.scheme === "file" && document.uri.fsPath === file)?.getText() + ?? await readFile(file, "utf8"); + } + + /** The application previewing `file`; a folder's first sync still running is waited for. */ + private async application(file: string, signal: AbortSignal): Promise<{ project: ElideProject; target: AndroidTargetModel }> { + signal.throwIfAborted(); + let project = this.workspace.projectFor(file); + if (project && !project.model && this.workspace.isSyncing(project.folder)) { + const folder = project.folder; + const { promise, resolve } = Promise.withResolvers(); + const subscription = this.workspace.onDidChange(() => setTimeout(() => { + if (!this.workspace.isSyncing(folder)) resolve(); + }, 0)); + signal.addEventListener("abort", () => resolve(), { once: true }); + await promise; + subscription.dispose(); + signal.throwIfAborted(); + project = this.workspace.projectFor(file); + } + if (!project) throw new Error(`${vscode.workspace.asRelativePath(file, false)} belongs to no Elide project of this window.`); + if (!project.model) throw new Error(`Sync ${path.basename(project.root)} first: it has no project model.`); + const app = previewApp(this.workspace, project, file); + if (!app) throw new Error(`No Android application in this workspace folder packages ${path.basename(file)}. Declare an AndroidApk that includes its source set or depends on its library, then sync.`); + return app; + } + + /** A backend's share of a session: the generated wrappers and the previews selected for its composables. */ + private async group(app: { project: ElideProject; target: AndroidTargetModel }, komodo: boolean, + items: { reference: string; order: number; declaration: ComposableDeclaration; text: string; arguments: Readonly>; configuration: PreviewConfiguration }[], + theme: ComposableDeclaration | undefined, signal: AbortSignal): Promise { + const key = backendKey(app.project, app.target); + const placements: Placement[] = []; + const previews: CompiledPreview[] = []; + const appendices = new Map(); + const originals = new Map(); + const seen = new Set(); + for (const item of items) { + const { declaration } = item; + const facade = jvmFacade(item.text, declaration.file); + const configured = Object.values(item.configuration).some((value) => value !== undefined); + const placement: Placement = { reference: item.reference, order: item.order, declaration, methods: new Set() }; + placements.push(placement); + if (declaration.previews && !Object.keys(item.arguments).length && !configured && !theme) { + // Its own @Preview configurations, as discovery reports them. + const method = `${facade}.${declaration.name}`; + const discovered = komodo + ? (await this.workspace.komodoPreviews(declaration.file)).previews.filter((preview) => preview.methodFQN === method) + : [{ methodFQN: method, previewId: method, name: declaration.name, previewParams: {}, methodParams: [] }]; + if (!discovered.length) throw new Error(`Komodo reports no @Preview for ${method}; the file may not compile.`); + placement.methods.add(method); + for (const preview of discovered) if (!seen.has(preview.previewId)) { + seen.add(preview.previewId); + previews.push(preview); + } + continue; + } + const wrapper = previewWrapper({ target: declaration, facade, arguments: item.arguments, theme, + configuration: { showBackground: true, ...item.configuration }, annotate: !komodo }); + placement.wrapper = wrapper; + placement.methods.add(wrapper.methodFQN); + if (seen.has(wrapper.methodFQN)) continue; + seen.add(wrapper.methodFQN); + if (!originals.has(declaration.file)) originals.set(declaration.file, item.text.length); + appendices.set(declaration.file, (appendices.get(declaration.file) ?? "") + wrapper.text); + previews.push({ methodFQN: wrapper.methodFQN, previewId: wrapper.methodFQN, name: declaration.name, previewParams: wrapper.previewParams, methodParams: [] }); + } + const files = [...new Set(items.map((item) => item.declaration.file))]; + if (komodo) await this.workspace.requireKomodoPreview(files[0]!); + signal.throwIfAborted(); + if ((readConfig().composePreviewEngine === "komodo") !== komodo) throw new Error("The Compose preview engine changed; render again with the selected engine."); + const backend = komodo ? this.komodoBackend(key) : this.host.backends.acquire(key); + const group: Group = { + key, app, backend, komodo: backend instanceof KomodoBackend ? backend : undefined, sourceFile: files[0]!, files: files.slice(1), + appendices, previews, placements, units: [], originals, + texts: new Map(), latest: new Map(), frameAt: new Map(), lost: false, subscriptions: [], released: false, + }; + this.groups.add(group); + group.subscriptions.push( + backend.onImages((images) => { + if (group.released) return; + for (const image of images) { + group.latest.set(image.id, image); + group.frameAt.set(image.id, performance.now()); + } + }), + backend.onCompile((outcome) => { group.compileFailure = outcome.failed ? outcome.messages : undefined; }), + backend.onSessionLost(() => { group.lost = true; }), + ); + return group; + } + + /** A wrapper is generated from the declaration it was resolved to; an edit that changes the declaration fails here, not in the compile. */ + private revalidate(placement: Placement): void { + if (!placement.wrapper) return; + const text = vscode.workspace.textDocuments.find((document) => document.uri.fsPath === placement.declaration.file)?.getText(); + if (text === undefined) return; + const current = composableDeclarations(text, placement.declaration.file).find((declaration) => declaration.name === placement.declaration.name); + if (!current) throw new Error(`${placement.reference} is no longer a top-level composable of ${path.basename(placement.declaration.file)}; render it again.`); + } + + /** The agent's Komodo backend for an application: the warm one of an earlier call, else a new one outside the panel pool. */ + private komodoBackend(key: string): ComposeBackend { + const idle = this.idleKomodo.get(key); + if (idle && !idle.backend.disposed) { + clearTimeout(idle.timer); + this.idleKomodo.delete(key); + return idle.backend; + } + return this.host.backends.detached(key); + } + + private release(group: Group): void { + if (group.released) return; + group.released = true; + this.groups.delete(group); + for (const unsubscribe of group.subscriptions) unsubscribe(); + if (!group.komodo) { + this.host.backends.release(group.backend); + return; + } + for (const image of group.latest.keys()) group.backend.blur(image); + this.host.arbiter.setActive(group.komodo, false); + const previous = this.idleKomodo.get(group.key); + if (this.disposed || !this.workspace.komodoPreviewAvailable || group.backend.disposed || (previous && previous.backend !== group.backend)) { + group.backend.dispose(); + return; + } + const keepAlive = readConfig().composePreviewKeepAlive * 60_000; + if (keepAlive <= 0) { + group.backend.dispose(); + return; + } + const timer = setTimeout(() => { + if (this.idleKomodo.get(group.key)?.backend === group.backend) this.idleKomodo.delete(group.key); + group.backend.dispose(); + }, Math.min(keepAlive, 0x7fff_ffff)); + timer.unref(); + this.idleKomodo.set(group.key, { backend: group.backend, timer }); + } + + /** + * The request for a group: the folder's editor texts, with every program file's current text (a file the agent changed on + * disk included, waiting for VS Code to reload an unmodified editor of it, which the language server compiles from). + */ + private async request(group: Group): Promise { + const folder = group.app.project.folder; + const program = [group.sourceFile, ...group.files]; + for (const file of program) { + const disk = await readFile(file, "utf8"); + let document = vscode.workspace.textDocuments.find((item) => item.uri.scheme === "file" && item.uri.fsPath === file); + // The language server compiles open documents: open the program's files so their text reaches it. + document ??= await vscode.workspace.openTextDocument(vscode.Uri.file(file)); + if (!document.isDirty && document.getText() !== disk) await this.reloaded(document, disk); + } + const documents = new Map(vscode.workspace.textDocuments.filter((item) => ownedBy(this.workspace, folder, item.uri)) + .map((item) => [item.uri.fsPath, item.getText()] as const)); + for (const file of program) if (!documents.has(file)) documents.set(file, await readFile(file, "utf8")); + const packageName = /^\s*package\s+([\w.`]+)/m.exec(documents.get(group.sourceFile) ?? "")?.[1]?.replaceAll("`", "") ?? ""; + return { documents, sourceFile: group.sourceFile, packageName, inspect: true, + selection: { appendices: group.appendices, previews: group.previews, files: group.files } }; + } + + /** Waits (briefly) for VS Code to reload an unmodified document whose file changed on disk. */ + private async reloaded(document: vscode.TextDocument, disk: string): Promise { + const { promise, resolve } = Promise.withResolvers(); + const subscription = vscode.workspace.onDidChangeTextDocument((event) => { + if (event.document === document && document.getText() === disk) resolve(); + }); + const timer = setTimeout(resolve, 3_000); + await promise; + clearTimeout(timer); + subscription.dispose(); + } + + /** Prepares the group's backend (renderer, compiler; a build when there is none yet or `rebuild`) and renders its selection. */ + private async renderGroup(group: Group, rebuild: boolean, signal: AbortSignal, trace: TimelineNode | undefined, progress: (text: string) => void): Promise { + const queueKey = group.komodo ? `komodo\0${group.key}` : group.key; + const previous = this.queues.get(queueKey) ?? Promise.resolve(); + const run = previous.then(() => this.renderNow(group, rebuild, signal, trace, progress)).catch((error: unknown) => { + if (group.komodo && !group.backend.disposed) group.komodo.preempt("The agent preview did not complete; refresh to try again."); + throw error; + }); + const settled = run.catch(() => {}); + this.queues.set(queueKey, settled); + void settled.then(() => { if (this.queues.get(queueKey) === settled) this.queues.delete(queueKey); }); + return run; + } + + private async renderNow(group: Group, rebuild: boolean, signal: AbortSignal, trace: TimelineNode | undefined, progress: (text: string) => void): Promise { + signal.throwIfAborted(); + const { backend } = group; + if (group.komodo) { + await this.workspace.requireKomodoPreview(group.sourceFile); + signal.throwIfAborted(); + await group.komodo.prepareSession(group.sourceFile, group.appendices.size > 0, signal); + signal.throwIfAborted(); + } + const project = this.workspace.projectAt(group.app.project.root); + const model = project?.model; + if (!project || !model) throw new Error(`Sync ${path.basename(group.app.project.root)} before previewing.`); + const artifact = model.android?.targets.find((target) => target.name === group.app.target.name); + if (!artifact) throw new Error(`Android artifact ${group.app.target.name} no longer exists; render again.`); + const step = (label: string, work: () => Promise): Promise => { + progress(label); + return trace ? trace.run(label, () => work()) : work(); + }; + progress("Reading sources"); + const request = await this.request(group); + signal.throwIfAborted(); + const build = previewBuild(this.workspace, project, model, artifact); + const runtime = await step("Compose runtime", () => ensureComposeRuntime(this.host.cacheRoot, signal, progress)); + signal.throwIfAborted(); + await backend.startRenderer(runtime, javaFor(model), group.previews.length); + signal.throwIfAborted(); + let built = false; + if (rebuild) { + await this.build(project, model, artifact, build.models, signal, trace, progress); + built = true; + } + try { + await step("Prepare compiler", () => backend.configure(build, request.documents, built)); + } catch (error) { + // Never built, or a build is rewriting its outputs: build, then derive the compiler from what it wrote. + if (built || error instanceof StaleBuildRecordError) throw error; + signal.throwIfAborted(); + await this.build(project, model, artifact, build.models, signal, trace, progress); + await step("Prepare compiler", () => backend.configure(build, request.documents, true)); + } + signal.throwIfAborted(); + if (backend.needsWarmup) await step("Initialize compiler", () => backend.warm(request)); + if (group.komodo) { + // A session closed meanwhile released the group: it must not hold the arbiter again. + signal.throwIfAborted(); + this.host.arbiter.setActive(group.komodo, true); + } + // A live session pushes the changed text (no live card: nothing is pushed) and compiles it before its cards change. + const changed = group.komodo && [...group.latest.values()].some((image) => image.live && !image.error) + && [...group.texts].some(([file, text]) => request.documents.get(file) !== text); + const compiled = changed ? this.compiled(group, signal) : undefined; + // The frames shown before the push: the edit's reload frame can still show them (Komodo), and does not count as its frame. + const before = compiled ? new Map([...group.latest].map(([id, image]) => [id, image.data] as const)) : undefined; + group.compileFailure = undefined; + let gallery: PreviewGallery; + try { + gallery = await step("Render", () => this.preview(backend, request, trace)); + } catch (error) { + compiled?.cancel(); + throw this.located(error, group, request); + } + group.lost = false; + for (const file of [group.sourceFile, ...group.files]) group.texts.set(file, request.documents.get(file)!); + for (const image of backend.latestImages(gallery.images)) { + group.latest.set(image.id, image); + group.frameAt.set(image.id, performance.now()); + } + // The edit's frames follow its compile: wait for them as for input's (a failed compile renders none). + let compiledAt: number | undefined; + if (compiled) { + progress("Compiling the edit"); + compiledAt = await compiled.done; + signal.throwIfAborted(); + } + if (group.komodo) await this.settle(group, [...group.latest.keys()], DEFAULT_SETTLE_MS, signal, compiledAt, before); + const effective = new Map(request.documents); + for (const [file, appendix] of group.appendices) effective.set(file, (effective.get(file) ?? "") + appendix); + group.units = (await backend.compiler?.sources(effective))?.files ?? [effective]; + } + + /** Renders `request`, confirming annotation processing as a preview panel does. */ + private async preview(backend: ComposeBackend, request: PreviewRequest, trace: TimelineNode | undefined): Promise { + let gallery: PreviewGallery; + try { + gallery = await this.inspected(backend, request, trace); + } catch (error) { + if (!backend.processingRequired) throw error; + await backend.verify(request, trace); + gallery = await this.inspected(backend, request, trace); + } + while (!gallery.verified && await backend.verify(request, trace)) gallery = await this.inspected(backend, request, trace); + return gallery; + } + + /** With layout sources when the build's bytecode allows; pixels without them otherwise. */ + private async inspected(backend: ComposeBackend, request: PreviewRequest, trace: TimelineNode | undefined): Promise { + try { + return await backend.preview(request, true, trace); + } catch (error) { + if (!request.inspect || !/Layout inspection requires preview source instrumentation/.test(message(error))) throw error; + return backend.preview({ ...request, inspect: false }, true, trace); + } + } + + /** When a live engine reports the compile of the edit being pushed, if it succeeds (undefined: it failed, or never came). */ + private compiled(group: Group, signal: AbortSignal): { done: Promise; cancel: () => void } { + const { promise, resolve } = Promise.withResolvers(); + const timer = setTimeout(() => resolve(undefined), COMPILE_WAIT_MS); + const unsubscribe = group.backend.onCompile((outcome) => resolve(outcome.failed ? undefined : performance.now())); + const aborted = () => resolve(undefined); + signal.addEventListener("abort", aborted, { once: true }); + const done = promise.finally(() => { + clearTimeout(timer); + unsubscribe(); + signal.removeEventListener("abort", aborted); + }); + return { done, cancel: () => resolve(undefined) }; + } + + /** + * Until no listed card received a frame for {@link SETTLE_QUIET_MS}, at most `maxMs`. After input sent from `inputAt` on, the + * quiet period starts with its first frame (with `before`, the first whose pixels differ from those), which recomposition can + * take longer than that period to produce; input that changes nothing renders none, and waits {@link INPUT_FRAME_MS} at most. + */ + private async settle(group: Group, imageIds: readonly string[], maxMs: number, signal: AbortSignal, inputAt?: number, + before?: ReadonlyMap): Promise { + const start = performance.now(); + const deadline = start + maxMs; + const latest = () => Math.max(0, ...imageIds.map((id) => group.frameAt.get(id) ?? 0)); + const redrawn = (since: number) => imageIds.some((id) => (group.frameAt.get(id) ?? 0) >= since + && (!before || group.latest.get(id)?.data !== before.get(id))); + if (inputAt !== undefined) while (!redrawn(inputAt) && performance.now() < Math.min(deadline, start + INPUT_FRAME_MS)) await sleep(20, signal); + for (;;) { + const quietUntil = Math.max(start, latest()) + SETTLE_QUIET_MS; + const now = performance.now(); + if (now >= quietUntil || now >= deadline) return; + await sleep(Math.min(quietUntil, deadline) - now, signal); + } + } + + /** A compile failure inside a generated wrapper shows the wrapper, numbered as the compiler numbers it. */ + private located(error: unknown, group: Group, request: PreviewRequest): Error { + const text = message(error); + const wrappers = [...group.appendices].map(([file, appendix]) => { + const original = request.documents.get(file) ?? ""; + const first = original.split("\n").length; + return `${vscode.workspace.asRelativePath(file, false)}, lines ${first}+ (generated wrapper, appended for the preview only):\n${numbered(appendix, first)}`; + }); + return new Error(wrappers.length ? `${text}\n\n${wrappers.join("\n\n")}` : text, { cause: error }); + } + + /** Runs the build of the application's preview inputs (no APK, no device), as a step of `trace`. */ + private async build(project: ElideProject, model: ProjectModel, artifact: AndroidTargetModel, models: readonly ProjectModel[], signal: AbortSignal, + trace: TimelineNode | undefined, progress: (text: string) => void): Promise { + progress(`Building ${artifact.name} preview inputs`); + const step = trace?.start("Build"); + const root = step ?? this.ui.activity.start(`Agent Compose preview build: ${artifact.name}`); + let code: number | undefined; + try { + code = await runObservedElide(project, "build", { args: composePreviewBuildTargets(model, artifact, models) }, { + signal, diagnostics: this.diagnostics, onCommand: observeCommands(() => root, (line) => this.ui.log(line)), + }); + } catch (error) { + root.end("failed", message(error)); + throw error; + } + if (code === 0) { + root.end("succeeded"); + return; + } + if (code === undefined) { + root.end("cancelled", "stopped"); + throw new Error("The preview build was stopped."); + } + const errors = buildErrors(root.lines()); + root.end("failed", `exit ${code}`); + throw new Error([`elide build exited with ${code} building ${artifact.name}'s preview inputs.`, ...errors].join("\n")); + } + + /** Every card of the session, in the order the agent named their composables (a composable's variants in render order). */ + private allCards(session: AgentSession): AgentCard[] { + return session.groups.flatMap((group) => this.cards(session, group)).sort((left, right) => left.placement.order - right.placement.order); + } + + /** The group's cards: one per rendered image of a selected method, keeping card ids across renders. */ + private cards(session: AgentSession, group: Group): AgentCard[] { + const result: AgentCard[] = []; + for (const placement of group.placements) { + for (const image of group.latest.values()) { + if (!placement.methods.has(image.detail) && !(image.detail === "" && [...placement.methods].some((method) => image.id.startsWith(`${method}:`)))) continue; + let card = [...session.cards.values()].find((candidate) => candidate.group === group && candidate.imageId === image.id && candidate.placement === placement); + if (!card) { + card = { id: `c${this.nextCard++}`, group, imageId: image.id, placement, nodes: [] }; + session.cards.set(card.id, card); + } + result.push(card); + } + } + return result; + } + + private async report(session: AgentSession, cards: readonly AgentCard[], layout: boolean): Promise { + const content: McpContent[] = []; + const engine = cards[0]?.group.komodo ? "komodo" : "layoutlib"; + const header = session.live && !session.closed + ? `Live session ${session.id} (${engine} engine): pass its id and a card id to compose_preview_interact (input) or compose_preview_refresh (after editing the source); close it with compose_preview_close when done.` + : `Rendered with the ${engine} engine${session.live ? "" : " (one-shot; render with live: true for an interactive session)"}.`; + const failures = [...new Set(cards.map((card) => card.group))].flatMap((group) => group.compileFailure ?? []); + content.push({ type: "text", text: [header, ...failures.length ? [`The edit does not compile; the cards show the last program that did:\n${failures + .map((failure) => `- ${failure.file ? `${vscode.workspace.asRelativePath(failure.file, false)}:${failure.line ?? 0}:${failure.column ?? 0}: ` : ""}${failure.level}: ${failure.text}`).join("\n")}`] : []].join("\n\n") }); + if (!cards.length) content.push({ type: "text", text: "No card rendered: the renderer reported no preview for the selection." }); + for (const card of cards) { + const image = card.group.latest.get(card.imageId); + const declaration = card.placement.declaration; + const lines = [`### ${card.id} · ${image?.name ?? declaration.name} — ${card.placement.reference}`]; + const where = `${vscode.workspace.asRelativePath(declaration.file, false)}:${declaration.line}`; + if (card.placement.wrapper) { + const values = card.placement.wrapper.arguments.map((argument) => `${argument.name} = ${argument.value}${argument.source === "placeholder" ? " (placeholder)" : ""}`); + lines.push(`Composable at ${where}, called by the generated preview ${card.placement.wrapper.name}(${values.length ? `): ${declaration.name}(${values.join(", ")})` : `): ${declaration.name}()`}.`); + } else lines.push(`Its own @Preview at ${where}${image?.id.endsWith(":0") ? "" : ` (variant ${image?.id.slice(image.id.lastIndexOf(":") + 1)})`}.`); + if (!image) lines.push("No frame."); + else if (image.error) lines.push(`Failed: ${image.error}`); + else lines.push(`${image.width ?? "?"}×${image.height ?? "?"} px${image.live ? ", live" : ", snapshot"}.`); + card.nodes = []; + if (image?.data && !image.error && layout) { + const targets = new Map(); + const nodes = inspectableNodes(image, card.group.units, targets) ?? []; + const tree = layoutTree(nodes); + card.nodes = tree.map(({ node, depth }, index) => { + const target = targets.get(`${image.id}\0${node.id}`); + let location: string | undefined; + if (target) { + const original = card.group.originals.get(target.file); + const at = position(target.content, target.offset); + location = original !== undefined && target.offset >= original + ? "generated wrapper" + : `${vscode.workspace.asRelativePath(target.file, false)}:${at.line}:${at.column}`; + } + return { id: `n${index + 1}`, node, depth, location }; + }); + if (image.layoutError) lines.push(`Layout unavailable: ${image.layoutError}`); + else if (!card.nodes.length) lines.push("Layout: no inspectable nodes (code compiled without Compose source information shows bounds only on live cards)."); + else { + lines.push("Layout (image pixels: x,y width×height; call site):"); + for (const entry of card.nodes.slice(0, LAYOUT_NODES)) { + const { node } = entry; + lines.push(`${" ".repeat(entry.depth)}${entry.id} ${node.name} ${node.x},${node.y} ${node.width}×${node.height}${entry.location ? ` — ${entry.location}` : ""}`); + } + if (card.nodes.length > LAYOUT_NODES) lines.push(`… ${card.nodes.length - LAYOUT_NODES} more nodes omitted.`); + } + } + content.push({ type: "text", text: lines.join("\n") }); + if (image?.data && !image.error) content.push({ type: "image", data: image.data, mimeType: "image/png" }); + } + if (cards.some((card) => card.group.komodo) && session.live && !session.closed) { + content.push({ type: "text", text: `While this session is open, the Compose preview panel shows "${PAUSED_PANELS}"; close the session to resume it.` }); + } + return content; + } +} diff --git a/packages/vscode/src/composeAgentMcp.ts b/packages/vscode/src/composeAgentMcp.ts new file mode 100644 index 0000000..5c5fef2 --- /dev/null +++ b/packages/vscode/src/composeAgentMcp.ts @@ -0,0 +1,356 @@ +import { randomBytes } from "node:crypto"; +import * as vscode from "vscode"; +import type { AgentAction, AgentActionType, AgentPreviewItem, ComposeAgentPreviews } from "./composeAgent.js"; +import type { PreviewConfiguration } from "./composeAgentSource.js"; +import { readConfig } from "./config.js"; +import { McpHttpServer, type McpTool } from "./mcpServer.js"; +import type { ElideUi } from "./output.js"; + +const PROVIDER_ID = "elide.composePreview"; +const LABEL = "Elide Compose Preview"; +const TOKEN_KEY = "elide.composePreviewMcp.token"; +const PORT_KEY = "elide.composePreviewMcp.port"; +export const MCP_URL_VARIABLE = "ELIDE_COMPOSE_PREVIEW_MCP_URL"; +export const MCP_TOKEN_VARIABLE = "ELIDE_COMPOSE_PREVIEW_MCP_TOKEN"; + +const INSTRUCTIONS = `Renders Jetpack Compose UI of this VS Code workspace's Elide Android projects and returns PNG images plus each +card's layout tree (composable calls with pixel bounds and source call sites). +- Name composables by code reference: package.Function, Function, or path/to/File.kt#Function (path relative to the + workspace folder; add :line to pick an overload). The composable does not need @Preview: a wrapper calling it is + generated for the preview only and never saved. Required parameters without defaults get placeholders where the type + allows (strings, numbers, lambdas, Modifier, collections, nullable types); pass others, or better values, as Kotlin + expressions in "arguments" (they compile in the composable's own file, so its imports and private declarations apply). +- Use compose_preview_list to find composables and their parameters. +- live: true keeps an interactive session (komodo engine): compose_preview_interact taps, drags, scrolls and types into + a card and returns its new frame; compose_preview_refresh renders again after you edit the source; close the session + with compose_preview_close when done (it pauses the user's own preview panel while open). +- Coordinates are image pixels of the returned PNG; actions may instead target a layout node id (n3) of the last report.`; + +const configurationSchema = { + type: "object", + description: "@Preview configuration; unset fields use the renderer's defaults (showBackground defaults to true).", + properties: { + widthDp: { type: "integer", minimum: 1, maximum: 4096 }, + heightDp: { type: "integer", minimum: 1, maximum: 8192 }, + showBackground: { type: "boolean" }, + backgroundColor: { type: "string", description: "ARGB as 0xAARRGGBB, or #RRGGBB / #AARRGGBB." }, + darkTheme: { type: "boolean", description: "Night mode (uiMode UI_MODE_NIGHT_YES)." }, + fontScale: { type: "number", exclusiveMinimum: 0, maximum: 4 }, + locale: { type: "string", description: "A locale qualifier, such as fr or en-rGB." }, + device: { type: "string", description: "A @Preview device, such as id:pixel_5 or spec:width=411dp,height=891dp." }, + showSystemUi: { type: "boolean" }, + apiLevel: { type: "integer", minimum: 21, maximum: 100 }, + }, + additionalProperties: false, +} as const; + +function object(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optional(args: Record, name: string, type: "string" | "boolean" | "number"): T | undefined { + const value = args[name]; + if (value === undefined || value === null) return undefined; + if (typeof value !== type) throw new Error(`${name} must be a ${type}.`); + return value as T; +} + +function required(args: Record, name: string): string { + const value = optional(args, name, "string"); + if (!value) throw new Error(`${name} is required.`); + return value; +} + +function configuration(value: unknown, where: string): PreviewConfiguration | undefined { + if (value === undefined || value === null) return undefined; + if (!object(value)) throw new Error(`${where} must be an object.`); + const known = new Set(Object.keys(configurationSchema.properties)); + const unknown = Object.keys(value).filter((key) => !known.has(key)); + if (unknown.length) throw new Error(`${where} has unknown fields: ${unknown.join(", ")}.`); + const integer = (name: string, minimum: number, maximum: number) => { + const field = optional(value, name, "number"); + if (field !== undefined && (!Number.isInteger(field) || field < minimum || field > maximum)) throw new Error(`${where}.${name} must be an integer from ${minimum} to ${maximum}.`); + return field; + }; + const background = optional(value, "backgroundColor", "string"); + let backgroundColor: number | undefined; + if (background !== undefined) { + const hex = /^(?:0x|#)?([0-9a-f]{6}|[0-9a-f]{8})$/i.exec(background)?.[1]; + if (!hex) throw new Error(`${where}.backgroundColor must be 0xAARRGGBB, #RRGGBB or #AARRGGBB.`); + backgroundColor = Number.parseInt(hex.length === 6 ? `ff${hex}` : hex, 16); + } + const fontScale = optional(value, "fontScale", "number"); + if (fontScale !== undefined && !(fontScale > 0 && fontScale <= 4)) throw new Error(`${where}.fontScale must be above 0 and at most 4.`); + return { + widthDp: integer("widthDp", 1, 4096), heightDp: integer("heightDp", 1, 8192), + showBackground: optional(value, "showBackground", "boolean"), backgroundColor, + darkTheme: optional(value, "darkTheme", "boolean"), fontScale, + locale: optional(value, "locale", "string"), device: optional(value, "device", "string"), + showSystemUi: optional(value, "showSystemUi", "boolean"), apiLevel: integer("apiLevel", 21, 100), + }; +} + +function items(value: unknown): AgentPreviewItem[] { + if (!Array.isArray(value) || !value.length || value.length > 12) throw new Error("composables must list 1 to 12 composables."); + return value.map((item, index) => { + if (typeof item === "string") return { reference: item }; + if (!object(item)) throw new Error(`composables[${index}] must be an object with a reference.`); + const reference = required(item, "reference"); + const given = item.arguments; + if (given !== undefined && (!object(given) || !Object.values(given).every((argument) => typeof argument === "string"))) { + throw new Error(`composables[${index}].arguments maps parameter names to Kotlin expressions (strings).`); + } + return { reference, arguments: given as Record | undefined, preview: configuration(item.preview, `composables[${index}].preview`) }; + }); +} + +const ACTIONS: readonly AgentActionType[] = ["tap", "longPress", "drag", "scroll", "type", "key", "wait"]; + +function actions(value: unknown): AgentAction[] { + if (!Array.isArray(value) || !value.length || value.length > 100) throw new Error("actions must list 1 to 100 actions."); + return value.map((action, index) => { + if (!object(action) || typeof action.type !== "string" || !ACTIONS.some((type) => type === action.type)) { + throw new Error(`actions[${index}].type must be one of ${ACTIONS.join(", ")}.`); + } + const number = (name: string) => optional(action, name, "number"); + const durationMs = number("durationMs"); + if (durationMs !== undefined && !(durationMs >= 0 && durationMs <= 60_000)) throw new Error(`actions[${index}].durationMs must be from 0 to 60000.`); + return { + type: action.type as AgentActionType, x: number("x"), y: number("y"), node: optional(action, "node", "string"), + toX: number("toX"), toY: number("toY"), toNode: optional(action, "toNode", "string"), dx: number("dx"), dy: number("dy"), + text: optional(action, "text", "string"), key: optional(action, "key", "string"), shift: optional(action, "shift", "boolean"), + ctrl: optional(action, "ctrl", "boolean"), alt: optional(action, "alt", "boolean"), meta: optional(action, "meta", "boolean"), + durationMs, + }; + }); +} + +/** The Compose preview tools, over the agent service. */ +export function composePreviewTools(previews: ComposeAgentPreviews): McpTool[] { + const sessionSchema = { type: "string", description: "A live session id from compose_preview_render (s1)." }; + return [ + { + name: "compose_preview_render", + title: "Render Compose previews", + description: "Render one or more composables of this workspace (named by code reference; @Preview not required) and return " + + "each card's PNG and layout tree. With live: true the session stays open for compose_preview_interact/refresh.", + inputSchema: { + type: "object", + properties: { + composables: { + type: "array", minItems: 1, maxItems: 12, + items: { + type: "object", + properties: { + reference: { type: "string", description: "package.Function, Function, or path/to/File.kt#Function[:line]." }, + arguments: { type: "object", additionalProperties: { type: "string" }, + description: "Kotlin expressions by parameter name, e.g. {\"title\": \"\\\"Hello\\\"\", \"count\": \"3\"}; they compile in the composable's file." }, + preview: configurationSchema, + }, + required: ["reference"], + additionalProperties: false, + }, + }, + preview: { ...configurationSchema, description: "Configuration for every composable; each composable's own preview overrides it." }, + theme: { type: "string", description: "A theme composable to call each composable in (last parameter a @Composable content lambda), e.g. com.example.ui.AppTheme or androidx.compose.material3.MaterialTheme." }, + live: { type: "boolean", description: "Keep an interactive session open (komodo engine; layoutlib sessions only refresh)." }, + rebuild: { type: "boolean", description: "Run the Elide build of the preview inputs first: after resource changes, or edits the preview's own compile does not include." }, + layout: { type: "boolean", description: "Include each card's layout tree (default true)." }, + }, + required: ["composables"], + additionalProperties: false, + }, + annotations: { title: "Render Compose previews", readOnlyHint: true, openWorldHint: false }, + call: async (args, context) => { + const theme = optional(args, "theme", "string"); + return { content: await previews.render({ + composables: items(args.composables), preview: configuration(args.preview, "preview"), theme, + live: optional(args, "live", "boolean"), rebuild: optional(args, "rebuild", "boolean"), layout: optional(args, "layout", "boolean"), + }, context.signal, context.progress) }; + }, + }, + { + name: "compose_preview_interact", + title: "Interact with a live Compose preview", + description: "Send taps, long presses, drags, scrolls and keyboard input to a live card, then return its settled frame and layout.", + inputSchema: { + type: "object", + properties: { + session: sessionSchema, + card: { type: "string", description: "A card id of the session (c1)." }, + actions: { + type: "array", minItems: 1, maxItems: 100, + items: { + type: "object", + properties: { + type: { type: "string", enum: ACTIONS }, + x: { type: "number", description: "Image pixels (tap, longPress, drag start, scroll)." }, + y: { type: "number" }, + node: { type: "string", description: "Instead of x/y: the center of a layout node of the card's last report (n3)." }, + toX: { type: "number", description: "Drag end." }, + toY: { type: "number" }, + toNode: { type: "string" }, + dx: { type: "number", description: "Scroll amount, pixels." }, + dy: { type: "number" }, + text: { type: "string", description: "type: characters to type into the focused field (tap it first)." }, + key: { type: "string", description: "key: one character, or Enter, Tab, Backspace, Delete, Escape, ArrowLeft/Right/Up/Down, Home, End, PageUp, PageDown, Insert, F1-F12." }, + shift: { type: "boolean" }, + ctrl: { type: "boolean" }, + alt: { type: "boolean" }, + meta: { type: "boolean" }, + durationMs: { type: "number", minimum: 0, maximum: 60000, description: "longPress hold, drag duration, or wait." }, + }, + required: ["type"], + additionalProperties: false, + }, + }, + settleMs: { type: "integer", minimum: 0, maximum: 30000, description: "Longest wait for frames to settle after the actions (default 3000)." }, + }, + required: ["session", "card", "actions"], + additionalProperties: false, + }, + annotations: { title: "Interact with a live Compose preview", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + call: async (args, context) => { + const settle = optional(args, "settleMs", "number"); + if (settle !== undefined && !(Number.isInteger(settle) && settle >= 0 && settle <= 30_000)) throw new Error("settleMs must be an integer from 0 to 30000."); + return { content: await previews.interact(required(args, "session"), required(args, "card"), actions(args.actions), settle, context.signal) }; + }, + }, + { + name: "compose_preview_refresh", + title: "Refresh a Compose preview session", + description: "Render a session's composables again from the current source (after you edited it) and return every card.", + inputSchema: { type: "object", properties: { session: sessionSchema }, required: ["session"], additionalProperties: false }, + annotations: { title: "Refresh a Compose preview session", readOnlyHint: true, openWorldHint: false }, + call: async (args, context) => ({ content: await previews.refresh(required(args, "session"), context.signal, context.progress) }), + }, + { + name: "compose_preview_close", + title: "Close a Compose preview session", + description: "Close a live session (the user's preview panel resumes).", + inputSchema: { type: "object", properties: { session: sessionSchema }, required: ["session"], additionalProperties: false }, + annotations: { title: "Close a Compose preview session", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + call: async (args) => ({ content: [{ type: "text", text: previews.closeSession(required(args, "session")) }] }), + }, + { + name: "compose_preview_list", + title: "List composables", + description: "List top-level @Composable functions (with their references, parameters and whether they have @Preview) of a file or the workspace.", + inputSchema: { + type: "object", + properties: { + file: { type: "string", description: "A Kotlin file, relative to the workspace folder or absolute." }, + query: { type: "string", description: "Substring of the function or package name." }, + }, + additionalProperties: false, + }, + annotations: { title: "List composables", readOnlyHint: true, openWorldHint: false }, + call: async (args) => ({ content: [{ type: "text", text: await previews.list(optional(args, "file", "string"), optional(args, "query", "string")) }] }), + }, + ]; +} + +/** The running server's address and token; consumed by the extension-host integration test, as an external agent connects. */ +export interface ElideComposePreviewMcpApi { + endpoint(): Promise<{ url: string; token: string } | undefined>; +} + +/** + * Serves the Compose preview tools over MCP for this window: a loopback Streamable HTTP server with a per-workspace bearer token, + * offered to VS Code's agents by an MCP server definition provider, to agents in the integrated terminal by environment variables, + * and to any other client by the Copy MCP Server Configuration command. Port and token stay the same across reloads when they can. + */ +export function registerComposePreviewMcp(context: vscode.ExtensionContext, previews: ComposeAgentPreviews, ui: ElideUi, version: string): + { disposable: vscode.Disposable; api: ElideComposePreviewMcpApi } { + const changed = new vscode.EventEmitter(); + let server: McpHttpServer | undefined; + let token: string | undefined; + let starting: Promise = Promise.resolve(); + let disposed = false; + const enabled = () => readConfig().composePreviewMcp; + + const stop = async () => { + const running = server; + server = undefined; + context.environmentVariableCollection.delete(MCP_URL_VARIABLE); + context.environmentVariableCollection.delete(MCP_TOKEN_VARIABLE); + changed.fire(); + await running?.close(); + }; + const start = async () => { + if (disposed || server || !enabled()) return; + const key = `${TOKEN_KEY}:${context.storageUri?.toString() ?? ""}`; + token = await context.secrets.get(key); + if (!token) { + token = randomBytes(32).toString("hex"); + await context.secrets.store(key, token); + } + const started = await McpHttpServer.start({ + name: "elide-compose-preview", version, instructions: INSTRUCTIONS, tools: composePreviewTools(previews), token, + port: context.workspaceState.get(PORT_KEY) ?? 0, log: (line) => ui.log(`Compose preview MCP: ${line}`), + }); + if (disposed || !enabled()) { + await started.close(); + return; + } + server = started; + await context.workspaceState.update(PORT_KEY, started.port); + if (disposed || server !== started) return; + context.environmentVariableCollection.description = "Lets agents in this terminal reach the Elide Compose preview MCP server."; + context.environmentVariableCollection.replace(MCP_URL_VARIABLE, started.url); + context.environmentVariableCollection.replace(MCP_TOKEN_VARIABLE, token); + ui.log(`Compose preview MCP server at ${started.url}`); + changed.fire(); + }; + const reconcile = () => { + starting = starting.then(() => enabled() ? start() : stop()).catch((error: unknown) => { + ui.log(`Compose preview MCP server failed to start: ${error instanceof Error ? error.message : String(error)}`); + }); + }; + + const subscriptions: vscode.Disposable[] = [ + changed, + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("elide.android.composePreview.mcp")) reconcile(); + }), + vscode.commands.registerCommand("elide.composePreviewMcp.copyConfiguration", async () => { + await starting; + if (!server || !token) { + void vscode.window.showWarningMessage("Elide: the Compose preview MCP server is off (elide.android.composePreview.mcp.enabled)."); + return; + } + const configuration = { mcpServers: { "elide-compose-preview": { type: "http", url: server.url, headers: { Authorization: `Bearer ${token}` } } } }; + await vscode.env.clipboard.writeText(JSON.stringify(configuration, null, 2)); + void vscode.window.showInformationMessage(`Elide: copied the Compose preview MCP server configuration (${server.url}). It holds this workspace's access token.`); + }), + vscode.commands.registerCommand("elide.composePreviewMcp.closeSessions", () => { + const closed = previews.closeAll(); + void vscode.window.showInformationMessage(`Elide: closed ${closed} agent Compose preview session${closed === 1 ? "" : "s"}.`); + }), + ]; + // Editors without VS Code's MCP API (older builds, some forks) still serve the URL to terminals and the copied configuration. + if (typeof vscode.lm?.registerMcpServerDefinitionProvider === "function") { + subscriptions.push(vscode.lm.registerMcpServerDefinitionProvider(PROVIDER_ID, { + onDidChangeMcpServerDefinitions: changed.event, + provideMcpServerDefinitions: async () => { + await starting; + return server && token ? [new vscode.McpHttpServerDefinition(LABEL, vscode.Uri.parse(server.url), { Authorization: `Bearer ${token}` }, version)] : []; + }, + resolveMcpServerDefinition: async (definition) => { + await starting; + if (!server) throw new Error("The Elide Compose preview MCP server is off (elide.android.composePreview.mcp.enabled)."); + return definition; + }, + })); + } + reconcile(); + const disposable = new vscode.Disposable(() => { + disposed = true; + for (const subscription of subscriptions) subscription.dispose(); + void stop(); + }); + return { disposable, api: { endpoint: async () => { + await starting; + return server && token ? { url: server.url, token } : undefined; + } } }; +} diff --git a/packages/vscode/src/composeAgentSource.ts b/packages/vscode/src/composeAgentSource.ts new file mode 100644 index 0000000..00b5b39 --- /dev/null +++ b/packages/vscode/src/composeAgentSource.ts @@ -0,0 +1,848 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; + +/** A function parameter as declared: `type` and `defaultValue` are source text without comments. */ +export interface KotlinParameter { name: string; type: string; defaultValue?: string; vararg: boolean; annotations: string[] } + +export interface ComposableDeclaration { + name: string; + packageName: string; + file: string; + /** Offset of the `fun` keyword in the text. */ + offset: number; + /** 1-based line of `fun`. */ + line: number; + visibility: "public" | "internal" | "private" | "protected"; + /** Extension receiver type as declared (`ColumnScope`). */ + receiver?: string; + /** Context parameters (`context(a: A)`, `name` absent for `_`) and context receivers (`context(A)`) as declared; absent when none. */ + contexts?: KotlinContext[]; + /** Type parameters as declared (`T`, `reified T : Any`). */ + typeParameters: string[]; + parameters: KotlinParameter[]; + /** Annotated `@Preview` or a multipreview (an annotation whose simple name contains `Preview`). */ + previews: boolean; + /** The function's annotations as written (`@Composable`, `@Preview(name = "Dark")`). */ + annotations: string[]; +} + +/** A context parameter or receiver: `name` is absent for a context receiver and for `_`. */ +export interface KotlinContext { name?: string; type: string } + +/** A composable named by an agent: `name` is empty when `line` alone locates it in `file`. */ +export interface ComposableReference { file?: string; packageName?: string; name: string; line?: number } + +export interface PreviewConfiguration { + widthDp?: number; + heightDp?: number; + showBackground?: boolean; + /** ARGB. */ + backgroundColor?: number; + darkTheme?: boolean; + fontScale?: number; + locale?: string; + device?: string; + showSystemUi?: boolean; + apiLevel?: number; +} + +export interface WrapperRequest { + target: ComposableDeclaration; + /** JVM facade class of the target's file ({@link jvmFacade}). */ + facade: string; + /** Kotlin expressions by parameter name. */ + arguments: Readonly>; + /** A composable whose last parameter is a composable lambda, wrapped around the target. */ + theme?: ComposableDeclaration; + configuration: PreviewConfiguration; + /** Emit a `@Preview` annotation carrying the configuration (layoutlib discovers previews by it). */ + annotate: boolean; +} + +export interface PreviewWrapper { + name: string; + /** `.`. */ + methodFQN: string; + /** Appended to the target file's text. */ + text: string; + /** The configuration's `@Preview` attributes as preview discovery reports them. */ + previewParams: Record; + /** What the wrapper passes to the target, in parameter order. */ + arguments: { name: string; value: string; source: "argument" | "placeholder" }[]; +} + +/** The target cannot be wrapped as requested; the message tells the agent what to pass. */ +export class WrapperError extends Error { + override readonly name = "WrapperError"; +} + +const IDENTIFIER_PART = /[\p{L}\p{Nd}_]/u; +const identifierPart = (char: string | undefined): boolean => char !== undefined && IDENTIFIER_PART.test(char); +const MODIFIERS = [ + "public", "private", "internal", "protected", "inline", "suspend", "tailrec", "operator", "infix", "external", "override", "open", + "final", "abstract", "expect", "actual", +]; +const HARD_KEYWORDS = [ + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", "interface", "is", "null", "object", "package", + "return", "super", "this", "throw", "true", "try", "typealias", "typeof", "val", "var", "when", "while", +]; + +/** + * Two views of Kotlin source with the offsets of `text`: `code` blanks comments, `masked` also blanks the contents of string + * (raw strings and `${}` templates included) and character literals, keeping their quotes. Newlines are kept. + */ +function scan(text: string): { code: string; masked: string } { + const code = text.split(""); + const masked = text.split(""); + const blank = (chars: string[], from: number, to: number) => { + for (let index = from; index < to; index++) if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " "; + }; + // Open literals, innermost last: a string, a raw string, or a template's code with its unclosed braces. + const stack: ({ kind: "string" | "raw" } | { kind: "template"; braces: number })[] = []; + let literal = 0; + let index = 0; + const close = (end: number, next: number) => { + stack.pop(); + if (stack.length === 0) blank(masked, literal, end); + index = next; + }; + while (index < text.length) { + const top = stack.at(-1); + const char = text[index]!; + if (!top || top.kind === "template") { + if (text.startsWith("//", index)) { + const end = text.indexOf("\n", index); + const stop = end < 0 ? text.length : end; + if (!top) blank(code, index, stop); + blank(masked, index, stop); + index = stop; + } else if (text.startsWith("/*", index)) { + let depth = 1; + let end = index + 2; + while (end < text.length && depth > 0) { + if (text.startsWith("/*", end)) depth++; + else if (text.startsWith("*/", end)) depth--; + else { + end++; + continue; + } + end += 2; + } + if (!top) blank(code, index, end); + blank(masked, index, end); + index = end; + } else if (char === '"') { + const raw = text.startsWith('"""', index); + index += raw ? 3 : 1; + if (!top) literal = index; + stack.push({ kind: raw ? "raw" : "string" }); + } else if (char === "'") { + let end = index + 1; + while (end < text.length && text[end] !== "'" && text[end] !== "\n") end += text[end] === "\\" ? 2 : 1; + if (!top) blank(masked, index + 1, Math.min(end, text.length)); + index = end + 1; + } else if (char === "`") { + const end = text.indexOf("`", index + 1); + const newline = text.indexOf("\n", index + 1); + index = end < 0 || (newline >= 0 && newline < end) ? index + 1 : end + 1; + } else { + if (top && char === "{") top.braces++; + else if (top && char === "}") { + if (top.braces === 0) stack.pop(); + else top.braces--; + } + index++; + } + continue; + } + if (char === "$" && text[index + 1] === "{") { + stack.push({ kind: "template", braces: 0 }); + index += 2; + } else if (top.kind === "string") { + if (char === "\\") index += 2; + else if (char === '"') close(index, index + 1); + else if (char === "\n") close(index, index); // unterminated + else index++; + } else if (text.startsWith('"""', index)) { + // Quotes before the closing three belong to the string. + let end = index + 3; + while (text[end] === '"') end++; + close(end - 3, end); + } else index++; + } + if (stack.length > 0) blank(masked, literal, text.length); + return { code: code.join(""), masked: masked.join("") }; +} + +function skipSpace(text: string, index: number): number { + while (index < text.length && /\s/.test(text[index]!)) index++; + return index; +} + +/** The identifier at `index` (backticks removed) and the offset after it. */ +function identifierAt(text: string, index: number): { name: string; end: number } | undefined { + if (text[index] === "`") { + const end = text.indexOf("`", index + 1); + return end > index + 1 ? { name: text.slice(index + 1, end), end: end + 1 } : undefined; + } + let end = index; + while (identifierPart(text[end])) end++; + return end > index && !/\d/.test(text[index]!) ? { name: text.slice(index, end), end } : undefined; +} + +/** The offset of the bracket closing the one at `open` in masked text, or -1. */ +function closing(masked: string, open: number): number { + const opener = masked[open]!; + const closer = { "(": ")", "[": "]", "{": "}" }[opener]; + if (!closer) return -1; + let depth = 0; + for (let index = open; index < masked.length; index++) { + if (masked[index] === opener) depth++; + else if (masked[index] === closer && --depth === 0) return index; + } + return -1; +} + +/** The offset of the bracket opening the one at `close` in masked text, scanning backwards, or -1. */ +function opening(masked: string, close: number): number { + const closer = masked[close]!; + const opener = { ")": "(", "]": "[" }[closer]; + if (!opener) return -1; + let depth = 0; + for (let index = close; index >= 0; index--) { + if (masked[index] === closer) depth++; + else if (masked[index] === opener && --depth === 0) return index; + } + return -1; +} + +/** + * The `>` closing the type argument list opened at `open`, or -1 when the text there cannot be one (a less-than comparison): + * only type syntax may occur in between, `->` does not close it, and parentheses must balance. + */ +function angleClosing(text: string, open: number): number { + let angles = 0; + let parens = 0; + for (let index = open; index < text.length; index++) { + const char = text[index]!; + if (char === "<") angles++; + else if (char === ">") { + if (--angles === 0) return parens === 0 ? index : -1; + } else if (char === "-" && text[index + 1] === ">") index++; + else if (char === "(") parens++; + else if (char === ")") { + if (--parens < 0) return -1; + } else if (!identifierPart(char) && !/[\s.,?*:@&\[\]`]/.test(char)) return -1; + } + return -1; +} + +/** `[from, to)` split at commas outside (), [], {} and type argument lists, as trimmed non-empty ranges. */ +function splitTopLevel(text: string, from: number, to: number): [number, number][] { + const ranges: [number, number][] = []; + let depth = 0; + let start = from; + const push = (end: number) => { + let first = start; + let last = end; + while (first < last && /\s/.test(text[first]!)) first++; + while (last > first && /\s/.test(text[last - 1]!)) last--; + if (last > first) ranges.push([first, last]); + }; + for (let index = from; index < to; index++) { + const char = text[index]!; + if (char === "(" || char === "[" || char === "{") depth++; + else if (char === ")" || char === "]" || char === "}") depth--; + else if (char === "<" && identifierPart(text[index - 1])) { + const close = angleClosing(text, index); + if (close >= 0 && close < to) index = close; + } else if (char === "," && depth === 0) { + push(index); + start = index + 1; + } + } + push(to); + return ranges; +} + +/** The offset after an annotation starting with `@` at `index`: `@a.b.Name`, `@site:Name`, `@Name(…)` or `@[A B(…)]`. */ +function annotationEnd(masked: string, index: number): number { + let end = index + 1; + if (masked[end] === "[") { + const close = closing(masked, end); + return close < 0 ? -1 : close + 1; + } + let name = identifierAt(masked, end); + if (!name) return -1; + end = name.end; + if (masked[end] === ":" && (name = identifierAt(masked, end + 1))) end = name.end; + while (masked[end] === "." && (name = identifierAt(masked, end + 1))) end = name.end; + if (masked[end] === "<") { + const close = angleClosing(masked, end); + if (close >= 0) end = close + 1; + } + if (masked[end] === "(") { + const close = closing(masked, end); + if (close < 0) return -1; + end = close + 1; + } + return end; +} + +/** The simple names of an annotation as written: `@a.Name(…)` is `Name`, `@[A B]` is `A`, `B`. */ +function annotationNames(annotation: string): string[] { + const body = annotation.startsWith("@[") ? annotation.slice(2, -1) : annotation.slice(1); + // Argument lists and type arguments contribute no names. + let flat = ""; + let depth = 0; + for (const char of body) { + if (char === "(" || char === "<") depth++; + else if (char === ")" || char === ">") depth--; + else if (depth === 0) flat += char; + } + return flat.split(/\s+/).filter(Boolean).map((name) => name.replace(/^[\p{L}_]+:/u, "").split(".").at(-1)!.replaceAll("`", "")); +} + +const collapse = (text: string) => text.trim().replace(/\s+/g, " "); + +function parameter(code: string, masked: string, from: number, to: number): KotlinParameter | undefined { + const annotations: string[] = []; + let index = from; + while (masked[index] === "@") { + const end = annotationEnd(masked, index); + if (end < 0 || end > to) return undefined; + annotations.push(collapse(code.slice(index, end))); + index = skipSpace(masked, end); + } + let vararg = false; + for (;;) { + const word = identifierAt(masked, index); + if (!word || masked[word.end] === ":" || !["vararg", "noinline", "crossinline"].includes(word.name) || !/\s/.test(masked[word.end] ?? "")) break; + vararg ||= word.name === "vararg"; + index = skipSpace(masked, word.end); + } + const name = identifierAt(masked, index); + if (!name) return undefined; + const colon = skipSpace(masked, name.end); + if (masked[colon] !== ":") return undefined; + let depth = 0; + let equals = -1; + for (let position = colon + 1; position < to && equals < 0; position++) { + const char = masked[position]; + if (char === "(" || char === "[" || char === "{") depth++; + else if (char === ")" || char === "]" || char === "}") depth--; + else if (char === "=" && depth === 0) equals = position; + } + const type = collapse(code.slice(colon + 1, equals < 0 ? to : equals)); + if (!type) return undefined; + const defaultValue = equals < 0 ? undefined : code.slice(equals + 1, to).trim(); + return { name: name.name, type, ...defaultValue !== undefined ? { defaultValue } : {}, vararg, annotations }; +} + +/** The start of the (qualified, possibly backticked) name ending at `end` in masked text; `end` when there is none. */ +function nameStart(masked: string, end: number): number { + let start = end; + for (;;) { + let segment = start; + if (masked[segment - 1] === "`") segment = Math.max(0, masked.lastIndexOf("`", segment - 2)); + else while (identifierPart(masked[segment - 1])) segment--; + if (segment === start) return start; + start = segment; + const before = masked[start - 2]; + if (masked[start - 1] !== "." || !(before === "`" || identifierPart(before))) return start; + start--; + } +} + +/** + * Annotations (as written, and their simple names) and modifiers before the `fun` at `offset`, in source order. Strings are + * masked for the names: `@Preview(name = "a)b")` is `Preview`. + */ +function prefix(code: string, masked: string, offset: number): { annotations: string[]; names: string[]; modifiers: string[]; contexts: KotlinContext[] } { + const annotations: string[] = []; + const names: string[] = []; + const modifiers: string[] = []; + const contexts: KotlinContext[][] = []; + let start = offset; + for (;;) { + let end = start - 1; + while (end >= 0 && /\s/.test(masked[end]!)) end--; + if (end < 0) break; + const group = masked[end] === "]"; + let wordEnd = end + 1; + if (group || masked[end] === ")") { + wordEnd = opening(masked, end); + if (wordEnd < 0) break; + } + const wordStart = group ? wordEnd : nameStart(masked, wordEnd); + if (masked[wordStart - 1] === "@" && (group || wordStart < wordEnd)) { + annotations.push(collapse(code.slice(wordStart - 1, end + 1))); + names.push(...annotationNames(masked.slice(wordStart - 1, end + 1))); + start = wordStart - 1; + continue; + } + const word = masked.slice(wordStart, wordEnd); + if (word === "context" && !group && masked[end] === ")") { + contexts.push(splitTopLevel(masked, wordEnd + 1, end).map(([from, to]) => { + const name = identifierAt(masked, skipSpace(masked, from)); + const colon = name ? skipSpace(masked, name.end) : -1; + if (!name || masked[colon] !== ":") return { type: collapse(code.slice(from, to)) }; + const type = collapse(code.slice(colon + 1, to)); + return name.name === "_" ? { type } : { name: name.name, type }; + })); + start = wordStart; + continue; + } + if (wordEnd !== end + 1 || !MODIFIERS.includes(word)) break; + modifiers.push(word); + start = wordStart; + } + return { annotations: annotations.reverse(), names, modifiers, contexts: contexts.reverse().flat() }; +} + +type Declaration = Omit & { composable: boolean }; + +/** The declaration of the top-level `fun` at `offset`; undefined for anonymous functions, `fun interface` and malformed ones. */ +function declaration(code: string, masked: string, offset: number): Declaration | undefined { + let index = skipSpace(masked, offset + 3); + let typeParameters: string[] = []; + if (masked[index] === "<") { + const close = angleClosing(masked, index); + if (close < 0) return undefined; + typeParameters = splitTopLevel(masked, index + 1, close).map(([from, to]) => collapse(code.slice(from, to))); + index = skipSpace(masked, close + 1); + } + const receiverStart = index; + let receiverEnd = -1; + let name: { name: string; end: number } | undefined; + if (masked[index] === "(") { + const close = closing(masked, index); + if (close < 0) return undefined; + let dot = skipSpace(masked, close + 1); + if (masked[dot] === "?") dot++; + if (masked[dot] !== ".") return undefined; + receiverEnd = dot; + name = identifierAt(masked, dot + 1); + } else { + for (;;) { + const segment = identifierAt(masked, index); + if (!segment) return undefined; + let end = segment.end; + if (masked[end] === "<") { + const close = angleClosing(masked, end); + if (close < 0) return undefined; + end = close + 1; + } + if (masked[end] === "?") end++; + if (masked[end] === ".") { + receiverEnd = end; + index = end + 1; + continue; + } + if (end !== segment.end) return undefined; + name = segment; + break; + } + } + if (!name) return undefined; + const open = skipSpace(masked, name.end); + if (masked[open] !== "(") return undefined; + const close = closing(masked, open); + if (close < 0) return undefined; + const parameters: KotlinParameter[] = []; + for (const [from, to] of splitTopLevel(masked, open + 1, close)) { + const parsed = parameter(code, masked, from, to); + if (!parsed) return undefined; + parameters.push(parsed); + } + const { annotations, names, modifiers, contexts } = prefix(code, masked, offset); + const visibility = (["private", "internal", "protected"] as const).find((modifier) => modifiers.includes(modifier)) ?? "public"; + return { + name: name.name, + offset, + visibility, + ...receiverEnd >= 0 ? { receiver: collapse(code.slice(receiverStart, receiverEnd)) } : {}, + typeParameters, + ...contexts.length > 0 ? { contexts } : {}, + parameters, + previews: names.some((annotation) => annotation.includes("Preview")), + annotations, + composable: names.includes("Composable"), + }; +} + +function packageOf(masked: string): string { + return /^[ \t]*package[ \t]+([\p{L}\p{Nd}_.`]+)/mu.exec(masked)?.[1]?.replaceAll("`", "") ?? ""; +} + +/** Top-level `@Composable` functions of a Kotlin file, in source order. */ +export function composableDeclarations(text: string, file: string): ComposableDeclaration[] { + const { code, masked } = scan(text); + const packageName = packageOf(masked); + const declarations: ComposableDeclaration[] = []; + let depth = 0; + let line = 1; + let counted = 0; + for (let index = 0; index < masked.length; index++) { + const char = masked[index]; + if (char === "{") depth++; + else if (char === "}") depth = Math.max(0, depth - 1); + else if (char === "`") { + const end = masked.indexOf("`", index + 1); + if (end > 0) index = end; + } else if (depth === 0 && masked.startsWith("fun", index) && !identifierPart(masked[index - 1]) && !identifierPart(masked[index + 3])) { + const found = declaration(code, masked, index); + if (!found?.composable) continue; + const { composable: _, ...composable } = found; + for (; counted < index; counted++) if (text[counted] === "\n") line++; + declarations.push({ ...composable, packageName, file, line }); + } + } + return declarations; +} + +const REFERENCE_FORMS = "pkg.Name, Name, path/to/File.kt#Name, path/to/File.kt:42 or path/to/File.kt#Name:42"; + +/** Splits a dotted name outside backticks, removing them. */ +function dotted(value: string): string[] | undefined { + const segments: string[] = []; + let index = 0; + for (;;) { + const segment = identifierAt(value, index); + if (!segment) return undefined; + segments.push(segment.name); + if (segment.end === value.length) return segments; + if (value[segment.end] !== ".") return undefined; + index = segment.end + 1; + } +} + +function lineNumber(value: string, reference: string): number { + const line = Number(value); + if (!Number.isSafeInteger(line) || line < 1) throw new Error(`Invalid line ${value} in composable reference "${reference}"`); + return line; +} + +/** + * Parses an agent's composable reference: `pkg.sub.Name`, `Name`, `path/to/File.kt#Name`, `path/to/File.kt:42` (the composable + * at that line) or `path/to/File.kt#Name:42`. Throws for anything else. + */ +export function parseReference(reference: string): ComposableReference { + const value = reference.trim(); + const invalid = () => new Error(`Unrecognized composable reference "${reference}": use ${REFERENCE_FORMS}`); + const hash = value.lastIndexOf("#"); + if (hash >= 0) { + const file = value.slice(0, hash).trim(); + const match = /^(.*?)(?::(\d+))?$/s.exec(value.slice(hash + 1).trim())!; + const name = dotted(match[1]!.trim()); + if (!file || name?.length !== 1) throw invalid(); + return { file, name: name[0]!, ...match[2] ? { line: lineNumber(match[2], reference) } : {} }; + } + const located = /^(.+\.kts?):(\d+)$/s.exec(value); + if (located) return { file: located[1]!.trim(), name: "", line: lineNumber(located[2]!, reference) }; + const segments = /\.kts?$/.test(value) ? undefined : dotted(value); + if (!segments) throw invalid(); + const name = segments.pop()!; + return segments.length > 0 ? { packageName: segments.join("."), name } : { name }; +} + +/** The JVM class holding a file's top-level functions: `@file:JvmName`, else `Kt`, in the file's package. */ +export function jvmFacade(text: string, file: string): string { + const { masked } = scan(text); + const annotation = /@file\s*:\s*(?:kotlin\s*\.\s*jvm\s*\.\s*)?JvmName\s*\(\s*(?:name\s*=\s*)?"/.exec(masked); + let facade: string; + if (annotation) { + const start = annotation.index + annotation[0].length; + facade = text.slice(start, masked.indexOf('"', start)); + } else { + const base = path.basename(file).replace(/\.[^.]*$/, "").replace(/[^\p{L}\p{Nd}_]/gu, "_"); + // Kotlin capitalizes ASCII letters only. + facade = `${base.replace(/^[a-z]/, (letter) => letter.toUpperCase()) || "_"}Kt`; + } + const packageName = packageOf(masked); + return packageName ? `${packageName}.${facade}` : facade; +} + +/** `type` without leading annotations and `suspend`. */ +function bareType(type: string): string { + let value = type.trim(); + for (;;) { + if (value.startsWith("@")) { + const end = annotationEnd(value, 0); + if (end < 0) return value; + value = value.slice(end).trim(); + } else if (/^suspend\b/.test(value)) value = value.slice(7).trim(); + else return value; + } +} + +/** The offset of `->` outside brackets in a type, or -1. */ +function topLevelArrow(type: string): number { + let depth = 0; + for (let index = 0; index < type.length; index++) { + const char = type[index]; + if (char === "-" && type[index + 1] === ">") { + if (depth === 0) return index; + index++; + } else if (char === "(" || char === "<" || char === "[") depth++; + else if (char === ")" || char === ">" || char === "]") depth--; + } + return -1; +} + +const PLACEHOLDERS: Record = { + Int: "0", + Long: "0L", + Short: "0.toShort()", + Byte: "0.toByte()", + Float: "0f", + Double: "0.0", + Boolean: "false", + Char: "'a'", + Modifier: "androidx.compose.ui.Modifier", + Color: "androidx.compose.ui.graphics.Color.Gray", + Dp: "androidx.compose.ui.unit.Dp(16f)", + List: "emptyList()", + Collection: "emptyList()", + Iterable: "emptyList()", + Set: "emptySet()", + Map: "emptyMap()", + MutableList: "mutableListOf()", + MutableSet: "mutableSetOf()", + MutableMap: "mutableMapOf()", +}; + +function kotlinString(value: string): string { + return `"${value.replace(/[\\"$]/g, "\\$&").replaceAll("\n", "\\n").replaceAll("\r", "\\r").replaceAll("\t", "\\t")}"`; +} + +/** A value for a required parameter an agent did not pass: an empty, neutral value of its type, or undefined when none is known. */ +export function placeholder(parameter: KotlinParameter): string | undefined { + let type = bareType(parameter.type); + for (;;) { + const arrow = topLevelArrow(type); + if (arrow >= 0) { + if (!/^(kotlin\.)?Unit$/.test(type.slice(arrow + 2).trim())) return undefined; + // The parameter list of `(A, B) -> Unit`, `Receiver.() -> Unit`; a receiver is the lambda's `this`. + const inputs = type.slice(0, arrow).trim(); + const open = opening(inputs, inputs.length - 1); + if (open < 0) return undefined; + const arity = splitTopLevel(inputs, open + 1, inputs.length - 1).length; + return arity === 0 ? "{}" : `{ ${Array(arity).fill("_").join(", ")} -> }`; + } + if (type.endsWith("?")) return "null"; + // Parentheses only group: `(() -> Unit)` is a function type. + if (!type.startsWith("(") || closing(type, 0) !== type.length - 1) break; + type = bareType(type.slice(1, -1)); + } + const simple = type.replace(/<[\s\S]*$/, "").trim().split(".").at(-1)!; + if (simple === "String" || simple === "CharSequence") return kotlinString(parameter.name); + return Object.hasOwn(PLACEHOLDERS, simple) ? PLACEHOLDERS[simple] : undefined; +} + +/** A name as Kotlin source: backticked when it is not a plain identifier or is a hard keyword. */ +function identifier(name: string): string { + return /^[\p{L}_][\p{L}\p{Nd}_]*$/u.test(name) && !HARD_KEYWORDS.includes(name) ? name : `\`${name}\``; +} + +function qualified(declaration: ComposableDeclaration): string { + return [...declaration.packageName ? declaration.packageName.split(".") : [], declaration.name].map(identifier).join("."); +} + +/** The name a type parameter declaration introduces: `reified T : Any` is `T`. */ +function typeParameterName(declaration: string): string { + return bareType(declaration).replace(/^((reified|in|out)\s+)+/, "").split(/[\s:]/)[0]!.replaceAll("`", ""); +} + +function mentions(type: string, name: string): boolean { + return new RegExp(`(^|[^\\p{L}\\p{Nd}_])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\p{L}\\p{Nd}_])`, "u").test(type); +} + +const signature = (parameters: readonly KotlinParameter[]) => parameters.map((parameter) => `${parameter.name}: ${parameter.type}`).join(", "); +const passArguments = (parameters: readonly KotlinParameter[]) => + `Pass arguments: {${parameters.map((parameter) => `${JSON.stringify(parameter.name)}: ""`).join(", ")}}.`; + +/** Java's `String.valueOf(float)` for the values an agent passes. */ +function javaFloat(value: number): string { + return Number.isInteger(value) ? value.toFixed(1) : String(value); +} + +const PRIMITIVE_ARRAYS: Record = { + Int: "intArrayOf", Long: "longArrayOf", Short: "shortArrayOf", Byte: "byteArrayOf", Float: "floatArrayOf", Double: "doubleArrayOf", + Boolean: "booleanArrayOf", Char: "charArrayOf", +}; + +/** A vararg argument in named form, which Kotlin only accepts spread: `"a", "b"` is `*arrayOf("a", "b")`. */ +function spread(parameter: KotlinParameter, value: string): string { + if (value.trim().startsWith("*")) return value; + const type = bareType(parameter.type).replace(/^kotlin\./, ""); + // The value ends its line: a trailing `// comment` in it must not swallow the closing parenthesis. + return `*${Object.hasOwn(PRIMITIVE_ARRAYS, type) ? PRIMITIVE_ARRAYS[type] : "arrayOf"}(${value}\n)`; +} + +const SCOPES: Record = { ColumnScope: "Column", RowScope: "Row", BoxScope: "Box" }; +const LAYOUT_PACKAGE = "androidx.compose.foundation.layout"; + +/** The argument key an agent supplies a context by: its name, or its type when it has none. */ +const contextKey = (context: KotlinContext) => context.name ?? context.type; +const contextSignature = (contexts: readonly KotlinContext[]) => + contexts.map((context) => (context.name ? `${context.name}: ${context.type}` : context.type)).join(", "); + +/** A public no-argument composable calling `target` with the agent's arguments, for the preview compile of its file. */ +export function previewWrapper(request: WrapperRequest): PreviewWrapper { + const { target, theme, configuration } = request; + const problems: string[] = []; + const contexts = target.contexts ?? []; + const known = new Set([...target.parameters.map((parameter) => parameter.name), ...contexts.map(contextKey)]); + const unknown = Object.keys(request.arguments).filter((name) => !known.has(name)); + if (unknown.length > 0) { + problems.push(`\`${target.name}\` has no parameter named ${unknown.join(", ")}; ` + + (target.parameters.length > 0 ? `its parameters are ${signature(target.parameters)}` : "it takes no parameters") + + (contexts.length > 0 ? ` and its contexts are ${contextSignature(contexts)}.` : ".")); + } + const absent = contexts.filter((context) => !Object.hasOwn(request.arguments, contextKey(context))); + if (absent.length > 0) { + problems.push(`\`${target.name}\` needs context ${contextSignature(absent)}, which the preview supplies with \`with(…)\` from arguments ` + + `keyed by context name (by type for a context receiver). Pass arguments: {${absent.map((context) => `${JSON.stringify(contextKey(context))}: ""`).join(", ")}}.`); + } + const empty = Object.entries(request.arguments).filter(([, value]) => !value.trim()).map(([name]) => name); + if (empty.length > 0) problems.push(`Arguments ${empty.join(", ")} are empty: pass a Kotlin expression for each.`); + + let scope: string | undefined; + if (target.receiver !== undefined) { + const receiver = target.receiver.replace(/\?$/, ""); + const simple = receiver.split(".").at(-1)!; + scope = Object.hasOwn(SCOPES, simple) && (receiver === simple || receiver === `${LAYOUT_PACKAGE}.${simple}`) ? SCOPES[simple] : undefined; + if (!scope) { + problems.push(`\`${target.name}\` extends ${target.receiver}: only ColumnScope, RowScope and BoxScope receivers can be previewed.`); + } + } + + // A type parameter is inferred from the arguments the agent passes; placeholders cannot determine it. + const passed = target.parameters.filter((parameter) => Object.hasOwn(request.arguments, parameter.name)); + const unresolved = target.typeParameters.map(typeParameterName) + .filter((name) => !passed.some((parameter) => mentions(parameter.type, name))); + for (const name of unresolved) { + const users = target.parameters.filter((parameter) => mentions(parameter.type, name)); + problems.push(users.length > 0 + ? `\`${target.name}\` has type parameter ${name}, which only an argument for a parameter whose type uses it determines ` + + `(${signature(users)}). ${passArguments(users)}` + : `\`${target.name}\` cannot be previewed: no parameter determines its type parameter ${name}.`); + } + + const values: PreviewWrapper["arguments"] = []; + const missing: KotlinParameter[] = []; + for (const parameter of target.parameters) { + if (Object.hasOwn(request.arguments, parameter.name)) { + const value = request.arguments[parameter.name]!; + values.push({ name: parameter.name, value: parameter.vararg ? spread(parameter, value) : value, source: "argument" }); + continue; + } + if (parameter.defaultValue !== undefined || parameter.vararg) continue; + if (unresolved.some((name) => mentions(parameter.type, name))) continue; // reported above + const value = placeholder(parameter); + if (value === undefined) missing.push(parameter); + else values.push({ name: parameter.name, value, source: "placeholder" }); + } + if (missing.length > 0) { + problems.push(`\`${target.name}\` needs a value for ${signature(missing)}: no placeholder fits ` + + `${missing.length === 1 ? "its type" : "their types"}. ${passArguments(missing)}`); + } + + let themeCall: string | undefined; + if (theme) { + const content = theme.parameters.at(-1); + const others = theme.parameters.slice(0, -1); + const themeArguments: string[] = []; + const unfilled: KotlinParameter[] = []; + for (const parameter of others) { + if (parameter.defaultValue !== undefined || parameter.vararg) continue; + const value = theme.typeParameters.length > 0 ? undefined : placeholder(parameter); + if (value === undefined) unfilled.push(parameter); + else themeArguments.push(`${identifier(parameter.name)} = ${value}`); + } + if (!content || topLevelArrow(bareType(content.type)) < 0 || !/@(androidx\.compose\.runtime\.)?Composable\b/.test(content.type)) { + problems.push(`Theme \`${theme.name}\` cannot wrap a preview: its last parameter must be a composable lambda (content: @Composable () -> Unit).`); + } else if (theme.receiver !== undefined) { + problems.push(`Theme \`${theme.name}\` cannot wrap a preview: it extends ${theme.receiver}.`); + } else if (theme.contexts !== undefined) { + problems.push(`Theme \`${theme.name}\` cannot wrap a preview: it needs context ${contextSignature(theme.contexts)}.`); + } else if (theme.visibility === "private" && theme.file !== target.file) { + problems.push(`Theme \`${theme.name}\` cannot wrap a preview: it is private to ${theme.file}.`); + } else if (unfilled.length > 0) { + problems.push(`Theme \`${theme.name}\` cannot wrap a preview: no placeholder fits its required parameters ${signature(unfilled)}.`); + } else themeCall = `${qualified(theme)}${themeArguments.length > 0 ? `(${themeArguments.join(", ")})` : ""}`; + } + + for (const [key, value] of [["widthDp", configuration.widthDp], ["heightDp", configuration.heightDp], ["apiLevel", configuration.apiLevel]] as const) { + if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) problems.push(`${key} must be a positive integer, not ${value}.`); + } + if (configuration.fontScale !== undefined && !(configuration.fontScale > 0)) problems.push(`fontScale must be positive, not ${configuration.fontScale}.`); + if (problems.length > 0) throw new WrapperError(problems.join(" ")); + + // Preview attributes as Kotlin source and as preview discovery reports them (String.valueOf of the annotation's values). + const attributes: [string, string, string][] = [["name", kotlinString(target.name), target.name]]; + const add = (name: string, source: string, reported: string) => attributes.push([name, source, reported]); + if (configuration.widthDp !== undefined) add("widthDp", String(configuration.widthDp), String(configuration.widthDp)); + if (configuration.heightDp !== undefined) add("heightDp", String(configuration.heightDp), String(configuration.heightDp)); + if (configuration.showBackground !== undefined) add("showBackground", String(configuration.showBackground), String(configuration.showBackground)); + if (configuration.backgroundColor !== undefined) { + const argb = configuration.backgroundColor >>> 0; + add("backgroundColor", `0x${argb.toString(16).toUpperCase().padStart(8, "0")}L`, String(argb)); + } + if (configuration.darkTheme) add("uiMode", "0x21", "33"); + if (configuration.fontScale !== undefined) add("fontScale", `${javaFloat(configuration.fontScale)}f`, javaFloat(configuration.fontScale)); + if (configuration.locale !== undefined) add("locale", kotlinString(configuration.locale), configuration.locale); + if (configuration.device !== undefined) add("device", kotlinString(configuration.device), configuration.device); + if (configuration.showSystemUi !== undefined) add("showSystemUi", String(configuration.showSystemUi), String(configuration.showSystemUi)); + if (configuration.apiLevel !== undefined) add("apiLevel", String(configuration.apiLevel), String(configuration.apiLevel)); + + const key = JSON.stringify([ + request.facade, + `${target.name}:${target.line}`, + Object.entries(request.arguments).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)), + theme ? `${qualified(theme)}:${theme.line}` : null, + Object.entries(configuration).filter(([, value]) => value !== undefined).sort(([left], [right]) => (left < right ? -1 : 1)), + request.annotate, + ]); + const name = `ElideAgentPreview_${createHash("sha256").update(key).digest("hex").slice(0, 10)}`; + + // Qualified, because an explicit import of the same name takes precedence over the file's own declaration; an extension's + // call cannot be qualified. + const callee = target.receiver === undefined ? qualified(target) : identifier(target.name); + // Each argument ends its line, so a trailing `// comment` in an agent's expression comments out nothing else. + const call = (pad: string) => (values.length === 0 ? `${pad}${callee}()` : [ + `${pad}${callee}(`, + ...values.map((value, index) => `${pad} ${index > 0 ? ", " : ""}${identifier(value.name)} = ${value.value}`), + `${pad})`, + ].join("\n")); + const blocks: ((pad: string) => string)[] = [ + ...themeCall !== undefined ? [(pad: string) => `${pad}${themeCall} {`] : [], + ...scope !== undefined ? [(pad: string) => `${pad}${LAYOUT_PACKAGE}.${scope} {`] : [], + // `with` makes the value an implicit receiver, which resolves both context parameters and context receivers. + ...contexts.map((context) => (pad: string) => `${pad}with(\n${pad} ${request.arguments[contextKey(context)]}\n${pad}) {`), + ]; + const body = [ + ...blocks.map((block, depth) => block(" ".repeat(depth + 1))), + call(" ".repeat(blocks.length + 1)), + ...blocks.map((_, depth) => `${" ".repeat(blocks.length - depth)}}`), + ]; + const lines = [ + "", + "", + "// Generated by Elide for an agent's Compose preview; never saved.", + ...request.annotate ? [`@androidx.compose.ui.tooling.preview.Preview(${attributes.map(([key, source]) => `${key} = ${source}`).join(", ")})`] : [], + "@androidx.compose.runtime.Composable", + `fun ${name}() {`, + ...body, + "}", + "", + ]; + return { + name, + methodFQN: `${request.facade}.${name}`, + text: lines.join("\n"), + previewParams: Object.fromEntries(attributes.map(([key, , reported]) => [key, reported])), + arguments: values, + }; +} diff --git a/packages/vscode/src/composeBackend.ts b/packages/vscode/src/composeBackend.ts index cebc88f..7ae6087 100644 --- a/packages/vscode/src/composeBackend.ts +++ b/packages/vscode/src/composeBackend.ts @@ -2,8 +2,10 @@ import { createHash, randomBytes } from "node:crypto"; import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { composePreviewInputs, type AndroidTargetModel, type ProjectModel, type TimelineNode } from "@elide/ide-core"; +import { composePreviewAnnotations, composePreviewInputs, type AndroidTargetModel, type ProjectModel, type TimelineNode } from "@elide/ide-core"; import { ComposeCompiler } from "./composeCompiler.js"; +import type { ComposeSources } from "./composeCompiler.js"; +import { StaleBuildRecordError } from "./composeProcessing.js"; import { ComposeRenderer, helperDigest, isPreviewImage, type CompiledPreview, type PreviewImage } from "./composeRender.js"; import type { ComposeRuntime } from "./composeRuntime.js"; import type { ComposeWorker } from "./composeWorker.js"; @@ -23,11 +25,17 @@ function previewImages(value: unknown): value is PreviewImage[] { return Array.isArray(value) && value.every(isPreviewImage) && new Set(value.map((image) => image.id)).size === value.length; } +/** A card failed to render: such a gallery is never replayed, since the failure may not recur (a timeout on a busy machine). */ +function failed(images: readonly PreviewImage[]): boolean { + return images.some((image) => image.error !== undefined); +} + const STORED_GALLERIES = 48; /** * Galleries by the exact inputs that rendered them: build fingerprint, compiler source text, previewed * file and renderer. Shared by every backend and window; replaying one skips compiling and rendering. + * Galleries with failed cards are neither stored nor replayed. */ export class ImageStore { constructor(private readonly directory: string) {} @@ -35,13 +43,14 @@ export class ImageStore { async get(key: string): Promise { try { const images: unknown = JSON.parse(await readFile(path.join(this.directory, `${key}.json`), "utf8")); - return previewImages(images) ? images : undefined; + return previewImages(images) && !failed(images) ? images : undefined; } catch { return undefined; } } async set(key: string, images: PreviewImage[]): Promise { + if (failed(images)) return; await mkdir(this.directory, { recursive: true }); const temporary = path.join(this.directory, `${key}.${randomBytes(6).toString("hex")}.tmp`); try { @@ -78,31 +87,152 @@ export interface CompileMessage { column?: number; } +/** Generated preview functions and the previews to render instead of every preview `sourceFile` declares. */ +export interface PreviewSelection { + /** Text appended to program files for this preview only (generated wrappers), by absolute path; never saved. */ + appendices: ReadonlyMap; + /** The previews to render, in order (generated wrappers and existing `@Preview` functions). */ + previews: readonly CompiledPreview[]; + /** Program files besides `sourceFile` whose previews are selected (komodo hosts them in one session). */ + files: readonly string[]; +} + export interface PreviewRequest { /** Open editor text of the project's sources, by path. */ documents: ReadonlyMap; sourceFile: string; packageName: string; + /** Require inspection support: snapshot source instrumentation or live measured layout metadata. */ + inspect?: boolean; + /** Render only these previews, compiled with their appendices; absent, every preview of `sourceFile`. */ + selection?: PreviewSelection; +} + +/** The texts `request` compiles: its documents, each appendix appended to its file's editor text or else its saved text. */ +export async function previewDocuments(request: PreviewRequest): Promise> { + const appendices = request.selection?.appendices; + if (!appendices?.size) return request.documents; + const documents = new Map(request.documents); + for (const [file, text] of appendices) documents.set(file, (request.documents.get(file) ?? await readFile(file, "utf8")) + text); + return documents; +} + +/** The preview functions a selection renders, sorted and distinct; undefined renders every preview of the file. */ +function selectedMethods(request: PreviewRequest): string[] | undefined { + return request.selection && [...new Set(request.selection.previews.map((preview) => preview.methodFQN))].sort(); +} + +/** Browser key identity and modifier state; composition and clipboard text are not keyboard events. */ +export interface PreviewKeyEvent { + action: "down" | "up"; + key: string; + code: string; + shiftKey: boolean; + ctrlKey: boolean; + altKey: boolean; + metaKey: boolean; + repeat: boolean; +} + +/** Named keys accepted by the native keyboard capability, in addition to printable Unicode code points. */ +export const PREVIEW_KEY_NAMES = [ + "Backspace", "Delete", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Enter", "Tab", + "Escape", "Home", "End", "PageUp", "PageDown", "Insert", "Shift", "Control", "Alt", "Meta", + "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", +] as const; + +/** Validate messages before using the same input handler as the extension API. */ +export function isPreviewKeyEvent(value: unknown): value is PreviewKeyEvent { + if (!value || typeof value !== "object") return false; + const event = value as Record; + if ((event.action !== "down" && event.action !== "up") || typeof event.key !== "string" || typeof event.code !== "string" + || !["shiftKey", "ctrlKey", "altKey", "metaKey", "repeat"].every((name) => typeof event[name] === "boolean")) return false; + const point = event.key.codePointAt(0); + return PREVIEW_KEY_NAMES.some((key) => key === event.key) + || [...event.key].length === 1 && point! >= 32 && point !== 127 && !(point! >= 0xd800 && point! <= 0xdfff); +} + +/** A gallery's provenance travels with its pixels, never with mutable backend-wide state. */ +export interface PreviewGallery { + images: PreviewImage[]; + verified: boolean; + reused: boolean; +} + +interface CachedGallery { + images: PreviewImage[]; + verified: boolean; + fingerprint: string; + sources: string; +} + +/** Workspaces of older versions name no owner; untouched this long, no running host still uses one. */ +const UNOWNED_WORKSPACE_AGE_MS = 24 * 60 * 60 * 1000; + +function running(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // Another user's process. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** + * Remove the backend workspaces in `root` of extension hosts that have exited. A host that is closed, reloaded or crashes + * cannot remove its own, and each holds copies of a project's classes and its compiler caches. + */ +async function sweepWorkspaces(root: string): Promise { + await Promise.all((await readdir(root, { withFileTypes: true })).map(async (entry) => { + if (!entry.isDirectory()) return; + const directory = path.join(root, entry.name); + const owner = /^elide-compose-(\d+)-[A-Za-z0-9]{6}$/.exec(entry.name); + if (owner) { + const pid = Number(owner[1]); + if (!Number.isSafeInteger(pid) || pid <= 0 || running(pid)) return; + } else { + if (!/^elide-compose-[A-Za-z0-9]{6}$/.test(entry.name)) return; + // Renderers and compilers add entries to the workspace while their host runs. + const files = [directory, ...(await readdir(directory)).map((name) => path.join(directory, name))]; + const changed = Math.max(...await Promise.all(files.map((file) => stat(file).then((stats) => stats.mtimeMs, () => Infinity)))); + if (Date.now() - changed < UNOWNED_WORKSPACE_AGE_MS) return; + } + await rm(directory, { recursive: true, force: true }); + }).map((sweep) => sweep.catch(() => {}))); } /** * Warm Compose preview infrastructure for one Android resource context: render JVMs, Elide compiler * workers and the current compiler. It outlives preview panels (keep-alive) and may start before - * one opens (prewarm). Lock order: `compiling`, then `rendering`; renders only read published classes. + * one opens (prewarm). Lock order: `configuring`, `compiling`, then `rendering`; renders only read published classes. */ export class ComposeBackend { private readonly controller = new AbortController(); private readonly workers = new Map(); + private readonly configuring = new Queue(); private readonly compiling = new Queue(); private readonly rendering = new Queue(); private directory: Promise | undefined; private renderer: ComposeRenderer | undefined; - private rendererSetup: { runtime: ComposeRuntime; java: string } | undefined; - /** Renderer code and JDK the next render uses; part of every stored gallery key. */ + /** + * `weight` sizes a new renderer's first JVMs; its renders resize the pool to each gallery. It keeps the last gallery's + * weight, so a renderer replacing a failed one starts as many JVMs as the gallery needs. + */ + private rendererSetup: { runtime: ComposeRuntime; java: string; weight: number; identity: Promise } | undefined; + /** + * Renderer code and JDK the next render uses, to look up stored galleries. A gallery is stored under its own renderer's + * identity (`rendererSetup`, changed only under the render lock): a render queued before a JDK change uses the old JVMs. + */ private rendererIdentity: Promise | undefined; private current: ComposeCompiler | undefined; - /** The current render JVMs completed a render, so their JIT and layoutlib are warm. */ - private rendered = false; + /** Verification of the published classes, changed only under the render lock. */ + private publishedVerified = false; + /** Unverified galleries stay in memory until processing confirms their exact inputs. */ + private readonly galleries = new Map(); + /** Renderer initialization belongs to a compiler's private classpath, not just the JVM process. */ + private renderedCompiler: ComposeCompiler | undefined; + private renderedInstrumented = false; /** Open preview panels using this backend. */ users = 0; disposed = false; @@ -113,14 +243,28 @@ export class ComposeBackend { get compiler(): ComposeCompiler | undefined { return this.current; } - /** The layoutlib runtime the render JVMs run (once started), which the Komodo engine's layoutlib host uses too. */ - protected get runtime(): ComposeRuntime | undefined { return this.rendererSetup?.runtime; } + /** The backend's lifetime: aborted when it is disposed. */ + protected get signal(): AbortSignal { return this.controller.signal; } + + /** The next refresh first replaces seeded build classes by this backend's own compile (see {@link warm}). */ + get needsWarmup(): boolean { return this.current?.needsWarmup ?? false; } + + /** Annotation processing must confirm what an edit compiled (see {@link verify}). */ + get processingRequired(): boolean { return this.current?.processingRequired ?? false; } - /** The previews the last render discovered (empty before one, or after a stored gallery). */ - protected get discovery(): readonly CompiledPreview[] { return this.renderer?.discovery ?? []; } + /** Pointer input on a live card (image pixels; `dx`/`dy` scroll by); false when this backend renders the card as a snapshot. */ + pointer(_imageId: string, _action: "down" | "up" | "move" | "scroll", _x: number, _y: number, _dx?: number, _dy?: number): boolean { return false; } - /** Pointer input on a live card (image pixels); false when this backend renders the card as a snapshot. */ - pointer(_imageId: string, _action: "down" | "up" | "move", _x: number, _y: number): boolean { return false; } + /** Whether this live card accepts browser keyboard events; snapshots and older daemons do not. */ + keyboardSupported(_imageId: string): boolean { return false; } + + keyboard(_imageId: string, _event: PreviewKeyEvent): boolean { return false; } + + /** Release held keys and native focus for this card only. */ + blur(_imageId: string): boolean { return false; } + + /** A scene is being replaced, removed or disconnected; release focus before accepting further input. */ + onInputReset(_listener: (imageId?: string) => void): () => void { return () => {}; } /** Cards updated after `preview` resolved (live engines push frames); the snapshot engine never pushes. */ onImages(_listener: (images: PreviewImage[]) => void): () => void { return () => {}; } @@ -128,50 +272,113 @@ export class ComposeBackend { /** Compile messages for the panel's diagnostics (live engines compile edits themselves); the snapshot engine reports none. */ onCompile(_listener: (outcome: { failed: boolean; messages: CompileMessage[] }) => void): () => void { return () => {}; } - /** Start (or keep) the render JVMs for this JDK and runtime. */ - startRenderer(runtime: ComposeRuntime, java: string): Promise { - this.rendererIdentity = helperDigest(runtime, this.helperDirectory) + /** + * A live engine's session ended on its own (a language-server reload or restart, a crash): the panel previews again to reconnect. + * The snapshot engine has no session to lose. + */ + onSessionLost(_listener: () => void): () => void { return () => {}; } + + /** + * `previous` is being replaced and its directory deleted, under the render lock: a live engine stops compiling and rendering + * against its inputs (its published class copies) here, and the deletion waits for what it returns. Snapshot renders already + * hold the render lock. + */ + protected retire(_previous: ComposeCompiler): Promise | void {} + + /** + * An unsaved edit of `file` for a live session, sent as soon as the editor reports it: a live engine applies it without waiting for + * the panel's refresh (which may still be confirming the open's build). The snapshot engine renders edits in its refresh only. + */ + edit(_file: string, _text: string): void {} + + /** Whether the preview compiles `file`'s editor text itself; an edit of another source needs it saved and built. */ + compiles(file: string): boolean { + return this.compiler?.supports(file) ?? false; + } + + /** `images` (a gallery `preview` returned) with the frames a live engine rendered since; the snapshot engine's are final. */ + latestImages(images: readonly PreviewImage[]): readonly PreviewImage[] { + return images; + } + + /** + * Start (or keep) the render JVMs for this JDK and runtime, about one per few of the source's `previews`, or of the last + * gallery's render weight when that is larger. + */ + async startRenderer(runtime: ComposeRuntime, java: string, previews = 1): Promise { + this.controller.signal.throwIfAborted(); + const identity = helperDigest(runtime, this.helperDirectory) .then((digest) => createHash("sha256").update(`${digest}\0${java}`).digest("hex")); - this.rendererIdentity.catch(() => {}); + identity.catch(() => {}); + this.rendererIdentity = identity; return this.rendering.run(async () => { - if (this.renderer?.alive && this.rendererSetup?.java === java && this.rendererSetup.runtime.layoutlibPath === runtime.layoutlibPath) return; - this.rendererSetup = { runtime, java }; + this.controller.signal.throwIfAborted(); + // Equal JDK and runtime is equal identity: the helper sources are the extension's own. + const setup = this.rendererSetup; + const same = setup?.java === java && setup.runtime.layoutlibPath === runtime.layoutlibPath + && setup.runtime.rendererClassPath.join(path.delimiter) === runtime.rendererClassPath.join(path.delimiter) ? setup : undefined; + if (this.renderer?.alive && same) return; + this.rendererSetup = { runtime, java, weight: Math.max(previews, same?.weight ?? 0), identity }; await this.createRenderer(); }); } /** * Derive a compiler from the build's current outputs. An equivalent existing compiler, warm - * incremental state included, is kept. Without `verified`, the outputs may predate the sources. - * Returns whether the published classes changed, so images rendered before are stale. + * incremental state included, is kept; so is one whose build changed only input content, such as + * linked resources or R classes (see {@link ComposeCompiler.rebase}). Without `verified`, the outputs + * may predate the sources. Returns whether the published classes or their inputs changed, so images + * rendered before are stale. */ async configure(build: PreviewBuild, documents: ReadonlyMap, verified: boolean): Promise { - const inputs = await composePreviewInputs(build.model, build.artifact, build.models); - const candidate = await ComposeCompiler.create(build.bin, build.model.root, inputs, await this.workDirectory(), this.controller.signal, this.workers); - let adopted = false; - try { - // Equivalence needs no lock: a compile in progress (warming, for example) keeps it. - const kept = this.current; - if (kept?.sameBuild(candidate) && kept.ready) return false; - return await this.compiling.run(async () => { - const previous = this.current; - if (previous?.sameBuild(candidate)) return !previous.ready && await this.seed(previous, documents, verified); - // Swap only between render generations: a running render reads the previous classes. - await this.rendering.run(async () => { - this.current = candidate; - adopted = true; - previous?.dispose(); - await previous?.remove(); + this.controller.signal.throwIfAborted(); + return this.configuring.run(async () => { + this.controller.signal.throwIfAborted(); + const inputs = await composePreviewInputs(build.model, build.artifact, build.models); + const candidate = await ComposeCompiler.create(build.bin, build.model.root, inputs, await this.workDirectory(), this.controller.signal, this.workers); + let adopted = false; + try { + this.controller.signal.throwIfAborted(); + // Equivalence needs no compile lock: an update in progress keeps the same compiler. + const kept = this.current; + if (kept?.sameBuild(candidate) && kept.ready) return false; + return await this.compiling.run(async () => { + this.controller.signal.throwIfAborted(); + const previous = this.current; + if (previous?.sameBuild(candidate)) return !previous.ready && await this.seed(previous, documents, verified); + // Between render generations, like a swap: renders read the inputs and key galleries by the fingerprint. + const rebased = previous?.ready && await this.rendering.run(async () => { + this.controller.signal.throwIfAborted(); + if (!previous.rebase(candidate)) return false; + // Processing confirms the galleries of the new inputs again. + this.publishedVerified = !previous.processingRequired; + return true; + }); + if (rebased) return true; + // Swap only between render generations: a running render reads the previous classes. + await this.rendering.run(async () => { + this.controller.signal.throwIfAborted(); + this.current = candidate; + this.publishedVerified = false; + adopted = true; + if (previous) await this.retire(previous); + previous?.dispose(); + // Background warm-ups read the previous classes outside the render lock. + this.renderer?.abandonWarmups(); + await previous?.remove(); + }); + // Workers of the previous toolchain, distribution or working directory serve no compiler any more. + candidate.pruneWorkers(); + await this.seed(candidate, documents, verified); + return true; }); - await this.seed(candidate, documents, verified); - return true; - }); - } finally { - if (!adopted) { - candidate.dispose(); - await candidate.remove(); + } finally { + if (!adopted) { + candidate.dispose(); + await candidate.remove(); + } } - } + }); } /** @@ -179,90 +386,188 @@ export class ComposeBackend { * what changed and render. Without `compile`, a compiler holding no classes yet returns undefined * instead of compiling from scratch. Compiling and rendering are timed as steps of `trace`. */ - preview(request: PreviewRequest, compile?: true, trace?: TimelineNode): Promise; - preview(request: PreviewRequest, compile: boolean, trace?: TimelineNode): Promise; - async preview(request: PreviewRequest, compile = true, trace?: TimelineNode): Promise { + preview(request: PreviewRequest, compile?: true, trace?: TimelineNode): Promise; + preview(request: PreviewRequest, compile: boolean, trace?: TimelineNode): Promise; + async preview(request: PreviewRequest, compile = true, trace?: TimelineNode): Promise { for (;;) { + this.controller.signal.throwIfAborted(); const compiler = this.current; if (!compiler) throw new Error("Compose preview has no compiler inputs; build the project first."); - const sources = await compiler.sources(request.documents); + if (request.inspect && !compiler.publishedInstrumented) { + if (!compile) return undefined; + await this.warm(request); + if (this.current !== compiler) continue; + if (!compiler.publishedInstrumented) throw new Error("Layout inspection requires preview source instrumentation; this build's bytecode post-processing cannot be replayed."); + } + const sources = await compiler.sources(await previewDocuments(request)); const stored = await this.stored(compiler, sources.key, request); + this.controller.signal.throwIfAborted(); if (stored) { trace?.record("Stored gallery", 0, "succeeded", "these exact inputs rendered before"); - this.prime(request); - return stored; + void this.prime(request).catch(() => {}); + return { images: stored, verified: true, reused: true }; } if (!compile && !compiler.ready) return undefined; - if (sources.key !== compiler.publishedSources) { - const replaced = await this.compiling.run(async () => { - if (this.current !== compiler) return true; - if (compiler.publishedSources === sources.key) return false; - await (trace ? trace.run("Compile", () => compiler.update(sources)) : compiler.update(sources)); - await this.rendering.run(() => compiler.publish()); - return false; - }); - if (replaced) continue; - } + if (sources.key !== compiler.publishedSources && await this.publishSources(compiler, sources, trace)) continue; const images = await this.rendering.run(async () => { - if (this.current !== compiler) return undefined; + // Another request may have queued publication while these classes were still current. + // Check under the render lock so a caller never receives another source generation. + if (this.current !== compiler || compiler.publishedSources !== sources.key) return undefined; return trace ? await trace.run("Render", () => this.render(compiler, request)) : await this.render(compiler, request); }); if (images) return images; } } + /** Compiles and publishes `sources` unless they are published; true when another compiler replaced this one meanwhile. */ + private publishSources(compiler: ComposeCompiler, sources: ComposeSources, trace?: TimelineNode): Promise { + return this.compiling.run(async () => { + if (this.current !== compiler) return true; + if (compiler.publishedSources === sources.key) return false; + await (trace ? trace.run("Compile", () => compiler.update(sources)) : compiler.update(sources)); + await this.rendering.run(() => this.publish(compiler)); + return false; + }); + } + + /** + * Confirm processing inputs after an optimistic paint, or recover a declaration edit whose first + * compile failed against old generated code. A confirmed stored gallery needs neither operation. + * Returns whether to fetch fresh pixels, including when another window stored a confirmed gallery. + */ + async verify(request: PreviewRequest, trace?: TimelineNode): Promise { + return this.compiling.run(async () => { + this.controller.signal.throwIfAborted(); + const compiler = this.current; + if (!compiler) throw new Error("Compose preview has no compiler inputs; build the project first."); + if (!compiler.processingRequired) return false; + const sources = await compiler.sources(await previewDocuments(request)); + if (await this.stored(compiler, sources.key, request)) return true; + return this.verifyCompiler(compiler, sources, trace); + }); + } + + /** Under `compiling`; processing can overlap a render, but certification waits for publication. */ + private async verifyCompiler(compiler: ComposeCompiler, sources: ComposeSources, trace?: TimelineNode): Promise { + const changed = await (trace + ? trace.run("Verify annotation processing", () => compiler.verify(sources)) + : compiler.verify(sources)); + await this.rendering.run(async () => { + this.controller.signal.throwIfAborted(); + if (changed) await this.publish(compiler); + else if (compiler.publishedSources === sources.key) this.publishedVerified = true; + for (const [key, gallery] of this.galleries) { + if (gallery.verified || gallery.fingerprint !== compiler.fingerprint || gallery.sources !== sources.key) continue; + if (changed) this.galleries.delete(key); + else { + gallery.verified = true; + this.store.set(key, gallery.images).catch(() => {}); + } + } + }); + return changed; + } + /** * Replace seeded build classes by this compiler's own output, priming incremental compilation so the * next edit compiles warm, and render once if stored galleries left the JVMs cold. */ async warm(request: PreviewRequest): Promise { - const compiler = this.current; - if (compiler?.seeded) { - await this.compiling.run(async () => { - if (this.current !== compiler || !compiler.seeded) return; - await compiler.update(await compiler.sources(request.documents), true); - await this.rendering.run(() => compiler.publish()); - }); - } - this.prime(request); + await this.compiling.run(async () => { + this.controller.signal.throwIfAborted(); + const compiler = this.current; + if (!compiler?.needsWarmup) return; + const sources = await compiler.sources(await previewDocuments(request)); + try { + await compiler.update(sources, true); + } catch (error) { + if (!compiler.processingRequired) throw error; + await compiler.verify(sources); + } + await this.rendering.run(() => this.publish(compiler)); + // Render this private classpath, not the discarded seed/candidate, while kapt verifies it. + // Renderers read published copies; processing only writes private outputs until publication. + const primed = this.prime(request); + primed.catch(() => {}); + try { + if (compiler.processingRequired && !compiler.isVerified(sources.key)) await this.verifyCompiler(compiler, sources); + } catch (error) { + await primed.catch(() => {}); + throw error; + } + await primed; + }); } - /** Stored galleries skip rendering; render once in the background so JVMs are warm for the first edit. */ - private prime(request: PreviewRequest): void { - if (this.rendered) return; - this.rendering.run(async () => { + /** Stored pixels need no seeded render; initialize the final private classpath once instead. */ + private async prime(request: PreviewRequest): Promise { + const current = this.current; + if (!current?.ready || current.needsWarmup + || this.renderedCompiler === current && (!current.publishedInstrumented || this.renderedInstrumented)) return; + await this.rendering.run(async () => { const compiler = this.current; - if (!this.rendered && compiler?.ready) await this.render(compiler, request); - }).catch(() => {}); + if (compiler?.ready && !compiler.needsWarmup + && (this.renderedCompiler !== compiler || compiler.publishedInstrumented && !this.renderedInstrumented)) { + await this.render(compiler, request); + } + }); } - /** Under `rendering`: render the published classes and store the gallery under their inputs. */ - private async render(compiler: ComposeCompiler, request: PreviewRequest): Promise { + /** Under `rendering`: only confirmed output may enter the persistent gallery store. */ + private async render(compiler: ComposeCompiler, request: PreviewRequest): Promise { if (!this.renderer?.alive) await this.createRenderer(); const published = compiler.publishedSources!; - const images = await this.renderer!.render(compiler.inputs, request.sourceFile, request.packageName); - this.rendered = true; - const key = await this.galleryKey(compiler, published, request); - if (key) this.store.set(key, images).catch(() => {}); - return images; + const images = await this.renderer!.render(compiler.inputs, request.sourceFile, request.packageName, selectedMethods(request)); + this.renderedCompiler = compiler; + this.renderedInstrumented = compiler.publishedInstrumented; + if (this.rendererSetup) this.rendererSetup.weight = this.renderer!.weight; + const verified = !compiler.processingRequired || this.publishedVerified; + const key = await this.galleryKey(compiler, published, request, compiler.publishedInstrumented, this.rendererSetup?.identity); + if (key) { + this.remember(key, { images, verified, fingerprint: compiler.fingerprint, sources: published }); + if (verified) this.store.set(key, images).catch(() => {}); + } + return { images, verified, reused: false }; } /** * Bring everything up before a panel asks: render JVMs, the compiler from the last build's outputs, * one gallery for `request` (stored for the panel) and incremental compilation. Never builds. + * `onStep` names each phase as it starts, for progress reporting. Stops after the step in progress + * once a panel uses this backend: it compiles its own, newer editor text, and would wait for these steps. */ - async prewarm(build: PreviewBuild, runtime: ComposeRuntime, java: string, request: PreviewRequest): Promise { - const renderer = this.startRenderer(runtime, java); + async prewarm(build: PreviewBuild, runtime: ComposeRuntime, java: string, request: PreviewRequest, onStep: (step: string) => void = () => {}): Promise { + onStep("starting the renderer and compiler"); + const [, configured] = await Promise.all([ + this.startRenderer(runtime, java, composePreviewAnnotations(request.documents.get(request.sourceFile) ?? "")), + // Never built, or a build is rewriting its outputs: the next panel builds first. A stale record needs more + // than that build, so its owner must hear of it. + previewDocuments(request).then((documents) => this.configure(build, documents, false)).then(() => true, (error: unknown) => { + if (error instanceof StaleBuildRecordError) throw error; + return false; + }), + ]); + if (!configured || this.users) return; + onStep("rendering from the last build"); try { - await this.configure(build, request.documents, false); - } catch { - // Never built, or a build is rewriting its outputs: the next panel builds first. - await renderer; - return; + await this.preview(request); + } catch (error) { + if (this.users) return; + if (!this.current?.processingRequired) throw error; + onStep("verifying annotation processing"); + await this.verify(request); + if (this.users) return; + await this.preview(request); } - await renderer; - await this.preview(request); + if (this.users) return; + onStep("initializing the compiler"); await this.warm(request); + if (this.users) return; + await this.preview(request); + if (this.users) return; + if (this.current?.processingRequired) onStep("verifying annotation processing"); + if (await this.verify(request) && !this.users) await this.preview(request); + if (!this.users) await this.prime(request); } dispose(): void { @@ -273,25 +578,53 @@ export class ComposeBackend { this.current?.dispose(); for (const worker of this.workers.values()) worker.dispose(); this.workers.clear(); - const directory = this.directory; - if (directory) void directory.then((value) => rm(value, { recursive: true, force: true })).catch(() => {}); + // Cancellation stops processes immediately, but filesystem operations and failure cleanup + // must settle before removal, otherwise they can recreate the deleted workspace. + void this.configuring.run(() => this.compiling.run(() => this.rendering.run(async () => { + if (this.directory) await rm(await this.directory, { recursive: true, force: true }); + }))).catch(() => {}); } private workDirectory(): Promise { this.controller.signal.throwIfAborted(); - return this.directory ??= mkdtemp(path.join(tmpdir(), "elide-compose-")); + if (!this.directory) { + const root = tmpdir(); + void sweepWorkspaces(root).catch(() => {}); + // The owner's PID lets later hosts remove the workspace when this one exits without doing so. + this.directory = mkdtemp(path.join(root, `elide-compose-${process.pid}-`)); + } + return this.directory; } - private async galleryKey(compiler: ComposeCompiler, sources: string, request: PreviewRequest): Promise { - const renderer = await this.rendererIdentity?.catch(() => undefined); + private async galleryKey(compiler: ComposeCompiler, sources: string, request: PreviewRequest, instrumented = compiler.publishedInstrumented, + identity = this.rendererIdentity): Promise { + const renderer = await identity?.catch(() => undefined); if (!renderer) return undefined; - return createHash("sha256").update([compiler.fingerprint, sources, renderer, request.sourceFile, request.packageName, - compiler.seeded ? "build-classes" : "preview-source-information"].join("\0")).digest("hex"); + const methods = selectedMethods(request); + return createHash("sha256").update(["verified-processing-v3", compiler.fingerprint, sources, renderer, request.sourceFile, request.packageName, + instrumented ? "preview-source-information" : "build-classes", ...methods ? [`methods:${methods.join(",")}`] : []].join("\0")).digest("hex"); } private async stored(compiler: ComposeCompiler, sources: string, request: PreviewRequest): Promise { - const key = await this.galleryKey(compiler, sources, request); - return key ? await this.store.get(key) : undefined; + // A confirmed instrumented gallery is valid for these exact sources even before a cold + // compiler has reproduced it. Never downgrade Inspect to an image-only build gallery. + for (const instrumented of compiler.publishedInstrumented || request.inspect ? [true] : [true, false]) { + const key = await this.galleryKey(compiler, sources, request, instrumented); + if (!key) return undefined; + const cached = this.galleries.get(key); + if (cached?.verified && !failed(cached.images)) return cached.images; + const images = await this.store.get(key); + if (!images) continue; + this.remember(key, { images, verified: true, fingerprint: compiler.fingerprint, sources }); + return images; + } + return undefined; + } + + private remember(key: string, gallery: CachedGallery): void { + this.galleries.delete(key); + this.galleries.set(key, gallery); + if (this.galleries.size > STORED_GALLERIES) this.galleries.delete(this.galleries.keys().next().value!); } /** Under `rendering`. */ @@ -300,14 +633,24 @@ export class ComposeBackend { if (!setup) throw new Error("Compose renderer was never started."); this.renderer?.dispose(); this.renderer = undefined; - this.rendered = false; - this.renderer = await ComposeRenderer.create(setup.runtime, setup.java, this.helperDirectory, this.cacheRoot, await this.workDirectory(), this.controller.signal); + this.renderedCompiler = undefined; + this.renderedInstrumented = false; + this.renderer = await ComposeRenderer.create(setup.runtime, setup.java, this.helperDirectory, this.cacheRoot, await this.workDirectory(), + this.controller.signal, { weight: setup.weight }); + } + + /** Under `rendering`: verification never races a render of the previous generated output. */ + private async publish(compiler: ComposeCompiler): Promise { + // Background warm-ups read the published classes outside the render lock. + this.renderer?.abandonWarmups(); + await compiler.publish(); + this.publishedVerified = !compiler.processingRequired || compiler.isVerified(compiler.publishedSources!); } /** Under `compiling`. */ private async seed(compiler: ComposeCompiler, documents: ReadonlyMap, verified: boolean): Promise { if (!await compiler.seed(await compiler.sources(documents), verified)) return false; - await this.rendering.run(() => compiler.publish()); + await this.rendering.run(() => this.publish(compiler)); return true; } } @@ -322,8 +665,8 @@ export class ComposeBackends { private readonly cacheRoot: string, private readonly helperDirectory: string, private readonly keepAlive: () => number, - /** Creates the backend for the configured engine; the layoutlib snapshot backend when absent. */ - private readonly create: (key: string, cacheRoot: string, helperDirectory: string, store: ImageStore) => ComposeBackend = + /** Creates the backend for the configured engine and its user (`agent`: {@link detached}); the layoutlib snapshot backend when absent. */ + private readonly create: (key: string, cacheRoot: string, helperDirectory: string, store: ImageStore, role: "panel" | "agent") => ComposeBackend = (key, cacheRoot, helperDirectory, store) => new ComposeBackend(key, cacheRoot, helperDirectory, store), private readonly engine: () => string = () => "layoutlib", ) { @@ -338,11 +681,16 @@ export class ComposeBackends { const existing = this.backends.get(key); if (existing && !existing.disposed && (existing.engine === this.engine() || existing.users)) return existing; for (const backend of this.backends.values()) if (!backend.users) this.drop(backend); - const backend = this.create(key, this.cacheRoot, this.helperDirectory, this.store); + const backend = this.create(key, this.cacheRoot, this.helperDirectory, this.store, "panel"); this.backends.set(key, backend); return backend; } + /** A backend for the configured engine outside the pool, for an agent's previews: never stored or dropped here; the caller disposes it. */ + detached(key: string): ComposeBackend { + return this.create(key, this.cacheRoot, this.helperDirectory, this.store, "agent"); + } + acquire(key: string): ComposeBackend { const backend = this.obtain(key); backend.users++; @@ -360,11 +708,14 @@ export class ComposeBackends { idle(backend: ComposeBackend): void { if (backend.users || backend.disposed) return; clearTimeout(this.timers.get(backend)); + this.timers.delete(backend); const delay = this.keepAlive(); if (delay <= 0) { this.drop(backend); return; } + // Longer than a timer can wait (about 24.8 days), which would fire at once: keep it until it is dropped otherwise. + if (delay > 0x7fff_ffff) return; const timer = setTimeout(() => this.drop(backend), delay); timer.unref(); this.timers.set(backend, timer); @@ -380,6 +731,11 @@ export class ComposeBackends { for (const backend of this.backends.values()) if (!backend.users && !keep(backend.key)) this.drop(backend); } + /** Compatibility loss retires an engine's active and idle resources without touching other engines. */ + retireEngine(engine: string): void { + for (const backend of this.backends.values()) if (backend.engine === engine) this.drop(backend); + } + dispose(): void { for (const backend of this.backends.values()) this.drop(backend); } diff --git a/packages/vscode/src/composeCompiler.ts b/packages/vscode/src/composeCompiler.ts index 8cbc1e0..85dc705 100644 --- a/packages/vscode/src/composeCompiler.ts +++ b/packages/vscode/src/composeCompiler.ts @@ -5,9 +5,11 @@ import path from "node:path"; import type { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import * as yauzl from "yauzl"; -import { sourceSelection, type SourceSelection } from "@elide/ide-core"; +import { parseElideOutputLine, sourceSelection, type SourceSelection } from "@elide/ide-core"; import type { ComposeRenderInputs } from "./composeRender.js"; import { ComposeWorker } from "./composeWorker.js"; +import { ComposeProcessing, StaleBuildRecordError, kotlinToolchain, toolchainOptions, type KotlinToolchainRecord } from "./composeProcessing.js"; +import { WorkspacePaths, canonicalPath, inside } from "./workspacePaths.js"; type Tool = "kotlinc" | "javac"; interface Source { @@ -18,6 +20,9 @@ interface Source { interface Unit { tool: Tool; project: string; + toolchain: KotlinToolchainRecord; + toolchainKey: string; + processing?: ComposeProcessing; sourceSet: string; original: string; /** The build's compiler record (`kotlinc.args`/`javac.args`) for `original`. */ @@ -49,6 +54,7 @@ export interface ComposeSources { readonly key: string; readonly documents: ReadonlyMap; readonly files: readonly ReadonlyMap[]; + readonly processingInputs: ReadonlyMap; } const classPathFlags: Record = { "-classpath": true, "-cp": true, "--class-path": true, "--module-path": true, "-p": true, "-Xmodule-path": true }; @@ -58,11 +64,6 @@ const sourceExtension = /\.(?:kt|java)$/; const portableKotlinValues = new Set(["-api-version", "-language-version", "-jvm-target", "-module-name"]); const portableKotlinSwitches = new Set(["-Xdisable-standard-script", "-no-stdlib", "-no-reflect", "-no-jdk", "-java-parameters", "-nowarn", "-Werror"]); -function inside(directory: string, file: string): boolean { - const relative = path.relative(directory, file); - return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)); -} - /** Elide ArgsRecord is argv, one unescaped argument per line, NOT a shell or Java @argfile. */ function recordedArguments(text: string): string[] { const args = text.split("\n"); @@ -70,6 +71,40 @@ function recordedArguments(text: string): string[] { return args; } +/** + * The first compiler error in `output` as one status line, `File.kt:12 message`. Elide prints a diagnostic as + * `error: kotlinc: message` with its `In file: path:line:column` on the next line (indented in a quoted build + * failure); javac's own format puts the location first. Notes and warnings, such as Elide's advice about recorded + * `-Xplugin` arguments, never stand in for the error. + */ +export function firstCompilerError(output: string): string | undefined { + const lines = output.split(/\r?\n/); + for (const [index, line] of lines.entries()) { + const parsed = parseElideOutputLine(line); + if (parsed?.kind === "diagnostic" && parsed.severity === "error") { + const message = parsed.message.replace(/^(?:kotlinc|javac|kapt):\s+/, ""); + const location = /^\s*In file:\s+(.+?):(\d+)(?::\d+)?\s*$/.exec(lines[index + 1] ?? ""); + return location ? `${path.basename(location[1]!)}:${location[2]} ${message}` : message; + } + const located = /^(.+\.(?:kt|java)):(\d+)(?::\d+)?:\s*error:\s*(.+)$/.exec(line); + if (located) return `${path.basename(located[1]!)}:${located[2]} ${located[3]!.trim()}`; + } + return undefined; +} + +/** + * A record must compile into the output it sits beside, physically: Elide records canonical paths, which the + * workspace may spell through a symlink. Anything else is another checkout's record. + */ +async function verifyDestination(record: string, args: readonly string[], project: string, original: string): Promise { + const index = args.findIndex((arg) => arg === "-d" || arg.startsWith("-d=")); + const destination = index < 0 ? undefined : args[index] === "-d" ? args[index + 1] : args[index]!.slice("-d=".length); + if (destination === undefined) throw new Error(`The compiler record at ${record} has no -d destination for ${original}. Rebuild the Elide project.`); + if (await canonicalPath(path.resolve(project, destination)) !== await canonicalPath(original)) { + throw new StaleBuildRecordError(record, `it compiles into ${destination}, not this checkout's ${original}`); + } +} + function splitOption(arg: string): { flag: string; value?: string } { const equals = arg.indexOf("="); return equals < 0 ? { flag: arg } : { flag: arg.slice(0, equals), value: arg.slice(equals + 1) }; @@ -111,8 +146,11 @@ async function walk(directory: string, accept: (file: string) => boolean): Promi return files; } -/** Only self-contained invocations may share a worker across project directories. */ -function portableKotlinArguments(args: readonly string[]): boolean { +/** + * Only self-contained invocations may share a worker across project directories. `bundledKotlin` is the Kotlin + * directory of the Elide distribution running the worker, whose compiler plugins (Compose, serialization, …) are its own. + */ +function portableKotlinArguments(args: readonly string[], toolchain: KotlinToolchainRecord, bundledKotlin: string | undefined): boolean { const homeIndex = args.indexOf("-kotlin-home"); const kotlinHome = homeIndex < 0 ? undefined : args[homeIndex + 1]; for (let index = 0; index < args.length; index++) { @@ -131,7 +169,11 @@ function portableKotlinArguments(args: readonly string[]): boolean { if (option.flag === "-Xplugin") { const value = option.value ?? args[++index]; // Custom plugins may read implicit cwd-relative configuration, even with an absolute jar. - if (!kotlinHome || value !== path.join(kotlinHome, "lib", "kotlin-compose-compiler-plugin.jar")) return false; + const externalCompose = kotlinHome && value === path.join(kotlinHome, "lib", "kotlin-compose-compiler-plugin.jar"); + const managedCompose = value && toolchain.kind === "managed" && toolchain.classpath?.includes(value) + && /^kotlin-compose-compiler-plugin-embeddable-[\d.]+(?:-[\w.]+)?\.jar$/.test(path.basename(value)); + const bundled = value && bundledKotlin && inside(bundledKotlin, value) && /^kotlin-[\w-]+-compiler-plugin\.jar$/.test(path.basename(value)); + if (!externalCompose && !managedCompose && !bundled) return false; continue; } const paths = classPathFlags[option.flag] || sourcePathFlags[option.flag] || outputFlags[option.flag] @@ -144,6 +186,24 @@ function portableKotlinArguments(args: readonly string[]): boolean { return true; } +/** What a build contributes to a compiler; a later build that only changed its content replaces it whole. */ +interface BuildInputs { + /** Renderer inputs; project classes resolve to the published copies. */ + inputs: ComposeRenderInputs; + /** Renderer inputs over the build's own outputs (project classes and workspace archives as the build wrote them). */ + built: ComposeRenderInputs; + /** Build path → this compiler's stand-ins: private outputs, content-addressed mirrors, extracted archive resources. */ + replacements: Map; + /** Everything a build contributes besides project classes: inputs, compiler records, resources, libraries. With a source key, determines the classes. */ + fingerprint: string; + /** The parts of `fingerprint` that decide what is compiled and how: records, toolchains, input paths, archive resources. */ + structure: string; + /** Content stamp of each classpath entry that is not a project output. */ + stamps: ReadonlyMap; + /** Stamp of the build's project class outputs. */ + originals: string; +} + /** A compiler session owns its sources, classes and IC caches. Build outputs remain read-only. */ export class ComposeCompiler { private disposed = false; @@ -153,31 +213,43 @@ export class ComposeCompiler { private current: string | undefined; /** Source key the render-facing classes hold. */ private publishedKey: string | undefined; + /** Some generation was published: the render-facing directories hold a complete set of classes. */ + private populated = false; + /** Instrumentation provenance of the published generation, never of an in-flight compile. */ + publishedInstrumented = false; /** Stamp of the build outputs seeded units were copied from. */ private seededFrom: string | undefined; + private verifiedKey: string | undefined; readonly rebuildRequired: boolean; private constructor( private readonly bin: string, private readonly cwd: string, - /** Renderer inputs; project classes resolve to the published copies. */ - readonly inputs: ComposeRenderInputs, private readonly units: Unit[], - private readonly replacements: Map, private readonly workers: Map, private readonly directory: string, - /** Everything a build contributes besides project classes: inputs, compiler records, resources, libraries. With a source key, determines the classes. */ - readonly fingerprint: string, - /** Stamp of the build's project class outputs when this compiler was created. */ - private readonly originals: string, + private build: BuildInputs, + /** Kotlin directory of the Elide distribution running the workers: its compiler plugins are portable. */ + private readonly bundledKotlin: string | undefined, private readonly signal: AbortSignal, ) { - // Elide's final kotlinc.args includes generated inputs but overwrites the earlier kapt - // processor invocation. Only a fresh build can regenerate those inputs authoritatively. - this.rebuildRequired = units.some((unit) => unit.args.some((arg) => /(?:kotlin-annotation-processing|plugin:org\.jetbrains\.kotlin\.kapt3)/.test(arg)) - || [...unit.explicit].some((file) => inside(path.join(unit.project, ".dev"), file))); + // Older builds cannot authorize replay of generated inputs without the processing record. + this.rebuildRequired = units.some((unit) => unit.processing?.requiresBytecodeTransform || (!unit.processing + && [...unit.explicit].some((file) => inside(path.join(unit.project, ".dev"), file)))); } + /** Renderer inputs; project classes resolve to the published copies. */ + get inputs(): ComposeRenderInputs { return this.build.inputs; } + + /** + * Renderer inputs that hold the project's classes now: the published copies once a generation was published, before that the + * build's own outputs (a source newer than the build, or an unsaved one, keeps the build's classes from being adopted). + */ + get currentInputs(): ComposeRenderInputs { return this.populated ? this.build.inputs : this.build.built; } + + /** See {@link BuildInputs.fingerprint}; part of every stored gallery key. */ + get fingerprint(): string { return this.build.fingerprint; } + /** * `workers` is shared across compilers of one backend and outlives them; its owner disposes it. * `directory` is the backend's: compilers share its content-addressed dependency mirror. @@ -187,11 +259,24 @@ export class ComposeCompiler { ): Promise { signal.throwIfAborted(); await mkdir(directory, { recursive: true }); - const mirrors = path.join(path.resolve(directory), "mirror"); directory = await mkdtemp(path.join(path.resolve(directory), "compiler-")); + try { + return await ComposeCompiler.createInDirectory(bin, cwd, inputs, directory, signal, workers); + } catch (error) { + await rm(directory, { recursive: true, force: true }).catch(() => {}); + throw error; + } + } + + private static async createInDirectory( + bin: string, cwd: string, inputs: ComposeRenderInputs, directory: string, signal: AbortSignal, workers: Map, + ): Promise { + const mirrors = path.join(path.dirname(directory), "mirror"); const units: Unit[] = []; - for (const originalEntry of new Set(inputs.projectClassPath)) { - const original = path.resolve(cwd, originalEntry); + const originals = [...new Set(inputs.projectClassPath)].map((entry) => path.resolve(cwd, entry)); + // Records name canonical paths; outputs are `/.dev/jvm/classes//`. + const paths = await WorkspacePaths.of([cwd, ...originals.map((original) => path.resolve(original, "../../../../.."))]); + for (const original of originals) { const language = path.basename(original); const parent = path.dirname(original); const sourceSet = path.basename(parent); @@ -203,7 +288,7 @@ export class ComposeCompiler { const tool = language === "kotlin" ? "kotlinc" : "javac"; const record = path.join(parent, `${tool}.args`); let args: string[]; - try { args = recordedArguments(await readFile(record, "utf8")); } + try { args = recordedArguments(await readFile(record, "utf8")).map((arg) => paths.argument(arg)); } catch (error) { throw new Error(`Compose preview needs authoritative compiler arguments at ${record}. Build with an Elide version that records ${tool}.args.`, { cause: error }); } @@ -223,6 +308,7 @@ export class ComposeCompiler { if (!configured) args.push("-P", `${sourceInformation}true`); } const project = path.dirname(dev); + await verifyDestination(record, args, project, original); const explicit = new Set(args.filter((arg) => !arg.startsWith("-") && sourceExtension.test(arg)).map((arg) => path.resolve(project, arg))); if (!explicit.size) throw new Error(`Compose preview found no source files in ${record}. Rebuild the Elide project.`); const patterns = inputs.sourceSelections?.[original]; @@ -230,8 +316,16 @@ export class ComposeCompiler { const selections = patterns.map((pattern) => sourceSelection(project, pattern)); const roots = [...new Set([...selections.map((selection) => sourceExtension.test(selection.root) ? path.dirname(selection.root) : selection.root), ...[...explicit].map((file) => path.dirname(file))])]; const privateDirectory = path.join(directory, String(units.length)); + const toolchain = tool === "kotlinc" ? await kotlinToolchain(path.join(parent, "kotlinc.toolchain.json")) : { version: 1 as const, kind: "embedded" as const }; + const toolchainFiles = toolchain.kind === "external" ? [path.join(toolchain.home!, "lib")] : toolchain.classpath ?? []; + const toolchainHash = createHash("sha256").update(JSON.stringify(toolchain)); + for (const file of toolchainFiles) toolchainHash.update("\0").update(await stamp(file)); + const toolchainKey = toolchainHash.digest("hex"); + const processing = tool === "kotlinc" ? await ComposeProcessing.create(path.join(parent, "kapt.args"), path.join(privateDirectory, "kapt"), project, paths) : undefined; + if (processing) await verifyDestination(path.join(parent, "kapt.args"), processing.args, project, original); + if (processing && toolchain.kind === "embedded") throw new Error(`kapt requires a recorded managed or external Kotlin toolchain at ${parent}. Rebuild with a current Elide version.`); units.push({ - tool, project, sourceSet, original, record, output: path.join(privateDirectory, language), + tool, toolchain, toolchainKey, processing, project, sourceSet, original, record, output: path.join(privateDirectory, language), published: path.join(privateDirectory, "published", language), publishedFiles: new Map(), directory: privateDirectory, args, selections, roots: roots.filter((root) => !roots.some((other) => other !== root && inside(other, root))), explicit, sources: new Map(), dependencies: new Set(), dirty: true, seeded: false, warmed: false, @@ -240,13 +334,28 @@ export class ComposeCompiler { if (!units.length) throw new Error("Compose preview has no project compiler outputs. Rebuild and sync the Elide project."); for (const kotlin of units.filter((unit) => unit.tool === "kotlinc")) { for (const java of units.filter((unit) => unit.tool === "javac" && sameSourceSet(unit, kotlin))) { + java.processing = kotlin.processing; for (const file of java.explicit) kotlin.explicit.add(file); for (const root of java.roots) if (!kotlin.roots.some((other) => inside(other, root))) kotlin.roots.push(root); + if (kotlin.processing) for (const root of kotlin.processing.roots) { + if (!java.roots.includes(root)) java.roots.push(root); + if (!kotlin.roots.includes(root)) kotlin.roots.push(root); + } } } + for (const unit of units) unit.roots = unit.roots.filter((root) => !unit.roots.some((other) => other !== root && inside(other, root))); - const fingerprint = createHash("sha256").update(`${bin}\0${path.resolve(cwd)}\0`).update(JSON.stringify(inputs)); - for (const unit of units) fingerprint.update("\0args\0").update(unit.args.join("\n")); + // `structure` decides what is compiled and how; the fingerprint adds the content of every input. + const structure = createHash("sha256").update(`${bin}\0${path.resolve(cwd)}\0`).update(JSON.stringify(inputs)); + const fingerprint = createHash("sha256"); + for (const unit of units) structure.update("\0args\0").update(unit.args.join("\n")).update(unit.toolchainKey); + for (const unit of units) { + if (unit.processing && unit.tool === "kotlinc") { + fingerprint.update(await unit.processing.inputs(stamp)); + fingerprint.update(await stamp(unit.processing.classes)); + for (const [file, content] of unit.processing.generated) fingerprint.update(file).update("\0").update(content); + } + } const replacements = new Map(units.map((unit) => [unit.original, [unit.output]])); const renderReplacements = new Map(units.map((unit) => [unit.original, [unit.published]])); const candidates = new Set([ @@ -266,10 +375,11 @@ export class ComposeCompiler { signal.throwIfAborted(); const resources = path.join(directory, "archives", createHash("sha256").update(archive).digest("hex").slice(0, 16)); const entries = await archiveResources(archive, resources, signal); - // Bytecode comes from this compiler; only packaged resources are build inputs here. - fingerprint.update("\0archive\0").update(archive); + // Bytecode comes from this compiler; only packaged resources are build inputs here. Their extracted copy is + // private to each compiler, so a change cannot carry over to an existing one: structure, not content. + structure.update("\0archive\0").update(archive); for (const [name, crc] of [...entries].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) { - if (!name.endsWith("/") && !/\.(?:class|kotlin_module)$/.test(name)) fingerprint.update(`\0${name}\0${crc}`); + if (!name.endsWith("/") && !/\.(?:class|kotlin_module)$/.test(name)) structure.update(`\0${name}\0${crc}`); } const groups = new Set(owners.map((unit) => unit.sourceSet)); let selected = owners; @@ -282,12 +392,15 @@ export class ComposeCompiler { selected = owners.filter((unit) => matching.has(unit.sourceSet)); if (!selected.length) throw new Error(`Compose preview cannot map workspace archive ${archive} to its source sets. Rebuild the artifact.`); } + structure.update(`\0owners\0${selected.map((unit) => unit.original).join("\0")}`); replacements.set(archive, [...selected.map((unit) => unit.output), resources]); renderReplacements.set(archive, [...selected.map((unit) => unit.published), resources]); } + const stamps = new Map(); for (const entry of [...candidates].sort()) { if (replacements.has(entry)) continue; const content = await stamp(entry); + stamps.set(entry, content); fingerprint.update(`\0${entry}\0${content}`); // Elide rewrites identical build outputs (libraries, R classes) on every build. A content-addressed copy // keeps the path and modification time the incremental compiler sees stable until the content changes, @@ -333,9 +446,16 @@ export class ComposeCompiler { } } const replace = (entries: string[], map: Map): string[] => [...new Set(entries.flatMap((entry) => map.get(path.resolve(cwd, entry)) ?? [entry]))]; - const compiler = new ComposeCompiler(bin, path.resolve(cwd), { - ...inputs, classPath: replace(inputs.classPath, renderReplacements), projectClassPath: replace(inputs.projectClassPath, renderReplacements), - }, ordered, replacements, workers, directory, fingerprint.digest("hex"), await classesStamp(ordered, "original"), signal); + const structureKey = structure.digest("hex"); + const build: BuildInputs = { + inputs: { ...inputs, classPath: replace(inputs.classPath, renderReplacements), projectClassPath: replace(inputs.projectClassPath, renderReplacements) }, + built: { ...inputs, classPath: inputs.classPath.map((entry) => path.resolve(cwd, entry)), projectClassPath: originals }, + replacements, structure: structureKey, fingerprint: fingerprint.update(structureKey).digest("hex"), stamps, + originals: await classesStamp(ordered, "original"), + }; + // Elide bundles its Kotlin compiler plugins at `/lib/resources/kotlin//lib`. + const bundledKotlin = path.isAbsolute(bin) ? path.join(path.dirname(path.dirname(await canonicalPath(bin))), "lib", "resources", "kotlin") : undefined; + const compiler = new ComposeCompiler(bin, path.resolve(cwd), ordered, workers, directory, build, bundledKotlin, signal); // Materialize the full private layout before any worker or renderer sees a classpath entry. await Promise.all(units.flatMap((unit) => [mkdir(unit.output, { recursive: true }), mkdir(unit.published, { recursive: true })])); signal.throwIfAborted(); @@ -362,10 +482,60 @@ export class ComposeCompiler { /** Some units still hold build classes and have not primed their incremental compiler. */ get seeded(): boolean { return this.units.some((unit) => unit.seeded); } + /** A complete private baseline (including workspace libraries) has not been compiled yet. */ + get needsWarmup(): boolean { return !this.rebuildRequired && (!this.initialized || this.seeded); } + + get processingRequired(): boolean { return !this.rebuildRequired && this.units.some((unit) => unit.processing !== undefined); } + + get processingStats(): { stubs: number; apt: number } { + let stubs = 0; + let apt = 0; + for (const unit of this.units) if (unit.tool === "kotlinc" && unit.processing) { + stubs += unit.processing.counts.stubs; + apt += unit.processing.counts.apt; + } + return { stubs, apt }; + } + + isVerified(key: string): boolean { return !this.processingRequired || this.verifiedKey === key; } + /** Whether `candidate`, created after a later build, describes the same compilation as this compiler. */ sameBuild(candidate: ComposeCompiler): boolean { return !this.rebuildRequired && this.fingerprint === candidate.fingerprint - && (this.seededFrom === undefined || this.seededFrom === candidate.originals); + && (this.seededFrom === undefined || this.seededFrom === candidate.build.originals); + } + + /** + * Take over the inputs of `candidate`, created after a later build that changed only input content (linked + * resources, R classes, libraries) but compiles the same records the same way. This compiler's classes, sources + * and incremental caches stay: no cold initialization. Units whose classpath content changed recompile + * incrementally on the next update, until which nothing is published. Callers exclude updates and renders; + * `candidate` is discarded afterwards. False when `candidate` compiles something else. + */ + rebase(candidate: ComposeCompiler): boolean { + if (this.disposed || this.updating || this.rebuildRequired || candidate.rebuildRequired + || this.build.structure !== candidate.build.structure + || this.seededFrom !== undefined && this.seededFrom !== candidate.build.originals) return false; + // Equal structure lays out private directories identically; `candidate`'s are about to be removed. + const own = (entry: string): string => inside(candidate.directory, entry) ? path.join(this.directory, path.relative(candidate.directory, entry)) : entry; + let invalidated = false; + for (const unit of this.units) { + const entries = pathArguments(unit.args).map((entry) => path.resolve(unit.project, entry)); + if (entries.every((entry) => this.build.stamps.get(entry) === candidate.build.stamps.get(entry))) continue; + unit.dirty = true; + invalidated = true; + } + const { inputs, replacements } = candidate.build; + this.build = { + ...candidate.build, + inputs: { ...inputs, classPath: inputs.classPath.map(own), projectClassPath: inputs.projectClassPath.map(own) }, + replacements: new Map([...replacements].map(([entry, targets]) => [entry, targets.map(own)])), + }; + if (invalidated) { + this.current = undefined; + this.publishedKey = undefined; + } + return true; } supports(file: string): boolean { @@ -383,12 +553,17 @@ export class ComposeCompiler { async sources(documents: ReadonlyMap): Promise { this.signal.throwIfAborted(); const files = await Promise.all(this.units.map((unit) => this.collect(unit, documents))); + const processingInputs = new Map(); + for (const unit of this.units) if (unit.tool === "kotlinc" && unit.processing) { + processingInputs.set(unit.processing, await this.processingInputFingerprint(unit.processing)); + } const key = createHash("sha256"); for (const unit of files) { for (const [file, content] of unit) key.update(file).update("\0").update(content).update("\0"); key.update("\u0001"); } - return { key: key.digest("hex"), documents, files }; + for (const input of processingInputs.values()) key.update("\0processing\0").update(input); + return { key: key.digest("hex"), documents, files, processingInputs }; } /** @@ -404,7 +579,7 @@ export class ComposeCompiler { if (this.units.some((unit) => this.accepts(unit, file)) && await readFile(file, "utf8") !== text) return false; } for (const [index, unit] of this.units.entries()) { - const files = sources.files[index]!; + const files = this.withGenerated(unit, sources.files[index]!); if (files.size !== unit.explicit.size || [...files.keys()].some((file) => !unit.explicit.has(file))) return false; if (!(await stat(unit.original)).isDirectory()) return false; if (!verified) { @@ -422,7 +597,7 @@ export class ComposeCompiler { this.signal.throwIfAborted(); await this.clean(unit); await cp(unit.original, unit.output, { recursive: true }); - await this.apply(unit, sources.files[index]!); + await this.apply(unit, this.withGenerated(unit, sources.files[index]!)); unit.dirty = false; unit.seeded = true; } @@ -446,11 +621,16 @@ export class ComposeCompiler { /** Compile `sources` into the private outputs. `warm` also compiles seeded units, priming incremental compilation. */ async update(sources: ComposeSources, warm = false): Promise { + await this.compile(sources, warm); + } + + private async compile(sources: ComposeSources, warm: boolean, stopBefore?: Unit): Promise { this.signal.throwIfAborted(); if (this.disposed) throw new Error("Compose compiler disposed."); if (this.updating) throw new Error("Concurrent Compose compiler updates are not supported."); this.updating = true; this.current = undefined; + if (warm) this.verifiedKey = undefined; const documents = sources.documents; let failed: Unit | undefined; let preserveClasspath = false; @@ -466,17 +646,17 @@ export class ComposeCompiler { if (this.rebuildRequired) { for (const [file, text] of documents) { if (this.units.some((unit) => this.accepts(unit, file)) && await readFile(file, "utf8") !== text) { - throw new Error(`Save ${file} to regenerate annotation-processor inputs before previewing. The final Elide compiler record does not contain the processor invocation.`); + throw new Error(`Save ${file} and rebuild the preview. This build needs processing records or bytecode post-processing that live compilation cannot replay.`); } } } // Synchronize every source before compiling, so mixed Kotlin/Java sees one document snapshot. for (const [index, unit] of this.units.entries()) { - if (await this.apply(unit, sources.files[index]!)) unit.dirty = true; + if (await this.apply(unit, this.withGenerated(unit, sources.files[index]!))) unit.dirty = true; if (warm && unit.seeded) unit.dirty = true; if (unit.dirty) changed.add(unit); for (const file of unit.sources.keys()) { - if (file.endsWith(".java") && !this.units.some((owner) => owner.tool === "javac" && this.accepts(owner, file))) { + if (file.endsWith(".java") && !this.units.some((owner) => owner.tool === "javac" && (this.accepts(owner, file) || owner.processing?.owns(file)))) { throw new Error(`Save ${file} and rebuild the preview to create its Java compiler inputs.`); } } @@ -501,6 +681,7 @@ export class ComposeCompiler { return; } for (const unit of this.units) { + if (unit === stopBefore) return; if (!unit.dirty) continue; this.signal.throwIfAborted(); if (this.disposed) throw new Error("Compose compiler disposed."); @@ -525,19 +706,11 @@ export class ComposeCompiler { if (unit.tool === "javac") await this.clean(unit); const args = this.arguments(unit); const options = unit.tool === "kotlinc" - ? ["--incremental", "--transactional-incremental", ...(!unit.warmed ? ["--warm-incremental"] : []), `--incremental-cache-dir=${path.join(unit.directory, "incremental")}`, "--plugins=", ...(unit.abiReport ? [`--report-abi=${unit.abiReport}`] : []), "--"] + ? [...toolchainOptions(unit.toolchain), "--incremental", "--transactional-incremental", ...(!unit.warmed ? ["--warm-incremental"] : []), `--incremental-cache-dir=${path.join(unit.directory, "incremental")}`, "--plugins=", ...(unit.abiReport ? [`--report-abi=${unit.abiReport}`] : []), "--"] : ["--"]; // Absolute standard Kotlin invocations retain module-local caches but share JVM/JIT/BTA // infrastructure. Unknown flags, relative paths and custom plugins keep their own cwd. - const cwd = unit.tool === "kotlinc" && portableKotlinArguments(args) ? this.cwd : unit.project; - const key = `${this.bin}\0${cwd}\0${unit.tool}`; - let worker = this.workers.get(key); - if (!worker?.alive) { - worker?.dispose(); - worker = new ComposeWorker(this.bin, unit.tool, cwd, this.signal); - this.workers.set(key, worker); - } - const response = await worker.request([...options, ...args]); + const response = await this.worker(unit.tool, this.workerDirectory(unit, args), unit.toolchainKey).request([...options, ...args]); if (response.exitCode !== 0) { preserveClasspath = response.exitCode === 1; const preserved = /^elide: incremental failure state preserved\r?$/m; @@ -549,6 +722,7 @@ export class ComposeCompiler { throw new Error(`${unit.tool} failed for ${unit.project} (${unit.sourceSet}, exit ${response.exitCode}).\n${diagnostic || "Elide returned no compiler diagnostics."}`); } if (unit.tool === "kotlinc") unit.warmed = true; + if (unit.tool === "kotlinc" && unit.processing) await cp(unit.processing.classes, unit.output, { recursive: true }); if (await this.abiChanged(unit)) { for (const consumer of this.units) if (consumer.dependencies.has(unit)) consumer.dirty = true; } @@ -568,6 +742,82 @@ export class ComposeCompiler { } } + /** Confirm processor inputs after the optimistic paint; failures never confirm stale generated code. */ + async verify(sources: ComposeSources): Promise { + this.signal.throwIfAborted(); + if (this.isVerified(sources.key) && this.current === sources.key) return false; + if (this.updating || this.disposed) throw new Error("Compose compiler cannot verify during another update or after disposal."); + let changed = this.current !== sources.key; + this.updating = true; + this.verifiedKey = undefined; + try { + for (const [index, unit] of this.units.entries()) { + if (await this.apply(unit, this.withGenerated(unit, sources.files[index]!))) unit.dirty = true; + } + for (const unit of this.units) { + const stage = unit.processing; + if (unit.tool !== "kotlinc" || !stage) continue; + // Earlier stages may have regenerated inputs since the optimistic compile. Materialize + // their Kotlin/Java outputs before this stage inspects the downstream classpath. + this.updating = false; + try { await this.compile(sources, false, unit); } + finally { this.updating = true; } + let processingInput = sources.processingInputs.get(stage)!; + for (const dependency of unit.dependencies) processingInput += `\0${dependency.original}\0${await stamp(dependency.output)}`; + for (const source of unit.sources.values()) { + if (source.file.endsWith(".java") && !stage.owns(source.file)) processingInput += `\0${source.file}\0${source.content}`; + } + // Processors may resolve options against the project directory. The Kotlin worker there serves kapt as well. + const worker = this.worker("kotlinc", unit.project, unit.toolchainKey); + const regenerated = await stage.verify(this.arguments(unit, stage.args, false), processingInput, async (args) => { + const response = await worker.request([...toolchainOptions(unit.toolchain), "--plugins=", "--", ...args]); + if (response.exitCode !== 0) { + let diagnostic = response.output; + for (const owner of this.units) for (const source of owner.sources.values()) diagnostic = diagnostic.replaceAll(source.snapshot, source.file); + throw new Error(`kapt failed for ${unit.project} (${unit.sourceSet}, exit ${response.exitCode}).\n${diagnostic}`); + } + }); + if (regenerated) { + changed = true; + for (const owner of this.units) if (owner.processing === stage) { + owner.dirty = true; + // Removed generated declarations must not survive an incremental cache from the old generation. + await this.clean(owner); + owner.warmed = false; + owner.seeded = false; + } + } + } + } finally { + this.updating = false; + } + await this.update(sources); + for (const [stage, input] of sources.processingInputs) { + if (await this.processingInputFingerprint(stage) !== input) throw new Error("Annotation processor inputs changed during preview verification; refresh the preview."); + } + this.verifiedKey = sources.key; + return changed; + } + + private async processingInputFingerprint(stage: ComposeProcessing): Promise { + return stage.inputs(async (file) => { + const replacement = this.build.replacements.get(file); + // Build-owned mirrors are frozen with this resource context. Project-unit outputs are + // instead verified in DAG order and separately included in each processing pass's key. + if (!replacement || replacement.some((entry) => this.units.some((unit) => unit.output === entry))) return stamp(file); + return (await Promise.all(replacement.map(stamp))).join("\0"); + }); + } + + private withGenerated(unit: Unit, files: ReadonlyMap): ReadonlyMap { + if (!unit.processing) return files; + const result = new Map(files); + for (const [file, text] of unit.processing.generated) { + if (unit.tool === "kotlinc" || file.endsWith(".java")) result.set(file, text); + } + return result; + } + /** Copy changed private classes to the render-facing directories. Callers exclude concurrent renders. */ async publish(): Promise { const key = this.current; @@ -592,12 +842,34 @@ export class ComposeCompiler { unit.publishedFiles = next; } this.publishedKey = key; + this.populated = true; + this.publishedInstrumented = !this.rebuildRequired && this.initialized && !this.seeded; } dispose(): void { this.disposed = true; } + /** + * Stop the backend's workers this compiler never uses: a replaced compiler's, for another toolchain, Elide distribution + * or working directory. Callers exclude compiles. + */ + pruneWorkers(): void { + const used = new Set(); + for (const unit of this.units) { + let args: string[]; + // Such a unit fails before it starts a worker. + try { args = this.arguments(unit); } catch { continue; } + used.add(this.workerKey(unit.tool, this.workerDirectory(unit, args), unit.toolchainKey)); + if (unit.processing) used.add(this.workerKey("kotlinc", unit.project, unit.toolchainKey)); + } + for (const [key, worker] of this.workers) { + if (used.has(key)) continue; + worker.dispose(); + this.workers.delete(key); + } + } + /** Delete this compiler's private directory. Only after `dispose` and outside renders using its classes. */ async remove(): Promise { await rm(this.directory, { recursive: true, force: true }); @@ -605,12 +877,14 @@ export class ComposeCompiler { private accepts(unit: Unit, file: string): boolean { return (unit.tool === "kotlinc" ? sourceExtension.test(file) : file.endsWith(".java")) + && !unit.processing?.owns(file) && (unit.explicit.has(file) || unit.selections.some((selection) => selection.matches(file))); } private async collect(unit: Unit, documents: ReadonlyMap): Promise> { const roots = [...new Set(unit.selections.map((selection) => selection.root))]; - const files = new Set([...unit.explicit, ...(await Promise.all(roots.map((root) => walk(root, (file) => this.accepts(unit, file))))).flat()]); + const files = new Set([...unit.explicit].filter((file) => !unit.processing?.owns(file))); + for (const file of (await Promise.all(roots.map((root) => walk(root, (file) => this.accepts(unit, file))))).flat()) files.add(file); for (const file of documents.keys()) if (this.accepts(unit, file)) files.add(file); const sorted = [...files].sort(); const contents = await Promise.all(sorted.map(async (file) => { @@ -656,33 +930,52 @@ export class ComposeCompiler { return changed; } - private arguments(unit: Unit): string[] { + /** The worker directory `unit` compiles `args` in: the workspace's when they are self-contained, else its project's. */ + private workerDirectory(unit: Unit, args: readonly string[]): string { + return unit.tool === "kotlinc" && portableKotlinArguments(args, unit.toolchain, this.bundledKotlin) ? this.cwd : unit.project; + } + + private workerKey(tool: Tool, cwd: string, toolchainKey: string): string { + return `${this.bin}\0${cwd}\0${tool}\0${toolchainKey}`; + } + + /** The backend's persistent `tool` worker in `cwd`, started on first use or after it died. */ + private worker(tool: Tool, cwd: string, toolchainKey: string): ComposeWorker { + const key = this.workerKey(tool, cwd, toolchainKey); + let worker = this.workers.get(key); + if (!worker?.alive) { + worker?.dispose(); + worker = new ComposeWorker(this.bin, tool, cwd, this.signal); + this.workers.set(key, worker); + } + return worker; + } + + private arguments(unit: Unit, recorded = unit.args, generated = true): string[] { const args: string[] = []; - let destination = false; - for (let index = 0; index < unit.args.length; index++) { - const arg = unit.args[index]!; - if (unit.explicit.has(path.resolve(unit.project, arg))) continue; + for (let index = 0; index < recorded.length; index++) { + const arg = recorded[index]!; + if (unit.explicit.has(path.resolve(unit.project, arg)) || unit.processing?.owns(path.resolve(unit.project, arg))) continue; const option = splitOption(arg); const paths = classPathFlags[option.flag] || option.flag === "-Xfriend-paths" || option.flag === "--patch-module"; const sources = sourcePathFlags[option.flag]; if (paths || sources || outputFlags[option.flag]) { - const value = option.value ?? unit.args[++index]; + const value = option.value ?? recorded[++index]; if (value === undefined) throw new Error(`Missing compiler argument after ${option.flag}.`); let replaced: string; if (paths) { const delimiter = option.flag === "-Xfriend-paths" ? "," : path.delimiter; const patch = option.flag === "--patch-module" ? patchModule(value) : undefined; - replaced = [...new Set((patch?.paths ?? value).split(delimiter).flatMap((entry) => this.replacements.get(path.resolve(unit.project, entry)) ?? [entry]))].join(delimiter); + replaced = [...new Set((patch?.paths ?? value).split(delimiter).flatMap((entry) => this.build.replacements.get(path.resolve(unit.project, entry)) ?? [entry]))].join(delimiter); if (patch) replaced = `${patch.module}=${replaced}`; } else if (sources) { const delimiter = option.flag === "-Xjava-source-roots" ? "," : path.delimiter; - replaced = unit.roots.map((root) => snapshotRoot(unit, root)).join(delimiter); + const roots = unit.roots.filter((root) => generated || !unit.processing?.owns(root)); + if (!roots.length) continue; + replaced = roots.map((root) => snapshotRoot(unit, root)).join(delimiter); } else { - if (option.flag === "-d") { - if (path.resolve(unit.project, value) !== unit.original) throw new Error(`Recorded compiler destination ${value} does not match ${unit.original}.`); - destination = true; - replaced = unit.output; - } else replaced = path.join(unit.directory, "generated", `${option.flag.replace(/^-+/, "")}${option.flag === "--emit-abi" && value.endsWith(".jar") ? ".jar" : ""}`); + replaced = option.flag === "-d" ? unit.output + : path.join(unit.directory, "generated", `${option.flag.replace(/^-+/, "")}${option.flag === "--emit-abi" && value.endsWith(".jar") ? ".jar" : ""}`); } if (option.value === undefined) args.push(option.flag, replaced); else args.push(`${option.flag}=${replaced}`); @@ -692,8 +985,7 @@ export class ComposeCompiler { args.push(`${arg.slice(0, equals + 1)}${path.join(unit.directory, "plugin-output", createHash("sha256").update(arg.slice(0, equals)).digest("hex").slice(0, 16) + archive)}`); } else args.push(arg); } - if (!destination) throw new Error(`Recorded ${unit.tool} arguments have no -d destination for ${unit.original}.`); - const sources = [...unit.sources.values()]; + const sources = [...unit.sources.values()].filter((source) => generated || !unit.processing?.owns(source.file)); if (unit.tool === "kotlinc") { // Elide's BTA source list contains Kotlin only; explicitly retain Java resolution roots. const java = unit.roots.filter((root) => sources.some((source) => source.file.endsWith(".java") && inside(root, source.file))).map((root) => snapshotRoot(unit, root)); diff --git a/packages/vscode/src/composeKomodo.ts b/packages/vscode/src/composeKomodo.ts index 3ccbe04..7333470 100644 --- a/packages/vscode/src/composeKomodo.ts +++ b/packages/vscode/src/composeKomodo.ts @@ -1,14 +1,19 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { access } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; +import { connect } from "node:net"; import path from "node:path"; +import type { Readable, Writable } from "node:stream"; import type { TimelineNode } from "@elide/ide-core"; -import { ComposeBackend, type CompileMessage, type ImageStore, type PreviewRequest } from "./composeBackend.js"; -import type { CompiledPreview, PreviewImage } from "./composeRender.js"; +import { ComposeBackend, isPreviewKeyEvent, type CompileMessage, type ImageStore, type PreviewBuild, type PreviewGallery, type PreviewKeyEvent, type PreviewRequest } from "./composeBackend.js"; +import type { ComposeCompiler } from "./composeCompiler.js"; +import { StaleBuildRecordError } from "./composeProcessing.js"; +import { layoutNodes, type CompiledPreview, type PreviewImage } from "./composeRender.js"; import type { ComposeRuntime } from "./composeRuntime.js"; /** The daemon protocol this client speaks (Komodo's docs/VSCODE_PREVIEW.md, protocol 2). */ export const KOMODO_PROTOCOL = 2; const lineLimit = 8 * 1024 * 1024; +/** How long a rebuild's compiler swap waits for the retired session's compile in flight (an edit's compile takes 0.3-1.5 s). */ +const RETIRE_COMPILE_MS = 5_000; export type KomodoEvent = Record & { event: string }; @@ -16,37 +21,63 @@ function object(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** The daemon cannot serve this session (older protocol, refused inputs): every card renders with layoutlib. */ +/** The daemon cannot serve the extension (an older protocol): the preview shows why. */ export class KomodoUnavailable extends Error {} +/** Protocol-2 JSON lines over an authenticated loopback session in the workspace language server. */ +interface KomodoChannel { + readonly output: Readable; + readonly input: Writable; + readonly ended: Promise; + /** Closes this session without stopping the workspace language server. */ + stop(): void; +} + /** - * A `komodo-preview --protocol-only` daemon over stdio JSON lines, modeled on the render workers: the `ready` handshake with - * the protocol check, a line cap, per-request timeouts, kill on failure. Events arrive in order; `next` waits for one. + * A protocol-2 daemon over JSON lines, modeled on the render workers: the `ready` handshake with the protocol check, a line cap, + * per-request timeouts, the channel stopped on failure. Events arrive in order; `next` waits for one. */ export class KomodoRenderer { private stdout = ""; private readonly listeners = new Set<(event: KomodoEvent) => void>(); + private readonly closeListeners = new Set<() => void>(); private readonly waiters = new Set<{ match: (event: KomodoEvent) => boolean; resolve: (event: KomodoEvent) => void; reject: (error: Error) => void }>(); private failure: Error | undefined; + private ended = false; + /** A `sources` command is sent and its `compile` answer has not arrived: the daemon may be reading the program's inputs. */ + private compiling = false; + private readonly abort = (): void => { this.dispose(); }; + + private constructor(private readonly channel: KomodoChannel, private readonly log: (line: string) => void, private readonly signal?: AbortSignal) { + channel.output.setEncoding("utf8"); + channel.output.on("data", (chunk: string) => this.read(chunk)); + channel.input.on("error", (error) => this.fail(error)); + void channel.ended.then((reason) => { + this.ended = true; + this.fail(new Error(reason)); + }); + signal?.addEventListener("abort", this.abort, { once: true }); + if (signal?.aborted) this.abort(); + } + - private constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly log: (line: string) => void) { - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => this.read(chunk)); - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk: string) => { for (const line of chunk.split("\n")) if (line.trim()) this.log(line); }); - child.on("error", (error) => this.fail(error)); - child.on("exit", (code, signal) => this.fail(new Error(`komodo-preview exited (${signal ?? code})`))); - child.stdin.on("error", (error) => this.fail(error)); + /** Connects to the language server session, authenticating with the first UTF-8 line before protocol-2 commands. */ + static async connect(port: number, token: string, log: (line: string) => void, timeoutMs = 60_000, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const socket = connect({ host: "127.0.0.1", port }); + const ended = Promise.withResolvers(); + socket.once("close", () => ended.resolve(`the Komodo preview session on port ${port} closed`)); + socket.once("error", (error) => ended.resolve(`the Komodo preview session on port ${port} failed: ${error.message}`)); + socket.write(`${token}\n`); + return KomodoRenderer.handshake(new KomodoRenderer({ output: socket, input: socket, ended: ended.promise, stop: () => socket.destroy() }, log, signal), timeoutMs); } - /** Starts the daemon and waits for its `ready`; rejects a daemon older than protocol 2 with KomodoUnavailable. */ - static async start(command: string, args: readonly string[], log: (line: string) => void, timeoutMs = 60_000): Promise { - const child = spawn(command, ["--protocol-only", ...args], { stdio: ["pipe", "pipe", "pipe"] }); - const renderer = new KomodoRenderer(child, log); + /** Waits for `ready`; rejects a daemon older than protocol 2 with KomodoUnavailable. */ + private static async handshake(renderer: KomodoRenderer, timeoutMs: number): Promise { try { const ready = await renderer.next((event) => event.event === "ready", timeoutMs); - if (typeof ready.protocol !== "number" || ready.protocol < KOMODO_PROTOCOL) { - throw new KomodoUnavailable(`komodo-preview speaks protocol ${String(ready.protocol ?? 1)}; this extension needs ${KOMODO_PROTOCOL} or later`); + if (ready.protocol !== KOMODO_PROTOCOL) { + throw new KomodoUnavailable("Unsupported Komodo preview protocol; install a compatible Komodo through Elide, then sync."); } renderer.ready = ready; return renderer; @@ -66,49 +97,71 @@ export class KomodoRenderer { return () => this.listeners.delete(listener); } + onClose(listener: () => void): () => void { + this.closeListeners.add(listener); + return () => this.closeListeners.delete(listener); + } + send(command: Record): void { if (this.failure) throw this.failure; const line = JSON.stringify(command); if (Buffer.byteLength(line) > lineLimit) throw new Error("Komodo command exceeds the protocol's line limit."); - this.child.stdin.write(`${line}\n`, (error) => { if (error) this.fail(error); }); + this.channel.input.write(`${line}\n`, (error) => { if (error) this.fail(error); }); + if (command.command === "sources") this.compiling = true; + } + + /** Resolves once the last `sources` command's compile has answered, the session has ended, or `timeoutMs` has passed. */ + async settled(timeoutMs: number): Promise { + if (!this.compiling || this.failure) return; + await this.poll((event) => event.event === "compile", timeoutMs).catch(() => undefined); } /** The next event `match` accepts, within `timeoutMs`; a daemon failure or the timeout rejects (and a timeout kills it). */ - next(match: (event: KomodoEvent) => boolean, timeoutMs: number): Promise { + async next(match: (event: KomodoEvent) => boolean, timeoutMs: number): Promise { + const event = await this.poll(match, timeoutMs); + if (event) return event; + const error = new Error(`the preview session did not answer within ${Math.round(timeoutMs / 1000)} s`); + this.fail(error); + throw error; + } + + /** The next event `match` accepts, or undefined after `timeoutMs` with the session left running; a daemon failure rejects. */ + poll(match: (event: KomodoEvent) => boolean, timeoutMs: number): Promise { if (this.failure) return Promise.reject(this.failure); - return new Promise((resolve, reject) => { - const waiter = { - match, - resolve: (event: KomodoEvent) => { clearTimeout(timer); resolve(event); }, - reject: (error: Error) => { clearTimeout(timer); reject(error); }, - }; - const timer = setTimeout(() => { - this.waiters.delete(waiter); - const error = new Error(`komodo-preview did not answer within ${Math.round(timeoutMs / 1000)} s`); - reject(error); - this.fail(error); - }, timeoutMs); - this.waiters.add(waiter); - }); + const { promise, resolve, reject } = Promise.withResolvers(); + const waiter = { + match, + resolve: (event: KomodoEvent) => { clearTimeout(timer); resolve(event); }, + reject: (error: Error) => { clearTimeout(timer); reject(error); }, + }; + const timer = setTimeout(() => { + this.waiters.delete(waiter); + resolve(undefined); + }, timeoutMs); + this.waiters.add(waiter); + return promise; } - dispose(): void { this.fail(new Error("komodo-preview disposed")); } + dispose(): void { this.fail(new Error("the preview session closed")); } + private read(chunk: string): void { + if (this.failure) return; this.stdout += chunk; if (this.stdout.length > lineLimit && !this.stdout.includes("\n")) { - this.fail(new Error("komodo-preview wrote a line beyond the protocol's limit")); + this.fail(new Error("the preview session wrote a line beyond the protocol's limit")); return; } - for (let newline = this.stdout.indexOf("\n"); newline >= 0; newline = this.stdout.indexOf("\n")) { + for (let newline = this.stdout.indexOf("\n"); !this.failure && newline >= 0; newline = this.stdout.indexOf("\n")) { const line = this.stdout.slice(0, newline); this.stdout = this.stdout.slice(newline + 1); if (!line.trim()) continue; let event: unknown; try { event = JSON.parse(line); } - catch { this.log(`komodo-preview: unreadable event ${line.slice(0, 200)}`); continue; } + catch { this.log(`Komodo preview: unreadable event ${line.slice(0, 200)}`); continue; } if (!object(event) || typeof event.event !== "string") continue; const typed = event as KomodoEvent; + if (typed.event === "compile") this.compiling = false; for (const listener of [...this.listeners]) listener(typed); for (const waiter of [...this.waiters]) { if (!waiter.match(typed)) continue; @@ -121,88 +174,354 @@ export class KomodoRenderer { private fail(error: Error): void { if (this.failure) return; this.failure = error; + this.signal?.removeEventListener("abort", this.abort); for (const waiter of this.waiters) waiter.reject(error); this.waiters.clear(); - if (this.child.exitCode === null && !this.child.killed) this.child.kill(); + for (const listener of this.closeListeners) listener(); + this.closeListeners.clear(); + if (!this.ended) this.channel.stop(); } } -/** One daemon entry per gallery card: the card's image id, its discovered preview, and `@Preview` parameters as numbers where numeric. */ -export function komodoEntries(images: readonly PreviewImage[], previews: readonly CompiledPreview[]): Record[] { - const byId = new Map(previews.map((preview) => [preview.previewId, preview])); - const entries: Record[] = []; - const expansions = new Map(); - for (const image of images) { - const previewId = image.id.slice(0, image.id.indexOf(":")); - const preview = byId.get(previewId); - if (!preview) continue; - const index = expansions.get(previewId) ?? 0; - expansions.set(previewId, index + 1); - const parameters: Record = {}; - for (const [name, value] of Object.entries(preview.previewParams)) { - parameters[name] = /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value === "true" ? true : value === "false" ? false : value; - } +/** A `@Preview` parameter as the daemon takes it: a number where numeric, a boolean where boolean, else the string. */ +function parameterValue(value: string): unknown { + return /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value === "true" ? true : value === "false" ? false : value; +} + +/** Whether the process lists `feature` on its ready event (`reconfigure`: it takes another configure and entries in the same process). */ +function supports(daemon: KomodoRenderer, feature: string): boolean { + return Array.isArray(daemon.ready.features) && daemon.ready.features.includes(feature); +} + +/** + * The daemon's entries for the discovered previews: one card `previewId:0` per plain preview, and per `@PreviewParameter` preview + * one entry named `previewId` without an index, which the daemon expands into a `previewId:index` card per provider value. + */ +export function komodoEntries(previews: readonly CompiledPreview[]): Record[] { + return previews.map((preview) => { + const parameters = Object.fromEntries(Object.entries(preview.previewParams).map(([name, value]) => [name, parameterValue(value)])); const provider = preview.methodParams[0]; - entries.push({ - id: image.id, previewId, method: preview.methodFQN, parameters, previewParams: preview.previewParams, - methodParameter: provider ? { ...provider, index } : null, - }); - } - return entries; + return { id: provider ? preview.previewId : `${preview.previewId}:0`, previewId: preview.previewId, method: preview.methodFQN, parameters, + previewParams: preview.previewParams, methodParameter: provider ? { ...provider } : null }; + }); } -/** The daemon to run: the configured path, else `komodo-preview` beside the Elide binary, else on PATH. */ -export async function komodoCommand(configured: string | undefined, elideBin: string | undefined): Promise { - if (configured) return configured; - if (elideBin) { - const beside = path.join(path.dirname(elideBin), "komodo-preview"); - try { await access(beside); return beside; } catch { /* not installed beside Elide */ } - } - return "komodo-preview"; +/** + * The lines of a Kotlin source that decide which previews it declares (annotations, functions, classes, its package): an edit + * that leaves them unchanged needs no new discovery. + */ +export function previewDeclarations(text: string): string { + return text.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("@") || /\b(fun|class|object|interface|package)\b/.test(line)).join("\n"); } export interface KomodoOptions { - /** `elide.android.composePreview.komodo.path`, or undefined. */ - path: () => string | undefined; - /** `elide.android.composePreview.komodo.fallback`: cards Komodo cannot render keep their layoutlib snapshot. */ - fallback: () => boolean; + /** `komodo/previews` for a file: its previews from source and its errors (Komodo's language server). */ + previews: (file: string) => Promise<{ previews: CompiledPreview[]; errors: { message: string }[] }>; + /** Opens a required protocol-2 preview session in the workspace language server; older servers report an upgrade error. */ + session: (file: string) => Promise<{ protocol: number; port: number; token: string; pid?: number }>; + sync: (files: readonly string[]) => Promise; log: (line: string) => void; + /** A known protocol/capability mismatch disables native previews until compatibility is refreshed. */ + unavailable?: (reason: string) => void; + /** Shared by every backend of the window: the language server hosts one preview session at a time. */ + arbiter?: KomodoSessionArbiter; + /** Who previews with this backend (default `panel`); an agent's backend is created by {@link ComposeBackends.detached}. */ + role?: "panel" | "agent"; } +/** Why a paused panel's cards do not render. */ +export const KOMODO_PAUSED = "Paused: an agent is using the Komodo preview session (Elide MCP); this preview resumes when it ends."; + /** - * The `komodo` engine (opt-in): the layoutlib backend still builds, discovers and renders every card (a stored gallery or a - * snapshot), and a Komodo daemon renders the cards it can host live, replacing their snapshots and pushing later frames; a card it - * refuses keeps its layoutlib snapshot (or, without fallback, shows the reason). Unsaved edits reach the daemon as its sources - * command; its compile messages become the panel's diagnostics. + * The language server hosts one Komodo preview session (opening one closes the others): the backend about to open one claims it. + * An agent's claim preempts the holder; a panel's claim preempts it too, unless it is an agent backend in use ({@link setActive}), + * when the panel pauses instead. A panel that lost the session to an agent pauses as well; paused panels resume (preview again) + * once the holder turns inactive or is released. + */ +export class KomodoSessionArbiter { + private holder: KomodoBackend | undefined; + private readonly active = new Set(); + private readonly paused = new Set(); + + /** Whether `backend` may open a session now; false pauses it (a panel behind an agent in use). */ + claim(backend: KomodoBackend): boolean { + const holder = this.holder; + if (holder === backend) return true; + if (holder && backend.role === "panel" && holder.role === "agent" && this.active.has(holder)) { + this.pause(backend); + return false; + } + this.paused.delete(backend); + this.holder = backend; + if (holder?.role === "panel" && backend.role === "agent") { + this.pause(holder); + holder.preempt(KOMODO_PAUSED); + } else holder?.preempt(`Komodo: ${backend.role === "agent" ? "an agent's preview (Elide MCP)" : "the preview panel"} took the preview session.`); + return true; + } + + /** Marks an agent backend in use (a render running, a live session open); a holder turning inactive resumes paused panels. */ + setActive(backend: KomodoBackend, active: boolean): void { + if (active) this.active.add(backend); + else if (this.active.delete(backend) && this.holder === backend) this.resume(); + } + + release(backend: KomodoBackend): void { + this.active.delete(backend); + this.paused.delete(backend); + if (this.holder !== backend) return; + this.holder = undefined; + this.resume(); + } + + private pause(backend: KomodoBackend): void { + this.paused.add(backend); + backend.paused = true; + } + + private resume(): void { + const paused = [...this.paused]; + this.paused.clear(); + for (const backend of paused) backend.resume(); + } +} + +/** + * The `komodo` engine (opt-in): a Komodo daemon renders every card live; nothing renders with the layoutlib snapshot path. The + * Elide build and the preview compiler still supply the inputs (resources, the project's classes, the libraries' class paths), and + * Komodo's language server reports the previews from source (`komodo/previews`) when the session starts or an edit changes the + * file's declarations: the preview engine needs `elide.kotlinLsp.server` = `komodo`. A card Komodo cannot render shows the reason. Unsaved edits + * reach the daemon as its sources command; its compile messages become the panel's diagnostics. + * + * A daemon serves one session (one previewed file over one set of inputs). A process advertising `reconfigure` takes the next + * session's configure in the same process and a changed preview set as another entries command; an older process is replaced for + * either (it cannot open layoutlib twice, and entries sent again leave its later deltas invisible). A card that fails to render + * is closed by the daemon (Komodo cea50485 and later) and shows its reason. */ export class KomodoBackend extends ComposeBackend { override readonly engine = "komodo"; + private runtime: ComposeRuntime | undefined; private daemon: KomodoRenderer | undefined; - private starting: Promise | undefined; - /** The inputs the daemon was configured with; another set configures it again. */ - private configured = ""; - /** Why the session cannot be hosted (configure refused), until the inputs change. */ - private refusal: string | undefined; - /** The entries the daemon was last given, as JSON; equal ones are not sent again. */ + /** The process of a closed session, kept for the next session's configure when it advertises `reconfigure`. */ + private spare: KomodoRenderer | undefined; + private starting: Promise | undefined; + private prepared: KomodoRenderer | undefined; + private preparing: Promise | undefined; + /** Owns a session before it is configured and published as `daemon`. */ + private startup: AbortController | undefined; + /** The inputs and previewed file of the current session; others start a new one. */ + private session = ""; + /** Why the session's file cannot be hosted (configure refused), and the text that was refused. */ + private refusal: { reason: string; text: string } | undefined; + /** The session's discovered previews, in gallery order, and the declarations they were discovered from. */ + private previews: CompiledPreview[] | undefined; + private declarations: string | undefined; + /** Value counts of the session's `@PreviewParameter` previews, by preview id, as the daemon expanded them. */ + private readonly expansions = new Map(); + /** Cards the daemon hosted and then could not render, by reason. */ + private readonly failed = new Map(); + /** The entries the daemon was given, as JSON; others need another daemon. */ private entriesSent = ""; - private hosted = new Map(); + /** The composables of those entries: the program the daemon compiled has no other preview functions. */ + private methodsSent = new Set(); + /** The entries' first frames are being collected: their failures are handled there, not by the event listener. */ + private hosting = false; + private hosted = new Set(); private refused = new Map(); - private latest = new Map(); + private readonly latest = new Map(); + /** The program's texts as the daemon last compiled or was pushed them. */ private lastDocuments = new Map(); - private logged = ""; + /** Other sources the editor changed, compiled in the program beside the session's file (ahead of their build classes). */ + private programFiles: string[] = []; + /** The session's file and the entries hosted for it, which a process configured again after a rejected delta hosts. */ + private sessionFile: string | undefined; + private entries: readonly Record[] = []; + /** A delta was rejected and the process is not configured again yet; that configure, queued behind the running preview. */ + private rejected = false; + private recovering: Promise | undefined; + /** The current session's last error that named no card: the reason it closed, if it then did. */ + private lastError: string | undefined; + /** How long hosting waits for the cards' first frames; a card without one by then fails alone. */ + protected readonly firstFrameMs: number = 60_000; private readonly imageListeners = new Set<(images: PreviewImage[]) => void>(); private readonly compileListeners = new Set<(outcome: { failed: boolean; messages: CompileMessage[] }) => void>(); + private readonly inputListeners = new Set<(imageId?: string) => void>(); + private readonly sessionListeners = new Set<() => void>(); + private readonly inputEntries = new Set(); + /** The libraries' sources jars by classes path, from the last build's models: Komodo expands library inline functions from them. */ + private librarySources = new Map(); + + /** Text the daemon appends to program files (a selection's generated wrappers), by absolute path. */ + private appendices: ReadonlyMap = new Map(); + /** Lost the session to an agent, or found an agent using it: previews show {@link KOMODO_PAUSED} until {@link resume}. */ + paused = false; + /** Counts preemptions: a session configured across one is the language server's no longer. */ + private preemptions = 0; + /** The appendices the daemon compiles, as JSON. */ + private appendicesSent = "[]"; - constructor(key: string, cacheRoot: string, helperDirectory: string, store: ImageStore, private readonly options: KomodoOptions, private readonly elideBin: () => string | undefined) { + constructor(key: string, cacheRoot: string, helperDirectory: string, store: ImageStore, private readonly options: KomodoOptions) { super(key, cacheRoot, helperDirectory, store); } - override pointer(imageId: string, action: "down" | "up" | "move", x: number, y: number): boolean { - if (!this.daemon?.alive || !this.hosted.has(imageId)) return false; - this.daemon.send({ command: "pointer", entry: imageId, action, x, y }); + /** Validate the native session before a preview allocates runtime/compiler inputs or builds the project. */ + async prepareSession(file: string, appendices = false, signal?: AbortSignal): Promise { + this.signal.throwIfAborted(); + signal?.throwIfAborted(); + if (this.paused || this.options.arbiter && !this.options.arbiter.claim(this)) throw new Error(`Komodo: ${KOMODO_PAUSED}`); + try { + // This method is required for unsaved buffers too; reject unsupported servers before any preview build. + await this.options.sync([file]); + this.signal.throwIfAborted(); + signal?.throwIfAborted(); + const renderer = this.daemon?.alive ? this.daemon : this.prepared?.alive ? this.prepared + : await (this.preparing ??= (async () => { + const controller = new AbortController(); + const abort = () => controller.abort(signal?.reason); + signal?.addEventListener("abort", abort, { once: true }); + this.startup = controller; + try { + const renderer = await this.open(file, AbortSignal.any([this.signal, controller.signal])); + this.prepared = renderer; + return renderer; + } finally { + signal?.removeEventListener("abort", abort); + if (this.startup === controller) this.startup = undefined; + this.preparing = undefined; + } + })()); + this.signal.throwIfAborted(); + signal?.throwIfAborted(); + if (appendices && !supports(renderer, "appendices")) { + throw new KomodoUnavailable("Previewing a composable without @Preview needs Komodo's appendices capability. Install a compatible Komodo through Elide, then sync."); + } + } catch (error) { + if (error instanceof KomodoUnavailable) this.options.unavailable?.(error.message); + throw error; + } + } + + get role(): "panel" | "agent" { return this.options.role ?? "panel"; } + + /** + * Another backend took the language server's session: this one's daemon closes quietly (no lost session) and its live cards fail + * with `reason`. The next preview opens a new session. + */ + preempt(reason: string): void { + this.resetInput(); + this.preemptions++; + const daemon = this.daemon; + // Unset first: closing it on purpose is no lost session. + this.daemon = undefined; + this.session = ""; + this.startup?.abort(); + daemon?.dispose(); + this.spare?.dispose(); + this.spare = undefined; + this.prepared?.dispose(); + this.prepared = undefined; + const cards = [...this.hosted].map((id) => { + this.latest.delete(id); + return { ...this.base(id), error: reason }; + }); + this.hosted.clear(); + if (cards.length) for (const listener of this.imageListeners) listener(cards); + } + + /** The agent that paused this panel is done: the panel previews again (and claims the session). */ + resume(): void { + if (!this.paused) return; + this.paused = false; + if (!this.disposed) for (const listener of this.sessionListeners) listener(); + } + + /** The daemon's own compile replaces the preview compiler's warm-up and annotation-processing confirmation. */ + override get needsWarmup(): boolean { return false; } + override get processingRequired(): boolean { return false; } + override async warm(): Promise {} + override async verify(): Promise { return false; } + + /** No render JVMs: the runtime is the daemon's layoutlib host. */ + override async startRenderer(runtime: ComposeRuntime): Promise { + this.signal.throwIfAborted(); + this.runtime = runtime; + } + + override async configure(build: PreviewBuild, documents: ReadonlyMap, verified: boolean): Promise { + this.librarySources = new Map(build.models.flatMap((model) => model.libraries) + .flatMap((library) => library.sources ? [[path.resolve(library.classes), library.sources] as const] : [])); + return super.configure(build, documents, verified); + } + + /** Starts the daemon for `request` from the last build's outputs, so the panel opens on live cards. Never builds. */ + override async prewarm(build: PreviewBuild, runtime: ComposeRuntime, java: string, request: PreviewRequest, onStep: (step: string) => void = () => {}): Promise { + await this.startRenderer(runtime); + onStep("preparing the compiler inputs"); + try { + await this.configure(build, request.documents, false); + } catch (error) { + // Never built, or a build is rewriting its outputs: the next panel builds first. A stale record needs more than that build. + if (error instanceof StaleBuildRecordError) throw error; + return; + } + onStep("starting the Komodo preview session"); + await this.preview(request); + } + + /** Pushes an edit of a file of the session's program at once; the refresh that follows finds it pushed. */ + override edit(file: string, text: string): void { + if (this.daemon?.alive && !this.hosting && this.lastDocuments.has(file)) this.push(new Map([[file, text]]), this.daemon); + } + + /** The workspace preview compiler accepts Kotlin sources only. */ + override compiles(file: string): boolean { + return file.endsWith(".kt") && super.compiles(file); + } + + /** Each card as its latest frame (or failure) has it: a frame may arrive between `preview` and the panel showing its gallery. */ + override latestImages(images: readonly PreviewImage[]): readonly PreviewImage[] { + return images.map((image) => { + const reason = this.failed.get(image.id); + return this.latest.get(image.id) ?? (reason === undefined ? image : this.failedCard(image.id, reason)); + }); + } + + override pointer(imageId: string, action: "down" | "up" | "move" | "scroll", x: number, y: number, dx?: number, dy?: number): boolean { + if (!this.daemon?.alive || this.hosting || !this.hosted.has(imageId)) return false; + if (action === "down" && supports(this.daemon, "keyboard")) this.inputEntries.add(imageId); + this.daemon.send({ command: "pointer", entry: imageId, action, x, y, ...action === "scroll" ? { dx: dx ?? 0, dy: dy ?? 0 } : {} }); return true; } + override keyboardSupported(imageId: string): boolean { + return !!this.daemon?.alive && !this.hosting && this.hosted.has(imageId) && supports(this.daemon, "keyboard"); + } + + override keyboard(imageId: string, event: PreviewKeyEvent): boolean { + if (!this.keyboardSupported(imageId) || !isPreviewKeyEvent(event)) return false; + this.inputEntries.add(imageId); + this.daemon!.send({ ...event, command: "keyboard", entry: imageId }); + return true; + } + + override blur(imageId: string): boolean { + this.inputEntries.delete(imageId); + if (!this.daemon?.alive || !this.hosted.has(imageId) || !supports(this.daemon, "keyboard")) return false; + this.daemon.send({ command: "blur", entry: imageId }); + return true; + } + + override onInputReset(listener: (imageId?: string) => void): () => void { + this.inputListeners.add(listener); + return () => this.inputListeners.delete(listener); + } + + private resetInput(imageId?: string): void { + if (imageId !== undefined) this.blur(imageId); + else for (const entry of this.inputEntries) this.blur(entry); + for (const listener of this.inputListeners) listener(imageId); + } + override onImages(listener: (images: PreviewImage[]) => void): () => void { this.imageListeners.add(listener); return () => this.imageListeners.delete(listener); @@ -213,121 +532,487 @@ export class KomodoBackend extends ComposeBackend { return () => this.compileListeners.delete(listener); } - override preview(request: PreviewRequest, compile?: true, trace?: TimelineNode): Promise; - override preview(request: PreviewRequest, compile: boolean, trace?: TimelineNode): Promise; - override async preview(request: PreviewRequest, compile = true, trace?: TimelineNode): Promise { - const images = await super.preview(request, compile, trace); - if (!images) return images; - try { - return trace ? await trace.run("Komodo", () => this.overlay(images, request)) : await this.overlay(images, request); - } catch (error) { - // The live engine failed: every card keeps its snapshot, and the reason is in the log and the Activity view. - const reason = error instanceof Error ? error.message : String(error); - this.options.log(`Komodo: ${reason}; the layoutlib snapshots stand`); - trace?.record("Komodo fallback", 0, "failed", reason); - return images; - } + override onSessionLost(listener: () => void): () => void { + this.sessionListeners.add(listener); + return () => this.sessionListeners.delete(listener); } - override dispose(): void { - this.daemon?.dispose(); + /** + * The session was configured over `previous`'s inputs (its published class copies), which are deleted next: a compile reading + * them then fails and ends the session. No further edit reaches it; one it is compiling now finishes first (up to + * {@link RETIRE_COMPILE_MS}); then it closes, also as a `spare`, since its process may keep reading those inputs. The next + * preview opens a session over the new compiler's inputs. + */ + protected override async retire(_previous: ComposeCompiler): Promise { + this.session = ""; + const retired = [this.daemon, this.spare]; + // Unset first: closing them on purpose is no lost session, and reset keeps no spare. this.daemon = undefined; - super.dispose(); + this.spare = undefined; + this.startup?.abort(); + this.reset(); + await Promise.all(retired.map((daemon) => daemon?.settled(RETIRE_COMPILE_MS))); + for (const daemon of retired) daemon?.dispose(); + } + + /** + * The live gallery for `request`. Without `compile` (the panel's paint before the build confirms) the session starts from the + * last build's classes, as a prewarm's does: the confirmed inputs start another only if the build changed them. Calls run one + * after another: a panel's call during a prewarm's waits for its daemon instead of starting a second one. + */ + override preview(request: PreviewRequest, compile?: true, trace?: TimelineNode): Promise; + override preview(request: PreviewRequest, compile: boolean, trace?: TimelineNode): Promise; + override preview(request: PreviewRequest, compile = true, trace?: TimelineNode): Promise { + const run = this.previewing.then(async () => { + const preemptions = this.preemptions; + try { + return await this.previewNow(request, compile, trace); + } catch (error) { + if (error instanceof KomodoUnavailable) this.options.unavailable?.(error.message); + // An agent took the session meanwhile (its configure or hosting then fails): the panel pauses, as if it had found it in use. + if (this.paused && this.preemptions !== preemptions && !this.disposed) return { images: this.pausedGallery(), verified: true, reused: false }; + throw error; + } + }); + this.previewing = run.catch(() => {}); + return run; } - /** Replaces hosted cards' snapshots by the daemon's frames; refused cards keep theirs (or show the reason without fallback). */ - private async overlay(images: PreviewImage[], request: PreviewRequest): Promise { + private previewing: Promise = Promise.resolve(); + + private async previewNow(request: PreviewRequest, compile: boolean, trace?: TimelineNode): Promise { + this.signal.throwIfAborted(); const compiler = this.compiler; - const previews = this.discovery; - if (!compiler || !previews.length) return images; - const inputs = JSON.stringify(compiler.inputs); - if (inputs !== this.configured) { - this.configured = inputs; - this.refusal = undefined; - this.entriesSent = ""; + if (!compiler) throw new Error("Compose preview has no compiler inputs; build the project first."); + // The compiler's first publication moves the project classes from the build's outputs to its copies: a new session. + // A selection's program (its files, the files it appends to) is part of the session; absent, the key is the panel's as before. + const selection = request.selection && { + files: [...new Set([...request.selection.files, ...request.selection.appendices.keys()])].filter((file) => file !== request.sourceFile).sort(), + appendices: [...request.selection.appendices.keys()].sort(), + }; + const session = JSON.stringify({ fingerprint: compiler.fingerprint, inputs: compiler.inputs, current: compiler.currentInputs, source: request.sourceFile, selection }); + if (session !== this.session) { + this.session = session; + this.sessionFile = request.sourceFile; + this.reset(); + if (selection) this.programFiles = [...selection.files]; } - if (this.refusal) return this.fallback(images, () => this.refusal!); - const daemon = await this.start(); - if (!daemon) return this.fallback(images, () => this.refusal ?? "komodo-preview is unavailable"); - if (!this.entriesSent) { - const runtime = this.runtime; - if (!runtime) return this.fallback(images, () => "the layoutlib runtime is not installed yet"); - // The renderer's class path without the project's kotlinc classes: Komodo's own classes of the program take their place. - const own = new Set(compiler.inputs.projectClassPath); - daemon.send({ - command: "configure", host: "layoutlib", ...compiler.inputs, classPath: compiler.inputs.classPath.filter((entry) => !own.has(entry)), - layoutlib: runtime.layoutlibPath, rendererClassPath: runtime.rendererClassPath, - sources: [...new Set(Object.values(compiler.inputs.sourceSelections).flat())], - compile: { classPath: compiler.compileClassPath }, - }); - const answer = await daemon.next((event) => event.event === "configured" || (event.event === "error" && event.kind === "configure"), 300_000); - if (answer.event === "error") { - this.refusal = typeof answer.message === "string" ? answer.message : "komodo-preview refused the session"; - return this.fallback(images, () => this.refusal!); + this.appendices = request.selection?.appendices ?? new Map(); + // Without a live session, an agent using the language server's session pauses a panel; a paused panel claims nothing. + if (this.paused || !this.daemon?.alive && this.options.arbiter && !this.options.arbiter.claim(this)) { + if (!this.previews) this.previews = request.selection ? [...request.selection.previews] : (await this.discover(request)).previews; + return { images: this.pausedGallery(), verified: true, reused: false }; + } + // Sources the editor changed that the previewed file's program compiles beside it (its module's, its dependencies'), ahead of + // their build classes, which hold their saved text. + const joining = compile ? await this.joining(request) : []; + this.programFiles.push(...joining); + // An edit of the live program reaches the daemon before any discovery it needs. + if (this.daemon?.alive && !joining.length) this.push(request.documents, this.daemon); + // A session without a daemon configures one while its previews are discovered: configure needs only the file and the inputs + // (on a cold language server, discovery waits for its first analysis: 2.2 s of a cold open on elide-android-sample). + const restart = () => trace ? trace.run("Start Komodo preview session", () => this.restart(request.sourceFile, request.documents)) : this.restart(request.sourceFile, request.documents); + // Another file the editor changed joins the program: a configure again, with its text. + const restarting = this.daemon?.alive && !joining.length ? undefined : restart(); + restarting?.catch(() => {}); + if (request.selection) this.previews = [...request.selection.previews]; + else { + const text = request.documents.get(request.sourceFile) ?? await readFile(request.sourceFile, "utf8"); + const declarations = previewDeclarations(text); + if (declarations !== this.declarations) { + const found = await (trace ? trace.run("Discover previews", () => this.discover(request)) : this.discover(request)); + this.previews = found.previews; + // Kept previews leave the declarations undiscovered: a body edit that clears the errors discovers again. + this.declarations = found.complete ? declarations : undefined; } - this.entriesSent = "configured"; } - const entries = komodoEntries(images, previews); + const entries = komodoEntries(this.previews!); const entriesJson = JSON.stringify(entries); - if (entriesJson !== this.entriesSent) { + if (restarting || entriesJson !== this.entriesSent) { + // A daemon that takes entries again (`reconfigure`) hosts a changed preview set itself, unless a preview function is new: its + // program has no such method. One that compiles a configure's unsaved texts (`texts`) configures again with the buffer. + const added = entries.some((entry) => !this.methodsSent.has(String(entry.method))); + const live = !restarting && this.daemon?.alive && supports(this.daemon, "reconfigure") && !(added && supports(this.daemon, "texts")) ? this.daemon : undefined; + const daemon = live ?? await (restarting ?? restart()); this.entriesSent = entriesJson; - this.latest.clear(); - this.lastDocuments = new Map(request.documents); - const frames = new Map(); - const stop = daemon.on((event) => { if (event.event === "frame" && typeof event.entry === "string" && !frames.has(event.entry)) frames.set(event.entry, event); }); - try { - daemon.send({ command: "entries", entries }); - const answer = await daemon.next((event) => event.event === "entries", 120_000); - this.hosted = new Map((Array.isArray(answer.hosted) ? answer.hosted : []).filter((id): id is string => typeof id === "string").map((id) => [id, id])); - this.refused = new Map((Array.isArray(answer.refused) ? answer.refused : []).filter((id): id is string => typeof id === "string").map((id) => [id, "refused by komodo-preview"])); - const deadline = Date.now() + 60_000; - while ([...this.hosted.keys()].some((id) => !frames.has(id)) && Date.now() < deadline) { - await daemon.next((event) => event.event === "frame" || (event.event === "error" && typeof event.entry === "string"), Math.max(1, deadline - Date.now())) - .then((event) => { if (event.event === "error" && typeof event.entry === "string") this.hosted.delete(event.entry); }); - } - } finally { - stop(); - } - for (const [id, frame] of frames) this.latest.set(id, this.card(images.find((image) => image.id === id), frame)); - } else this.push(request.documents, daemon); - return images.map((image) => this.latest.get(image.id) ?? (this.refused.has(image.id) ? this.refusedCard(image, this.refused.get(image.id)!) : image)); + this.entries = entries; + this.methodsSent = new Set(entries.map((entry) => String(entry.method))); + await (trace ? trace.run("Host cards", () => this.host(daemon, entries)) : this.host(daemon, entries)); + this.push(request.documents, daemon); + } + // A delta rejected while the cards were being hosted. + this.reconfigureRejected(); + if (request.inspect && this.daemon && !supports(this.daemon, "layout")) { + throw new Error("Layout inspection needs a newer Komodo. Install a compatible Komodo through Elide and reload the window."); + } + return { images: this.gallery(), verified: true, reused: false }; } - /** The documents that changed since the last push, sent as the daemon's sources command. */ - private push(documents: ReadonlyMap, daemon: KomodoRenderer): void { - const files: Record = {}; - for (const [file, text] of documents) if (file.endsWith(".kt") && this.lastDocuments.get(file) !== text) files[file] = text; - this.lastDocuments = new Map(documents); - if (Object.keys(files).length && this.hosted.size) daemon.send({ command: "sources", files }); + override dispose(): void { + this.resetInput(); + const daemon = this.daemon; + // Unset first: closing it on purpose is no lost session. + this.daemon = undefined; + daemon?.dispose(); + this.spare?.dispose(); + this.spare = undefined; + this.prepared?.dispose(); + this.prepared = undefined; + this.options.arbiter?.release(this); + super.dispose(); + } + + /** The discovered cards of a paused panel, each naming why it does not render. */ + private pausedGallery(): PreviewImage[] { + return (this.previews ?? []).flatMap((preview) => Array.from({ length: preview.methodParams.length ? this.expansions.get(preview.previewId) ?? 1 : 1 }, + (_, index) => ({ ...this.base(`${preview.previewId}:${index}`), error: KOMODO_PAUSED }))); } - private async start(): Promise { - if (this.daemon?.alive) return this.daemon; - this.starting ??= (async () => { + + /** A new session: nothing discovered, hosted or left out yet. */ + private reset(): void { + this.startup?.abort(); + this.resetInput(); + const previous = this.daemon; + this.daemon = undefined; + if (previous?.alive && supports(previous, "reconfigure")) this.spare = previous; + else previous?.dispose(); + this.refusal = undefined; + this.previews = undefined; + this.declarations = undefined; + this.expansions.clear(); + this.failed.clear(); + this.entriesSent = ""; + this.entries = []; + this.rejected = false; + this.methodsSent.clear(); + this.programFiles = []; + this.hosted.clear(); + this.refused.clear(); + this.latest.clear(); + } + + /** + * The previews the documents declare, from Komodo's language server; an edit with errors keeps those discovered before it, and + * is not `complete`: those may lack a preview the edit declares. + */ + private async discover(request: PreviewRequest): Promise<{ previews: CompiledPreview[]; complete: boolean }> { + const found = await this.options.previews(request.sourceFile); + this.signal.throwIfAborted(); + if (found.errors.length > 0 && this.previews) { + this.options.log(`Komodo: ${path.basename(request.sourceFile)} has errors; keeping the last previews (${found.errors[0]!.message.split("\n")[0]})`); + return { previews: this.previews, complete: false }; + } + return { previews: found.previews, complete: true }; + } + + /** Open sources outside the program whose editor text differs from the file and that the previewed file's compiler accepts. */ + private async joining(request: PreviewRequest): Promise { + const compiler = this.compiler!; + const candidates = [...request.documents].filter(([file]) => file !== request.sourceFile && file.endsWith(".kt") + && !this.programFiles.includes(file) && compiler.supports(file)); + const changed = await Promise.all(candidates.map(async ([file, text]) => await readFile(file, "utf8").catch(() => undefined) !== text ? file : undefined)); + return changed.filter((file): file is string => file !== undefined); + } + + /** Configures all selected workspace sources together; source errors never exclude a dependency or retry its saved text. */ + private async restart(sourceFile: string, documents: ReadonlyMap): Promise { + const controller = new AbortController(); + this.startup = controller; + const signal = AbortSignal.any([this.signal, controller.signal]); + // A reused daemon predates this startup's signal, but must also close if configuration is cancelled. + const previous = this.daemon ?? this.spare; + const abort = () => previous?.dispose(); + signal.addEventListener("abort", abort, { once: true }); + try { + return await this.restartNow(sourceFile, documents, signal); + } finally { + signal.removeEventListener("abort", abort); + if (this.startup === controller) this.startup = undefined; + if (previous !== this.daemon && previous !== this.spare) previous?.dispose(); + } + } + + private async restartNow(sourceFile: string, documents: ReadonlyMap, signal: AbortSignal): Promise { + signal.throwIfAborted(); + this.resetInput(); + const preemptions = this.preemptions; + const previous = this.daemon ?? this.spare; + this.daemon = undefined; + this.spare = undefined; + const reused = previous?.alive && supports(previous, "reconfigure") ? previous : undefined; + if (!reused) previous?.dispose(); + const saved = new Map(await Promise.all([sourceFile, ...this.programFiles].map(async (file) => [file, + await readFile(file, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" && documents.has(file)) return documents.get(file)!; + throw error; + }), + ] as const))); + signal.throwIfAborted(); + const compiled = new Map([...saved].map(([file, text]) => [file, documents.get(file) ?? text] as const)); + const key = (texts: ReadonlyMap) => JSON.stringify([[...texts], [...this.appendices]]); + if (this.refusal?.text === key(compiled)) { + this.spare = reused; + throw new Error(`Komodo: ${this.refusal.reason}`); + } + const daemon = reused ?? await (this.starting ??= (async () => { try { - const command = await komodoCommand(this.options.path(), this.elideBin()); - const daemon = await KomodoRenderer.start(command, ["--stdio-frames", "png"], this.options.log); - this.options.log(`Komodo: ${command} ready (protocol ${String(daemon.ready.protocol)}, host ${String(daemon.ready.host)}, Kotlin ${String(daemon.ready.kotlin)})`); - daemon.on((event) => this.event(event)); - this.daemon = daemon; - this.entriesSent = ""; - return daemon; - } catch (error) { - this.refusal = error instanceof Error ? error.message : String(error); - return undefined; + const started = this.prepared?.alive ? this.prepared : await this.open(sourceFile, signal); + this.prepared = undefined; + return started; } finally { this.starting = undefined; } - })(); - return this.starting; + })()); + const reason = await this.configureDaemon(daemon, compiled, signal).catch((error: unknown) => { + daemon.dispose(); + throw error; + }); + if (reason) { + // A process that takes another configure stays for the next attempt. + if (supports(daemon, "reconfigure")) this.spare = daemon; + else daemon.dispose(); + this.refusal = { reason, text: key(compiled) }; + throw new Error(`Komodo: ${reason}`); + } + // Preempted while it was configured: the language server closed this session for the other backend's. + if (this.preemptions !== preemptions) { + daemon.dispose(); + throw new Error(`Komodo: ${KOMODO_PAUSED}`); + } + this.refusal = undefined; + // The daemon compiled `compiled`: a later editor text is the first push. + this.lastDocuments = new Map(compiled); + this.lastError = undefined; + this.daemon = daemon; + return daemon; + } + + /** + * The current session closed on its own (a language-server reload or restart, a crash): its live cards fail with the reason and + * the panel previews again, which reconnects. A close while hosting fails that preview instead; one with no live card is quiet. + */ + private closed(daemon: KomodoRenderer): void { + if (this.daemon !== daemon) return; + this.resetInput(); + if (this.hosting) return; + this.daemon = undefined; + if (!this.hosted.size) return; + const reason = `the preview session closed${this.lastError ? ` (${this.lastError})` : ""}`; + const cards = [...this.hosted].map((id) => { + this.latest.delete(id); + this.failed.set(id, reason); + return this.failedCard(id, reason); + }); + this.hosted.clear(); + for (const listener of this.imageListeners) listener(cards); + for (const listener of this.sessionListeners) listener(); + } + + /** Opens a session in the workspace language server; there is no standalone compiler fallback. */ + private async open(sourceFile: string, signal: AbortSignal): Promise { + signal.throwIfAborted(); + if (this.options.arbiter && !this.options.arbiter.claim(this)) throw new Error(`Komodo: ${KOMODO_PAUSED}`); + const session = await this.options.session(sourceFile); + signal.throwIfAborted(); + if (session.protocol !== KOMODO_PROTOCOL) { + throw new KomodoUnavailable(`Unsupported Komodo preview protocol ${session.protocol}; expected ${KOMODO_PROTOCOL}. Install a compatible Komodo through Elide, then sync.`); + } + const connected = await KomodoRenderer.connect(session.port, session.token, this.options.log, 60_000, signal); + if (connected.ready.compiler !== "workspace-fir") { + connected.dispose(); + throw new KomodoUnavailable("The Komodo preview engine needs a compatible workspace-fir language server. Install a compatible Komodo through Elide, then sync."); + } + if (signal.aborted) { + connected.dispose(); + signal.throwIfAborted(); + } + this.options.log(`Komodo: preview session in the language server ready (protocol ${String(connected.ready.protocol)}, host ${String(connected.ready.host)}, Kotlin ${String(connected.ready.kotlin)}, compiler ${String(connected.ready.compiler ?? "unreported")}, pid ${String(session.pid ?? connected.ready.pid ?? "unreported")})`); + connected.on((event) => { + if (this.daemon !== connected) return; + if (event.event === "error" && typeof event.entry !== "string" && event.kind !== "rejected" && typeof event.message === "string") { + this.lastError = event.message.split("\n")[0]; + } + this.event(event); + }); + connected.onClose(() => this.closed(connected)); + return connected; + } + + /** Configures the daemon's session over the compiler's inputs, compiling `texts` (the program's files, by path); the refusal, or undefined. */ + private async configureDaemon(daemon: KomodoRenderer, texts: ReadonlyMap, signal: AbortSignal): Promise { + const compiler = this.compiler!; + const runtime = this.runtime; + if (!runtime) return "the layoutlib runtime is not installed yet"; + if (this.appendices.size && !supports(daemon, "appendices")) { + throw new KomodoUnavailable("Previewing a composable without @Preview needs a compatible Komodo (the appendices feature). Install it through Elide, then sync."); + } + const appendices = JSON.stringify([...this.appendices]); + await this.pushing; + await this.options.sync([...texts.keys()]); + signal.throwIfAborted(); + // A save's build replaced the compiler meanwhile and deletes its inputs: the next preview configures over the new ones. + const replaced = () => { + if (this.compiler !== compiler) throw new Error("the preview compiler was replaced while the session was configured; preview again"); + }; + replaced(); + const inputs = compiler.currentInputs; + const own = new Set(inputs.projectClassPath); + const compileClassPath = compiler.compileClassPath; + // A program that does not compile is a compile event, then the configure error: the refusal names the first errors. + const errors: string[] = []; + const stop = daemon.on((event) => { + if (signal.aborted || event.event !== "compile" || event.outcome !== "failure" || !Array.isArray(event.messages)) return; + // During configuration this.daemon is deliberately unset, so the live listener cannot publish these diagnostics. + this.event(event); + for (const message of event.messages.filter(object)) { + if (message.level !== "error" || typeof message.text !== "string") continue; + const at = typeof message.file === "string" ? `${path.basename(message.file)}${typeof message.line === "number" ? `:${message.line}` : ""}: ` : ""; + errors.push(`${at}${message.text}`); + } + }); + daemon.send({ + command: "configure", host: "layoutlib", namespace: inputs.namespace, resourceApkPath: inputs.resourceApkPath, + // Libraries and R classes; the project's own classes go behind the program (projectClassPath), which replaces the previewed file's. + classPath: inputs.classPath.filter((entry) => !own.has(entry)), projectClassPath: inputs.projectClassPath, + layoutlib: runtime.layoutlibPath, rendererClassPath: runtime.rendererClassPath, + sources: [...texts.keys()], + // Include new unsaved files too; the server's versioned Workspace buffer remains authoritative. + texts: Object.fromEntries(texts), + inlineSources: [...new Set(compileClassPath.flatMap((entry) => this.librarySources.get(path.resolve(entry)) ?? []))], + compile: { classPath: compileClassPath }, + ...this.appendices.size ? { appendices: Object.fromEntries(this.appendices) } : {}, + }); + const answer = await daemon.next((event) => event.event === "configured" || (event.event === "error" && event.kind === "configure"), 300_000) + .finally(stop); + signal.throwIfAborted(); + replaced(); + if (answer.event === "error") { + const reason = typeof answer.message === "string" ? answer.message : "the preview session refused the program"; + return errors.length ? `${reason} (${errors.slice(0, 3).join("; ")}${errors.length > 3 ? `; ${errors.length - 3} more` : ""})` : reason; + } + this.appendicesSent = appendices; + for (const listener of this.compileListeners) listener({ failed: false, messages: [] }); + return undefined; + } + + /** + * Sends the entries and collects every hosted card's first frame into `latest`, learning the provider expansions the daemon made. + * Refused cards keep their reason in `refused`; cards it hosted and then could not render, in `failed`. + */ + private async host(daemon: KomodoRenderer, entries: readonly Record[]): Promise { + this.resetInput(); + this.latest.clear(); + this.failed.clear(); + this.expansions.clear(); + const frames = new Map(); + const errors = new Map(); + // Recorded from the entries answer on, in the listener (errors can share a chunk with it): before the answer, a frame is the + // previous scenes', which keep rendering until the new cards' first frames and can carry the same card ids. + let answered = false; + const stop = daemon.on((event) => { + if (event.event === "entries") answered = true; + if (!answered || typeof event.entry !== "string") return; + if (event.event === "frame" && !frames.has(event.entry)) frames.set(event.entry, event); + if (event.event === "error" && event.fallback === true) errors.set(event.entry, typeof event.message === "string" ? event.message : "this card cannot be rendered live"); + }); + this.hosting = true; + try { + daemon.send({ command: "entries", entries }); + const answer = await daemon.next((event) => event.event === "entries", 120_000); + const ids = (value: unknown) => (Array.isArray(value) ? value : []).filter((id): id is string => typeof id === "string"); + this.hosted = new Set(ids(answer.hosted)); + const refused = ids(answer.refused); + const deadline = Date.now() + this.firstFrameMs; + while ([...this.hosted].some((id) => !frames.has(id) && !errors.has(id)) && Date.now() < deadline) { + // A card still without a frame at the deadline fails alone; the session and the other cards stay live. + const event = await daemon.poll((candidate) => typeof candidate.entry === "string" && (candidate.event === "frame" || (candidate.event === "error" && candidate.fallback === true)), Math.max(1, deadline - Date.now())); + if (!event) break; + } + this.refused = new Map(refused.map((id) => [id, errors.get(id) ?? "this card cannot be rendered live"])); + for (const id of this.hosted) { + if (errors.has(id)) this.failed.set(id, errors.get(id)!); + else if (!frames.has(id)) this.failed.set(id, `no frame was rendered within ${Math.round(this.firstFrameMs / 1000)} s`); + } + // An entry without an index expanded into one card per provider value. + for (const entry of entries) { + const methodParameter = entry.methodParameter; + if (!object(methodParameter) || "index" in methodParameter) continue; + const prefix = `${String(entry.id)}:`; + const indices = [...this.hosted, ...refused].filter((id) => id.startsWith(prefix)).map((id) => Number(id.slice(prefix.length))); + if (indices.length) this.expansions.set(String(entry.id), Math.max(...indices) + 1); + } + } finally { + this.hosting = false; + stop(); + } + for (const id of this.failed.keys()) this.hosted.delete(id); + for (const id of this.hosted) this.latest.set(id, this.card(this.base(id), frames.get(id)!)); } - /** Pushed frames and compile outcomes, after `preview` resolved. */ + /** The gallery: one card per discovered preview, or per provider value, live or showing why it is not. */ + private gallery(): PreviewImage[] { + return (this.previews ?? []).flatMap((preview) => { + const count = preview.methodParams.length ? this.expansions.get(preview.previewId) : 1; + // A provider preview the daemon did not expand (its provider failed) is one card naming the reason. + if (count === undefined) return [this.failedCard(`${preview.previewId}:0`, this.refused.get(preview.previewId) ?? "the @PreviewParameter provider was not expanded")]; + return Array.from({ length: count }, (_, index) => { + const id = `${preview.previewId}:${index}`; + return this.latest.get(id) ?? this.failedCard(id, this.failed.get(id) ?? this.refused.get(id) ?? "no frame was rendered"); + }); + }); + } + + /** A card's name and detail from its preview (`previewId:index`). */ + private base(id: string): PreviewImage { + const preview = this.previews?.find((candidate) => candidate.previewId === id.slice(0, id.lastIndexOf(":"))); + return { id, name: preview?.name ?? id, detail: preview?.methodFQN ?? "" }; + } + + private failedCard(id: string, reason: string): PreviewImage { + return { ...this.base(id), error: `Komodo: ${reason}` }; + } + + /** The program's files, sent as the daemon's sources command when an editor text in `documents` changed since the last push. */ + private push(documents: ReadonlyMap, daemon: KomodoRenderer): void { + const changed = new Map(documents); + this.pushing = this.pushing.then(async () => { + if (!daemon.alive || this.daemon !== daemon) return; + const appendices = JSON.stringify([...this.appendices]); + if (!this.hosted.size || appendices === this.appendicesSent + && ![...this.lastDocuments].some(([file, text]) => changed.has(file) && changed.get(file) !== text)) return; + await this.options.sync([...this.lastDocuments.keys()]); + if (!daemon.alive || this.daemon !== daemon) return; + for (const file of this.lastDocuments.keys()) this.lastDocuments.set(file, changed.get(file) ?? this.lastDocuments.get(file)!); + this.resetInput(); + // `appendices` replaces the session's; absent, they are unchanged. + const replaced = appendices === this.appendicesSent ? {} : { appendices: Object.fromEntries(this.appendices) }; + this.appendicesSent = appendices; + daemon.send({ command: "sources", files: Object.fromEntries(this.lastDocuments), ...replaced }); + }).catch((error: unknown) => { + if (this.daemon !== daemon || this.signal.aborted) return; + // Closed on purpose: no lost session to report. + if (this.daemon === daemon) this.daemon = undefined; + daemon.dispose(); + const message = `Komodo preview synchronization failed: ${error instanceof Error ? error.message : String(error)}`; + this.options.log(message); + for (const listener of this.compileListeners) listener({ failed: true, messages: [{ level: "error", text: message }] }); + }); + } + + private pushing: Promise = Promise.resolve(); + + + /** Pushed frames, compile outcomes and cards that stopped rendering, after `preview` resolved. */ private event(event: KomodoEvent): void { if (event.event === "frame" && typeof event.entry === "string" && this.hosted.has(event.entry) && typeof event.png === "string") { const previous = this.latest.get(event.entry); if (!previous) return; const card = this.card(previous, event); + // Layout can change without changing pixels (transparent/overlapping layouts). + if (previous.live && !previous.error && previous.data === card.data && previous.width === card.width + && previous.height === card.height && previous.layoutError === card.layoutError + && JSON.stringify(previous.nodes) === JSON.stringify(card.nodes)) return; this.latest.set(event.entry, card); for (const listener of this.imageListeners) listener([card]); } else if (event.event === "compile") { @@ -339,27 +1024,61 @@ export class KomodoBackend extends ComposeBackend { column: typeof message.column === "number" ? message.column : undefined, })); for (const listener of this.compileListeners) listener({ failed: event.outcome === "failure", messages }); - } else if (event.event === "error" && typeof event.entry === "string" && event.fallback === true) { + } else if (event.event === "error" && typeof event.entry === "string" && event.fallback === true && this.hosted.has(event.entry) && !this.hosting) { + // The daemon closed the card; the others keep running. + const reason = typeof event.message === "string" ? event.message : "this card cannot be rendered live"; + this.resetInput(event.entry); this.hosted.delete(event.entry); this.latest.delete(event.entry); - this.refused.set(event.entry, typeof event.message === "string" ? event.message : "komodo-preview cannot host this card"); + this.failed.set(event.entry, reason); + const card = this.failedCard(event.entry, reason); + for (const listener of this.imageListeners) listener([card]); + } else if (event.event === "error" && event.kind === "rejected" && event.restartRequired === true) { + // Hosting cards: the preview that hosts them configures again once it has. + this.rejected = true; + if (!this.hosting) this.reconfigureRejected(); } } - private card(image: PreviewImage | undefined, frame: KomodoEvent): PreviewImage { - const base = image ?? { id: String(frame.entry), name: String(frame.entry), detail: "" }; - if (typeof frame.png !== "string") return base; - return { ...base, data: frame.png, error: undefined, nodes: undefined, live: true, - width: typeof frame.width === "number" ? frame.width : base.width, height: typeof frame.height === "number" ? frame.height : base.height }; - } - - private refusedCard(image: PreviewImage, reason: string): PreviewImage { - return this.options.fallback() ? image : { ...image, data: undefined, width: undefined, height: undefined, nodes: undefined, error: `Komodo: ${reason}` }; + /** + * A rejected delta (a removed or re-signed member) leaves the daemon on its previous program, and every later delta of the file is + * rejected too: the process configures again from the pushed text and hosts the same entries. Only a process that compiles unsaved + * texts can; another keeps its cards until the file is saved and the preview restarts. The recovery queues behind the running + * preview, which would otherwise find the process taken and open a second session. + */ + private reconfigureRejected(): void { + if (!this.rejected || this.recovering || !this.daemon || !this.sessionFile || !supports(this.daemon, "texts")) return; + const recovery = this.previewing.then(async () => { + await this.pushing; + const daemon = this.daemon; + const file = this.sessionFile; + // That preview may have started another session or configured this one again. + if (!this.rejected || !daemon?.alive || !file || !supports(daemon, "texts")) return; + this.rejected = false; + const next = await this.restart(file, new Map(this.lastDocuments)); + await this.host(next, this.entries); + for (const listener of this.imageListeners) listener(this.gallery()); + }).catch((error: unknown) => { + this.options.log(`Komodo: configuring again after a rejected delta failed: ${error instanceof Error ? error.message : String(error)}`); + }).finally(() => { this.recovering = undefined; }); + this.recovering = this.previewing = recovery; } - private fallback(images: PreviewImage[], reason: () => string): PreviewImage[] { - if (this.logged !== reason()) this.options.log(`Komodo: ${reason()}; rendering with layoutlib`); - this.logged = reason(); - return images.map((image) => this.refusedCard(image, reason())); + private card(base: PreviewImage, frame: KomodoEvent): PreviewImage { + if (typeof frame.png !== "string") return base; + const width = typeof frame.width === "number" ? frame.width : base.width; + const height = typeof frame.height === "number" ? frame.height : base.height; + let nodes: PreviewImage["nodes"]; + let layoutError = typeof frame.layoutError === "string" ? frame.layoutError : undefined; + try { + if (!Number.isInteger(width) || !Number.isInteger(height) || width! <= 0 || height! <= 0) { + throw new Error("Invalid Compose frame dimensions."); + } + nodes = layoutNodes(frame.nodes, width!, height!); + } catch (error) { + nodes = []; + layoutError = error instanceof Error ? error.message : String(error); + } + return { ...base, data: frame.png, error: undefined, nodes, layoutError, live: true, width, height }; } } diff --git a/packages/vscode/src/composePreview.ts b/packages/vscode/src/composePreview.ts index d590535..84e36e8 100644 --- a/packages/vscode/src/composePreview.ts +++ b/packages/vscode/src/composePreview.ts @@ -1,6 +1,8 @@ +import { createHash } from "node:crypto"; import path from "node:path"; import { TimelineNode, + composePreviewAnnotations, composePreviewApplications, composePreviewBuildTargets, formatDuration, @@ -13,28 +15,52 @@ import { type TimelineLine, } from "@elide/ide-core"; import * as vscode from "vscode"; -import { ComposeBackend, ComposeBackends, type CompileMessage, type PreviewBuild, type PreviewRequest } from "./composeBackend.js"; -import { KomodoBackend } from "./composeKomodo.js"; -import type { PreviewImage } from "./composeRender.js"; +import { AndroidStackSourceIndex, parseAndroidStackFrame, type AndroidStackLocation } from "./androidStackSource.js"; +import { ComposeBackend, ComposeBackends, isPreviewKeyEvent, type CompileMessage, type PreviewBuild, type PreviewGallery, type PreviewKeyEvent, type PreviewRequest } from "./composeBackend.js"; +import { firstCompilerError } from "./composeCompiler.js"; +import { KomodoBackend, KomodoSessionArbiter } from "./composeKomodo.js"; +import { StaleBuildRecordError } from "./composeProcessing.js"; +import type { PreviewImage, PreviewLayoutNode } from "./composeRender.js"; import { previewHtml } from "./composePreviewHtml.js"; -import { resolvePreviewSource, type PreviewSourceTarget } from "./composeSource.js"; +import { inspectableNodes, type PreviewSourceTarget } from "./composeSource.js"; import { readConfig } from "./config.js"; import { ensureComposeRuntime, installedComposeRuntime } from "./composeRuntime.js"; import type { ElideUi } from "./output.js"; import type { ElideProject, ElideWorkspace } from "./projects.js"; -import { executeObservedElideTask } from "./tasks.js"; +import { runObservedElide } from "./tasks.js"; const previewInput = /\.(?:kt|java|xml|pkl)$/; /** Build output lines the panel keeps for its Build log; the activity log has every line. */ const LOG_LINES = 2_000; /** Compiler errors quoted in a failed build's diagnostics. */ const QUOTED_ERRORS = 20; +/** A live engine's frames reach the webview at most this often: each publication reposts and hashes every card. */ +const PUSH_INTERVAL_MS = 50; +/** Hashes of unacknowledged publications kept, should the webview skip acknowledgements while it is hidden. */ +const UNACKNOWLEDGED = 16; +const VIEW_TYPE = "elide.composePreview"; +/** + * Workspace state key of the previewed file's renames, old URI to new. A hidden panel's webview is not running, so it + * misses the rename's `source` message and a window reload restores its stale state: the restore follows these. + * Cleared once a running webview has the current source again. + */ +const RENAMES_KEY = "elide.composePreview.renames"; + +/** `work`, or its rejection with `signal`'s reason once `signal` aborts first. */ +function cancellable(work: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + const { promise, resolve, reject } = Promise.withResolvers(); + const abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + void work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); + return promise; +} /** * The compiler errors of a failed build as Elide printed them: each `error:` line with the `In file:` location that * follows it. */ -function buildErrors(lines: readonly TimelineLine[]): string[] { +export function buildErrors(lines: readonly TimelineLine[]): string[] { const errors: string[] = []; for (const [index, line] of lines.entries()) { const parsed = parseElideOutputLine(line.text); @@ -46,7 +72,7 @@ function buildErrors(lines: readonly TimelineLine[]): string[] { return errors; } -function ownedBy(workspace: ElideWorkspace, folder: vscode.WorkspaceFolder, uri: vscode.Uri): boolean { +export function ownedBy(workspace: ElideWorkspace, folder: vscode.WorkspaceFolder, uri: vscode.Uri): boolean { if (uri.scheme !== "file" || !previewInput.test(uri.fsPath)) return false; return workspace.projectFor(uri.fsPath)?.folder.uri.toString() === folder.uri.toString(); } @@ -60,7 +86,7 @@ async function previewRequest(workspace: ElideWorkspace, folder: vscode.Workspac return { documents, sourceFile: source.fsPath, packageName }; } -function previewBuild(workspace: ElideWorkspace, project: ElideProject, model: ProjectModel, artifact: AndroidTargetModel): PreviewBuild { +export function previewBuild(workspace: ElideWorkspace, project: ElideProject, model: ProjectModel, artifact: AndroidTargetModel): PreviewBuild { return { model, artifact, models: workspace.projects.flatMap((item) => item.model ? [item.model] : []), @@ -68,16 +94,16 @@ function previewBuild(workspace: ElideWorkspace, project: ElideProject, model: P }; } -function javaFor(model: ProjectModel): string { +export function javaFor(model: ProjectModel): string { return model.jdk ? path.join(model.jdk.home, "bin", process.platform === "win32" ? "java.exe" : "java") : "java"; } -function backendKey(project: ElideProject, artifact: AndroidTargetModel): string { +export function backendKey(project: ElideProject, artifact: AndroidTargetModel): string { return `${project.root}\0${artifact.name}`; } /** The application supplying the resource context for previews of `file`: its nearest consumer in the same folder. */ -function previewApp(workspace: ElideWorkspace, project: ElideProject, file: string): { project: ElideProject; target: AndroidTargetModel } | undefined { +export function previewApp(workspace: ElideWorkspace, project: ElideProject, file: string): { project: ElideProject; target: AndroidTargetModel } | undefined { const models = workspace.projects.filter((item) => item.folder.uri.toString() === project.folder.uri.toString()).flatMap((item) => item.model ? [item.model] : []); for (const { model, target } of composePreviewApplications(file, models)) { const app = workspace.projectAt(model.root); @@ -86,22 +112,105 @@ function previewApp(workspace: ElideWorkspace, project: ElideProject, file: stri return undefined; } -/** A gallery the panel displayed, or the build confirming one it displayed. */ +/** A gallery the panel displayed, or verification confirming one it displayed. */ export interface ComposePreviewPaint { source: string; images: number; failed: number; /** Since the change (or panel opening) that requested it. */ elapsedMs: number; - /** The images came from classes the running build may still replace. */ + /** These images are provisional until the build or annotation processing finishes. */ verifying: boolean; + /** Hash of the actual displayed image data, including retained pixels on failed cards. */ + contentHash: string; + /** Each card's displayed image data hashed, by card id: which cards a live engine's push or input changed. */ + cards: Record; + /** These exact inputs came from a previously confirmed stored gallery. */ + reused: boolean; + /** Cumulative processing passes in this compiler, for scoped integration measurements. */ + processing?: { stubs: number; apt: number }; + /** Selectable measured layouts and their resolved project source files in this publication. */ + inspection: { enabled: boolean; images: number; sources: string[]; + layouts: Record }; + /** Ids of the cards a live engine renders (pushed frames, pointer input); empty with the snapshot engine. */ + live: string[]; + /** Live cards whose native daemon advertises browser keyboard input. */ + keyboard: string[]; +} + +export interface ComposePreviewError { + source: string; + message: string; +} + +/** A recovery the panel offers beside an error that the next ordinary build cannot fix. */ +interface StatusAction { + id: "rebuildNoCache"; + label: string; + detail: string; +} + +/** A stack frame in a card's Details text that resolves to a project source: line index and UTF-16 span. */ +interface ErrorLink { + line: number; + start: number; + end: number; +} + +const STALE_RECORD_ACTION: StatusAction = { + id: "rebuildNoCache", + label: "Rebuild without cache", + detail: "The build cache restored build records of another checkout or an older build, so an ordinary rebuild restores them again. Rebuild without the cache to rewrite them.", +}; + +/** Hash of the displayed image data, including retained pixels on failed cards (ComposePreviewPaint.contentHash), and per card. */ +function imagesHash(images: readonly PreviewImage[]): { contentHash: string; cards: Record } { + const digest = createHash("sha256"); + const cards: Record = {}; + for (const image of images) { + const card = `${image.id}\0${image.data ?? ""}\0`; + digest.update(card); + cards[image.id] = createHash("sha256").update(card).digest("hex"); + } + return { contentHash: digest.digest("hex"), cards }; +} + +/** Project frames in the cards' Details, resolved against the exact source text the preview compiled. */ +async function errorFrames(images: readonly PreviewImage[], files: readonly ReadonlyMap[]): Promise<{ links: Record; targets: Map }> { + const links: Record = {}; + const targets = new Map(); + if (!images.some((image) => image.error)) return { links, targets }; + const contents = new Map(files.flatMap((unit) => [...unit])); + const index = new AndroidStackSourceIndex(async () => [...contents.keys()], async (file) => contents.get(file)!); + for (const image of images) { + for (const [line, text] of (image.error ?? "").split("\n").entries()) { + const frame = parseAndroidStackFrame(text); + const location = frame && await index.resolve(frame); + if (!frame || !location) continue; + const found = links[image.id] ??= []; + targets.set(`${image.id}\0${found.length}`, location); + found.push({ line, start: frame.start, end: frame.end }); + } + } + return { links, targets }; } /** Compose preview events; consumed by the extension-host integration test. */ export interface ElideComposePreviewApi { readonly onDidPaint: vscode.Event; + readonly onDidError: vscode.Event; /** A background prewarm finished for the backend of this key. */ readonly onDidPrewarm: vscode.Event; + /** Apply the same inspection request as the gallery's Inspect control. */ + setInspect(enabled: boolean): void; + /** Send pointer input to a live card, in its image pixels, as the panel does; false when no live card has this id. */ + pointer(imageId: string, action: "down" | "up" | "move", x: number, y: number): boolean; + /** Focus one keyboard-capable live card; undefined releases its held keys and native focus. */ + focusInput(imageId: string | undefined): boolean; + /** Forward a browser key only to the currently focused card. */ + keyboard(imageId: string, event: PreviewKeyEvent): boolean; + /** Open a layout node's call site as the inspector's Open source does; false when it has none. */ + openSource(imageId: string, nodeId: string): Promise; } /** Host-side layoutlib rendering. A session never installs an APK or communicates with a device. */ @@ -109,7 +218,10 @@ class PreviewSession implements vscode.Disposable { private readonly controller = new AbortController(); private timer: NodeJS.Timeout | undefined; private readonly subscriptions: vscode.Disposable[] = []; - private task: vscode.TaskExecution | undefined; + /** Stops the running preview build. */ + private stopBuild: AbortController | undefined; + /** The next build bypasses the build cache, which restored stale records. */ + private noCache = false; private building = false; private pending = false; private closed = false; @@ -117,6 +229,9 @@ class PreviewSession implements vscode.Disposable { private statusState: "working" | "ready" | "error" = "working"; /** Epoch milliseconds the phase the status names began, for the panel's elapsed-time counter. */ private statusSince: number | undefined; + private statusAction: StatusAction | undefined; + /** The status row's line when it is not the first line of the status text. */ + private statusHeadline: string | undefined; /** The current refresh: its steps time the summary shown once its images paint. */ private trace: TimelineNode | undefined; /** The latest preview build, whose log the panel's "Open in editor" shows. */ @@ -128,18 +243,45 @@ class PreviewSession implements vscode.Disposable { private logReset = false; private logTimer: NodeJS.Timeout | undefined; private images: PreviewImage[] = []; + /** Inspectable call sites by `imageId\0nodeId`, resolved against `sourceFiles`: the compiler inputs of the shown revision. */ private sourceTargets = new Map(); + private sourceFiles: readonly ReadonlyMap[] = []; + /** Linked frames of each card's Details, by image ID; `frameTargets` holds their sources by `imageId\0index`. */ + private errorLinks: Record = {}; + private frameTargets = new Map(); private failedRevision = -1; private model: ProjectModel | undefined; private rebuild = true; private revision = 0; private imageRevision = -1; + private imagePublication = 0; + /** The first publication of the shown gallery; later ones carry a live engine's pushed frames of it. */ + private galleryPublication = 0; + /** The newest publication the webview acknowledged: replaying it (focus, view state, Inspect off) paints nothing new. */ + private paintedPublication = 0; + /** Hashes of the shown gallery's unacknowledged publications: what each one's acknowledgement reports as painted. */ + private readonly publications = new Map }>(); + /** A live engine's coalesced frames, published at `pushedAt` + {@link PUSH_INTERVAL_MS}. */ + private pushTimer: NodeJS.Timeout | undefined; + private pushedAt = 0; + /** Time to the shown gallery's first paint, which the summary reports while pushed frames repaint it. */ + private paintedIn = 0; + /** A live engine's compile failure, shown over the rendered summary until the next change or a successful compile. */ + private compileFailure: string | undefined; private detectedAt = performance.now(); - /** Painted images came from classes the running build may still replace. */ + /** Painted images await build or annotation-processing verification. */ private verifying = false; + /** Hashes of the latest publication. */ + private contentHash = ""; + private cards: Record = {}; + private reused = false; private summary: string | undefined; - /** A build completed since the backend last refreshed its incremental compiler state. */ - private warmPending = false; + private inspecting = false; + private focusedInput: string | undefined; + private readonly heldKeys = new Set(); + /** Invalidates queued webview events when scenes or panel input availability change. */ + private inputEpoch = 0; + private browserFocus: number | undefined; private readonly backend: ComposeBackend; private readonly panel: vscode.WebviewPanel; @@ -150,79 +292,149 @@ class PreviewSession implements vscode.Disposable { private readonly backends: ComposeBackends, private readonly project: ElideProject, private readonly artifact: AndroidTargetModel, - private readonly source: vscode.Uri, + private source: vscode.Uri, private readonly paints: vscode.EventEmitter, + private readonly errors: vscode.EventEmitter, + /** Compiler diagnostics of preview builds, shown in the Problems view. */ + private readonly diagnostics: vscode.DiagnosticCollection, + /** The panel of a preview restored after a window reload. */ + restored?: vscode.WebviewPanel, ) { this.backend = backends.acquire(backendKey(project, artifact)); this.subscriptions.push( new vscode.Disposable(this.backend.onImages((images) => this.pushImages(images))), new vscode.Disposable(this.backend.onCompile((outcome) => this.compiled(outcome.failed, outcome.messages))), + new vscode.Disposable(this.backend.onInputReset((imageId) => { + if (imageId === undefined || imageId === this.focusedInput) this.resetInput(); + })), + // The cards are frozen until a refresh starts a new session. + new vscode.Disposable(this.backend.onSessionLost(() => this.schedule(false))), ); - this.panel = vscode.window.createWebviewPanel("elide.composePreview", `Compose: ${path.basename(source.fsPath)}`, vscode.ViewColumn.Beside, - { enableScripts: true, localResourceRoots: [] }); + const title = `Compose: ${path.basename(source.fsPath)}`; + const options = { enableScripts: true, localResourceRoots: [] }; + if (restored) { + restored.title = title; + restored.webview.options = options; + } + this.panel = restored ?? vscode.window.createWebviewPanel(VIEW_TYPE, title, vscode.ViewColumn.Beside, options); this.subscriptions.push( this.panel.onDidDispose(() => this.dispose()), + this.panel.onDidChangeViewState(() => { + if (!this.panel.visible) this.resetInput(); + else this.publishImages(true); + }), + vscode.window.onDidChangeWindowState(({ focused }) => { + if (!focused) this.resetInput(); + else if (this.panel.visible) this.publishImages(true); + }), this.panel.webview.onDidReceiveMessage((message: unknown) => { if (!message || typeof message !== "object" || !("type" in message)) return; if (message.type === "ready") { - this.status(this.lastStatus, this.statusState, this.statusSince); + this.resetInput(); + this.status(this.lastStatus, this.statusState, this.statusSince, this.statusAction, this.statusHeadline); void this.panel.webview.postMessage({ type: "log", reset: true, lines: this.logLines }); + // The replay holds the lines still queued for the next flush. + this.logQueue = []; + this.logReset = false; + // Restoring the panel after a window reload needs the previewed file. The webview now saves it. + void this.panel.webview.postMessage({ type: "source", uri: this.source.toString() }); + if (this.context.workspaceState.get(RENAMES_KEY)) void this.context.workspaceState.update(RENAMES_KEY, undefined); this.publishImages(true); void this.panel.webview.postMessage({ type: "invalidate", revision: this.revision }); } if (message.type === "refresh") this.schedule(true); - if (message.type === "pointer" && "imageId" in message && "action" in message && "x" in message && "y" in message + if (message.type === "action" && "id" in message && message.id === "rebuildNoCache" && this.statusAction?.id === message.id) { + this.noCache = true; + this.schedule(true); + } + if (message.type === "inspect" && "enabled" in message && typeof message.enabled === "boolean") this.setInspect(message.enabled); + if (message.type === "pointer" && "epoch" in message && message.epoch === this.inputEpoch + && "imageId" in message && "action" in message && "x" in message && "y" in message && typeof message.imageId === "string" && (message.action === "down" || message.action === "up" || message.action === "move") && typeof message.x === "number" && typeof message.y === "number") { - this.backend.pointer(message.imageId, message.action, message.x, message.y); + this.pointer(message.imageId, message.action, message.x, message.y); + } + if (message.type === "focusInput" && "epoch" in message && message.epoch === this.inputEpoch + && "focus" in message && typeof message.focus === "number" && "imageId" in message) { + if (typeof message.imageId === "string") { + if (this.focusInput(message.imageId)) this.browserFocus = message.focus; + else void this.panel.webview.postMessage({ type: "inputBlur", epoch: this.inputEpoch, focus: message.focus }); + } else if (message.imageId === null && message.focus === this.browserFocus) this.focusInput(undefined); + } + if (message.type === "keyboard" && "epoch" in message && message.epoch === this.inputEpoch + && "focus" in message && message.focus === this.browserFocus + && "imageId" in message && typeof message.imageId === "string" && "event" in message && isPreviewKeyEvent(message.event)) { + this.keyboard(message.imageId, message.event); } if (message.type === "openLog") void this.ui.activity.openLog(this.buildStep ?? this.trace); if (message.type === "showActivity") void vscode.commands.executeCommand("elide.showActivity"); + // Not tied to a publication: a live engine's pushed frames can repaint the card many times a second. if (message.type === "navigate" && "revision" in message && message.revision === this.revision && this.imageRevision === this.revision && "imageId" in message && "nodeId" in message && typeof message.imageId === "string" && typeof message.nodeId === "string") { void this.navigate(message.imageId, message.nodeId, this.revision); } + if (message.type === "openFrame" && "revision" in message && message.revision === this.revision + && this.imageRevision === this.revision && "imageId" in message && "link" in message + && typeof message.imageId === "string" && typeof message.link === "number") { + void this.openFrame(message.imageId, message.link, this.revision); + } if (message.type === "painted" && "revision" in message && message.revision === this.revision + && "publication" in message && typeof message.publication === "number" && this.imageRevision === this.revision && this.failedRevision !== this.revision && this.images.length) { - const errors = this.images.filter((image) => image.error).length; - const elapsed = Math.round(performance.now() - this.detectedAt); - this.summary = `${this.images.length - errors} rendered in ${formatDuration(elapsed)}${errors ? ` · ${errors} failed (open Details)` : ""}`; - this.renderedStatus(); - this.paints.fire({ source: this.source.fsPath, images: this.images.length, failed: errors, elapsedMs: elapsed, verifying: this.verifying }); + this.painted(message.publication); } }), vscode.workspace.onDidChangeTextDocument(({ document, contentChanges }) => { const compiler = this.backend.compiler; if (contentChanges.length && this.owns(document.uri) && /\.(kt|java)$/.test(document.uri.fsPath)) { - if (compiler && !compiler.supports(document.uri.fsPath)) { - this.revision++; - this.pending = false; - this.summary = undefined; - clearTimeout(this.timer); - void this.panel.webview.postMessage({ type: "invalidate", revision: this.revision }); - this.status(compiler.requiresBuild(document.uri.fsPath) - ? "Save to regenerate annotation-processor sources and rebuild the preview." - : "Save to rebuild the preview's source inputs."); - } else this.schedule(false); + if (compiler && !this.backend.compiles(document.uri.fsPath)) this.suspend("Save to rebuild the preview's source inputs."); + else { + // A live engine starts compiling now; the refresh scheduled below then finds the edit already applied. + this.backend.edit(document.uri.fsPath, document.getText()); + this.schedule(false); + } } }), vscode.workspace.onDidSaveTextDocument((document) => { - const compiler = this.backend.compiler; if (this.owns(document.uri) && (/\.(xml|pkl)$/.test(document.uri.fsPath) - || compiler && !compiler.supports(document.uri.fsPath))) this.schedule(true); + || this.backend.compiler && !this.backend.compiles(document.uri.fsPath))) this.schedule(true); }), vscode.workspace.onDidCreateFiles(({ files }) => { - for (const uri of files) if (this.owns(uri)) this.schedule(!this.backend.compiler?.supports(uri.fsPath)); + for (const uri of files) if (this.owns(uri)) this.schedule(!this.backend.compiles(uri.fsPath)); }), vscode.workspace.onDidDeleteFiles(({ files }) => { - for (const uri of files) if (this.owns(uri)) this.schedule(!this.backend.compiler?.supports(uri.fsPath)); + if (files.some((uri) => this.contains(uri))) { + this.suspend(`${path.basename(this.source.fsPath)} was deleted. Restore it to preview it again.`, "error"); + return; + } + for (const uri of files) if (this.owns(uri)) this.schedule(!this.backend.compiles(uri.fsPath)); }), vscode.workspace.onDidRenameFiles(({ files }) => { - for (const { oldUri, newUri } of files) if (this.owns(oldUri) || this.owns(newUri)) { - const compiler = this.backend.compiler; - this.schedule(!compiler?.supports(oldUri.fsPath) || !compiler.supports(newUri.fsPath)); + let rebuild: boolean | undefined; + for (const { oldUri, newUri } of files) { + if (this.contains(oldUri)) { + const previous = this.source.toString(); + this.source = vscode.Uri.file(path.join(newUri.fsPath, path.relative(oldUri.fsPath, this.source.fsPath))); + this.panel.title = `Compose: ${path.basename(this.source.fsPath)}`; + void this.panel.webview.postMessage({ type: "source", uri: this.source.toString() }); + this.recordRename(previous, this.source.toString()); + // A moved folder moves other sources too: the build has to see them in their new place. + rebuild ||= !this.owns(oldUri); + } + if (this.owns(oldUri) || this.owns(newUri)) { + rebuild ||= !this.backend.compiles(oldUri.fsPath) || !this.backend.compiles(newUri.fsPath); + } } + if (rebuild !== undefined) this.schedule(rebuild); + }), + workspace.onDidChangeKomodoAvailability(() => { + if (this.backend.engine !== "komodo" || workspace.komodoPreviewAvailable) return; + const file = this.source.fsPath; + this.dispose(); + void workspace.requireKomodoPreview(file).catch((error: unknown) => { + void vscode.window.showWarningMessage(`Elide: Compose preview stopped: ${error instanceof Error ? error.message : String(error)}`); + }); }), workspace.onDidChange(() => { if (!workspace.projectAt(project.root)) this.dispose(); @@ -235,17 +447,107 @@ class PreviewSession implements vscode.Disposable { get open(): boolean { return !this.closed; } + /** Whether this session previews `source` with `artifact` of `project`. */ + previews(project: ElideProject, artifact: AndroidTargetModel, source: vscode.Uri): boolean { + return this.project.root === project.root && this.artifact.name === artifact.name && this.source.toString() === source.toString(); + } + + /** Show the panel where the user left it. */ + reveal(): void { + this.panel.reveal(); + } + + /** + * Lets a window reload restore the renamed source. Each rename drops the new name's own entry (the file is there + * again), so the entries never form a cycle. + */ + private recordRename(from: string, to: string): void { + const renames: Record = { ...this.context.workspaceState.get>(RENAMES_KEY), [from]: to }; + delete renames[to]; + void this.context.workspaceState.update(RENAMES_KEY, renames); + } + + setInspect(enabled: boolean): void { + if (this.closed || this.inspecting === enabled) return; + if (enabled) this.resetInput(); + this.inspecting = enabled; + void this.panel.webview.postMessage({ type: "inspect", enabled }); + if (enabled) this.schedule(false); + // The webview drops a gallery older than the failed revision it shows: refresh without the instrumentation instead. + else if (this.failedRevision === this.revision) this.schedule(false); + else this.publishImages(); + } + + /** Pointer input for a live card, as the panel forwards it while not inspecting. */ + pointer(imageId: string, action: "down" | "up" | "move", x: number, y: number): boolean { + if (this.closed || this.inspecting || !this.panel.visible || this.imageRevision !== this.revision + || !this.images.some((image) => image.id === imageId && image.live && image.data && !image.error)) return false; + if (action === "down" && this.backend.keyboardSupported(imageId)) this.focusInput(imageId); + return this.backend.pointer(imageId, action, x, y); + } + + /** The inspector's Open source for a node of the shown publication. */ + openSource(imageId: string, nodeId: string): Promise { + return this.imageRevision === this.revision ? this.navigate(imageId, nodeId, this.revision) : Promise.resolve(false); + } + + focusInput(imageId: string | undefined): boolean { + if (this.closed) return false; + if (imageId !== undefined && (!this.panel.visible || this.inspecting || this.imageRevision !== this.revision + || !this.images.some((image) => image.id === imageId && image.live && image.data && !image.error) + || !this.backend.keyboardSupported(imageId))) return false; + if (imageId === this.focusedInput) return true; + if (this.focusedInput !== undefined) this.backend.blur(this.focusedInput); + if (this.browserFocus !== undefined) { + void this.panel.webview.postMessage({ type: "inputBlur", epoch: this.inputEpoch, focus: this.browserFocus }); + } + this.focusedInput = imageId; + this.browserFocus = undefined; + this.heldKeys.clear(); + return true; + } + + keyboard(imageId: string, event: PreviewKeyEvent): boolean { + if (this.closed || this.focusedInput !== imageId || !isPreviewKeyEvent(event)) return false; + if (this.inspecting || !this.panel.visible || !this.backend.keyboardSupported(imageId)) { + this.resetInput(); + return false; + } + const key = event.code || event.key; + if ((event.action === "up" || event.repeat) && !this.heldKeys.has(key)) return false; + if (!this.backend.keyboard(imageId, event)) return false; + if (event.action === "down") this.heldKeys.add(key); + else this.heldKeys.delete(key); + return true; + } + + private resetInput(): void { + this.focusInput(undefined); + this.inputEpoch++; + if (!this.closed) void this.panel.webview.postMessage({ type: "inputReset", epoch: this.inputEpoch }); + } + private owns(uri: vscode.Uri): boolean { return ownedBy(this.workspace, this.project.folder, uri); } + /** `uri` is the previewed file or a folder containing it. */ + private contains(uri: vscode.Uri): boolean { + const relative = path.relative(uri.fsPath, this.source.fsPath); + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); + } + private schedule(rebuild: boolean): void { if (this.closed) return; + // A rebuild supersedes the running build: stop it rather than wait for output nothing uses. + if (rebuild && this.building) this.stopBuild?.abort(); + this.resetInput(); this.revision++; this.detectedAt = performance.now(); this.rebuild ||= rebuild; this.pending = true; this.summary = undefined; + this.compileFailure = undefined; // Invalidate work already in flight, including images still being decoded in the webview. void this.panel.webview.postMessage({ type: "invalidate", revision: this.revision }); this.status(this.backend.compiler?.ready && !this.rebuild ? "Updating preview…" : "Preparing preview compiler and resources…"); @@ -253,22 +555,94 @@ class PreviewSession implements vscode.Disposable { this.timer = setTimeout(() => void this.refresh(), 0); } - private status(text: string, state: "working" | "ready" | "error" = "working", since?: number): void { + /** Show no gallery for the current revision and refresh nothing until the next change: `text` says what has to happen. */ + private suspend(text: string, state: "working" | "error" = "working"): void { + if (this.closed) return; + // Nothing uses the running build's output, and its progress would replace `text`. + this.stopBuild?.abort(); + this.resetInput(); + this.revision++; + this.pending = false; + this.summary = undefined; + clearTimeout(this.timer); + void this.panel.webview.postMessage({ type: "invalidate", revision: this.revision }); + this.status(text, state); + } + + private status(text: string, state: "working" | "ready" | "error" = "working", since?: number, action?: StatusAction, headline?: string): void { this.lastStatus = text; this.statusState = state; this.statusSince = since; + this.statusAction = action; + this.statusHeadline = headline; if (!this.closed) void this.panel.webview.postMessage({ type: "status", text, state, since, - retained: state !== "ready" && this.images.some((image) => image.data) }); + retained: state !== "ready" && this.images.some((image) => image.data), ...(action ? { action } : {}), ...(headline ? { headline } : {}) }); } /** The painted summary, the phases of the refresh that took long enough to matter, and what happens next. */ private renderedStatus(): void { if (!this.summary) return; + if (this.compileFailure) { + this.status(this.compileFailure, "error"); + return; + } const phases = (this.trace?.children ?? []).filter((step) => step.durationMs >= 50 && (step.state === "succeeded" || step.state === "failed")) .map((step) => `${step.label.toLowerCase()} ${formatDuration(step.durationMs)}`); - this.status(`${this.summary}${phases.length ? ` · ${phases.join(", ")}` : ""} · host layoutlib · ${this.verifying ? "verifying build…" : "live edits"}`, + this.status(`${this.summary}${phases.length ? ` · ${phases.join(", ")}` : ""} · host layoutlib · ${this.verifying + ? this.buildStep?.running ? "verifying build…" : "verifying annotation processing…" : "live edits"}`, this.verifying ? "working" : this.images.some((image) => image.error) ? "error" : "ready", - this.verifying && this.buildStep?.running ? this.buildStep.startedAt : undefined); + this.verifying ? this.buildStep?.running ? this.buildStep.startedAt : this.trace?.children.findLast((step) => step.running)?.startedAt : undefined); + } + + /** The webview painted `publication`: the first acknowledgement of a publication of the shown gallery reports its paint. */ + private painted(publication: number): void { + const shown = this.publications.get(publication); + if (!shown || publication < this.galleryPublication || publication <= this.paintedPublication) return; + for (const key of this.publications.keys()) if (key <= publication) this.publications.delete(key); + const elapsed = Math.round(performance.now() - this.detectedAt); + // Pushed frames repaint the gallery; its summary keeps the time to its first paint. + if (this.paintedPublication < this.galleryPublication) this.paintedIn = elapsed; + this.paintedPublication = publication; + const errors = this.images.filter((image) => image.error).length; + this.summary = `${this.images.length - errors} rendered in ${formatDuration(this.paintedIn)}${errors ? ` · ${errors} failed (open Details)` : ""}`; + this.renderedStatus(); + this.emitPaint(elapsed, shown); + } + + private emitPaint(elapsedMs = Math.round(performance.now() - this.detectedAt), { contentHash, cards } = { contentHash: this.contentHash, cards: this.cards }): void { + const processing = this.backend.compiler?.processingStats; + this.paints.fire({ + source: this.source.fsPath, images: this.images.length, failed: this.images.filter((image) => image.error).length, + elapsedMs, verifying: this.verifying, contentHash, cards: { ...cards }, reused: this.reused, + inspection: { enabled: this.inspecting, images: this.images.filter((image) => image.nodes?.length).length, + sources: [...new Set([...this.sourceTargets.values()].map((target) => target.file))].sort(), + layouts: Object.fromEntries(this.images.filter((image) => image.width && image.height) + .map((image) => [image.id, { width: image.width!, height: image.height!, nodes: image.nodes ?? [] }])) }, + live: this.images.filter((image) => image.live).map((image) => image.id), + keyboard: this.images.filter((image) => image.live && !image.error && this.backend.keyboardSupported(image.id)).map((image) => image.id), + ...(processing ? { processing: { ...processing } } : {}), + }); + } + + private confirm(revision: number): void { + if (this.closed || revision !== this.revision || revision === this.failedRevision) return; + this.verifying = false; + this.renderedStatus(); + if (this.summary) this.emitPaint(); + } + + private fail(error: unknown, revision: number): void { + if (this.closed || revision !== this.revision) return; + const message = error instanceof Error ? error.message : String(error); + this.failedRevision = revision; + this.verifying = false; + this.summary = undefined; + this.appendLog(message); + // The first line of a compiler failure only names the unit; the row shows the first error and Details the rest. + const compilerError = firstCompilerError(message); + this.status(`Preview failed: ${message}`, "error", undefined, error instanceof StaleBuildRecordError ? STALE_RECORD_ACTION : undefined, + compilerError && `Preview failed: ${compilerError}`); + this.errors.fire({ source: this.source.fsPath, message }); } /** Queue a build output line for the panel's Build log; posted in batches. */ @@ -294,14 +668,17 @@ class PreviewSession implements vscode.Disposable { this.logQueue = []; this.logReset = false; const build = this.buildStep; - if (!build?.running || this.summary) return; + if (!build?.running || this.summary || this.stopBuild?.signal.aborted) return; const command = build.children.at(-1); const task = command?.children.findLast((child) => !child.running); const progress = [liveStatus(build), command?.detail, task && `last: ${task.label} (${formatDuration(task.durationMs)})`].filter(Boolean).join(" · "); this.status(`Building ${this.artifact.name} preview inputs${progress ? ` · ${progress}` : "…"}`, "working", build.startedAt); } - /** Cards a live engine updated after their gallery was shown: replaced in place within the same revision. */ + /** + * Cards a live engine updated after their gallery was shown: replaced in place within the same revision, as a new publication + * whose paint reports the pushed pixels. + */ private pushImages(images: PreviewImage[]): void { if (this.closed || this.imageRevision !== this.revision) return; const updated = new Map(images.map((image) => [image.id, image])); @@ -310,17 +687,40 @@ class PreviewSession implements vscode.Disposable { const next = updated.get(image.id); if (!next) return image; changed = true; - return { ...next, nodes: [] }; + for (const key of this.sourceTargets.keys()) if (key.startsWith(`${image.id}\0`)) this.sourceTargets.delete(key); + // The shown revision's sources: a pushed frame (input, animation) runs the program its gallery compiled. + return { ...next, nodes: next.error ? [] : inspectableNodes(next, this.sourceFiles, this.sourceTargets) }; }); - if (changed) this.publishImages(); + if (!changed || this.pushTimer) return; + const wait = this.pushedAt + PUSH_INTERVAL_MS - performance.now(); + if (wait <= 0) { + this.publish(); + return; + } + this.pushTimer = setTimeout(() => { + this.pushTimer = undefined; + if (!this.closed && this.imageRevision === this.revision) this.publish(); + }, wait); } - /** A live engine's compile outcome: its messages in Problems (`elide-compose-preview`), cleared by the next success. */ + /** Post the images as a new publication, recording what its acknowledgement reports as painted. */ + private publish(): void { + ({ contentHash: this.contentHash, cards: this.cards } = imagesHash(this.images)); + this.imagePublication++; + this.publications.set(this.imagePublication, { contentHash: this.contentHash, cards: this.cards }); + // A hidden webview acknowledges nothing; keep the newest. + for (const key of this.publications.keys()) if (this.publications.size > UNACKNOWLEDGED) this.publications.delete(key); + this.pushedAt = performance.now(); + this.publishImages(); + } + + /** A live engine's compile outcome replaces the preview's Problems (the newest compile owns them); a success clears them. */ private compiled(failed: boolean, messages: CompileMessage[]): void { - const collection = composeDiagnostics; - if (!collection || this.closed) return; + if (this.closed) return; + const collection = this.diagnostics; collection.clear(); if (!failed) { + this.compileFailure = undefined; this.renderedStatus(); return; } @@ -336,65 +736,91 @@ class PreviewSession implements vscode.Disposable { } for (const [file, diagnostics] of byFile) collection.set(vscode.Uri.file(file), diagnostics); const first = messages.find((message) => message.level === "error") ?? messages[0]; - this.status(`Live preview does not compile: ${first?.text ?? "see Problems"}`, "error"); + this.compileFailure = `Live preview does not compile: ${first?.text ?? "see Problems"}`; + this.status(this.compileFailure, "error"); } private publishImages(replay = false): void { - if (!this.closed) void this.panel.webview.postMessage({ type: "images", images: this.images, revision: this.imageRevision, + if (this.focusedInput !== undefined && !this.images.some((image) => image.id === this.focusedInput && image.live && !image.error)) this.resetInput(); + if (!this.closed) void this.panel.webview.postMessage({ type: "images", images: this.images, errorLinks: this.errorLinks, + revision: this.imageRevision, publication: this.imagePublication, inputEpoch: this.inputEpoch, + keyboard: this.images.filter((image) => image.live && !image.error && this.backend.keyboardSupported(image.id)).map((image) => image.id), ...(replay ? { currentRevision: this.revision } : {}) }); } /** Publish images for `revision` unless a newer change superseded them. */ - private async show(images: PreviewImage[], revision: number, verifying: boolean, request: PreviewRequest): Promise { + private async show(gallery: PreviewGallery, revision: number, verifying: boolean, request: PreviewRequest): Promise { if (this.closed || revision !== this.revision || revision === this.failedRevision) return false; + const { images } = gallery; if (!images.length) throw new Error("No compiled @Preview functions found in this file."); const sources = await this.backend.compiler?.sources(request.documents); + const files = sources?.files ?? []; + const frames = await errorFrames(images, files); if (this.closed || revision !== this.revision || revision === this.failedRevision) return false; const targets = new Map(); this.verifying = verifying; + this.reused = gallery.reused; + this.summary = undefined; // A failing component keeps its own last good pixels; other successful cards can still update. const previous = new Map(this.images.map((image) => [image.id, image])); - this.images = images.map((image) => { + // A live engine's frames since it built this gallery (pushed while the gallery was on its way, so not applied above). + this.images = this.backend.latestImages(images).map((image) => { const retained = image.error ? previous.get(image.id) : undefined; if (retained?.data) return { ...image, data: retained.data, width: retained.width, height: retained.height, nodes: [] }; - // Keep inspectable call sites in project sources, not Compose's implementation internals. - const nodes = image.nodes?.filter((node) => { - const target = node.source && sources && resolvePreviewSource(node.source, sources.files); - if (!target) return false; - targets.set(`${image.id}\0${node.id}`, target); - return true; - }); - return { ...image, nodes }; + return { ...image, nodes: inspectableNodes(image, files, targets) }; }); this.sourceTargets = targets; + this.sourceFiles = files; + this.errorLinks = frames.links; + this.frameTargets = frames.targets; this.imageRevision = revision; - this.publishImages(); + // Frames pushed for the replaced gallery are in these images already. + clearTimeout(this.pushTimer); + this.pushTimer = undefined; + this.publications.clear(); + this.galleryPublication = this.imagePublication + 1; + this.publish(); return true; } /** - * Build what the preview renders as a step of `trace`. The task terminal stays hidden; the panel's Build log, its - * status line and the Activity view follow the build instead, and a failure quotes the compiler's errors. + * Build what the preview renders as a step of `trace`. The build runs as a process of its own, not a task: VS Code + * saves dirty editors before every task, and a preview works from unsaved editor text. The panel's Build log, its + * status line, the Elide output channel and the Activity view follow the build, its compiler diagnostics go to + * the Problems view, and a failure quotes the compiler's errors. */ - private async build(model: ProjectModel, artifact: AndroidTargetModel, models: readonly ProjectModel[], trace: TimelineNode): Promise { + private async build(model: ProjectModel, artifact: AndroidTargetModel, models: readonly ProjectModel[], trace: TimelineNode, signal: AbortSignal): Promise { const step = trace.start("Build"); this.buildStep = step; this.resetLog(); this.status(`Building ${artifact.name} preview inputs…`, "working", step.startedAt); - const { execution, exitCode } = await executeObservedElideTask(this.project, "build", { args: composePreviewBuildTargets(model, artifact, models) }, - { reveal: vscode.TaskRevealKind.Silent, panel: vscode.TaskPanelKind.Dedicated, focus: false }, - observeCommands(() => step, (line) => this.appendLog(line))); - this.task = execution; + const stop = new AbortController(); + this.stopBuild = stop; + const noCache = this.noCache; + this.noCache = false; + let code: number | undefined; + try { + code = await runObservedElide(this.project, "build", { args: composePreviewBuildTargets(model, artifact, models), ...(noCache ? { options: { "no-cache": true } } : {}) }, { + signal: AbortSignal.any([signal, stop.signal]), + diagnostics: this.diagnostics, + onCommand: observeCommands(() => step, (line) => { + this.appendLog(line); + this.ui.log(line); + }), + }); + } catch (error) { + step.end("failed", error instanceof Error ? error.message : String(error)); + throw error; + } finally { + if (this.stopBuild === stop) this.stopBuild = undefined; + } if (this.closed) { - execution.terminate(); step.end("cancelled", "preview closed"); throw new Error("Compose preview closed."); } - const code = await exitCode; - this.task = undefined; if (code === 0) { step.end("succeeded"); - if (!this.summary) this.status(`Built ${artifact.name} preview inputs in ${formatDuration(step.durationMs)}; rendering…`); + if (!this.summary && !stop.signal.aborted) this.status(`Built ${artifact.name} preview inputs in ${formatDuration(step.durationMs)}; rendering…`); return; } if (code === undefined) { @@ -411,20 +837,6 @@ class PreviewSession implements vscode.Disposable { ].join("\n")); } - /** Once per build, after the images it produced are shown: get the next edit's compile warm. */ - private warm(request: PreviewRequest): void { - if (!this.warmPending) return; - this.warmPending = false; - const revision = this.revision; - const enrich = this.backend.compiler?.seeded; - this.backend.warm(request).then(async () => { - // Last-build classes paint immediately; their private compile adds source locations afterwards. - if (enrich && !this.closed && revision === this.revision) { - await this.show(await this.backend.preview(request), revision, false, request); - } - }).catch(() => {}); - } - /** * Bring the gallery up to date with the editor. A refresh that builds is an operation of the Activity view, since * it can take a while and fail in the build; a live edit's compile-and-render is timed for the status line only. @@ -435,29 +847,46 @@ class PreviewSession implements vscode.Disposable { this.pending = false; const revision = this.revision; const backend = this.backend; + // The Activity view's Cancel stops whichever step runs; the backend finishes a step it started under its own locks. + const cancel = new AbortController(); + const signal = AbortSignal.any([this.controller.signal, cancel.signal]); const trace = this.rebuild || !backend.compiler - ? this.ui.activity.start(`Compose preview: ${path.basename(this.source.fsPath)}`, { detail: this.artifact.name, cancel: () => this.task?.terminate() }) + ? this.ui.activity.start(`Compose preview: ${path.basename(this.source.fsPath)}`, { detail: this.artifact.name, cancel: () => cancel.abort() }) : TimelineNode.root("Compose preview update"); this.trace = trace; let outcome: "succeeded" | "failed" | "cancelled" = "cancelled"; let failure: string | undefined; + let preparing = false; try { if (!vscode.workspace.isTrusted) throw new Error("Trust this workspace before executing Compose previews."); + if (backend instanceof KomodoBackend) { + await this.workspace.requireKomodoPreview(this.source.fsPath); + signal.throwIfAborted(); + await backend.prepareSession(this.source.fsPath, false, signal); + signal.throwIfAborted(); + } const model = this.workspace.projectAt(this.project.root)?.model; if (!model) throw new Error("Sync the Android project before previewing."); const artifact = model.android?.targets.find((target) => target.name === this.artifact.name); if (!artifact) throw new Error(`Android artifact ${this.artifact.name} no longer exists. Reopen the preview.`); - const request = await previewRequest(this.workspace, this.project.folder, this.source); + const request = { ...await previewRequest(this.workspace, this.project.folder, this.source), inspect: this.inspecting }; + if (this.closed) return; + signal.throwIfAborted(); + let gallery: PreviewGallery | undefined; if (this.rebuild || !backend.compiler) { + preparing = true; this.rebuild = false; const build = previewBuild(this.workspace, this.project, model, artifact); const runtime = await trace.run("Compose runtime", () => - ensureComposeRuntime(this.context.globalStorageUri.fsPath, this.controller.signal, (text) => this.status(text))); + // A suspended revision's status stays until the next change. + ensureComposeRuntime(this.context.globalStorageUri.fsPath, signal, (text) => { if (revision === this.revision || this.pending) this.status(text); })); + if (this.closed) return; + signal.throwIfAborted(); const starting = trace.start("Start renderer"); - const renderer = backend.startRenderer(runtime, javaFor(model)); + const renderer = backend.startRenderer(runtime, javaFor(model), composePreviewAnnotations(request.documents.get(request.sourceFile) ?? "")); renderer.then(() => starting.end(), (error: unknown) => starting.end("failed", error instanceof Error ? error.message : String(error))); let buildFailed = false; - const built = this.build(model, artifact, build.models, trace).catch((error: unknown) => { + const built = this.build(model, artifact, build.models, trace, signal).catch((error: unknown) => { buildFailed = true; throw error; }); @@ -467,8 +896,9 @@ class PreviewSession implements vscode.Disposable { const early = trace.start("Preview from the last build"); const provisional = (async () => { if (!backend.compiler) await backend.configure(build, request.documents, false); - const images = await backend.preview(request, false, early); - return images && !buildFailed ? this.show(images, revision, true, request) : false; + const provisionalGallery = await backend.preview(request, false, early); + return provisionalGallery && !buildFailed && await this.show(provisionalGallery, revision, true, request) + ? provisionalGallery : undefined; })().then( (shown) => { early.end(shown ? "succeeded" : "skipped", shown ? undefined : "nothing to show before the build"); @@ -477,59 +907,105 @@ class PreviewSession implements vscode.Disposable { (error: unknown) => { // Never built, or the build is rewriting its outputs: the gallery waits for the build instead. early.end("skipped", error instanceof Error ? error.message : String(error)); - return false; + return undefined; }, ); await built; - this.warmPending = true; - const shown = await provisional; + const provisionalGallery = await provisional; if (this.closed) return; - const changed = await trace.run("Prepare compiler", () => backend.configure(build, request.documents, true)); + const changed = await trace.run("Prepare compiler", () => cancellable(backend.configure(build, request.documents, true), signal)); + preparing = false; this.model = model; if (this.closed || revision !== this.revision) return; - if (shown && !changed) { - this.verifying = false; - outcome = "succeeded"; - trace.end("succeeded"); - this.renderedStatus(); - if (this.summary) { - const failed = this.images.filter((image) => image.error).length; - this.paints.fire({ source: this.source.fsPath, images: this.images.length, failed, elapsedMs: Math.round(performance.now() - this.detectedAt), verifying: false }); - } - this.warm(request); - return; + if (provisionalGallery && !changed) gallery = provisionalGallery; + await trace.run("Wait for renderer", () => cancellable(renderer, signal)); + } + if (this.closed || revision !== this.revision) return; + if (backend.needsWarmup) { + this.status("Initializing preview compiler…"); + await trace.run("Initialize compiler", () => cancellable(backend.warm(request), signal)); + if (this.closed || revision !== this.revision) return; + // The provisional build classes and their metadata are not the instrumented baseline. + gallery = undefined; + } + if (!gallery) { + try { + gallery = await backend.preview(request, true, trace); + } catch (error) { + if (this.closed || revision !== this.revision) return; + // A declaration edit can make the last generated sources uncompilable. Processing must + // still run, then compile the edited declarations with their new generated counterparts. + if (!backend.processingRequired) throw error; + this.status("Verifying annotation processing…"); + await cancellable(backend.verify(request, trace), signal); + if (this.closed || revision !== this.revision) return; + gallery = await backend.preview(request, true, trace); } - await trace.run("Wait for renderer", () => renderer); + if (!await this.show(gallery, revision, !gallery.verified, request)) return; + } + while (!gallery.verified) { + if (!this.summary) this.status("Verifying annotation processing…"); + else this.renderedStatus(); + const changed = await cancellable(backend.verify(request, trace), signal); + if (this.closed || revision !== this.revision) return; + if (changed) { + gallery = await backend.preview(request, true, trace); + if (!await this.show(gallery, revision, !gallery.verified, request)) return; + } else break; } - if (!await this.show(await backend.preview(request, true, trace), revision, false, request)) return; + this.confirm(revision); outcome = "succeeded"; - this.warm(request); } catch (error) { - if (!this.closed && revision === this.revision) { - outcome = "failed"; - failure = error instanceof Error ? error.message.split("\n")[0] : String(error); + if (backend instanceof KomodoBackend && !backend.disposed) backend.preempt("The preview did not complete; refresh to try again."); + // A failed resource/build setup cannot be repaired by compiling only the next Kotlin edit. + if (preparing) this.rebuild = true; + if (!this.closed && revision === this.revision && cancel.signal.aborted) { + // Not a failure: the next refresh rebuilds whatever the cancel interrupted. + failure = "cancelled"; + this.rebuild = true; this.failedRevision = revision; this.verifying = false; this.summary = undefined; - this.status(`Preview failed: ${error instanceof Error ? error.message : String(error)}`, "error"); + this.status("Preview cancelled. Rebuild to try again.", "error"); + } else if (!this.closed && revision === this.revision) { + outcome = "failed"; + failure = error instanceof Error ? error.message.split("\n")[0] : String(error); + this.fail(error, revision); } } finally { - trace.end(outcome, failure ?? (outcome === "cancelled" ? "superseded by a newer change" : undefined)); - this.task = undefined; + trace.end(outcome, failure ?? (outcome === "cancelled" ? this.closed ? "preview closed" : "superseded by a newer change" : undefined)); this.building = false; if (!this.closed && this.pending) { clearTimeout(this.timer); void this.refresh(); } } } - private async navigate(imageId: string, nodeId: string, revision: number): Promise { + /** Opens a node's call site, selecting its range; false when it has none or its file's text is no longer what was compiled. */ + private async navigate(imageId: string, nodeId: string, revision: number): Promise { const target = this.sourceTargets.get(`${imageId}\0${nodeId}`); - if (!target || this.closed || revision !== this.revision || revision === this.failedRevision) return; + if (!target || this.closed || revision !== this.revision || revision === this.failedRevision) return false; try { const document = await vscode.workspace.openTextDocument(vscode.Uri.file(target.file)); - if (this.closed || revision !== this.revision || document.getText() !== target.content) return; + if (this.closed || revision !== this.revision || document.getText() !== target.content) return false; const selection = new vscode.Range(document.positionAt(target.offset), document.positionAt(target.offset + target.length)); const editor = await vscode.window.showTextDocument(document, { viewColumn: vscode.ViewColumn.One, selection, preview: true }); editor.revealRange(selection, vscode.TextEditorRevealType.InCenterIfOutsideViewport); + return true; + } catch (error) { + if (!this.closed) void vscode.window.showWarningMessage(`Cannot open Compose source: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + } + + private async openFrame(imageId: string, link: number, revision: number): Promise { + const target = this.frameTargets.get(`${imageId}\0${link}`); + if (!target || this.closed || revision !== this.revision) return; + try { + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(target.file)); + if (this.closed || revision !== this.revision) return; + const position = new vscode.Position(Math.min(target.line, document.lineCount - 1), 0); + const selection = new vscode.Range(position, position); + const editor = await vscode.window.showTextDocument(document, { viewColumn: vscode.ViewColumn.One, selection, preview: true }); + editor.revealRange(selection, vscode.TextEditorRevealType.InCenterIfOutsideViewport); } catch (error) { if (!this.closed) void vscode.window.showWarningMessage(`Cannot open Compose source: ${error instanceof Error ? error.message : String(error)}`); } @@ -537,65 +1013,151 @@ class PreviewSession implements vscode.Disposable { dispose(): void { if (this.closed) return; + // VS Code disposes the panel before onDidDispose: release native focus/held keys without touching its webview. + if (this.focusedInput !== undefined) this.backend.blur(this.focusedInput); + this.focusedInput = undefined; + this.browserFocus = undefined; + this.heldKeys.clear(); this.closed = true; clearTimeout(this.timer); clearTimeout(this.logTimer); + clearTimeout(this.pushTimer); this.controller.abort(); - this.task?.terminate(); for (const subscription of this.subscriptions) subscription.dispose(); + // The panel's build and live compile errors; nothing refreshes them once it is closed. + this.diagnostics.clear(); this.panel.dispose(); this.backends.release(this.backend); } } -/** Compile messages of the live (Komodo) engine; one collection for the extension. */ -let composeDiagnostics: vscode.DiagnosticCollection | undefined; - -export function registerComposePreview(workspace: ElideWorkspace, ui: ElideUi, context: vscode.ExtensionContext): { disposable: vscode.Disposable; api: ElideComposePreviewApi } { +export function registerComposePreview(workspace: ElideWorkspace, ui: ElideUi, context: vscode.ExtensionContext): { + disposable: vscode.Disposable; api: ElideComposePreviewApi; + agentHost: { backends: ComposeBackends; arbiter: KomodoSessionArbiter; cacheRoot: string }; +} { const cacheRoot = context.globalStorageUri.fsPath; - composeDiagnostics = vscode.languages.createDiagnosticCollection("elide-compose-preview"); - const elideBin = (): string | undefined => { - try { return resolveElideDistribution({ explicitHome: readConfig().home }).bin; } catch { return undefined; } - }; + const arbiter = new KomodoSessionArbiter(); const backends = new ComposeBackends(cacheRoot, context.asAbsolutePath("media/compose"), () => readConfig().composePreviewKeepAlive * 60_000, - (key, root, helpers, store) => readConfig().composePreviewEngine === "komodo" + (key, root, helpers, store, role) => readConfig().composePreviewEngine === "komodo" ? new KomodoBackend(key, root, helpers, store, { - path: () => readConfig().composePreviewKomodoPath, - fallback: () => readConfig().composePreviewKomodoFallback, + previews: (file) => workspace.komodoPreviews(file), + session: (file) => workspace.komodoPreviewSession(file), + sync: (files) => workspace.komodoPreviewSync(files), log: (line) => ui.log(line), - }, elideBin) + unavailable: (reason) => workspace.rejectKomodoPreview(reason), + arbiter, + role, + }) : new ComposeBackend(key, root, helpers, store), () => readConfig().composePreviewEngine); const paints = new vscode.EventEmitter(); const prewarmed = new vscode.EventEmitter(); + const errors = new vscode.EventEmitter(); + const diagnostics = vscode.languages.createDiagnosticCollection("elide-compose-preview"); let session: PreviewSession | undefined; let prewarmTimer: NodeJS.Timeout | undefined; let prewarming: Promise = Promise.resolve(); + /** The last prewarm failure logged; focus changes retry prewarming, and each distinct failure is logged once. */ + let prewarmFailure: string | undefined; + let disposed = false; + /** Last value of `elide.composePreviewAvailable`, which shows Open Compose Preview in the editor title. */ + let available: boolean | undefined; + let availabilityTimer: NodeJS.Timeout | undefined; + + /** The application rendering `editor`'s file: a Kotlin source declaring previews, in a synced app's preview closure. */ + const previewable = (editor: vscode.TextEditor | undefined): { project: ElideProject; target: AndroidTargetModel } | undefined => { + const document = editor?.document; + if (!document || document.languageId !== "kotlin" || document.uri.scheme !== "file" || !composePreviewAnnotations(document.getText())) return undefined; + const project = workspace.projectFor(document.uri.fsPath); + return project?.model ? previewApp(workspace, project, document.uri.fsPath) : undefined; + }; + const updateAvailability = (): void => { + clearTimeout(availabilityTimer); + const value = !disposed && (readConfig().composePreviewEngine !== "komodo" || workspace.komodoPreviewAvailable) + && previewable(vscode.window.activeTextEditor) !== undefined; + if (value === available) return; + available = value; + void vscode.commands.executeCommand("setContext", "elide.composePreviewAvailable", value); + }; /** Warm the backend the next Open Compose Preview of the focused file would use. */ const prewarm = async (): Promise => { const config = readConfig(); - if (!config.composePreviewPrewarm || config.composePreviewKeepAlive <= 0 || !vscode.workspace.isTrusted || session?.open) return; + if (disposed || !config.composePreviewPrewarm || config.composePreviewKeepAlive <= 0 || !vscode.workspace.isTrusted || session?.open) return; const editor = vscode.window.activeTextEditor; - if (!editor || editor.document.languageId !== "kotlin" || editor.document.uri.scheme !== "file") return; - if (!/@\w*Preview\b|androidx\.compose\.ui\.tooling\.preview\./.test(editor.document.getText())) return; - const project = workspace.projectFor(editor.document.uri.fsPath); - if (!project?.model) return; - const app = previewApp(workspace, project, editor.document.uri.fsPath); + const app = previewable(editor); const model = app?.project.model; - if (!app || !model) return; + if (!editor || !app || !model) return; const runtime = await installedComposeRuntime(cacheRoot); - if (!runtime || session?.open) return; + if (!runtime || disposed || session?.open) return; + // previewApp keeps the application in the file's workspace folder. + const request = await previewRequest(workspace, app.project.folder, editor.document.uri); + if (readConfig().composePreviewEngine === "komodo") await workspace.requireKomodoPreview(editor.document.uri.fsPath); + if (disposed || session?.open) return; const key = backendKey(app.project, app.target); const backend = backends.obtain(key); - backends.idle(backend); - await backend.prewarm(previewBuild(workspace, app.project, model, app.target), runtime, javaFor(model), - await previewRequest(workspace, project.folder, editor.document.uri)); - prewarmed.fire(key); + const file = path.basename(editor.document.uri.fsPath); + try { + if (backend instanceof KomodoBackend) await backend.prepareSession(editor.document.uri.fsPath); + if (backend.disposed || disposed || session?.open) return; + // The first warm-up of a large project can take a minute: say so where background work shows. + await vscode.window.withProgress({ location: vscode.ProgressLocation.Window, title: `Warming Compose preview for ${file}` }, (progress) => + backend.prewarm(previewBuild(workspace, app.project, model, app.target), runtime, javaFor(model), request, (step) => progress.report({ message: step }))); + } catch (error) { + if (backend instanceof KomodoBackend && !backend.disposed) backend.preempt("Preview prewarm did not complete."); + // Expiry, a model change or shutdown stopped this backend: nothing failed. + if (backend.disposed || disposed) return; + throw new Error(`Compose preview prewarm for ${file} failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); + } finally { + // The keep-alive period counts from the end of the warm-up, which could otherwise outlast it. + backends.idle(backend); + } + prewarmFailure = undefined; + if (!disposed) prewarmed.fire(key); }; const schedulePrewarm = (): void => { clearTimeout(prewarmTimer); - prewarmTimer = setTimeout(() => { prewarming = prewarming.then(prewarm).catch(() => {}); }, 300); + if (disposed) return; + prewarmTimer = setTimeout(() => { + prewarming = prewarming.then(prewarm).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + if (!disposed && message !== prewarmFailure) ui.log(message); + prewarmFailure = message; + }); + }, 300); + }; + + /** Resolves once no sync of `folder` runs, or when `token` is cancelled. */ + const syncEnded = (folder: vscode.WorkspaceFolder, token: vscode.CancellationToken): Promise => { + const { promise, resolve } = Promise.withResolvers(); + const listeners = [token.onCancellationRequested(() => resolve()), workspace.onDidChange(() => { + // A superseded sync reports its end, then starts again in the same task. + setTimeout(() => { if (!workspace.isSyncing(folder)) resolve(); }, 0); + })]; + return promise.finally(() => { for (const listener of listeners) listener.dispose(); }); + }; + /** + * The application previewing `source`. A folder's first sync can still be running (the window just opened): wait for it + * rather than fail. Undefined when the user cancels that wait. + */ + const previewTarget = async (source: vscode.Uri): Promise<{ project: ElideProject; target: AndroidTargetModel } | undefined> => { + let project = workspace.projectFor(source.fsPath); + if (project && !project.model && workspace.isSyncing(project.folder)) { + const folder = project.folder; + const waited = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, cancellable: true, + title: `Elide: waiting for the sync of ${folder.name} to preview ${path.basename(source.fsPath)}…` }, async (_progress, token) => { + await syncEnded(folder, token); + return !token.isCancellationRequested; + }); + if (!waited) return undefined; + project = workspace.projectFor(source.fsPath); + const failure = workspace.lastError(folder); + if (!project?.model && failure) throw new Error(`The sync of ${folder.name} failed: ${failure}`); + } + if (!project?.model) throw new Error("Sync the project before opening a Compose preview."); + const app = previewApp(workspace, project, source.fsPath); + if (!app) throw new Error("No Android application in this workspace folder packages this file. Declare an AndroidApk that includes its source set or depends on its library, then sync."); + return app; }; const command = vscode.commands.registerCommand("elide.androidComposePreview", async () => { @@ -603,42 +1165,110 @@ export function registerComposePreview(workspace: ElideWorkspace, ui: ElideUi, c if (!vscode.workspace.isTrusted) throw new Error("Trust this workspace before building a preview."); const editor = vscode.window.activeTextEditor; if (!editor || editor.document.languageId !== "kotlin" || editor.document.uri.scheme !== "file") throw new Error("Open a Kotlin source file containing @Preview functions."); - const project = workspace.projectFor(editor.document.uri.fsPath); - if (!project?.model) throw new Error("Sync the project before opening a Compose preview."); - if (!await editor.document.save()) return; - const app = previewApp(workspace, project, editor.document.uri.fsPath); - if (!app) throw new Error("No Android application in this workspace folder packages this file. Declare an AndroidApk that includes its source set or depends on its library, then sync."); + // Building cannot find previews the source does not declare; say so before spending a build on it. + if (readConfig().composePreviewEngine === "komodo") await workspace.requireKomodoPreview(editor.document.uri.fsPath); + if (!composePreviewAnnotations(editor.document.getText())) { + throw new Error(`${path.basename(editor.document.uri.fsPath)} declares no @Preview functions. Annotate a composable with @Preview, or with a multipreview annotation whose name contains "Preview", to preview it.`); + } + const source = editor.document.uri; + const app = await previewTarget(source); + if (!app || disposed) return; + if (readConfig().composePreviewEngine === "komodo") await workspace.requireKomodoPreview(source.fsPath); + if (disposed) return; + // The open panel keeps its place, gallery and state; its Rebuild button builds again. + if (session?.open && session.previews(app.project, app.target, source)) { + session.reveal(); + return; + } session?.dispose(); - session = new PreviewSession(workspace, ui, context, backends, app.project, app.target, editor.document.uri, paints); + session = new PreviewSession(workspace, ui, context, backends, app.project, app.target, source, paints, errors, diagnostics); } catch (error) { void vscode.window.showErrorMessage(`Elide: ${error instanceof Error ? error.message : String(error)}`); } }); + // Reopen the preview a window reload closed: its webview state names the previewed file. + const serializer = vscode.window.registerWebviewPanelSerializer(VIEW_TYPE, { + deserializeWebviewPanel: async (panel: vscode.WebviewPanel, state: unknown) => { + let source = state && typeof state === "object" && "source" in state && typeof state.source === "string" ? state.source : undefined; + // A panel renamed while hidden saved its old source. The bound only guards against a corrupted record. + const renames = context.workspaceState.get>(RENAMES_KEY) ?? {}; + for (let hops = Object.keys(renames).length; hops > 0 && source !== undefined && renames[source] !== undefined; hops--) source = renames[source]; + const saved = source === undefined ? undefined : vscode.Uri.parse(source); + if (saved?.scheme !== "file") { + panel.dispose(); + return; + } + const file = path.basename(saved.fsPath); + try { + if (!vscode.workspace.isTrusted) throw new Error("Trust this workspace before building a preview."); + await vscode.workspace.fs.stat(saved).then(undefined, () => { throw new Error(`${file} no longer exists.`); }); + if (readConfig().composePreviewEngine === "komodo") await workspace.requireKomodoPreview(saved.fsPath); + const app = await previewTarget(saved); + if (readConfig().composePreviewEngine === "komodo") await workspace.requireKomodoPreview(saved.fsPath); + // The user opened another preview meanwhile; there is one at a time. + if (!app || disposed || session?.open) { + panel.dispose(); + return; + } + session = new PreviewSession(workspace, ui, context, backends, app.project, app.target, saved, paints, errors, diagnostics, panel); + } catch (error) { + panel.dispose(); + void vscode.window.showWarningMessage(`Elide: the Compose preview of ${file} was not restored: ${error instanceof Error ? error.message : String(error)}`); + } + }, + }); const subscriptions = [ command, + serializer, paints, + errors, + diagnostics, prewarmed, - vscode.window.onDidChangeActiveTextEditor(schedulePrewarm), + workspace.onDidChangeKomodoAvailability(() => { + if (!workspace.komodoPreviewAvailable) { + clearTimeout(prewarmTimer); + backends.retireEngine("komodo"); + } else schedulePrewarm(); + updateAvailability(); + }), + vscode.window.onDidChangeActiveTextEditor(() => { + updateAvailability(); + schedulePrewarm(); + }), + vscode.workspace.onDidChangeTextDocument(({ document }) => { + if (document !== vscode.window.activeTextEditor?.document) return; + clearTimeout(availabilityTimer); + availabilityTimer = setTimeout(updateAvailability, 300); + }), vscode.workspace.onDidGrantWorkspaceTrust(schedulePrewarm), vscode.workspace.onDidChangeConfiguration((event) => { if (!event.affectsConfiguration("elide.android.composePreview")) return; + session?.focusInput(undefined); backends.rearm(); schedulePrewarm(); + updateAvailability(); }), workspace.onDidChange(() => { backends.retain((key) => workspace.projects.some((project) => project.model?.android?.targets.some((target) => backendKey(project, target) === key))); + updateAvailability(); schedulePrewarm(); }), ]; + updateAvailability(); schedulePrewarm(); const disposable = new vscode.Disposable(() => { + disposed = true; clearTimeout(prewarmTimer); + clearTimeout(availabilityTimer); session?.dispose(); backends.dispose(); - composeDiagnostics?.dispose(); - composeDiagnostics = undefined; for (const subscription of subscriptions) subscription.dispose(); }); - return { disposable, api: { onDidPaint: paints.event, onDidPrewarm: prewarmed.event } }; + return { disposable, agentHost: { backends, arbiter, cacheRoot }, api: { onDidPaint: paints.event, onDidError: errors.event, onDidPrewarm: prewarmed.event, + setInspect: (enabled) => session?.setInspect(enabled), + pointer: (imageId, action, x, y) => session?.pointer(imageId, action, x, y) ?? false, + focusInput: (imageId) => session?.focusInput(imageId) ?? false, + keyboard: (imageId, event) => session?.keyboard(imageId, event) ?? false, + openSource: (imageId, nodeId) => session?.openSource(imageId, nodeId) ?? Promise.resolve(false) } }; } diff --git a/packages/vscode/src/composePreviewHtml.ts b/packages/vscode/src/composePreviewHtml.ts index 7810c7b..f7ff88d 100644 --- a/packages/vscode/src/composePreviewHtml.ts +++ b/packages/vscode/src/composePreviewHtml.ts @@ -1,4 +1,5 @@ import { randomBytes } from "node:crypto"; +import { PREVIEW_KEY_NAMES } from "./composeBackend.js"; /** Self-contained webview: preview pixels and tooling coordinates share one canvas. */ export function previewHtml(): string { @@ -36,7 +37,8 @@ h1 { font-size: 13px; font-weight: 650; margin: 0; letter-spacing: .01em; } .status-row[data-state="ready"] .status-dot { background: var(--vscode-testing-iconPassed, #73c991); } .status-row[data-state="error"] .status-dot { background: var(--error); } #status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } -#diagnostics-toggle, #log-toggle { font-size: 11px; min-height: 24px; padding: 1px 6px; } +#diagnostics-toggle, #log-toggle, #status-action { font-size: 11px; min-height: 24px; padding: 1px 6px; } +#status-action { color: var(--accent); border-color: var(--border); } #status-elapsed { font-variant-numeric: tabular-nums; white-space: nowrap; } #retained { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; border: 1px solid var(--border); border-radius: 3px; padding: 0 5px; white-space: nowrap; } #gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); align-items: start; gap: 18px; padding: 20px 18px 32px; } @@ -68,6 +70,9 @@ body:not(.inspect) .layout-overlay { display: none; } .component-picker { min-width: 0; flex: 1; width: 0; height: 25px; border: 1px solid var(--vscode-dropdown-border, var(--border)); border-radius: 3px; color: var(--vscode-dropdown-foreground, var(--text)); background: var(--vscode-dropdown-background, var(--surface)); font-size: 11px; padding: 0 5px; } .parent-node { min-height: 25px; padding: 1px 5px; font-size: 11px; } .inspection-hint { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.6; } +.card-size { font-size: 11px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } +.inspect-hint { margin: 14px 18px -6px; color: var(--muted); font-size: 12px; } +.inspect-hint strong { color: var(--text); font-weight: 600; } #empty { max-width: 400px; margin: 15vh auto 0; padding: 0 24px; text-align: center; color: var(--muted); } .empty-icon { display: inline-flex; padding: 14px; border: 1px solid var(--border); border-radius: 12px; margin-bottom: 12px; color: var(--accent); } .empty-icon svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.25; } @@ -78,6 +83,7 @@ body:not(.inspect) .layout-overlay { display: none; } .panel-actions { display: flex; align-items: center; gap: 4px; } #diagnostics-title, #build-log-title { margin: 0; font-size: 12px; } #diagnostics-body { margin: 0; padding: 12px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.6 var(--vscode-editor-font-family, monospace); user-select: text; color: var(--error); } +#diagnostics-body a { color: var(--accent); } #build-log-body { min-height: 120px; padding: 8px 12px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.5 var(--vscode-editor-font-family, monospace); user-select: text; } .log-error { color: var(--error); } .log-warning { color: var(--vscode-editorWarning-foreground, #cca700); } @@ -85,7 +91,7 @@ body:not(.inspect) .layout-overlay { display: none; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } .vscode-high-contrast .preview-card, .vscode-high-contrast-light .preview-card, .vscode-high-contrast button, .vscode-high-contrast-light button { border-color: var(--vscode-contrastBorder, var(--border)); } .vscode-high-contrast .layout-outline, .vscode-high-contrast-light .layout-outline { stroke-opacity: 1; stroke-width: 2; } -@media (max-width: 420px) { .toolbar-main { padding: 9px 10px 5px; gap: 6px; } .actions { gap: 3px; } .actions button { padding: 4px 6px; font-size: 11px; } .status-row { padding-left: 10px; padding-right: 10px; } #gallery { padding: 12px 10px 24px; gap: 12px; } .viewport { padding: 10px; } #retained { display: none; } } +@media (max-width: 420px) { .toolbar-main { padding: 9px 10px 5px; gap: 6px; } .actions { gap: 3px; } .actions button { padding: 4px 6px; font-size: 11px; } .status-row { padding-left: 10px; padding-right: 10px; } #gallery { padding: 12px 10px 24px; gap: 12px; } .viewport { padding: 10px; } #retained { display: none; } .inspect-hint { margin: 10px 10px -2px; } } @media (forced-colors: active) { .layout-outline { stroke: Highlight; stroke-opacity: 1; } .layout-outline.selected, .layout-outline.hovered { stroke: Highlight; fill: Highlight; } button { border-color: ButtonText; } } @@ -100,13 +106,15 @@ body:not(.inspect) .layout-overlay { display: none; } -
Starting preview…
+
Starting preview…

Preparing your previews

Rendered composables will appear here. You can keep editing while they build.

+

Use arrow keys to select components, Home or End for the first or last component, Alt plus Up for the enclosing component, and Enter to open source. Click to select, or double-click to open source.

+

Click or Tab to a live preview to send keyboard input to that card. Tab then moves between its Compose controls. Press F6 or Shift+F6 to leave using VS Code's next or previous part focus commands, or click outside the preview. Escape remains available to Compose. IME composition and clipboard paste are not supported.

@@ -123,11 +131,70 @@ let statusState = 'working'; let statusText = 'Starting preview…'; let diagnosticsText = ''; let diagnosticOrigin = null; +/** The recovery the extension offers for the current error, if the next ordinary build cannot fix it. */ +let statusAction = null; /** Epoch milliseconds the running phase began; the status row counts up from it. */ let since = null; let logLines = 0; const LOG_LIMIT = 2000; const cards = new Map(); +const inputKeys = ${JSON.stringify(PREVIEW_KEY_NAMES)}; +let inputEpoch = 0; +let inputFocus = 0; +let focusedInput = null; +let composing = false; +const heldKeys = new Set(); + +function releaseInput(notify = true) { + const previous = focusedInput; + focusedInput = null; + heldKeys.clear(); + if (previous && notify) api.postMessage({ type: 'focusInput', imageId: null, epoch: inputEpoch, focus: previous.focus }); +} +function focusInput(card) { + if (inspecting || composing || !card.live || !card.keyboard || !card.image || !isFresh()) return false; + if (focusedInput && focusedInput.card === card) return true; + releaseInput(); + focusedInput = { card, focus: ++inputFocus }; + api.postMessage({ type: 'focusInput', imageId: card.id, epoch: inputEpoch, focus: inputFocus }); + return true; +} +function resetInput(epoch) { + const canvas = focusedInput && focusedInput.card.canvas; + releaseInput(false); + inputEpoch = epoch; + composing = false; + for (const card of cards.values()) { card.keyboard = false; updateInspector(card); } + if (canvas && document.activeElement === canvas) canvas.blur(); +} +function keyboard(card, action, event) { + if (composing || event.isComposing || event.keyCode === 229) { + if (focusedInput && focusedInput.card === card) releaseInput(); + return; + } + if (inspecting || !focusedInput || focusedInput.card !== card || document.activeElement !== card.canvas + || !card.live || !card.keyboard || !isFresh()) return; + // Preserve VS Code's keyboard-only exit from a webview instead of trapping its focus-navigation keys. + if (event.key === 'F6' && !event.ctrlKey && !event.altKey && !event.metaKey) { + if (action === 'down') { + releaseInput(); + card.canvas.blur(); + } + return; // Bubble to the webview host's existing Focus Next/Previous Part bindings. + } + const point = event.key.codePointAt(0); + if (!inputKeys.includes(event.key) + && !([...event.key].length === 1 && point >= 32 && point !== 127 && !(point >= 0xd800 && point <= 0xdfff))) return; + const key = event.code || event.key; + if ((action === 'up' || event.repeat) && !heldKeys.has(key)) return; + if (action === 'down') heldKeys.add(key); + else heldKeys.delete(key); + api.postMessage({ type: 'keyboard', imageId: card.id, epoch: inputEpoch, focus: focusedInput.focus, + event: { action, key: event.key, code: event.code, shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, + altKey: event.altKey, metaKey: event.metaKey, repeat: event.repeat } }); + event.preventDefault(); + event.stopPropagation(); +} const svgNS = 'http://www.w3.org/2000/svg'; function element(tag, className, text) { const result = document.createElement(tag); @@ -142,11 +209,13 @@ function button(className, text, title) { return result; } function preferences() { + if (inspecting) releaseInput(); document.body.classList.toggle('fit', fitted); document.body.classList.toggle('inspect', inspecting); document.getElementById('fit').setAttribute('aria-pressed', String(fitted)); document.getElementById('fit-label').textContent = fitted ? 'Fit' : '1:1'; document.getElementById('inspect').setAttribute('aria-pressed', String(inspecting)); + document.getElementById('inspect-hint').hidden = inspecting || cards.size === 0; for (const card of cards.values()) updateInspector(card); } function isFresh() { return imageRevision === currentRevision && imageRevision >= 0; } @@ -180,17 +249,24 @@ function parentNode(card, node) { function updateInspector(card) { const node = activeNode(card); const selected = selectedNode(card); - card.canvas.tabIndex = inspecting && card.nodes.length ? 0 : -1; - card.canvas.setAttribute('aria-label', card.name + (selected ? ', selected ' + selected.name : ', layout inspector')); - card.controls.hidden = !inspecting || !card.nodes.length; - card.hint.hidden = inspecting && card.nodes.length > 0; - card.open.hidden = !inspecting || !card.nodes.length; + card.canvas.tabIndex = inspecting ? card.nodes.length ? 0 : -1 : card.live && card.image ? 0 : -1; + card.canvas.setAttribute('aria-label', card.name + (inspecting ? selected ? ', selected ' + selected.name : ', layout inspector' + : card.keyboard ? ', interactive preview' : card.live ? ', keyboard input unavailable' : ', preview image')); + card.canvas.setAttribute('aria-roledescription', inspecting ? 'layout inspector' : 'Compose preview'); + card.canvas.setAttribute('aria-describedby', inspecting ? 'keyboard-help' : 'input-help'); + card.canvas.title = !inspecting && card.live && !card.keyboard + ? 'Keyboard input unavailable: this Komodo does not support it. Install a compatible Komodo through Elide.' : ''; + card.size.textContent = card.image ? card.dimensions : ''; + // Inspection details take room only while Inspect is on; the toolbar's Inspect toggle is the one way in. + card.inspector.hidden = !inspecting; + card.controls.hidden = !card.nodes.length; + card.hint.hidden = card.nodes.length > 0; + card.open.hidden = !card.nodes.length; card.open.disabled = !canNavigate(card, node); card.open.title = !isFresh() ? 'Source navigation resumes after a successful rebuild' : node && node.source ? 'Open ' + sourceLabel(node) : 'Select a component with source information'; - card.component.textContent = !inspecting ? 'Layout inspection' : node ? node.name : card.nodes.length ? 'Select a component' : 'No layout information'; - card.context.textContent = !inspecting ? card.dimensions : !isFresh() ? 'Waiting for fresh source locations · preview retained' : node ? sourceLabel(node) + ' · ' + Math.round(node.width) + ' × ' + Math.round(node.height) : card.nodes.length ? card.nodes.length + ' components · click a layout outline' : 'This render did not provide component bounds.'; + card.component.textContent = node ? node.name : card.nodes.length ? 'Select a component' : 'No layout information'; + card.context.textContent = (node ? Math.round(node.width) + ' × ' + Math.round(node.height) + ' px · x ' + node.x + ', y ' + node.y + (node.source ? ' · ' + sourceLabel(node) : ' · No source location') : card.nodes.length ? card.nodes.length + ' layouts · click an outline' : card.layoutError || 'This render did not provide layout bounds.') + (!isFresh() ? ' · Last rendered layout; awaiting update' : ''); card.context.title = card.context.textContent; - card.hint.textContent = !inspecting ? 'Enable Inspect to explore bounds and open component source.' : 'Rebuild this preview to refresh layout information.'; card.picker.value = card.selectedId || ''; card.parent.disabled = !parentNode(card, selected); for (const [id, rect] of card.rects) { @@ -202,7 +278,7 @@ function selectNode(card, node, announce) { card.selectedId = node ? node.id : null; card.hoveredId = null; updateInspector(card); - if (announce && node) document.getElementById('announcement').textContent = node.name + ', ' + sourceLabel(node); + if (announce && node) document.getElementById('announcement').textContent = node.name + ', ' + node.width + ' by ' + node.height + ' pixels'; } function navigate(card, node) { if (!canNavigate(card, node)) return; @@ -250,12 +326,13 @@ function createCard(id) { heading.append(title, detail); const error = button('card-error', 'Details', 'Show preview diagnostics'); error.hidden = true; - header.append(heading, error); + const size = element('span', 'card-size'); + header.append(heading, size, error); const viewport = element('div', 'viewport'); const canvas = element('div', 'canvas'); canvas.setAttribute('role', 'group'); - canvas.setAttribute('aria-roledescription', 'layout inspector'); - canvas.setAttribute('aria-describedby', 'keyboard-help'); + canvas.setAttribute('aria-roledescription', 'Compose preview'); + canvas.setAttribute('aria-describedby', 'input-help'); const overlay = document.createElementNS(svgNS, 'svg'); overlay.classList.add('layout-overlay'); overlay.setAttribute('aria-hidden', 'true'); @@ -275,11 +352,12 @@ function createCard(id) { picker.setAttribute('aria-label', 'Select component'); const parent = button('parent-node', '↑ Parent', 'Select enclosing component (Alt+Up)'); controls.append(picker, parent); - const hint = element('p', 'inspection-hint'); + const hint = element('p', 'inspection-hint', 'Rebuild this preview to refresh layout information.'); inspector.append(row, context, controls, hint); + inspector.hidden = true; root.append(header, viewport, inspector); - const card = { id, root, title, detail, error, viewport, canvas, overlay, unavailable, component, open, context, controls, picker, parent, hint, nodes: [], rects: new Map(), image: null, data: null, selectedId: null, hoveredId: null, width: 0, height: 0, dimensions: '', name: '', errorText: '' }; - error.onclick = () => showDiagnostics(card.errorText, card.name, error); + const card = { id, root, title, detail, size, error, viewport, canvas, overlay, unavailable, inspector, component, open, context, controls, picker, parent, hint, nodes: [], rects: new Map(), image: null, data: null, selectedId: null, hoveredId: null, width: 0, height: 0, dimensions: '', name: '', errorText: '', errorLinks: [], live: false, keyboard: false }; + error.onclick = () => showDiagnostics(card.errorText, card.name, error, card); open.onclick = () => navigate(card, activeNode(card)); picker.onchange = () => selectNode(card, card.nodes.find(node => node.id === picker.value), true); parent.onclick = () => selectNode(card, parentNode(card, selectedNode(card)), true); @@ -298,16 +376,31 @@ function createCard(id) { canvas.addEventListener('dblclick', event => { if (inspecting) navigate(card, hitNode(card, event)); }); // A live card (the Komodo engine) takes pointer input while not inspecting, in the image's pixels. const forward = (action, event) => { - if (inspecting || !card.live || !card.image) return; + if (inspecting || !card.live || !card.image || !isFresh()) return; const bounds = card.image.getBoundingClientRect(); if (!bounds.width || !bounds.height) return; - api.postMessage({ type: 'pointer', imageId: card.id, action, + api.postMessage({ type: 'pointer', imageId: card.id, epoch: inputEpoch, action, x: Math.round((event.clientX - bounds.left) * card.width / bounds.width), y: Math.round((event.clientY - bounds.top) * card.height / bounds.height) }); }; - canvas.addEventListener('pointerdown', event => { if (card.live && !inspecting) { canvas.setPointerCapture(event.pointerId); forward('down', event); } }); + canvas.addEventListener('pointerdown', event => { + if (card.live && !inspecting) { + canvas.focus({ preventScroll: true }); + focusInput(card); + canvas.setPointerCapture(event.pointerId); + forward('down', event); + } + }); canvas.addEventListener('pointerup', event => forward('up', event)); canvas.addEventListener('pointermove', event => { if (event.buttons) forward('move', event); }); - canvas.addEventListener('keydown', event => inspectKey(card, event)); + canvas.addEventListener('focus', () => focusInput(card)); + canvas.addEventListener('blur', () => { if (focusedInput && focusedInput.card === card) releaseInput(); }); + canvas.addEventListener('keydown', event => { if (inspecting) inspectKey(card, event); else keyboard(card, 'down', event); }); + canvas.addEventListener('keyup', event => keyboard(card, 'up', event)); + canvas.addEventListener('compositionstart', () => { composing = true; releaseInput(); }); + canvas.addEventListener('compositionend', () => { + composing = false; + if (document.activeElement === canvas) focusInput(card); + }); return card; } function installNodes(card, nodes) { @@ -328,7 +421,7 @@ function installNodes(card, nodes) { rect.setAttribute('height', String(node.height)); card.overlay.append(rect); card.rects.set(node.id, rect); - const option = element('option', '', node.name + (node.source ? ' — ' + sourceLabel(node) : '')); + const option = element('option', '', node.name + ' — ' + (node.source ? sourceLabel(node) : node.width + ' × ' + node.height + ' px at ' + node.x + ', ' + node.y)); option.value = node.id; card.picker.append(option); } @@ -336,18 +429,45 @@ function installNodes(card, nodes) { function emptyState() { document.getElementById('empty').hidden = cards.size > 0; document.getElementById('empty-title').textContent = statusState === 'error' ? 'Preview could not be built' : statusState === 'working' ? 'Preparing your previews' : 'No previews to display'; - document.getElementById('empty-description').textContent = statusState === 'error' ? 'Open Details for diagnostics, or Build log for the build output. Your previews will appear after the next successful build.' : statusState === 'working' ? 'Rendered composables will appear here. You can keep editing while they build' + (logLines ? '; open Build log to follow the build.' : '.') : 'Add a @Preview composable to this file, then rebuild.'; + document.getElementById('empty-description').textContent = statusState === 'error' ? 'Open Details for diagnostics, or Build log for the build output. ' + (statusAction ? statusAction.detail : 'Your previews will appear after the next successful build.') : statusState === 'working' ? 'Rendered composables will appear here. You can keep editing while they build' + (logLines ? '; open Build log to follow the build.' : '.') : 'Add a @Preview composable to this file, then rebuild.'; } function closeDiagnostics() { document.getElementById('diagnostics').hidden = true; document.getElementById('diagnostics-toggle').setAttribute('aria-expanded', 'false'); if (diagnosticOrigin && diagnosticOrigin.isConnected) diagnosticOrigin.focus({ preventScroll: true }); } -function showDiagnostics(text, title, origin) { +/** Diagnostics text; a card's project stack frames link to their source, by line and UTF-16 span. */ +function diagnosticsContent(text, card) { + const body = document.getElementById('diagnostics-body'); + const links = card ? card.errorLinks : []; + if (!links.length) { body.textContent = text; return; } + const fragment = document.createDocumentFragment(); + const lines = text.split('\n'); + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + let cursor = 0; + links.forEach((link, linkIndex) => { + if (link.line !== index || link.start < cursor || link.end > line.length) return; + fragment.append(line.slice(cursor, link.start)); + const anchor = element('a', '', line.slice(link.start, link.end)); + anchor.href = '#'; + anchor.title = 'Open ' + anchor.textContent; + anchor.onclick = event => { + event.preventDefault(); + api.postMessage({ type: 'openFrame', revision: imageRevision, imageId: card.id, link: linkIndex }); + }; + fragment.append(anchor); + cursor = link.end; + }); + fragment.append(line.slice(cursor) + (index < lines.length - 1 ? '\n' : '')); + } + body.replaceChildren(fragment); +} +function showDiagnostics(text, title, origin, card) { closeBuildLog(false); diagnosticOrigin = origin; document.getElementById('diagnostics-title').textContent = title || 'Preview diagnostics'; - document.getElementById('diagnostics-body').textContent = text; + diagnosticsContent(text, card); document.getElementById('diagnostics').hidden = false; document.getElementById('diagnostics-toggle').setAttribute('aria-expanded', 'true'); document.getElementById('diagnostics-close').focus({ preventScroll: true }); @@ -396,12 +516,18 @@ function applyStatus(data) { statusState = ['working', 'ready', 'error'].includes(data.state) ? data.state : 'working'; statusText = typeof data.text === 'string' ? data.text : ''; diagnosticsText = statusState === 'error' ? statusText : ''; + statusAction = statusState === 'error' && data.action && typeof data.action.id === 'string' ? data.action : null; document.getElementById('status').dataset.state = statusState; - const summary = statusText.split(/\r?\n/).find(line => line.trim()) || (statusState === 'error' ? 'Preview build failed' : 'Preparing previews'); + const summary = (typeof data.headline === 'string' && data.headline) || statusText.split(/\r?\n/).find(line => line.trim()) || (statusState === 'error' ? 'Preview build failed' : 'Preparing previews'); document.getElementById('status-text').textContent = summary; document.getElementById('status-text').title = summary; document.getElementById('retained').hidden = data.retained !== true; document.getElementById('diagnostics-toggle').hidden = !diagnosticsText; + document.getElementById('status-action').hidden = !statusAction; + if (statusAction) { + document.getElementById('status-action').textContent = statusAction.label; + document.getElementById('status-action').title = statusAction.detail; + } since = Number.isFinite(data.since) ? data.since : null; updateElapsed(); gallery.setAttribute('aria-busy', String(statusState === 'working')); @@ -416,6 +542,10 @@ async function receiveImages(message) { // A restored webview may receive old pixels with the current invalidation revision. const expectedRevision = Number.isFinite(message.currentRevision) ? message.currentRevision : message.revision; if (expectedRevision < currentRevision || message.revision > expectedRevision) return; + if (!Number.isFinite(message.inputEpoch) || message.inputEpoch < inputEpoch) return; + if (message.inputEpoch !== inputEpoch) resetInput(message.inputEpoch); + if (focusedInput && (!Array.isArray(message.keyboard) || !message.keyboard.includes(focusedInput.card.id) + || !message.images.some(image => image.id === focusedInput.card.id && image.live && image.data && !image.error))) releaseInput(); currentRevision = expectedRevision; const ticket = ++generation; for (const card of cards.values()) updateInspector(card); @@ -430,6 +560,8 @@ async function receiveImages(message) { catch { return { preview, image: null, decodeError: 'The rendered PNG could not be decoded. Rebuild to try again.' }; } })); if (ticket !== generation || currentRevision !== expectedRevision) return; + // An input reset during decoding already released input: the pixels stand, the keyboard cards wait for the next publication. + const keyboard = inputEpoch === message.inputEpoch && Array.isArray(message.keyboard) ? message.keyboard : []; const scrollX = window.scrollX; const scrollY = window.scrollY; const keep = new Set(); @@ -445,7 +577,12 @@ async function receiveImages(message) { card.title.textContent = preview.name; card.detail.textContent = preview.detail || ''; card.errorText = [preview.error, entry.decodeError].filter(Boolean).join('\n\n'); + card.layoutError = typeof preview.layoutError === 'string' ? preview.layoutError : ''; + card.errorLinks = message.errorLinks && Array.isArray(message.errorLinks[preview.id]) ? message.errorLinks[preview.id] : []; card.error.hidden = !card.errorText; + card.live = preview.live === true && !!preview.data && !card.errorText; + card.keyboard = card.live && keyboard.includes(card.id); + if (focusedInput && focusedInput.card === card && !card.keyboard) releaseInput(); if (entry.image) { const left = card.viewport.scrollLeft; const top = card.viewport.scrollTop; @@ -462,7 +599,6 @@ async function receiveImages(message) { card.width = !retainedPixels && preview.width > 0 ? preview.width : card.image.naturalWidth; card.height = !retainedPixels && preview.height > 0 ? preview.height : card.image.naturalHeight; card.dimensions = card.image.naturalWidth + ' × ' + card.image.naturalHeight + ' px'; - card.live = preview.live === true; card.overlay.setAttribute('viewBox', '0 0 ' + card.width + ' ' + card.height); installNodes(card, retainedPixels ? [] : Array.isArray(preview.nodes) ? preview.nodes : []); } else installNodes(card, []); @@ -473,18 +609,30 @@ async function receiveImages(message) { if (atPosition !== card.root) gallery.insertBefore(card.root, atPosition || null); position++; } - for (const [id, card] of cards) if (!keep.has(id)) { card.root.remove(); cards.delete(id); } + for (const [id, card] of cards) if (!keep.has(id)) { + if (focusedInput && focusedInput.card === card) releaseInput(); + card.root.remove(); + cards.delete(id); + } document.getElementById('count').textContent = String(cards.size); + document.getElementById('inspect-hint').hidden = inspecting || cards.size === 0; gallery.setAttribute('aria-busy', 'false'); emptyState(); window.scrollTo(scrollX, scrollY); + // Acknowledged even when a newer publication is decoding: the extension reports this one's pixels as painted. requestAnimationFrame(() => requestAnimationFrame(() => { - if (ticket === generation && currentRevision === expectedRevision) api.postMessage({ type: 'painted', revision: imageRevision }); + if (currentRevision === expectedRevision) api.postMessage({ type: 'painted', revision: message.revision, publication: message.publication }); })); } document.getElementById('refresh').onclick = () => api.postMessage({ type: 'refresh' }); +document.getElementById('status-action').onclick = () => { if (statusAction) api.postMessage({ type: 'action', id: statusAction.id }); }; document.getElementById('fit').onclick = () => { fitted = !fitted; preferences(); api.setState(Object.assign({}, saved, { fitted, inspecting })); }; -document.getElementById('inspect').onclick = () => { inspecting = !inspecting; preferences(); api.setState(Object.assign({}, saved, { fitted, inspecting })); }; +document.getElementById('inspect').onclick = () => { + inspecting = !inspecting; + preferences(); + api.setState(Object.assign({}, saved, { fitted, inspecting })); + api.postMessage({ type: 'inspect', enabled: inspecting }); +}; document.getElementById('diagnostics-toggle').onclick = event => { if (document.getElementById('diagnostics').hidden) showDiagnostics(diagnosticsText, 'Preview diagnostics', event.currentTarget); else closeDiagnostics(); @@ -502,19 +650,44 @@ document.addEventListener('keydown', event => { if (!document.getElementById('diagnostics').hidden) { event.preventDefault(); closeDiagnostics(); } else if (!document.getElementById('build-log').hidden) { event.preventDefault(); closeBuildLog(true); } }); +window.addEventListener('blur', () => { + const canvas = focusedInput && focusedInput.card.canvas; + releaseInput(); + composing = false; + if (canvas) canvas.blur(); +}); +document.addEventListener('visibilitychange', () => { if (document.hidden) releaseInput(); }); +window.addEventListener('pagehide', () => releaseInput()); setInterval(updateElapsed, 1000); window.addEventListener('message', ({ data }) => { if (!data || typeof data !== 'object') return; + if (data.type === 'inputReset' && Number.isFinite(data.epoch) && data.epoch >= inputEpoch) resetInput(data.epoch); + else if (data.type === 'inputBlur' && data.epoch === inputEpoch && focusedInput && data.focus === focusedInput.focus) { + const canvas = focusedInput.card.canvas; + releaseInput(false); + if (document.activeElement === canvas) canvas.blur(); + } if (data.type === 'status') applyStatus(data); else if (data.type === 'invalidate' && Number.isFinite(data.revision) && data.revision >= currentRevision) { if (data.revision > currentRevision) { currentRevision = data.revision; generation++; } + releaseInput(); gallery.setAttribute('aria-busy', 'true'); for (const card of cards.values()) updateInspector(card); } else if (data.type === 'images') void receiveImages(data); - else if (data.type === 'log' && Array.isArray(data.lines)) appendLog(data.lines, data.reset === true); + else if (data.type === 'source' && typeof data.uri === 'string') { + // What the extension reopens after a window reload. + saved.source = data.uri; + api.setState(Object.assign({}, saved, { fitted, inspecting })); + } else if (data.type === 'log' && Array.isArray(data.lines)) appendLog(data.lines, data.reset === true); + else if (data.type === 'inspect' && typeof data.enabled === 'boolean') { + inspecting = data.enabled; + preferences(); + api.setState(Object.assign({}, saved, { fitted, inspecting })); + } }); preferences(); api.postMessage({ type: 'ready' }); +if (inspecting) api.postMessage({ type: 'inspect', enabled: true }); `; diff --git a/packages/vscode/src/composeProcessing.ts b/packages/vscode/src/composeProcessing.ts new file mode 100644 index 0000000..1e0ed30 --- /dev/null +++ b/packages/vscode/src/composeProcessing.ts @@ -0,0 +1,308 @@ +import { createHash } from "node:crypto"; +import { cp, mkdir, readFile, readdir, rename, rm } from "node:fs/promises"; +import path from "node:path"; +import { inside, type WorkspacePaths } from "./workspacePaths.js"; + +const prefix = "plugin:org.jetbrains.kotlin.kapt3:"; + +/** + * A build record that does not describe this checkout's last build: it names another checkout, or outputs that no + * longer exist, as when the build cache restored records written elsewhere. A build without the cache rewrites it. + */ +export class StaleBuildRecordError extends Error { + constructor(readonly record: string, detail: string) { + super(`Stale build record ${record}: ${detail}. Rebuild the preview inputs without the build cache (elide build --no-cache) to rewrite it.`); + this.name = "StaleBuildRecordError"; + } +} + +export interface KotlinToolchainRecord { + version: 1; + kind: "embedded" | "managed" | "external"; + home?: string; + classpath?: string[]; +} + +export async function kotlinToolchain(record: string): Promise { + let value: KotlinToolchainRecord; + try { value = JSON.parse(await readFile(record, "utf8")); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`Compose preview needs the Kotlin toolchain record at ${record}. Rebuild with a current Elide version.`, { cause: error }); + throw error; + } + if (!value || value.version !== 1 || !["embedded", "managed", "external"].includes(value.kind) + || (value.kind === "external" && (typeof value.home !== "string" || !path.isAbsolute(value.home))) + || (value.kind === "managed" && (!Array.isArray(value.classpath) || !value.classpath.length || !value.classpath.every((entry) => typeof entry === "string" && path.isAbsolute(entry))))) { + throw new Error(`Invalid Kotlin toolchain record: ${record}. Rebuild with a current Elide version.`); + } + return value; +} + +export function toolchainOptions(toolchain: KotlinToolchainRecord): string[] { + if (toolchain.kind === "managed") return [`--toolchain-classpath=${toolchain.classpath!.join(path.delimiter)}`]; + return toolchain.kind === "external" ? [`--toolchain-home=${toolchain.home}`] : []; +} + +async function files(directory: string): Promise { + try { + const entries = await readdir(directory, { withFileTypes: true }); + return (await Promise.all(entries.map((entry) => { + const file = path.join(directory, entry.name); + return entry.isDirectory() ? files(file) : [file]; + }))).flat().sort(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +async function outputFingerprint(directory: string, extension = ""): Promise { + const hash = createHash("sha256"); + for (const file of await files(directory)) { + if (file.endsWith(extension)) hash.update(path.relative(directory, file)).update("\0").update(await readFile(file)).update("\0"); + } + return hash.digest("hex"); +} + +/** kapt hashes Java declarations, not diagnostic source-position metadata (.kapt_metadata). */ +export async function stubFingerprint(directory: string): Promise { + return outputFingerprint(directory, ".java"); +} + +/** Java ObjectOutputStream block-data framing around DataOutput's int + modified-UTF map. */ +export function decodeProcessorOptions(encoded: string): Map { + const bytes = Buffer.from(encoded, "base64"); + if (bytes.readUInt32BE(0) !== 0xaced0005) throw new Error("Invalid kapt processor option stream."); + const blocks: Buffer[] = []; + for (let offset = 4; offset < bytes.length;) { + const tag = bytes[offset++]!; + const length = tag === 0x77 ? bytes[offset++]! : tag === 0x7a ? bytes.readUInt32BE(offset) : -1; + if (tag === 0x7a) offset += 4; + if (length < 0 || offset + length > bytes.length) throw new Error("Invalid kapt processor option block."); + blocks.push(bytes.subarray(offset, offset + length)); + offset += length; + } + const data = Buffer.concat(blocks); + let offset = 4; + const utf = (): string => { + const length = data.readUInt16BE(offset); + offset += 2; + const end = offset + length; + if (end > data.length) throw new Error("Truncated kapt processor option."); + let result = ""; + while (offset < end) { + const byte = data[offset++]!; + const code = byte < 0x80 ? byte : byte < 0xe0 + ? ((byte & 0x1f) << 6) | (data[offset++]! & 0x3f) + : ((byte & 0x0f) << 12) | ((data[offset++]! & 0x3f) << 6) | (data[offset++]! & 0x3f); + result += String.fromCharCode(code); + } + return result; + }; + const options = new Map(); + const count = data.readInt32BE(0); + if (count < 0 || count > data.length / 4) throw new Error("Invalid kapt processor option count."); + for (let index = 0; index < count; index++) options.set(utf(), utf()); + if (offset !== data.length) throw new Error("Trailing kapt processor option data."); + return options; +} + +export function encodeProcessorOptions(options: ReadonlyMap): string { + const count = Buffer.alloc(4); + count.writeInt32BE(options.size); + const values: Buffer[] = [count]; + for (const entry of options) for (const text of entry) { + const bytes: number[] = []; + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index); + if (code > 0 && code < 0x80) bytes.push(code); + else if (code < 0x800) bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); + else bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); + } + if (bytes.length > 0xffff) throw new Error("kapt processor option exceeds Java UTF limit."); + const length = Buffer.alloc(2); + length.writeUInt16BE(bytes.length); + values.push(length, Buffer.from(bytes)); + } + const data = Buffer.concat(values); + const header = Buffer.alloc(9); + header.writeUInt32BE(0xaced0005); + header[4] = 0x7a; + header.writeUInt32BE(data.length, 5); + return Buffer.concat([header, data]).toString("base64"); +} + +function option(arg: string): { name: string; value: string } | undefined { + if (arg.startsWith("-P=")) arg = arg.slice(3); + if (!arg.startsWith(prefix)) return undefined; + const equals = arg.indexOf("=", prefix.length); + if (equals < 0) throw new Error(`Invalid kapt option: ${arg}`); + return { name: arg.slice(prefix.length, equals), value: arg.slice(equals + 1) }; +} + +export class ComposeProcessing { + readonly generated = new Map(); + readonly counts = { stubs: 0, apt: 0 }; + private stubKey: string | undefined; + private externalKey: string | undefined; + private readonly options: Map; + private readonly paths = new Map(); + private readonly dependencies: string[]; + private readonly stubs: string; + readonly classes: string; + private readonly sources: string; + + get requiresBytecodeTransform(): boolean { return this.options.has("dagger.hilt.android.internal.disableAndroidSuperclassValidation"); } + + private constructor( + readonly args: string[], readonly roots: string[], readonly directory: string, readonly project: string, + ) { + const values = args.map(option).filter((value) => value !== undefined); + const encoded = values.find((value) => value.name === "apoptions")?.value; + this.options = encoded ? decodeProcessorOptions(encoded) : new Map(); + this.dependencies = values.filter((value) => value.name === "apclasspath").map((value) => path.resolve(project, value.value)); + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + if (!/^(?:-classpath|-cp|--class-path|-Xmodule-path)(?:=|$)/.test(arg)) continue; + const value = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : args[++index]; + if (value === undefined) throw new Error(`Missing processing classpath in ${project}.`); + for (const entry of value.split(path.delimiter)) { + const file = path.resolve(project, entry); + if (!this.owns(file)) this.dependencies.push(file); + } + } + for (const [key, value] of this.options) { + if (key === "kapt.kotlin.generated") continue; + if (value && (path.isAbsolute(value) || /(?:location|directory|dir|path|file)$/i.test(key))) this.paths.set(key, path.resolve(project, value)); + } + this.stubs = path.join(directory, "stubs"); + this.classes = path.join(directory, "classes"); + this.sources = path.join(directory, "attempt", "sources"); + } + + /** `paths` respells the record's canonical project paths the way the workspace spells them. */ + static async create(record: string, directory: string, project: string, paths: WorkspacePaths): Promise { + let args: string[]; + try { + args = (await readFile(record, "utf8")).split("\n"); + if (args.at(-1) === "") args.pop(); + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + const provenancePath = path.join(path.dirname(record), "kapt.generated.json"); + const provenance = JSON.parse(await readFile(provenancePath, "utf8")) as { version: number; roots: string[]; files: string[] }; + if (provenance.version !== 1 || !Array.isArray(provenance.roots) || !provenance.roots.length + || !provenance.roots.every((root) => typeof root === "string" && path.isAbsolute(root)) + || !Array.isArray(provenance.files) || !provenance.files.every((file) => typeof file === "string" && path.isAbsolute(file))) { + throw new Error(`Invalid generated source provenance: ${provenancePath}`); + } + const roots = provenance.roots.map((root) => paths.lexical(root)); + const foreign = roots.find((root) => !inside(project, root)); + if (foreign) throw new StaleBuildRecordError(provenancePath, `its generated sources are in ${foreign}, outside ${project}`); + const stage = new ComposeProcessing(args.map((arg) => paths.argument(arg)), roots, directory, project); + await mkdir(stage.classes, { recursive: true }); + for (const file of provenance.files.map((file) => paths.lexical(file))) { + if (!stage.owns(file)) throw new Error(`Generated file outside recorded roots: ${file}`); + try { + if (/\.(?:java|kt)$/.test(file)) stage.generated.set(file, await readFile(file, "utf8")); + else { + const root = roots.find((root) => file.startsWith(`${root}${path.sep}`))!; + const destination = path.join(stage.classes, path.relative(root, file)); + await mkdir(path.dirname(destination), { recursive: true }); + await cp(file, destination); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + throw new StaleBuildRecordError(provenancePath, `the generated file ${file} it lists does not exist`); + } + } + return stage; + } + + owns(file: string): boolean { + return this.roots.some((root) => inside(root, file)); + } + + async inputs(stamp: (file: string) => Promise): Promise { + const hash = createHash("sha256").update(this.args.join("\n")); + for (const file of [...this.dependencies, ...this.paths.values()]) hash.update(file).update("\0").update(await stamp(file)).update("\0"); + return hash.digest("hex"); + } + + /** Isolated stubs first; only commit generated inputs after apt succeeds. */ + async verify(args: string[], externalKey: string, run: (args: string[]) => Promise): Promise { + await rm(this.stubs, { recursive: true, force: true }); + await rm(path.join(this.directory, "attempt"), { recursive: true, force: true }); + await mkdir(this.stubs, { recursive: true }); + await mkdir(this.sources, { recursive: true }); + await mkdir(path.join(this.directory, "attempt", "classes"), { recursive: true }); + this.counts.stubs++; + await run(this.arguments(args, "stubs")); + const stubKey = await stubFingerprint(this.stubs); + if (stubKey === this.stubKey && externalKey === this.externalKey) return false; + await rm(this.sources, { recursive: true, force: true }); + await mkdir(this.sources, { recursive: true }); + for (const [key, source] of this.paths) { + const target = this.optionPath(key, source); + await rm(target, { recursive: true, force: true }); + await mkdir(path.dirname(target), { recursive: true }); + try { await cp(source, target, { recursive: true }); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + } + this.counts.apt++; + await run(this.arguments(args, "apt")); + const generated = new Map(); + for (const file of await files(this.sources)) { + if (/\.(?:java|kt)$/.test(file)) generated.set(path.join(this.roots[0]!, path.relative(this.sources, file)), await readFile(file, "utf8")); + else { + const destination = path.join(this.directory, "attempt", "classes", path.relative(this.sources, file)); + await mkdir(path.dirname(destination), { recursive: true }); + await cp(file, destination); + } + } + const classes = path.join(this.directory, "attempt", "classes"); + const changed = generated.size !== this.generated.size + || [...generated].some(([file, text]) => this.generated.get(file) !== text) + || await outputFingerprint(classes) !== await outputFingerprint(this.classes); + if (changed) { + await rm(this.classes, { recursive: true, force: true }); + await rename(classes, this.classes); + this.generated.clear(); + for (const [file, text] of generated) this.generated.set(file, text); + } + // Changed declarations can produce identical output (for example a new composable beside + // Room entities). Confirm those inputs without discarding warm compiler state or repainting. + this.stubKey = stubKey; + this.externalKey = externalKey; + return changed; + } + + private optionPath(key: string, source: string): string { + return path.join(this.directory, "options", createHash("sha256").update(key).digest("hex").slice(0, 16), path.basename(source)); + } + + private arguments(args: string[], mode: "stubs" | "apt"): string[] { + const options = new Map(this.options); + options.set("kapt.kotlin.generated", this.sources); + for (const [key, source] of this.paths) options.set(key, this.optionPath(key, source)); + const processingClasses = path.join(this.directory, "attempt", "classes"); + const overrides: Record = { + aptMode: mode, sources: this.sources, stubs: this.stubs, classes: processingClasses, apoptions: encodeProcessorOptions(options), + incrementalData: path.join(this.directory, "attempt", "incremental-data"), + dumpFileReadHistory: path.join(this.directory, "attempt", "file-read-history.txt"), + dumpProcessorStats: path.join(this.directory, "attempt", "processor-stats.txt"), + }; + const result: string[] = []; + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + const parsed = option(arg); + if (parsed && parsed.name in overrides) result.push(`${arg.startsWith("-P=") ? "-P=" : ""}${prefix}${parsed.name}=${overrides[parsed.name]}`); + else if (arg === "-d") { result.push(arg, processingClasses); index++; } + else if (arg.startsWith("-d=")) result.push(`-d=${processingClasses}`); + else result.push(arg); + } + return result; + } +} diff --git a/packages/vscode/src/composeRender.ts b/packages/vscode/src/composeRender.ts index 93736c3..4326e83 100644 --- a/packages/vscode/src/composeRender.ts +++ b/packages/vscode/src/composeRender.ts @@ -54,6 +54,8 @@ export interface PreviewImage { width?: number; height?: number; nodes?: PreviewLayoutNode[]; + /** Inspection failed without invalidating the rendered pixels. */ + layoutError?: string; } function object(value: unknown): value is Record { @@ -89,12 +91,12 @@ function integer(value: unknown, minimum = 0): value is number { } /** Never forward unchecked renderer metadata to the webview or source resolver. */ -function layoutNodes(value: unknown, width: number, height: number): PreviewLayoutNode[] | undefined { +export function layoutNodes(value: unknown, width: number, height: number): PreviewLayoutNode[] | undefined { if (value === undefined) return undefined; if (!Array.isArray(value) || value.length > 2048) throw new Error("Invalid Compose layout metadata."); const ids = new Set(); for (const node of value) { - if (!object(node) || typeof node.id !== "string" || !node.id || node.id.length > 128 || ids.has(node.id) + if (!object(node) || typeof node.id !== "string" || !node.id || node.id.length > 2048 || ids.has(node.id) || typeof node.name !== "string" || !node.name || node.name.length > 512 || !integer(node.x) || !integer(node.y) || !integer(node.width, 1) || !integer(node.height, 1) || node.x + node.width > width || node.y + node.height > height) { @@ -114,6 +116,49 @@ function layoutNodes(value: unknown, width: number, height: number): PreviewLayo return value as PreviewLayoutNode[]; } +/** Visible text of a layoutlib problem's HTML summary, without its link to the details printed beside it. */ +function htmlText(html: string): string { + return html.replace(/\s*\(?]*>[\s\S]*?<\/a>\)?/gi, "").replace(//gi, "\n").replace(/<[^>]*>/g, "") + .replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'|'/g, "'").replace(/ /g, " ").replace(/&/g, "&") + .trim(); +} + +/** + * A renderer `ScreenshotError` as a person reads it: each problem's summary and stack trace, not the result envelope, + * whose `status` describes the render session rather than the preview. layoutlib reports a composable's exception + * twice, also wrapped in the reflective call's `InvocationTargetException`; the wrapper is dropped. + */ +export function screenshotErrorText(error: unknown): string { + if (typeof error === "string") return error; + if (!object(error)) return "The renderer reported a failure without details."; + const text = (value: unknown): string => typeof value === "string" ? value.trim() : ""; + const problems = [ + { summary: text(error.message), trace: text(error.stackTrace) }, + ...(Array.isArray(error.problems) ? error.problems : []).filter(object) + .map((problem) => ({ summary: htmlText(text(problem.html)), trace: text(problem.stackTrace) })), + ].filter((problem) => problem.summary || problem.trace); + const headlines = new Set(problems.map((problem) => problem.trace.split("\n", 1)[0]!.trim()).filter(Boolean)); + const traces = new Set(); + const sections: string[] = []; + for (const problem of problems) { + const causes = problem.trace.split("\n").filter((line) => line.startsWith("Caused by: ")).map((line) => line.slice("Caused by: ".length).trim()); + if (problem.trace && (traces.has(problem.trace) || causes.some((cause) => headlines.has(cause)))) continue; + if (problem.trace) traces.add(problem.trace); + sections.push([problem.summary, problem.trace].filter(Boolean).join("\n")); + } + const classes = (value: unknown) => (Array.isArray(value) ? value : []).map((entry) => typeof entry === "string" ? entry + : object(entry) ? [text(entry.className), text(entry.stackTrace)].filter(Boolean).join("\n") : "").filter(Boolean); + const missing = classes(error.missingClasses); + if (missing.length) sections.push(`Missing classes:\n${missing.join("\n")}`); + const broken = classes(error.brokenClasses); + if (broken.length) sections.push(`Classes that could not be initialized:\n${broken.join("\n\n")}`); + if (!sections.length) { + const status = text(error.status); + sections.push(`The renderer reported a failure without details${status && status !== "SUCCESS" ? ` (status ${status})` : ""}.`); + } + return sections.join("\n\n"); +} + /** Shared boundary for persisted gallery images and renderer messages. */ export function isPreviewImage(value: unknown): value is PreviewImage { if (!object(value) || typeof value.id !== "string" || !value.id || value.id.length > 1024 @@ -143,17 +188,41 @@ interface PendingResponse { interface WorkerResult { order: string[]; images: { previewId: string; image: PreviewImage; weight: number }[]; - previews: CompiledPreview[]; +} + +/** Render weight (about one small preview image) one render JVM serves before another one is worth its memory. */ +const WEIGHT_PER_WORKER = 4; +/** JVMs a smaller gallery leaves idle stop after this long without a render needing them. */ +const SURPLUS_IDLE_MS = 60_000; +/** + * A JVM that neither answers nor reports a rendered image for this long is hung. RenderServer bounds each image's + * render steps (up to about 100 s in all) and reports it as a failed card; a shard of many images may take far longer. + */ +const RENDER_STALL_MS = 120_000; + +/** + * Render JVMs for a gallery of `weight` (see {@link ComposeRenderer}): each holds layoutlib, the runtime and the + * application's classes, several hundred MB, so one per few previews, at most four and half the cores. + */ +export function renderWorkersFor(weight: number, parallelism = availableParallelism()): number { + return Math.max(1, Math.min(4, Math.floor(parallelism / 2), Math.ceil(weight / WEIGHT_PER_WORKER))); } /** Each worker owns one single-threaded layoutlib; compiled helpers are shared on disk. */ class RenderWorker { private readonly child: ChildProcessWithoutNullStreams; readonly ready: Promise>; + /** Answered its ready handshake. */ + started = false; + /** Takes shards: one of the first JVMs, or one that rendered a whole gallery alone, so it is not cold. */ + joined = false; + warming = false; private pending?: PendingResponse; private stdout = ""; private logs = ""; private dead = false; + /** Stopped as surplus, not failed: its exit must not fail the renderer. */ + private retired = false; private rendering = false; private generation = 0; private readonly abort = (): void => { this.fail(new Error("Compose renderer cancelled.")); }; @@ -161,15 +230,16 @@ class RenderWorker { constructor( java: string, runtime: ComposeRuntime, classesDirectory: string, private readonly directory: string, private readonly signal: AbortSignal, - private readonly shardIndex: number, private readonly shardCount: number, - private readonly onFailure: () => void, + private readonly onFailure: (error: Error) => void, private readonly stallMs: number, ) { + signal.throwIfAborted(); // Favor first-edit latency over long-running throughput; other JVMs may ignore HotSpot tuning. this.child = spawn(java, ["-Djava.awt.headless=true", "--enable-native-access=ALL-UNNAMED", "-XX:+IgnoreUnrecognizedVMOptions", "-XX:TieredStopAtLevel=1", "-cp", [classesDirectory, ...runtime.rendererClassPath].join(path.delimiter), "RenderServer", runtime.layoutlibPath], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); this.ready = this.response(0); + this.ready.then(() => { this.started = true; }, () => {}); this.child.stdout.setEncoding("utf8"); this.child.stderr.setEncoding("utf8"); this.child.stdout.on("data", (chunk: string) => this.receive(chunk)); @@ -184,14 +254,13 @@ class RenderWorker { if (signal.aborted) this.abort(); } - get alive(): boolean { return !this.dead; } private response(id: number): Promise> { const { promise, resolve, reject } = Promise.withResolvers>(); this.pending = { id, resolve, reject, - timer: setTimeout(() => this.fail(new Error("Compose preview rendering timed out.")), 60_000), + timer: setTimeout(() => this.fail(new Error("Compose preview rendering timed out.")), this.stallMs), }; return promise; } @@ -213,6 +282,11 @@ class RenderWorker { if (!pending || !object(value) || (pending.id === 0 ? value.ready !== true : value.id !== pending.id)) { throw new Error("Invalid Compose renderer protocol response."); } + // Another image of the shard is done: the JVM is working, however long the whole shard takes. + if (pending.id !== 0 && value.progress === true) { + pending.timer.refresh(); + continue; + } this.pending = undefined; clearTimeout(pending.timer); pending.resolve(value); @@ -229,28 +303,34 @@ class RenderWorker { this.signal.removeEventListener("abort", this.abort); this.child.kill("SIGKILL"); const pending = this.pending; + const failure = new Error(`${error.message}${this.logs ? `\n${this.logs}` : ""}`, { cause: error }); this.pending = undefined; if (pending) { clearTimeout(pending.timer); - pending.reject(new Error(`${error.message}${this.logs ? `\n${this.logs}` : ""}`)); + pending.reject(failure); } this.stdout = ""; - this.onFailure(); + if (!this.retired) this.onFailure(failure); } - async render(inputs: ComposeRenderInputs, sourceFile: string, packageName: string, weights: Record): Promise { + /** + * `affinity` maps preview IDs to the shard that rendered them last, identically for every shard of a request. `methods` names + * the preview functions to render (a selection); absent, every preview of the file renders. + */ + async render(inputs: ComposeRenderInputs, sourceFile: string, packageName: string, weights: Record, + affinity: Record, shardIndex: number, shardCount: number, methods?: readonly string[]): Promise { if (this.dead) throw new Error("Compose renderer is no longer running."); if (this.rendering) throw new Error("Compose renderer requests must be serialized."); this.rendering = true; const id = ++this.generation; const outputFolder = path.join(this.directory, `images-${id}`); try { - await mkdir(outputFolder); + await mkdir(outputFolder, { recursive: true }); this.signal.throwIfAborted(); if (this.dead) throw new Error("Compose renderer is no longer running."); const request = JSON.stringify({ id, ...inputs, sourceFile: path.basename(sourceFile), packageName, outputFolder, - shardIndex: this.shardIndex, shardCount: this.shardCount, weights, + shardIndex, shardCount, weights, affinity, methods: methods ?? null, }); if (Buffer.byteLength(request) > protocolLimit) throw new Error("Compose renderer request exceeds its protocol limit."); const response = this.response(id); @@ -274,7 +354,7 @@ class RenderWorker { imageIds.add(result.id); const image: PreviewImage = { id: result.id, name: preview.name, detail: preview.methodFQN }; let weight = 1; - if (result.error) image.error = JSON.stringify(result.error, null, 2); + if (result.error) image.error = screenshotErrorText(result.error); else if (result.imagePath) { const file = await realpath(path.resolve(outputFolder, result.imagePath)); const relative = path.relative(root, file); @@ -295,7 +375,7 @@ class RenderWorker { } else image.error = "Renderer returned neither an image nor a diagnostic."; images.push({ previewId: result.previewId, image, weight }); } - return { order: [...previews.keys()], images, previews: results.previews }; + return { order: [...previews.keys()], images }; } catch (error) { this.fail(error instanceof Error ? error : new Error(String(error))); throw error; @@ -306,20 +386,52 @@ class RenderWorker { } dispose(): void { this.fail(new Error("Compose renderer disposed.")); } + + retire(): void { + this.retired = true; + this.fail(new Error("Compose renderer stopped as surplus.")); + } } -/** One atomic gallery generation, using at most six independent, persistent render JVMs. */ +export interface RendererOptions { + /** Expected render weight of the first gallery, sizing the JVMs started up front; later renders resize the pool. */ + weight?: number; + surplusIdleMs?: number; + /** How long after an abandoned warm-up no warm-up or replacement JVM starts. */ + quietMs?: number; + /** How long a JVM may go without answering or reporting a rendered image before it counts as hung. */ + stallMs?: number; +} + +/** Classes still being republished (live typing) make warm-ups wasted work; wait for a pause this long. */ +const WARMUP_QUIET_MS = 3_000; + +/** + * One atomic gallery generation over independent, persistent render JVMs, as many as the last gallery's render + * weight needs (see {@link renderWorkersFor}). A larger gallery starts more in the background, which join the next + * render; a smaller one stops the surplus once it has idled. + */ export class ComposeRenderer { private disposed = false; private rendering = false; + private failure: Error | undefined; private weights: Record = {}; - /** The last render's discovered previews, in gallery order; the Komodo engine names its entries from them. */ - discovery: readonly CompiledPreview[] = []; - - private constructor(private readonly workers: RenderWorker[], private readonly directory: string) {} + /** The shard that last rendered each preview, so every JVM computes the same assignment, new ones included. */ + private affinity: Record = {}; + private surplus: NodeJS.Timeout | undefined; + /** Counts abandoned warm-up generations: a warm-up joins only if none was abandoned while it ran. */ + private warmups = 0; + /** No warm-up or growth starts before this time, set when warm-ups are abandoned. */ + private quietUntil = 0; + + private constructor( + private readonly workers: RenderWorker[], private readonly directory: string, + private readonly spawn: (joined: boolean) => RenderWorker, private readonly surplusIdleMs: number, private readonly quietMs: number, + ) {} static async create( runtime: ComposeRuntime, java: string, helperDirectory: string, cacheRoot: string, directory: string, signal: AbortSignal, + options: RendererOptions = {}, ): Promise { signal.throwIfAborted(); await mkdir(directory, { recursive: true }); @@ -328,37 +440,54 @@ export class ComposeRenderer { let renderer: ComposeRenderer | undefined; try { const classes = await compiledHelpers(runtime, java, helperDirectory, cacheRoot, signal); - const count = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 2))); - const directories = Array.from({ length: count }, (_, index) => path.join(sessionDirectory, `worker-${index}`)); - await Promise.all(directories.map((workerDirectory) => mkdir(workerDirectory))); - const failure = (): void => { - renderer?.dispose(); + signal.throwIfAborted(); + const failure = (error: Error): void => { + renderer?.dispose(error); for (const worker of workers) worker.dispose(); }; - for (const [index, workerDirectory] of directories.entries()) { - workers.push(new RenderWorker(java, runtime, classes, workerDirectory, signal, index, count, failure)); - } - renderer = new ComposeRenderer(workers, sessionDirectory); + let started = 0; + // A JVM still warming up serves no paint: its failure drops only that JVM, and the next render starts another. + const spawn = (joined: boolean): RenderWorker => { + const worker: RenderWorker = new RenderWorker(java, runtime, classes, path.join(sessionDirectory, `worker-${started++}`), signal, + (error) => worker.joined ? failure(error) : renderer?.drop(worker), options.stallMs ?? RENDER_STALL_MS); + worker.joined = joined; + return worker; + }; + for (let count = renderWorkersFor(options.weight ?? 1); workers.length < count;) workers.push(spawn(true)); + renderer = new ComposeRenderer(workers, sessionDirectory, spawn, options.surplusIdleMs ?? SURPLUS_IDLE_MS, options.quietMs ?? WARMUP_QUIET_MS); await Promise.all(workers.map((worker) => worker.ready)); return renderer; } catch (error) { for (const worker of workers) worker.dispose(); - await rm(sessionDirectory, { recursive: true, force: true }); - throw error; + await rm(sessionDirectory, { recursive: true, force: true }).catch(() => {}); + throw renderer?.failure ?? error; } } get alive(): boolean { return !this.disposed && this.workers.every((worker) => worker.alive); } - async render(inputs: ComposeRenderInputs, sourceFile: string, packageName: string): Promise { + /** Render JVMs running or starting. */ + get size(): number { return this.workers.length; } + + /** Render weight of the last gallery (see {@link RendererOptions.weight}); 0 before the first render. */ + get weight(): number { return Object.values(this.weights).reduce((sum, weight) => sum + weight, 0); } + + /** + * `methods` renders only those preview functions of the file (a selection). Such a gallery's weights and affinities join the + * last ones instead of replacing them, so a selection's render never shrinks the pool its file's full gallery needs. + */ + async render(inputs: ComposeRenderInputs, sourceFile: string, packageName: string, methods?: readonly string[]): Promise { if (!this.alive) { this.dispose(); throw new Error("Compose renderer is no longer running."); } if (this.rendering) throw new Error("Compose renderer requests must be serialized."); this.rendering = true; + clearTimeout(this.surplus); try { - const shards = await Promise.all(this.workers.map((worker) => worker.render(inputs, sourceFile, packageName, this.weights))); + const workers = this.workers.filter((worker) => worker.joined); + const shards = await Promise.all(workers.map((worker, index) => + worker.render(inputs, sourceFile, packageName, this.weights, this.affinity, index, workers.length, methods))); const order = shards[0]!.order; if (shards.some((shard) => shard.order.length !== order.length || shard.order.some((id, index) => id !== order[index]))) { throw new Error("Compiled preview discovery changed during rendering."); @@ -367,23 +496,69 @@ export class ComposeRenderer { const positions = new Map(order.map((id, index) => [id, index])); const rendered = new Set(images.map((result) => result.previewId)); if (order.some((id) => !rendered.has(id))) throw new Error("Compose renderer produced no result for a preview."); - this.weights = {}; + if (!methods) { + this.weights = {}; + this.affinity = {}; + } else for (const id of order) delete this.weights[id]; for (const result of images) this.weights[result.previewId] = (this.weights[result.previewId] ?? 0) + result.weight; + for (const [index, shard] of shards.entries()) for (const result of shard.images) this.affinity[result.previewId] = index; + // A JVM started for a larger gallery first renders this one alone, in the background, so a cold JVM never + // slows a paint; it takes shards from a later render on. Not while classes are being republished. + for (const worker of performance.now() < this.quietUntil ? [] : this.workers) { + if (!worker.started || worker.joined || worker.warming) continue; + worker.warming = true; + const generation = this.warmups; + worker.render(inputs, sourceFile, packageName, this.weights, this.affinity, 0, 1, methods) + .then(() => { worker.joined ||= generation === this.warmups; }, () => {}).finally(() => { worker.warming = false; }); + } + this.fit(renderWorkersFor(this.weight)); // Stable sorting preserves provider expansion order within each preview configuration. images.sort((left, right) => positions.get(left.previewId)! - positions.get(right.previewId)!); - this.discovery = shards[0]!.previews; return images.map((result) => result.image); } catch (error) { - this.dispose(); - throw error; + this.dispose(error instanceof Error ? error : new Error(String(error))); + // A failing shard cancels its siblings. Their cleanup may finish first; report the + // initiating error rather than whichever cancellation happened to reject first. + throw this.failure ?? error; } finally { this.rendering = false; } } - dispose(): void { + /** + * The caller is about to rewrite or remove the last gallery's classes, which background warm-ups read outside its + * render lock. A warm-up in progress is discarded rather than stopped: each render loads project classes in a + * fresh class loader, and its stable loader is rebuilt when the inputs change, so the JVM's state stays sound; + * a failure drops only that JVM. No warm-up or replacement JVM starts until publications pause for `quietMs`, + * so live typing never keeps a JVM busy with work that is thrown away. + */ + abandonWarmups(): void { + this.warmups++; + this.quietUntil = performance.now() + this.quietMs; + } + + private drop(worker: RenderWorker): void { + const index = this.workers.indexOf(worker); + if (index >= 0) this.workers.splice(index, 1); + } + + /** Start the JVMs a larger gallery needs; stop those a smaller one leaves idle after `surplusIdleMs`. */ + private fit(wanted: number): void { + if (performance.now() >= this.quietUntil) while (this.workers.length < wanted) this.workers.push(this.spawn(false)); + if (this.workers.length <= wanted) return; + this.surplus = setTimeout(() => { + // A render in progress re-evaluates its own need when it finishes. + if (this.disposed || this.rendering) return; + for (const worker of this.workers.splice(wanted)) worker.retire(); + }, this.surplusIdleMs); + this.surplus.unref(); + } + + dispose(error?: Error): void { if (this.disposed) return; this.disposed = true; + this.failure = error; + clearTimeout(this.surplus); for (const worker of this.workers) worker.dispose(); void rm(this.directory, { recursive: true, force: true }).catch(() => {}); } diff --git a/packages/vscode/src/composeRuntime.ts b/packages/vscode/src/composeRuntime.ts index 3e61a26..e6ef7ba 100644 --- a/packages/vscode/src/composeRuntime.ts +++ b/packages/vscode/src/composeRuntime.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { createWriteStream } from "node:fs"; -import { copyFile, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; @@ -113,6 +113,8 @@ export async function ensureComposeRuntime( ): Promise { signal.throwIfAborted(); const { platform, key, root, destination, marker, required, isReady } = runtimeInstallation(cacheRoot); + // Never fails; also after another host completed the install an exited one abandoned. + void sweepAbandonedInstalls(root); if (await isReady()) { signal.throwIfAborted(); return runtimeAt(destination); @@ -163,6 +165,26 @@ export async function ensureComposeRuntime( } } +/** Staging directories untouched this long belong to a host that exited mid-install (a window reload, a crash). */ +const ABANDONED_INSTALL_MS = 24 * 60 * 60 * 1000; + +/** + * Remove such staging directories in `root`: their owner cannot, and each can hold a partial runtime of up to + * hundreds of MB. An install in progress keeps writing into its own, so it is never this old. + */ +export async function sweepAbandonedInstalls(root: string, now = Date.now()): Promise { + let entries; + try { entries = await readdir(root, { withFileTypes: true }); } + catch { return; } + await Promise.all(entries.map(async (entry) => { + if (!entry.isDirectory() || !/\.install-[A-Za-z0-9]{6}$/.test(entry.name)) return; + const directory = path.join(root, entry.name); + const files = [directory, ...(await readdir(directory)).map((name) => path.join(directory, name))]; + const changed = Math.max(...await Promise.all(files.map((file) => stat(file).then((stats) => stats.mtimeMs, () => Infinity)))); + if (now - changed >= ABANDONED_INSTALL_MS) await rm(directory, { recursive: true, force: true }); + }).map((sweep) => sweep.catch(() => {}))); +} + /** `onBytes` hears how much has arrived, at most every quarter second, against the advertised size if any. */ async function download(artifact: Artifact, directory: string, signal: AbortSignal, onBytes: (received: number, total: number | undefined) => void): Promise { const response = await fetch(artifact.url, { signal }); diff --git a/packages/vscode/src/composeSource.ts b/packages/vscode/src/composeSource.ts index a9c2140..40a3b97 100644 --- a/packages/vscode/src/composeSource.ts +++ b/packages/vscode/src/composeSource.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import type { PreviewLayoutNode } from "./composeRender.js"; +import type { PreviewImage, PreviewLayoutNode } from "./composeRender.js"; export interface PreviewSourceTarget { file: string; @@ -18,7 +18,7 @@ export function resolvePreviewSource( for (const [file, content] of files) { if (path.basename(file) !== source.fileName) continue; if (source.packageHash !== -1) { - const packageName = /^\s*package\s+([\w.`]+)/m.exec(content)?.[1]?.replaceAll("`", "") ?? ""; + const packageName = /^\s*package\s+([\p{L}\p{Nd}_.`]+)/mu.exec(content)?.[1]?.replaceAll("`", "") ?? ""; let hash = 0; for (let index = 0; index < packageName.length; index++) hash = (Math.imul(hash, 31) + packageName.charCodeAt(index)) | 0; // Kotlin abs(Int.MIN_VALUE) remains negative. @@ -42,3 +42,73 @@ export function resolvePreviewSource( } return { file, content, offset, length: 0 }; } + +/** The called function's name at the start of a call's range: the last segment of a qualified call (`a.b.Box(…)` is `Box`). */ +const CALLEE = /^\s*(?:(?:[\p{L}_$][\p{L}\p{N}_$]*|`[^`]+`)\s*\.\s*)*([\p{L}_$][\p{L}\p{N}_$]*|`[^`]+`)/u; + +/** + * The nodes the inspector offers for `image`: calls located in the compiler inputs `units` whose range calls them, each recorded + * in `targets` by `imageId\0nodeId`, not Compose's implementation internals. A call whose range names another function is not + * located: tooling assigns a group's call sites by position, which code compiled without source information shifts. A live image + * also offers the measured bounds of its other calls (compiled without source information, or a renderer without any), the + * outermost call of each rectangle no located call has, without a location. + */ +export function inspectableNodes( + image: PreviewImage, + units: readonly ReadonlyMap[], + targets: Map, +): PreviewLayoutNode[] | undefined { + if (!image.nodes) return undefined; + const located = new Set(); + for (const node of image.nodes) { + const target = node.source && resolvePreviewSource(node.source, units); + if (!target) continue; + const callee = CALLEE.exec(target.content.slice(target.offset, target.offset + target.length))?.[1]?.replaceAll("`", ""); + if (target.length > 0 && callee !== node.name) continue; + located.add(node); + targets.set(`${image.id}\0${node.id}`, target); + } + if (!image.live) return image.nodes.filter((node) => located.has(node)); + const rectangle = (node: PreviewLayoutNode) => `${node.x},${node.y},${node.width},${node.height}`; + const claimed = new Set([...located].map(rectangle)); + // Children come first: the last call of a rectangle is its outermost. + const outermost = new Map(); + for (const node of image.nodes) if (!located.has(node) && !claimed.has(rectangle(node))) outermost.set(rectangle(node), node); + const measured = new Set(outermost.values()); + return image.nodes.filter((node) => located.has(node) || measured.has(node)) + .map((node) => located.has(node) || !node.source ? node + : { id: node.id, name: node.name, x: node.x, y: node.y, width: node.width, height: node.height }); +} + +/** + * Nodes as a tree by geometric containment: the renderer lists children first, so a node's parent is the smallest later node + * whose bounds contain it (the nearest one among equal bounds). Siblings go top to bottom, then left to right. + */ +export function layoutTree(nodes: readonly PreviewLayoutNode[]): { node: PreviewLayoutNode; depth: number }[] { + const parent = nodes.map((node, index) => { + let best = -1; + let area = Infinity; + for (let candidate = index + 1; candidate < nodes.length; candidate++) { + const other = nodes[candidate]!; + if (other.x > node.x || other.y > node.y || other.x + other.width < node.x + node.width || other.y + other.height < node.y + node.height) continue; + const size = other.width * other.height; + if (size < area) { + area = size; + best = candidate; + } + } + return best; + }); + const children = nodes.map(() => [] as number[]); + const roots: number[] = []; + for (const [index, owner] of parent.entries()) (owner < 0 ? roots : children[owner]!).push(index); + const order = (list: number[]) => list.sort((left, right) => nodes[left]!.y - nodes[right]!.y || nodes[left]!.x - nodes[right]!.x || left - right); + const result: { node: PreviewLayoutNode; depth: number }[] = []; + const visit = (index: number, depth: number) => { + result.push({ node: nodes[index]!, depth }); + for (const child of order(children[index]!)) visit(child, depth + 1); + }; + for (const root of order(roots)) visit(root, 0); + return result; +} + diff --git a/packages/vscode/src/composeWorker.ts b/packages/vscode/src/composeWorker.ts index e81817e..d04ea21 100644 --- a/packages/vscode/src/composeWorker.ts +++ b/packages/vscode/src/composeWorker.ts @@ -1,6 +1,14 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; const MAX_FRAME = 64 * 1024 * 1024; +/** Elide's worker writes nothing until it answers, so a request has a deadline rather than an inactivity limit. */ +const REQUEST_DEADLINE_MS = 120_000; +const MAX_REQUEST_DEADLINE_MS = 8 * REQUEST_DEADLINE_MS; +/** + * Deadlines raised by timeouts, per worker binary, tool and directory. A timed-out compile loses its worker and its + * incremental state, so the next attempt repeats the same cold compile: it gets twice as long instead of failing again. + */ +const deadlines = new Map(); function varint(value: number): Buffer { const bytes: number[] = []; @@ -82,15 +90,19 @@ export function decodeWorkResponse(bytes: Buffer): WorkResponse { /** One serial request stream per tool. stdout is exclusively length-delimited protobuf. */ export class ComposeWorker { private readonly process: ChildProcessWithoutNullStreams; + private readonly key: string; private buffer: Buffer = Buffer.alloc(0); private stderr = ""; private nextRequest = 1; private failure: Error | undefined; + /** Answered a request: it supports persistent workers, so a later exit is a crash, not an incompatible distribution. */ + private answered = false; private pending: { id: number; resolve: (response: WorkResponse) => void; reject: (error: Error) => void; timer: NodeJS.Timeout } | undefined; private readonly abort = () => this.close(new Error("Compose compilation cancelled.")); constructor(bin: string, private readonly tool: "kotlinc" | "javac", cwd: string, private readonly signal: AbortSignal) { signal.throwIfAborted(); + this.key = `${bin}\0${tool}\0${cwd}`; this.process = spawn(bin, [tool, "--persistent_worker"], { cwd, stdio: "pipe", windowsHide: true, detached: process.platform !== "win32", }); @@ -99,8 +111,10 @@ export class ComposeWorker { this.process.stderr.on("data", (text: string) => { this.stderr = (this.stderr + text).slice(-64 * 1024); }); this.process.on("error", (error) => this.close(new Error(`Cannot start Elide ${tool} worker: ${error.message}`, { cause: error }))); this.process.stdin.on("error", (error) => this.close(new Error(`Cannot write to Elide ${tool} worker: ${error.message}`, { cause: error }))); - this.process.on("exit", (code, reason) => this.close(new Error( - `Elide ${tool} worker exited (${reason ?? code}). The selected Elide distribution must support persistent workers.\n${this.stderr}`, + // `exit` can precede the final stdout response and stderr diagnostics; drain both first. + this.process.on("close", (code, reason) => this.close(new Error(this.answered + ? `Elide ${tool} worker exited (${reason ?? code}).\n${this.stderr}` + : `Elide ${tool} worker exited (${reason ?? code}). The selected Elide distribution must support persistent workers.\n${this.stderr}`, ))); signal.addEventListener("abort", this.abort, { once: true }); if (signal.aborted) this.abort(); @@ -112,7 +126,13 @@ export class ComposeWorker { const id = this.nextRequest++; const frame = encodeWorkRequest(args, id); return await new Promise((resolve, reject) => { - const timer = setTimeout(() => this.close(new Error(`Elide ${this.tool} worker timed out.\n${this.stderr}`)), 120_000); + const deadline = deadlines.get(this.key) ?? REQUEST_DEADLINE_MS; + const timer = setTimeout(() => { + const next = Math.min(2 * deadline, MAX_REQUEST_DEADLINE_MS); + deadlines.set(this.key, next); + const retry = next > deadline ? `; the next attempt allows ${next / 1000} s` : ""; + this.close(new Error(`Elide ${this.tool} worker timed out after ${deadline / 1000} s${retry}.\n${this.stderr}`)); + }, deadline); timer.unref(); this.pending = { id, resolve, reject, timer }; this.process.stdin.write(frame); @@ -136,6 +156,7 @@ export class ComposeWorker { const pending = this.pending; if (!pending || response.requestId !== pending.id) throw new Error(`Unexpected Elide worker response ${response.requestId}.`); this.pending = undefined; + this.answered = true; clearTimeout(pending.timer); if (response.wasCancelled) pending.reject(new Error("Elide cancelled Compose compilation.")); else pending.resolve(response); diff --git a/packages/vscode/src/config.ts b/packages/vscode/src/config.ts index 51a2690..bf5d056 100644 --- a/packages/vscode/src/config.ts +++ b/packages/vscode/src/config.ts @@ -52,10 +52,8 @@ export interface ElideConfig { composePreviewKeepAlive: number; /** The Compose preview engine: layoutlib snapshots (default) or the opt-in live Komodo engine. */ composePreviewEngine: "layoutlib" | "komodo"; - /** The komodo-preview daemon; undefined resolves it beside the Elide binary, then on PATH. */ - composePreviewKomodoPath: string | undefined; - /** Cards the Komodo engine cannot render keep their layoutlib snapshot (else they show the reason). */ - composePreviewKomodoFallback: boolean; + /** Serve the Compose preview to AI agents over MCP. */ + composePreviewMcp: boolean; codeLens: boolean; /** `-f` build flags added to every Elide invocation (`elide.flags`). */ flags: string[]; @@ -75,7 +73,7 @@ export const WINDOW_SETTINGS = [ "android.composePreview.prewarm", "android.composePreview.keepAliveMinutes", "android.composePreview.engine", - "android.composePreview.komodo.fallback", + "android.composePreview.mcp.enabled", ] as const; /** @@ -108,8 +106,7 @@ export function readConfig(scope?: vscode.ConfigurationScope): ElideConfig { composePreviewPrewarm: window.get("android.composePreview.prewarm", true), composePreviewKeepAlive: Math.max(0, window.get("android.composePreview.keepAliveMinutes", 10)), composePreviewEngine: window.get("android.composePreview.engine", "layoutlib") === "komodo" ? "komodo" : "layoutlib", - composePreviewKomodoPath: nonEmpty(c.get("android.composePreview.komodo.path")), - composePreviewKomodoFallback: window.get("android.composePreview.komodo.fallback", true), + composePreviewMcp: window.get("android.composePreview.mcp.enabled", true), codeLens: c.get("codeLens.enabled", true), flags: elideStringArrayFrom(c.get("flags")), commandOptions: { diff --git a/packages/vscode/src/explorer.ts b/packages/vscode/src/explorer.ts index c13b803..dbe0a26 100644 --- a/packages/vscode/src/explorer.ts +++ b/packages/vscode/src/explorer.ts @@ -5,12 +5,14 @@ import { LIBRARY_NAME_PREFIX, androidFeatureTask, buildTasksOf, + currentAndroidDeviceTargets, + isAndroidDeviceTask, isNativeImageBinary, parseManifestArtifacts, resolveElideDistribution, unqualifiedTaskName, type AndroidArtifactKind, - type AndroidProjectModel, + type AndroidDeviceTargets, type AndroidTargetModel, type BuildTaskInfo, type ElideCommand, @@ -21,19 +23,25 @@ import { } from "@elide/ide-core"; import * as vscode from "vscode"; import { androidSdkFor, attachedDevices } from "./android.js"; +import { androidEmulatorOwners, androidToolsProject } from "./androidDeploymentState.js"; import { readConfig } from "./config.js"; +import { onDidEndDebugRun } from "./debug.js"; import type { ElideUi } from "./output.js"; import type { ElideProject, ElideWorkspace } from "./projects.js"; import { entrypointArgs } from "./tasks.js"; export const PROJECTS_VIEW_ID = "elide.projects"; -/** The fixed sections of a project, plus the lazily loaded build targets and Android devices nested under them. */ -type GroupKind = "entrypoints" | "android" | "androidDevices" | "tasks" | "buildTargets" | "otherBuildTargets" | "sourceSets" | "dependencies"; +/** + * The fixed sections of a project, plus the lazily loaded build targets and Android devices nested under them. A + * workspace tree's devices and Android tools are one `androidTools` section of its top-level row, not one per member. + */ +type GroupKind = "entrypoints" | "android" | "androidTools" | "androidDevices" | "tasks" | "buildTargets" | "otherBuildTargets" | "sourceSets" | "dependencies"; const GROUP_LABELS: Record = { entrypoints: "Entrypoints", android: "Android", + androidTools: "Android devices & tools", androidDevices: "Devices", tasks: "Tasks", buildTargets: "Build targets", @@ -45,6 +53,7 @@ const GROUP_LABELS: Record = { const GROUP_ICONS: Record = { entrypoints: "rocket", android: "device-mobile", + androidTools: "remote-explorer", androidDevices: "plug", tasks: "tools", buildTargets: "list-tree", @@ -78,6 +87,11 @@ export type ElideNode = debuggable: boolean; /** Set when the target is a Native Image binary: `elide.runArtifact` builds it and runs what it produced. */ runnable: boolean; + /** + * Set when the target installs onto one device: `elide.androidRunDeviceTask` passes it the deployment target's + * `--device`, instead of letting Elide pick whichever device is attached. + */ + deviceBound: boolean; /** Output name the artifact declares, which decides the binary `elide.runArtifact` runs. */ outputName?: string; } @@ -91,12 +105,17 @@ export type ElideNode = /** `args` so the row can be handed to `elide.build` unchanged. */ args: string[]; description: string; + /** What Elide lists for an APK — Run, Debug, instrumented tests — which decides the actions its row offers. */ + offers?: AndroidDeviceTargets; } /** The obfuscation mapping a minifying Android artifact publishes, beside the archive it belongs to. */ | { kind: "androidMapping"; label: string; root: string; artifact: string; path: string; description: string } - | { kind: "androidDevice"; label: string; root: string; device: string; description: string; ready: boolean } - /** A project-wide Android action the sidebar offers: booting the emulator, creating the AVD, installing the SDK. */ - | { kind: "androidAction"; label: string; root: string; command: string; description: string; icon: string } + | { kind: "androidDevice"; label: string; root: string; device: string; description: string; ready: boolean; emulator: boolean } + /** + * An Android action of the shared devices & tools section. `bare` actions run as from the palette, choosing among + * all of the workspace's Android projects; the others act for the project `root` names. + */ + | { kind: "androidAction"; label: string; root: string; command: string; description: string; icon: string; bare?: boolean } | { kind: "module"; label: string; root: string; module: ModuleModel } | { kind: "sourceRoot"; label: string; root: string; module: string; path: string; sourceKind: SourceRootKind } | { kind: "library"; label: string; root: string; classes: string; attached: string } @@ -143,6 +162,8 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El vscode.tasks.onDidEndTask((event) => { if (event.execution.task.source === "elide") this.changed.fire(undefined); }), + // A debug run builds the APK, which can turn out not to be debuggable and withdraw its Debug action. + onDidEndDebugRun(() => this.changed.fire(undefined)), ]; } @@ -160,6 +181,10 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El item.contextValue = node.kind === "entrypoint" ? "entrypointRunOnly" : "buildTarget"; } } + if (node.kind === "androidTarget" && node.offers?.debuggable) { + const project = this.workspace.projectAt(node.root); + if (project && !this.workspace.komodoDebugAvailable(project.folder)) item.contextValue = item.contextValue.replace(".debug", ""); + } const id = nodeId(node); if (id) item.id = id; switch (node.kind) { @@ -189,13 +214,18 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El case "buildTarget": item.iconPath = new vscode.ThemeIcon("target"); item.description = node.description; - item.tooltip = node.debuggable ? `elide build ${node.label} [--debugger]` : `elide build ${node.label}`; - item.command = { command: "elide.executeTask", title: "Build", arguments: [node] }; + item.tooltip = node.deviceBound ? `elide build ${node.label} --device=` + : node.debuggable ? `elide build ${node.label} [--debugger]` : `elide build ${node.label}`; + item.command = node.deviceBound + ? { command: "elide.androidRunDeviceTask", title: "Run on Deployment Target", arguments: [node] } + : { command: "elide.executeTask", title: "Build", arguments: [node] }; break; case "androidTarget": item.iconPath = new vscode.ThemeIcon(ANDROID_TARGET_ICONS[node.targetKind]); item.description = node.description; - item.tooltip = `${node.description ? `${node.description}\n` : ""}elide build ${node.args.join(" ")}`; + item.tooltip = `${node.description ? `${node.description}\n` : ""}elide build ${node.args.join(" ")}` + + (node.offers && !node.offers.runnable && !node.offers.debuggable + ? "\nNot installable: Elide lists no run or debug target for it (an unsigned APK, say)." : ""); item.command = { command: "elide.build", title: "Build", arguments: [node] }; break; case "androidMapping": @@ -213,7 +243,7 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El case "androidAction": item.iconPath = new vscode.ThemeIcon(node.icon); item.description = node.description; - item.command = { command: node.command, title: node.label, arguments: [node] }; + item.command = { command: node.command, title: node.label, arguments: node.bare ? [] : [node] }; break; case "module": item.iconPath = new vscode.ThemeIcon(node.module.kind === "test" ? "beaker" : "symbol-namespace"); @@ -264,17 +294,22 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El /** * A project without a model knows no entrypoints, source sets or libraries yet; its tasks still run. A section - * with nothing under it is left out: a workspace root commonly declares members and no sources of its own. + * with nothing under it is left out: a workspace root commonly declares members and no sources of its own. A + * top-level row carries the one devices & tools section of its whole tree, acting for its first application. */ private projectGroups(root: string): ElideNode[] { - const model = this.workspace.projectAt(root)?.model; - const groups: GroupKind[] = []; - if (model && model.entrypoints.length > 0) groups.push("entrypoints"); - if (model?.android) groups.push("android"); - groups.push("tasks"); - if (model && model.modules.length > 0) groups.push("sourceSets"); - if (model && model.libraries.length > 0) groups.push("dependencies"); - return groups.map((group) => ({ kind: "group", label: GROUP_LABELS[group], root, group })); + const project = this.workspace.projectAt(root); + const model = project?.model; + const groups: ElideNode[] = []; + const group = (kind: GroupKind, owner = root) => groups.push({ kind: "group", label: GROUP_LABELS[kind], root: owner, group: kind }); + if (model && model.entrypoints.length > 0) group("entrypoints"); + if (model?.android && model.android.targets.length > 0) group("android"); + const tools = project && !project.workspaceRoot ? androidToolsProject([project, ...this.workspace.membersOf(root)]) : undefined; + if (tools) group("androidTools", tools.root); + group("tasks"); + if (model && model.modules.length > 0) group("sourceSets"); + if (model && model.libraries.length > 0) group("dependencies"); + return groups; } private async groupChildren(node: Extract): Promise { @@ -296,7 +331,11 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El case "sourceSets": return (model?.modules ?? []).map((module) => ({ kind: "module", label: module.sourceSet, root: node.root, module })); case "android": - return androidGroupNodes(node.root, model?.android); + return (model?.android?.targets ?? []).flatMap((target) => androidTargetNodes(node.root, target)); + case "androidTools": { + const top = project.workspaceRoot ? this.workspace.projectAt(project.workspaceRoot) : project; + return androidToolNodes(project, top ? [top, ...this.workspace.membersOf(top.root)] : [project]); + } case "androidDevices": return await this.androidDeviceNodes(project); case "dependencies": @@ -343,6 +382,7 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El // Only a target that starts a JVM declares `--debugger`; compilation targets have nothing to attach to. debuggable: target.options.some((option) => option.option === "--debugger"), runnable: image !== undefined, + deviceBound: isAndroidDeviceTask(target), ...(image?.outputName ? { outputName: image.outputName } : {}), }; }); @@ -370,6 +410,7 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El description: device.ready ? (device.emulator ? "emulator" : "device") : device.state === "unauthorized" ? "unauthorized — unlock device and accept USB debugging" : `${device.state} — reconnect device`, ready: device.ready, + emulator: device.emulator, })); } catch (error) { this.ui.log(`Android device discovery: ${error instanceof Error ? error.message : String(error)}`); @@ -410,33 +451,33 @@ function projectNode(project: ElideProject): ElideNode { } /** - * The Android section of a project: what it publishes, what is attached, and the project-wide device actions. - * - * The emulator, the AVD and the SDK install are one per project rather than one per artifact — the device is the - * project's, declared once in `android.device` — which is why they sit here and not under each APK. + * The devices & tools section of a workspace tree, shown once however many Android members it has: what is attached, + * then the actions. `owner` is the project they act for; selection and logcat choose among all Android projects, as + * from the palette. Each emulator row belongs to an application that declares its AVD; a library declares none it + * could boot, so a library-only tree offers no emulator rows. */ -function androidGroupNodes(root: string, android: AndroidProjectModel | undefined): ElideNode[] { - if (!android) return []; - const nodes: ElideNode[] = android.targets.flatMap((target) => androidTargetNodes(root, target)); - nodes.push({ kind: "group", label: GROUP_LABELS.androidDevices, root, group: "androidDevices" }); - nodes.push( - { kind: "androidAction", label: "Select deployment target", root, command: "elide.androidSelectTarget", description: "application and device for Run / Debug", icon: "device-mobile" }, +function androidToolNodes(owner: ElideProject, tree: readonly ElideProject[]): ElideNode[] { + const root = owner.root; + const nodes: ElideNode[] = [ + { kind: "group", label: GROUP_LABELS.androidDevices, root, group: "androidDevices" }, + { kind: "androidAction", label: "Select deployment target", root, command: "elide.androidSelectTarget", description: "application and device for Run / Debug", icon: "device-mobile", bare: true }, + { kind: "androidAction", label: "Pair device over Wi-Fi", root, command: "elide.androidPairWireless", description: "QR code or pairing code", icon: "radio-tower" }, { kind: "androidAction", label: "Check Android environment", root, command: "elide.androidDoctor", description: "SDK, tools and device readiness", icon: "checklist" }, { kind: "androidAction", label: "Refresh devices", root, command: "elide.androidRefreshDevices", description: "discover connected devices", icon: "refresh" }, - { kind: "androidAction", label: "Show logcat", root, command: "elide.androidLogcat", description: "device and application logs", icon: "output" }, - { kind: "androidAction", label: "Start emulator", root, command: "elide.androidStartEmulator", description: android.avd, icon: "vm-running" }, - { kind: "androidAction", label: "Stop emulators", root, command: "elide.androidStopEmulator", description: "adb emu kill", icon: "vm-outline" }, - { kind: "androidAction", label: "Create AVD", root, command: "elide.androidCreateAvd", description: android.avd, icon: "vm-connect" }, - ); - if (!android.sdkRoot) { - nodes.push({ - kind: "androidAction", - label: "Install Android SDK components", - root, - command: "elide.androidInstallSdk", - description: "sdkmanager --install", - icon: "cloud-download", - }); + { kind: "androidAction", label: "Show logcat", root, command: "elide.androidLogcat", description: "device and application logs", icon: "output", bare: true }, + { kind: "androidAction", label: "Explore databases", root, command: "elide.androidDatabase", description: "connected app's Room / SQLite data", icon: "database" }, + ]; + const emulators = androidEmulatorOwners(tree); + for (const { avd, project } of emulators) { + const description = emulators.length > 1 ? `${avd} · ${project.model?.name ?? path.basename(project.root)}` : avd; + nodes.push( + { kind: "androidAction", label: "Start emulator", root: project.root, command: "elide.androidStartEmulator", description, icon: "vm-running" }, + { kind: "androidAction", label: "Stop emulator", root: project.root, command: "elide.androidStopEmulator", description, icon: "vm-outline" }, + { kind: "androidAction", label: "Create AVD", root: project.root, command: "elide.androidCreateAvd", description, icon: "vm-connect" }, + ); + } + if (!owner.model?.android?.sdkRoot) { + nodes.push({ kind: "androidAction", label: "Install Android SDK components", root, command: "elide.androidInstallSdk", description: "sdkmanager --install", icon: "cloud-download" }); } return nodes; } @@ -455,6 +496,7 @@ function androidTargetNodes(root: string, target: AndroidTargetModel): ElideNode args: [target.kind === "feature" ? androidFeatureTask(target.name) : target.name], description: target.kind === "feature" ? `${target.delivery} → ${target.base}${target.featureDependencies?.length ? ` · requires: ${target.featureDependencies.join(", ")}` : ""}` : target.features?.length ? `${path.basename(target.output)} · features: ${target.features.join(", ")}` : path.basename(target.output), + ...(target.device ? { offers: currentAndroidDeviceTargets(root, target) ?? target.device } : {}), }; if (!target.mapping) return [node]; return [ @@ -477,16 +519,24 @@ const ANDROID_TARGET_CONTEXTS: Record = { apk: "and /** * `view/item/context` key. A member of a workspace takes no member of its own, so its row offers no such action; a * script entrypoint offers no Debug action (a script is a shell command line); a build target offers the JVM Debug - * action only when it declares `--debugger`, and the Run/Debug pair of a Native Image only when it is a binary one. - * Only an APK is installed, launched and debugged, and only a device in the `device` state can take an install. + * action only when it declares `--debugger`, the Run/Debug pair of a Native Image only when it is a binary one, and + * runs on the deployment target when it installs onto a device. An APK row carries `.run`, `.debug` and `.test` for + * the device targets Elide lists for it; only a device in the `device` state can take an install, and only an + * emulator can be stopped. */ function contextValueOf(node: ElideNode): string { if (node.kind === "project") return node.member ? "projectMember" : "project"; + if (node.kind === "group" && (node.group === "android" || node.group === "androidTools" || node.group === "androidDevices")) return node.group; if (node.kind === "entrypoint") return node.script ? "script" : node.kind; if (node.kind === "buildTarget" && node.runnable) return "buildTargetRunnable"; + if (node.kind === "buildTarget" && node.deviceBound) return "buildTargetDevice"; if (node.kind === "buildTarget" && node.debuggable) return "buildTargetDebuggable"; - if (node.kind === "androidTarget") return ANDROID_TARGET_CONTEXTS[node.targetKind]; - if (node.kind === "androidDevice") return node.ready ? "androidDevice" : "androidDeviceUnavailable"; + if (node.kind === "androidTarget") { + const offers = node.offers; + const actions = offers ? [offers.runnable ? ".run" : "", offers.debuggable ? ".debug" : "", offers.instrumented ? ".test" : ""].join("") : ""; + return `${ANDROID_TARGET_CONTEXTS[node.targetKind]}${actions}`; + } + if (node.kind === "androidDevice") return `${node.emulator ? "androidEmulator" : "androidDevice"}${node.ready ? "" : "Unavailable"}`; return node.kind; } @@ -541,7 +591,8 @@ function nodeId(node: ElideNode): string | undefined { case "androidDevice": return `d:${node.root}:${node.device}`; case "androidAction": - return `x:${node.root}:${node.command}`; + // The label too: a failed device discovery offers `elide.androidDoctor` beside the section's own row for it. + return `x:${node.root}:${node.command}:${node.label}`; case "androidMapping": return `am:${node.root}:${node.artifact}`; } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 5c0647e..c05cbe5 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -7,17 +7,26 @@ import { createAndroidAvd, createAndroidKeystore, debugAndroid, + executeAndroidTask, installAndroidSdk, + isAndroidBuild, openAndroidMapping, runAndroid, + runAndroidDeviceTask, startAndroidEmulator, stopAndroidEmulator, } from "./android.js"; import { ELIDE_ANDROID_DEBUG_TYPE, ElideAndroidDebugConfigurationProvider } from "./androidDebug.js"; import { AndroidDeploymentController } from "./androidDeployment.js"; +import { androidApks, hasAndroidProjects } from "./androidDeploymentState.js"; +import { ElideAndroidTestController } from "./androidTesting.js"; +import { registerAndroidDatabase } from "./androidDatabase.js"; import { registerAndroidLogcat } from "./androidLogcat.js"; import { registerAndroidReadiness } from "./androidReadiness.js"; +import { registerAndroidWireless } from "./androidWireless.js"; import { ElideCodeLensProvider } from "./codelens.js"; +import { ComposeAgentPreviews } from "./composeAgent.js"; +import { registerComposePreviewMcp, type ElideComposePreviewMcpApi } from "./composeAgentMcp.js"; import { registerComposePreview, type ElideComposePreviewApi } from "./composePreview.js"; import { readConfig, type KotlinLspServer } from "./config.js"; import { ELIDE_DEBUG_TYPE, ElideDebugConfigurationProvider, KOMODO_DEBUG_TYPE } from "./debug.js"; @@ -37,6 +46,7 @@ export interface ElideExtensionApi { tests: ElideTestApi; explorer: ElideExplorerApi; composePreview: ElideComposePreviewApi; + composePreviewMcp: ElideComposePreviewMcpApi; activity: ElideActivityApi; } @@ -47,11 +57,17 @@ export async function activate(context: vscode.ExtensionContext): Promise deployments.target), + registerAndroidDatabase(workspace, context, () => deployments.target), registerAndroidReadiness(workspace, ui), + registerAndroidWireless(workspace, () => deployments.target), deployments, ); @@ -59,6 +75,7 @@ export async function activate(context: vscode.ExtensionContext): Promise runTaskCommand(workspace)), vscode.commands.registerCommand("elide.newProject", () => newProject(ui)), vscode.commands.registerCommand("elide.addMember", (target: unknown) => addWorkspaceMember(workspace, ui, target)), - vscode.commands.registerCommand("elide.run", (target: unknown) => runEntrypoint(workspace, target, "run")), - vscode.commands.registerCommand("elide.build", (target: unknown) => runEntrypoint(workspace, target, "build")), + vscode.commands.registerCommand("elide.run", (target: unknown) => runEntrypoint(workspace, ui, target, "run")), + vscode.commands.registerCommand("elide.build", (target: unknown) => runEntrypoint(workspace, ui, target, "build")), vscode.commands.registerCommand("elide.runArtifact", (target: unknown) => runArtifact(workspace, ui, target)), vscode.commands.registerCommand("elide.debug", (target: unknown) => debugEntrypoint(workspace, target)), vscode.commands.registerCommand("elide.debugArtifact", (target: unknown) => debugArtifact(workspace, target)), - vscode.commands.registerCommand("elide.executeTask", (target: unknown) => runNamedTask(workspace, target)), + vscode.commands.registerCommand("elide.executeTask", (target: unknown) => runNamedTask(workspace, ui, target)), vscode.commands.registerCommand("elide.openManifest", (target: unknown) => openManifest(workspace, target)), vscode.commands.registerCommand("elide.androidSelectTarget", (target: unknown) => deployments.select(target)), - vscode.commands.registerCommand("elide.androidRun", (target: unknown) => runAndroid(workspace, deployments, target)), + vscode.commands.registerCommand("elide.androidRun", (target: unknown) => runAndroid(workspace, ui, deployments, target)), vscode.commands.registerCommand("elide.androidDebug", (target: unknown) => debugAndroid(workspace, deployments, target)), + vscode.commands.registerCommand("elide.androidRunDeviceTask", (target: unknown) => runAndroidDeviceTask(workspace, ui, deployments, target)), vscode.commands.registerCommand("elide.androidStartEmulator", (target: unknown) => startAndroidEmulator(workspace, target)), vscode.commands.registerCommand("elide.androidStopEmulator", (target: unknown) => stopAndroidEmulator(workspace, target)), vscode.commands.registerCommand("elide.androidCreateAvd", (target: unknown) => createAndroidAvd(workspace, target)), @@ -98,6 +116,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { void vscode.commands.executeCommand("setContext", "elide.komodoAvailable", workspace.komodoAvailable); void vscode.commands.executeCommand("setContext", "elide.komodoPreviewAvailable", workspace.komodoPreviewAvailable); + void updateContextKeys(workspace); if (!workspace.komodoAvailable) { for (const session of komodoSessions) void vscode.debug.stopDebugging(session); } }; updateKomodoAvailability(); context.subscriptions.push( - workspace.onDidChange(() => void vscode.commands.executeCommand("setContext", "elide.hasProjects", workspace.projects.length > 0)), + workspace.onDidChange(() => void updateContextKeys(workspace)), workspace.onDidChangeKomodoAvailability(updateKomodoAvailability), vscode.debug.onDidStartDebugSession((session) => { if (session.type !== KOMODO_DEBUG_TYPE) return; @@ -157,11 +177,11 @@ export async function activate(context: vscode.ExtensionContext): Promise 0); + await updateContextKeys(workspace); // No project is not a dead end: commands, the welcome view and project creation stay available. if (workspace.projects.length === 0) ui.log("No elide.pkl found in the workspace."); else void workspace.syncAll("startup", (folder) => readConfig(folder).syncOnStartup); - return { tests, explorer, composePreview: composePreview.api, activity: ui.activity }; + return { tests, explorer, composePreview: composePreview.api, composePreviewMcp: composePreviewMcp.api, activity: ui.activity }; } export function deactivate(): void {} @@ -269,7 +289,10 @@ function registerWatchers(context: vscode.ExtensionContext, workspace: ElideWork lockfiles, workspace.onDidChangeModules(watchModules), { dispose: () => moduleWatchers.forEach((w) => w.dispose()) }, - vscode.workspace.onDidChangeConfiguration((e) => workspace.settingsChanged(e)), + vscode.workspace.onDidChangeConfiguration((e) => { + workspace.settingsChanged(e); + if (e.affectsConfiguration("elide.debug.adapter") || e.affectsConfiguration("elide.kotlinLsp.server")) void updateContextKeys(workspace); + }), vscode.workspace.onDidChangeWorkspaceFolders((e) => { for (const removed of e.removed) { clearTimeout(timers.get(removed.uri.toString())); @@ -375,17 +398,34 @@ async function openWorkspaceJson(workspace: ElideWorkspace): Promise { } } -/** Actions offered by the status-bar menu, in order; entries whose command is not registered are skipped. */ -const MENU_ACTIONS: { label: string; description: string; command: string }[] = [ +/** + * `elide.hasProjects`, and the keys that keep Android commands out of non-Android workspaces: `elide.hasAndroidProjects` + * (a synced project declares `android`) and `elide.hasUnsyncedProjects` (a project has no model yet, so it may). + */ +async function updateContextKeys(workspace: ElideWorkspace): Promise { + await Promise.all([ + vscode.commands.executeCommand("setContext", "elide.hasProjects", workspace.projects.length > 0), + vscode.commands.executeCommand("setContext", "elide.hasAndroidProjects", hasAndroidProjects(workspace.projects)), + vscode.commands.executeCommand("setContext", "elide.hasUnsyncedProjects", workspace.projects.some((project) => !project.model)), + vscode.commands.executeCommand("setContext", "elide.androidDebugAvailable", + workspace.projects.some((project) => project.model?.android && workspace.komodoDebugAvailable(project.folder))), + ]); +} + +/** Actions offered by the status-bar menu, in order; entries whose command is not registered are skipped, Android ones without an Android project. */ +const MENU_ACTIONS: { label: string; description: string; command: string; android?: true }[] = [ { label: "$(sync) Sync Project(s)", description: "Re-resolve the project model and refresh the Kotlin LSP", command: "elide.sync" }, { label: "$(cloud-download) Install Komodo", description: "Explicitly install the managed native Kotlin component", command: "elide.installKomodo" }, - { label: "$(play) Run Elide Command…", description: "Build, test, install, or run an entrypoint", command: "elide.runTask" }, - { label: "$(device-mobile) Select Android Deployment Target…", description: "Choose the application and device for Run and Debug", command: "elide.androidSelectTarget" }, - { label: "$(play) Run Android App…", description: "Build, install and launch on the selected device", command: "elide.androidRun" }, - { label: "$(debug-alt) Debug Android App…", description: "Build, install and attach the debugger", command: "elide.androidDebug" }, - { label: "$(checklist) Check Android Environment", description: "Diagnose SDK, tools and connected devices", command: "elide.androidDoctor" }, - { label: "$(preview) Open Compose Preview", description: "Render composables locally, without a device", command: "elide.androidComposePreview" }, - { label: "$(output) Show Android Logcat", description: "Application logs and stack traces", command: "elide.androidLogcat" }, + { label: "$(play) Run Elide Command…", description: "Build, test, install, run an entrypoint or an Android app", command: "elide.runTask" }, + { label: "$(device-mobile) Select Android Deployment Target…", description: "Choose the application and device for Run and Debug", command: "elide.androidSelectTarget", android: true }, + { label: "$(radio-tower) Pair Android Device over Wi-Fi…", description: "Pair by QR or pairing code, or reconnect a paired device", command: "elide.androidPairWireless", android: true }, + { label: "$(play) Run Android App…", description: "Build, install and launch on the selected device", command: "elide.androidRun", android: true }, + { label: "$(debug-alt) Debug Android App…", description: "Build, install and attach the debugger", command: "elide.androidDebug", android: true }, + { label: "$(beaker) Run Android Instrumented Tests…", description: "Install a test APK and run its tests on the selected device", command: "elide.androidTest", android: true }, + { label: "$(checklist) Check Android Environment", description: "Diagnose SDK, tools and connected devices", command: "elide.androidDoctor", android: true }, + { label: "$(preview) Open Compose Preview", description: "Render composables locally, without a device", command: "elide.androidComposePreview", android: true }, + { label: "$(output) Show Android Logcat", description: "Application logs and stack traces", command: "elide.androidLogcat", android: true }, + { label: "$(database) Explore Android Databases", description: "The connected app's Room / SQLite data", command: "elide.androidDatabase", android: true }, { label: "$(beaker) Run All Tests", description: "Run every test in the Testing view", command: "testing.runAll" }, { label: "$(file-code) Open elide.pkl", description: "Project manifest", command: "elide.openManifest" }, { label: "$(json) Open Generated Kotlin LSP Workspace", description: WORKSPACE_JSON, command: "elide.openWorkspaceJson" }, @@ -408,31 +448,47 @@ async function showMenu(workspace: ElideWorkspace): Promise { }); break; } - items.push(...MENU_ACTIONS.filter((a) => registered.has(a.command) && - (a.command !== "elide.androidComposePreview" || readConfig().composePreviewEngine !== "komodo" || workspace.komodoPreviewAvailable))); + // The environment check also diagnoses a project that is not synced yet, and may turn out to be an Android one. + const android = hasAndroidProjects(workspace.projects); + const unsynced = workspace.projects.some((project) => !project.model); + items.push(...MENU_ACTIONS.filter((a) => registered.has(a.command) + && (!a.android || android || (a.command === "elide.androidDoctor" && unsynced)) + && (a.command !== "elide.androidDebug" || workspace.projects.some((project) => project.model?.android && workspace.komodoDebugAvailable(project.folder))) + && (a.command !== "elide.androidComposePreview" || readConfig().composePreviewEngine !== "komodo" || workspace.komodoPreviewAvailable))); const pick = await vscode.window.showQuickPick(items, { placeHolder: "Elide" }); if (pick) await vscode.commands.executeCommand(pick.command); } +/** + * Every command the projects offer: build, test and install each, run each entrypoint, and — on the deployment + * target — run, debug or test each APK, as far as Elide lists a device target for it. + */ async function runTaskCommand(workspace: ElideWorkspace): Promise { - const items: (vscode.QuickPickItem & { command: ElideCommand; args: string[]; project: ElideProject })[] = []; + const items: (vscode.QuickPickItem & { run: () => Thenable })[] = []; for (const project of workspace.projects) { const rel = path.relative(project.folder.uri.fsPath, project.root); const desc = rel ? rel : project.folder.name; const add = (command: ElideCommand, args: string[] = [], label = [command, ...args].join(" ")) => - items.push({ label: `elide ${label}`, description: desc, command, args, project }); + items.push({ label: `elide ${label}`, description: desc, run: () => executeElideTask(workspace, project, command, { args }) }); add("build"); add("test"); add("install"); for (const ep of project.model?.entrypoints ?? []) add("run", entrypointArgs(ep), entrypointLabel(ep)); + for (const apk of androidApks(project)) { + const target = { root: project.root, artifact: apk.name }; + const android = (label: string, command: string) => + items.push({ label, description: desc, detail: "on the Android deployment target", run: () => vscode.commands.executeCommand(command, target) }); + if (apk.device?.runnable) android(`$(play) Run Android app ${apk.name}`, "elide.androidRun"); + if (apk.device?.debuggable) android(`$(debug-alt) Debug Android app ${apk.name}`, "elide.androidDebug"); + if (apk.device?.instrumented) android(`$(beaker) Run instrumented tests of ${apk.name}`, "elide.androidTest"); + } } if (items.length === 0) { void vscode.window.showInformationMessage("Elide: no Elide projects found; run 'Elide: Sync Project(s)' first."); return; } const pick = await vscode.window.showQuickPick(items, { placeHolder: "Elide command to run" }); - if (!pick) return; - await executeElideTask(workspace, pick.project, pick.command, { args: pick.args }); + if (pick) await pick.run(); } /** What an `elide.run`/`elide.debug`/`elide.build`/`elide.runArtifact`/`elide.debugArtifact`/`elide.executeTask` invocation points at. */ @@ -465,11 +521,14 @@ function projectForTarget(workspace: ElideWorkspace, target: CommandTarget): Eli return project; } -async function runEntrypoint(workspace: ElideWorkspace, value: unknown, command: ElideCommand): Promise { +/** `elide ` as a task; an Android build reports its failure in Elide's words (see {@link executeAndroidTask}). */ +async function runEntrypoint(workspace: ElideWorkspace, ui: ElideUi, value: unknown, command: ElideCommand): Promise { const target = toTarget(value); if (!target) return; const project = projectForTarget(workspace, target); - if (project) await executeElideTask(workspace, project, command, { args: target.args }); + if (!project) return; + if (command === "build" && isAndroidBuild(project, target.args)) await executeAndroidTask(workspace, ui, project, { args: target.args }); + else await executeElideTask(workspace, project, command, { args: target.args }); } /** @@ -499,11 +558,10 @@ async function runArtifact(workspace: ElideWorkspace, ui: ElideUi, value: unknow await executeProgramTask(project, `run ${artifact}`, binary); } -async function runNamedTask(workspace: ElideWorkspace, value: unknown): Promise { +async function runNamedTask(workspace: ElideWorkspace, ui: ElideUi, value: unknown): Promise { const target = toTarget(value); if (!target?.command) return; - const project = projectForTarget(workspace, target); - if (project) await executeElideTask(workspace, project, target.command, { args: target.args }); + await runEntrypoint(workspace, ui, target, target.command); } /** diff --git a/packages/vscode/src/failures.ts b/packages/vscode/src/failures.ts index ecc6a16..dd88ef8 100644 --- a/packages/vscode/src/failures.ts +++ b/packages/vscode/src/failures.ts @@ -1,6 +1,7 @@ -import { type ElideFailure } from "@elide/ide-core"; +import { androidIncompatibleUpdatePackage, androidKeystoreHintArtifact, type ElideFailure } from "@elide/ide-core"; import * as vscode from "vscode"; import type { ElideUi } from "./output.js"; +import type { ElideProject } from "./projects.js"; /** A fix a failure notification offers, beside showing where Elide's whole output is: a command, or a step of the run that failed. */ export type FailureAction = { label: string } & ({ command: string; arguments?: unknown[] } | { run: () => Promise }); @@ -13,6 +14,25 @@ export function elideFailureMessage(failure: ElideFailure, fallback: string): st return `Elide: ${failure.error ? `${failure.error}${more}` : fallback}${failure.hint ? ` — hint: ${failure.hint}` : ""}`; } +/** + * Fixes a failed Android build offers for what Elide said. A missing keystore names the target that mints it + * (`hint: Create one with: elide build android-keystore-`): Create Android Signing Keystore, in the first of + * `projects` that declares the artifact — a workspace build reports its members' artifacts. An install Android + * refused because the app on the device is signed with another key (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) offers + * Uninstall and Retry when the failed run says how: `reinstall` uninstalls that application, once confirmed, and + * runs again. + */ +export function androidFailureActions(projects: readonly ElideProject[], failure: ElideFailure, reinstall?: (application: string) => Promise): FailureAction[] { + const actions: FailureAction[] = []; + const artifact = failure.hint ? androidKeystoreHintArtifact(failure.hint) : undefined; + const owner = artifact && projects.find((project) => project.model?.android?.targets.some((target) => + target.name === artifact && (target.kind === "apk" || target.kind === "aab"))); + if (owner) actions.push({ label: "Create Android Signing Keystore", command: "elide.androidCreateKeystore", arguments: [{ root: owner.root, artifact }] }); + const application = reinstall && androidIncompatibleUpdatePackage(`${failure.error ?? ""}\n${failure.hint ?? ""}`); + if (application) actions.push({ label: "Uninstall and Retry", run: () => reinstall(application) }); + return actions; +} + /** * Tell the user an Elide run failed, in Elide's own words, and log the same line to the Elide output channel. The * run's whole output is in a terminal, which `reveal` brings forward. diff --git a/packages/vscode/src/jetbrains.ts b/packages/vscode/src/jetbrains.ts index 00db289..e0aad69 100644 --- a/packages/vscode/src/jetbrains.ts +++ b/packages/vscode/src/jetbrains.ts @@ -18,6 +18,7 @@ export const JETBRAINS_EXTENSION_ID = "JetBrains.kotlin-server"; const RELOAD_COMMAND = "jetbrains.kotlin.reloadWorkspace"; const RESTART_COMMAND = "jetbrains.kotlin.restartLsp"; const DEBUGGER_TYPES = ["intellij_jvm", "intellij_debugger"]; +const ASYNC_JDI_PROPERTY = "-Ddebugger.async.jdi"; const PLUGIN_PATH_PROPERTY = "-Dplugin.path"; /** A wait shorter than this attaches without a progress notification flashing by. */ const ANALYSIS_PROGRESS_DELAY_MS = 750; @@ -29,6 +30,8 @@ const ANALYSIS_STAGES: Record = { /** What this window adds to the Kotlin server's launch options. */ export interface KotlinLspLaunchOptions { + /** The window holds an Android project: default to synchronous JDI metadata queries, unless set explicitly. */ + androidDebugger: boolean; /** Elide's server plugin directory (`kotlinLspPlugin`), listed in `-Dplugin.path` while a Compose project is open. */ plugin?: string; } @@ -48,11 +51,14 @@ export class KotlinLspEnvironment { constructor(private readonly environment: NodeJS.ProcessEnv = process.env) {} /** Apply `options` over the inherited launcher options; `true` when they changed, which needs a new server. */ - configure(options: KotlinLspLaunchOptions): boolean { + configure(options: KotlinLspLaunchOptions, args: readonly string[]): boolean { const current = this.environment.IJ_JAVA_OPTIONS; const ownsCurrent = this.applied !== undefined && current === this.applied; const inherited = ownsCurrent ? this.inherited : current; + const explicit = args.some((arg) => arg === ASYNC_JDI_PROPERTY || arg.startsWith(`${ASYNC_JDI_PROPERTY}=`)) + || /(?:^|\s)['"]?-Ddebugger\.async\.jdi(?:=|['"]?(?:\s|$))/.test(inherited ?? ""); const added = [ + ...(options.androidDebugger && !explicit ? [`${ASYNC_JDI_PROPERTY}=false`] : []), ...(options.plugin ? [launcherQuote(`${PLUGIN_PATH_PROPERTY}=${options.plugin}`)] : []), ]; const next = added.length > 0 ? [...(inherited ? [inherited] : []), ...added].join(" ") : inherited; @@ -65,7 +71,7 @@ export class KotlinLspEnvironment { } dispose(): void { - this.configure({}); + this.configure({ androidDebugger: false }, []); } } @@ -270,7 +276,7 @@ export async function reloadKotlinLsp(relaunch = false, waitForDebugger = false) const deadline = Date.now() + 30_000; while (true) { if (discovery.some(publishesPort) && (await vscode.commands.getCommands(true)).includes("start_debug_server")) break; - if (Date.now() >= deadline) throw new Error("Kotlin LSP did not finish restarting with the debugger options."); + if (Date.now() >= deadline) throw new Error("Kotlin LSP did not finish restarting with the Android debugger options."); const { promise, resolve } = Promise.withResolvers(); setTimeout(resolve, 100); await promise; diff --git a/packages/vscode/src/komodoLsp.ts b/packages/vscode/src/komodoLsp.ts index 7ab74a3..dc45901 100644 --- a/packages/vscode/src/komodoLsp.ts +++ b/packages/vscode/src/komodoLsp.ts @@ -4,7 +4,7 @@ import { existsSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { writeKomodoProject, type ProjectModel } from "@elide/ide-core"; import * as vscode from "vscode"; -import { CloseAction, ErrorAction, LanguageClient, State, type LanguageClientOptions, type ServerOptions } from "vscode-languageclient/node"; +import { CloseAction, ErrorAction, ErrorCodes, LanguageClient, ResponseError, State, type LanguageClientOptions, type ServerOptions } from "vscode-languageclient/node"; import type * as lsp from "vscode-languageclient/node"; import { KomodoResolutionError, resolveKomodo, type KomodoResolution } from "./komodoDistribution.js"; import { requireKomodoDebugProtocol } from "./komodoDebug.js"; @@ -92,9 +92,24 @@ interface Running { failure?: string; } -/** Graceful shutdown includes disposing analysis workspaces. */ +/** Graceful shutdown includes closing preview sessions and disposing analysis workspaces. */ const EXIT_GRACE_MS = 30_000; +/** A `komodo/previews` answer: entries shaped like compiled discovery's, plus each preview's source ranges. */ +export interface KomodoPreviews { + previews: { methodFQN: string; previewId: string; name: string; previewParams: Record; methodParams: Record[] }[]; + errors: lsp.Diagnostic[]; +} + +/** A `komodo/preview/open` answer: a preview session served in the language server's process, on a loopback port. */ +export interface KomodoPreviewSession { + protocol: number; + port: number; + /** The session's first line from the client: the server serves only the connection that writes it. */ + token: string; + pid?: number; +} + /** * Komodo's language server for this window: one process analyzing the Elide projects of every folder whose * `elide.kotlinLsp.server` is `komodo`, from one Komodo project manifest the syncs write into extension storage (never @@ -384,6 +399,76 @@ export class KomodoLanguageServer implements vscode.Disposable { return content.text; } + /** + * `komodo/previews`: the Compose previews `file` declares, from its resolved source (the open buffer, which the client syncs + * before the request), with its error diagnostics. Throws without a running server for the file's folder. + */ + async previews(file: string): Promise { + const { client, uri } = await this.clientFor(file); + try { + return await client.sendRequest("komodo/previews", { textDocument: { uri } }); + } catch (error) { + if (error instanceof ResponseError && error.code === ErrorCodes.MethodNotFound) { + const reason = "This Komodo language server cannot discover previews. Install a compatible Komodo through Elide, then sync."; + if (this.running?.client === client) this.rejectPreview(reason); + throw new Error(reason); + } + throw error; + } + } + + /** Opens a preview in the workspace server; an older server must be upgraded, never replaced by a preview child. */ + async previewSession(file: string): Promise { + const { client, uri } = await this.clientFor(file); + try { + const session = await client.sendRequest("komodo/preview/open", { textDocument: { uri } }); + if (session?.protocol !== 2 || !Number.isInteger(session.port) || session.port < 1 || session.port > 65535 + || typeof session.token !== "string" || !session.token || /[\r\n]/.test(session.token)) { + const reason = "Komodo returned an invalid preview session. Install a compatible Komodo through Elide, then sync."; + if (this.running?.client === client) this.rejectPreview(reason); + throw new Error(reason); + } + return session; + } catch (error) { + if (error instanceof ResponseError && error.code === ErrorCodes.MethodNotFound) { + const reason = "This Komodo language server is too old for the Komodo preview engine. Install a compatible Komodo through Elide; there is no fallback."; + if (this.running?.client === client) this.rejectPreview(reason); + throw new Error(reason); + } + throw error; + } + } + + /** Orders versioned editor buffers on the analysis thread before the socket asks it to compile. */ + async syncPreview(files: readonly string[]): Promise { + const file = files[0]; + if (!file) return; + const { client } = await this.clientFor(file); + const wanted = new Set(files); + const documents = vscode.workspace.textDocuments + .filter((document) => document.uri.scheme === "file" && wanted.has(document.uri.fsPath) && document.uri.fsPath.endsWith(".kt")) + .map((document) => ({ uri: document.uri.toString(), version: document.version, text: document.getText() })); + try { + await client.sendRequest("komodo/preview/sync", { documents }); + } catch (error) { + if (error instanceof ResponseError && error.code === ErrorCodes.MethodNotFound) { + const reason = "This Komodo language server is too old to synchronize unsaved edits with the Komodo preview engine. Install a compatible Komodo through Elide, then sync."; + if (this.running?.client === client) this.rejectPreview(reason); + throw new Error(reason); + } + throw error; + } + } + + /** The running client serving `file`'s folder, started, and the file's protocol URI; throws without a server for it. */ + private async clientFor(file: string): Promise<{ client: LanguageClient; uri: string }> { + await this.requirePreview(file); + const client = this.running?.client; + const uri = vscode.Uri.file(file); + if (!client || !this.available) throw new Error("Komodo previews are disabled. Sync with a compatible component before trying again."); + return { client, uri: client.code2ProtocolConverter.asUri(uri) }; + } + /** The lens command: VS Code's reference peek, from the LSP JSON the server put in the lens. */ private async showReferences(uri: string, position: lsp.Position, locations: lsp.Location[]): Promise { const client = this.running?.client; diff --git a/packages/vscode/src/mcpServer.ts b/packages/vscode/src/mcpServer.ts index e54b8c1..8d80b22 100644 --- a/packages/vscode/src/mcpServer.ts +++ b/packages/vscode/src/mcpServer.ts @@ -142,7 +142,13 @@ export class McpHttpServer { if (authorization === undefined || !sameToken(authorization, this.options.token)) { return reject(401, "Unauthorized", { "WWW-Authenticate": "Bearer" }); } - if (new URL(request.url ?? "/", "http://127.0.0.1").pathname !== "/mcp") return reject(404, "Not found"); + let pathname: string; + try { + pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; + } catch { + return reject(400, "Invalid request target"); + } + if (pathname !== "/mcp") return reject(404, "Not found"); if (request.method !== "POST") return reject(405, "Method not allowed", { Allow: "POST" }); const version = request.headers["mcp-protocol-version"]; if (typeof version === "string" && !PROTOCOL_VERSIONS.includes(version)) return reject(400, `Unsupported MCP-Protocol-Version ${version}`); diff --git a/packages/vscode/src/projects.ts b/packages/vscode/src/projects.ts index b4176f6..5e67cbc 100644 --- a/packages/vscode/src/projects.ts +++ b/packages/vscode/src/projects.ts @@ -36,7 +36,7 @@ import * as vscode from "vscode"; import { importsWorkspaceJson, readConfig } from "./config.js"; import { addGitExclude, gitExcludeCandidate } from "./gitExclude.js"; import { KotlinLspEnvironment, KotlinSetupPendingError, awaitKotlinDebuggerAnalysis, jetBrainsComposeCompilerPlugin, jetBrainsServerDirectory, notifyKotlinSetupPending, reloadKotlinLsp } from "./jetbrains.js"; -import { KomodoLanguageServer, type KomodoDebugLaunch, type KomodoSettings } from "./komodoLsp.js"; +import { KomodoLanguageServer, type KomodoDebugLaunch, type KomodoPreviewSession, type KomodoPreviews, type KomodoSettings } from "./komodoLsp.js"; import { probeSourceRoot } from "./kotlinAnalysis.js"; import { kotlinLspPlugin, withoutKotlinLspPlugin } from "./kotlinLspPlugin.js"; import { moduleReaders, projectModules } from "./manifestModules.js"; @@ -172,6 +172,34 @@ export class ElideWorkspace implements vscode.Disposable { (readConfig(folder).kotlinLspServer === "komodo" && this.komodoAvailable); } + /** `file`'s Compose previews from Komodo's language server (see {@link KomodoLanguageServer.previews}). */ + komodoPreviews(file: string): Promise { + return this.komodoServer.previews(file); + } + + /** A Compose preview session in Komodo's language server for `file` (see {@link KomodoLanguageServer.previewSession}). */ + komodoPreviewSession(file: string): Promise { + return this.komodoServer.previewSession(file); + } + + komodoPreviewSync(files: readonly string[]): Promise { + return this.komodoServer.syncPreview(files); + } + + + private get hasAndroidProjects(): boolean { + return this.projects.some((project) => project.model?.android !== undefined); + } + + /** Launch only after this window's Android transport default has reached a new Kotlin server. */ + async prepareAndroidDebugger(): Promise { + await this.kotlinLspReady; + if (this.configureKotlinLspEnvironment()) { + this.kotlinLspReady = reloadKotlinLsp(true, true); + await this.kotlinLspReady; + } + } + /** * What Komodo's debug adapter runs with for `folder` (`elide.debug.adapter` `komodo`): the launcher, project manifest * and JDK of the Komodo language server its sync configured. Throws, saying why, when there is none yet. @@ -208,13 +236,15 @@ export class ElideWorkspace implements vscode.Disposable { } /** - * Apply this window's Kotlin server launch options ({@link KotlinLspEnvironment}): Elide's plugin while a - * Compose project is open. `true` when they changed, which takes a new server. + * Apply this window's Kotlin server launch options ({@link KotlinLspEnvironment}): the Android debugger default + * while an Android project is open, Elide's plugin while a Compose project is. `true` when they changed, which + * takes a new server. */ private configureKotlinLspEnvironment(): boolean { const compose = this.projects.some((project) => project.model?.kotlin.compose); const plugin = compose ? this.kotlinLspPluginPath : undefined; - return this.kotlinLspEnvironment.configure(plugin ? { plugin } : {}); + return this.kotlinLspEnvironment.configure({ androidDebugger: this.hasAndroidProjects, ...(plugin ? { plugin } : {}) }, + vscode.workspace.getConfiguration("intellij").get("additionalJvmArgs") ?? []); } /** @@ -442,7 +472,7 @@ export class ElideWorkspace implements vscode.Disposable { this.publishManifestError(folder, undefined); void this.komodoServer.remove(folder, komodoSettings()); if (this.configureKotlinLspEnvironment()) { - this.kotlinLspReady = this.kotlinLspReady.catch(() => undefined).then(() => reloadKotlinLsp(true)); + this.kotlinLspReady = this.kotlinLspReady.catch(() => undefined).then(() => reloadKotlinLsp(true, this.hasAndroidProjects)); void this.kotlinLspReady.catch((error) => this.ui.log(`Kotlin LSP restart failed: ${String(error)}`)); } this.notifyChanged(); @@ -790,7 +820,7 @@ export class ElideWorkspace implements vscode.Disposable { signal.throwIfAborted(); const restart = this.configureKotlinLspEnvironment(); // The JetBrains reload command may block for as long as the server's import runs; never let it hold the sync. - this.kotlinLspReady = this.kotlinLspReady.catch(() => undefined).then(() => reloadKotlinLsp(restart)); + this.kotlinLspReady = this.kotlinLspReady.catch(() => undefined).then(() => reloadKotlinLsp(restart, this.hasAndroidProjects)); void this.kotlinLspReady.then( (used) => this.ui.log(used ? `Kotlin LSP ${restart ? "restart" : "reload"} requested via ${used}` : "Kotlin LSP not running yet; it will import workspace.json on start"), (e) => { diff --git a/packages/vscode/src/tasks.ts b/packages/vscode/src/tasks.ts index b0078b9..203e75e 100644 --- a/packages/vscode/src/tasks.ts +++ b/packages/vscode/src/tasks.ts @@ -1,4 +1,3 @@ -import { existsSync } from "node:fs"; import path from "node:path"; import { ElideCli, @@ -6,8 +5,11 @@ import { ElideFailureReport, elideInvocationArgs, elideInvocationOptionsFrom, + elideRecordOutput, mergeElideInvocationOptions, + parseElideOutputLine, resolveElideDistribution, + type CommandObserver, type ElideCommand, type ElideDistribution, type ElideFailure, @@ -18,6 +20,7 @@ import { import * as vscode from "vscode"; import { configuredInvocation, readConfig } from "./config.js"; import type { ElideProject, ElideWorkspace } from "./projects.js"; +import { WorkspacePaths } from "./workspacePaths.js"; export const ELIDE_TASK_TYPE = "elide"; @@ -66,48 +69,71 @@ export async function executeElideTask( return await vscode.tasks.executeTask(task); } +const IN_FILE = /^In file:\s+(?:file:\/\/)?(.+?)(?::(\d+))?(?::(\d+))?\s*$/; +/** The tool a diagnostic headline names before its message, as the `$elide` problem matcher drops it: `kotlinc: `. */ +const TOOL_PREFIX = /^(?:\[\w+\]\s*)?(?:[\w.+-]+:\s+)?/; + /** - * Run one `elide` task whose output the extension follows too. The task terminal shows the output and the `$elide` - * problem matcher reads it as for any other task, while `onCommand` sees the process start, each line it prints and - * its exit — what a caller needs to show the build's progress somewhere the terminal is not. + * Run one `elide` command for a project as a process the extension follows, not as a VS Code task: VS Code saves + * every dirty editor before it runs a task (`task.saveBeforeRun`), which a caller that works from unsaved editor + * text must not cause. `onCommand` sees the process start, each line it prints and its exit. The compiler + * diagnostics it reports replace those in `diagnostics`, as the `$elide` problem matcher does for a task, at the + * paths the workspace spells. * - * `exitCode` settles when the process ends; `undefined` means it was terminated or never started. + * Settles with the exit code, or `undefined` when `signal` stopped the process. */ -export async function executeObservedElideTask( +export async function runObservedElide( project: ElideProject, command: ElideCommand, invocation: ElideInvocationOptions, - presentation: vscode.TaskPresentationOptions, - onCommand: NonNullable, -): Promise<{ execution: vscode.TaskExecution; exitCode: Promise }> { - const { definition, label } = taskDefinition(project, command, invocation); - const elide = taskProcess(project, definition); - const exit = Promise.withResolvers(); - const rel = path.relative(project.folder.uri.fsPath, project.root); - const task = new vscode.Task(definition, project.folder, `${label}${rel ? ` (${rel})` : ""}`, ELIDE_TASK_TYPE, - new vscode.CustomExecution(async () => new ObservedElideTerminal(elide, onCommand, exit.resolve)), ["$elide"]); - task.presentationOptions = { reveal: vscode.TaskRevealKind.Always, panel: vscode.TaskPanelKind.Shared, clear: true, ...presentation }; - // VS Code runs one instance of a task: executing it while an earlier run still holds its terminal — as one does - // for CLOSE_DELAY_MS after its process exits — hands back that run, which then ends without this one starting. - const key = JSON.stringify(definition); - await Promise.all(vscode.tasks.taskExecutions - .filter((running) => running.task.source === ELIDE_TASK_TYPE && JSON.stringify(running.task.definition) === key) - .map((running) => { - const { promise, resolve } = Promise.withResolvers(); - const subscription = vscode.tasks.onDidEndTask((e) => { - if (e.execution !== running) return; - subscription.dispose(); - resolve(); - }); - return promise; - })); - const execution = await vscode.tasks.executeTask(task); - const ended = vscode.tasks.onDidEndTask((e) => { - if (e.execution !== execution) return; - ended.dispose(); - exit.resolve(undefined); - }); - return { execution, exitCode: exit.promise }; + options: { signal: AbortSignal; onCommand: NonNullable; diagnostics: vscode.DiagnosticCollection }, +): Promise { + const { dist, cwd, argv, env } = taskProcess(project, taskDefinition(project, command, invocation).definition); + const paths = await WorkspacePaths.of([project.root, project.folder.uri.fsPath]); + const found = new Map(); + const report = (severity: "error" | "warning" | "hint", message: string, file: string, line?: number, column?: number) => { + const position = new vscode.Position(Math.max(0, (line ?? 1) - 1), Math.max(0, (column ?? 1) - 1)); + const diagnostic = new vscode.Diagnostic(new vscode.Range(position, position), message.replace(TOOL_PREFIX, ""), severity === "error" + ? vscode.DiagnosticSeverity.Error : severity === "warning" ? vscode.DiagnosticSeverity.Warning : vscode.DiagnosticSeverity.Information); + diagnostic.source = "elide"; + const key = paths.lexical(path.resolve(cwd, file)); + found.set(key, [...found.get(key) ?? [], diagnostic]); + }; + const onCommand = (args: readonly string[], root: string): CommandObserver => { + const observer = options.onCommand(args, root); + // Records carry each diagnostic's location; without them, a text headline's location is on the next line. + let structured = false; + let headline: { severity: "error" | "warning" | "hint"; message: string } | undefined; + return { + record: (record) => { + structured = true; + observer?.record?.(record); + const output = elideRecordOutput(record); + if (output?.kind === "diagnostic" && output.file) report(output.severity, output.message, output.file, output.line, output.column); + }, + line: (line, stderr) => { + observer?.line?.(line, stderr); + if (structured) return; + const location = headline && IN_FILE.exec(line); + if (headline && location) report(headline.severity, headline.message, location[1]!, location[2] ? Number(location[2]) : undefined, location[3] ? Number(location[3]) : undefined); + const parsed = parseElideOutputLine(line); + headline = parsed?.kind === "diagnostic" ? { severity: parsed.severity, message: parsed.message } : undefined; + }, + end: (outcome) => observer?.end(outcome), + }; + }; + let code: number | undefined; + try { + await new ElideCli(dist, cwd).run(argv, { signal: options.signal, onCommand, ...(env ? { env } : {}) }); + code = 0; + } catch (error) { + if (options.signal.aborted) return undefined; + if (!(error instanceof ElideCommandFailedError)) throw error; + code = error.exitCode ?? 1; + } + options.diagnostics.clear(); + for (const [file, diagnostics] of found) options.diagnostics.set(vscode.Uri.file(file), diagnostics); + return code; } interface TaskProcess { @@ -130,69 +156,6 @@ function taskProcess(project: ElideProject, def: ElideTaskDefinition): TaskProce }; } -/** A task terminal running `elide` with piped output, so the extension sees every line the terminal shows. */ -class ObservedElideTerminal implements vscode.Pseudoterminal { - private readonly written = new vscode.EventEmitter(); - readonly onDidWrite = this.written.event; - private readonly closed = new vscode.EventEmitter(); - readonly onDidClose = this.closed.event; - private readonly controller = new AbortController(); - - constructor( - private readonly elide: TaskProcess, - private readonly onCommand: NonNullable, - private readonly settle: (exitCode: number | undefined) => void, - ) {} - - open(): void { - const { dist, cwd, argv, env } = this.elide; - this.written.fire(`> elide ${argv.join(" ")}\r\n`); - void Promise.resolve() - .then(() => new ElideCli(dist, cwd).run(argv, { - signal: this.controller.signal, - onCommand: this.onCommand, - onLine: (line) => this.written.fire(`${withAbsoluteLocation(line, cwd)}\r\n`), - // The terminal is read by a person and the `$elide` matcher, not as records; with piped output, `plain` is - // the mode that still says which task started. - env: { ELIDE_PROGRESS: "plain", ...env }, - })) - .then( - () => 0, - (error: unknown) => { - if (error instanceof ElideCommandFailedError) return error.exitCode ?? 1; - if (!this.controller.signal.aborted) this.written.fire(`${error instanceof Error ? error.message : String(error)}\r\n`); - return 1; - }, - ) - .then((code) => { - this.settle(this.controller.signal.aborted ? undefined : code); - // The terminal parses what was written asynchronously, and the task's problem matcher stops reading when the - // terminal closes: closing right after the last line loses the compiler errors that precede the verdict. - setTimeout(() => this.closed.fire(code), CLOSE_DELAY_MS); - }); - } - - close(): void { - this.controller.abort(new Error("Elide task terminated")); - } -} - -const IN_FILE = /^In file:\s+(?:file:\/\/)?(\S+?)((?::\d+){0,2})\s*$/; -/** How long the terminal stays open after the process exits, for the problem matcher to read the last lines. */ -const CLOSE_DELAY_MS = 500; - -/** - * `line`, with the relative path of an `In file:` location resolved against the task's directory. The `$elide` - * problem matcher finds relative paths by searching the workspace folder, which VS Code does not do for a task it - * does not run as a process itself; an absolute path needs no search. - */ -function withAbsoluteLocation(line: string, cwd: string): string { - const match = IN_FILE.exec(line); - if (!match || path.isAbsolute(match[1]!)) return line; - const file = path.resolve(cwd, match[1]!); - return existsSync(file) ? `In file: ${file}${match[2]}` : line; -} - /** * Exit code of a running task's process, once it ends. `undefined` means no process reported one: the task was * terminated, or it never started. diff --git a/packages/vscode/test/androidDatabaseLifecycle.test.ts b/packages/vscode/test/androidDatabaseLifecycle.test.ts new file mode 100644 index 0000000..6c74145 --- /dev/null +++ b/packages/vscode/test/androidDatabaseLifecycle.test.ts @@ -0,0 +1,135 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +// Separate process: VS Code mocks must not affect the rest of Bun's module registry. +test("database inspection cancels stale discovery, drops late reads and never writes after a closed confirmation", () => { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { mock } from "bun:test"; + import * as core from "@elide/ide-core"; + const source = ${JSON.stringify(source)}; + const commands = new Map(), sessions = [], panels = [], errors = []; + const model = { android: { targets: [] } }, project = { root: "/app", model }; + const devices = [{ serial: "phone", state: "device", ready: true }]; + let changed, discover = async () => [{ packageName: "com.example.app", user: 10 }], confirm = async () => undefined; + let read = async () => ({ columns: ["title"], rows: [["original"]], hasMore: false }); + const disposable = () => ({ dispose() {} }); + mock.module("@elide/ide-core", () => ({ + ...core, adbExecutable: () => process.execPath, + listAndroidDatabasePackages: (_adb, _device, signal) => discover(signal), + AndroidDatabaseSession: class { + disposed = false; writes = 0; cancelled = 0; + constructor(options) { this.options = options; sessions.push(this); } + async databases() { return ["notes.db"]; } + async schema() { return [{ name: "notes", type: "table", sql: "CREATE TABLE notes(title TEXT)" }]; } + async query() { return read(); } + async execute() { this.writes++; return { changes: 1 }; } + dispose() { this.disposed = true; } + cancel() { this.cancelled++; } + }, + })); + mock.module("vscode", () => ({ + workspace: { isTrusted: true }, ViewColumn: { Active: 1 }, QuickPickItemKind: { Separator: -1 }, + Disposable: class { constructor(dispose) { this.dispose = dispose; } }, + commands: { registerCommand: (name, callback) => { commands.set(name, callback); return disposable(); } }, + window: { + showQuickPick: async (items) => items[0], + showErrorMessage: async (message) => { errors.push(message); }, + showWarningMessage: (...args) => confirm(...args), + createWebviewPanel: () => { + let closed, receive; + const panel = { messages: [], disposed: false, visible: true, + webview: { onDidReceiveMessage: callback => { receive = callback; return disposable(); }, postMessage: value => { panel.messages.push(value); } }, + onDidDispose: callback => { closed = callback; return disposable(); }, + send: value => receive(value), + dispose() { if (!this.disposed) { this.disposed = true; closed(); } }, + }; + panels.push(panel); return panel; + }, + }, + })); + mock.module(source + "android.js", () => ({ androidSdkFor: () => "/sdk", attachedDevices: async () => devices, toAndroidTarget: value => value })); + const { registerAndroidDatabase } = await import(source + "androidDatabase.ts"); + const registration = registerAndroidDatabase({ projects: [project], projectAt: () => project, + onDidChange: callback => { changed = callback; return disposable(); } }, { asAbsolutePath: name => name }, () => undefined); + const open = () => commands.get("elide.androidDatabase")({ root: "/app", device: "phone" }); + const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; + + const discovery = Promise.withResolvers(), started = Promise.withResolvers(); let signal; + discover = async value => { signal = value; started.resolve(); return discovery.promise; }; + const pending = open(); await started.promise; + project.model = undefined; changed(); + assert.equal(signal.aborted, true); + project.model = model; changed(); discovery.resolve([{ packageName: "com.example.app", user: 10 }]); await pending; + assert.equal(panels.length, 0, "late discovery must not resurrect a removed project"); + + discover = async () => [{ packageName: "com.example.app", user: 10 }]; + await open(); let panel = panels.at(-1); + assert.equal(sessions.at(-1).options.user, 10, "opening the panel pins the user whose packages were offered, not whoever is current at its first request"); + panel.send({ type: "ready" }); await flush(); + const delayed = Promise.withResolvers(); read = () => delayed.promise; + panel.send({ type: "query", sql: "SELECT * FROM notes" }); await flush(); + const oldSession = sessions.at(-1); + panel.send({ type: "cancel" }); await flush(); + assert.equal(oldSession.cancelled, 1, "cancel stops the helper but keeps the session"); + assert.equal(oldSession.disposed, false); + delayed.resolve({ columns: ["title"], rows: [["stale"]], hasMore: false }); await flush(); + assert.equal(panel.messages.some(message => message.type === "result"), false, "cancelled read cannot repaint stale rows"); + + const answer = Promise.withResolvers(), prompted = Promise.withResolvers(); + confirm = async () => { prompted.resolve(); return answer.promise; }; + panel.send({ type: "execute", sql: "DELETE FROM notes" }); await prompted.promise; + panel.dispose(); answer.resolve("Execute write"); await flush(); + assert.equal(sessions.reduce((total, session) => total + session.writes, 0), 0, "closing the panel revokes a pending write confirmation"); + + await open(); panel = panels.at(-1); panel.send({ type: "ready" }); await flush(); + confirm = async () => undefined; + panel.send({ type: "execute", sql: "DELETE FROM notes" }); await flush(); + assert.equal(sessions.at(-1).writes, 0, "dismissing confirmation must not send SQL"); + confirm = async () => "Execute write"; + panel.send({ type: "execute", sql: "DELETE FROM notes" }); await flush(); + assert.equal(sessions.at(-1).writes, 1); + assert.ok(panel.messages.some(message => message.type === "written" && message.changes === 1)); + + read = async () => ({ columns: ["title"], rows: [["original"]], hasMore: false }); + panel.send({ type: "browse", name: "notes" }); await flush(); + read = async () => { throw new Error("no such column: nope"); }; + panel.send({ type: "query", sql: "SELECT nope FROM notes" }); await flush(); + read = async () => ({ columns: ["title"], rows: [["original"]], hasMore: false }); + panel.messages.length = 0; + panel.send({ type: "refresh" }); await flush(); + assert.equal(panel.messages.some(message => message.type === "result"), false, "refresh after a failed query must not repaint the previous query's rows"); + + panel.send({ type: "browse", name: "notes" }); await flush(); + const cancelledRead = Promise.withResolvers(); + read = () => cancelledRead.promise; + panel.send({ type: "query", sql: "SELECT title FROM notes WHERE title = 'different'" }); await flush(); + panel.send({ type: "cancel" }); await flush(); + cancelledRead.resolve({ columns: ["title"], rows: [["different"]], hasMore: false }); await flush(); + read = async () => ({ columns: ["title"], rows: [["original"]], hasMore: false }); + panel.messages.length = 0; + panel.send({ type: "refresh" }); await flush(); + assert.equal(panel.messages.some(message => message.type === "result"), false, "refresh after cancellation must not repaint an earlier query under the cancelled SQL"); + + const release = { packageName: "com.example.release", user: 10, refusal: "run-as: package not debuggable: com.example.release", notDebuggable: true }; + const opened = panels.length; + discover = async () => [release]; + await open(); + assert.equal(panels.length, opened, "a non-debuggable application is never opened"); + assert.match(errors.splice(0).join("\\n"), /None of the 1 installed applications on phone is debuggable/); + discover = async () => [release, { packageName: "com.example.app", user: 10 }]; + await open(); + assert.equal(panels.length, opened + 1, "debuggable applications are listed first"); + assert.deepEqual(errors, []); + panel = panels.at(-1); + project.model = undefined; changed(); + assert.equal(panel.disposed, true); + assert.equal(sessions.at(-1).disposed, true); + registration.dispose(); + assert.deepEqual(errors, []); + `], { cwd: new URL("..", import.meta.url).pathname, encoding: "utf8", timeout: 20_000 }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); +}); diff --git a/packages/vscode/test/androidDeployment.test.ts b/packages/vscode/test/androidDeployment.test.ts index 940046e..b6ea0d8 100644 --- a/packages/vscode/test/androidDeployment.test.ts +++ b/packages/vscode/test/androidDeployment.test.ts @@ -1,10 +1,15 @@ import { describe, expect, test } from "bun:test"; -import type { AndroidDevice } from "@elide/ide-core"; -import { androidApks, androidAvdToBoot, androidDeviceProblem, preferredAndroidProject, restoredAndroidTarget } from "../src/androidDeploymentState.js"; +import type { AndroidDevice, AndroidDeviceTargets } from "@elide/ide-core"; +import { + androidApks, androidAvdToBoot, androidDeviceProblem, androidEmulatorOwners, androidToolsProject, deployableApks, hasAndroidProjects, + preferredAndroidProject, restoredAndroidTarget, +} from "../src/androidDeploymentState.js"; import type { ElideProject } from "../src/projects.js"; -function project(root: string, kind = "apk"): ElideProject { - return { root, model: { android: { targets: [{ name: "app", kind }] } } } as ElideProject; +const SIGNED: AndroidDeviceTargets = { runnable: true, debuggable: true, instrumented: false }; + +function project(root: string, kind = "apk", device: AndroidDeviceTargets | undefined = kind === "apk" ? SIGNED : undefined): ElideProject { + return { root, model: { android: { targets: [{ name: "app", kind, ...(device ? { device } : {}) }] } } } as ElideProject; } const phone: AndroidDevice = { serial: "phone", state: "device", ready: true, emulator: false }; @@ -45,12 +50,21 @@ describe("Android deployment decisions", () => { const pinned = { serial: "emulator-5554", avd: "pixel" }; expect(androidAvdToBoot([], pinned, "project")).toBe("pixel"); expect(androidAvdToBoot([{ serial: "emulator-5554", state: "offline", ready: false, emulator: true }], pinned, "project")).toBe("pixel"); - expect(androidAvdToBoot([{ serial: "emulator-5554", state: "device", ready: true, emulator: true }], pinned, "project")).toBeUndefined(); + expect(androidAvdToBoot([{ serial: "emulator-5554", state: "device", ready: true, emulator: true }], pinned, "project", "pixel")).toBeUndefined(); expect(androidAvdToBoot([], { serial: "emulator-5556" }, "project")).toBe("project"); expect(androidAvdToBoot([phone], { serial: "old" }, "project")).toBeUndefined(); expect(androidAvdToBoot([{ serial: "old", state: "unauthorized", ready: false, emulator: false }], { serial: "old" }, "project")).toBeUndefined(); }); + test("a reused emulator serial cannot substitute another AVD for the selected one", () => { + const ready: AndroidDevice = { serial: "emulator-5554", state: "device", ready: true, emulator: true }; + const pinned = { serial: ready.serial, avd: "selected" }; + expect(androidAvdToBoot([ready], pinned, "project", "other")).toBe("selected"); + expect(androidAvdToBoot([ready], pinned, "project", undefined)).toBe("selected"); + expect(androidAvdToBoot([ready], pinned, "project", "selected")).toBeUndefined(); + expect(androidAvdToBoot([ready], { serial: ready.serial }, "project")).toBeUndefined(); + }); + test("APKs are re-read from the live model, not restored artifact names or library outputs", () => { const app = project("/app"); expect(androidApks(app).some((apk) => apk.name === "app")).toBe(true); @@ -76,7 +90,60 @@ describe("Android deployment decisions", () => { expect(preferredAndroidProject([app, library], undefined, library.root)).toBeUndefined(); }); + test("an APK is deployed only for what Elide lists a target for", () => { + const unsigned = project("/unsigned", "apk", { runnable: false, debuggable: false, instrumented: false }); + expect(androidApks(unsigned).map((apk) => apk.name)).toEqual(["app"]); + expect(deployableApks(unsigned)).toEqual([]); + expect(deployableApks(unsigned, "run")).toEqual([]); + const release = project("/release", "apk", { runnable: true, debuggable: false, instrumented: false }); + expect(deployableApks(release, "run").map((apk) => apk.name)).toEqual(["app"]); + expect(deployableApks(release, "debug")).toEqual([]); + const tests = project("/tests", "apk", { runnable: false, debuggable: false, instrumented: true }); + expect(deployableApks(tests).map((apk) => apk.name)).toEqual(["app"]); + expect(deployableApks(tests, "test").map((apk) => apk.name)).toEqual(["app"]); + // A model synced before Elide listed device targets offers none. + expect(deployableApks({ root: "/old", model: { android: { targets: [{ name: "app", kind: "apk" }] } } } as ElideProject)).toEqual([]); + }); + + test("an active app that cannot do what was asked does not outrank the remembered one that can", () => { + const release = project("/release", "apk", { runnable: true, debuggable: false, instrumented: false }); + const debug = project("/debug"); + expect(preferredAndroidProject([release, debug], release, debug.root, "debug")).toBe(debug); + expect(preferredAndroidProject([release, debug], release, debug.root, "run")).toBe(release); + expect(preferredAndroidProject([release], release, undefined, "debug")).toBeUndefined(); + }); + test("removed remembered projects require a new choice, not the first unrelated app", () => { expect(preferredAndroidProject([project("/other")], undefined, "/removed")).toBeUndefined(); }); }); + +describe("Android UI gating", () => { + const jvm = { root: "/jvm", model: { name: "jvm" } } as unknown as ElideProject; + const unsynced = { root: "/new" } as ElideProject; + const withAvd = (root: string, kind: string, avd: string) => + ({ root, model: { android: { avd, targets: [{ name: root.slice(1), kind, device: SIGNED }] } } }) as unknown as ElideProject; + + test("Android commands show only once a synced project declares an android block, library or application", () => { + expect(hasAndroidProjects([])).toBe(false); + expect(hasAndroidProjects([jvm, unsynced])).toBe(false); + expect(hasAndroidProjects([jvm, project("/lib", "aar")])).toBe(true); + }); + + test("a workspace tree's devices and tools act for its first application, else its first Android project", () => { + const library = project("/model", "aar"); + const app = project("/app"); + expect(androidToolsProject([jvm, library, app])).toBe(app); + expect(androidToolsProject([jvm, library])).toBe(library); + expect(androidToolsProject([jvm, unsynced])).toBeUndefined(); + }); + + test("emulator rows belong to applications, one per AVD, never to a library", () => { + const libraries = [withAvd("/model", "aar", "model"), withAvd("/ui", "aar", "ui")]; + const dev = withAvd("/dev", "apk", "pixel"); + const tests = withAvd("/tests", "apk", "pixel"); + const tablet = withAvd("/tablet", "apk", "tablet"); + expect(androidEmulatorOwners(libraries)).toEqual([]); + expect(androidEmulatorOwners([...libraries, dev, tests, tablet])).toEqual([{ avd: "pixel", project: dev }, { avd: "tablet", project: tablet }]); + }); +}); diff --git a/packages/vscode/test/androidDeploymentCommands.test.ts b/packages/vscode/test/androidDeploymentCommands.test.ts new file mode 100644 index 0000000..317ec1c --- /dev/null +++ b/packages/vscode/test/androidDeploymentCommands.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +// Module mocks live in a separate process so other extension unit tests see their real dependencies. +function run(script: string): { status: number | null; stderr: string } { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { mock } from "bun:test"; + const source = ${JSON.stringify(source)}; + ${script} + `], { cwd: new URL("..", import.meta.url).pathname, encoding: "utf8", timeout: 20_000 }); + expect(result.error).toBeUndefined(); + return { status: result.status, stderr: result.stderr }; +} + +const APK = "{ name: 'app', kind: 'apk', device: { runnable: true, debuggable: true, instrumented: false } }"; + +test("Run on the project emulator boots it headless when the emulator window is off, and Debug names a root project", () => { + expect(run(` + const started = [], tasks = []; + mock.module("vscode", () => ({ + workspace: { saveAll: async () => true }, + debug: { startDebugging: async (folder, config) => { started.push(config); return true; } }, + })); + mock.module(source + "config.js", () => ({ readConfig: () => ({ androidEmulatorWindow: false }) })); + mock.module(source + "tasks.js", () => ({ + executeElideTask() {}, taskExitCode() {}, + executeReportedElideTask: async (_workspace, _project, command, invocation) => { tasks.push({ command, invocation }); }, + })); + mock.module(source + "androidDebug.js", () => ({ ELIDE_ANDROID_DEBUG_TYPE: "elide-android" })); + // Loaded only once the VS Code mock is installed. + const { debugAndroid, runAndroid } = await import(source + "android.ts"); + const project = { root: "/work/app", folder: { uri: { fsPath: "/work/app" } }, model: { android: { avd: "project", targets: [${APK}] } } }; + const workspace = { projects: [project] }; + const deployments = (destination) => ({ resolve: async () => ({ root: project.root, artifact: "app", destination, project }) }); + + await runAndroid(workspace, {}, deployments({ kind: "emulator" }), undefined); + assert.deepEqual(tasks, [{ command: "build", invocation: { args: ["android-run-emulator-app"], options: { "no-window": true } } }]); + + // The project at the folder root is named too, so the configuration cannot pick a member declaring the same APK. + await debugAndroid(workspace, deployments({ kind: "device", serial: "phone" }), undefined); + assert.deepEqual(started, [{ type: "elide-android", request: "launch", name: "Elide: Debug app", artifact: "app", device: "phone", project: "." }]); + `)).toEqual({ status: 0, stderr: "" }); +}); + +test("an elide-android configuration naming an APK no synced project declares says so without opening a picker", () => { + expect(run(` + const errors = []; + mock.module("vscode", () => ({ + workspace: { workspaceFolders: [] }, + window: { + showErrorMessage: async (message) => { errors.push(message); }, + showQuickPick: async () => { throw new Error("no project to pick from"); }, + }, + })); + mock.module(source + "config.js", () => ({ configuredInvocation: () => ({}), readConfig: () => ({}) })); + mock.module(source + "debug.js", () => ({ launchWithJdwp() {}, resolveAttachType: async () => "java", warnSymlinkedFolder() {} })); + mock.module(source + "jetbrains.js", () => ({ isJetBrainsDebugger: () => false })); + // Loaded only once the VS Code mock is installed. + const { ElideAndroidDebugConfigurationProvider } = await import(source + "androidDebug.ts"); + const folder = { uri: { fsPath: "/work" } }; + const app = { root: "/work/app", folder, model: { android: { targets: [${APK}] } } }; + const workspace = { projectsIn: () => [app], projectAt: () => app }; + const provider = new ElideAndroidDebugConfigurationProvider(workspace, { log() {} }, new Map(), { target: undefined }); + const config = { type: "elide-android", request: "launch", name: "Debug", artifact: "ap" }; + assert.equal(await provider.resolveDebugConfigurationWithSubstitutedVariables(folder, config), undefined); + assert.deepEqual(errors, ["Elide: 'ap' is not an Android APK in any synced project in this folder."]); + `)).toEqual({ status: 0, stderr: "" }); +}); diff --git a/packages/vscode/test/androidDeploymentLifecycle.test.ts b/packages/vscode/test/androidDeploymentLifecycle.test.ts new file mode 100644 index 0000000..6f45766 --- /dev/null +++ b/packages/vscode/test/androidDeploymentLifecycle.test.ts @@ -0,0 +1,285 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +// Isolate VS Code module mocks from the pure unit tests in Bun's shared module registry. +test("Android deployment preserves emulator identity and respects cancellation during adb replies", () => { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { mock } from "bun:test"; + import { setTimeout as sleep } from "node:timers/promises"; + const source = ${JSON.stringify(source)}; + const token = { isCancellationRequested: false }; + const errors = []; + // A notification the user leaves open: its promise only settles once it is dismissed. + let leaveOpen = false; + const progress = []; + const device = { serial: "emulator-5554", state: "device", ready: true, emulator: true }; + const project = { root: "/app", folder: {}, model: { android: { avd: "project", targets: [{ name: "app", kind: "apk", device: { runnable: true, debuggable: true, instrumented: false } }] } } }; + const workspace = { projects: [project], projectAt: (root) => root === project.root ? project : undefined, onDidChange: () => ({ dispose() {} }) }; + const writes = []; + const state = { get() {}, async update(_key, value) { writes.push(value); } }; + mock.module("vscode", () => ({ + StatusBarAlignment: { Left: 1 }, ProgressLocation: { Notification: 1 }, + window: { + createStatusBarItem: () => ({ hide() {}, show() {}, dispose() {} }), + showErrorMessage: (message) => { errors.push(message); return leaveOpen ? Promise.withResolvers().promise : Promise.resolve(undefined); }, + showWarningMessage: async (message) => { errors.push(message); }, + withProgress: (_options, run) => { const shown = run({}, token); progress.push(shown); return shown; }, + }, + })); + mock.module(source + "android.js", () => ({ + androidSdkFor: () => "/sdk", attachedDevices: async () => [device], toAndroidTarget: (value) => value, uninstallAndroidApp() {}, + })); + mock.module(source + "config.js", () => ({ readConfig: () => ({ androidEmulatorWindow: true }) })); + mock.module(source + "tasks.js", () => ({ executeElideTask() {}, taskExitCode() {} })); + // Dynamic loading is intentional: the mocked VS Code runtime must exist before the module loads. + const { AndroidDeploymentController } = await import(source + "androidDeployment.ts"); + const make = () => new AndroidDeploymentController(workspace, state); + + // Explicit serial invocations record identity too, not just devices picked through Quick Pick. + const selected = make(); + selected.avdOf = async () => "selected"; + const deployment = await selected.resolve({ root: "/app", artifact: "app", device: device.serial }); + assert.equal(deployment.destination.avd, "selected"); + // A different AVD now owns that port: find/boot the original AVD, never install on the reused port. + selected.avdOf = async () => "other"; + selected.bootAvd = async (_project, avd) => { assert.equal(avd, "selected"); return "emulator-5556"; }; + assert.equal(await selected.readyDevice(deployment), "emulator-5556"); + assert.deepEqual(writes.at(-1).destination, { kind: "device", serial: "emulator-5556", avd: "selected" }); + selected.dispose(); + + // Cancellation while the final adb discovery is pending must not return a deployable serial. + const onEmulator = { root: "/app", artifact: "app", destination: { kind: "emulator" } }; + const cancelled = make(); + cancelled.selected = onEmulator; + let discoveries = 0; + cancelled.findEmulator = async () => { + if (++discoveries === 1) return undefined; + token.isCancellationRequested = true; + return device; + }; + cancelled.bootThroughElide = async () => true; + assert.equal(await cancelled.readyDevice({ ...onEmulator, project }), undefined); + assert.equal(errors.length, 0, "deliberate cancellation is not a startup failure"); + cancelled.dispose(); + + // SDK polling must recheck cancellation after sys.boot_completed resolves. + token.isCancellationRequested = false; + const sdk = make(); + sdk.findEmulator = async () => device; + sdk.bootCompleted = async () => { token.isCancellationRequested = true; return true; }; + assert.equal(await sdk.bootThroughSdk(project, "/sdk", "other", true, true, token), false); + sdk.dispose(); + + // Disposal while checking an already booted AVD must not resurrect a deployment. + const disposed = make(); + disposed.selected = onEmulator; + disposed.findEmulator = async () => device; + disposed.bootCompleted = async () => { disposed.dispose(); return true; }; + assert.equal(await disposed.readyDevice({ ...onEmulator, project }), undefined); + + // A failed startup closes its progress and ends the deployment while the user still has the error open. + token.isCancellationRequested = false; + leaveOpen = true; + progress.length = 0; + const failed = make(); + failed.selected = onEmulator; + failed.findEmulator = async () => undefined; + failed.bootThroughElide = async () => false; + const settled = await Promise.race([failed.readyDevice({ ...onEmulator, project }), sleep(1_000, "pending", { ref: false })]); + assert.equal(settled, undefined, "the deployment ends without waiting for the notification"); + assert.equal(progress.length, 1); + assert.equal(await Promise.race([progress[0], sleep(0, "pending")]), undefined, "the startup progress closed"); + assert.match(errors.at(-1), /startup failed or exceeded two minutes/); + failed.dispose(); + `], { encoding: "utf8", timeout: 20_000 }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); +}); + +test("Android deployment ignores superseded replies and removed models, never a dismissed picker or an equal model", () => { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { mock } from "bun:test"; + const source = ${JSON.stringify(source)}; + const devices = [ + { serial: "first", state: "device", ready: true, emulator: false }, + { serial: "second", state: "device", ready: true, emulator: false }, + ]; + let pick; + // The modal confirmation's answer, and every warning shown. + let confirmation; + const warnings = [], uninstalled = []; + mock.module("vscode", () => ({ + StatusBarAlignment: { Left: 1 }, + window: { + createStatusBarItem: () => ({ hide() {}, show() {}, dispose() {} }), + showErrorMessage: async () => undefined, + showWarningMessage: async (message, options) => { warnings.push({ message, options }); return confirmation; }, + showQuickPick: (...args) => pick(...args), + }, + })); + mock.module(source + "android.js", () => ({ + androidSdkFor: () => "/sdk", attachedDevices: async () => devices, toAndroidTarget: (value) => value, + uninstallAndroidApp: async (project, serial, application) => { uninstalled.push([project.root, serial, application]); }, + })); + mock.module(source + "config.js", () => ({ readConfig: () => ({ androidEmulatorWindow: true }) })); + mock.module(source + "tasks.js", () => ({ executeElideTask() {}, taskExitCode() {} })); + // Load only after installing the VS Code module mocks. + const { AndroidDeploymentController } = await import(source + "androidDeployment.ts"); + const make = () => { + const project = { root: "/app", folder: {}, model: { android: { avd: "project", targets: [{ name: "app", kind: "apk", device: { runnable: true, debuggable: true, instrumented: false } }] } } }; + const workspace = { projects: [project], projectAt: (root) => workspace.projects.find((candidate) => candidate.root === root), onDidChange: () => ({ dispose() {} }) }; + const writes = []; + const state = { get() {}, async update(_key, value) { writes.push(value); } }; + return { project, workspace, writes, state, controller: new AndroidDeploymentController(workspace, state) }; + }; + const target = (device) => ({ root: "/app", artifact: "app", ...(device ? { device } : {}) }); + + const discovery = make(); + const reply = Promise.withResolvers(); + let calls = 0; + discovery.controller.devices = async () => ++calls === 1 ? reply.promise : devices; + const older = discovery.controller.resolve(target("first")); + const newer = await discovery.controller.resolve(target("second")); + reply.resolve(devices); + assert.equal(await older, undefined); + assert.equal(newer.destination.serial, "second"); + assert.equal(discovery.controller.target.destination.serial, "second"); + assert.equal(discovery.writes.length, 1); + discovery.controller.dispose(); + + const picker = make(); + const answer = Promise.withResolvers(), opened = Promise.withResolvers(); + pick = (items) => { opened.resolve(items); return answer.promise; }; + const choosing = picker.controller.resolve(target()); + const items = await opened.promise; + await picker.controller.resolve(target("second")); + answer.resolve(items.find((item) => item.destination.serial === "first")); + assert.equal(await choosing, undefined); + assert.equal(picker.controller.target.destination.serial, "second"); + picker.controller.dispose(); + + const storage = make(); + const saved = Promise.withResolvers(), saving = Promise.withResolvers(); + storage.state.update = async (_key, value) => { + storage.writes.push(value); + if (value.destination.serial === "first") { saving.resolve(); await saved.promise; } + }; + const earlierSave = storage.controller.resolve(target("first")); + await saving.promise; + await storage.controller.resolve(target("second")); + saved.resolve(); + assert.equal(await earlierSave, undefined); + assert.equal(storage.controller.target.destination.serial, "second", "a slow Memento completion cannot restore an old target"); + storage.controller.dispose(); + + const removed = make(); + removed.controller.devices = async () => { removed.project.model = undefined; return devices; }; + assert.equal(await removed.controller.resolve(target("first")), undefined); + assert.equal(removed.writes.length, 0); + removed.controller.dispose(); + + const booting = make(); + const emulator = { serial: "emulator-5554", state: "device", ready: true, emulator: true }; + booting.controller.devices = async () => [...devices, emulator]; + booting.controller.avdOf = async () => "selected"; + const deployment = await booting.controller.resolve(target(emulator.serial)); + booting.controller.avdOf = async () => "other"; + const boot = Promise.withResolvers(), bootStarted = Promise.withResolvers(); + booting.controller.bootAvd = async () => { bootStarted.resolve(); return boot.promise; }; + const ready = booting.controller.readyDevice(deployment); + await bootStarted.promise; + await booting.controller.resolve(target("second")); + boot.resolve("emulator-5556"); + assert.equal(await ready, undefined, "a completed old boot cannot launch or replace a newer target"); + assert.equal(booting.controller.target.destination.serial, "second"); + assert.match(warnings.at(-1).message, /target changed/, "the user learns why nothing was deployed"); + booting.controller.dispose(); + + // While the emulator boots, a target picker the user dismisses and a sync that rebuilds an equal model (in + // place for a root project, as a new project for a workspace member) leave the deployment wanted. + const kept = make(); + kept.controller.devices = async () => [...devices, emulator]; + kept.controller.avdOf = async () => "selected"; + const pinned = await kept.controller.resolve(target(emulator.serial)); + kept.controller.avdOf = async () => "other"; + const keptBoot = Promise.withResolvers(), keptStarted = Promise.withResolvers(); + kept.controller.bootAvd = async () => { keptStarted.resolve(); return keptBoot.promise; }; + const keptReady = kept.controller.readyDevice(pinned); + await keptStarted.promise; + pick = async () => undefined; + await kept.controller.select(); + kept.project.model = structuredClone(kept.project.model); + kept.workspace.projects = [{ ...kept.project, model: structuredClone(kept.project.model) }]; + warnings.length = 0; + keptBoot.resolve("emulator-5556"); + assert.equal(await keptReady, "emulator-5556"); + assert.deepEqual(kept.controller.target.destination, { kind: "device", serial: "emulator-5556", avd: "selected" }); + assert.deepEqual(warnings, []); + kept.controller.dispose(); + + // A device row carries its tree's tools owner, the first app: Run deploys the tree's selected app on that device. + const app = (root) => ({ root, workspaceRoot: "/ws", folder: {}, model: { android: { avd: "project", targets: [{ name: "app", kind: "apk", device: { runnable: true, debuggable: true, instrumented: false } }] } } }); + const mobile = app("/ws/mobile"), wear = app("/ws/wear"); + const tree = { + projects: [{ root: "/ws", folder: {}, model: { members: [mobile.root, wear.root] } }, mobile, wear], + projectAt: (root) => tree.projects.find((candidate) => candidate.root === root), + membersOf: (root) => root === "/ws" ? [mobile, wear] : [], + onDidChange: () => ({ dispose() {} }), + }; + const rows = new AndroidDeploymentController(tree, { get: () => ({ root: wear.root, artifact: "app", destination: { kind: "device", serial: "first" } }), async update() {} }); + rows.devices = async () => devices; + const onRow = await rows.resolve({ root: mobile.root, device: "second" }, "run"); + assert.equal(onRow.root, wear.root); + assert.deepEqual(onRow.destination, { kind: "device", serial: "second" }); + assert.equal(rows.target.root, wear.root, "the selection keeps its app"); + rows.dispose(); + + // Uninstall and Retry uninstalls nothing until the user confirms, naming the device, that the app's data goes. + const removal = make(); + removal.controller.devices = async () => devices; + const onFirst = await removal.controller.resolve(target("first")); + confirmation = undefined; + assert.equal(await removal.controller.uninstall(onFirst, "sample.app", "first"), false); + assert.deepEqual(uninstalled, [], "declining the confirmation runs no adb"); + const { message, options } = warnings.at(-1); + assert.match(message + options.detail, /first/); + assert.match(options.detail, /data/); + assert.equal(options.modal, true); + confirmation = "Uninstall"; + assert.equal(await removal.controller.uninstall(onFirst, "sample.app", "first"), true); + assert.deepEqual(uninstalled, [["/app", "first", "sample.app"]]); + removal.controller.dispose(); + + // A reboot may recycle the serial while the destructive confirmation is open. It must not erase another AVD. + const identityRemoval = make(); + identityRemoval.controller.devices = async () => [emulator]; + let identity = "selected"; + identityRemoval.controller.avdOf = async () => identity; + const onSelected = await identityRemoval.controller.resolve(target(emulator.serial)); + const erase = Promise.withResolvers(); + confirmation = erase.promise; + const erasing = identityRemoval.controller.uninstall(onSelected, "sample.app", emulator.serial); + identity = "other"; + erase.resolve("Uninstall"); + assert.equal(await erasing, false, "a confirmation cannot authorize another AVD at the same serial"); + assert.equal(uninstalled.length, 1); + + // An unanswered identity probe is not proof that this is the authorized emulator either. + identity = undefined; + confirmation = "Uninstall"; + assert.equal(await identityRemoval.controller.uninstall(onSelected, "sample.app", emulator.serial), false); + assert.equal(uninstalled.length, 1); + identity = "selected"; + assert.equal(await identityRemoval.controller.uninstall(onSelected, "sample.app", emulator.serial), true); + assert.deepEqual(uninstalled.at(-1), ["/app", emulator.serial, "sample.app"]); + identityRemoval.controller.dispose(); + `], { cwd: new URL("..", import.meta.url).pathname, encoding: "utf8", timeout: 20_000 }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); +}); diff --git a/packages/vscode/test/androidFailures.test.ts b/packages/vscode/test/androidFailures.test.ts new file mode 100644 index 0000000..48eca46 --- /dev/null +++ b/packages/vscode/test/androidFailures.test.ts @@ -0,0 +1,212 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +/** What `elide build android-run-app` printed for a sample whose debug keystore is missing (Elide 1.6.1+5e6565617). */ +const KEYSTORE_FAILURE = [ + "[16.3s] ✓ [app] Assembled 'app' from 193 entries (620ms)", + "[16.4s] error: Keystore /work/app/.dev/crypto/debug.keystore not found", + "[16.4s] hint: Create one with: elide build android-keystore-app", + "[16.4s] ✗ [app] android-sign-app (4ms)", + "[16.5s] ✗ Build failed in 16.5s", +]; + +// Module mocks live in a separate process so other extension unit tests see their real dependencies. +function run(script: string): { status: number | null; stderr: string } { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; + import os from "node:os"; + import path from "node:path"; + import { setImmediate as immediate } from "node:timers/promises"; + import { mock } from "bun:test"; + const source = ${JSON.stringify(source)}; + const KEYSTORE_FAILURE = ${JSON.stringify(KEYSTORE_FAILURE)}; + const notices = [], commands = [], shown = []; + let pick; + class EventEmitter { + listeners = []; + event = (listener) => { this.listeners.push(listener); return { dispose() {} }; }; + fire(value) { for (const listener of this.listeners) listener(value); } + } + const terminals = [{ name: "Task - build android-run-app", show: () => shown.push("task terminal") }]; + mock.module("vscode", () => ({ + EventEmitter, + CustomExecution: class { constructor(callback) { this.callback = callback; } }, + ProcessExecution: class {}, + Task: class { constructor(definition, scope, name, source, execution, matchers) { Object.assign(this, { definition, scope, name, source, execution, matchers }); } }, + TaskRevealKind: { Always: 1 }, + TaskPanelKind: { Shared: 1 }, + Uri: { file: (fsPath) => ({ fsPath }) }, + tasks: { executeTask: async (task) => task }, + commands: { executeCommand: async (...args) => { commands.push(args); } }, + workspace: { workspaceFolders: [{}] }, + window: { + terminals, + createTerminal: ({ pty }) => ({ show: () => { shown.push("debug terminal"); pty.open(); } }), + showErrorMessage: async (message, ...actions) => { notices.push({ level: "error", message, actions }); return pick; }, + showWarningMessage: async (message, ...actions) => { notices.push({ level: "warning", message, actions }); return pick; }, + }, + debug: { onDidStartDebugSession: () => ({ dispose() {} }) }, + })); + mock.module(source + "config.js", () => ({ configuredInvocation: () => ({}), readConfig: () => ({ home: globalThis.elideHome }) })); + mock.module(source + "jetbrains.js", () => ({ isJetBrainsDebugger: (type) => type === "intellij_jvm", jetBrainsDebuggerType() {}, jetBrainsExtension() {}, notifyKotlinSetupPending() {}, pendingKotlinSetup: async () => undefined })); + const ui = { lines: [], log(line) { this.lines.push(line); } }; + const dirs = []; + const scratch = (prefix) => { const dir = realpathSync(mkdtempSync(path.join(os.tmpdir(), prefix))); dirs.push(dir); return dir; }; + /** A distribution whose \`elide\` prints \`lines\` on stderr, the environment's progress mode on stdout, and exits 1. */ + const fakeElide = (lines) => { + const home = scratch("elide-home-"); + mkdirSync(path.join(home, "bin")); + writeFileSync(path.join(home, "bin", "elide"), "#!/bin/sh\\n" + lines.map((line) => "echo '" + line + "' >&2").join("\\n") + "\\necho \\"progress=$ELIDE_PROGRESS\\"\\nexit 1\\n"); + chmodSync(path.join(home, "bin", "elide"), 0o755); + return home; + }; + try { + ${script} + } finally { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + } + `], { cwd: new URL("..", import.meta.url).pathname, encoding: "utf8", timeout: 20_000 }); + expect(result.error).toBeUndefined(); + return { status: result.status, stderr: result.stderr }; +} + +test("a failed Android task notifies with Elide's error and hint, and offers the keystore it names", () => { + expect(run(` + globalThis.elideHome = fakeElide(KEYSTORE_FAILURE); + const root = scratch("elide-app-"); + const project = { root, manifestPath: path.join(root, "elide.pkl"), folder: { uri: { fsPath: root } }, + model: { android: { targets: [{ name: "app", kind: "apk" }] } } }; + const workspace = { projects: [project] }; + // Loaded only once the VS Code mock is installed. + const { executeAndroidTask } = await import(source + "android.ts"); + const task = await executeAndroidTask(workspace, ui, project, { args: ["android-run-app"], options: { device: "emulator-5554" } }); + assert.equal(task.name, "build android-run-app"); + assert.deepEqual(task.definition, { type: "elide", command: "build", args: ["android-run-app"], options: { device: "emulator-5554" } }); + assert.deepEqual(task.matchers, ["$elide"], "the problem matcher still reads the terminal"); + const pty = await task.execution.callback(); + const written = []; + const closed = Promise.withResolvers(); + pty.onDidWrite((text) => written.push(text)); + pty.onDidClose((code) => closed.resolve(code)); + pick = "Create Android Signing Keystore"; + pty.open(); + assert.equal(await closed.promise, 1, "the task ends with Elide's exit code"); + await immediate(); + const output = written.join(""); + assert.match(output, /\\$ elide build android-run-app --device=emulator-5554/); + assert.match(output, /error: Keystore \\/work\\/app\\/\\.dev\\/crypto\\/debug\\.keystore not found\\r\\n/); + assert.match(output, /progress=plain/, "the terminal shows Elide's plain progress"); + const message = "Elide: Keystore /work/app/.dev/crypto/debug.keystore not found — hint: Create one with: elide build android-keystore-app"; + assert.deepEqual(notices, [{ level: "error", message, actions: ["Create Android Signing Keystore", "Show Terminal"] }]); + assert.deepEqual(ui.lines, [message], "the Elide output channel gets the same line"); + assert.deepEqual(commands, [["elide.androidCreateKeystore", { root, artifact: "app" }]]); + + // Show Terminal reveals the task's own terminal, which VS Code titles with the task's name. + pick = "Show Terminal"; + const again = await (await executeAndroidTask(workspace, ui, project, { args: ["android-run-app"] })).execution.callback(); + const ended = Promise.withResolvers(); + again.onDidClose(() => ended.resolve()); + again.open(); + await ended.promise; + await immediate(); + assert.deepEqual(shown, ["task terminal"]); + `)).toEqual({ status: 0, stderr: "" }); +}); + +test("a Run whose install Android refuses for another signing key offers to uninstall that app and run again", () => { + expect(run(` + globalThis.elideHome = fakeElide([ + "error: adb failed with exit code 1: adb: failed to install /work/app/.dev/artifacts/app.apk: Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: Existing package sample.app signatures do not match newer version; ignoring!]", + ]); + const root = scratch("elide-app-"); + const project = { root, manifestPath: path.join(root, "elide.pkl"), folder: { uri: { fsPath: root } }, + model: { android: { targets: [{ name: "app", kind: "apk" }] } } }; + // Loaded only once the VS Code mock is installed. + const { executeAndroidTask } = await import(source + "android.ts"); + const { androidFailureActions } = await import(source + "failures.ts"); + const reinstalled = []; + const task = await executeAndroidTask({ projects: [project] }, ui, project, { args: ["android-run-app"] }, async (application) => { reinstalled.push(application); }); + const pty = await task.execution.callback(); + const closed = Promise.withResolvers(); + pty.onDidClose(() => closed.resolve()); + pick = "Uninstall and Retry"; + pty.open(); + await closed.promise; + await immediate(); + assert.deepEqual(notices.at(-1).actions, ["Uninstall and Retry", "Show Terminal"]); + assert.deepEqual(reinstalled, ["sample.app"], "the action uninstalls the application the failure names"); + assert.deepEqual(commands, []); + + // Without a way to run again, as for a build that installs nothing, there is nothing to offer. + assert.deepEqual(androidFailureActions([project], { errors: 1, error: "Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: Package sample.app signatures do not match]" }), []); + `)).toEqual({ status: 0, stderr: "" }); +}); + +test("a debug run that fails before JDWP names Elide's error, reveals its terminal and offers the fix", () => { + expect(run(` + globalThis.elideHome = fakeElide([]); + // Loaded only once the VS Code mock is installed. + const { launchWithJdwp, onDidEndDebugRun } = await import(source + "debug.ts"); + let ended = 0; + onDidEndDebugRun(() => ended++); + const { androidFailureActions } = await import(source + "failures.ts"); + const projects = [{ root: "/work/app", model: { android: { targets: [{ name: "bundle", kind: "aab" }, { name: "app", kind: "apk" }] } } }]; + const lines = [ + "error: app.acme.android.dev is not debuggable", + 'hint: Set android:debuggable="true" on in its manifest', + ]; + const dist = path.join(fakeElide(lines), "bin", "elide"); + const exited = (failureActions, binary = dist) => { + const session = launchWithJdwp({ dist: binary, argv: ["build", "android-debug-dev"], cwd: os.tmpdir(), name: "Elide: Debug dev", folder: { uri: { fsPath: os.tmpdir() } }, + attachType: "java", ...(failureActions ? { failureActions } : {}) }, ui, new Map()); + return session.exit; + }; + pick = "Show Terminal"; + shown.length = 0; + assert.equal(await exited((failure) => androidFailureActions(projects, failure)), 1); + await immediate(); + assert.deepEqual(notices.at(-1), { level: "error", + message: 'Elide: app.acme.android.dev is not debuggable — hint: Set android:debuggable="true" on in its manifest', + actions: ["Show Terminal"] }); + assert.deepEqual(shown, ["debug terminal", "debug terminal"], "Show Terminal reveals the debug run's terminal"); + assert.equal(ended, 1, "the end of a debug run refreshes what the tree and code lens offer"); + + // Nothing recognizable printed: the exit code is all there is. + pick = undefined; + assert.equal(await exited(undefined, path.join(fakeElide([]), "bin", "elide")), 1); + await immediate(); + assert.equal(notices.at(-1).message, "Elide: the debug run exited with code 1 before opening a JDWP port."); + + // The keystore hint offers the keystore of whichever signed artifact it names, an app bundle's too. + assert.deepEqual(androidFailureActions(projects, { errors: 1, error: "Keystore x not found", hint: "Create one with: elide build android-keystore-bundle" }), + [{ label: "Create Android Signing Keystore", command: "elide.androidCreateKeystore", arguments: [{ root: "/work/app", artifact: "bundle" }] }]); + assert.deepEqual(androidFailureActions(projects, { errors: 1, hint: "Create one with: elide build android-keystore-missing" }), []); + assert.deepEqual(androidFailureActions(projects, { errors: 1, hint: "Set android:debuggable" }), []); + `)).toEqual({ status: 0, stderr: "" }); +}); + +test("a JetBrains debug launch from a folder opened through a symlink warns and offers its real path", () => { + expect(run(` + const real = scratch("elide-real-"); + const link = path.join(scratch("elide-link-"), "app"); + symlinkSync(real, link); + const { symlinkedFolderTarget, warnSymlinkedFolder } = await import(source + "debug.ts"); + assert.equal(symlinkedFolderTarget({ uri: { fsPath: link } }), real); + assert.equal(symlinkedFolderTarget({ uri: { fsPath: real } }), undefined); + assert.equal(symlinkedFolderTarget({ uri: { fsPath: path.join(real, "missing") } }), undefined); + // Debugger for Java is not known to mind; only the JetBrains debugger is warned about. + warnSymlinkedFolder({ uri: { fsPath: link } }, "java"); + assert.equal(notices.length, 0); + pick = "Reopen Folder at Real Path"; + warnSymlinkedFolder({ uri: { fsPath: link } }, "intellij_jvm"); + await immediate(); + assert.equal(notices.length, 1); + assert.equal(notices[0].level, "warning"); + assert.ok(notices[0].message.includes(real) && notices[0].message.includes("breakpoints"), notices[0].message); + assert.deepEqual(commands, [["vscode.openFolder", { fsPath: real }, { forceNewWindow: false }]]); + warnSymlinkedFolder({ uri: { fsPath: real } }, "intellij_jvm"); + assert.equal(notices.length, 1, "a folder opened at its real path is not warned about"); + `)).toEqual({ status: 0, stderr: "" }); +}); diff --git a/packages/vscode/test/androidLogcatLifecycle.test.ts b/packages/vscode/test/androidLogcatLifecycle.test.ts new file mode 100644 index 0000000..80ebe94 --- /dev/null +++ b/packages/vscode/test/androidLogcatLifecycle.test.ts @@ -0,0 +1,191 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +// Keep VS Code module mocks out of Bun's shared registry for the other extension tests. +test("Logcat cancels discovery and stale filters when the Android model or stream disappears", () => { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { mock } from "bun:test"; + import * as core from "@elide/ide-core"; + const source = ${JSON.stringify(source)}; + const commands = new Map(), streams = [], outputs = [], errors = []; + const model = { android: { targets: [] } }; + const project = { root: "/app", model }; + const devices = [{ serial: "phone", state: "device", ready: true, emulator: false }]; + let discover = async () => devices; + let input = async () => ""; + let changed; + mock.module("@elide/ide-core", () => ({ + ...core, + adbExecutable: () => process.execPath, + listAndroidDevices: (_sdk, signal) => discover(signal), + AndroidLogcatSession: class { + active = false; + disposed = false; + starts = 0; + constructor() { streams.push(this); } + async start(options) { + if (this.disposed) throw new Error("The logcat session has been disposed."); + this.starts++; + this.device = options.device; + this.active = true; + } + stop() { this.active = false; } + dispose() { this.disposed = true; this.stop(); } + }, + })); + mock.module("vscode", () => ({ + workspace: { isTrusted: true }, + Disposable: class { constructor(dispose) { this.dispose = dispose; } }, + window: { + createOutputChannel: () => { + const output = { disposed: false, appendLine() {}, show() {}, dispose() { this.disposed = true; } }; + outputs.push(output); + return output; + }, + showInputBox: (...args) => input(...args), + showQuickPick: async (items) => items[0], + showErrorMessage: async (message) => { errors.push(message); }, + }, + commands: { registerCommand: (name, run) => { commands.set(name, run); return { dispose() {} }; } }, + })); + mock.module(source + "android.js", () => ({ androidSdkFor: () => "/sdk", toAndroidTarget: (value) => value })); + mock.module(source + "androidStackLinks.js", () => ({ ANDROID_LOGCAT_LANGUAGE: "logcat", registerAndroidStackLinks: () => ({ dispose() {} }) })); + // Load only after installing the VS Code and logcat-session module mocks. + const { registerAndroidLogcat } = await import(source + "androidLogcat.ts"); + const registration = registerAndroidLogcat({ + projects: [project], projectAt: () => project, + onDidChange: (listener) => { changed = listener; return { dispose() {} }; }, + }, () => undefined); + const target = { root: "/app", device: "phone" }; + + const response = Promise.withResolvers(), requested = Promise.withResolvers(); + let signal; + discover = async (value) => { signal = value; requested.resolve(); return response.promise; }; + const opening = commands.get("elide.androidLogcat")(target); + await requested.promise; + project.model = undefined; + changed(); + assert.equal(signal.aborted, true, "losing the Android model must terminate in-flight adb discovery"); + response.resolve(devices); + await opening; + assert.equal(streams.length, 0, "a late adb reply cannot start logcat for a removed Android model"); + assert.equal(outputs.length, 0); + + project.model = model; + discover = async () => devices; + await commands.get("elide.androidLogcat")(target); + const view = streams.at(-1); + assert.equal(view.active, true); + const answer = Promise.withResolvers(), editing = Promise.withResolvers(); + let inputCalls = 0; + input = async () => { + if (++inputCalls === 1) { editing.resolve(); return answer.promise; } + return ""; + }; + const filtering = commands.get("elide.androidLogcatFilter")(target); + await editing.promise; + project.model = undefined; + changed(); + project.model = model; + changed(); + answer.resolve("com.example.app"); + await filtering; + assert.equal(view.disposed, true); + assert.equal(view.starts, 1, "a late filter cannot resurrect its disposed stream even if the model returns"); + assert.deepEqual(errors, []); + + await commands.get("elide.androidLogcat")(target); + const active = streams.at(-1); + const restartReply = Promise.withResolvers(), restarting = Promise.withResolvers(); + discover = async (value) => { signal = value; restarting.resolve(); return restartReply.promise; }; + const restart = commands.get("elide.androidLogcatRestart")(target); + await restarting.promise; + await commands.get("elide.androidLogcatStop")(target); + assert.equal(signal.aborted, true); + assert.equal(active.active, false); + restartReply.resolve(devices); + await restart; + assert.equal(active.starts, 1, "stopping during discovery must not restart the stream afterward"); + discover = async () => [{ serial: "phone:5556", state: "device", ready: true, emulator: false }]; + await commands.get("elide.androidLogcatRestart")(target); + assert.equal(active.device, "phone:5556", "Restart follows the device to its new serial"); + registration.dispose(); + assert.equal(outputs.every((output) => output.disposed), true); + assert.deepEqual(errors, []); + `], { cwd: new URL("..", import.meta.url).pathname, encoding: "utf8", timeout: 20_000 }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); +}); + +test("Logcat cancels installed-package discovery on disposal or model removal without opening a late picker", () => { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { mock } from "bun:test"; + import * as core from "@elide/ide-core"; + const source = ${JSON.stringify(source)}; + const commands = new Map(), errors = []; + const model = { android: { targets: ["first", "second"].map(name => ({ + name, kind: "apk", output: name + ".apk", manifestFile: "com.example." + name, + })) } }; + const project = { root: "/app", model }; + let changed, started, release, options, picks = 0, outputs = 0; + const disposable = () => ({ dispose() {} }); + mock.module("node:child_process", () => ({ + execFile: (_adb, _args, value, callback) => { + options = value; + release = () => callback(null, "", ""); + value.signal?.addEventListener("abort", () => callback(Object.assign(new Error("Cancelled"), { name: "AbortError" })), { once: true }); + started.resolve(); + return {}; + }, + })); + mock.module("node:fs/promises", () => ({ readFile: async file => '' })); + mock.module("yauzl", () => ({ open: (_file, _options, callback) => callback(new Error("not built")) })); + mock.module("@elide/ide-core", () => ({ + ...core, adbExecutable: () => process.execPath, + listAndroidDevices: async () => [{ serial: "phone", state: "device", ready: true }], + AndroidLogcatSession: class { constructor() { throw new Error("Unexpected stream"); } }, + })); + mock.module("vscode", () => ({ + workspace: { isTrusted: true }, + Disposable: class { constructor(dispose) { this.dispose = dispose; } }, + window: { + createOutputChannel: () => { outputs++; throw new Error("Unexpected output"); }, + showQuickPick: async items => { picks++; return items[0]; }, + showErrorMessage: async message => { errors.push(message); }, + }, + commands: { registerCommand: (name, run) => { commands.set(name, run); return disposable(); } }, + })); + mock.module(source + "android.js", () => ({ androidSdkFor: () => "/sdk", toAndroidTarget: value => value })); + mock.module(source + "androidStackLinks.js", () => ({ ANDROID_LOGCAT_LANGUAGE: "logcat", registerAndroidStackLinks: disposable })); + // Load after the process/VS Code mocks; a static import would capture the real module dependencies. + const { registerAndroidLogcat } = await import(source + "androidLogcat.ts"); + for (const reason of ["dispose", "model"]) { + project.model = model; + const registration = registerAndroidLogcat({ + projects: [project], projectAt: () => project, + onDidChange: listener => { changed = listener; return disposable(); }, + }, () => undefined); + started = Promise.withResolvers(); + const opening = commands.get("elide.androidLogcat")({ root: "/app", device: "phone" }); + await started.promise; + if (reason === "dispose") registration.dispose(); + else { project.model = undefined; changed(); } + assert.equal(options.signal?.aborted, true, reason + " cancels package discovery, not just device discovery"); + assert.equal(options.killSignal, "SIGKILL", "read-only discovery must reap a SIGTERM-resistant adb"); + release(); + await opening; + assert.equal(picks, 0, "cancelled discovery must not open an application picker"); + assert.equal(outputs, 0); + assert.deepEqual(errors, []); + registration.dispose(); + } + `], { cwd: new URL("..", import.meta.url).pathname, encoding: "utf8", timeout: 20_000 }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); +}); diff --git a/packages/vscode/test/androidStackSource.test.ts b/packages/vscode/test/androidStackSource.test.ts index 9fc8770..1a9da4f 100644 --- a/packages/vscode/test/androidStackSource.test.ts +++ b/packages/vscode/test/androidStackSource.test.ts @@ -33,6 +33,8 @@ describe("Android stack frame parsing", () => { expect(frame("at app.HelpersKt.work(Helpers.kt:1)").fileName).toBe("Helpers.kt"); expect(frame("at Main.main(Main.java:1)").packageName).toBe(""); expect(frame("at app.module@1.0/app.Main.main(Main.java:1)").packageName).toBe("app"); + expect(frame("\tat app.ComposableSingletons$MainKt$lambda-1$1.invoke(Main.kt:12)").className).toBe("app.ComposableSingletons$MainKt$lambda-1$1"); + expect(frame("\tat app.ComposableSingletons$MainKt$lambda$-1868395445$1.invoke(Main.kt:12)").packageName).toBe("app"); }); test("rejects unknown/native frames, paths, invalid lines and non-frame prose", () => { diff --git a/packages/vscode/test/androidTestingLifecycle.test.ts b/packages/vscode/test/androidTestingLifecycle.test.ts new file mode 100644 index 0000000..fe0576c --- /dev/null +++ b/packages/vscode/test/androidTestingLifecycle.test.ts @@ -0,0 +1,220 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +// Keep VS Code module mocks out of Bun's shared registry for the other extension tests. +test("Android instrumented tests report crashes, honor exclusions and reject late device replies after disposal", () => { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { mock } from "bun:test"; + import * as core from "@elide/ide-core"; + const source = ${JSON.stringify(source)}; + const commands = new Map(), events = [], invocations = []; + const collection = (owner) => { + const items = new Map(); + return { + get size() { return items.size; }, + add(item) { item.parent = owner; items.set(item.id, item); }, + delete(id) { items.delete(id); }, + replace(list) { items.clear(); for (const item of list) this.add(item); }, + [Symbol.iterator]: () => items.entries(), + }; + }; + const item = (id, label, uri) => { + const created = { id, label, uri, parent: undefined }; + created.children = collection(created); + return created; + }; + mock.module("@elide/ide-core", () => ({ + ...core, + resolveElideDistribution: () => ({ bin: "elide" }), + scanJvmTestSource: () => ({ classes: [{ binaryName: "X", line: 0, methods: [{ name: "a", line: 1 }, { name: "b", line: 2 }, { name: "c", line: 3 }] }] }), + AndroidTestResultParser: class { + constructor(_task, onResult, onProblem) { this.onResult = onResult; this.onProblem = onProblem; } + accept(record) { if (record.result) this.onResult(record.result); else this.onProblem(record.problem); } + // The run ended with a failure still waiting for its stack trace (as the real parser holds it). + end() { this.onResult({ className: "X", method: "b", outcome: "failed" }); } + }, + ElideCli: class { + async run(argv, options) { + invocations.push(argv); + const handler = options.onCommand(); + handler.record({ result: { className: "X", method: "a", outcome: "passed" } }); + handler.record({ problem: "Instrumentation run failed due to 'Process crashed.'" }); + throw new Error("elide exited with 1"); + } + }, + })); + class Disposable { dispose() {} } + const events$ = { dispose() {} }; + mock.module("vscode", () => ({ + tests: { + createTestController: () => ({ + items: collection(undefined), + createTestItem: item, + createRunProfile: () => ({}), + createTestRun: () => ({ + enqueued() {}, started() {}, appendOutput() {}, end() {}, + passed: (test) => events.push(["passed", test.id]), + skipped: (test) => events.push(["skipped", test.id]), + failed: (test) => events.push(["failed", test.id]), + errored: (test, message) => events.push(["errored", test.id, message.message]), + }), + dispose() {}, + }), + }, + TestRunProfileKind: { Run: 1 }, + TestRunRequest: class { constructor(include, exclude, profile) { this.include = include; this.exclude = exclude; this.profile = profile; } }, + TestMessage: class { constructor(message) { this.message = message; } }, + CancellationTokenSource: class { token = { isCancellationRequested: false, onCancellationRequested: () => events$ }; dispose() {} }, + RelativePattern: class { constructor(base, pattern) { this.base = base; this.pattern = pattern; } }, + Range: class {}, + Location: class {}, + Position: class {}, + Uri: { file: (fsPath) => ({ fsPath, scheme: "file" }) }, + Disposable, + workspace: { + textDocuments: [], + fs: { readFile: async () => new TextEncoder().encode("class X") }, + findFiles: async () => [{ fsPath: "/app/src/X.kt", scheme: "file" }], + saveAll: async () => true, + createFileSystemWatcher: () => ({ onDidCreate: () => events$, onDidChange: () => events$, onDidDelete: () => events$, dispose() {} }), + onDidChangeTextDocument: () => events$, + }, + window: { showWarningMessage: async () => undefined }, + commands: { registerCommand: (name, run) => { commands.set(name, run); return events$; } }, + })); + mock.module(source + "android.js", () => ({ toAndroidTarget: (value) => value })); + mock.module(source + "androidDeploymentState.js", () => ({ androidApks: () => [{ name: "app", device: { instrumented: true }, sourceSets: ["test"] }] })); + mock.module(source + "config.js", () => ({ readConfig: () => ({}), configuredInvocation: () => ({}) })); + // Load only after installing the VS Code and Elide CLI module mocks. + const { ElideAndroidTestController } = await import(source + "androidTesting.ts"); + const project = { root: "/app", folder: {}, model: { name: "app", modules: [{ sourceSet: "test", contentRoots: [{ sourceRoots: [{ kind: "test", path: "/app/src" }] }] }] } }; + const controller = new ElideAndroidTestController( + { projects: [project], projectAt: () => project, onDidChange: () => events$ }, + { log() {} }, + { resolve: async () => ({}), readyDevice: async () => "serial" }, + ); + await commands.get("elide.androidTest")(undefined); + assert.deepEqual(events, [ + ["passed", "a:/app:app/X#a"], + ["failed", "a:/app:app/X#b"], + ["errored", "a:/app:app/X#c", "Instrumentation run failed due to 'Process crashed.'\\nelide exited with 1"], + ]); + const suite = [...controller.suites.values()][0]; + const token = { isCancellationRequested: false, onCancellationRequested: () => events$ }; + invocations.length = 0; events.length = 0; + await controller.run({ include: [suite.item], exclude: [...suite.tests.values()] }, token); + assert.deepEqual(invocations, [], "excluding every method must not run the entire test APK without --tests"); + assert.deepEqual(events, []); + + const onlyClass = [...suite.item.children][0][1]; + await controller.run({ include: [suite.item], exclude: [onlyClass] }, token); + assert.deepEqual(invocations, [], "excluding every class must not deploy either"); + + // A controller disposed while device preparation is pending owns no CLI yet, but must not launch one later. + const deviceReady = Promise.withResolvers(), preparing = Promise.withResolvers(); + controller.deployments.readyDevice = () => { preparing.resolve(); return deviceReady.promise; }; + const preparingRun = controller.run({ include: [suite.item] }, token); + await preparing.promise; + controller.dispose(); + deviceReady.resolve("serial"); + await preparingRun; + assert.deepEqual(invocations, [], "a late device reply must not launch a detached CLI after disposal"); + assert.deepEqual(events, []); + controller.dispose(); + `], { cwd: new URL("..", import.meta.url).pathname, encoding: "utf8", timeout: 20_000 }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); +}); + +test("Disposing the controller ends the detached android-test process of a run in flight", () => { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { mock } from "bun:test"; + import * as core from "@elide/ide-core"; + const source = ${JSON.stringify(source)}; + const commands = new Map(), events = []; + const collection = (owner) => { + const items = new Map(); + return { + get size() { return items.size; }, + add(item) { item.parent = owner; items.set(item.id, item); }, + delete(id) { items.delete(id); }, + replace(list) { items.clear(); for (const item of list) this.add(item); }, + [Symbol.iterator]: () => items.entries(), + }; + }; + const item = (id, label, uri) => { + const created = { id, label, uri, parent: undefined }; + created.children = collection(created); + return created; + }; + const { promise: running, resolve: started } = Promise.withResolvers(); + mock.module("@elide/ide-core", () => ({ + ...core, + resolveElideDistribution: () => ({ bin: "elide" }), + scanJvmTestSource: () => ({ classes: [{ binaryName: "X", line: 0, methods: [{ name: "a", line: 1 }] }] }), + ElideCli: class { + run(_argv, options) { + started(options.signal); + return new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(options.signal.reason))); + } + }, + })); + const events$ = { dispose() {} }; + mock.module("vscode", () => ({ + tests: { + createTestController: () => ({ + items: collection(undefined), + createTestItem: item, + createRunProfile: () => ({}), + createTestRun: () => ({ + enqueued() {}, started() {}, appendOutput() {}, end: () => events.push(["end"]), + passed() {}, skipped() {}, failed() {}, + errored: (test) => events.push(["errored", test.id]), + }), + dispose() {}, + }), + }, + TestRunProfileKind: { Run: 1 }, + TestRunRequest: class { constructor(include, exclude, profile) { this.include = include; this.exclude = exclude; this.profile = profile; } }, + TestMessage: class { constructor(message) { this.message = message; } }, + CancellationTokenSource: class { token = { isCancellationRequested: false, onCancellationRequested: () => events$ }; dispose() {} }, + RelativePattern: class { constructor(base, pattern) { this.base = base; this.pattern = pattern; } }, + Range: class {}, + Uri: { file: (fsPath) => ({ fsPath, scheme: "file" }) }, + workspace: { + textDocuments: [], + fs: { readFile: async () => new TextEncoder().encode("class X") }, + findFiles: async () => [{ fsPath: "/app/src/X.kt", scheme: "file" }], + saveAll: async () => true, + createFileSystemWatcher: () => ({ onDidCreate: () => events$, onDidChange: () => events$, onDidDelete: () => events$, dispose() {} }), + onDidChangeTextDocument: () => events$, + }, + window: { showWarningMessage: async () => undefined }, + commands: { registerCommand: (name, run) => { commands.set(name, run); return events$; } }, + })); + mock.module(source + "android.js", () => ({ toAndroidTarget: (value) => value })); + mock.module(source + "androidDeploymentState.js", () => ({ androidApks: () => [{ name: "app", device: { instrumented: true }, sourceSets: ["test"] }] })); + mock.module(source + "config.js", () => ({ readConfig: () => ({}), configuredInvocation: () => ({}) })); + const { ElideAndroidTestController } = await import(source + "androidTesting.ts"); + const project = { root: "/app", folder: {}, model: { name: "app", modules: [{ sourceSet: "test", contentRoots: [{ sourceRoots: [{ kind: "test", path: "/app/src" }] }] }] } }; + const controller = new ElideAndroidTestController( + { projects: [project], projectAt: () => project, onDidChange: () => events$ }, + { log() {} }, + { resolve: async () => ({}), readyDevice: async () => "serial" }, + ); + const command = commands.get("elide.androidTest")(undefined); + const signal = await running; + controller.dispose(); + await command; + assert.equal(signal.aborted, true); + assert.deepEqual(events, [["end"]]); + `], { cwd: new URL("..", import.meta.url).pathname, encoding: "utf8", timeout: 20_000 }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); +}); diff --git a/packages/vscode/test/androidWirelessLifecycle.test.ts b/packages/vscode/test/androidWirelessLifecycle.test.ts new file mode 100644 index 0000000..3def361 --- /dev/null +++ b/packages/vscode/test/androidWirelessLifecycle.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +// Isolated process keeps the VS Code boundary mock out of other suites. +test("wireless pairing revokes late operations and QR pairing ignores unrelated services", () => { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { mock } from "bun:test"; + import * as core from "@elide/ide-core"; + const source = ${JSON.stringify(source)}; + const commands = new Map(), panels = [], paired = []; + const project = { root: "/app", model: { android: {} } }; + let changed, configurationChanged, refreshes = 0; + let discover = async () => [], pair = async () => {}; + const disposable = () => ({ dispose() {} }); + mock.module("@elide/ide-core", () => ({ + ...core, adbExecutable: () => process.execPath, + createAndroidWirelessQr: () => ({ name: "studio-owned", secret: "secret", payload: "WIFI:T:ADB;S:studio-owned;P:secret;;" }), + discoverAndroidWireless: (_adb, signal) => discover(signal), + pairAndroidWireless: async (_adb, endpoint, secret, signal) => { paired.push(endpoint); await pair(signal); }, + })); + mock.module("qrcode", () => ({ default: { toDataURL: async () => "data:image/png;base64,AA==" } })); + mock.module(source + "android.js", () => ({ androidSdkFor: () => "/sdk", toAndroidTarget: value => value })); + mock.module("vscode", () => ({ + workspace: { isTrusted: true, onDidChangeConfiguration: callback => { configurationChanged = callback; return disposable(); } }, + ViewColumn: { Active: 1 }, Disposable: class { constructor(dispose) { this.dispose = dispose; } }, + commands: { registerCommand: (name, callback) => { commands.set(name, callback); return disposable(); }, + executeCommand: async () => { refreshes++; } }, + window: { createWebviewPanel: () => { + let receive, closed; + const panel = { messages: [], disposed: false, + webview: { onDidReceiveMessage: callback => { receive = callback; return disposable(); }, postMessage: value => panel.messages.push(value) }, + onDidDispose: callback => { closed = callback; return disposable(); }, reveal() {}, + send: value => receive(value), dispose() { if (!this.disposed) { this.disposed = true; closed(); } }, + }; + panels.push(panel); return panel; + } }, + })); + // Import after installing mocks: this deliberately exercises the module-loading boundary. + const { registerAndroidWireless } = await import(source + "androidWireless.ts"); + const registration = registerAndroidWireless({ projects: [project], projectAt: () => project, + onDidChange: callback => { changed = callback; return disposable(); } }, () => undefined); + const open = () => commands.get("elide.androidPairWireless")({ root: "/app" }); + const flush = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); }; + await open(); let panel = panels.at(-1); + panel.send({ type: "pair", endpoint: "127.0.0.1:1234", code: "not-six" }); await flush(); + assert.equal(paired.length, 0, "invalid code must never authorize a process"); + assert.equal(panel.messages.at(-1).error, true); + + const pending = Promise.withResolvers(); let signal; + pair = async value => { signal = value; await pending.promise; }; + panel.send({ type: "pair", endpoint: "127.0.0.1:1234", code: "123456" }); await flush(); + panel.send({ type: "pair", endpoint: "127.0.0.1:4321", code: "123456" }); await flush(); + assert.deepEqual(paired, ["127.0.0.1:1234"], "double submission must not pair a second device"); + panel.send({ type: "cancel" }); await flush(); + assert.equal(signal.aborted, true); + pending.resolve(); await flush(); + assert.equal(refreshes, 0, "cancelled operation cannot announce success or refresh"); + assert.match(panel.messages.at(-1).status, /Cancelled/); + + const discovery = Promise.withResolvers(); + discover = async value => { signal = value; return discovery.promise; }; + panel.send({ type: "qr" }); await flush(); + assert.ok(panel.messages.at(-1).qr); + panel.dispose(); assert.equal(signal.aborted, true); + discovery.resolve([{ name: "studio-owned", type: "pairing", endpoint: "127.0.0.1:2222" }]); await flush(); + assert.deepEqual(paired, ["127.0.0.1:1234"], "closed QR panel must not pair a late discovery"); + + await open(); panel = panels.at(-1); pair = async () => {}; + discover = async () => [ + { name: "studio-unrelated", type: "pairing", endpoint: "127.0.0.1:3333" }, + { name: "studio-owned", type: "connect", endpoint: "127.0.0.1:4444" }, + { name: "studio-owned", type: "pairing", endpoint: "127.0.0.1:5555" }, + ]; + panel.send({ type: "qr" }); await flush(); + assert.deepEqual(paired, ["127.0.0.1:1234", "127.0.0.1:5555"]); + assert.equal(refreshes, 1); + assert.equal(panel.messages.at(-1).qr, undefined, "completed QR credentials must disappear"); + assert.match(panel.messages.at(-1).status, /Paired successfully/); + + const removed = Promise.withResolvers(); + pair = async value => { signal = value; await removed.promise; }; + panel.send({ type: "pair", endpoint: "127.0.0.1:1234", code: "123456" }); await flush(); + project.model = undefined; changed(); + assert.equal(panel.disposed, true); assert.equal(signal.aborted, true); + removed.resolve(); await flush(); assert.equal(refreshes, 1); + registration.dispose(); + `], { cwd: new URL("..", import.meta.url).pathname, encoding: "utf8", timeout: 20_000 }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); +}); diff --git a/packages/vscode/test/composeAgentMcp.test.ts b/packages/vscode/test/composeAgentMcp.test.ts new file mode 100644 index 0000000..bab7cdf --- /dev/null +++ b/packages/vscode/test/composeAgentMcp.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +// Keep the VS Code boundary mocks out of the other suites; use the real HTTP server. +test("MCP disposal during port persistence cannot republish terminal credentials", () => { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { mock } from "bun:test"; + const source = ${JSON.stringify(source)}; + class Disposable { constructor(close = () => {}) { this.close = close; } dispose() { this.close(); } } + class EventEmitter { event = () => new Disposable(); fire() {} dispose() {} } + mock.module("vscode", () => ({ + Disposable, EventEmitter, + workspace: { onDidChangeConfiguration: () => new Disposable() }, + commands: { registerCommand: () => new Disposable() }, + })); + mock.module(source + "config.js", () => ({ readConfig: () => ({ composePreviewMcp: true }) })); + // Load after installing the VS Code boundary; a static import would run before the mocks. + const { registerComposePreviewMcp } = await import(source + "composeAgentMcp.ts"); + const entered = Promise.withResolvers(); + const saved = Promise.withResolvers(); + const environment = new Map(); + const context = { + secrets: { get: async () => "test-token", store: async () => {} }, + workspaceState: { get: () => 0, update: (_key, port) => { entered.resolve(port); return saved.promise; } }, + environmentVariableCollection: { + delete: key => environment.delete(key), replace: (key, value) => environment.set(key, value), + }, + }; + const registration = registerComposePreviewMcp(context, {}, { log() {} }, "1"); + try { + const port = await entered.promise; + const response = await fetch("http://127.0.0.1:" + port + "/mcp", { + method: "POST", headers: { authorization: "Bearer test-token", "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }), + }); + assert.equal(response.status, 200); + await response.text(); + registration.disposable.dispose(); + saved.resolve(); + assert.equal(await registration.api.endpoint(), undefined); + assert.deepEqual([...environment], [], "shutdown must not advertise a closed server or restore its token"); + } finally { + saved.resolve(); + registration.disposable.dispose(); + } + `], { encoding: "utf8", timeout: 10_000 }); + expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); +}); + +test("agent native gating preserves listing and layoutlib and retires pending/live native work", () => { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { mock } from "bun:test"; + const source = ${JSON.stringify(source)}; + class Disposable { constructor(close = () => {}) { this.close = close; } dispose() { this.close(); } } + class EventEmitter { + listeners = new Set(); + event = listener => { this.listeners.add(listener); return new Disposable(() => this.listeners.delete(listener)); }; + fire() { for (const listener of this.listeners) listener(); } + } + const changed = new EventEmitter(); + let engine = "komodo"; + let available = false; + let allocated = 0; + let resources = 0; + let applications = 0; + let gate = Promise.resolve(); + mock.module("vscode", () => ({ + Disposable, + workspace: { isTrusted: true, asRelativePath: file => file }, + languages: { createDiagnosticCollection: () => ({ clear() {}, dispose() {} }) }, + })); + mock.module(source + "config.js", () => ({ readConfig: () => ({ composePreviewEngine: engine, composePreviewKeepAlive: 1 }) })); + mock.module(source + "composeRuntime.js", () => ({ ensureComposeRuntime: async () => { resources++; return {}; } })); + mock.module(source + "tasks.js", () => ({ + runObservedElide: async () => { resources++; throw new Error("Native gating must prevent preview builds"); }, + })); + mock.module(source + "composePreview.js", () => ({ + backendKey: () => "app", buildErrors: () => [], javaFor: () => "java", ownedBy: () => true, + previewApp: () => { applications++; throw new Error("no Android application"); }, + previewBuild: () => { resources++; return {}; }, + })); + // Install boundary mocks before evaluating the service; a static import would load VS Code first. + const { ComposeAgentPreviews } = await import(source + "composeAgent.ts"); + const workspace = { + get komodoPreviewAvailable() { return available; }, + onDidChangeKomodoAvailability: changed.event, + requireKomodoPreview: async () => { await gate; if (!available) throw new Error("Komodo unavailable: install then sync"); }, + projectFor: () => ({ model: {}, root: "/app", folder: {} }), + projectAt: () => { resources++; return undefined; }, + }; + const host = { + backends: { acquire() { allocated++; }, detached() { allocated++; }, release() {} }, + arbiter: { setActive() {} }, + }; + const service = new ComposeAgentPreviews(workspace, {}, host); + service.declaration = async () => ({ declaration: { file: "/app/Card.kt", name: "Card" }, text: "" }); + service.kotlinFiles = async () => ["/app/Card.kt"]; + service.text = async () => "package app\\n@Composable fun Card() {}"; + const options = { composables: [{ reference: "app.Card" }] }; + const signal = new AbortController().signal; + try { + assert.match(await service.list(undefined, undefined), /app.Card/); + await assert.rejects(service.render(options, signal, () => {}), /Komodo unavailable/); + assert.equal(applications, 0, "native gating precedes Android application lookup"); + assert.equal(allocated, 0); + assert.equal(resources, 0); + available = true; + changed.fire(); + await assert.rejects(service.render(options, signal, () => {}), /no Android application/); + assert.equal(applications, 1, "sync recovery permits a fresh request without reloading"); + available = false; + engine = "layoutlib"; + await assert.rejects(service.render(options, signal, () => {}), /no Android application/); + assert.equal(applications, 2, "layoutlib does not depend on native availability"); + + const backend = { disposed: false, blur() {}, dispose() { this.disposed = true; } }; + const group = { key: "native", sourceFile: "/app/Card.kt", backend, komodo: backend, + latest: new Map(), subscriptions: [], released: false, appendices: new Map() }; + const session = { id: "s1", groups: [group], closed: false, queue: Promise.resolve(), closing: new AbortController() }; + const layoutlib = { id: "s2", groups: [{ komodo: undefined, backend: {}, subscriptions: [], released: false }], + closed: false, queue: Promise.resolve(), closing: new AbortController() }; + service.groups.add(group); + service.sessions.set(session.id, session); + service.sessions.set(layoutlib.id, layoutlib); + available = true; + const reporting = Promise.withResolvers(); + const entered = Promise.withResolvers(); + const pending = service.serialized(session, signal, async () => { entered.resolve(); await reporting.promise; return ["obsolete pixels"]; }); + await entered.promise; + const controller = new AbortController(); + const snapshot = new AbortController(); + service.rendering.set(controller, true); + service.rendering.set(snapshot, false); + const requiring = Promise.withResolvers(); + gate = requiring.promise; + const rendering = service.renderNow(group, true, controller.signal, undefined, () => {}).catch(error => error); + available = false; + changed.fire(); + assert.equal(backend.disposed, true); + assert.equal(session.closed, true); + assert.equal(layoutlib.closed, false); + assert.equal(controller.signal.aborted, true); + assert.equal(snapshot.signal.aborted, false); + reporting.resolve(); + await assert.rejects(pending, /closed/); + available = true; + changed.fire(); + requiring.resolve(); + assert.ok(await rendering instanceof Error); + assert.equal(resources, 0, "loss followed by quick recovery cannot revive queued native work"); + service.rendering.clear(); + } finally { service.dispose(); } + `], { encoding: "utf8", timeout: 10_000 }); + expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); +}); diff --git a/packages/vscode/test/composeAgentSource.test.ts b/packages/vscode/test/composeAgentSource.test.ts new file mode 100644 index 0000000..83c3144 --- /dev/null +++ b/packages/vscode/test/composeAgentSource.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, test } from "bun:test"; +import { + composableDeclarations, + jvmFacade, + parseReference, + placeholder, + previewWrapper, + WrapperError, + type ComposableDeclaration, + type KotlinParameter, + type WrapperRequest, +} from "../src/composeAgentSource.js"; + +const declarations = (text: string) => composableDeclarations(text, "/app/src/Card.kt"); +const only = (text: string) => { + const found = declarations(text); + expect(found).toHaveLength(1); + return found[0]!; +}; +const parameter = (type: string, name = "value"): KotlinParameter => ({ name, type, vararg: false, annotations: [] }); + +describe("Kotlin source scanning", () => { + test("ignores functions and braces inside comments, strings, raw strings, templates and character literals", () => { + const text = [ + "package app", + "// @Composable fun InLineComment() {", + "/* outer /* nested } */ @Composable fun InBlockComment() { */", + 'val brace = "{ @Composable fun InString() {"', + 'val raw = """', + " { @Composable fun InRaw() {", + ' ${"}"} ${listOf("{").first()} $brace "" \\"', + '"""""', + "val char = '{'", + "val quote = '\\''", + "@Composable", + "fun Real(label: String = \"a, b) {\") {", + ' Text("}")', + "}", + ].join("\n"); + const found = declarations(text); + expect(found.map((declaration) => declaration.name)).toEqual(["Real"]); + expect(found[0]!.line).toBe(12); + expect(found[0]!.offset).toBe(text.indexOf("fun Real")); + expect(found[0]!.parameters).toEqual([{ name: "label", type: "String", defaultValue: '"a, b) {"', vararg: false, annotations: [] }]); + }); + + test("finds top-level composables only, never nested, member, anonymous or non-composable functions", () => { + const text = [ + "package app", + "@Composable fun Outer() {", + " @Composable fun Inner() {}", + "}", + "class Host {", + " @Composable fun Member() {}", + " companion object { @Composable fun Companion() {} }", + "}", + "object Holder { @Composable fun InObject() {} }", + "val anonymous = @Composable fun() {}", + "fun interface Callback { fun call() }", + "fun helper(): Int = listOf(1).size", + "@Composable fun Second() = Outer()", + "@Composable", + "fun Third() {}", + ].join("\n"); + expect(declarations(text).map((declaration) => [declaration.name, declaration.line])).toEqual([["Outer", 2], ["Second", 13], ["Third", 15]]); + }); + + test("detects @Composable and previews by simple name, qualified and multipreview annotations included", () => { + const text = [ + "package `my`.app", + "@androidx.compose.runtime.Composable", + '@androidx.compose.ui.tooling.preview.Preview(name = "a)b", widthDp = 10)', + "fun Qualified() {}", + "@PreviewLightDark @Composable fun Multi() {}", + "@Composable", + "@ThemePreviews", + "internal fun Custom() {}", + "@[Composable]", + "fun Grouped() {}", + "@Composable fun Plain() {}", + "@Preview fun NotComposable() {}", + ].join("\n"); + const found = declarations(text); + expect(found.map((declaration) => [declaration.name, declaration.previews])).toEqual([ + ["Qualified", true], + ["Multi", true], + ["Custom", true], + ["Grouped", false], + ["Plain", false], + ]); + expect(found[0]!.packageName).toBe("my.app"); + expect(found[0]!.annotations).toEqual(["@androidx.compose.runtime.Composable", '@androidx.compose.ui.tooling.preview.Preview(name = "a)b", widthDp = 10)']); + expect(found[1]!.annotations).toEqual(["@PreviewLightDark", "@Composable"]); + }); + + test("reads visibility and modifiers between annotations and fun", () => { + const text = [ + "@Composable private fun Hidden() {}", + "@Composable internal inline fun Module() {}", + "@Composable\n/** docs */\npublic fun Open() {}", + "@Composable fun Default() {}", + "@Composable protected actual fun Guarded() {}", + ].join("\n"); + expect(declarations(text).map((declaration) => [declaration.name, declaration.visibility])).toEqual([ + ["Hidden", "private"], + ["Module", "internal"], + ["Open", "public"], + ["Default", "public"], + ["Guarded", "protected"], + ]); + expect(declarations(text)[0]!.packageName).toBe(""); + }); + + test("parses extension receivers and type parameters", () => { + const text = [ + "@Composable fun ColumnScope.Item(text: String) {}", + "@Composable fun androidx.compose.foundation.layout.RowScope.Cell() {}", + "@Composable fun List?.Items(render: @Composable (T) -> Unit) {}", + "@Composable fun , reified R> Generic(items: List, lookup: Map>) {}", + "@Composable fun (@Composable () -> Unit).Invoke() {}", + ].join("\n"); + const found = declarations(text); + expect(found.map((declaration) => [declaration.name, declaration.receiver, declaration.typeParameters])).toEqual([ + ["Item", "ColumnScope", []], + ["Cell", "androidx.compose.foundation.layout.RowScope", []], + ["Items", "List?", ["T"]], + ["Generic", undefined, ["T : Comparable", "reified R"]], + ["Invoke", "(@Composable () -> Unit)", []], + ]); + expect(found[3]!.parameters.map((parameter) => parameter.type)).toEqual(["List", "Map>"]); + }); + + test("discovers composables with context parameters, with annotations before or after them", () => { + const text = [ + "@Composable context(theme: Theme, _: Log)", + "fun Themed() {}", + "context(scope: pkg.Scope) @Composable internal fun Scoped() {}", + "context(Legacy)", + "@Composable fun Old() {}", + "@Composable fun Plain() {}", + "context(theme: Theme) fun NotComposable() {}", + ].join("\n"); + expect(declarations(text).map((declaration) => [declaration.name, declaration.visibility, declaration.contexts])).toEqual([ + ["Themed", "public", [{ name: "theme", type: "Theme" }, { type: "Log" }]], + ["Scoped", "internal", [{ name: "scope", type: "pkg.Scope" }]], + ["Old", "public", [{ type: "Legacy" }]], + ["Plain", "public", undefined], + ]); + }); + + test("splits parameters over brackets and generics, with annotations, vararg, function types and defaults", () => { + const { parameters } = only([ + "@Composable", + "fun Card(", + " @PreviewParameter(NamesProvider::class) name: String,", + " pair: Pair = 1 to 2,", + " sum: (Int, Int) -> Int = { a, b -> a + b },", + " smaller: Boolean = a < b,", + " content: @Composable RowScope.() -> Unit = { Text(\"x, y\") },", + " callback: suspend (Map, Int) -> Unit, // trailing comment", + " /* block */ vararg tags: String,", + " noinline `odd name`: Array = arrayOf(1, 2),", + " multiline: Map<", + " String,", + " Int", + " >,", + ") {}", + ].join("\n")); + expect(parameters).toEqual([ + { name: "name", type: "String", vararg: false, annotations: ["@PreviewParameter(NamesProvider::class)"] }, + { name: "pair", type: "Pair", defaultValue: "1 to 2", vararg: false, annotations: [] }, + { name: "sum", type: "(Int, Int) -> Int", defaultValue: "{ a, b -> a + b }", vararg: false, annotations: [] }, + { name: "smaller", type: "Boolean", defaultValue: "a < b", vararg: false, annotations: [] }, + { name: "content", type: "@Composable RowScope.() -> Unit", defaultValue: '{ Text("x, y") }', vararg: false, annotations: [] }, + { name: "callback", type: "suspend (Map, Int) -> Unit", vararg: false, annotations: [] }, + { name: "tags", type: "String", vararg: true, annotations: [] }, + { name: "odd name", type: "Array", defaultValue: "arrayOf(1, 2)", vararg: false, annotations: [] }, + { name: "multiline", type: "Map< String, Int >", vararg: false, annotations: [] }, + ]); + }); +}); + +describe("composable references", () => { + test("parses every reference form", () => { + expect(parseReference("sample.components.MemberCard")).toEqual({ packageName: "sample.components", name: "MemberCard" }); + expect(parseReference(" MemberCard ")).toEqual({ name: "MemberCard" }); + expect(parseReference("`my`.app.`Odd Name`")).toEqual({ packageName: "my.app", name: "Odd Name" }); + expect(parseReference("app/src/main/kotlin/Card.kt#MemberCard")).toEqual({ file: "app/src/main/kotlin/Card.kt", name: "MemberCard" }); + expect(parseReference("app/src/Card.kt:42")).toEqual({ file: "app/src/Card.kt", name: "", line: 42 }); + expect(parseReference("/abs/Card.kt#MemberCard:7")).toEqual({ file: "/abs/Card.kt", name: "MemberCard", line: 7 }); + expect(parseReference("C:\\src\\Card.kt:3")).toEqual({ file: "C:\\src\\Card.kt", name: "", line: 3 }); + }); + + test("rejects anything else with the accepted forms", () => { + for (const reference of ["", "app/Card.kt", "Card.kt#", "#Card", "a b", "Card.kt#a.B", "Card.kt:x"]) { + expect(() => parseReference(reference)).toThrow(/use pkg\.Name, Name, path\/to\/File\.kt#Name/); + } + expect(() => parseReference("Card.kt:0")).toThrow("Invalid line 0"); + }); +}); + +describe("JVM facades", () => { + test("uses @file:JvmName or the capitalized, sanitized file name", () => { + expect(jvmFacade('@file:JvmName("Cards")\npackage app.ui', "/x/Card.kt")).toBe("app.ui.Cards"); + expect(jvmFacade('@file : kotlin.jvm.JvmName( "Named" )\n', "/x/Card.kt")).toBe("Named"); + expect(jvmFacade('@file:JvmName(name = "ByName")\npackage app', "/x/Card.kt")).toBe("app.ByName"); + expect(jvmFacade('// @file:JvmName("Commented")\npackage app', "/x/card.kt")).toBe("app.CardKt"); + expect(jvmFacade("package app", "/x/my-card.screen.kt")).toBe("app.My_card_screenKt"); + expect(jvmFacade("package app", "/x/ärger.kt")).toBe("app.ärgerKt"); + expect(jvmFacade("", "Main.kt")).toBe("MainKt"); + }); +}); + +describe("placeholders", () => { + test("fill required parameters of well-known types", () => { + const cases: [string, string | undefined][] = [ + ["String?", "null"], + ["Member?", "null"], + ["(() -> Unit)?", "null"], + ["String", '"value"'], + ["kotlin.CharSequence", '"value"'], + ["Int", "0"], + ["Long", "0L"], + ["Short", "0.toShort()"], + ["Byte", "0.toByte()"], + ["Float", "0f"], + ["Double", "0.0"], + ["Boolean", "false"], + ["Char", "'a'"], + ["Modifier", "androidx.compose.ui.Modifier"], + ["androidx.compose.ui.graphics.Color", "androidx.compose.ui.graphics.Color.Gray"], + ["Dp", "androidx.compose.ui.unit.Dp(16f)"], + ["List", "emptyList()"], + ["Collection", "emptyList()"], + ["Iterable", "emptyList()"], + ["Set", "emptySet()"], + ["Map", "emptyMap()"], + ["MutableList", "mutableListOf()"], + ["MutableSet", "mutableSetOf()"], + ["MutableMap", "mutableMapOf()"], + ["() -> Unit", "{}"], + ["@Composable () -> Unit", "{}"], + ["@Composable ColumnScope.() -> Unit", "{}"], + ["(Int) -> Unit", "{ _ -> }"], + ["suspend (Map, (Int) -> Unit) -> kotlin.Unit", "{ _, _ -> }"], + ["(@Composable () -> Unit)", "{}"], + ["() -> Int", undefined], + ["Member", undefined], + ["T", undefined], + ]; + for (const [type, expected] of cases) expect([type, placeholder(parameter(type))]).toEqual([type, expected]); + expect(placeholder(parameter("String", "say$\"hi\""))).toBe('"say\\$\\"hi\\""'); + }); +}); + +const SOURCE = [ + "package sample.components", + "", + "@Composable", + "private fun MemberCard(title: String, member: Member, onClick: () -> Unit, subtitle: String? = null, modifier: Modifier = Modifier) {}", + "@Composable fun ColumnScope.Row(count: Int) {}", + "@Composable fun LazyItemScope.Item() {}", + "@Composable fun Picker(items: List, selected: T? = null, onPick: (T) -> Unit) {}", + "@Composable fun Unused(label: String) {}", + "@Composable fun Tags(vararg tags: String) {}", +].join("\n"); +const THEME = only("package sample.theme\n@Composable fun ElideTheme(dark: Boolean, content: @Composable () -> Unit) {}"); +const target = (name: string): ComposableDeclaration => composableDeclarations(SOURCE, "/app/Card.kt").find((declaration) => declaration.name === name)!; +const wrapper = (request: Partial & { target: ComposableDeclaration }) => + previewWrapper({ facade: "sample.components.CardKt", arguments: {}, configuration: {}, annotate: false, ...request }); + +describe("preview wrappers", () => { + test("generates the annotated, themed wrapper with arguments and placeholders", () => { + const result = wrapper({ + target: target("MemberCard"), + arguments: { member: "Member(\"Ada\")" }, + theme: THEME, + configuration: { widthDp: 360, heightDp: 640, showBackground: true, backgroundColor: 0xff336699, darkTheme: true, fontScale: 1.5, + locale: "fr-rFR", device: "id:pixel_5", showSystemUi: false, apiLevel: 34 }, + annotate: true, + }); + expect(result.name).toMatch(/^ElideAgentPreview_[0-9a-f]{10}$/); + expect(result.methodFQN).toBe(`sample.components.CardKt.${result.name}`); + expect(result.text).toBe([ + "", + "", + "// Generated by Elide for an agent's Compose preview; never saved.", + '@androidx.compose.ui.tooling.preview.Preview(name = "MemberCard", widthDp = 360, heightDp = 640, showBackground = true, ' + + 'backgroundColor = 0xFF336699L, uiMode = 0x21, fontScale = 1.5f, locale = "fr-rFR", device = "id:pixel_5", showSystemUi = false, ' + + "apiLevel = 34)", + "@androidx.compose.runtime.Composable", + `fun ${result.name}() {`, + " sample.theme.ElideTheme(dark = false) {", + " sample.components.MemberCard(", + ' title = "title"', + ' , member = Member("Ada")', + " , onClick = {}", + " )", + " }", + "}", + "", + ].join("\n")); + expect(result.previewParams).toEqual({ name: "MemberCard", widthDp: "360", heightDp: "640", showBackground: "true", backgroundColor: "4281558681", + uiMode: "33", fontScale: "1.5", locale: "fr-rFR", device: "id:pixel_5", showSystemUi: "false", apiLevel: "34" }); + expect(result.arguments).toEqual([ + { name: "title", value: '"title"', source: "placeholder" }, + { name: "member", value: 'Member("Ada")', source: "argument" }, + { name: "onClick", value: "{}", source: "placeholder" }, + ]); + }); + + test("wraps scope receivers in their layout and omits the annotation unless asked", () => { + const result = wrapper({ target: target("Row"), configuration: { fontScale: 2 } }); + expect(result.text).toBe([ + "", + "", + "// Generated by Elide for an agent's Compose preview; never saved.", + "@androidx.compose.runtime.Composable", + `fun ${result.name}() {`, + " androidx.compose.foundation.layout.Column {", + " Row(", + " count = 0", + " )", + " }", + "}", + "", + ].join("\n")); + expect(result.previewParams).toEqual({ name: "Row", fontScale: "2.0" }); + expect(wrapper({ target: target("Tags") }).text).toContain(" {\n sample.components.Tags()\n}\n"); + }); + + test("passes vararg arguments spread, as named form requires", () => { + expect(wrapper({ target: target("Tags"), arguments: { tags: '"a", "b"' } }).text).toContain('sample.components.Tags(\n tags = *arrayOf("a", "b"\n)\n )'); + expect(wrapper({ target: target("Tags"), arguments: { tags: "*names" } }).text).toContain("sample.components.Tags(\n tags = *names\n )"); + const numbers = only("@Composable fun Numbers(vararg values: Int) {}"); + expect(wrapper({ target: numbers, arguments: { values: "1, 2" } }).text).toContain("Numbers(\n values = *intArrayOf(1, 2\n)"); + }); + + test("ends each argument's line, so a trailing line comment in it comments out nothing else", () => { + const result = wrapper({ target: target("MemberCard"), arguments: { member: "m // the member", title: '"t" // one' } }); + expect(result.text).toContain('MemberCard(\n title = "t" // one\n , member = m // the member\n , onClick = {}\n )\n}\n'); + expect(wrapper({ target: target("Tags"), arguments: { tags: '"a" // a' } }).text).toContain('tags = *arrayOf("a" // a\n)\n )'); + }); + + test("supplies contexts from arguments keyed by context name or receiver type", () => { + const themed = only("package t\n@Composable context(theme: Theme, _: Log) fun Themed(title: String) {}"); + const result = wrapper({ target: themed, theme: THEME, arguments: { theme: "Theme() // dark", Log: "Log()", title: '"t"' } }); + expect(result.text).toContain([ + " sample.theme.ElideTheme(dark = false) {", + " with(", + " Theme() // dark", + " ) {", + " with(", + " Log()", + " ) {", + " t.Themed(", + ' title = "t"', + " )", + " }", + " }", + " }", + ].join("\n")); + expect(result.arguments).toEqual([{ name: "title", value: '"t"', source: "argument" }]); + expect(() => wrapper({ target: themed, arguments: { title: '"t"' } })).toThrow(new WrapperError( + "`Themed` needs context theme: Theme, Log, which the preview supplies with `with(…)` from arguments keyed by context name " + + '(by type for a context receiver). Pass arguments: {"theme": "", "Log": ""}.')); + expect(() => wrapper({ target: themed, arguments: { theme: "x", Log: "y", colour: "1" } })).toThrow( + "`Themed` has no parameter named colour; its parameters are title: String and its contexts are theme: Theme, Log."); + const contextual = only("package t\ncontext(_: Palette) @Composable fun Contextual(content: @Composable () -> Unit) {}"); + expect(() => wrapper({ target: target("Row"), theme: contextual })).toThrow("Theme `Contextual` cannot wrap a preview: it needs context Palette."); + }); + + test("infers type parameters from the agent's arguments", () => { + const result = wrapper({ target: target("Picker"), arguments: { items: "listOf(1, 2)" } }); + expect(result.text).toContain("sample.components.Picker(\n items = listOf(1, 2)\n , onPick = { _ -> }\n )"); + }); + + test("names each missing parameter with its type and how to pass it", () => { + expect(() => wrapper({ target: target("MemberCard") })).toThrow(new WrapperError( + '`MemberCard` needs a value for member: Member: no placeholder fits its type. Pass arguments: {"member": ""}.')); + expect(() => wrapper({ target: target("Picker") })).toThrow(new WrapperError( + "`Picker` has type parameter T, which only an argument for a parameter whose type uses it determines " + + '(items: List, selected: T?, onPick: (T) -> Unit). Pass arguments: {"items": "", ' + + '"selected": "", "onPick": ""}.')); + expect(() => wrapper({ target: target("Unused") })).toThrow("no parameter determines its type parameter T"); + }); + + test("rejects unknown arguments, unsupported receivers and unusable themes", () => { + expect(() => wrapper({ target: target("MemberCard"), arguments: { member: "m", colour: "1", size: "2" } })).toThrow(new WrapperError( + "`MemberCard` has no parameter named colour, size; its parameters are title: String, member: Member, onClick: () -> Unit, " + + "subtitle: String?, modifier: Modifier.")); + expect(() => wrapper({ target: target("Item") })).toThrow( + "`Item` extends LazyItemScope: only ColumnScope, RowScope and BoxScope receivers can be previewed."); + const plain = only("package t\n@Composable fun Plain(dark: Boolean) {}"); + expect(() => wrapper({ target: target("Row"), theme: plain })).toThrow("its last parameter must be a composable lambda"); + const needy = only("package t\n@Composable fun Needy(palette: Palette, content: @Composable () -> Unit) {}"); + expect(() => wrapper({ target: target("Row"), theme: needy })).toThrow("no placeholder fits its required parameters palette: Palette"); + }); + + test("names wrappers deterministically from the request", () => { + const base = { target: target("MemberCard"), arguments: { member: "m", title: '"t"' }, configuration: { widthDp: 200, heightDp: 100 } }; + const name = wrapper(base).name; + expect(wrapper({ ...base, arguments: { title: '"t"', member: "m" }, configuration: { heightDp: 100, widthDp: 200 } }).name).toBe(name); + expect(wrapper({ ...base, arguments: { member: "n", title: '"t"' } }).name).not.toBe(name); + expect(wrapper({ ...base, annotate: true }).name).not.toBe(name); + expect(wrapper({ ...base, theme: THEME }).name).not.toBe(name); + expect(wrapper({ ...base, facade: "sample.components.Cards" }).name).not.toBe(name); + expect(wrapper({ ...base, configuration: { widthDp: 201, heightDp: 100 } }).name).not.toBe(name); + }); +}); diff --git a/packages/vscode/test/composeBackend.test.ts b/packages/vscode/test/composeBackend.test.ts new file mode 100644 index 0000000..0ce787e --- /dev/null +++ b/packages/vscode/test/composeBackend.test.ts @@ -0,0 +1,42 @@ +import { describe, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +describe("Compose backend lifecycle", () => { + for (const [name, fixture, scenario] of [ + ["prewarm observes renderer failure while compiler discovery is pending", "composeBackendLifecycle", "prewarm-failure"], + ["shutdown drains compiler setup before removing its workspace", "composeBackendLifecycle", "dispose-setup"], + ["concurrent previews render their own source generations", "composeBackendLifecycle", "concurrent-generations"], + ["processing confirms galleries, recovers declarations and reuses exact reverts", "composeBackendLifecycle", "processing-verification"], + ["verification cannot certify a render of superseded generated output", "composeBackendLifecycle", "processing-publication"], + ["cold initialization compiles dependencies before a single processing baseline", "composeBackendLifecycle", "cold-baseline"], + ["saved Inspect bypasses build galleries and tracks published instrumentation", "composeBackendLifecycle", "inspect-seeded"], + ["cached galleries initialize the confirmed compiler's renderer alongside processing", "composeBackendLifecycle", "renderer-generation"], + ["a replacement renderer keeps the pool size the last gallery needed", "composeBackendLifecycle", "renderer-weight"], + ["a new backend removes the workspaces of exited hosts and keeps those of running ones", "composeBackendLifecycle", "sweep"], + ["a gallery with a failed card is rendered again, never stored or replayed", "composeBackendLifecycle", "error-gallery"], + ["a gallery is stored under the JDK that rendered it, not one starting meanwhile", "composeBackendLifecycle", "renderer-identity"], + ["prewarm stops once a panel uses its backend", "composeBackendLifecycle", "prewarm-yields"], + ["a keep-alive of months keeps the unused backend instead of dropping it at once", "composeBackendLifecycle", "keep-alive"], + ["native compatibility loss retires active and idle native backends but retains layoutlib", "composeBackendLifecycle", "retire-native"], + ["a selection compiles its appendices and renders and stores only its previews, apart from the file's gallery", "composeBackendLifecycle", "selection"], + ["managed Compose modules share cold worker initialization", "composeCompilerLifecycle", "managed-compose"], + ["custom Kotlin plugins retain their project-local worker", "composeCompilerLifecycle", "custom-plugin"], + ["Elide's bundled Kotlin plugins share one worker across workspace members", "composeCompilerLifecycle", "bundled-plugins"], + ["a resource or R class rebuild keeps the warm compiler and recompiles only affected units", "composeCompilerLifecycle", "rebase"], + ["a new Kotlin toolchain stops the replaced compiler's worker", "composeCompilerLifecycle", "toolchain-switch"], + ["a failed shard preserves its diagnostic and permits a fresh renderer", "composeRenderLifecycle", "failure"], + ["the render pool follows the gallery: JVMs join when started and idle surplus stops", "composeRenderLifecycle", "resize"], + ["a selection's render reaches every shard and keeps the pool the file's gallery needs", "composeRenderLifecycle", "methods"], + ["a failed or abandoned warm-up stops only its own JVM", "composeRenderLifecycle", "warmup-failure"], + ["a long shard that reports each image is not hung; a quiet one is", "composeRenderLifecycle", "progress"], + ["compiler shutdown drains its last protocol response and blames a crash, not the distribution", "composeWorkerLifecycle", "exit"], + ["a compile that timed out gets a longer deadline on its next attempt instead of the same one", "composeWorkerLifecycle", "timeout"], + ]) { + test(name!, () => { + // Bun module replacements are process-wide; keep the real modules in other test files intact. + execFileSync(process.execPath, [fileURLToPath(new URL(`./fixtures/${fixture}.ts`, import.meta.url)), scenario!], + { encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] }); + }, 15_000); + } +}); diff --git a/packages/vscode/test/composeCompiler.test.ts b/packages/vscode/test/composeCompiler.test.ts new file mode 100644 index 0000000..597b3cf --- /dev/null +++ b/packages/vscode/test/composeCompiler.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ComposeCompiler, firstCompilerError } from "../src/composeCompiler.js"; +import { StaleBuildRecordError } from "../src/composeProcessing.js"; + +/** A built Kotlin source set with kapt, its records written with the paths under `recorded` as Elide writes them. */ +async function project(root: string, recorded = root) { + const at = (base: string) => ({ + parent: path.join(base, ".dev/jvm/classes/main"), + kotlin: path.join(base, ".dev/jvm/classes/main/kotlin"), + generated: path.join(base, ".dev/jvm/generated-sources/main/kapt"), + source: path.join(base, "src/Example.kt"), + impl: path.join(base, ".dev/jvm/generated-sources/main/kapt/Example_Impl.java"), + }); + const files = at(root); + const record = at(recorded); + await Promise.all([files.kotlin, files.generated, path.dirname(files.source)].map((directory) => mkdir(directory, { recursive: true }))); + await Promise.all([ + writeFile(files.source, "class Example"), writeFile(files.impl, "class Example_Impl {}"), + writeFile(path.join(files.parent, "kotlinc.args"), ["-d", record.kotlin, record.source, record.impl].join("\n")), + writeFile(path.join(files.parent, "kapt.args"), ["-d", record.kotlin, record.source].join("\n")), + writeFile(path.join(files.parent, "kotlinc.toolchain.json"), JSON.stringify({ version: 1, kind: "managed", classpath: [path.join(root, "compiler.jar")] })), + writeFile(path.join(files.parent, "kapt.generated.json"), JSON.stringify({ version: 1, roots: [record.generated], files: [record.impl] })), + ]); + return files; +} + +function inputs(kotlin: string) { + return { projectClassPath: [kotlin], classPath: [kotlin], namespace: "sample", resourceApkPath: "resources.apk", sourceSelections: { [kotlin]: ["src/**.*"] } }; +} + +describe("Compose compiler setup", () => { + test("failed input discovery removes each private compiler directory", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "elide-compose-compiler-test-")); + try { + for (const output of [path.join(directory, "unknown-output"), path.join(directory, ".dev", "jvm", "classes", "main", "kotlin")]) { + const workspace = path.join(directory, "workspace"); + const inputs = { classPath: [], projectClassPath: [output], sourceSelections: {}, namespace: "sample", resourceApkPath: "resources.apk" }; + await expect(ComposeCompiler.create("elide", directory, inputs, workspace, new AbortController().signal, new Map())).rejects.toThrow("Compose preview"); + expect(await readdir(workspace)).toEqual([]); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("a workspace opened through a symlink owns the canonical paths Elide records", async () => { + const directory = await realpath(await mkdtemp(path.join(tmpdir(), "elide-compose-symlink-"))); + let compiler: ComposeCompiler | undefined; + try { + const canonical = path.join(directory, "checkout"); + const opened = path.join(directory, "opened"); + await mkdir(canonical); + await symlink(canonical, opened, "dir"); + const files = await project(opened, canonical); + compiler = await ComposeCompiler.create("elide", opened, inputs(files.kotlin), path.join(directory, "private"), new AbortController().signal, new Map()); + const edited = await compiler.sources(new Map([[files.source, "class Example(val edited: Int)"]])); + expect([...edited.files[0]!]).toEqual([[files.source, "class Example(val edited: Int)"]]); + expect(compiler.supports(files.source)).toBe(true); + expect(await compiler.seed(await compiler.sources(new Map()), true)).toBe(true); + } finally { + compiler?.dispose(); + await compiler?.remove(); + await rm(directory, { recursive: true, force: true }); + } + }); + + test("records of another checkout are stale, not missing files", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "elide-compose-stale-")); + try { + const checkout = path.join(directory, "checkout"); + const other = path.join(directory, "deleted-checkout"); + const files = await project(checkout, other); + await expect(ComposeCompiler.create("elide", checkout, inputs(files.kotlin), path.join(directory, "private"), new AbortController().signal, new Map())) + .rejects.toThrow(new StaleBuildRecordError(path.join(files.parent, "kotlinc.args"), `it compiles into ${path.join(other, ".dev/jvm/classes/main/kotlin")}, not this checkout's ${files.kotlin}`)); + // Only the generated-source provenance came from the other checkout. + await writeFile(path.join(files.parent, "kotlinc.args"), ["-d", files.kotlin, files.source].join("\n")); + await writeFile(path.join(files.parent, "kapt.args"), ["-d", files.kotlin, files.source].join("\n")); + await expect(ComposeCompiler.create("elide", checkout, inputs(files.kotlin), path.join(directory, "private"), new AbortController().signal, new Map())) + .rejects.toThrow(/^Stale build record .*kapt\.generated\.json: its generated sources are in .*deleted-checkout.*--no-cache/); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("renderers load the build's classes until the compiler publishes its own copy", async () => { + const directory = await realpath(await mkdtemp(path.join(tmpdir(), "elide-compose-current-"))); + let compiler: ComposeCompiler | undefined; + try { + const files = await project(path.join(directory, "checkout")); + compiler = await ComposeCompiler.create("elide", path.join(directory, "checkout"), inputs(files.kotlin), path.join(directory, "private"), new AbortController().signal, new Map()); + // An unsaved edit keeps the build's classes from being adopted: nothing is published, and the copies are empty. + expect(await compiler.seed(await compiler.sources(new Map([[files.source, "class Example(val edited: Int)"]])), true)).toBe(false); + expect(compiler.currentInputs.projectClassPath).toEqual([files.kotlin]); + expect(compiler.currentInputs.classPath).toEqual([files.kotlin]); + expect(await compiler.seed(await compiler.sources(new Map()), true)).toBe(true); + await compiler.publish(); + expect(compiler.currentInputs).toEqual(compiler.inputs); + expect(compiler.currentInputs.projectClassPath.every((entry) => entry.startsWith(path.join(directory, "private")))).toBe(true); + } finally { + compiler?.dispose(); + await compiler?.remove(); + await rm(directory, { recursive: true, force: true }); + } + }); + + test("the status line names the first compiler error and its location, never a warning or note", () => { + expect(firstCompilerError([ + "kotlinc failed for /work/ui (main, exit 1).", + "kotlinc: -Xplugin '/opt/kotlin/lib/kotlin-compose-compiler-plugin.jar' matches the bundled 'compose' plugin. Prefer Elide's flag (`--compose` or `--plugins=compose`).", + "warning: kotlinc: Parameter 'x' is never used.", + "In file: /work/ui/src/Other.kt:3:9", + "error: kotlinc: Unresolved reference 'missing'.", + "In file: /work/ui/src/main/kotlin/Cards.kt:12:5", + " 12 | missing()", + "error: kotlinc: Second error.", + ].join("\n"))).toBe("Cards.kt:12 Unresolved reference 'missing'."); + expect(firstCompilerError("Build failed after 2.1s: elide build exited with 1.\nerror: kotlinc: Type mismatch.\n In file: src/Cards.kt:4:3\nOpen the Build log for the complete output.")) + .toBe("Cards.kt:4 Type mismatch."); + expect(firstCompilerError("javac failed for /work/app (main, exit 1).\n/work/app/src/Main.java:7: error: cannot find symbol")).toBe("Main.java:7 cannot find symbol"); + expect(firstCompilerError("kapt failed for /work/app (main, exit 1).\nerror: kapt: Room cannot verify the data integrity.")).toBe("Room cannot verify the data integrity."); + expect(firstCompilerError("Elide kotlinc worker exited (1).\nwarning: kotlinc: deprecated flag")).toBeUndefined(); + }); +}); diff --git a/packages/vscode/test/composeKomodo.test.ts b/packages/vscode/test/composeKomodo.test.ts index 9da05ef..ed35cdf 100644 --- a/packages/vscode/test/composeKomodo.test.ts +++ b/packages/vscode/test/composeKomodo.test.ts @@ -1,58 +1,81 @@ -import { describe, expect, test } from "bun:test"; -import { chmod, mkdtemp, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { KomodoRenderer, KomodoUnavailable, komodoEntries } from "../src/composeKomodo.js"; - -/** A stand-in daemon: announces `protocol`, answers configure and entries as komodo-preview does, then pushes a frame. */ -async function fakeDaemon(protocol: number, crash = false): Promise { - const directory = await mkdtemp(path.join(tmpdir(), "komodo-fake-")); - const file = path.join(directory, "komodo-preview"); - await writeFile(file, `#!/usr/bin/env node -const out = (event) => process.stdout.write(JSON.stringify(event) + "\\n"); -if (process.argv[2] !== "--protocol-only") process.exit(3); -out({ event: "ready", protocol: ${protocol}, engine: "komodo", host: "desktop", features: ["push", "multiEntry"], kotlin: "2.5.0-Beta1" }); -let buffer = ""; -process.stdin.on("data", (chunk) => { - buffer += chunk; - for (let i = buffer.indexOf("\\n"); i >= 0; i = buffer.indexOf("\\n")) { - const command = JSON.parse(buffer.slice(0, i)); - buffer = buffer.slice(i + 1); - if (${crash}) process.exit(1); - if (command.command === "configure" && command.host === "layoutlib") out({ event: "error", kind: "configure", message: "no layoutlib host", fallback: true }); - else if (command.command === "configure") out({ event: "configured", module: "main" }); - else if (command.command === "entries") { - out({ event: "entries", hosted: [command.entries[0].id], refused: command.entries.slice(1).map((e) => e.id) }); - out({ event: "frame", entry: command.entries[0].id, sequence: 1, width: 2, height: 3, png: "UE5H" }); - } - } -}); -`); - await chmod(file, 0o755); - return file; +import { afterEach, describe, expect, test } from "bun:test"; +import { createServer, type Socket } from "node:net"; +import { setTimeout as sleep } from "node:timers/promises"; +import { ImageStore } from "../src/composeBackend.js"; +import type { ComposeCompiler } from "../src/composeCompiler.js"; +import { KOMODO_PAUSED, KomodoBackend, KomodoRenderer, KomodoSessionArbiter, KomodoUnavailable, komodoEntries, previewDeclarations, type KomodoOptions } from "../src/composeKomodo.js"; +import type { CompiledPreview, PreviewImage } from "../src/composeRender.js"; +import type { ComposeRuntime } from "../src/composeRuntime.js"; + +/** An authenticated session fixture; no child process is involved. */ +async function fakeSession(protocol: number, crash = false) { + const sockets = new Set(); + const lines: string[] = []; + const server = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + socket.setEncoding("utf8"); + let buffer = ""; + let authenticated = false; + const out = (event: object) => socket.write(`${JSON.stringify(event)}\n`); + socket.on("data", (chunk) => { + buffer += chunk; + for (let i = buffer.indexOf("\n"); i >= 0; i = buffer.indexOf("\n")) { + const line = buffer.slice(0, i); + buffer = buffer.slice(i + 1); + lines.push(line); + if (!authenticated) { + if (line !== "session-token") { socket.destroy(); return; } + authenticated = true; + out({ event: "ready", protocol, host: "layoutlib", compiler: "workspace", pid: process.pid }); + continue; + } + const command = JSON.parse(line); + if (crash) { socket.destroy(); return; } + if (command.command === "configure" && command.host === "layoutlib") out({ event: "error", kind: "configure", message: "no layoutlib host", fallback: true }); + else if (command.command === "configure") out({ event: "configured", module: "main" }); + else if (command.command === "entries") { + out({ event: "entries", hosted: [command.entries[0].id], refused: command.entries.slice(1).map((e: { id: string }) => e.id) }); + out({ event: "frame", entry: command.entries[0].id, sequence: 1, width: 2, height: 3, png: "UE5H" }); + } + } + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing fixture port"); + return { + port: address.port, lines, sockets, + close: () => { for (const socket of sockets) socket.destroy(); server.close(); }, + }; } describe("Komodo engine", () => { - test("names one entry per card, with numeric @Preview parameters and the provider's expansion index", () => { + test("names a plain preview's card and leaves a provider's values for the daemon to expand", () => { const previews = [ { methodFQN: "app.MainKt.Card", previewId: "p0", name: "Card", previewParams: { widthDp: "240", name: "light", showBackground: "true" }, methodParams: [] }, - { methodFQN: "app.MainKt.Names", previewId: "p1", name: "Names", previewParams: {}, methodParams: [{ provider: "app.NamesProvider" }] }, + { methodFQN: "app.MainKt.Names", previewId: "p1", name: "Names", previewParams: {}, methodParams: [{ provider: "app.NamesProvider", limit: "2" }] }, ]; - const images = [ - { id: "p0:0", name: "Card", detail: "" }, { id: "p1:0", name: "Names", detail: "" }, { id: "p1:1", name: "Names", detail: "" }, - { id: "unknown:0", name: "?", detail: "" }, - ]; - expect(komodoEntries(images, previews)).toEqual([ + expect(komodoEntries(previews)).toEqual([ { id: "p0:0", previewId: "p0", method: "app.MainKt.Card", parameters: { widthDp: 240, name: "light", showBackground: true }, previewParams: previews[0]!.previewParams, methodParameter: null }, - { id: "p1:0", previewId: "p1", method: "app.MainKt.Names", parameters: {}, previewParams: {}, methodParameter: { provider: "app.NamesProvider", index: 0 } }, - { id: "p1:1", previewId: "p1", method: "app.MainKt.Names", parameters: {}, previewParams: {}, methodParameter: { provider: "app.NamesProvider", index: 1 } }, + { id: "p1", previewId: "p1", method: "app.MainKt.Names", parameters: {}, previewParams: {}, methodParameter: { provider: "app.NamesProvider", limit: "2" } }, ]); }); + test("needs a new discovery for declaration edits, not for body edits", () => { + const file = "package app\n\n@Preview(widthDp = 240)\n@Composable\nfun Card() {\n val label = \"one\"\n Text(label)\n}\n"; + expect(previewDeclarations(file.replace("\"one\"", "\"two\"").replace("Text(label)", "Column { Text(label) }"))).toBe(previewDeclarations(file)); + expect(previewDeclarations(file.replace("widthDp = 240", "widthDp = 320"))).not.toBe(previewDeclarations(file)); + expect(previewDeclarations(file.replace("fun Card()", "fun Card(@PreviewParameter(NamesProvider::class) name: String)"))).not.toBe(previewDeclarations(file)); + expect(previewDeclarations(`${file}\n@Preview\n@Composable\nfun Other() {}\n`)).not.toBe(previewDeclarations(file)); + }); + test("handshakes protocol 2, relays refusals with fallback and receives pushed frames", async () => { - const daemon = await KomodoRenderer.start(await fakeDaemon(2), [], () => {}, 10_000); + const session = await fakeSession(2); + const daemon = await KomodoRenderer.connect(session.port, "session-token", () => {}, 10_000); try { expect(daemon.ready.protocol).toBe(2); + expect(session.lines[0]).toBe("session-token"); daemon.send({ command: "configure", host: "layoutlib" }); const refusal = await daemon.next((event) => event.event === "error", 5_000); expect(refusal.fallback).toBe(true); @@ -65,15 +88,555 @@ describe("Komodo engine", () => { expect(await frame).toMatchObject({ entry: "a", png: "UE5H" }); } finally { daemon.dispose(); + session.close(); } }); - test("refuses a protocol 1 daemon and fails waiters when the daemon dies", async () => { - await expect(KomodoRenderer.start(await fakeDaemon(1), [], () => {}, 10_000)).rejects.toBeInstanceOf(KomodoUnavailable); - const daemon = await KomodoRenderer.start(await fakeDaemon(2, true), [], () => {}, 10_000); - const answer = daemon.next((event) => event.event === "configured", 5_000); - daemon.send({ command: "configure" }); - await expect(answer).rejects.toThrow("exited"); - expect(daemon.alive).toBe(false); + test("refuses an old protocol and fails waiters when the session closes", async () => { + const old = await fakeSession(1); + try { + await expect(KomodoRenderer.connect(old.port, "session-token", () => {}, 10_000)).rejects.toBeInstanceOf(KomodoUnavailable); + } finally { old.close(); } + const session = await fakeSession(2, true); + try { + const daemon = await KomodoRenderer.connect(session.port, "session-token", () => {}, 10_000); + const answer = daemon.next((event) => event.event === "configured", 5_000); + daemon.send({ command: "configure" }); + await expect(answer).rejects.toThrow("closed"); + expect(daemon.alive).toBe(false); + } finally { session.close(); } + }); + + test("disposal closes only its socket and the server can serve another session", async () => { + const session = await fakeSession(2); + try { + const first = await KomodoRenderer.connect(session.port, "session-token", () => {}, 10_000); + const socket = [...session.sockets][0]!; + const closed = new Promise((resolve) => socket.once("close", () => resolve())); + first.dispose(); + await closed; + expect(first.alive).toBe(false); + const next = await KomodoRenderer.connect(session.port, "session-token", () => {}, 10_000); + expect(next.alive).toBe(true); + next.dispose(); + } finally { session.close(); } + }); + + test("authentication failure rejects the handshake", async () => { + const session = await fakeSession(2); + try { + await expect(KomodoRenderer.connect(session.port, "wrong-token", () => {}, 10_000)).rejects.toThrow("closed"); + expect(session.lines).toEqual(["wrong-token"]); + } finally { session.close(); } + }); +}); + +type Command = Record & { command: string }; + +/** A protocol-2 session server as the language server runs it: every connection is one session, and every command is recorded. */ +async function fakeDaemon(options: { + features?: string[]; renders?: (id: string) => boolean; + ready?: (out: (event: object) => void) => void; + configure?: (command: Command, out: (event: object) => void) => void; +} = {}) { + const connections: { commands: Command[]; out: (event: object) => void; socket: Socket }[] = []; + const hooks: { command?: (command: Command, out: (event: object) => void) => void } = {}; + const server = createServer((socket) => { + // Cancellation can race a deliberately delayed response in these lifecycle fixtures. + socket.on("error", () => {}); + socket.setEncoding("utf8"); + const out = (event: object) => socket.write(`${JSON.stringify(event)}\n`); + const connection = { commands: [] as Command[], out, socket }; + connections.push(connection); + let buffer = ""; + let authenticated = false; + socket.on("data", (chunk) => { + buffer += chunk; + for (let i = buffer.indexOf("\n"); i >= 0; i = buffer.indexOf("\n")) { + const line = buffer.slice(0, i); + buffer = buffer.slice(i + 1); + if (!authenticated) { + authenticated = true; + if (options.ready) options.ready(out); + else out({ event: "ready", protocol: 2, host: "layoutlib", compiler: "workspace-fir", features: options.features ?? ["reconfigure", "texts"] }); + continue; + } + const command = JSON.parse(line) as Command; + connection.commands.push(command); + if (command.command === "configure") { + if (options.configure) options.configure(command, out); + else out({ event: "configured", module: "main" }); + } + if (command.command === "entries") { + const ids = (command.entries as { id: string }[]).map((entry) => entry.id); + out({ event: "entries", hosted: ids, refused: [] }); + for (const id of ids.filter(options.renders ?? (() => true))) out({ event: "frame", entry: id, sequence: 1, width: 2, height: 3, png: "UE5H" }); + } + hooks.command?.(command, out); + } + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing fixture port"); + return { + port: address.port, connections, hooks, + commands: (name: string) => connections.flatMap((connection) => connection.commands.filter((command) => command.command === name)), + close: () => { for (const connection of connections) connection.socket.destroy(); server.close(); }, + }; +} + +/** The inputs of a preview compiler whose project classes were published under `published`. */ +function fakeCompiler(published: string): ComposeCompiler { + const inputs = { namespace: "app", resourceApkPath: "/build/resources.apk", classPath: ["/libs/compose.jar", published], projectClassPath: [published], sourceSelections: {} }; + return { fingerprint: published, inputs, currentInputs: inputs, compileClassPath: ["/libs/compose.jar"], supports: () => true } as unknown as ComposeCompiler; +} + +/** The engine over a stand-in compiler; `replaceCompiler` is what `configure` does when a build replaces it. */ +class TestBackend extends KomodoBackend { + protected override readonly firstFrameMs = 200; + stub: ComposeCompiler | undefined = fakeCompiler("/old/published"); + override get compiler(): ComposeCompiler | undefined { return this.stub; } + replaceCompiler(next: ComposeCompiler): Promise { + const previous = this.stub!; + this.stub = next; + return Promise.resolve(this.retire(previous)); + } +} + +/** Resolves once the client closed this server-side connection. */ +function serverClosed(socket: Socket): Promise { + return socket.destroyed ? Promise.resolve() : new Promise((resolve) => socket.once("close", () => resolve())); +} + +const file = "/nonexistent/app/src/main/kotlin/app/Main.kt"; +const card = "package app\n\n@Preview\n@Composable\nfun Card() {\n Text(\"one\")\n}\n"; +const preview = (name: string): CompiledPreview => ({ methodFQN: `app.MainKt.${name}`, previewId: name, name, previewParams: {}, methodParams: [] }); +const request = (text: string) => ({ documents: new Map([[file, text]]), sourceFile: file, packageName: "app" }); +const runtime = { rendererJar: "/runtime/renderer.jar", layoutlibPath: "/runtime/layoutlib", rendererClassPath: [] } as ComposeRuntime; + +describe("Komodo engine sessions", () => { + const cleanups: (() => void)[] = []; + afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); }); + + async function engine(daemon: { port: number; close(): void }, options: Partial = {}) { + const backend = new TestBackend("key", "/nonexistent/cache", "/nonexistent/helpers", new ImageStore("/nonexistent/images"), { + previews: async () => ({ previews: [preview("Card"), preview("Other")], errors: [] }), + session: async () => ({ protocol: 2, port: daemon.port, token: "session-token" }), + sync: async () => {}, + log: () => {}, + ...options, + }); + cleanups.push(() => { backend.dispose(); daemon.close(); }); + await backend.startRenderer(runtime); + return backend; + } + + test("preflight checks buffer synchronization before opening a native session", async () => { + const daemon = await fakeDaemon(); + let opened = false; + const backend = await engine(daemon, { + sync: async () => { throw new Error("This server cannot synchronize preview buffers"); }, + session: async () => { opened = true; return { protocol: 2, port: daemon.port, token: "session-token" }; }, + }); + await expect(backend.prepareSession(file)).rejects.toThrow("cannot synchronize"); + expect(opened).toBe(false); + expect(daemon.commands("configure")).toEqual([]); + }); + + test("cancelling a preflight closes its handshake socket without waiting for ready", async () => { + const authenticating = Promise.withResolvers(); + const daemon = await fakeDaemon({ ready: () => authenticating.resolve() }); + const backend = await engine(daemon); + const cancel = new AbortController(); + const result = backend.prepareSession(file, false, cancel.signal).catch((error: unknown) => error); + await authenticating.promise; + cancel.abort(); + expect(await result).toBeInstanceOf(Error); + await serverClosed(daemon.connections[0]!.socket); + expect(daemon.commands("configure")).toEqual([]); + }); + + test("preflight validates and reuses the session before configuring any project inputs", async () => { + const daemon = await fakeDaemon(); + const backend = await engine(daemon); + await backend.prepareSession(file); + expect(daemon.connections).toHaveLength(1); + expect(daemon.commands("configure")).toEqual([]); + await backend.preview(request(card)); + expect(daemon.connections).toHaveLength(1); + expect(daemon.commands("configure")).toHaveLength(1); + }); + + test("preflight reports protocol incompatibility without connecting or configuring a daemon", async () => { + const daemon = await fakeDaemon(); + const unavailable: string[] = []; + const backend = await engine(daemon, { + session: async () => ({ protocol: 1, port: daemon.port, token: "session-token" }), + unavailable: (reason) => unavailable.push(reason), + }); + await expect(backend.prepareSession(file)).rejects.toBeInstanceOf(KomodoUnavailable); + expect(unavailable).toHaveLength(1); + expect(daemon.connections).toEqual([]); + expect(daemon.commands("configure")).toEqual([]); + }); + + test("preflight rejects an incompatible renderer or missing appendices before any configure", async () => { + for (const ready of [ + { event: "ready", protocol: 1, compiler: "workspace-fir" }, + { event: "ready", protocol: 2, compiler: "workspace" }, + { event: "ready", protocol: 2, compiler: "workspace-fir", features: [] }, + ]) { + const daemon = await fakeDaemon({ ready: (out) => out(ready) }); + const unavailable: string[] = []; + const backend = await engine(daemon, { unavailable: (reason) => { unavailable.push(reason); backend.dispose(); } }); + await expect(backend.prepareSession(file, true)).rejects.toBeInstanceOf(KomodoUnavailable); + expect(unavailable).toHaveLength(1); + expect(daemon.commands("configure")).toEqual([]); + await serverClosed(daemon.connections[0]!.socket); + } + }); + + test("disposing during preflight cannot adopt its late endpoint", async () => { + const opening = Promise.withResolvers(); + const endpoint = Promise.withResolvers<{ protocol: number; port: number; token: string }>(); + const daemon = await fakeDaemon(); + const backend = await engine(daemon, { session: () => { opening.resolve(); return endpoint.promise; } }); + const result = backend.prepareSession(file).catch((error: unknown) => error); + await opening.promise; + backend.dispose(); + endpoint.resolve({ protocol: 2, port: daemon.port, token: "session-token" }); + expect(await result).toBeInstanceOf(Error); + expect(daemon.connections).toEqual([]); + }); + + test("disposing during the ready handshake closes the socket without waiting for ready", async () => { + const authenticating = Promise.withResolvers(); + const daemon = await fakeDaemon({ ready: () => authenticating.resolve() }); + const backend = await engine(daemon); + const result = backend.preview(request(card)).catch((error: unknown) => error); + await authenticating.promise; + backend.dispose(); + await serverClosed(daemon.connections[0]!.socket); + expect(await result).toBeInstanceOf(Error); + expect(daemon.commands("configure")).toEqual([]); + }); + + for (const reused of [false, true]) test(`disposing during ${reused ? "reused" : "initial"} configuration cannot publish a late gallery`, async () => { + const configuring = Promise.withResolvers<(event: object) => void>(); + let hold = !reused; + const daemon = await fakeDaemon({ + configure: (_command, out) => { + if (hold) configuring.resolve(out); + else out({ event: "configured", module: "main" }); + }, + }); + let previews = [preview("Card")]; + const backend = await engine(daemon, { previews: async () => ({ previews, errors: [] }) }); + if (reused) await backend.preview(request(card)); + const published: unknown[] = []; + backend.onCompile((outcome) => published.push(outcome)); + hold = true; + previews = [preview("Card"), preview("New")]; + const result = backend.preview(request(`${card}\n@Preview\nfun New() {}\n`)).catch((error: unknown) => error); + const out = await configuring.promise; + const closed = serverClosed(daemon.connections[0]!.socket); + // Queue stale replies on the open peer, then dispose before the client can process TCP events. + // Writing after disposal instead races the platform's socket teardown, not the backend lifecycle. + out({ event: "compile", outcome: "failure", messages: [{ level: "error", text: "obsolete compile" }] }); + out({ event: "configured", module: "main" }); + backend.dispose(); + expect(await result).toBeInstanceOf(Error); + await closed; + expect(published).toEqual([]); + expect(daemon.commands("entries")).toHaveLength(reused ? 1 : 0); + }); + + test("disposing while the session request is pending never connects its late endpoint", async () => { + const opening = Promise.withResolvers(); + const endpoint = Promise.withResolvers<{ protocol: number; port: number; token: string }>(); + const daemon = await fakeDaemon(); + const backend = await engine(daemon, { + session: () => { opening.resolve(); return endpoint.promise; }, + }); + const result = backend.preview(request(card)).catch((error: unknown) => error); + await opening.promise; + backend.dispose(); + endpoint.resolve({ protocol: 2, port: daemon.port, token: "session-token" }); + expect(await result).toBeInstanceOf(Error); + expect(daemon.connections).toEqual([]); + }); + + test("a retired session's delayed synchronization failure never publishes diagnostics into its replacement", async () => { + const syncing = Promise.withResolvers(); + const synchronized = Promise.withResolvers(); + let hold = false; + const daemon = await fakeDaemon(); + const backend = await engine(daemon, { + sync: async () => { + if (!hold) return; + hold = false; + syncing.resolve(); + await synchronized.promise; + }, + }); + await backend.preview(request(card)); + const failures: unknown[] = []; + backend.onCompile((outcome) => { if (outcome.failed) failures.push(outcome); }); + hold = true; + backend.edit(file, card.replace("one", "two")); + await syncing.promise; + await backend.replaceCompiler(fakeCompiler("/new/published")); + synchronized.reject(new Error("old language server stopped")); + const gallery = await backend.preview(request(card.replace("one", "two"))); + expect(gallery.images.every((image) => image.live)).toBe(true); + expect(daemon.connections).toHaveLength(2); + expect(failures).toEqual([]); + }); + + test("preempting a panel during configuration closes its unpublished session and returns paused cards", async () => { + const configuring = Promise.withResolvers(); + const daemon = await fakeDaemon({ configure: () => configuring.resolve() }); + const arbiter = new KomodoSessionArbiter(); + const panel = await engine(daemon, { arbiter }); + const agent = await engine(daemon, { arbiter, role: "agent" }); + const pending = panel.preview(request(card)); + await configuring.promise; + arbiter.setActive(agent, true); + expect(arbiter.claim(agent)).toBe(true); + const gallery = await pending; + expect(gallery.images.map((image) => image.error)).toEqual([KOMODO_PAUSED, KOMODO_PAUSED]); + await serverClosed(daemon.connections[0]!.socket); + expect(daemon.commands("entries")).toEqual([]); + }); + + test("a replaced compiler's session receives no edit and closes, and the next preview configures over the new inputs", async () => { + const daemon = await fakeDaemon(); + let syncing: PromiseWithResolvers | undefined; + const backend = await engine(daemon, { sync: async () => { await syncing?.promise; } }); + await backend.preview(request(card)); + // An edit waiting for the language server's buffers when a save's build replaces the compiler and deletes its classes. + syncing = Promise.withResolvers(); + backend.edit(file, card.replace("one", "two")); + const retired = backend.replaceCompiler(fakeCompiler("/new/published")); + syncing.resolve(); + syncing = undefined; + await retired; + backend.edit(file, card.replace("one", "three")); + const gallery = await backend.preview(request(card.replace("one", "three"))); + expect(daemon.commands("sources")).toEqual([]); + // The retired session's process may still read the deleted inputs: it is not reconfigured. + expect(daemon.connections).toHaveLength(2); + await serverClosed(daemon.connections[0]!.socket); + const configures = daemon.commands("configure"); + expect(configures.map((command) => command.projectClassPath)).toEqual([["/old/published"], ["/new/published"]]); + expect((configures[1]!.texts as Record)[file]).toContain("three"); + expect(gallery.images.map((image) => image.live)).toEqual([true, true]); + }); + + test("a replaced compiler's classes outlive the compile its session is running", async () => { + const daemon = await fakeDaemon(); + const backend = await engine(daemon); + await backend.preview(request(card)); + const sent = Promise.withResolvers(); + daemon.hooks.command = (command) => { if (command.command === "sources") sent.resolve(); }; + backend.edit(file, card.replace("one", "two")); + await sent.promise; + let retired = false; + const retiring = backend.replaceCompiler(fakeCompiler("/new/published")).then(() => { retired = true; }); + await sleep(100); + expect(retired).toBe(false); + daemon.connections[0]!.out({ event: "compile", outcome: "success", messages: [] }); + await retiring; + await serverClosed(daemon.connections[0]!.socket); + }); + + test("a session that closes on its own fails its cards with the reason and asks for one new preview", async () => { + const daemon = await fakeDaemon(); + const backend = await engine(daemon); + const gallery = await backend.preview(request(card)); + const published: PreviewImage[] = []; + backend.onImages((images) => published.push(...images)); + let lost = 0; + const closed = Promise.withResolvers(); + backend.onSessionLost(() => { lost++; closed.resolve(); }); + daemon.connections[0]!.out({ event: "error", message: "IrGenerationExtensionException: NoSuchFileException /old/published\n\tat Compiler" }); + daemon.connections[0]!.socket.end(); + await closed.promise; + expect(lost).toBe(1); + expect(published.map((image) => [image.id, image.error])).toEqual(["Card:0", "Other:0"].map((id) => + [id, "Komodo: the preview session closed (IrGenerationExtensionException: NoSuchFileException /old/published)"])); + expect(backend.latestImages(gallery.images).every((image) => image.error && !image.live)).toBe(true); + expect(backend.pointer("Card:0", "down", 1, 1)).toBe(false); + // The panel's preview reconnects; closing it on purpose is no lost session. + const again = await backend.preview(request(card)); + expect(daemon.connections).toHaveLength(2); + expect(again.images.map((image) => image.live)).toEqual([true, true]); + backend.dispose(); + expect(lost).toBe(1); + }); + + test("a card without a first frame fails alone and the session stays live", async () => { + const daemon = await fakeDaemon({ renders: (id) => id !== "Other:0" }); + const backend = await engine(daemon); + const gallery = await backend.preview(request(card)); + expect(gallery.images[0]!.live).toBe(true); + expect(gallery.images[1]!.error).toStartWith("Komodo: no frame was rendered within"); + expect(backend.pointer("Card:0", "down", 1, 1)).toBe(true); + expect(backend.pointer("Other:0", "down", 1, 1)).toBe(false); + const pushed = Promise.withResolvers(); + daemon.hooks.command = (command) => { if (command.command === "sources") pushed.resolve(command); }; + backend.edit(file, card.replace("one", "two")); + expect((await pushed.promise).files).toEqual({ [file]: card.replace("one", "two") }); + expect(daemon.connections).toHaveLength(1); + }); + + test("a preview declared while the file has errors is discovered once a body edit fixes them", async () => { + const daemon = await fakeDaemon(); + const answers = [ + { previews: [preview("Card")], errors: [] }, + { previews: [preview("Card"), preview("New")], errors: [{ message: "Expecting ')'" }] }, + { previews: [preview("Card"), preview("New")], errors: [] }, + ]; + let discoveries = 0; + const backend = await engine(daemon, { previews: async () => answers[discoveries++]! }); + await backend.preview(request(card)); + const added = `${card}\n@Preview\n@Composable\nfun New() {\n Text(\n}\n`; + expect((await backend.preview(request(added))).images.map((image) => image.id)).toEqual(["Card:0"]); + const fixed = (await backend.preview(request(added.replace("Text(\n", "Text(\"new\")\n")))).images; + expect(discoveries).toBe(3); + expect(fixed.map((image) => [image.id, image.live])).toEqual([["Card:0", true], ["New:0", true]]); + }); + + test("a delta rejected while a preview discovers configures the same session again after it", async () => { + const daemon = await fakeDaemon(); + const discovering = Promise.withResolvers(); + let discoveries = 0; + const backend = await engine(daemon, { + previews: async () => { + if (discoveries++ === 0) return { previews: [preview("Card"), preview("Other")], errors: [] }; + await discovering.promise; + return { previews: [preview("Card")], errors: [] }; + }, + }); + const other = `${card}\n@Preview\n@Composable\nfun Other() {}\n`; + await backend.preview(request(other)); + daemon.hooks.command = (command, out) => { + if (command.command !== "sources") return; + out({ event: "error", kind: "rejected", restartRequired: true, message: "Other was removed" }); + // Events are handled in order: this compile outcome tells the test that the rejection was. + out({ event: "compile", outcome: "success", messages: [] }); + }; + const rejected = Promise.withResolvers(); + backend.onCompile(() => rejected.resolve()); + const recovered = Promise.withResolvers(); + backend.onImages((images) => { if (images.length === 1 && images[0]!.live) recovered.resolve(); }); + const removing = backend.preview(request(card)); + await rejected.promise; + discovering.resolve(); + expect((await removing).images.map((image) => image.id)).toEqual(["Card:0"]); + await recovered.promise; + expect(daemon.connections).toHaveLength(1); + expect(daemon.commands("configure")).toHaveLength(2); + expect((daemon.commands("configure")[1]!.texts as Record)[file]).toBe(card); + }); + + const wrapper = "\n\n@Composable\nfun ElideAgentPreview_1() {\n Card()\n}\n"; + const selected = (text: string) => ({ ...request(text), + selection: { appendices: new Map([[file, wrapper]]), previews: [preview("ElideAgentPreview_1")], files: [] as string[] } }); + + test("a selection hosts its previews without discovery and configures its appendices", async () => { + const daemon = await fakeDaemon({ features: ["reconfigure", "texts", "appendices"] }); + let discoveries = 0; + const backend = await engine(daemon, { previews: async () => { discoveries++; return { previews: [preview("Card")], errors: [] }; } }); + const gallery = await backend.preview(selected(card)); + expect(discoveries).toBe(0); + expect(gallery.images.map((image) => [image.id, image.live])).toEqual([["ElideAgentPreview_1:0", true]]); + const [configure] = daemon.commands("configure"); + expect(configure!.appendices).toEqual({ [file]: wrapper }); + expect((configure!.texts as Record)[file]).toBe(card); + expect((daemon.commands("entries")[0]!.entries as { method: string }[]).map((entry) => entry.method)).toEqual(["app.MainKt.ElideAgentPreview_1"]); + // A full-file request is another session's program. + await backend.preview(request(card)); + expect(discoveries).toBe(1); + expect(daemon.commands("configure")[1]!.appendices).toBeUndefined(); + }); + + test("a daemon without appendices fails a selection with the upgrade", async () => { + const daemon = await fakeDaemon(); + const backend = await engine(daemon); + const failure = await backend.preview(selected(card)).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(KomodoUnavailable); + expect((failure as Error).message).toContain("compatible Komodo"); + expect(daemon.commands("configure")).toEqual([]); + }); + + test("an agent preempts the panel, which pauses and resumes once the agent is done", async () => { + const daemon = await fakeDaemon({ features: ["reconfigure", "texts", "appendices"] }); + const arbiter = new KomodoSessionArbiter(); + const panel = await engine(daemon, { arbiter }); + const agent = await engine(daemon, { arbiter, role: "agent" }); + await panel.preview(request(card)); + const pushed: PreviewImage[] = []; + panel.onImages((images) => pushed.push(...images)); + let lost = 0; + panel.onSessionLost(() => lost++); + arbiter.setActive(agent, true); + await agent.preview(selected(card)); + expect(pushed.map((image) => [image.id, image.error])).toEqual([["Card:0", KOMODO_PAUSED], ["Other:0", KOMODO_PAUSED]]); + expect(lost).toBe(0); + await serverClosed(daemon.connections[0]!.socket); + // The paused panel shows why and opens no session. + const paused = await panel.preview(request(card)); + expect(paused.images.map((image) => [image.id, image.error])).toEqual([["Card:0", KOMODO_PAUSED], ["Other:0", KOMODO_PAUSED]]); + expect(daemon.connections).toHaveLength(2); + arbiter.setActive(agent, false); + expect(lost).toBe(1); + // The panel previews again and takes the session from the inactive agent. + const agentCards: PreviewImage[] = []; + agent.onImages((images) => agentCards.push(...images)); + const resumed = await panel.preview(request(card)); + expect(resumed.images.map((image) => image.live)).toEqual([true, true]); + expect(daemon.connections).toHaveLength(3); + expect(agentCards.map((image) => image.id)).toEqual(["ElideAgentPreview_1:0"]); + expect(agentCards[0]!.error).toContain("took the preview session"); + expect(agent.pointer("ElideAgentPreview_1:0", "down", 1, 1)).toBe(false); + }); + + test("a panel an agent preempts while it hosts its cards shows them paused, not failed", async () => { + const daemon = await fakeDaemon({ features: ["reconfigure", "texts", "appendices"], renders: () => false }); + const arbiter = new KomodoSessionArbiter(); + const panel = await engine(daemon, { arbiter }); + const agent = await engine(daemon, { arbiter, role: "agent" }); + const hosting = Promise.withResolvers(); + daemon.hooks.command = (command) => { if (command.command === "entries") hosting.resolve(); }; + const previewing = panel.preview(request(card)); + await hosting.promise; + arbiter.setActive(agent, true); + expect(arbiter.claim(agent)).toBe(true); + const gallery = await previewing; + expect(gallery.images.map((image) => [image.id, image.error])).toEqual([["Card:0", KOMODO_PAUSED], ["Other:0", KOMODO_PAUSED]]); + expect(panel.paused).toBe(true); + }); + + test("a panel behind an agent in use pauses without claiming; scroll reaches a live card", async () => { + const daemon = await fakeDaemon({ features: ["reconfigure", "texts", "appendices"] }); + const arbiter = new KomodoSessionArbiter(); + const agent = await engine(daemon, { arbiter, role: "agent" }); + const panel = await engine(daemon, { arbiter }); + arbiter.setActive(agent, true); + await agent.preview(selected(card)); + const pointed = Promise.withResolvers(); + daemon.hooks.command = (command) => { if (command.command === "pointer") pointed.resolve(command); }; + expect(agent.pointer("ElideAgentPreview_1:0", "scroll", 1, 2, 0, -40)).toBe(true); + expect(await pointed.promise).toMatchObject({ entry: "ElideAgentPreview_1:0", action: "scroll", x: 1, y: 2, dx: 0, dy: -40 }); + let lost = 0; + panel.onSessionLost(() => lost++); + const paused = await panel.preview(request(card)); + expect(paused.images.every((image) => image.error === KOMODO_PAUSED)).toBe(true); + expect(daemon.connections).toHaveLength(1); + agent.dispose(); + expect(lost).toBe(1); + expect((await panel.preview(request(card))).images.map((image) => image.live)).toEqual([true, true]); }); }); diff --git a/packages/vscode/test/composePreview.test.ts b/packages/vscode/test/composePreview.test.ts new file mode 100644 index 0000000..325d800 --- /dev/null +++ b/packages/vscode/test/composePreview.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +// Isolate module mocks from the backend and other extension tests in this process. +for (const scenario of ["close", "failed-build", "prewarm-close", "prewarm-failure", "stale-record", "processing", "processor-failure", "processing-recovery", "superseded-processing", "stale-paint", "cold-initialization", "inspect", "availability", + "animation", "replay", "reveal", "rename", "session-lost", "sync-wait", "sync-failure", "restore", "cancel", "rebuild", "log-replay", "delete-during-build", + "komodo-missing", "komodo-incompatible", "komodo-loss", "layoutlib-native-loss"]) { + test(`Compose session lifecycle: ${scenario}`, () => { + const result = spawnSync(process.execPath, [new URL("./fixtures/composePreviewLifecycle.ts", import.meta.url).pathname, scenario], { + encoding: "utf8", timeout: 10_000, + }); + expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); + }); +} diff --git a/packages/vscode/test/composeProcessing.test.ts b/packages/vscode/test/composeProcessing.test.ts new file mode 100644 index 0000000..8f09f04 --- /dev/null +++ b/packages/vscode/test/composeProcessing.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ComposeCompiler } from "../src/composeCompiler.js"; +import { ComposeProcessing, decodeProcessorOptions, encodeProcessorOptions, stubFingerprint } from "../src/composeProcessing.js"; +import { WorkspacePaths } from "../src/workspacePaths.js"; + +describe("Compose annotation processing inputs", () => { + test("generated source editors and later build writes cannot replace frozen processing inputs", async () => { + const root = await mkdtemp(path.join(tmpdir(), "elide-compose-provenance-")); + let compiler: ComposeCompiler | undefined; + try { + const parent = path.join(root, ".dev/jvm/classes/main"); + const kotlin = path.join(parent, "kotlin"); + const java = path.join(parent, "java"); + const generated = path.join(root, ".dev/jvm/generated-sources/main/kapt"); + const source = path.join(root, "src/Example.kt"); + const impl = path.join(generated, "Example_Impl.java"); + const stubs = path.join(root, ".dev/jvm/kapt-stubs/main"); + await Promise.all([kotlin, java, generated, stubs, path.dirname(source)].map((directory) => mkdir(directory, { recursive: true }))); + await Promise.all([ + writeFile(source, "class Example"), writeFile(impl, "class Example_Impl {}"), + writeFile(path.join(parent, "kotlinc.args"), ["-d", kotlin, source, impl].join("\n")), + writeFile(path.join(parent, "javac.args"), ["-d", java, "-proc:none", impl].join("\n")), + writeFile(path.join(parent, "kapt.args"), ["-d", kotlin, source, "-P", `plugin:org.jetbrains.kotlin.kapt3:stubs=${stubs}`].join("\n")), + writeFile(path.join(parent, "kotlinc.toolchain.json"), JSON.stringify({ version: 1, kind: "managed", classpath: [path.join(root, "compiler.jar")] })), + writeFile(path.join(parent, "kapt.generated.json"), JSON.stringify({ version: 1, roots: [generated, kotlin], files: [impl] })), + ]); + compiler = await ComposeCompiler.create("elide", root, { + projectClassPath: [kotlin, java], classPath: [kotlin, java], namespace: "sample", resourceApkPath: "resources.apk", + sourceSelections: { [kotlin]: ["src/**.*"], [java]: ["src/**.*"] }, + }, path.join(root, "private"), new AbortController().signal, new Map()); + const before = await compiler.sources(new Map()); + await writeFile(impl, "class WrongBuildGeneration {}"); + const generatedEdit = await compiler.sources(new Map([[impl, "class UnsavedGeneratedEdit {}"]])); + expect(generatedEdit.key).toBe(before.key); + expect(compiler.supports(source)).toBe(true); + expect(compiler.supports(impl)).toBe(false); + const edited = await compiler.sources(new Map([[source, "class Example(val value: Int)"]])); + expect(edited.key).not.toBe(before.key); + expect(edited.files.some((files) => files.get(source) === "class Example(val value: Int)")).toBe(true); + expect(compiler.isVerified(edited.key)).toBe(false); + } finally { + compiler?.dispose(); + await compiler?.remove(); + await rm(root, { recursive: true, force: true }); + } + }); + + test("stub declaration annotations and removals invalidate processing, diagnostic offsets do not", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "elide-compose-stubs-")); + try { + const file = path.join(directory, "Dao.java"); + const declaration = '@Query("SELECT * FROM journeys") public java.util.List journeys();'; + await writeFile(file, declaration); + await writeFile(path.join(directory, "Dao.kapt_metadata"), "positions:1,2"); + const baseline = await stubFingerprint(directory); + await writeFile(path.join(directory, "Dao.kapt_metadata"), "positions:1,200"); + expect(await stubFingerprint(directory)).toBe(baseline); + await writeFile(file, declaration.replace("SELECT *", "SELECT missing_column")); + expect(await stubFingerprint(directory)).not.toBe(baseline); + await rm(file); + expect(await stubFingerprint(directory)).not.toBe(baseline); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + + test("kapt options preserve Java modified UTF and long block data when private paths are substituted", () => { + const options = new Map([["room.schemaLocation", "/tmp/schema"], ["unicode", "nul\0 astral \ud83d\ude80"], ["long", "x".repeat(2_000)]]); + const encoded = encodeProcessorOptions(options); + const decoded = decodeProcessorOptions(encoded); + expect(decoded).toEqual(options); + decoded.set("room.schemaLocation", "/private/schema"); + expect(decodeProcessorOptions(encodeProcessorOptions(decoded))).toEqual(new Map([...options, ["room.schemaLocation", "/private/schema"]])); + expect(() => decodeProcessorOptions("AAAAAA==")).toThrow("Invalid kapt"); + }); + test("processing confirms a fresh baseline and commits source-output resources only after success", async () => { + const root = await mkdtemp(path.join(tmpdir(), "elide-compose-processing-")); + try { + const generated = path.join(root, "build-sources"); + const classes = path.join(root, "build-classes"); + const stubs = path.join(root, "build-stubs"); + await Promise.all([generated, classes, stubs].map((directory) => mkdir(directory, { recursive: true }))); + const source = path.join(generated, "Generated.java"); + await writeFile(source, "class Generated {}"); + await writeFile(path.join(stubs, "Input.java"), "class Input {}"); + const args = ["-d", classes, ...Object.entries({ + aptMode: "stubsAndApt", sources: generated, stubs, classes, + dumpFileReadHistory: path.join(root, "build-read-history"), + }).flatMap(([key, value]) => ["-P", `plugin:org.jetbrains.kotlin.kapt3:${key}=${value}`])]; + await writeFile(path.join(root, "kapt.args"), args.join("\n")); + await writeFile(path.join(root, "kapt.generated.json"), JSON.stringify({ version: 1, roots: [generated, classes], files: [source] })); + const stage = (await ComposeProcessing.create(path.join(root, "kapt.args"), path.join(root, "private"), root, await WorkspacePaths.of([root])))!; + let declaration = "class Input {}"; + let fail = false; + let emitResource = true; + let generatedText = "class Generated { int version; }"; + let resource = "new-schema"; + let emitSource = true; + const run = async (invocation: string[]) => { + const options = Object.fromEntries(invocation.filter((arg) => arg.startsWith("plugin:org.jetbrains.kotlin.kapt3:")).map((arg) => { + const value = arg.slice("plugin:org.jetbrains.kotlin.kapt3:".length); + const equals = value.indexOf("="); + return [value.slice(0, equals), value.slice(equals + 1)]; + })); + await writeFile(path.join(options.stubs!, "Input.java"), declaration); + if (options.aptMode !== "apt") return; + await writeFile(options.dumpFileReadHistory!, "private diagnostic output"); + if (emitSource) await writeFile(path.join(options.sources!, "Generated.java"), generatedText); + if (emitResource) await writeFile(path.join(options.sources!, "schema.json"), resource); + if (fail) throw new Error("processor failed after writing outputs"); + }; + expect(await stage.verify(args, "current-inputs", run)).toBe(true); + expect(await readFile(path.join(stage.classes, "schema.json"), "utf8")).toBe("new-schema"); + expect(await readFile(source, "utf8")).toBe("class Generated {}"); + await expect(readFile(path.join(root, "build-read-history"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await stage.verify(args, "current-inputs", run)).toBe(false); + declaration = "class Input { int added; }"; + expect(await stage.verify(args, "current-inputs", run)).toBe(false); + expect(stage.counts.apt).toBe(2); + expect(await stage.verify(args, "current-inputs", run)).toBe(false); + expect(stage.counts.apt).toBe(2); + expect(await stage.verify(args, "changed-classpath", run)).toBe(false); + expect(stage.counts.apt).toBe(3); + declaration = "class Input { int added; int changedAgain; }"; + fail = true; + emitResource = false; + await expect(stage.verify(args, "current-inputs", run)).rejects.toThrow("processor failed"); + expect(await readFile(path.join(stage.classes, "schema.json"), "utf8")).toBe("new-schema"); + fail = false; + expect(await stage.verify(args, "current-inputs", run)).toBe(true); + await expect(readFile(path.join(stage.classes, "schema.json"))).rejects.toMatchObject({ code: "ENOENT" }); + generatedText = "class Generated { String version; }"; + expect(await stage.verify(args, "source-change", run)).toBe(true); + expect(stage.generated.get(source)).toBe(generatedText); + emitResource = true; + expect(await stage.verify(args, "resource-added", run)).toBe(true); + resource = "changed-schema"; + expect(await stage.verify(args, "resource-changed", run)).toBe(true); + expect(await readFile(path.join(stage.classes, "schema.json"), "utf8")).toBe(resource); + emitSource = false; + expect(await stage.verify(args, "source-removed", run)).toBe(true); + expect(stage.generated.has(source)).toBe(false); + expect(await stage.verify(args, "unchanged-empty-sources", run)).toBe(false); + } finally { await rm(root, { recursive: true, force: true }); } + }); +}); diff --git a/packages/vscode/test/composeRender.test.ts b/packages/vscode/test/composeRender.test.ts new file mode 100644 index 0000000..9f931a1 --- /dev/null +++ b/packages/vscode/test/composeRender.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { renderWorkersFor, screenshotErrorText } from "../src/composeRender.js"; + +describe("Compose renderer diagnostics", () => { + test("a composable's exception reads as its stack trace, reported once", () => { + const cause = "java.lang.IllegalStateException: preview failure\n\tat sample.app.MemberPreviews.MemberCard(PreviewGallery.kt:72)\n\tat java.base/java.lang.reflect.Method.invoke(Method.java:565)"; + const text = screenshotErrorText({ + status: "SUCCESS", message: "", stackTrace: "", + problems: [ + { html: "View layout failed (Details)", stackTrace: cause }, + { html: "Exception raised during rendering (Details)", stackTrace: `java.lang.reflect.InvocationTargetException\n\tat java.base/java.lang.reflect.Method.invoke(Method.java:565)\nCaused by: ${cause.split("\n")[0]}\n\t... 78 more` }, + ], + brokenClasses: [], missingClasses: [], + }); + expect(text).toBe(`View layout failed\n${cause}`); + }); + + test("render setup failures keep their message and the classes layoutlib could not load", () => { + expect(screenshotErrorText({ status: "ERROR_INFLATION", message: "Inflation failed", stackTrace: "", problems: [], missingClasses: ["sample.Missing"], brokenClasses: [{ className: "sample.Broken", stackTrace: "java.lang.ExceptionInInitializerError" }] })) + .toBe("Inflation failed\n\nMissing classes:\nsample.Missing\n\nClasses that could not be initialized:\nsample.Broken\njava.lang.ExceptionInInitializerError"); + expect(screenshotErrorText({ status: "ERROR_UNKNOWN", problems: [] })).toBe("The renderer reported a failure without details (status ERROR_UNKNOWN)."); + }); +}); + +describe("Compose render pool", () => { + test("one JVM per few previews, at most four and half the cores", () => { + expect([0, 1, 4, 5, 11, 13, 40].map((weight) => renderWorkersFor(weight, 16))).toEqual([1, 1, 1, 2, 3, 4, 4]); + expect(renderWorkersFor(40, 4)).toBe(2); + expect(renderWorkersFor(40, 1)).toBe(1); + }); +}); diff --git a/packages/vscode/test/composeRuntime.test.ts b/packages/vscode/test/composeRuntime.test.ts new file mode 100644 index 0000000..5990b99 --- /dev/null +++ b/packages/vscode/test/composeRuntime.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { sweepAbandonedInstalls } from "../src/composeRuntime.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + +describe("Compose runtime cache", () => { + test("removes staging directories of installs abandoned by an exited host, never one in progress or the runtime", async () => { + const root = await mkdtemp(path.join(tmpdir(), "compose-runtime-")); + roots.push(root); + const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + const staging = async (name: string, stale: boolean) => { + const directory = path.join(root, name); + await mkdir(directory); + await writeFile(path.join(directory, "layoutlib-runtime.jar.part"), "partial"); + if (stale) { + await utimes(path.join(directory, "layoutlib-runtime.jar.part"), old, old); + await utimes(directory, old, old); + } + }; + await staging("compose-a-r1.install-abc123", true); + await staging("compose-a-r1.install-def456", false); + await staging("compose-a-r1", true); + await sweepAbandonedInstalls(root); + expect((await readdir(root)).sort()).toEqual(["compose-a-r1", "compose-a-r1.install-def456"]); + }); + + test("tolerates a cache without runtimes", async () => { + await sweepAbandonedInstalls(path.join(tmpdir(), "compose-runtime-missing-0f3a")); + }); +}); diff --git a/packages/vscode/test/composeSource.test.ts b/packages/vscode/test/composeSource.test.ts index 4d50890..9532dce 100644 --- a/packages/vscode/test/composeSource.test.ts +++ b/packages/vscode/test/composeSource.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { resolvePreviewSource } from "../src/composeSource.js"; +import { inspectableNodes, layoutTree, resolvePreviewSource, type PreviewSourceTarget } from "../src/composeSource.js"; const location = { fileName: "Card.kt", packageHash: -1, lineNumber: 2, offset: -1, length: -1 }; @@ -17,6 +17,8 @@ describe("Compose source navigation", () => { expect(resolvePreviewSource(location, [app, library])).toBeUndefined(); expect(resolvePreviewSource({ ...location, packageHash: 96801 }, [app, library])?.file).toBe("/app/Card.kt"); expect(resolvePreviewSource({ ...location, packageHash: 96801 }, [app, new Map([["/other/Card.kt", "package app\nCard()"]])])).toBeUndefined(); + const unicode = new Map([["/app/Card.kt", "package app.ärger\nCard()"]]); + expect(resolvePreviewSource({ ...location, packageHash: 544004729 }, [unicode])?.file).toBe("/app/Card.kt"); }); test("falls back to one-based lines only for unavailable offsets, never arbitrary paths or invalid ranges", () => { @@ -27,4 +29,51 @@ describe("Compose source navigation", () => { expect(resolvePreviewSource({ ...location, offset: 100, length: 2 }, files)).toBeUndefined(); expect(resolvePreviewSource({ ...location, fileName: "../Card.kt" }, files)).toBeUndefined(); }); + + test("offers located project calls over library internals at the same bounds, never a call another call's range names", () => { + const content = "package app\nCard()\nlib.Row { Badge() }"; + const files = [new Map([["/app/Card.kt", content]])]; + const bounds = { x: 0, y: 0, width: 10, height: 10 }; + const at = (call: string, length: number) => ({ ...location, packageHash: 96801, offset: content.indexOf(call), length }); + const card = { id: "n2", name: "Card", ...bounds, source: at("Card(", 6) }; + const row = { id: "n4", name: "Row", x: 0, y: 10, width: 10, height: 5, source: at("lib.Row", 19) }; + const nodes = [ + { id: "n0", name: "Layout", ...bounds }, + { id: "n1", name: "BasicText", ...bounds, source: { ...location, fileName: "BasicText.kt", offset: 40, length: 3 } }, + card, + // Its group took the location of the call before it, whose callee has no source information. + { id: "n3", name: "Text", x: 0, y: 10, width: 4, height: 5, source: at("Badge(", 7) }, + row, + ]; + const image = { id: "card", name: "Card", detail: "", live: true, width: 10, height: 15, nodes }; + const targets = new Map(); + expect(inspectableNodes({ ...image, live: false }, files, targets)).toEqual([card, row]); + expect([...targets.keys()]).toEqual(["card\0n2", "card\0n4"]); + expect(targets.get("card\0n2")).toEqual({ file: "/app/Card.kt", content, offset: 12, length: 6 }); + // A live image keeps the measured bounds of the unlocated call, without the location it does not have. + expect(inspectableNodes(image, files, new Map())).toEqual([card, { id: "n3", name: "Text", x: 0, y: 10, width: 4, height: 5 }, row]); + }); + + test("offers one measured call per rectangle, the outermost, when a live image has no source locations", () => { + const bounds = { x: 0, y: 0, width: 10, height: 10 }; + const nodes = [{ id: "n0", name: "Layout", ...bounds }, { id: "n1", name: "BasicText", ...bounds }, { id: "n2", name: "Box", x: 0, y: 0, width: 20, height: 20 }]; + const image = { id: "card", name: "Card", detail: "", live: true, width: 20, height: 20, nodes }; + expect(inspectableNodes(image, [], new Map())).toEqual([nodes[1], nodes[2]]); + expect(inspectableNodes({ ...image, live: false }, [], new Map())).toEqual([]); + }); +}); + +describe("Compose layout tree", () => { + const box = (id: string, x: number, y: number, width: number, height: number) => ({ id, name: id, x, y, width, height }); + + test("nests children-first nodes under their smallest enclosing node, siblings top to bottom then left to right", () => { + // Post-order, as the renderers list them: a row's two items (right one first), the row, a caption, the root column. + const nodes = [box("right", 50, 0, 40, 10), box("left", 0, 0, 40, 10), box("row", 0, 0, 100, 10), box("caption", 0, 20, 30, 10), box("column", 0, 0, 100, 40)]; + expect(layoutTree(nodes).map(({ node, depth }) => `${depth}:${node.id}`)).toEqual(["0:column", "1:row", "2:left", "2:right", "1:caption"]); + }); + + test("among equal bounds the later (outer) call is the parent, and disjoint nodes are roots", () => { + const nodes = [box("text", 0, 0, 10, 10), box("padding", 0, 0, 10, 10), box("card", 0, 0, 10, 10), box("elsewhere", 20, 20, 5, 5)]; + expect(layoutTree(nodes).map(({ node, depth }) => `${depth}:${node.id}`)).toEqual(["0:card", "1:padding", "2:text", "0:elsewhere"]); + }); }); diff --git a/packages/vscode/test/fixtures/composeBackendLifecycle.ts b/packages/vscode/test/fixtures/composeBackendLifecycle.ts new file mode 100644 index 0000000..3748948 --- /dev/null +++ b/packages/vscode/test/fixtures/composeBackendLifecycle.ts @@ -0,0 +1,579 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { mock } from "bun:test"; +import type { PreviewImage } from "../../src/composeRender.js"; + +const scenario = process.argv[2]; +const remove = fs.rm; +const root = await fs.mkdtemp(path.join(os.tmpdir(), "elide-compose-backend-test-")); +// Backend workspaces stay inside the test's directory. +mock.module("node:os", () => ({ ...os, tmpdir: () => root })); +const factoryEntered = Promise.withResolvers(); +const factoryGate = Promise.withResolvers(); +const removed = Promise.withResolvers(); +const events: string[] = []; +let backendDirectory: string | undefined; +mock.module("node:fs/promises", () => ({ ...fs, rm: async (file: string, options: Parameters[1]) => { + if (file === backendDirectory) { + events.push("remove-backend"); + await remove(file, options); + removed.resolve(); + } else await remove(file, options); +} })); +mock.module("@elide/ide-core", () => ({ composePreviewInputs: async () => ({}), composePreviewAnnotations: () => 1 })); +mock.module("../../src/composeCompiler.js", () => ({ ComposeCompiler: class { + static async create(_bin: string, _cwd: string, _inputs: unknown, directory: string) { + backendDirectory = directory; + factoryEntered.resolve(); + await factoryGate.promise; + const privateDirectory = path.join(directory, "compiler-test"); + await fs.mkdir(privateDirectory, { recursive: true }); + await fs.writeFile(path.join(privateDirectory, "classes"), "generation"); + events.push("factory-finished"); + return { dispose() {}, remove: () => remove(privateDirectory, { recursive: true, force: true }), + sources: async () => ({}), seed: async () => false }; + } +} })); +/** A render JVM pool the backend started: the JDK and initial weight it was created with. */ +class FakeRenderer { + alive = true; + /** The last gallery's weight, as the real renderer learns it. */ + weight = 0; + constructor(readonly java: string, readonly initialWeight: number | undefined) {} + abandonWarmups() {} + dispose() { this.alive = false; } + render(_inputs: unknown, _sourceFile: string, _packageName: string, methods?: readonly string[]): Promise { return renderGallery(this, methods); } +} +const renderers: FakeRenderer[] = []; +let renderGallery = async (_renderer: FakeRenderer, _methods?: readonly string[]): Promise => []; +// A copy of the real module: the replacement below would otherwise also replace what it spreads. +const render = { ...await import("../../src/composeRender.js") }; +mock.module("../../src/composeRender.js", () => ({ + ...render, + helperDigest: async () => "helpers", + ComposeRenderer: { create: async (_runtime: unknown, java: string, _helpers: string, _cache: string, _directory: string, _signal: AbortSignal, + options: { weight?: number } = {}) => { + const renderer = new FakeRenderer(java, options.weight); + renderers.push(renderer); + return renderer; + } }, +})); +// Backend dependencies must be replaced before the real module captures their constructors. +const { ComposeBackend, ComposeBackends, ImageStore } = await import("../../src/composeBackend.js"); +const backend = new ComposeBackend("test", root, root, new ImageStore(path.join(root, "images"))); +const runtime = { rendererJar: "renderer.jar", rendererClassPath: ["renderer.jar"], layoutlibPath: "layoutlib" }; +/** A compiler whose published classes are `source`'s, needing no processing. */ +const publishedCompiler = (source = "saved") => ({ + ready: true, needsWarmup: false, publishedInstrumented: false, fingerprint: "build", processingRequired: false, + publishedSources: source, inputs: {}, + sources: async (documents: ReadonlyMap) => ({ key: documents.get("Gallery.kt") ?? "saved" }), + update: async () => {}, publish: async () => {}, dispose() {}, remove: async () => {}, +}); +/** Backend state of a started renderer, for scenarios substituting the renderer itself: galleries are keyed by its identity. */ +const started = () => ({ rendererIdentity: Promise.resolve("renderer"), rendererSetup: { identity: Promise.resolve("renderer") } }); +const build = { bin: "elide", model: { root }, artifact: {}, models: [] }; +const request = { documents: new Map(), sourceFile: "Gallery.kt", packageName: "sample" }; +try { + if (scenario === "prewarm-failure") { + const configure = Promise.withResolvers(); + const failure = new Error("renderer startup diagnostic"); + const unhandled: unknown[] = []; + const onUnhandled = (error: unknown) => { unhandled.push(error); }; + process.on("unhandledRejection", onUnhandled); + backend.startRenderer = async () => { throw failure; }; + backend.configure = () => configure.promise; + try { + const result = backend.prewarm(build as never, {} as never, "java", request).then(() => undefined, (error: unknown) => error); + await sleep(20); + configure.resolve(false); + assert.equal(await result, failure, "renderer startup failures remain visible to the caller"); + assert.deepEqual(unhandled, [], "renderer rejection must be observed while compiler discovery is pending"); + } finally { + configure.resolve(false); + process.off("unhandledRejection", onUnhandled); + } + } else if (scenario === "dispose-setup") { + const configured = backend.configure(build as never, request.documents, false).then(() => undefined, (error: unknown) => error); + await factoryEntered.promise; + backend.dispose(); + await sleep(20); + factoryGate.resolve(); + const failure = await configured; + await removed.promise; + assert.ok(failure instanceof Error, "shutdown must reject an in-flight compiler setup"); + assert.deepEqual(events, ["factory-finished", "remove-backend"], "workspace removal waits for in-flight writes and candidate cleanup"); + await assert.rejects(fs.stat(backendDirectory!), { code: "ENOENT" }); + await assert.rejects(backend.configure(build as never, request.documents, false)); + await assert.rejects(backend.startRenderer({} as never, "java")); + } else if (scenario === "concurrent-generations") { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const updated = Promise.withResolvers(); + let compiled = "old"; + const compiler = { + ready: true, + publishedSources: "old", + inputs: {}, + sources: async (documents: ReadonlyMap) => ({ key: documents.get("Gallery.kt")! }), + update: async (sources: { key: string }) => { compiled = sources.key; updated.resolve(); }, + publish: async () => { compiler.publishedSources = compiled; }, + dispose() {}, + }; + let first = true; + const renderer = { + alive: true, + abandonWarmups() {}, + async render() { + if (first) { + first = false; + entered.resolve(); + await release.promise; + } + return [{ id: "gallery", name: compiler.publishedSources, detail: "sample.Gallery" }]; + }, + dispose() {}, + }; + // Substitute process-backed dependencies, retaining the real backend queues and preview API. + Object.assign(backend, { current: compiler, renderer }); + const oldRequest = { ...request, documents: new Map([["Gallery.kt", "old"]]) }; + const newRequest = { ...request, documents: new Map([["Gallery.kt", "new"]]) }; + const holding = backend.preview(oldRequest); + await entered.promise; + const newer = backend.preview(newRequest); + await updated.promise; + await sleep(0); // New classes are queued for publication behind the active render. + const older = backend.preview(oldRequest); + await sleep(0); // This request sees the old published key, but renders after publication. + release.resolve(); + const [heldImages, newImages, oldImages] = await Promise.all([holding, newer, older]); + assert.equal(heldImages.images[0]?.name, "old"); + assert.equal(newImages.images[0]?.name, "new", "each caller must render its requested source generation"); + assert.equal(oldImages.images[0]?.name, "old", "a queued publication must not substitute another request's sources"); + } else if (scenario === "cold-baseline" || scenario === "inspect-seeded") { + const compiled = Promise.withResolvers(); + const release = Promise.withResolvers(); + let source = "saved"; + let verified: string | undefined; + let updates = 0; + let verifications = 0; + let publications = 0; + const compiler = { + ready: true, seeded: true, publishedInstrumented: false, fingerprint: "build", processingRequired: true, + publishedSources: "saved", inputs: {}, + get needsWarmup() { return this.seeded; }, + sources: async (documents: ReadonlyMap) => ({ key: documents.get("Gallery.kt") ?? "saved" }), + isVerified: (key: string) => verified === key, + async update(sources: { key: string }, warm: boolean) { + assert.equal(warm, true, "cold setup explicitly compiles every seeded workspace unit"); + updates++; + source = sources.key; + this.seeded = false; + compiled.resolve(); + if (scenario === "inspect-seeded") await release.promise; + }, + async verify(sources: { key: string }) { + assert.equal(this.seeded, false, "kapt must not initialize against soon-to-be-replaced build dependencies"); + verifications++; + verified = sources.key; + return false; + }, + async publish() { + publications++; + this.publishedSources = source; + this.publishedInstrumented = !this.seeded; + }, + dispose() {}, + }; + const stored = new Map(); + const renderer = { + alive: true, + abandonWarmups() {}, + async render() { + return [{ id: "gallery", name: "saved", detail: "sample.Gallery", + nodes: compiler.publishedInstrumented ? [{ id: "library", name: "JourneyCard", x: 0, y: 0, width: 10, height: 10 }] : [] }]; + }, + dispose() {}, + }; + Object.assign(backend, { + current: compiler, renderer, ...started(), publishedVerified: true, + store: { get: async (key: string) => stored.get(key), set: async (key: string, value: unknown) => { stored.set(key, value); } }, + }); + const buildGallery = await backend.preview(request); + assert.equal(buildGallery.images[0]?.nodes?.length, 0); + assert.equal(stored.size, 1, "seeded build pixels can be retained for fast first paint"); + assert.equal(await backend.preview({ ...request, inspect: true }, false), undefined, "early Inspect cannot render build classes"); + const warming = backend.warm(request); + await compiled.promise; + if (scenario === "inspect-seeded") { + assert.equal((await backend.preview(request)).reused, true, "in-flight compiler flags cannot relabel the published build gallery"); + const inspecting = backend.preview({ ...request, inspect: true }); + release.resolve(); + const inspected = await inspecting; + assert.equal(inspected.images[0]?.nodes?.[0]?.name, "JourneyCard", "saved Inspect renders the instrumented library without an edit"); + } + await warming; + await backend.warm(request); + const inspected = await backend.preview({ ...request, inspect: true }); + assert.equal(inspected.images[0]?.nodes?.length, 1); + assert.equal(inspected.verified, true); + assert.equal((await backend.preview({ ...request, inspect: true })).reused, true); + assert.deepEqual({ updates, verifications, publications }, { updates: 1, verifications: 1, publications: 1 }, + "cold setup and Inspect share a single instrumented, processed baseline"); + assert.equal(stored.size, 2, "build and instrumented metadata occupy distinct stored galleries"); + compiler.seeded = true; + compiler.publishedInstrumented = false; + const restored = await backend.preview(request); + assert.equal(restored.reused, true); + assert.equal(restored.images[0]?.nodes?.length, 1, "a cold backend replays the confirmed instrumented gallery before initialization"); + } else if (scenario === "renderer-generation") { + const rendering = Promise.withResolvers(); + const verifying = Promise.withResolvers(); + const releaseRender = Promise.withResolvers(); + const releaseVerify = Promise.withResolvers(); + let verified = false; + let renders = 0; + const compiler = { + ready: true, seeded: false, publishedInstrumented: true, fingerprint: "build", processingRequired: true, + publishedSources: "saved", inputs: { generation: "provisional" }, + get needsWarmup() { return this.seeded; }, + sources: async () => ({ key: "saved" }), + isVerified: () => verified, + async update() { this.seeded = false; }, + async verify() { + verifying.resolve(); + await releaseVerify.promise; + verified = true; + return false; + }, + async publish() { this.publishedInstrumented = !this.seeded; }, + dispose() {}, + }; + const renderer = { + alive: true, + abandonWarmups() {}, + async render(inputs: { generation: string }) { + renders++; + if (inputs.generation === "confirmed") { + assert.equal(next.publishedInstrumented, true, "never prime the candidate's uninstrumented seed"); + rendering.resolve(); + await releaseRender.promise; + } + return [{ id: "gallery", name: "saved", detail: "sample.Gallery" }]; + }, + dispose() {}, + }; + Object.assign(backend, { current: compiler, renderer, ...started(), publishedVerified: true }); + await backend.preview(request); + const next = { ...compiler, seeded: true, publishedInstrumented: false, inputs: { generation: "confirmed" }, + get needsWarmup() { return this.seeded; } }; + Object.assign(backend, { current: next }); + assert.equal((await backend.preview(request)).reused, true, "equivalent pixels can satisfy the new compiler's initial paint"); + await sleep(0); + assert.equal(renders, 1, "cached first paint must not prime a seeded classpath that initialization discards"); + let finished = false; + const warming = backend.warm(request).then(() => { finished = true; }); + await Promise.all([rendering.promise, verifying.promise]); + assert.equal(finished, false, "readiness waits for the actual instrumented compiler's renderer"); + releaseVerify.resolve(); + releaseRender.resolve(); + await warming; + assert.equal(renders, 2, "new private classpaths initialize once even when old JVMs rendered another compiler"); + assert.equal((await backend.preview(request)).reused, true); + await backend.warm(request); + assert.equal(renders, 2, "warm cache hits must not repeat renderer initialization"); + } else if (scenario === "processing-verification" || scenario === "processing-publication") { + let compiled = "initial"; + let generated = "old"; + let publishedGenerated = "old"; + let verified: string | undefined; + let updates = 0; + let verifications = 0; + let publications = 0; + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const verifiedEntered = Promise.withResolvers(); + const stored = new Map(); + const compiler = { + ready: true, seeded: false, fingerprint: "build", processingRequired: true, + publishedSources: "initial", inputs: {}, + sources: async (documents: ReadonlyMap) => ({ key: documents.get("Gallery.kt")! }), + isVerified: (key: string) => verified === key, + async update(sources: { key: string }) { + updates++; + if (sources.key === "declaration" && generated === "old") throw new Error("outdated generated declaration"); + compiled = sources.key; + }, + async verify(sources: { key: string }) { + verifications++; + if (sources.key === "invalid") throw new Error("Room: invalid query"); + const changed = (sources.key === "declaration" || scenario === "processing-publication") && generated === "old"; + if (changed) generated = "new"; + await this.update(sources); + verified = sources.key; + verifiedEntered.resolve(); + return changed || compiler.publishedSources !== sources.key; + }, + async publish() { + publications++; + compiler.publishedSources = compiled; + publishedGenerated = generated; + }, + dispose() {}, + }; + const renderer = { + alive: true, + abandonWarmups() {}, + async render() { + const generation = publishedGenerated; + if (scenario === "processing-publication") { + entered.resolve(); + await release.promise; + } + return [{ id: "gallery", name: `${compiler.publishedSources}:${generation}`, detail: "sample.Gallery" }]; + }, + dispose() {}, + }; + Object.assign(backend, { + current: compiler, renderer, ...started(), + store: { get: async (key: string) => stored.get(key), set: async (key: string, value: unknown) => { stored.set(key, value); } }, + }); + const forSource = (source: string) => ({ ...request, documents: new Map([["Gallery.kt", source]]) }); + if (scenario === "processing-publication") { + const optimistic = backend.preview(forSource("initial")); + await entered.promise; + const verification = backend.verify(forSource("initial")); + await verifiedEntered.promise; + release.resolve(); + assert.equal((await optimistic).verified, false, "a completed processor pass does not verify still-published old classes"); + assert.equal(await verification, true); + assert.equal(stored.size, 0, "the render completed before publication must never persist"); + const corrected = await backend.preview(forSource("initial")); + assert.equal(corrected.images[0]?.name, "initial:new"); + assert.equal(corrected.verified, true); + } else { + const initial = await backend.preview(forSource("initial")); + assert.equal(initial.verified, false); + assert.equal(stored.size, 0, "optimistic images remain private"); + assert.equal(await backend.verify(forSource("initial")), false); + assert.equal(stored.size, 1, "same-stub confirmation persists the already rendered gallery"); + const initialCounts = { updates, verifications, publications }; + assert.equal((await backend.preview(forSource("initial"))).reused, true); + assert.equal(await backend.verify(forSource("initial")), true, "verification selects confirmed pixels without regenerating classes"); + assert.deepEqual({ updates, verifications, publications }, initialCounts, "confirmed galleries skip both compile and processing"); + + await assert.rejects(backend.preview(forSource("declaration")), /outdated generated declaration/); + assert.equal(await backend.verify(forSource("declaration")), true, "processing recovers a failed optimistic compile"); + const declaration = await backend.preview(forSource("declaration")); + assert.equal(declaration.images[0]?.name, "declaration:new"); + assert.equal(declaration.verified, true); + + const invalid = await backend.preview(forSource("invalid")); + assert.equal(invalid.verified, false); + const confirmedCount = stored.size; + await assert.rejects(backend.verify(forSource("invalid")), /Room: invalid query/); + assert.equal(stored.size, confirmedCount, "processor failures cannot persist an optimistic gallery"); + const beforeRevert = { updates, verifications, publications }; + const reverted = await backend.preview(forSource("initial")); + assert.equal(reverted.reused, true); + assert.equal(reverted.verified, true); + assert.equal(reverted.images[0]?.name, "initial:old"); + assert.equal(await backend.verify(forSource("initial")), true); + assert.deepEqual({ updates, verifications, publications }, beforeRevert, "reverting must not alter the compiler or run kapt"); + + backend.configure = async () => false; + backend.startRenderer = async () => {}; + await assert.rejects(backend.prewarm(build as never, {} as never, "java", forSource("invalid")), /Room: invalid query/, + "prewarm must not silently bless or persist failing processor output"); + assert.equal(stored.size, confirmedCount); + } + } else if (scenario === "renderer-weight") { + renderGallery = async (renderer) => { + renderer.weight = 12; + return [{ id: "gallery", name: "Gallery", detail: "sample.Gallery" }]; + }; + await backend.startRenderer(runtime, "java", 1); + Object.assign(backend, { current: publishedCompiler() }); + await backend.preview(request); + renderers[0]!.alive = false; + await backend.startRenderer(runtime, "java", 1); + assert.deepEqual(renderers.map((renderer) => renderer.initialWeight), [1, 12], + "a renderer replacing a failed one starts as many JVMs as the last gallery needed, not one per annotation"); + } else if (scenario === "sweep") { + const day = 24 * 60 * 60 * 1000; + const workspace = async (name: string, age = 0) => { + const directory = path.join(root, name); + await fs.mkdir(directory); + await fs.writeFile(path.join(directory, "classes"), ""); + const time = (Date.now() - age) / 1000; + for (const file of [path.join(directory, "classes"), directory]) await fs.utimes(file, time, time); + return directory; + }; + const exists = (directory: string) => fs.stat(directory).then(() => true, () => false); + const exited = spawnSync(process.execPath, ["--version"]).pid; + const dead = await workspace(`elide-compose-${exited}-AbC123`); + const unowned = await workspace("elide-compose-GhI789", 2 * day); + const kept = [ + // Another window's workspace. + await workspace(`elide-compose-${process.ppid}-DeF456`, 2 * day), + // An older version's workspace still in use. + await workspace("elide-compose-JkL012"), + await workspace("elide-compose-render-test-MnO345", 2 * day), + ]; + await backend.startRenderer(runtime, "java"); + for (const deadline = Date.now() + 5_000; await exists(dead) || await exists(unowned);) { + if (Date.now() > deadline) throw new Error("timed out waiting for the exited host's workspaces to be removed"); + await sleep(10); + } + for (const directory of kept) assert.equal(await exists(directory), true, `${path.basename(directory)} must be kept`); + const own = (await fs.readdir(root)).filter((name) => name.startsWith(`elide-compose-${process.pid}-`)); + assert.equal(own.length, 1, "the backend's own workspace names its host"); + } else if (scenario === "error-gallery") { + let renders = 0; + let timingOut = true; + renderGallery = async () => { + renders++; + return [timingOut + ? { id: "gallery", name: "Gallery", detail: "sample.Gallery", error: "java.util.concurrent.TimeoutException" } + : { id: "gallery", name: "Gallery", detail: "sample.Gallery", data: "pixels", width: 1, height: 1 }]; + }; + const store = new ImageStore(path.join(root, "images")); + const backends: InstanceType[] = []; + const fresh = async () => { + const next = new ComposeBackend("test", root, root, store); + backends.push(next); + await next.startRenderer(runtime, "java"); + Object.assign(next, { current: publishedCompiler() }); + return next; + }; + try { + const first = await fresh(); + assert.match((await first.preview(request)).images[0]?.error ?? "", /TimeoutException/); + assert.equal((await first.preview(request)).reused, false, "the same backend renders a failed gallery again"); + timingOut = false; + const retried = await (await fresh()).preview(request); + assert.deepEqual({ renders, reused: retried.reused, error: retried.images[0]?.error }, { renders: 3, reused: false, error: undefined }, + "a fresh backend renders the gallery again instead of replaying the failed card"); + // The store writes in the background. + for (const deadline = Date.now() + 5_000; !(await fs.readdir(path.join(root, "images")).catch(() => [])).some((file) => file.endsWith(".json"));) { + if (Date.now() > deadline) throw new Error("timed out waiting for the gallery to be stored"); + await sleep(5); + } + assert.equal((await (await fresh()).preview(request)).reused, true, "a gallery without failed cards is replayed"); + + await fs.writeFile(path.join(root, "images", "legacy.json"), + JSON.stringify([{ id: "gallery", name: "Gallery", detail: "sample.Gallery", error: "TimeoutException" }])); + assert.equal(await store.get("legacy"), undefined, "galleries with failed cards stored by earlier versions are not replayed"); + } finally { + for (const created of backends) created.dispose(); + } + } else if (scenario === "renderer-identity") { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + renderGallery = async (renderer) => { + if (renderer.java === "jdk-a") { + entered.resolve(); + await release.promise; + } + return [{ id: "gallery", name: renderer.java, detail: "sample.Gallery", data: "pixels", width: 1, height: 1 }]; + }; + const stored = new Map(); + Object.assign(backend, { + store: { get: async (key: string) => stored.get(key), set: async (key: string, images: PreviewImage[]) => { stored.set(key, images); } }, + }); + await backend.startRenderer(runtime, "jdk-a"); + Object.assign(backend, { current: publishedCompiler() }); + const rendering = backend.preview(request); + await entered.promise; + const switching = backend.startRenderer(runtime, "jdk-b"); + release.resolve(); + assert.equal((await rendering).images[0]?.name, "jdk-a"); + await switching; + const switched = await backend.preview(request); + assert.deepEqual({ reused: switched.reused, name: switched.images[0]?.name }, { reused: false, name: "jdk-b" }, + "a gallery the old JDK rendered while the new one was starting is never replayed as the new JDK's"); + assert.deepEqual([...stored.values()].map((images) => images[0]?.name).sort(), ["jdk-a", "jdk-b"]); + } else if (scenario === "prewarm-yields") { + const warming = Promise.withResolvers(); + const warmed = Promise.withResolvers(); + const calls: string[] = []; + Object.assign(backend, { + startRenderer: async () => {}, configure: async () => true, + preview: async () => { calls.push("preview"); return { images: [], verified: true, reused: false }; }, + warm: async () => { + calls.push("warm"); + warming.resolve(); + await warmed.promise; + }, + verify: async () => { calls.push("verify"); return true; }, + prime: async () => { calls.push("prime"); }, + }); + const backends = new ComposeBackends(root, root, () => 60_000, () => backend); + const prewarming = backend.prewarm(build as never, runtime, "java", request); + await warming.promise; + assert.equal(backends.acquire("test"), backend); + warmed.resolve(); + await prewarming; + assert.deepEqual(calls, ["preview", "warm"], "the opened panel compiles and renders its own editor text"); + backends.dispose(); + } else if (scenario === "keep-alive") { + let minutes = 1; + const backends = new ComposeBackends(root, root, () => minutes * 60_000, () => backend); + backends.idle(backends.obtain("test")); + // A user's "never": longer than a timer can wait. + minutes = 99_999; + backends.rearm(); + await sleep(30); + assert.equal(backend.disposed, false, "a keep-alive beyond the timer limit keeps the backend"); + backends.dispose(); + assert.equal(backend.disposed, true); + } else if (scenario === "retire-native") { + class NativeBackend extends ComposeBackend { override readonly engine = "komodo"; } + const backends = new ComposeBackends(root, root, () => 60_000, (key, cache, helpers, store) => + key.startsWith("native") ? new NativeBackend(key, cache, helpers, store) : new ComposeBackend(key, cache, helpers, store)); + const active = backends.acquire("native-active"); + const layoutlib = backends.acquire("layoutlib"); + const idle = backends.obtain("native-idle"); + backends.idle(idle); + backends.retireEngine("komodo"); + assert.equal(active.disposed, true); + assert.equal(idle.disposed, true); + assert.equal(layoutlib.disposed, false, "native compatibility loss must preserve layoutlib"); + assert.notEqual(backends.acquire("native-active"), active, "recovery allocates a fresh backend"); + backends.dispose(); + } else if (scenario === "selection") { + const calls: (readonly string[] | undefined)[] = []; + renderGallery = async (_renderer, methods) => { + calls.push(methods); + return [{ id: methods ? `${methods.join("+")}:0` : "all:0", name: "Gallery", detail: "sample.Gallery", data: "pixels", width: 1, height: 1 }]; + }; + await backend.startRenderer(runtime, "java"); + let compiled = "text"; + const compiler = { ...publishedCompiler("text"), update: async (sources: { key: string }) => { compiled = sources.key; }, + publish: async () => { compiler.publishedSources = compiled; } }; + Object.assign(backend, { current: compiler }); + const preview = (methodFQN: string) => ({ methodFQN, previewId: methodFQN, name: methodFQN, previewParams: {}, methodParams: [] }); + const full = { ...request, documents: new Map([["Gallery.kt", "text"]]) }; + const wrapped = { ...full, selection: { appendices: new Map([["Gallery.kt", "\nwrapper"]]), previews: [preview("sample.B"), preview("sample.A")], files: [] } }; + const plain = { ...full, selection: { appendices: new Map(), previews: [preview("sample.A")], files: [] } }; + assert.equal((await backend.preview(full)).images[0]?.id, "all:0"); + const selected = await backend.preview(wrapped); + assert.equal(compiler.publishedSources, "text\nwrapper", "the appendix is compiled after the file's editor text"); + assert.deepEqual(selected.images.map((image) => image.id), ["sample.A+sample.B:0"]); + assert.equal((await backend.preview(plain)).reused, false, "a selection never replays the full file's gallery of the same sources"); + assert.equal(compiler.publishedSources, "text"); + assert.deepEqual(calls, [undefined, ["sample.A", "sample.B"], ["sample.A"]], "the renderer receives the selected methods, sorted"); + const replayed = [await backend.preview(full), await backend.preview(plain)]; + assert.deepEqual(replayed.map((gallery) => [gallery.reused, gallery.images[0]?.id]), [[true, "all:0"], [true, "sample.A:0"]], + "each request replays its own gallery"); + assert.equal(calls.length, 3); + } else throw new Error(`Unknown scenario: ${scenario}`); +} finally { + factoryGate.resolve(); + backend.dispose(); + await remove(root, { recursive: true, force: true }); + if (backendDirectory) await remove(backendDirectory, { recursive: true, force: true }); +} diff --git a/packages/vscode/test/fixtures/composeCompilerLifecycle.ts b/packages/vscode/test/fixtures/composeCompilerLifecycle.ts new file mode 100644 index 0000000..e23dcb7 --- /dev/null +++ b/packages/vscode/test/fixtures/composeCompilerLifecycle.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { mock } from "bun:test"; + +import type { ComposeWorker } from "../../src/composeWorker.js"; +const scenario = process.argv[2]; +// Elide records canonical paths; the bundled plugins are recognized under the canonical distribution. +const root = await realpath(await mkdtemp(path.join(tmpdir(), "elide-compose-compiler-lifecycle-"))); +const calls: { cwd: string; args: string[] }[] = []; +const starts: string[] = []; +mock.module("../../src/composeWorker.js", () => ({ ComposeWorker: class { + alive = true; + constructor(_bin: string, _tool: string, readonly cwd: string) { starts.push(cwd); } + async request(args: string[]) { + calls.push({ cwd: this.cwd, args }); + const output = args[args.indexOf("-d") + 1]!; + await mkdir(output, { recursive: true }); + await writeFile(path.join(output, "Preview.class"), "instrumented"); + const abi = args.find((arg) => arg.startsWith("--report-abi=")); + if (abi) await writeFile(abi.slice("--report-abi=".length), "abi"); + return { exitCode: 0, output: "" }; + } + dispose() { this.alive = false; } +} })); +// Register the process-boundary replacement before loading the real compiler module. +const { ComposeCompiler } = await import("../../src/composeCompiler.js"); +const compilers: { dispose(): void; remove(): Promise }[] = []; +try { + const bundled = scenario === "bundled-plugins" || scenario === "rebase"; + const bin = bundled ? path.join(root, "dist", "bin", "elide") : "elide"; + const pluginDirectory = path.join(root, "dist", "lib", "resources", "kotlin", "2.5.0-Beta1", "lib"); + const plugin = bundled ? path.join(pluginDirectory, "kotlin-compose-compiler-plugin.jar") : path.join(root, "kotlin-compose-compiler-plugin-embeddable-2.2.21.jar"); + const custom = path.join(root, "custom-plugin.jar"); + await mkdir(path.dirname(plugin), { recursive: true }); + await writeFile(plugin, "compose plugin"); + await writeFile(custom, "custom plugin"); + const outputs = ["library", "app"].map((project) => path.join(root, project, ".dev", "jvm", "classes", "main", "kotlin")); + const rClasses = path.join(root, "app", ".dev", "android", "app", "r"); + const resources = path.join(root, "app", ".dev", "android", "app", "resources.apk"); + await mkdir(rClasses, { recursive: true }); + await writeFile(path.join(rClasses, "R.class"), "ids v1"); + await writeFile(resources, "resources v1"); + const files: string[] = []; + const records: string[] = []; + const record = async (index: number, extra: string[] = []) => { + const output = outputs[index]!; + const source = files[index]!; + const plugins = !index && scenario === "custom-plugin" ? [`-Xplugin=${custom}`] + : !index && bundled ? [`-Xplugin=${path.join(pluginDirectory, "kotlin-serialization-compiler-plugin.jar")}`] : []; + const args = ["-jvm-target", "17", `-Xplugin=${plugin}`, "-P", "plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=false", + ...(index ? ["-classpath", [outputs[0]!, rClasses].join(path.delimiter)] : []), ...plugins, ...extra, "-d", output, source]; + records[index] = args.join("\n") + "\n"; + await writeFile(path.join(path.dirname(output), "kotlinc.args"), records[index]!); + await writeFile(path.join(path.dirname(output), "kotlinc.toolchain.json"), + JSON.stringify(bundled ? { version: 1, kind: "embedded" } : { version: 1, kind: "managed", classpath: [plugin] })); + }; + for (const [index, output] of outputs.entries()) { + const project = path.join(root, index ? "app" : "library"); + const source = path.join(project, "src", "Gallery.kt"); + files.push(source); + await mkdir(path.dirname(source), { recursive: true }); + await writeFile(source, `package ${index ? "app" : "library"}\nfun Gallery() = Unit\n`); + await mkdir(output, { recursive: true }); + await writeFile(path.join(output, "Preview.class"), "build"); + await record(index); + } + const workers = new Map(); + const create = async () => { + const compiler = await ComposeCompiler.create(bin, path.join(root, "app"), { + classPath: [...outputs, rClasses], projectClassPath: outputs, sourceSelections: Object.fromEntries(outputs.map((output) => [output, ["src/**/*.kt"]])), + namespace: "app", resourceApkPath: resources, + }, path.join(root, "private"), new AbortController().signal, workers); + compilers.push(compiler); + return compiler; + }; + const compiler = await create(); + const sources = await compiler.sources(new Map()); + assert.equal(await compiler.seed(sources, true), true); + await compiler.publish(); + assert.equal(compiler.publishedInstrumented, false); + assert.equal(compiler.needsWarmup, true); + assert.equal(calls.length, 0, "saved build outputs paint without invoking the compiler"); + await compiler.update(sources, true); + assert.equal(compiler.publishedInstrumented, false, "private compile cannot relabel the published build classes"); + await compiler.publish(); + assert.equal(compiler.publishedInstrumented, true); + assert.equal(compiler.needsWarmup, false); + assert.equal(calls.length, 2, "initialize app and workspace library once each"); + assert.equal(starts.length, scenario === "custom-plugin" ? 2 : 1, + "managed and Elide-bundled Compose share their JVM, as do Elide's other bundled plugins; custom plugins retain project cwd"); + assert.ok(calls.every(({ args }) => args.includes("--warm-incremental") && args.includes("plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=true"))); + assert.ok(calls.every(({ args }) => !args.includes("plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=false"))); + if (scenario === "rebase") { + // Relinked resources with unchanged identifiers: the warm compiler takes the new inputs and compiles nothing. + const fingerprint = compiler.fingerprint; + const published = compiler.inputs.projectClassPath; + await writeFile(resources, "resources v2"); + const relinked = await create(); + assert.equal(compiler.sameBuild(relinked), false); + assert.equal(compiler.rebase(relinked), true, "a resource-only build keeps the compiler"); + assert.equal(compiler.fingerprint, relinked.fingerprint); + assert.notEqual(compiler.fingerprint, fingerprint, "stored galleries of the old resources stay apart"); + assert.equal(compiler.ready, true, "unchanged classes stay published"); + assert.equal(compiler.needsWarmup, false, "no cold initialization"); + await compiler.update(await compiler.sources(new Map())); + assert.equal(calls.length, 2, "nothing recompiles"); + // New resource identifiers change the app's R classes: only the app recompiles, incrementally, against them. + await writeFile(path.join(rClasses, "R.class"), "ids v2"); + const regenerated = await create(); + assert.equal(compiler.rebase(regenerated), true, "an R class change keeps the compiler"); + assert.equal(compiler.ready, false, "classes compiled against the old identifiers are not rendered"); + assert.equal(compiler.needsWarmup, false); + assert.deepEqual(compiler.inputs.projectClassPath, published, "render inputs name this compiler's own classes, not the discarded candidate's"); + await compiler.update(await compiler.sources(new Map())); + await compiler.publish(); + assert.equal(calls.length, 3, "only the app recompiles"); + assert.equal(calls[2]!.args.includes("--warm-incremental"), false, "incrementally"); + const classPath = calls[2]!.args[calls[2]!.args.indexOf("-classpath") + 1]!.split(path.delimiter); + const mirrored = classPath.find((entry) => entry.startsWith(path.join(root, "private", "mirror"))); + assert.equal(mirrored && await readFile(path.join(mirrored, "R.class"), "utf8"), "ids v2", "against the new R classes"); + // A changed compiler record is another compilation. + await record(1, ["-opt-in=kotlin.RequiresOptIn"]); + assert.equal(compiler.rebase(await create()), false, "changed records need a new compiler"); + } else if (scenario === "toolchain-switch") { + const [previous] = workers.values(); + compiler.pruneWorkers(); + assert.equal(previous!.alive, true, "a compiler keeps the worker it compiles with"); + // A Kotlin upgrade: the managed toolchain's compiler jars change, and with them the worker. + await writeFile(plugin, "compose plugin v2"); + const upgraded = await create(); + upgraded.pruneWorkers(); + assert.equal(previous!.alive, false, "the replaced toolchain's worker stops with its compiler"); + assert.equal(workers.size, 0); + const sources = await upgraded.sources(new Map()); + assert.equal(await upgraded.seed(sources, true), true); + await upgraded.update(sources, true); + assert.equal(starts.length, 2, "the new toolchain starts its own worker"); + upgraded.pruneWorkers(); + assert.equal(workers.size, 1); + } else { + await compiler.update(await compiler.sources(new Map([[files[1]!, "package app\nfun Gallery() = Unit\n// edit\n"]]))); + assert.equal(calls.length, 3, "first edit only recompiles the changed app"); + assert.equal(calls[2]!.args.includes("--warm-incremental"), false, "first edit does not repeat cold incremental initialization"); + } + for (const [index, output] of outputs.entries()) { + assert.equal(await readFile(path.join(output, "Preview.class"), "utf8"), "build"); + assert.equal(await readFile(path.join(path.dirname(output), "kotlinc.args"), "utf8"), records[index]); + } + for (const worker of workers.values()) worker.dispose(); +} finally { + for (const compiler of compilers) { + compiler.dispose(); + await compiler.remove(); + } + await rm(root, { recursive: true, force: true }); +} diff --git a/packages/vscode/test/fixtures/composePreviewLifecycle.ts b/packages/vscode/test/fixtures/composePreviewLifecycle.ts new file mode 100644 index 0000000..6661a69 --- /dev/null +++ b/packages/vscode/test/fixtures/composePreviewLifecycle.ts @@ -0,0 +1,638 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { mock } from "bun:test"; +import { TimelineNode } from "@elide/ide-core"; +import { StaleBuildRecordError } from "../../src/composeProcessing.js"; + +class Disposable { + constructor(private readonly close: () => void = () => {}) {} + dispose(): void { this.close(); } +} +class EventEmitter { + private listeners = new Set<(value: T) => unknown>(); + event = (listener: (value: T) => unknown) => { + this.listeners.add(listener); + return new Disposable(() => this.listeners.delete(listener)); + }; + fire(value: T): void { for (const listener of this.listeners) listener(value); } + dispose(): void { this.listeners.clear(); } +} +const scenario = process.argv[2]; +const native = scenario?.startsWith("komodo-") === true; +const processing = ["processing", "processor-failure", "processing-recovery", "superseded-processing", "stale-paint"].includes(scenario!); +/** Scenarios whose webview acknowledges every gallery it receives, as soon as it arrives. */ +const acknowledging = processing || ["cold-initialization", "inspect", "replay", "rename", "session-lost", "restore"].includes(scenario!); +/** Scenarios whose error notifications are recorded rather than failing the run. */ +const notifying = native || ["availability", "sync-failure"].includes(scenario!); +const changed = new EventEmitter(); +const received = new EventEmitter(); +const disposed = new EventEmitter(); +const renamed = new EventEmitter(); +const deleted = new EventEmitter(); +const workspaceChanged = new EventEmitter(); +const availabilityChanged = new EventEmitter(); +let nativeAvailable = scenario !== "komodo-missing"; +let nativeReason = "Komodo is missing; install a compatible Komodo through Elide, then sync."; +const noopEvent = () => new Disposable(); +const messages: Record[] = []; +const commands = new Map unknown>(); +/** `executeCommand` calls, such as `setContext`. */ +const contexts: unknown[][] = []; +/** Error notifications of the `notifying` scenarios. */ +const shown: string[] = []; +const warnings: string[] = []; +/** Titles of `withProgress` calls. */ +const progress: string[] = []; +/** Files opened in an editor by source navigation. */ +const opened: string[] = []; +let panels = 0; +let reveals = 0; +const fileUri = (fsPath: string) => ({ scheme: "file", fsPath, toString: () => `file://${fsPath}` }); +const uri = fileUri("/app/src/main/Gallery.kt"); +const folder = { name: "app", uri: { fsPath: "/app", toString: () => "file:///app" } }; +let text = "package sample\n@Preview fun Gallery() {}"; +const document = { uri, languageId: "kotlin", getText: () => text, positionAt: (offset: number) => ({ offset }), + save: async () => { throw new Error("preview must not save unsaved editor text"); } }; +const webview = { html: "", options: {}, postMessage: async (message: Record) => { + messages.push(message); + if (acknowledging && message.type === "images" && (scenario !== "stale-paint" || message.revision === 1)) { + queueMicrotask(() => received.fire({ type: "painted", revision: message.revision, publication: message.publication })); + } + // A webview busy with frames: each acknowledgement arrives after newer frames were posted. + if (scenario === "animation" && message.type === "images") { + setTimeout(() => received.fire({ type: "painted", revision: message.revision, publication: message.publication }), 20); + } + return true; +}, onDidReceiveMessage: received.event }; +/** Like VS Code's panel: once disposed (before onDidDispose runs), its webview and view state throw. */ +let panelDisposed = false; +const panel = { + title: "", + reveal: () => { reveals++; }, + get visible() { + if (panelDisposed) throw new Error("Webview is disposed"); + return true; + }, + onDidChangeViewState: noopEvent, + get webview() { + if (panelDisposed) throw new Error("Webview is disposed"); + return webview; + }, + onDidDispose: disposed.event, + dispose: () => { + if (panelDisposed) return; + panelDisposed = true; + disposed.fire(); + }, +}; +class Range { constructor(readonly start: unknown, readonly end: unknown) {} } +/** Problems entries of the preview's diagnostic collection, by file. */ +const problems = new Map(); +let serializer: { deserializeWebviewPanel(panel: unknown, state: unknown): Promise } | undefined; +mock.module("vscode", () => ({ + Disposable, EventEmitter, Range, ViewColumn: { One: 1, Beside: 2 }, TextEditorRevealType: { InCenterIfOutsideViewport: 2 }, + Diagnostic: class { constructor(readonly range: unknown, readonly message: string, readonly severity: number) {} }, + DiagnosticSeverity: { Error: 0, Warning: 1 }, + Uri: { file: fileUri, parse: (value: string) => value.startsWith("file://") ? fileUri(value.slice("file://".length)) : { scheme: value.split(":")[0], fsPath: "", toString: () => value } }, + commands: { + registerCommand: (name: string, callback: () => unknown) => { commands.set(name, callback); return new Disposable(); }, + executeCommand: async (...args: unknown[]) => { contexts.push(args); }, + }, + languages: { createDiagnosticCollection: () => ({ + clear: () => problems.clear(), set: (file: { fsPath: string }, entries: unknown[]) => problems.set(file.fsPath, entries), dispose() {}, + }) }, + ProgressLocation: { Notification: 15, Window: 10 }, + window: { activeTextEditor: { document }, createWebviewPanel: () => { panels++; return panel; }, onDidChangeActiveTextEditor: noopEvent, + onDidChangeWindowState: noopEvent, + registerWebviewPanelSerializer: (_viewType: string, value: typeof serializer) => { serializer = value; return new Disposable(); }, + withProgress: (options: { title: string }, task: (progress: { report(value: unknown): void }, token: unknown) => unknown) => { + progress.push(options.title); + return task({ report() {} }, { isCancellationRequested: false, onCancellationRequested: () => new Disposable() }); + }, + showTextDocument: async (opening: typeof document) => { + opened.push(opening.uri.fsPath); + return { revealRange() {} }; + }, + showWarningMessage: (message: string) => { warnings.push(message); }, + showErrorMessage: (message: string) => { + if (!notifying) throw new Error(message); + shown.push(message); + } }, + workspace: { isTrusted: true, textDocuments: [document], openTextDocument: async () => document, + fs: { stat: async (file: { fsPath: string }) => { + if (file.fsPath !== uri.fsPath) throw new Error("EntryNotFound"); + return {}; + } }, + onDidChangeTextDocument: changed.event, onDidSaveTextDocument: noopEvent, onDidCreateFiles: noopEvent, + onDidDeleteFiles: deleted.event, onDidRenameFiles: renamed.event, onDidGrantWorkspaceTrust: noopEvent, + onDidChangeConfiguration: noopEvent }, +})); +const prewarming = scenario === "prewarm-close" || scenario === "prewarm-failure"; +mock.module("../../src/config.js", () => ({ readConfig: () => ({ composePreviewPrewarm: prewarming, composePreviewKeepAlive: 1, + composePreviewEngine: native ? "komodo" : "layoutlib" }) })); +const runtimeGate = Promise.withResolvers(); +let runtimeEntered = false; +mock.module("../../src/composeRuntime.js", () => ({ + installedComposeRuntime: async () => { + if (!prewarming) return undefined; + runtimeEntered = true; + return scenario === "prewarm-failure" ? {} : runtimeGate.promise; + }, + ensureComposeRuntime: async (_root: string, signal: AbortSignal) => { + runtimeEntered = true; + const aborted = Promise.withResolvers(); + signal.addEventListener("abort", () => aborted.reject(signal.reason), { once: true }); + return Promise.race([runtimeGate.promise, aborted.promise]); + }, +})); +let builds = 0; +let starts = 0; +let previews = 0; +let obtained = 0; +const buildGates: PromiseWithResolvers[] = []; +/** Each build's cancellation signal. */ +const buildSignals: AbortSignal[] = []; +const verifyGates: PromiseWithResolvers[] = []; +const confirmed = new Set(); +const initialization = Promise.withResolvers(); +let warming = false; +let inspections = 0; +const invocations: { options?: Record }[] = []; +mock.module("../../src/tasks.js", () => ({ runObservedElide: async (_project: unknown, _command: unknown, invocation: { options?: Record }, options: { signal: AbortSignal }) => { + builds++; + invocations.push(invocation); + const gate = Promise.withResolvers(); + buildGates.push(gate); + buildSignals.push(options.signal); + options.signal.addEventListener("abort", () => gate.resolve(undefined), { once: true }); + return gate.promise; +} })); +/** The preview's source files of `preview` calls. */ +const requested: string[] = []; +/** A layout node calling `Gallery` in Gallery.kt, which the inspector's Open source opens. */ +const galleryNode = { id: "call", name: "Gallery", x: 0, y: 0, width: 1, height: 1, + source: { fileName: "Gallery.kt", packageHash: -1, lineNumber: 2, offset: text.indexOf("Gallery()"), length: "Gallery".length } }; +let pushImages: (images: unknown[]) => void = () => {}; +let compiled: (outcome: { failed: boolean; messages: unknown[] }) => void = () => {}; +let sessionLost = () => {}; +const backend = { + engine: native ? "komodo" : "layoutlib", + prepareSession: async () => { + if (scenario === "komodo-incompatible") { + workspace.rejectKomodoPreview("Unsupported Komodo preview protocol; install a compatible Komodo, then sync."); + throw new Error(nativeReason); + } + }, + preempt: () => {}, + compiler: { ready: true, seeded: false, processingRequired: processing, processingStats: { stubs: 0, apt: 0 }, + needsWarmup: scenario === "cold-initialization", + supports: () => true, sources: async (documents: ReadonlyMap) => ({ files: [documents] }) }, + get needsWarmup() { return backend.compiler.needsWarmup; }, + get processingRequired() { return backend.compiler.processingRequired; }, + startRenderer: async () => { starts++; }, + disposed: false, + onImages: (listener: typeof pushImages) => { pushImages = listener; return () => {}; }, + onCompile: (listener: typeof compiled) => { compiled = listener; return () => {}; }, + onInputReset: () => () => {}, + onSessionLost: (listener: () => void) => { sessionLost = listener; return () => {}; }, + keyboardSupported: () => false, + blur: () => false, + pointer: () => false, + edit: () => {}, + compiles: () => true, + latestImages: (images: unknown) => images, + prewarm: async () => { throw new Error("renderer startup failed"); }, + configure: async () => { + if (scenario === "stale-record" && !invocations.at(-1)?.options?.["no-cache"]) throw new StaleBuildRecordError("/app/kotlinc.args", "it compiles into /elsewhere"); + return false; + }, + preview: async (request: { documents: ReadonlyMap; sourceFile: string; inspect?: boolean }) => { + previews++; + requested.push(request.sourceFile); + if (request.inspect) inspections++; + const source = request.documents.get(uri.fsPath)!; + if (scenario === "processing-recovery" && source.includes("declaration") && !confirmed.has(source)) throw new Error("old generated declaration"); + const live = scenario === "animation" ? { live: true, nodes: [galleryNode] } : {}; + return { images: [{ id: "gallery", data: source, width: 1, height: 1, ...live }], verified: !processing || confirmed.has(source), reused: false }; + }, + verify: async (request: { documents: ReadonlyMap }) => { + backend.compiler.processingStats.stubs++; + const gate = Promise.withResolvers(); + verifyGates.push(gate); + const result = await gate.promise; + confirmed.add(request.documents.get(uri.fsPath)!); + if (result) backend.compiler.processingStats.apt++; + return result; + }, + warm: async () => { + warming = true; + await initialization.promise; + backend.compiler.needsWarmup = false; + }, +}; +// `ComposeBackend` is the base class the Komodo engine extends; the session never constructs it here. +// Load after the VS Code mock above; a static import would resolve the real extension API outside an extension host. +const { isPreviewKeyEvent, PREVIEW_KEY_NAMES } = await import("../../src/composeBackend.js"); +mock.module("../../src/composeBackend.js", () => ({ isPreviewKeyEvent, PREVIEW_KEY_NAMES, ComposeBackend: class {}, ComposeBackends: class { + acquire() { return backend; } + obtain() { obtained++; return backend; } + idle() {} + release() {} + retain() {} + retireEngine(engine: string) { if (backend.engine === engine) backend.disposed = true; } + dispose() {} +} })); +mock.module("../../src/composeKomodo.js", () => ({ + KomodoBackend: class { static [Symbol.hasInstance](value: unknown) { return value === backend && native; } }, + KomodoSessionArbiter: class {}, +})); +// The session resolves the CLI before running it, whether or not the host has one installed; `runObservedElide` is +// mocked, so any file at `bin/elide` stands in. +const elideHome = mkdtempSync(path.join(tmpdir(), "elide-compose-home-")); +process.on("exit", () => rmSync(elideHome, { recursive: true, force: true })); +mkdirSync(path.join(elideHome, "bin")); +writeFileSync(path.join(elideHome, "bin", "elide"), ""); +process.env.ELIDE_HOME = elideHome; +// Load after registering mocks: exercise the real session at its VS Code and backend boundaries. +const { registerComposePreview } = await import("../../src/composePreview.js"); +const target = { name: "app", kind: "apk", sourceSets: ["main"] }; +const model = { + root: "/app", name: "app", members: [], kotlin: { compose: true }, android: { targets: [target] }, + modules: [{ name: "app.main", sourceSet: "main", kind: "source", sourcePaths: ["src/main/**/*.kt"], + runtimeModuleDeps: [], runtimeClassPath: [], compilationTargets: ["compile-kotlin-main"] }], +}; +const syncing = scenario === "sync-wait" || scenario === "sync-failure"; +const project: { root: string; folder: typeof folder; model?: typeof model } = { root: "/app", folder, ...(syncing ? {} : { model }) }; +const sync = { running: syncing, error: undefined as string | undefined }; +const workspace = { projects: [project], projectFor: () => project, projectAt: () => project, onDidChange: workspaceChanged.event, + isSyncing: () => sync.running, lastError: () => sync.error, + get komodoPreviewAvailable() { return nativeAvailable; }, + onDidChangeKomodoAvailability: availabilityChanged.event, + requireKomodoPreview: async () => { if (!nativeAvailable) throw new Error(nativeReason); }, + rejectKomodoPreview: (reason: string) => { nativeReason = reason; nativeAvailable = false; availabilityChanged.fire(); }, +}; +const roots: TimelineNode[] = []; +/** The Activity view's Cancel of each operation. */ +const cancels: (() => void)[] = []; +const logged: string[] = []; +/** The extension's workspace state. */ +const workspaceState = new Map(); +const RENAMES_KEY = "elide.composePreview.renames"; +const registration = registerComposePreview(workspace as never, { log: (line: string) => logged.push(line), activity: { + start: (label: string, options?: { cancel?: () => void }) => { + const root = TimelineNode.root(label); + roots.push(root); + if (options?.cancel) cancels.push(options.cancel); + return root; + }, +} } as never, { globalStorageUri: { fsPath: "/cache" }, asAbsolutePath: (value: string) => value, workspaceState: { + get: (key: string) => workspaceState.get(key), + update: async (key: string, value: unknown) => { if (value === undefined) workspaceState.delete(key); else workspaceState.set(key, value); }, +} } as never); +const paints: { verifying: boolean; contentHash: string; processing?: { stubs: number; apt: number } }[] = []; +const errors: { source: string; message: string }[] = []; +registration.api.onDidPaint((paint) => paints.push(paint)); +registration.api.onDidError((error) => errors.push(error)); +async function until(predicate: () => boolean): Promise { + for (let index = 0; index < 200; index++) { + if (predicate()) return; + await sleep(5); + } + throw new Error(`Timed out: ${JSON.stringify({ builds, starts, previews, messages })}`); +} +/** Let the first preview build finish and its gallery paint. */ +async function firstPaint(): Promise { + runtimeGate.resolve({}); + await until(() => builds === 1); + buildGates[0]!.resolve(0); + await until(() => paints.some((paint) => !paint.verifying)); +} +const lastImages = () => messages.findLast((message) => message.type === "images")!; +try { + if (scenario === "restore") { + let closed = 0; + await serializer!.deserializeWebviewPanel({ dispose: () => { closed++; } }, { source: "file:///app/src/main/Missing.kt", fitted: true }); + assert.equal(closed, 1, "a preview whose file is gone is closed, not left empty"); + assert.deepEqual(warnings, ["Elide: the Compose preview of Missing.kt was not restored: Missing.kt no longer exists."]); + // Renamed Old.kt to Moved.kt while shown, then to Gallery.kt while hidden: the webview saved Moved.kt. + workspaceState.set(RENAMES_KEY, { "file:///app/src/main/Old.kt": "file:///app/src/main/Moved.kt", "file:///app/src/main/Moved.kt": uri.toString() }); + await serializer!.deserializeWebviewPanel(panel, { source: "file:///app/src/main/Moved.kt", fitted: true }); + await until(() => runtimeEntered); + } else if (!native && !["availability", "sync-wait", "sync-failure", "layoutlib-native-loss"].includes(scenario!)) { + if (!prewarming) await commands.get("elide.androidComposePreview")!(); + await until(() => runtimeEntered); + } + if (scenario === "komodo-missing") { + await commands.get("elide.androidComposePreview")!(); + assert.match(shown[0]!, /Komodo is missing/); + assert.equal(panels, 0); + assert.equal(runtimeEntered, false); + assert.equal(builds, 0); + nativeAvailable = true; + availabilityChanged.fire(); + await commands.get("elide.androidComposePreview")!(); + await until(() => runtimeEntered); + assert.equal(panels, 1, "sync recovery can open a native preview without reloading"); + } else if (scenario === "komodo-incompatible") { + await commands.get("elide.androidComposePreview")!(); + await until(() => panelDisposed); + await commands.get("elide.androidComposePreview")!(); + assert.equal(panels, 1, "protocol failure stays disabled until compatibility refresh"); + assert.equal(runtimeEntered, false); + assert.equal(builds, 0); + assert.equal(starts, 0); + assert.equal(backend.disposed, true); + assert.match(shown[0]!, /Unsupported Komodo preview protocol/); + } else if (scenario === "komodo-loss" || scenario === "layoutlib-native-loss") { + await commands.get("elide.androidComposePreview")!(); + await until(() => runtimeEntered); + nativeAvailable = false; + availabilityChanged.fire(); + runtimeGate.resolve({}); + if (native) { + await until(() => panelDisposed); + await sleep(20); + assert.equal(builds, 0, "availability loss cancels a runtime await before the build starts"); + assert.equal(previews, 0); + assert.equal(backend.disposed, true); + assert.equal(registration.api.pointer("live", "down", 1, 1), false); + } else { + await until(() => builds === 1); + assert.equal(panelDisposed, false, "layoutlib remains open on native availability loss"); + assert.equal(backend.disposed, false); + } + } else if (scenario === "availability") { + assert.deepEqual(contexts.at(-1), ["setContext", "elide.composePreviewAvailable", true], "a preview source of a synced Android app offers the editor button"); + text = "package sample\nclass MainActivity"; + changed.fire({ document, contentChanges: [{}] }); + await until(() => contexts.at(-1)?.[2] === false); + assert.deepEqual(contexts.at(-1), ["setContext", "elide.composePreviewAvailable", false], "editing the previews away hides the button"); + await commands.get("elide.androidComposePreview")!(); + assert.match(shown.at(-1) ?? "", /^Elide: Gallery\.kt declares no @Preview functions\./); + assert.equal(builds, 0, "a source without previews never builds"); + assert.equal(panels, 0, "nor opens a panel"); + } else if (scenario === "prewarm-close") { + registration.disposable.dispose(); + runtimeGate.resolve({}); + await sleep(0); + assert.equal(obtained, 0, "a pending prewarm must not recreate a backend after extension disposal"); + } else if (scenario === "prewarm-failure") { + await until(() => logged.length > 0); + assert.match(logged[0]!, /^Compose preview prewarm for Gallery\.kt failed: renderer startup failed$/, "a failed prewarm is reported, not swallowed"); + } else if (scenario === "stale-record") { + runtimeGate.resolve({}); + await until(() => builds === 1); + buildGates[0]!.resolve(0); + await until(() => messages.some((message) => message.type === "status" && message.state === "error")); + const failure = messages.findLast((message) => message.type === "status")!; + assert.equal((failure.action as { id: string } | undefined)?.id, "rebuildNoCache", "a stale build record offers a rebuild without the cache"); + assert.equal(invocations[0]!.options, undefined, "an ordinary preview build uses the build cache"); + received.fire({ type: "action", id: "rebuildNoCache" }); + await until(() => builds === 2); + assert.deepEqual(invocations[1]!.options, { "no-cache": true }); + buildGates[1]!.resolve(0); + await until(() => messages.some((message) => message.type === "images")); + } else if (scenario === "close") { + panel.dispose(); + runtimeGate.resolve({}); + await until(() => !roots[0]!.running); + assert.equal(builds, 0, "closing during runtime preparation must not start an Elide build"); + assert.equal(starts, 0, "closing during runtime preparation must not start a renderer"); + } else if (scenario === "failed-build") { + runtimeGate.resolve({}); + await until(() => builds === 1); + buildGates[0]!.resolve(1); + await until(() => messages.some((message) => message.type === "status" && message.state === "error")); + changed.fire({ document, contentChanges: [{ text: "edited" }] }); + await until(() => builds === 2 || messages.some((message) => message.type === "images" && message.revision === 2)); + assert.equal(builds, 2, "a live edit after a failed build must verify resources before confirming images"); + buildGates[1]!.resolve(0); + await until(() => messages.some((message) => message.type === "images" && message.revision === 2)); + } else if (scenario === "cold-initialization") { + runtimeGate.resolve({}); + await until(() => builds === 1 && paints.length > 0); + buildGates[0]!.resolve(0); + await until(() => warming); + assert.ok(paints.every((paint) => paint.verifying), "stored first paint stays provisional during compiler initialization"); + assert.equal(verifyGates.length, 0, "processing cannot run against the seeded dependency baseline"); + initialization.resolve(); + await until(() => paints.some((paint) => !paint.verifying)); + assert.equal(backend.compiler.needsWarmup, false, "confirmation means first-edit compilation is initialized"); + assert.equal(roots[0]!.children.find((step) => step.label === "Initialize compiler")?.state, "succeeded"); + } else if (scenario === "inspect") { + runtimeGate.resolve({}); + await until(() => builds === 1); + buildGates[0]!.resolve(0); + await until(() => paints.some((paint) => !paint.verifying)); + received.fire({ type: "inspect", enabled: true }); + await until(() => inspections > 0 && messages.some((message) => message.type === "images" && message.revision === 2)); + assert.equal(builds, 1, "Inspect requires an instrumented preview, not a no-op saved-source rebuild"); + assert.equal(text, "package sample\n@Preview fun Gallery() {}", "Inspect must work without a live edit"); + } else if (processing) { + runtimeGate.resolve({}); + await until(() => builds === 1); + buildGates[0]!.resolve(0); + await until(() => verifyGates.length === 1 && paints.length > 0); + assert.equal(paints[0]!.verifying, true, "build completion alone cannot confirm a kapt gallery"); + verifyGates[0]!.resolve(false); + await until(() => paints.some((paint) => !paint.verifying)); + const initial = paints.at(-1)!; + assert.match(initial.contentHash, /^[a-f0-9]{64}$/); + assert.deepEqual(initial.processing, { stubs: 1, apt: 0 }); + const baselinePaints = paints.length; + text += scenario === "processing-recovery" ? "\n// declaration" : "\n// body"; + changed.fire({ document, contentChanges: [{ text }] }); + await until(() => verifyGates.length === 2); + if (scenario === "stale-paint") { + const optimistic = messages.findLast((message) => message.type === "images")!; + verifyGates[1]!.resolve(true); + await until(() => messages.some((message) => message.type === "images" && message.publication !== optimistic.publication && message.revision === 2)); + const corrected = messages.findLast((message) => message.type === "images")!; + received.fire({ type: "painted", revision: optimistic.revision, publication: optimistic.publication }); + assert.equal(paints.length, baselinePaints, "an old gallery acknowledgement must not report corrected pixels as painted"); + received.fire({ type: "painted", revision: corrected.revision, publication: corrected.publication }); + assert.equal(paints.length, baselinePaints + 1); + assert.equal(paints.at(-1)!.verifying, false); + } else if (scenario === "processor-failure") { + verifyGates[1]!.reject(new Error("Room: missing_live_column")); + await until(() => errors.length === 1); + assert.match(errors[0]!.message, /missing_live_column/); + assert.equal(errors[0]!.source, uri.fsPath); + assert.equal(paints.slice(baselinePaints).some((paint) => !paint.verifying), false, "a processor error cannot confirm stale pixels"); + assert.ok(messages.some((message) => message.type === "status" && message.state === "error" && String(message.text).includes("missing_live_column"))); + } else if (scenario === "superseded-processing") { + text += "\n// newest"; + changed.fire({ document, contentChanges: [{ text }] }); + const boundary = messages.length; + verifyGates[1]!.resolve(true); + await until(() => verifyGates.length === 3); + assert.equal(messages.slice(boundary).some((message) => message.type === "images" && message.revision === 2), false, + "obsolete processing cannot replace the newer revision's pixels"); + verifyGates[2]!.resolve(false); + await until(() => paints.at(-1)?.verifying === false); + assert.equal(errors.length, 0); + } else { + if (scenario === "processing") { + assert.equal(paints.at(-1)!.verifying, true, "live pixels must paint while kapt is checking"); + verifyGates[1]!.resolve(false); + } else verifyGates[1]!.resolve(true); + await until(() => paints.length > baselinePaints && paints.at(-1)?.verifying === false); + assert.notEqual(paints.at(-1)!.contentHash, initial.contentHash); + assert.deepEqual(paints.at(-1)!.processing, { stubs: 2, apt: scenario === "processing-recovery" ? 1 : 0 }); + assert.equal(errors.length, 0, "an optimistic declaration failure must recover through processing"); + } + } else if (scenario === "animation") { + await firstPaint(); + const paintsBefore = paints.length; + const publishedBefore = messages.filter((message) => message.type === "images").length; + let frame = 0; + const frames = setInterval(() => pushImages([{ id: "gallery", name: "Gallery", detail: "", data: `frame ${++frame}`, width: 1, height: 1, live: true, nodes: [galleryNode] }]), 5); + try { + await sleep(300); + const published = messages.filter((message) => message.type === "images").length - publishedBefore; + assert.ok(published <= 300 / 50 + 2, `frames every 5 ms are posted at most every 50 ms, not ${published} times in 300 ms`); + assert.ok(paints.length > paintsBefore, "acknowledgements arriving after newer frames still report paints"); + received.fire({ type: "navigate", revision: lastImages().revision, imageId: "gallery", nodeId: "call" }); + await until(() => opened.length === 1); + assert.equal(await registration.api.openSource("gallery", "call"), true); + assert.deepEqual(opened, [uri.fsPath, uri.fsPath], "Open source works while frames arrive"); + } finally { + clearInterval(frames); + } + } else if (scenario === "replay") { + await firstPaint(); + await sleep(20); + const settled = paints.length; + const shown = lastImages(); + received.fire({ type: "painted", revision: shown.revision, publication: shown.publication }); + // A webview reload replays the gallery, and acknowledges it again. + received.fire({ type: "ready" }); + await sleep(20); + assert.equal(paints.length, settled, "a replayed publication is not a new paint"); + received.fire({ type: "inspect", enabled: true }); + await until(() => inspections > 0 && paints.length > settled); + await sleep(20); + const inspected = paints.length; + registration.api.setInspect(false); + await sleep(20); + assert.equal(paints.length, inspected, "turning Inspect off re-shows the painted gallery without a new paint"); + compiled({ failed: true, messages: [{ file: uri.fsPath, line: 2, column: 1, text: "Unresolved reference: Galery", level: "error" }] }); + assert.deepEqual([...problems.keys()], [uri.fsPath]); + // A frame pushed while the program does not compile paints, and the status keeps the compile error. + pushImages([{ id: "gallery", name: "Gallery", detail: "", data: "frame", width: 1, height: 1 }]); + await until(() => paints.length > inspected); + received.fire({ type: "ready" }); + await sleep(20); + const status = messages.findLast((message) => message.type === "status")!; + assert.equal(status.state, "error"); + assert.equal(status.text, "Live preview does not compile: Unresolved reference: Galery"); + panel.dispose(); + assert.equal(problems.size, 0, "closing the preview clears its Problems"); + } else if (scenario === "reveal") { + await commands.get("elide.androidComposePreview")!(); + assert.equal(panels, 1, "opening the previewed file's preview again keeps its panel"); + assert.equal(reveals, 1); + runtimeGate.resolve({}); + await until(() => builds === 1); + await sleep(20); + assert.equal(builds, 1, "and does not build again"); + } else if (scenario === "rename") { + await firstPaint(); + const screens = fileUri("/app/src/main/Screens.kt"); + renamed.fire({ files: [{ oldUri: uri, newUri: screens }] }); + await until(() => requested.at(-1) === screens.fsPath); + assert.equal(panel.title, "Compose: Screens.kt"); + assert.ok(messages.some((message) => message.type === "source" && message.uri === screens.toString()), "a window reload restores the renamed file"); + assert.deepEqual(workspaceState.get(RENAMES_KEY), { [uri.toString()]: screens.toString() }, "so does one after a rename the hidden webview missed"); + assert.equal(builds, 1, "a renamed Kotlin file is a live edit"); + renamed.fire({ files: [{ oldUri: fileUri("/app/src/main"), newUri: fileUri("/app/src/ui") }] }); + await until(() => builds === 2 && requested.at(-1) === "/app/src/ui/Screens.kt"); + buildGates[1]!.resolve(0); + await until(() => !roots.at(-1)!.running); + assert.deepEqual(workspaceState.get(RENAMES_KEY), { [uri.toString()]: screens.toString(), [screens.toString()]: "file:///app/src/ui/Screens.kt" }); + received.fire({ type: "ready" }); + assert.equal(workspaceState.get(RENAMES_KEY), undefined, "a running webview saves the current source itself"); + deleted.fire({ files: [fileUri("/app/src")] }); + const status = messages.findLast((message) => message.type === "status")!; + assert.equal(status.state, "error"); + assert.match(String(status.text), /^Screens\.kt was deleted\./); + } else if (scenario === "session-lost") { + await firstPaint(); + const { revision } = lastImages(); + const previewed = previews; + sessionLost(); + await until(() => (lastImages().revision as number) > (revision as number)); + assert.ok(previews > previewed, "a lost live session is previewed again"); + assert.equal(builds, 1, "without a build"); + } else if (scenario === "sync-wait" || scenario === "sync-failure") { + const opening = commands.get("elide.androidComposePreview")!(); + await until(() => progress.length > 0); + assert.equal(progress[0], "Elide: waiting for the sync of app to preview Gallery.kt…"); + assert.equal(panels, 0, "the preview waits for the running sync"); + sync.running = false; + if (scenario === "sync-wait") project.model = model; + else sync.error = "elide.pkl: unknown field"; + workspaceChanged.fire(); + await opening; + if (scenario === "sync-wait") { + assert.equal(panels, 1, "and opens once it finishes"); + await until(() => runtimeEntered); + } else { + assert.deepEqual(shown, ["Elide: The sync of app failed: elide.pkl: unknown field"]); + assert.equal(panels, 0); + } + } else if (scenario === "restore") { + assert.equal(panels, 0, "the restored panel is reused"); + assert.equal(panel.title, "Compose: Gallery.kt"); + assert.deepEqual(webview.options, { enableScripts: true, localResourceRoots: [] }); + received.fire({ type: "ready" }); + assert.ok(messages.some((message) => message.type === "source" && message.uri === uri.toString())); + assert.equal(workspaceState.get(RENAMES_KEY), undefined); + await firstPaint(); + } else if (scenario === "cancel") { + cancels[0]!(); + await until(() => !roots[0]!.running); + assert.equal(roots[0]!.state, "cancelled"); + assert.equal(builds, 0); + assert.match(String(messages.findLast((message) => message.type === "status")!.text), /^Preview cancelled\./); + } else if (scenario === "rebuild") { + runtimeGate.resolve({}); + await until(() => builds === 1); + received.fire({ type: "refresh" }); + assert.equal(buildSignals[0]!.aborted, true, "Rebuild stops the build it supersedes"); + await until(() => builds === 2); + buildGates[1]!.resolve(0); + await until(() => messages.some((message) => message.type === "images" && message.revision === 2)); + assert.equal(errors.length, 0); + } else if (scenario === "delete-during-build") { + runtimeGate.resolve({}); + await until(() => builds === 1); + deleted.fire({ files: [uri] }); + assert.equal(buildSignals[0]!.aborted, true, "deleting the previewed file stops its build"); + buildGates[0]!.resolve(0); + await until(() => !roots[0]!.running); + await sleep(20); + assert.match(String(messages.findLast((message) => message.type === "status")!.text), /^Gallery\.kt was deleted\./, + "the stopped build does not replace the deletion status"); + } else if (scenario === "log-replay") { + runtimeGate.resolve({}); + await until(() => builds === 1); + buildGates[0]!.resolve(1); + await until(() => messages.some((message) => message.type === "status" && message.state === "error")); + received.fire({ type: "ready" }); + await sleep(150); + // The Build log as the webview shows it. + const log: string[] = []; + for (const message of messages) if (message.type === "log") { + if (message.reset) log.length = 0; + log.push(...message.lines as string[]); + } + assert.equal(log.filter((line) => line.startsWith("Build failed")).length, 1, "a webview reload replays each log line once"); + } else throw new Error(`Unknown scenario: ${scenario}`); + console.log(`PASS ${scenario}`); +} finally { + registration.disposable.dispose(); +} diff --git a/packages/vscode/test/fixtures/composeRenderLifecycle.ts b/packages/vscode/test/fixtures/composeRenderLifecycle.ts new file mode 100644 index 0000000..97f00fc --- /dev/null +++ b/packages/vscode/test/fixtures/composeRenderLifecycle.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import * as childProcess from "node:child_process"; +import { EventEmitter } from "node:events"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import path from "node:path"; +import { PassThrough, Writable } from "node:stream"; +import { setTimeout as sleep } from "node:timers/promises"; +import { mock } from "bun:test"; +import type { ComposeRenderer as Renderer } from "../../src/composeRender.js"; + +const scenario = process.argv[2]; +const remove = fs.rm; +const root = await fs.mkdtemp(path.join(os.tmpdir(), "elide-compose-render-test-")); +const cleanupGate = Promise.withResolvers(); +const cleanupFinished = Promise.withResolvers(); +let delayedCleanup = false; +let failRender = scenario === "failure"; +let requests = 0; +/** Previews the fake renderers discover; each shard answers for the ones the request assigns it. */ +let previews = 0; +/** Answers the held warm-up of the warmup-failure scenario. */ +let release: (() => void) | undefined; +/** progress: each fake shard reports every image this many ms apart before answering; undefined answers at once. */ +let pace: number | undefined; +/** progress: the fake shard goes quiet instead of answering. */ +let hang = false; +const received: { child: number; shardIndex: number; shardCount: number; affinity: Record; methods: string[] | null }[] = []; +const children: Array boolean; killed: boolean }> = []; +mock.module("node:os", () => ({ ...os, availableParallelism: () => 4 })); +mock.module("node:fs/promises", () => ({ ...fs, rm: async (file: string, options: Parameters[1]) => { + const delay = failRender && String(file).endsWith(path.join("worker-1", "images-1")); + if (delay) { delayedCleanup = true; await cleanupGate.promise; } + await remove(file, options); + if (delay) cleanupFinished.resolve(); +} })); +mock.module("node:child_process", () => ({ ...childProcess, spawn: () => { + const index = children.length; + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), stderr: new PassThrough(), stdin: undefined as unknown as Writable, + killed: false, + kill() { + if (!child.killed) { + child.killed = true; + queueMicrotask(() => child.emit("close", null, "SIGKILL")); + } + return true; + }, + }); + child.stdin = new Writable({ write(chunk, _encoding, callback) { + const request = JSON.parse(String(chunk)); + callback(); + received.push({ child: index, shardIndex: request.shardIndex, shardCount: request.shardCount, affinity: request.affinity, methods: request.methods }); + // warmup-failure: the second JVM's whole-gallery warm-up fails; the third's first one waits for `release`. + const warmup = scenario === "warmup-failure" && index > 0 && request.shardCount === 1; + // As RenderServer does: a request's methods filter the discovered previews before sharding. + const discovered = Array.from({ length: previews }, (_, preview) => ({ + methodFQN: `sample.Preview${preview}`, previewId: `p${preview}`, name: `Preview${preview}`, previewParams: {}, methodParams: [], + })).filter((preview) => !request.methods || request.methods.includes(preview.methodFQN)); + const results = discovered.filter((_, preview) => preview % request.shardCount === request.shardIndex) + .map((preview) => ({ id: `${preview.previewId}:0`, previewId: preview.previewId, error: "not rendered by the fake" })); + const respond = () => child.stdout.write(`${JSON.stringify({ id: request.id, previews: discovered, screenshotResults: results })}\n`); + if (warmup && index === 1) { + queueMicrotask(() => child.stdout.write(`${JSON.stringify({ id: request.id, globalError: "warm-up failure" })}\n`)); + } else if (warmup && index === 2 && !release) { + // Held: the warm-up is still reading the gallery's classes. + release = respond; + } else if (hang) { + // A hung JVM: neither progress nor an answer. + } else if (pace !== undefined) { + const delay = pace; + void (async () => { + for (let image = 0; image < results.length; image++) { + await sleep(delay); + child.stdout.write(`${JSON.stringify({ id: request.id, progress: true })}\n`); + } + await sleep(delay); + respond(); + })(); + } else if (!failRender) { + queueMicrotask(respond); + } else if (++requests === 2) { + queueMicrotask(() => { + children[1]!.stderr.write("layoutlib original stderr\n"); + children[1]!.stdout.write(`${JSON.stringify({ id: request.id, globalError: "original preview rendering failure" })}\n`); + }); + } + } }); + children.push(child); + queueMicrotask(() => child.stdout.write('{"ready":true}\n')); + return child; +} })); +// Worker and filesystem modules must be replaced before loading the real renderer. +const { ComposeRenderer, helperDigest } = await import("../../src/composeRender.js"); +const runtime = { rendererJar: "renderer.jar", rendererClassPath: ["renderer.jar"], layoutlibPath: "layoutlib" }; +const helperDirectory = path.join(root, "helpers"); +let renderer: Renderer | undefined; +/** The renderer's private JVM list: their protocol streams settle asynchronously, so the steps below wait on state, not time. */ +interface Pool { workers: { started: boolean; joined: boolean; warming: boolean }[] } +const pool = () => { + // Test-only view of private state the public API deliberately hides. + const internals = renderer as unknown as Pool; + return internals.workers; +}; +async function until(what: string, condition: () => boolean): Promise { + for (const deadline = Date.now() + 5_000; !condition();) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await sleep(2); + } +} +try { + await fs.mkdir(helperDirectory); + for (const name of ["CompileRenderer.java", "DiscoverPreviews.java", "RenderServer.java"]) await fs.writeFile(path.join(helperDirectory, name), "helper"); + const classes = path.join(root, "compose-helpers", (await helperDigest(runtime, helperDirectory)).slice(0, 24)); + await fs.mkdir(classes, { recursive: true }); + await fs.writeFile(path.join(classes, "RenderServer.class"), "compiled helper"); + await fs.writeFile(path.join(classes, "ready"), ""); + const directory = path.join(root, "workspace"); + const inputs = { classPath: [], projectClassPath: [], sourceSelections: {}, namespace: "sample", resourceApkPath: "resources.apk" }; + if (scenario === "failure") { + renderer = await ComposeRenderer.create(runtime, "java", helperDirectory, root, directory, new AbortController().signal, { weight: 8 }); + assert.equal(children.length, 2, "a large expected gallery starts several JVMs up front"); + const failure = await renderer.render(inputs, "Gallery.kt", "sample").then(() => undefined, (error: unknown) => error); + assert.ok(failure instanceof Error); + assert.match(failure.message, /original preview rendering failure/); + assert.match(failure.message, /layoutlib original stderr/); + assert.equal(renderer.alive, false); + assert.ok(children.every((child) => child.killed), "a failed gallery stops all shards"); + cleanupGate.resolve(); + await cleanupFinished.promise; + failRender = false; + renderer = await ComposeRenderer.create(runtime, "java", helperDirectory, root, directory, new AbortController().signal); + assert.deepEqual(await renderer.render(inputs, "Gallery.kt", "sample"), [], "a replacement renderer can render after the failure"); + } else if (scenario === "warmup-failure") { + renderer = await ComposeRenderer.create(runtime, "java", helperDirectory, root, directory, new AbortController().signal, { quietMs: 40 }); + previews = 8; + await renderer.render(inputs, "Gallery.kt", "sample"); + await until("the new JVM to start", () => pool().every((worker) => worker.started)); + assert.equal((await renderer.render(inputs, "Gallery.kt", "sample")).length, 8); + await until("the failed warm-up to leave the pool", () => renderer!.size === 1); + assert.equal(renderer.alive, true, "a failed warm-up never fails the renderer"); + assert.deepEqual(children.map((child) => child.killed), [false, true], "only the warming JVM stops"); + assert.equal(renderer.size, 1, "and it leaves the pool"); + assert.equal((await renderer.render(inputs, "Gallery.kt", "sample")).length, 8, "joined JVMs keep painting"); + assert.equal(renderer.size, 2, "the next render starts a replacement"); + await until("the replacement to start", () => pool().every((worker) => worker.started)); + await renderer.render(inputs, "Gallery.kt", "sample"); + await until("its warm-up request to arrive", () => release !== undefined); + renderer.abandonWarmups(); + assert.equal(children[2]!.killed, false, "publication never kills a warm-up: restarting JVMs while typing burns CPU"); + release!(); + await until("the abandoned warm-up to finish", () => !pool().some((worker) => worker.warming)); + assert.equal(children[0]!.killed, false); + assert.equal(children.length, 3, "no replacement JVM was started"); + assert.equal(renderer.size, 2); + assert.equal(renderer.alive, true); + assert.equal((await renderer.render(inputs, "Gallery.kt", "sample")).length, 8); + } else if (scenario === "progress") { + renderer = await ComposeRenderer.create(runtime, "java", helperDirectory, root, directory, new AbortController().signal, { stallMs: 100 }); + previews = 4; + pace = 40; + const started = performance.now(); + assert.equal((await renderer.render(inputs, "Gallery.kt", "sample")).length, 4, "a shard reporting each image may outlast the stall limit"); + assert.ok(performance.now() - started > 150, "the fake shard took longer than the stall limit"); + assert.equal(renderer.alive, true); + hang = true; + await assert.rejects(renderer.render(inputs, "Gallery.kt", "sample"), /timed out/, "a JVM that goes quiet still counts as hung"); + assert.equal(renderer.alive, false); + } else if (scenario === "methods") { + renderer = await ComposeRenderer.create(runtime, "java", helperDirectory, root, directory, new AbortController().signal, { surplusIdleMs: 20 }); + previews = 8; + assert.equal((await renderer.render(inputs, "Gallery.kt", "sample")).length, 8); + assert.equal(received[0]!.methods, null, "a full-file render names no methods"); + await until("the new JVM to start", () => pool().every((worker) => worker.started)); + await renderer.render(inputs, "Gallery.kt", "sample"); + await until("the warm-up to join", () => pool().every((worker) => worker.joined)); + const weight = renderer.weight; + received.length = 0; + const selected = await renderer.render(inputs, "Gallery.kt", "sample", ["sample.Preview3"]); + assert.deepEqual(selected.map((image) => image.id), ["p3:0"], "only the selected preview renders"); + assert.ok(received.length > 0 && received.every((request) => JSON.stringify(request.methods) === JSON.stringify(["sample.Preview3"])), + "every shard request carries the selection"); + assert.equal(renderer.weight, weight, "a selection's render keeps the file gallery's weights"); + await sleep(60); + assert.equal(renderer.size, 2, "and the pool the file's gallery needs"); + } else { + renderer = await ComposeRenderer.create(runtime, "java", helperDirectory, root, directory, new AbortController().signal, { surplusIdleMs: 20 }); + assert.equal(children.length, 1, "a small expected gallery starts one JVM"); + previews = 8; + assert.equal((await renderer.render(inputs, "Gallery.kt", "sample")).length, 8, "the running JVM renders the whole gallery"); + assert.deepEqual(received.map(({ child, shardCount }) => [child, shardCount]), [[0, 1]]); + assert.equal(renderer.size, 2, "a larger gallery starts another JVM, capped at half the cores"); + await until("the new JVM to start", () => pool().every((worker) => worker.started)); + received.length = 0; + assert.equal((await renderer.render(inputs, "Gallery.kt", "sample")).length, 8); + await until("the warm-up to join", () => pool().every((worker) => worker.joined)); + assert.deepEqual(received.map(({ child, shardIndex, shardCount }) => [child, shardIndex, shardCount]), [[0, 0, 1], [1, 0, 1]], + "a started JVM first renders the whole gallery alone, in the background"); + received.length = 0; + assert.equal((await renderer.render(inputs, "Gallery.kt", "sample")).length, 8); + // Shards write their requests after creating their output folders, in either order. + assert.deepEqual(received.map(({ child, shardIndex, shardCount }) => [child, shardIndex, shardCount]).sort(), [[0, 0, 2], [1, 1, 2]], "then it takes shards"); + assert.deepEqual(received[0]!.affinity, received[1]!.affinity, "every shard computes the same assignment"); + assert.deepEqual(received[0]!.affinity, Object.fromEntries(Array.from({ length: 8 }, (_, preview) => [`p${preview}`, 0]))); + previews = 2; + await renderer.render(inputs, "Gallery.kt", "sample"); + assert.equal(renderer.size, 2, "surplus JVMs stay until they idle"); + await until("the idle surplus JVM to stop", () => renderer!.size === 1); + assert.equal(renderer.size, 1, "an idle surplus JVM stops"); + assert.equal(children[1]!.killed, true); + assert.equal(renderer.alive, true, "stopping surplus never fails the renderer"); + received.length = 0; + assert.equal((await renderer.render(inputs, "Gallery.kt", "sample")).length, 2); + assert.deepEqual(received.map(({ child, shardCount }) => [child, shardCount]), [[0, 1]]); + } +} finally { + cleanupGate.resolve(); + if (delayedCleanup) await cleanupFinished.promise; + renderer?.dispose(); + await remove(root, { recursive: true, force: true }); +} diff --git a/packages/vscode/test/fixtures/composeWorkerLifecycle.ts b/packages/vscode/test/fixtures/composeWorkerLifecycle.ts new file mode 100644 index 0000000..fb4929f --- /dev/null +++ b/packages/vscode/test/fixtures/composeWorkerLifecycle.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import * as childProcess from "node:child_process"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { mock } from "bun:test"; + +const scenario = process.argv[2]; +const fakeChild = () => Object.assign(new EventEmitter(), { + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), +}); +let child = fakeChild(); +mock.module("node:child_process", () => ({ ...childProcess, spawn: () => child })); +// Install the child-process replacement before loading the real persistent-worker transport. +const { ComposeWorker } = await import("../../src/composeWorker.js"); + +if (scenario === "exit") { + const worker = new ComposeWorker("elide", "kotlinc", ".", new AbortController().signal); + const answering = child; + child = fakeChild(); + const unanswered = new ComposeWorker("elide", "kotlinc", ".", new AbortController().signal); + try { + const response = worker.request([]); + // ChildProcess permits exit before its stdio streams finish delivering buffered data. + answering.emit("exit", 1, null); + answering.stderr.write("late compiler diagnostic\n"); + answering.stdout.write(Buffer.from([2, 24, 1])); + answering.emit("close", 1, null); + assert.deepEqual(await response, { exitCode: 0, output: "", requestId: 1, wasCancelled: false }); + assert.equal(worker.alive, false); + const crash = await worker.request([]).then(() => undefined, (error: unknown) => error); + assert.ok(crash instanceof Error); + assert.match(crash.message, /late compiler diagnostic/); + assert.doesNotMatch(crash.message, /persistent workers/, "a worker that answered before supports the protocol: blame the crash, not Elide's version"); + + const never = unanswered.request([]); + child.emit("close", 2, null); + await assert.rejects(never, /must support persistent workers/, "a worker that never answers may not speak the protocol"); + } finally { + worker.dispose(); + unanswered.dispose(); + } +} else if (scenario === "timeout") { + // The worker's deadline is its only timer: record it and expire it on demand. + let deadline: { delay: number; expire: () => void } | undefined; + globalThis.setTimeout = ((expire: () => void, delay: number) => { + deadline = { delay, expire }; + return { unref() {} }; + }) as unknown as typeof setTimeout; + globalThis.clearTimeout = (() => {}) as typeof clearTimeout; + const signal = new AbortController().signal; + /** One attempt on a fresh worker, as the compiler makes after a timeout: the deadline it got and how it ended. */ + const attempt = async (cwd: string, tool: "kotlinc" | "javac" = "kotlinc", answer = false) => { + child = fakeChild(); + const worker = new ComposeWorker("elide", tool, cwd, signal); + try { + const response = worker.request([]); + const { delay, expire } = deadline!; + if (answer) child.stdout.write(Buffer.from([2, 24, 1])); + else expire(); + return { delay, error: await response.then(() => undefined, (error: unknown) => (error as Error).message) }; + } finally { + worker.dispose(); + } + }; + const first = await attempt("/large"); + assert.equal(first.delay, 120_000); + assert.match(first.error!, /kotlinc worker timed out after 120 s; the next attempt allows 240 s\./); + // A cold compile slower than the deadline is not repeated under the same deadline until the user gives up. + assert.deepEqual([(await attempt("/large")).delay, (await attempt("/large")).delay], [240_000, 480_000]); + const capped = await attempt("/large"); + assert.equal(capped.delay, 960_000); + assert.match(capped.error!, /timed out after 960 s\.\n/, "a hung worker still fails, without promising a longer retry"); + assert.equal((await attempt("/large")).delay, 960_000); + // Only the worker that timed out waits longer: other directories and tools keep the default. + assert.equal((await attempt("/small")).delay, 120_000); + assert.equal((await attempt("/large", "javac")).delay, 120_000); + const answered = await attempt("/fast", "kotlinc", true); + assert.deepEqual(answered, { delay: 120_000, error: undefined }); + assert.equal((await attempt("/fast")).delay, 120_000, "an answered request raises nothing"); +} else throw new Error(`Unknown scenario ${scenario}`); diff --git a/packages/vscode/test/integration/android.ts b/packages/vscode/test/integration/android.ts index 8e083a3..d76f3bb 100644 --- a/packages/vscode/test/integration/android.ts +++ b/packages/vscode/test/integration/android.ts @@ -3,8 +3,8 @@ * sync installs every dependency and the build compiles, dexes and packages the APK from scratch. * * Drives what a user does: the startup sync, watched live through the Activity view the way the status bar shows - * it; **Build** on the APK's row of the Android section; **Run** on the attached device, or — when none is — on a - * pinned emulator that has stopped, which Run boots by itself. The app is running when `adb` finds its process. + * it; **Create Android Signing Keystore**, then **Build** on the APK's row of the Android section; **Run** on the + * attached device, or on a stopped pinned emulator. The app is running when `adb` finds its process. */ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; @@ -14,6 +14,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import { ANDROID_EMULATOR_TASK, androidArtifactFile, + androidKeystoreTask, androidRunTask, androidSdkRoot, listAndroidDevices, @@ -35,7 +36,7 @@ interface Row { } /** Breadth-first through the Elide sidebar until `match` finds a row. */ -async function findRow(explorer: ElideApi["explorer"], match: (row: Row) => boolean): Promise { +export async function findRow(explorer: ElideApi["explorer"], match: (row: Row) => boolean): Promise { const queue: (Row | undefined)[] = [undefined]; for (let depth = 0; queue.length > 0 && depth < 200; depth++) { const parent = queue.shift(); @@ -48,7 +49,7 @@ async function findRow(explorer: ElideApi["explorer"], match: (row: Row) => bool } /** The exit code of the next `elide` task process whose arguments include `arg`. */ -function taskExit(arg: string): Promise { +export function taskExit(arg: string): Promise { const { promise, resolve } = Promise.withResolvers(); const subscription = vscode.tasks.onDidEndTaskProcess((event) => { const args: unknown = event.execution.task.definition.args; @@ -99,9 +100,16 @@ export async function runAndroidScenario(): Promise { log(`skipped tasks kept: ${skipped.length}, e.g. ${skipped.slice(0, 3).map((node) => `${node.label} (${node.detail}, ${Math.round(node.durationMs)} ms)`).join(" | ")}`); assert.ok(skipped.length > 0 && skipped.every((node) => node.detail), "skipped tasks stay in the Activity view with the reason"); - // 2. Build the APK from its row in the Android section. + // 2. A clean copy excludes .dev, including its configured debug signing key. Create it through the same + // extension command a user invokes, rather than borrowing a key or build output from the source checkout. const row = await waitFor("APK row", () => findRow(api.explorer, (r) => r.kind === "androidTarget" && r.artifact === ARTIFACT), 60_000); assert.equal(row.root, member); + const keyCreated = taskExit(androidKeystoreTask(ARTIFACT)); + await vscode.commands.executeCommand("elide.androidCreateKeystore", row); + assert.equal(await waitFor("debug signing keystore", () => keyCreated, 120_000), 0, "debug signing key created"); + log("created the fixture's debug signing keystore"); + + // Build the APK from its row in the Android section. const apk = androidArtifactFile(member, ARTIFACT, "apk", `${ARTIFACT}.apk`); const built = taskExit(ARTIFACT); let started = performance.now(); diff --git a/packages/vscode/test/integration/compose.ts b/packages/vscode/test/integration/compose.ts index d3efb77..232e8d3 100644 --- a/packages/vscode/test/integration/compose.ts +++ b/packages/vscode/test/integration/compose.ts @@ -1,10 +1,13 @@ /** * The `compose` scenario (see runner.ts), on the `elide-android-sample` checkout, built at least once. Checks the - * Compose preview latency a user sees: focusing a preview file prewarms the renderer and compiler, so opening the - * preview paints within about a second; a live edit repaints within the edit budget; and a reopened panel reuses - * the backend kept alive after closing. + * 13-image gallery in a managed Kotlin module with Room/kapt: optimistic unsaved edits meet the paint budget, + * processor confirmation is measured separately, schema changes regenerate visible output, and invalid queries + * surface diagnostics. Reverting and reopening must reuse the confirmed gallery; opening over an unsaved edit must + * leave it unsaved. Pointing `ELIDE_ANDROID_SAMPLE` through a symlink (macOS `/tmp`) checks a workspace opened at + * a path other than the canonical one Elide records. */ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import path from "node:path"; import * as vscode from "vscode"; import type { ComposePreviewPaint } from "../../src/composePreview.js"; @@ -18,16 +21,22 @@ const EDIT_BUDGET_MS = 1_500; export async function runComposeScenario(): Promise { const sample = process.env.ELIDE_TEST_SAMPLE!; const gallery = vscode.Uri.file(path.join(sample, "app", "src", "main", "kotlin", "sample", "app", "PreviewGallery.kt")); + const database = vscode.Uri.file(path.join(sample, "app", "src", "main", "kotlin", "sample", "data", "JourneyDatabase.kt")); const extension = vscode.extensions.all.find((e) => e.id.endsWith(".elide")); assert.ok(extension, "elide extension found"); const api = await waitFor("compose preview api", () => (extension.exports as ElideApi | undefined)?.composePreview, 60_000, 200); const paints: ComposePreviewPaint[] = []; + const errors: { source: string; message: string }[] = []; + api.onDidError((error) => { + log("preview error", JSON.stringify(error)); + errors.push(error); + }); api.onDidPaint((paint) => { log("paint", JSON.stringify(paint)); paints.push(paint); }); - const nextPaint = (after: number, what: string, final: boolean) => - waitFor(what, () => paints.slice(after).find((paint) => paint.source === gallery.fsPath && (!final || !paint.verifying)), 300_000, 20); + const nextPaint = (after: number, what: string, final: boolean, matches: (paint: ComposePreviewPaint) => boolean = () => true) => + waitFor(what, () => paints.slice(after).find((paint) => paint.source === gallery.fsPath && (!final || !paint.verifying) && matches(paint)), 300_000, 20); // 1. Focusing a preview file after the sync prewarms its backend from the last build's classes. let prewarmed: string | undefined; @@ -67,19 +76,158 @@ export async function runComposeScenario(): Promise { assert.match(buildLog, /\$ elide build|Build successful/); log(`activity: ${opened.label} ${opened.durationMs} ms: ${opened.children.map((step) => `${step.label} ${step.durationMs} ms`).join(", ")}`); - // 3. A live edit (unsaved) repaints within the edit budget. - const editor = await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(gallery), { preview: false, viewColumn: vscode.ViewColumn.One }); + // Saved-source Inspect must expose measured call sites without a sacrificial unsaved edit. mark = paints.length; - started = performance.now(); - await editor.edit((builder) => builder.insert(new vscode.Position(editor.document.lineCount, 0), `\n// preview edit ${Date.now()}\n`)); - const edited = await nextPaint(mark, "edit paint", true); - const editMs = performance.now() - started; - log(`edit: painted in ${Math.round(editMs)} ms`); - assert.equal(edited.images, GALLERY); - assert.ok(editMs < EDIT_BUDGET_MS, `edit painted in ${Math.round(editMs)} ms, budget ${EDIT_BUDGET_MS} ms`); - mark = paints.length; - await vscode.commands.executeCommand("workbench.action.files.revert"); - await nextPaint(mark, "revert paint", true); + api.setInspect(true); + const inspected = await nextPaint(mark, "saved gallery inspection", true, (paint) => paint.inspection.enabled); + assert.equal(inspected.inspection.images, GALLERY, "every saved preview supplies selectable layout bounds"); + assert.ok(inspected.inspection.sources.includes(path.join(sample, "components", "src", "main", "kotlin", "sample", "components", "JourneyCard.kt")), + "saved inspection includes instrumented workspace-library call sites"); + assert.equal((await vscode.workspace.openTextDocument(gallery)).isDirty, false, "Inspect does not mutate saved sources"); + log("saved Inspect", JSON.stringify(inspected.inspection)); + api.setInspect(false); + + // 3. Body-only edits paint optimistically within budget; confirmation must not rerun Room. + const editor = await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(gallery), { preview: false, viewColumn: vscode.ViewColumn.One }); + const databaseDocument = await vscode.workspace.openTextDocument(database); + assert.equal(editor.document.isDirty, false, "the fixture gallery starts saved"); + assert.equal(databaseDocument.isDirty, false, "the fixture schema starts saved"); + const replace = async (target: vscode.TextEditor, before: string, after: string) => { + const text = target.document.getText(); + const offset = text.indexOf(before); + assert.ok(offset >= 0 && text.indexOf(before, offset + before.length) < 0, `unique fixture edit: ${before}`); + assert.ok(await target.edit((builder) => builder.replace( + new vscode.Range(target.document.positionAt(offset), target.document.positionAt(offset + before.length)), after, + ))); + assert.equal(target.document.isDirty, true, "live edits remain unsaved"); + }; + const revert = async (document: vscode.TextDocument) => { + await vscode.window.showTextDocument(document, { preview: false, viewColumn: vscode.ViewColumn.One }); + await vscode.commands.executeCommand("workbench.action.files.revert"); + }; + const assertGallery = (paint: ComposePreviewPaint) => { + assert.equal(paint.images, GALLERY); + assert.equal(paint.failed, 0); + assert.match(paint.contentHash, /^[a-f0-9]{64}$/); + }; + assertGallery(confirmed); + assert.ok(confirmed.processing, "the preview-bearing app has a processing stage"); + let baseline = confirmed; + try { + mark = paints.length; + started = performance.now(); + await replace(editor, 'BasicText("Hello World!")', 'BasicText("Hello live Room!")'); + const edited = await nextPaint(mark, "optimistic body edit", false, (paint) => paint.contentHash !== baseline.contentHash); + const editMs = performance.now() - started; + assertGallery(edited); + assert.equal(edited.verifying, true, "body edit paints before processing confirmation"); + assert.ok(editMs < EDIT_BUDGET_MS, `optimistic edit painted in ${Math.round(editMs)} ms, budget ${EDIT_BUDGET_MS} ms`); + const bodyConfirmed = await nextPaint(mark, "body processing confirmation", true, (paint) => paint.contentHash === edited.contentHash); + log(`body edit: optimistic ${Math.round(editMs)} ms; confirmed ${Math.round(performance.now() - started)} ms`); + assertGallery(bodyConfirmed); + assert.ok(bodyConfirmed.processing); + assert.equal(bodyConfirmed.processing.apt, baseline.processing!.apt, "unchanged stubs do not rerun Room"); + assert.ok(bodyConfirmed.processing.stubs > baseline.processing!.stubs, "body edit verifies kapt stubs"); + mark = paints.length; + started = performance.now(); + await revert(editor.document); + baseline = await nextPaint(mark, "body revert reuses gallery", true, (paint) => paint.reused && paint.contentHash === confirmed.contentHash); + assertGallery(baseline); + log(`body revert: reused gallery in ${Math.round(performance.now() - started)} ms`); + + // Layout edits change the composition, not the Room declarations in this module. + for (const [name, replacement] of [ + ["nested layout", 'Row { BasicText("Hello World!"); BasicText("Structural preview") }'], + ["conditional composition", 'if (example.stops > 0) { BasicText("Structural branch") }'], + ["new composable", 'LiveStructuralText()'], + ] as const) { + mark = paints.length; + started = performance.now(); + const text = editor.document.getText(); + await replace(editor, text, text.replace('BasicText("Hello World!")', replacement) + + (name === "new composable" ? '\n@Composable\nprivate fun LiveStructuralText() { BasicText("Structural helper") }\n' : "")); + const structural = await nextPaint(mark, `${name} paint`, false, (paint) => paint.contentHash !== baseline.contentHash); + const paintMs = performance.now() - started; + assertGallery(structural); + assert.ok(paintMs < EDIT_BUDGET_MS, `${name} painted in ${Math.round(paintMs)} ms, budget ${EDIT_BUDGET_MS} ms`); + const verified = await nextPaint(mark, `${name} confirmation`, true, (paint) => paint.contentHash === structural.contentHash); + log(`${name}: optimistic ${Math.round(paintMs)} ms; confirmed ${Math.round(performance.now() - started)} ms; processing ${JSON.stringify(verified.processing)}`); + assertGallery(verified); + assert.ok(performance.now() - started < 2_500, `${name} confirms without a cold recompile`); + if (name !== "new composable") assert.equal(verified.processing!.apt, baseline.processing!.apt); + else { + assert.ok(verified.processing!.apt > baseline.processing!.apt, "new declarations still run processors"); + mark = paints.length; + started = performance.now(); + await replace(editor, 'BasicText("Structural helper")', 'Row { BasicText("Warm structural follow-up"); BasicText("Still warm") }'); + const followup = await nextPaint(mark, "structural follow-up", false, (paint) => paint.contentHash !== verified.contentHash); + const followupMs = performance.now() - started; + assertGallery(followup); + assert.ok(followupMs < EDIT_BUDGET_MS, `structural follow-up painted in ${Math.round(followupMs)} ms`); + await nextPaint(mark, "structural follow-up confirmation", true, (paint) => paint.contentHash === followup.contentHash); + log(`structural follow-up: optimistic ${Math.round(followupMs)} ms; confirmed ${Math.round(performance.now() - started)} ms`); + } + mark = paints.length; + await revert(editor.document); + baseline = await nextPaint(mark, `${name} revert`, true, (paint) => paint.reused && paint.contentHash === confirmed.contentHash); + } + + // A column rename leaves the Kotlin constructor unchanged. Only regenerated Room code can change the + // identity hash drawn by StatePreview: entity reflection or a stale generated class cannot pass this check. + const schemaEditor = await vscode.window.showTextDocument(databaseDocument, { preview: false, viewColumn: vscode.ViewColumn.One }); + mark = paints.length; + started = performance.now(); + await replace(schemaEditor, '@ColumnInfo(name = "stops")', '@ColumnInfo(name = "live_stops")'); + const schemaOptimistic = await nextPaint(mark, "optimistic entity edit", false); + assertGallery(schemaOptimistic); + assert.equal(schemaOptimistic.verifying, true); + assert.equal(schemaOptimistic.contentHash, baseline.contentHash, "optimistic schema paint uses the last generated Room implementation"); + const schemaConfirmed = await nextPaint(mark, "regenerated Room schema paint", true, (paint) => paint.contentHash !== baseline.contentHash); + assertGallery(schemaConfirmed); + assert.ok(schemaConfirmed.processing && baseline.processing); + assert.ok(schemaConfirmed.processing.apt > baseline.processing.apt, "changed entity stubs rerun Room"); + log(`entity edit: optimistic ${schemaOptimistic.elapsedMs} ms; regenerated confirmation ${Math.round(performance.now() - started)} ms`); + mark = paints.length; + await revert(databaseDocument); + baseline = await nextPaint(mark, "entity revert reuses gallery", true, (paint) => paint.reused && paint.contentHash === confirmed.contentHash); + assertGallery(baseline); + + // Adding a defaulted Kotlin property keeps handwritten callers valid, but the old generated + // Java constructor call no longer compiles. Verification must recover without requiring a save. + const declarationErrors = errors.length; + mark = paints.length; + await replace(schemaEditor, " val createdAt: Long,", ' val createdAt: Long,\n val previewNote: String = "",'); + const declarationConfirmed = await nextPaint(mark, "generated constructor recovery", true, (paint) => paint.contentHash !== baseline.contentHash); + assertGallery(declarationConfirmed); + assert.ok(declarationConfirmed.processing!.apt > baseline.processing!.apt); + assert.equal(errors.length, declarationErrors, "obsolete generated constructor errors must recover through kapt"); + mark = paints.length; + await revert(databaseDocument); + baseline = await nextPaint(mark, "property revert reuses gallery", true, (paint) => paint.reused && paint.contentHash === confirmed.contentHash); + + // Kotlin accepts this annotation value, but Room must reject it. Do not accept a confirmed stale image. + const queryEditor = await vscode.window.showTextDocument(databaseDocument, { preview: false, viewColumn: vscode.ViewColumn.One }); + const errorMark = errors.length; + mark = paints.length; + started = performance.now(); + await replace(queryEditor, "SELECT * FROM journeys ORDER BY createdAt DESC, id DESC", + "SELECT missing_live_column FROM journeys ORDER BY createdAt DESC, id DESC"); + const processorError = await waitFor("Room query processor diagnostic", () => errors.slice(errorMark) + .find((error) => error.source === gallery.fsPath && /missing_live_column|no such column/i.test(error.message)), 300_000, 20); + log(`invalid query: diagnostic after ${Math.round(performance.now() - started)} ms: ${processorError.message}`); + assert.ok(!paints.slice(mark).some((paint) => paint.source === gallery.fsPath && !paint.verifying), + "invalid query never confirms stale generated output"); + assert.equal(databaseDocument.isDirty, true, "processor diagnostic came from an unsaved query"); + mark = paints.length; + started = performance.now(); + await revert(databaseDocument); + const recovered = await nextPaint(mark, "query revert reuses gallery", true, (paint) => paint.reused && paint.contentHash === confirmed.contentHash); + assertGallery(recovered); + assert.ok(performance.now() - started < EDIT_BUDGET_MS, "reverting a processor error reuses the stored gallery within the edit budget"); + } finally { + if (databaseDocument.isDirty) await revert(databaseDocument); + if (editor.document.isDirty) await revert(editor.document); + } // 4. Closing keeps the backend alive: reopening paints within the open budget again. const closeTab = async () => { @@ -99,8 +247,30 @@ export async function runComposeScenario(): Promise { assert.ok(reopenMs < OPEN_BUDGET_MS, `reopened preview painted in ${Math.round(reopenMs)} ms, budget ${OPEN_BUDGET_MS} ms`); await nextPaint(mark, "reopen confirmation", true); + // 4b. Opening a preview over an unsaved edit builds from the disk without saving any editor, under VS Code's + // default `task.saveBeforeRun` (`always`), and paints the editor's text. + await closeTab(); + const unsavedEditor = await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(gallery), { preview: false, viewColumn: vscode.ViewColumn.One }); + const onDisk = readFileSync(gallery.fsPath, "utf8"); + const saved: string[] = []; + const saving = vscode.workspace.onWillSaveTextDocument((event) => saved.push(event.document.uri.fsPath)); + try { + await unsavedEditor.edit((builder) => builder.insert(new vscode.Position(unsavedEditor.document.lineCount, 0), `\n// unsaved ${Date.now()}\n`)); + mark = paints.length; + await vscode.commands.executeCommand("elide.androidComposePreview"); + const unsavedConfirmed = await nextPaint(mark, "unsaved open confirmation", true); + assertGallery(unsavedConfirmed); + assert.equal(unsavedEditor.document.isDirty, true, "opening a preview leaves the unsaved editor dirty"); + assert.equal(readFileSync(gallery.fsPath, "utf8"), onDisk, "opening a preview does not write editor text to disk"); + assert.deepEqual(saved, [], "opening a preview saves no editor"); + log("unsaved open: editor stays dirty, build confirmed from the disk baseline"); + } finally { + saving.dispose(); + await revert(unsavedEditor.document); + } + // 5. Without a warm backend (as after restarting VS Code), unchanged sources still paint their stored gallery - // while the renderer and the build start; the next edit renders from scratch. + // while the renderer and the build start; confirmation initializes one private compiler baseline. await closeTab(); const settings = vscode.workspace.getConfiguration("elide.android.composePreview"); await settings.update("prewarm", false, vscode.ConfigurationTarget.Global); @@ -114,14 +284,31 @@ export async function runComposeScenario(): Promise { log(`cold backend: painted stored gallery in ${Math.round(restoredMs)} ms`); assert.equal(restored.images, GALLERY); assert.ok(restoredMs < OPEN_BUDGET_MS, `stored gallery painted in ${Math.round(restoredMs)} ms, budget ${OPEN_BUDGET_MS} ms`); - await nextPaint(mark, "cold backend confirmation", true); + const coldConfirmed = await nextPaint(mark, "cold backend confirmation", true); + const coldConfirmedMs = performance.now() - started; + log(`cold backend: confirmed in ${Math.round(coldConfirmedMs)} ms; processing ${JSON.stringify(coldConfirmed.processing)}`); + assert.equal(coldConfirmed.processing?.apt, 1, "cold initialization processes the instrumented baseline once, not seeded then private dependencies"); + assert.equal(coldConfirmed.processing?.stubs, 1, "cold initialization verifies a single dependency baseline"); + mark = paints.length; + api.setInspect(true); + const coldInspected = await nextPaint(mark, "cold saved gallery inspection", true, (paint) => paint.inspection.enabled); + assert.equal(coldInspected.inspection.images, GALLERY, "cached cold reopen retains measured bounds without a live edit"); + assert.ok(coldInspected.inspection.sources.some((file) => file.endsWith(`${path.sep}JourneyCard.kt`))); + assert.deepEqual(coldInspected.processing, coldConfirmed.processing, "Inspect reuses initialized processing and compiler state"); + api.setInspect(false); const coldEditor = await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(gallery), { preview: false, viewColumn: vscode.ViewColumn.One }); mark = paints.length; started = performance.now(); await coldEditor.edit((builder) => builder.insert(new vscode.Position(coldEditor.document.lineCount, 0), `\n// cold edit ${Date.now()}\n`)); - const coldEdit = await nextPaint(mark, "cold backend edit paint", true); - log(`cold backend: first edit painted in ${Math.round(performance.now() - started)} ms`); - assert.equal(coldEdit.images, GALLERY); + const coldOptimistic = await nextPaint(mark, "cold backend optimistic edit paint", false); + const coldEditMs = performance.now() - started; + const coldEdit = await nextPaint(mark, "cold backend edit confirmation", true); + log(`cold backend: cached ${Math.round(restoredMs)} ms; confirmed ${Math.round(coldConfirmedMs)} ms; first edit optimistic ${Math.round(coldEditMs)} ms, confirmed ${Math.round(performance.now() - started)} ms`); + assertGallery(coldEdit); + assert.equal(coldOptimistic.verifying, true, "the initialized first edit paints before processing verification"); + assert.ok(coldEditMs < EDIT_BUDGET_MS, `cold backend's initialized first edit painted in ${Math.round(coldEditMs)} ms, budget ${EDIT_BUDGET_MS} ms`); + assert.equal(coldEdit.processing?.apt, coldConfirmed.processing?.apt, "first comment edit cannot rerun Room"); + assert.equal(coldEdit.processing?.stubs, coldConfirmed.processing!.stubs + 1, "first edit needs only one processing verification"); await vscode.commands.executeCommand("workbench.action.files.revert"); await closeTab(); diff --git a/packages/vscode/test/integration/composeAgent.ts b/packages/vscode/test/integration/composeAgent.ts new file mode 100644 index 0000000..01d52be --- /dev/null +++ b/packages/vscode/test/integration/composeAgent.ts @@ -0,0 +1,279 @@ +/** + * The `compose-agent` scenario (see runner.ts): the Compose preview MCP tools, driven over HTTP as an external agent drives them, + * on the `elide-android-sample` (`ELIDE_ANDROID_SAMPLE`, built once) with the engine `ELIDE_COMPOSE_ENGINE` names (`layoutlib`, + * the default, or `komodo`). Composables without `@Preview` render through generated wrappers (placeholders, agent arguments, + * a private composable, a private value of its file), an existing `@Preview` renders its own configuration, and wrong references + * and wrapper compile errors are reported. A live session follows an unsaved edit; with `komodo` its cards take taps and typing, + * and the preview panel is paused while it is open and resumes when it closes. Nothing reaches the disk, and no editor ever holds + * a wrapper. + */ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import * as vscode from "vscode"; +import type { ComposePreviewPaint } from "../../src/composePreview.js"; +import type { ElideExtensionApi as ElideApi } from "../../src/extension.js"; +import { log, waitFor } from "./helpers.js"; + +/** A cold first render builds and starts the renderer (and, for komodo, the session). */ +const RENDER_TIMEOUT_MS = 600_000; + +type Content = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; +interface ToolResult { content: Content[]; isError?: boolean } + +class McpClient { + private id = 0; + constructor(readonly url: string, private readonly token: string) {} + + async post(body: unknown, token = this.token): Promise { + return fetch(this.url, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(RENDER_TIMEOUT_MS), + }); + } + + /** A request's result: a JSON response, or the final message of an SSE stream (progress notifications before it). */ + async request(method: string, params: Record, onProgress?: () => Promise): Promise> { + const id = ++this.id; + const response = await this.post({ jsonrpc: "2.0", id, method, params }); + if (response.status !== 200) assert.fail(`${method}: HTTP ${response.status} ${await response.text()}`); + let text = ""; + if (onProgress) { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let notified = false; + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + text += decoder.decode(chunk.value, { stream: true }); + if (!notified && text.includes('"notifications/progress"')) { + notified = true; + await onProgress(); + } + } + text += decoder.decode(); + } finally { + reader.releaseLock(); + } + assert.ok(notified, "the render reported progress before closing its session"); + } else text = await response.text(); + const messages = response.headers.get("content-type")?.includes("text/event-stream") + ? text.split(/\n\n/).flatMap((event) => event.split("\n").filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5)) as Record)) + : [JSON.parse(text) as Record]; + const answer = messages.find((message) => message.id === id); + assert.ok(answer, `${method}: no response in ${text.slice(0, 500)}`); + assert.equal(answer.error, undefined, `${method}: ${JSON.stringify(answer.error)}`); + return answer.result as Record; + } + + /** With KEEP_TEST_DIRS, the PNGs every call returned, for looking at. */ + private readonly images = process.env.KEEP_TEST_DIRS ? mkdtempSync(path.join(tmpdir(), "elide-compose-agent-")) : undefined; + private calls = 0; + + async call(name: string, args: Record): Promise { + const started = performance.now(); + const result = await this.request("tools/call", { name, arguments: args, _meta: { progressToken: `t${this.id}` } }) as unknown as ToolResult; + log(`${name}: ${Math.round(performance.now() - started)} ms${result.isError ? " (error)" : ""}`, JSON.stringify(result.content.map((item) => item.type === "text" ? item.text : ``)).slice(0, 4_000)); + const call = ++this.calls; + if (this.images) { + result.content.forEach((item, index) => { + if (item.type === "image") writeFileSync(path.join(this.images!, `${String(call).padStart(2, "0")}-${name}-${index}.png`), Buffer.from(item.data, "base64")); + }); + log(`images kept in ${this.images}`); + } + return result; + } +} + +function texts(result: ToolResult): string { + return result.content.flatMap((item) => item.type === "text" ? [item.text] : []).join("\n"); +} + +function images(result: ToolResult): string[] { + return result.content.flatMap((item) => item.type === "image" ? [item.data] : []); +} + +function assertPng(data: string): void { + const png = Buffer.from(data, "base64"); + assert.ok(png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), "a PNG image"); + assert.ok(png.readUInt32BE(16) > 0 && png.readUInt32BE(20) > 0, "PNG dimensions"); +} + +function ok(result: ToolResult, what: string): ToolResult { + assert.ok(!result.isError, `${what}: ${texts(result)}`); + return result; +} + +/** The first `### cN` card id of a report, and the layout node whose line contains `needle`. */ +function card(result: ToolResult): string { + const id = /^### (c\d+)/m.exec(texts(result))?.[1]; + assert.ok(id, `a card id in ${texts(result)}`); + return id; +} + +function node(result: ToolResult, needle: RegExp): string { + const line = texts(result).split("\n").find((candidate) => /^\s*n\d+ /.test(candidate) && needle.test(candidate)); + assert.ok(line, `a layout node matching ${needle} in ${texts(result)}`); + return /^\s*(n\d+) /.exec(line)![1]!; +} + +export async function runComposeAgentScenario(): Promise { + const sample = process.env.ELIDE_TEST_SAMPLE!; + const komodo = vscode.workspace.getConfiguration("elide.android.composePreview").get("engine") === "komodo"; + const gallery = vscode.Uri.file(path.join(sample, "app", "src", "main", "kotlin", "sample", "app", "PreviewGallery.kt")); + const journey = vscode.Uri.file(path.join(sample, "components", "src", "main", "kotlin", "sample", "components", "JourneyCard.kt")); + const disk = new Map([gallery, journey].map((uri) => [uri.fsPath, readFileSync(uri.fsPath, "utf8")])); + const extension = vscode.extensions.all.find((e) => e.id.endsWith(".elide")); + assert.ok(extension, "elide extension found"); + const api = await waitFor("extension api", () => extension.exports as ElideApi | undefined, 60_000, 200); + const endpoint = await waitFor("MCP endpoint", () => api.composePreviewMcp.endpoint(), 60_000, 200); + assert.match(endpoint.url, /^http:\/\/127\.0\.0\.1:\d+\/mcp$/); + log("MCP endpoint", endpoint.url, komodo ? "(komodo engine)" : "(layoutlib engine)"); + const client = new McpClient(endpoint.url, endpoint.token); + + // The protocol, as any client sees it. + assert.equal((await client.post({ jsonrpc: "2.0", id: 0, method: "ping" }, "wrong")).status, 401, "a wrong token is refused"); + const initialized = await client.request("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "elide-test", version: "1" } }); + assert.equal(initialized.protocolVersion, "2025-06-18"); + assert.equal((await client.post({ jsonrpc: "2.0", method: "notifications/initialized" })).status, 202); + const tools = (await client.request("tools/list", {})).tools as { name: string }[]; + assert.deepEqual(tools.map((tool) => tool.name).sort(), ["compose_preview_close", "compose_preview_interact", "compose_preview_list", "compose_preview_refresh", "compose_preview_render"]); + + const listed = texts(ok(await client.call("compose_preview_list", { file: path.relative(sample, journey.fsPath) }), "list")); + assert.match(listed, /sample\.components\.JourneyCard .*JourneyCard\.kt:\d+/); + assert.match(listed, /sample\.components\.LibraryCardPreview .* @Preview/); + + // VS Code's agents reach the same server through the extension's MCP server definition provider, whose servers VS Code lists + // once it loads the provider. A user trusts a server on its first start (a dialog); this start is trusted as the user's + // explicit one is (`autoTrustChanges`), without the dialog. + const tool = await waitFor("VS Code's compose_preview_list tool", async () => { + await vscode.commands.executeCommand("workbench.mcp.startServer", "*", { promptType: "never", autoTrustChanges: true, waitForLiveTools: true }); + return vscode.lm.tools.find((candidate) => candidate.name.endsWith("compose_preview_list")); + }, 60_000, 1_000).catch((error: unknown) => { throw new Error(`${error instanceof Error ? error.message : String(error)}; tools: ${vscode.lm.tools.map((candidate) => candidate.name).join(", ")}`); }); + const invoked = await vscode.lm.invokeTool(tool.name, { input: { query: "JourneyCard" }, toolInvocationToken: undefined }); + const invokedText = invoked.content.flatMap((part) => part instanceof vscode.LanguageModelTextPart ? [part.value] : []).join("\n"); + log(`VS Code tool ${tool.name}`, invokedText.slice(0, 300)); + assert.match(invokedText, /sample\.components\.JourneyCard/); + + // A required parameter without a placeholder names what to pass. + const missing = await client.call("compose_preview_render", { composables: [{ reference: "sample.components.JourneyCard" }] }); + assert.ok(missing.isError, "a required Journey has no placeholder"); + assert.match(texts(missing), /journey: Journey/); + assert.match(texts((await client.call("compose_preview_render", { composables: ["sample.components.NoSuchCard"] }))), /No top-level @Composable function sample\.components\.NoSuchCard/); + + // Composables without @Preview: agent arguments, placeholders, a private composable and a private value of its file. + const rendered = ok(await client.call("compose_preview_render", { + composables: [ + { reference: "sample.components.JourneyCard", arguments: { journey: "Journey(\"Agent trip\", \"Rendered for an agent\", 4)" }, preview: { widthDp: 320 } }, + { reference: "app/src/main/kotlin/sample/app/PreviewGallery.kt#ActionButton" }, + { reference: "sample.app.SampleScreen", arguments: { journeys: "listOf(example)" }, preview: { widthDp: 360, heightDp: 480 } }, + { reference: "sample.components.LibraryCardPreview" }, + ], + }), "render"); + const report = texts(rendered); + assert.equal(images(rendered).length, 4, `four cards: ${report}`); + images(rendered).forEach(assertPng); + assert.match(report, /generated preview ElideAgentPreview_[0-9a-f]{10}\(\): JourneyCard\(journey = Journey\("Agent trip"/); + assert.match(report, /ActionButton\(label = "label" \(placeholder\), onClick = \{\} \(placeholder\)\)/); + assert.match(report, /Its own @Preview at .*JourneyCard\.kt:\d+/); + assert.match(report, /n\d+ \S+ \d+,\d+ \d+×\d+ — components\/src\/main\/kotlin\/sample\/components\/JourneyCard\.kt:\d+:\d+/, "layout nodes at their call sites"); + assert.match(report, /n\d+ \S+ \d+,\d+ \d+×\d+ — app\/src\/main\/kotlin\/sample\/app\/PreviewGallery\.kt:\d+:\d+/); + const wide = Buffer.from(images(rendered)[2]!, "base64"); + const narrow = Buffer.from(images(rendered)[0]!, "base64"); + assert.ok(wide.readUInt32BE(16) > narrow.readUInt32BE(16), "widthDp applies (360 dp wider than 320 dp)"); + + // Closing sessions also cancels a render that has not returned its session id yet. + const closing = await client.request("tools/call", { + name: "compose_preview_render", arguments: { composables: ["sample.components.LibraryCardPreview"], live: true }, + _meta: { progressToken: "close-during-render" }, + }, async () => { await vscode.commands.executeCommand("elide.composePreviewMcp.closeSessions"); }) as unknown as ToolResult; + assert.ok(closing.isError, "Close Agent Compose Preview Sessions cancels an in-flight render"); + assert.match(texts(closing), /closed|cancel/i); + + // An argument that does not compile is the wrapper's error, shown with its numbered lines. + const mistyped = await client.call("compose_preview_render", { composables: [{ reference: "sample.components.JourneyCard", arguments: { journey: "42" } }] }); + assert.ok(mistyped.isError, "a mistyped argument fails"); + assert.match(texts(mistyped), /generated wrapper, appended for the preview only/); + + // A live session over an unsaved edit (the agent's composable exists only in the editor). + const document = await vscode.workspace.openTextDocument(gallery); + const editor = await vscode.window.showTextDocument(document, { preview: false }); + const fixture = [ + "", + "@Composable", + "private fun AgentCounter(start: Int) {", + " val count = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(start) }", + " val text = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(\"\") }", + " Column(Modifier.background(androidx.compose.ui.graphics.Color.White).padding(8.dp)) {", + " ActionButton(\"Count ${count.value}\") { count.value++ }", + " androidx.compose.foundation.text.BasicTextField(text.value, { text.value = it }, Modifier.width(200.dp).background(androidx.compose.ui.graphics.Color(0xFFE0E0E0)).padding(8.dp))", + " androidx.compose.foundation.text.BasicText(DeprecationLevel.WARNING.name)", + " }", + "}", + "", + ].join("\n"); + await editor.edit((edit) => edit.insert(document.lineAt(document.lineCount - 1).range.end, fixture)); + const live = ok(await client.call("compose_preview_render", { + composables: [{ reference: "sample.app.AgentCounter", preview: { widthDp: 240 } }], live: true, + }), "live render"); + const session = /Live session (s\d+)/.exec(texts(live))?.[1]; + assert.ok(session, `a session id: ${texts(live)}`); + assert.match(texts(live), /AgentCounter\(start = 0 \(placeholder\)\)/); + const counter = card(live); + const before = images(live)[0]!; + + if (komodo) { + const paints: ComposePreviewPaint[] = []; + api.composePreview.onDidPaint((paint) => paints.push(paint)); + const tapped = ok(await client.call("compose_preview_interact", { session, card: counter, actions: [{ type: "tap", node: node(live, /ActionButton|BasicText/) }] }), "tap"); + assert.notEqual(images(tapped)[0], before, "the tap ran the click handler and recomposed"); + const typed = ok(await client.call("compose_preview_interact", { + session, card: counter, actions: [{ type: "tap", node: node(tapped, /BasicTextField/) }, { type: "type", text: "Hi agent" }], + }), "type"); + assert.notEqual(images(typed)[0], images(tapped)[0], "typing edited the text field"); + + // The panel waits while the agent holds Komodo's one preview session, and resumes when it ends. + await vscode.window.showTextDocument(journey, { preview: false }); + await vscode.commands.executeCommand("elide.androidComposePreview"); + const paused = await waitFor("paused panel", () => paints.find((paint) => paint.source === journey.fsPath && paint.failed === paint.images), RENDER_TIMEOUT_MS, 50); + log("panel paused", JSON.stringify({ images: paused.images, failed: paused.failed })); + const after = paints.length; + ok(await client.call("compose_preview_close", { session }), "close"); + await waitFor("resumed panel", () => paints.slice(after).find((paint) => paint.source === journey.fsPath && paint.failed === 0 && paint.live.length === paint.images), RENDER_TIMEOUT_MS, 50); + } else { + const snapshot = await client.call("compose_preview_interact", { session, card: counter, actions: [{ type: "tap", x: 10, y: 10 }] }); + assert.ok(snapshot.isError, "snapshot cards take no input"); + assert.match(texts(snapshot), /snapshot/); + } + + // The session follows the agent's next (unsaved) edit; komodo reconnects after the close above by rendering again. + // Showing another file closed this TextEditor; edits go through the one showing the document again. + const shown = await vscode.window.showTextDocument(document, { preview: false }); + const labelAt = document.getText().indexOf("\"Count ${count.value}\""); + await shown.edit((edit) => edit.replace(new vscode.Range(document.positionAt(labelAt), document.positionAt(labelAt + "\"Count ${count.value}\"".length)), "\"Total ${count.value}!\"")); + if (komodo) { + const reopened = ok(await client.call("compose_preview_render", { composables: [{ reference: "sample.app.AgentCounter", preview: { widthDp: 240 } }], live: true }), "live again"); + const again = /Live session (s\d+)/.exec(texts(reopened))![1]!; + assert.notEqual(images(reopened)[0], before, "the edited label renders"); + const labelEnd = document.getText().indexOf("\"Total ${count.value}!\""); + await shown.edit((edit) => edit.replace(new vscode.Range(document.positionAt(labelEnd), document.positionAt(labelEnd + "\"Total ${count.value}!\"".length)), "\"Sum ${count.value}\"")); + const refreshed = ok(await client.call("compose_preview_refresh", { session: again }), "refresh"); + assert.notEqual(images(refreshed)[0], images(reopened)[0], "refresh renders the pushed edit"); + ok(await client.call("compose_preview_close", { session: again }), "close again"); + } else { + const refreshed = ok(await client.call("compose_preview_refresh", { session }), "refresh"); + assert.notEqual(images(refreshed)[0], before, "refresh renders the edited label"); + ok(await client.call("compose_preview_close", { session }), "close"); + } + assert.ok((await client.call("compose_preview_refresh", { session })).isError, "a closed session is gone"); + + // Nothing the agent rendered reached a file or an editor. + await vscode.commands.executeCommand("workbench.action.files.revert"); + for (const open of vscode.workspace.textDocuments) assert.ok(!open.getText().includes("ElideAgentPreview_"), `no wrapper in ${open.uri.fsPath}`); + for (const [file, text] of disk) assert.equal(readFileSync(file, "utf8"), text, `${file} unchanged on disk`); + log("compose-agent scenario passed"); +} diff --git a/packages/vscode/test/integration/composeKomodo.ts b/packages/vscode/test/integration/composeKomodo.ts new file mode 100644 index 0000000..4cb59c1 --- /dev/null +++ b/packages/vscode/test/integration/composeKomodo.ts @@ -0,0 +1,608 @@ +/** + * The `compose-komodo` scenario (see runner.ts): the `elide-android-sample` previews with the `komodo` engine, which renders every + * card in the workspace language server and none with layoutlib snapshots. Analysis and preview share one process + * and Workspace compiler; Komodo's language server (ELIDE_KOMODO_LSP, `elide.kotlinLsp.server` = `komodo`) also discovers + * the previews. In components' JourneyCard.kt every card is live, the two values of the + * `@PreviewParameter(limit = 2)` provider the file declares included: unsaved edits reach the plain card by push, a click runs its + * handler, and an edit that does not compile is a Komodo diagnostic in Problems. The app's PreviewGallery.kt is live in full, its + * `@PreviewParameter` cards included: they keep their `previewId:index` identity through a pushed edit, and a click on one + * recomposes that card alone. Prewarming, body edits and cross-module edits must keep the same workspace daemon without touching disk sources. + * Last, every sample file that declares a `@Preview` opens with all its cards live. + */ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import * as vscode from "vscode"; +import type { ComposePreviewPaint } from "../../src/composePreview.js"; +import type { ElideExtensionApi as ElideApi } from "../../src/extension.js"; +import { log, waitFor } from "./helpers.js"; + +/** + * Unsaved edit to the live card's new frame: Komodo's compile is 1.5 to 3.5 s on this host (2.6 s measured for JourneyCard.kt, + * 4.4 s for the 13-card gallery), plus the panel. + */ +const LIVE_EDIT_BUDGET_MS = 6_000; +/** A live card's first frame: the daemon compiles the file and starts the renderer when the preview opens. */ +const LIVE_TIMEOUT_MS = 300_000; + + +/** Inspect only this extension host's descendants, never unrelated developer JVMs. */ +function workspaceServerPid(checkpoint: string): number { + const rows = execFileSync("ps", ["-axo", "pid=,ppid=,rss=,command="], { encoding: "utf8" }).trim().split("\n") + .map((line) => /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(line)) + .filter((row): row is RegExpExecArray => row !== null) + .map((row) => ({ pid: Number(row[1]), parent: Number(row[2]), rssKiB: Number(row[3]), command: row[4]! })); + const descendants = new Set([process.pid]); + for (let previous = -1; previous !== descendants.size;) { + previous = descendants.size; + for (const row of rows) if (descendants.has(row.parent)) descendants.add(row.pid); + } + const children = rows.filter((row) => row.pid !== process.pid && descendants.has(row.pid)); + assert.ok(!children.some((row) => /komodo-preview|ArchiveClassesAtExit=.*training/.test(row.command)), + `no standalone preview or preview CDS trainer: ${JSON.stringify(children)}`); + const jvms = children.filter((row) => /(?:java|java\.bin)\s/.test(row.command)); + const comparison = process.env.ELIDE_KOMODO_RUNTIME === "jvm"; + if (!comparison) assert.equal(jvms.length, 0, `native workspace must not launch a JVM: ${JSON.stringify(jvms)}`); + const servers = comparison + ? jvms.filter((row) => /komodo|dev\.elide\.kotlin/.test(row.command)) + : children.filter((row) => /\/komodo(?:\s|$)/.test(row.command)); + assert.equal(servers.length, 1, `one workspace Komodo daemon: ${JSON.stringify(children)}`); + log(`${checkpoint}: workspace pid ${servers[0]!.pid}, RSS ${(servers[0]!.rssKiB / 1024).toFixed(1)} MiB (${comparison ? "JVM comparison" : "native"})`); + return servers[0]!.pid; +} +export async function runComposeKomodoScenario(): Promise { + const sample = process.env.ELIDE_TEST_SAMPLE!; + const card = vscode.Uri.file(path.join(sample, "components", "src", "main", "kotlin", "sample", "components", "JourneyCard.kt")); + const gallery = vscode.Uri.file(path.join(sample, "app", "src", "main", "kotlin", "sample", "app", "PreviewGallery.kt")); + const diskSources = new Map([card, gallery].map((uri) => [uri.fsPath, readFileSync(uri.fsPath)])); + const extension = vscode.extensions.all.find((e) => e.id.endsWith(".elide")); + assert.ok(extension, "elide extension found"); + assert.equal(vscode.workspace.getConfiguration("elide.android.composePreview").get("engine"), "komodo"); + const api = await waitFor("compose preview api", () => (extension.exports as ElideApi | undefined)?.composePreview, 60_000, 200); + const paints: ComposePreviewPaint[] = []; + api.onDidError((error) => log("preview error", JSON.stringify(error))); + api.onDidPaint((paint) => { + log("paint", JSON.stringify({ ...paint, inspection: undefined, cards: undefined })); + paints.push(paint); + }); + const nextPaint = async (source: vscode.Uri, after: number, what: string, matches: (paint: ComposePreviewPaint) => boolean, timeoutMs = LIVE_TIMEOUT_MS) => { + const paint = await waitFor(what, () => paints.slice(after).find((paint) => paint.source === source.fsPath && matches(paint)), timeoutMs, 20).catch((error: unknown) => { + // The extension host's console does not reach the runner: the failure carries what was painted. + throw new Error(`${error instanceof Error ? error.message : String(error)}; paints: ${JSON.stringify(paints.slice(after).map((paint) => ({ ...paint, inspection: undefined, cards: undefined })))}`); + }); + return paint; + }; + const assertLayout = (paint: ComposePreviewPaint, ids = paint.live) => { + for (const id of ids) { + const layout = paint.inspection.layouts[id]; + assert.ok(layout, `published image dimensions for ${id}`); + assert.ok(paint.cards[id], `layout accompanies pixels for ${id}`); + assert.ok(Number.isInteger(layout.width) && layout.width > 0); + assert.ok(Number.isInteger(layout.height) && layout.height > 0); + assert.ok(layout.nodes.length > 0 && layout.nodes.length <= 2048, `measured nodes for ${id}`); + assert.equal(new Set(layout.nodes.map((node) => node.id)).size, layout.nodes.length, "node identities are unique within a card"); + for (const node of layout.nodes) { + assert.ok(node.id.length > 0 && node.name.length > 0, "composable calls have identities and names"); + assert.ok([node.x, node.y, node.width, node.height].every(Number.isInteger), "bounds use image pixels"); + assert.ok(node.x >= 0 && node.y >= 0 && node.width > 0 && node.height > 0); + assert.ok(node.x + node.width <= layout.width && node.y + node.height <= layout.height, "measured bounds are clipped to the published image"); + } + } + assert.ok(paint.inspection.sources.length > 0, "inspection resolves project call sites"); + }; + const bounds = (paint: ComposePreviewPaint, id: string) => paint.inspection.layouts[id]!.nodes + .map(({ x, y, width, height }) => ({ x, y, width, height })); + /** Inspect's Open source for a `name` call of card `id`: the editor of `source` selects that call, in the text Komodo compiled. */ + const openCall = async (paint: ComposePreviewPaint, id: string, name: string, source: vscode.Uri) => { + // A card can call `name` in several files (LocalizedPreview's Column and JourneyCard's own, when Elide built the components + // module with Compose source information): take the call in `source`. + const node = paint.inspection.layouts[id]?.nodes.find((candidate) => candidate.name === name + && candidate.source?.fileName === path.basename(source.fsPath)); + assert.ok(node, `${id} has a located ${name} call: ${JSON.stringify(paint.inspection.layouts[id]?.nodes.map((n) => n.name))}`); + assert.ok(await api.openSource(id, node.id), `${name} of ${id} opens its source`); + const opened = vscode.window.activeTextEditor; + assert.equal(opened?.document.uri.fsPath, source.fsPath, `${name} of ${id} opens ${path.basename(source.fsPath)}`); + const selected = opened!.document.getText(opened!.selection); + assert.ok(selected.startsWith(name), `the selection is the ${name} call: ${JSON.stringify(selected)}`); + assert.equal(opened!.selection.start.line + 1, node.source!.lineNumber, `${name} of ${id} is on its line`); + log(`open source: ${name} of ${id} at ${path.basename(source.fsPath)}:${node.source!.lineNumber}`); + return selected; + }; + const komodoDiagnostics = (source: vscode.Uri) => vscode.languages.getDiagnostics(source).filter((diagnostic) => diagnostic.source === "Komodo"); + const replace = async (editor: vscode.TextEditor, before: string, after: string) => { + const text = editor.document.getText(); + const offset = text.indexOf(before); + assert.ok(offset >= 0 && text.indexOf(before, offset + before.length) < 0, `unique fixture edit: ${before}`); + assert.ok(await editor.edit((builder) => builder.replace( + new vscode.Range(editor.document.positionAt(offset), editor.document.positionAt(offset + before.length)), after))); + }; + const revert = async (editor: vscode.TextEditor) => { + if (!editor.document.isDirty) return; + await vscode.window.showTextDocument(editor.document, { preview: false, viewColumn: vscode.ViewColumn.One }); + await vscode.commands.executeCommand("workbench.action.files.revert"); + }; + /** An edit that does not compile is a Komodo diagnostic at its line, cleared by the next good edit. */ + const brokenEdit = async (editor: vscode.TextEditor, source: vscode.Uri, before: string, after: string) => { + const started = performance.now(); + await replace(editor, before, after); + const broken = await waitFor("Komodo compile diagnostic", () => komodoDiagnostics(source).length ? komodoDiagnostics(source) : undefined, 60_000, 50); + log(`broken edit: ${broken.length} diagnostics after ${Math.round(performance.now() - started)} ms: ${broken.map((d) => `${d.range.start.line + 1}: ${d.message}`).join(" | ")}`); + const brokenLine = editor.document.getText().split("\n").findIndex((line) => line.includes(after)); + assert.ok(broken.some((diagnostic) => diagnostic.range.start.line === brokenLine), `a diagnostic on line ${brokenLine + 1}`); + const recoveryMark = paints.length; + await replace(editor, after, before); + await waitFor("Komodo diagnostics cleared", () => komodoDiagnostics(source).length === 0 || undefined, 60_000, 50); + const recovered = await nextPaint(source, recoveryMark, "layout after compile recovery", + (paint) => paint.failed === 0 && paint.live.length > 0 && paint.inspection.enabled); + assertLayout(recovered); + }; + + // 0. Prewarm from the existing build without touching the sample's disk sources. + { + let galleryPrewarmed: string | undefined; + const stop = api.onDidPrewarm((key) => { galleryPrewarmed = key; }); + await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(gallery), { preview: false }); + await waitFor("gallery workspace-session prewarm", () => galleryPrewarmed, 120_000, 200); + stop.dispose(); + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + } + + // 1. Every card is live, the provider's cards included: the provider is declared in the previewed file, so Compose tooling loads it + // from Komodo's program classes, and `limit = 2` keeps its first two values. Focusing the file after the sync prewarms its + // backend; the preview opens after that, as in the layoutlib scenario. + let prewarmed: string | undefined; + api.onDidPrewarm((key) => { prewarmed = key; }); + const editor = await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(card), { preview: false }); + await waitFor("sync and prewarm", () => prewarmed, 120_000, 200); + let mark = paints.length; + let started = performance.now(); + await vscode.commands.executeCommand("elide.androidComposePreview"); + const live = await nextPaint(card, mark, "live card", (paint) => paint.live.length > 0); + log(`open: ${live.live.length} of ${live.images} cards live after ${Math.round(performance.now() - started)} ms: ${live.live.join(", ")}`); + assert.equal(live.images, 3, "LibraryCardPreview and LibraryDataPreview's two values"); + assert.equal(live.failed, 0); + assert.equal(live.live.length, 3, "every JourneyCard.kt card is live"); + const serverPid = workspaceServerPid("prewarmed gallery and library"); + log(`shared workspace language server pid: ${serverPid}`); + const libraryData = live.live.filter((id) => /\.LibraryDataPreview_[^:]*:\d+$/.test(id)).sort(); + assert.deepEqual(libraryData.map((id) => id.slice(id.lastIndexOf(":"))), [":0", ":1"], "limit = 2: one live card per kept value"); + assert.notEqual(live.cards[libraryData[0]!], live.cards[libraryData[1]!], "each provider card shows its own value"); + const hosted = live.live.find((id) => /LibraryCardPreview/.test(id)); + assert.ok(hosted, "LibraryCardPreview is live"); + mark = paints.length; + api.setInspect(true); + const inspected = await nextPaint(card, mark, "library inspection", (paint) => paint.inspection.enabled); + assert.equal(inspected.inspection.images, 3, "every live library card has measured bounds"); + assertLayout(inspected); + assert.deepEqual(inspected.cards, live.cards, "enabling inspection preserves published pixels"); + // Inspect off shows the painted publication again without its overlay: that is no new paint. + api.setInspect(false); + mark = paints.length; + api.setInspect(true); + const reinspected = await nextPaint(card, mark, "inspection re-enabled", (paint) => paint.inspection.enabled); + assertLayout(reinspected); + assert.ok(reinspected.inspection.sources.includes(card.fsPath), "library cards locate their calls in JourneyCard.kt"); + assert.ok((await openCall(reinspected, hosted, "JourneyCard", card)).startsWith('JourneyCard(Journey("Shared components"')); + + try { + // 2. An unsaved body edit reaches the live card as a pushed frame. + mark = paints.length; + started = performance.now(); + await replace(editor, "Rendered using the consuming app's resources", "Rendered live by Komodo"); + const edited = await nextPaint(card, mark, "live body edit", (paint) => paint.cards[hosted] !== live.cards[hosted] && paint.live.includes(hosted)); + const editMs = performance.now() - started; + log(`live edit: painted in ${Math.round(editMs)} ms`); + assert.ok(editMs < LIVE_EDIT_BUDGET_MS, `live edit painted in ${Math.round(editMs)} ms, budget ${LIVE_EDIT_BUDGET_MS} ms`); + assertLayout(edited); + // The edit shortened LibraryCardPreview, above LibraryDataPreview: that function only moved, and Open source follows it. + const moved = editor.document.getText().indexOf("JourneyCard(journey)"); + const relocated = await nextPaint(card, mark, "moved call located", (paint) => paint.inspection.layouts[libraryData[0]!]?.nodes + .some((node) => node.name === "JourneyCard" && node.source?.offset === moved) ?? false); + assert.equal(await openCall(relocated, libraryData[0]!, "JourneyCard", card), "JourneyCard(journey)"); + + // 3. A stateful edit, then a click on the live card runs its handler and recomposes it. + mark = paints.length; + assert.equal(workspaceServerPid("library body edit"), serverPid, "unsaved preview edits reuse the analysis daemon"); + await replace(editor, "import androidx.compose.foundation.background\n", "import androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\n"); + await replace(editor, 'JourneyCard(Journey("Shared components", "Rendered live by Komodo", 2))', [ + "val clicks = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(0) }", + " Column {", + ' BasicText("Clicks: ${clicks.value}", Modifier.clickable { clicks.value++ }.padding(16.dp))', + ' JourneyCard(Journey("Shared components", "Rendered live by Komodo", 2))', + " }", + ].join("\n")); + const stateful = await nextPaint(card, mark, "stateful edit", (paint) => paint.cards[hosted] !== edited.cards[hosted] && paint.live.includes(hosted)); + assertLayout(stateful); + assert.notDeepEqual(bounds(stateful, hosted), bounds(edited, hosted), + "adding measured content updates layout together with the image"); + api.setInspect(false); + mark = paints.length; + started = performance.now(); + assert.ok(api.pointer(hosted, "down", 40, 40), "the live card takes pointer input"); + await sleep(100); + assert.ok(api.pointer(hosted, "up", 40, 40)); + const clicked = await nextPaint(card, mark, "click recomposition", (paint) => paint.cards[hosted] !== stateful.cards[hosted], 30_000); + log(`click: recomposed in ${Math.round(performance.now() - started)} ms (${clicked.live.length} live)`); + assertLayout(clicked); + mark = paints.length; + api.setInspect(true); + await nextPaint(card, mark, "inspection after click", (paint) => paint.inspection.enabled); + + // 4. An edit that does not compile is a Komodo diagnostic at its line, cleared by the next good edit. + await brokenEdit(editor, card, "clicks.value++", 'clicks.value += "one"'); + + // 4b. A changed @Preview configuration re-hosts the card at its new size, and the next edit still reaches it: a daemon that + // hosted a second preview set must keep applying deltas (Komodo finding #3). + mark = paints.length; + started = performance.now(); + const before = paints.at(-1)!.cards[hosted]; + await replace(editor, 'name = "Library resource context", widthDp = 340', 'name = "Library resource context", widthDp = 300'); + const resized = await nextPaint(card, mark, "re-hosted card", (paint) => paint.cards[hosted] !== before && paint.live.includes(hosted)); + log(`preview configuration change: ${resized.live.length} of ${resized.images} live after ${Math.round(performance.now() - started)} ms`); + assert.equal(resized.live.length, 3, "every card is live after the preview set changed"); + assertLayout(resized); + assert.ok(resized.inspection.layouts[hosted]!.width < stateful.inspection.layouts[hosted]!.width, + "re-hosting at a smaller width publishes smaller image bounds"); + mark = paints.length; + started = performance.now(); + await replace(editor, '"Rendered live by Komodo"', '"Rendered live, resized"'); + await nextPaint(card, mark, "edit after the preview set changed", (paint) => paint.cards[hosted] !== resized.cards[hosted] && paint.live.includes(hosted)); + log(`edit after the preview set changed: painted in ${Math.round(performance.now() - started)} ms`); + + // 4c. A @Preview added while typing is live before the file is saved (the daemon compiles the buffer), and takes edits. + mark = paints.length; + started = performance.now(); + await replace(editor, "@Preview(name = \"Library sample data\"", [ + '@Preview(name = "Added while typing", widthDp = 200)', + "@Composable", + "fun AddedLivePreview() {", + ' JourneyCard(Journey("Added", "Added while typing", 1))', + "}", + "", + '@Preview(name = "Library sample data"', + ].join("\n")); + const grown = await nextPaint(card, mark, "added preview", (paint) => paint.images === 4 && paint.live.length === 4); + log(`added preview: ${grown.live.length} of ${grown.images} live after ${Math.round(performance.now() - started)} ms`); + const added = grown.live.find((id) => /AddedLivePreview/.test(id)); + assert.ok(added, "the added preview is live"); + assertLayout(grown); + mark = paints.length; + started = performance.now(); + await replace(editor, '"Added while typing", 1', '"Added and edited", 1'); + await nextPaint(card, mark, "edit of the added preview", (paint) => paint.cards[added] !== grown.cards[added] && paint.live.includes(added)); + log(`edit of the added preview: painted in ${Math.round(performance.now() - started)} ms`); + + // Transparent child geometry changes without changing the PNG: metadata must not be deduplicated by pixels. + const addedBody = 'JourneyCard(Journey("Added", "Added and edited", 1))'; + const transparent = "Box(Modifier.size(120.dp)) { Box(Modifier.size(20.dp)) }"; + const beforeTransparent = paints.at(-1)!.cards[added]; + mark = paints.length; + await replace(editor, addedBody, transparent); + const empty = await nextPaint(card, mark, "transparent measured preview", + (paint) => paint.live.includes(added) && paint.cards[added] !== beforeTransparent); + assertLayout(empty, [added]); + const emptyBounds = JSON.stringify(bounds(empty, added)); + mark = paints.length; + await replace(editor, "Box(Modifier.size(20.dp))", "Box(Modifier.size(40.dp))"); + const geometryOnly = await nextPaint(card, mark, "layout-only frame update", + (paint) => paint.live.includes(added) && JSON.stringify(bounds(paint, added)) !== emptyBounds); + assert.equal(geometryOnly.cards[added], empty.cards[added], "transparent resize leaves the PNG byte-identical"); + assertLayout(geometryOnly, [added]); + log("layout-only update: changed measured bounds with identical PNG"); + mark = paints.length; + await replace(editor, transparent.replace("size(20.dp)", "size(40.dp)"), addedBody); + await nextPaint(card, mark, "restored visible preview", (paint) => paint.cards[added] !== empty.cards[added]); + + // Real text fields: the small measured marker encodes exact text, so cursor blinking cannot satisfy these assertions. + const keyboardBody = `val first = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("") } + val second = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("") } + Column(Modifier.width(180.dp)) { + androidx.compose.foundation.text.BasicTextField(first.value, { first.value = it }, Modifier.fillMaxWidth().height(44.dp), + textStyle = TextStyle(color = Color.Black, fontSize = 16.sp)) + androidx.compose.foundation.text.BasicTextField(second.value, { second.value = it }, Modifier.fillMaxWidth().height(44.dp), + textStyle = TextStyle(color = Color.Black, fontSize = 16.sp)) + BasicText("first=" + first.value + "; second=" + second.value, style = TextStyle(color = Color.Black)) + val signature = when (first.value + "|" + second.value) { + "|" -> 11 + "abc|" -> 31 + "aZc|" -> 41 + "aZc|B" -> 51 + "aZc|B\\n" -> 61 + "aZc|B\\nx" -> 71 + "aZc|B\\nxé😀" -> 81 + else -> 3 + } + Box(Modifier.width(signature.dp).height(5.dp).background(Color.Red)) + }`; + api.setInspect(false); + mark = paints.length; + await replace(editor, addedBody, keyboardBody); + const marker = (paint: ComposePreviewPaint, id: string, signature: number, widthDp = 200) => { + const layout = paint.inspection.layouts[id]; + if (!layout) return false; + const density = layout.width / widthDp; + return layout.nodes.some((node) => Math.abs(node.width - signature * density) <= 1 + && Math.abs(node.height - 5 * density) <= 1); + }; + await nextPaint(card, mark, "empty keyboard fields", (paint) => marker(paint, added, 11)); + const keyEvent = (key: string, code: string, action: "down" | "up" = "down", + modifiers: Partial[1]> = {}) => ({ + action, key, code, shiftKey: false, ctrlKey: false, altKey: false, metaKey: false, repeat: false, ...modifiers, + }); + const press = (key: string, code: string, modifiers: Partial[1]> = {}) => { + assert.ok(api.keyboard(added, keyEvent(key, code, "down", modifiers)), `accepted keydown ${key}`); + assert.ok(api.keyboard(added, keyEvent(key, code, "up", modifiers)), `accepted keyup ${key}`); + }; + const field = (id: string, second = false, widthDp = 200) => { + assert.ok(api.focusInput(id), "focus live keyboard card"); + const density = paints.at(-1)!.inspection.layouts[id]!.width / widthDp; + assert.ok(api.pointer(id, "down", 20 * density, (second ? 64 : 20) * density)); + assert.ok(api.pointer(id, "up", 20 * density, (second ? 64 : 20) * density)); + }; + await vscode.commands.executeCommand("workbench.action.focusSecondEditorGroup"); + field(added); + assert.equal(api.keyboard(hosted, keyEvent("q", "KeyQ")), false, "another card never receives focused input"); + mark = paints.length; + // As a browser types: caret movement and deletion queued behind the insertions, with no frame in between. + for (const letter of "abc") press(letter, `Key${letter.toUpperCase()}`); + press("ArrowLeft", "ArrowLeft"); + press("Backspace", "Backspace"); + press("Z", "KeyZ", { shiftKey: true }); + await nextPaint(card, mark, "typed abc, then arrow/backspace/shift produce aZc", (paint) => marker(paint, added, 41)); + mark = paints.length; + press("Tab", "Tab"); + press("B", "KeyB", { shiftKey: true }); + await nextPaint(card, mark, "Tab focuses second field", (paint) => marker(paint, added, 51)); + mark = paints.length; + press("Enter", "Enter"); + await nextPaint(card, mark, "Enter inserts newline", (paint) => marker(paint, added, 61)); + assert.ok(api.keyboard(added, keyEvent("Shift", "ShiftLeft", "down", { shiftKey: true }))); + api.focusInput(undefined); + assert.equal(api.keyboard(added, keyEvent("Shift", "ShiftLeft", "up")), false, "blur discards late releases"); + field(added, true); + mark = paints.length; + press("End", "End", { ctrlKey: true }); + press("x", "KeyX"); + await nextPaint(card, mark, "blur clears held modifier", (paint) => marker(paint, added, 71)); + mark = paints.length; + press("é", ""); + press("😀", ""); + await nextPaint(card, mark, "Unicode keyboard values", (paint) => marker(paint, added, 81)); + api.setInspect(true); + assert.equal(api.focusInput(added), false, "Inspect cannot focus application input"); + assert.equal(api.keyboard(added, keyEvent("q", "KeyQ")), false, "Inspect owns keyboard input"); + mark = paints.length; + api.setInspect(false); + // Inspect re-requests the scene; input stays refused until those fresh images paint, then the card is focusable again. + await nextPaint(card, mark, "Inspect released keyboard card", (paint) => !paint.inspection.enabled && marker(paint, added, 81)); + field(added); + assert.ok(api.keyboard(added, keyEvent("Control", "ControlLeft", "down", { ctrlKey: true }))); + mark = paints.length; + await replace(editor, '@Preview(name = "Added while typing", widthDp = 200)', '@Preview(name = "Added while typing", widthDp = 220)'); + const reconfiguredKeys = await nextPaint(card, mark, "keyboard card reconfigured", (paint) => { + const id = paint.live.find((id) => /AddedLivePreview/.test(id)); + return !!id && marker(paint, id, 11, 220); + }); + assert.equal(api.keyboard(added, keyEvent("Control", "ControlLeft", "up")), false, "reconfigure discards stale held input"); + const newAdded = reconfiguredKeys.live.find((id) => /AddedLivePreview/.test(id))!; + field(newAdded, false, 220); + mark = paints.length; + for (const letter of "abc") { + assert.ok(api.keyboard(newAdded, keyEvent(letter, `Key${letter.toUpperCase()}`, "down"))); + assert.ok(api.keyboard(newAdded, keyEvent(letter, `Key${letter.toUpperCase()}`, "up"))); + } + await nextPaint(card, mark, "text input recovers after reconfigure", (paint) => marker(paint, newAdded, 31, 220)); + api.focusInput(undefined); + mark = paints.length; + await replace(editor, keyboardBody, addedBody); + await nextPaint(card, mark, "restored added card", (paint) => paint.live.includes(newAdded) && !marker(paint, newAdded, 31, 220)); + await replace(editor, '@Preview(name = "Added while typing", widthDp = 220)', '@Preview(name = "Added while typing", widthDp = 200)'); + api.setInspect(true); + log("keyboard: text editing, modifiers, Tab/Enter, focus isolation, Inspect, blur and reconfigure recovery passed"); + assert.equal(workspaceServerPid("keyboard lifecycle recovery"), serverPid, "keyboard input shares the original native workspace daemon"); + + // 4d. Removing a function changes the program's shape, which the daemon rejects as a delta (and every later one): the preview + // configures again from the buffer, and the next edit still reaches the remaining cards. + mark = paints.length; + started = performance.now(); + const beforeRemoval = paints.at(-1)!.cards[hosted]; + await replace(editor, [ + '@Preview(name = "Added while typing", widthDp = 200)', + "@Composable", + "fun AddedLivePreview() {", + ' JourneyCard(Journey("Added", "Added and edited", 1))', + "}", + "", + "", + ].join("\n"), ""); + const shrunk = await nextPaint(card, mark, "removed preview", (paint) => paint.images === 3 && paint.live.length === 3); + log(`removed preview: ${shrunk.live.length} of ${shrunk.images} live after ${Math.round(performance.now() - started)} ms`); + assertLayout(shrunk); + assert.equal(shrunk.inspection.layouts[added], undefined, "removed cards leave no inspectable layout"); + mark = paints.length; + started = performance.now(); + await replace(editor, '"Rendered live, resized"', '"Rendered after a removal"'); + await nextPaint(card, mark, "edit after a removed function", (paint) => paint.cards[hosted] !== beforeRemoval && paint.cards[hosted] !== shrunk.cards[hosted] && paint.live.includes(hosted), 30_000); + log(`edit after a removed function: painted in ${Math.round(performance.now() - started)} ms`); + } finally { + await revert(editor); + } + + // 5. The gallery is live in full: repeated and device @Previews, the member function, the Room-backed state card, and the + // provider's three values as one card each, with distinct pixels. + const galleryEditor = await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(gallery), { preview: false, viewColumn: vscode.ViewColumn.One }); + mark = paints.length; + started = performance.now(); + await vscode.commands.executeCommand("elide.androidComposePreview"); + const opened = await nextPaint(gallery, mark, "gallery live", (paint) => paint.live.length > 0); + log(`gallery: ${opened.live.length} of ${opened.images} cards live after ${Math.round(performance.now() - started)} ms: ${opened.live.join(", ")}`); + assert.equal(opened.images, 13); + assert.equal(opened.failed, 0, "every gallery card renders"); + assert.equal(opened.live.length, 13, "every gallery card is live"); + const data = opened.live.filter((id) => /\.DataPreview_[^:]*:\d+$/.test(id)).sort(); + assert.deepEqual(data.map((id) => id.slice(id.lastIndexOf(":"))), [":0", ":1", ":2"], "one live card per provider value"); + assert.equal(new Set(data.map((id) => opened.cards[id])).size, 3, "each provider card shows its own value"); + mark = paints.length; + api.setInspect(false); + api.setInspect(true); + const galleryInspected = await nextPaint(gallery, mark, "gallery inspection", (paint) => paint.inspection.enabled); + assert.equal(galleryInspected.inspection.images, 13); + assertLayout(galleryInspected); + assert.ok(galleryInspected.inspection.sources.includes(gallery.fsPath), "gallery cards locate their calls in PreviewGallery.kt"); + // The card's own calls in this file are located whether or not Elide's build of the components module (JourneyCard) + // recorded Compose source information. + const localized = opened.live.find((id) => /\.LocalizedPreview_/.test(id))!; + assert.ok((await openCall(galleryInspected, localized, "Column", gallery)).startsWith("Column {")); + + try { + // A file initializer is evaluated once: changing it must refresh cached values, not merely report a successful delta. + const initialAppearance = opened.live.find((id) => /\.AppearancePreview_/.test(id))!; + mark = paints.length; + await replace(galleryEditor, '"Coastal express"', '"Coastal expresso"'); + await nextPaint(gallery, mark, "initializer edit", (paint) => paint.live.includes(initialAppearance) + && paint.cards[initialAppearance] !== opened.cards[initialAppearance]); + mark = paints.length; + await replace(galleryEditor, '"Coastal expresso"', '"Coastal express"'); + await nextPaint(gallery, mark, "initializer revert", (paint) => paint.live.includes(initialAppearance) + && paint.cards[initialAppearance] === opened.cards[initialAppearance]); + assert.equal(workspaceServerPid("initializer refresh"), serverPid, "initializer refresh retains the shared daemon"); + + // 6. A pushed edit of the provider's composable reaches its three cards, which keep their identity (previewId:index). + // The sample's gallery imports no clickable: add it first, outside the timed edit. + await replace(galleryEditor, "import androidx.compose.foundation.background\n", "import androidx.compose.foundation.background\nimport androidx.compose.foundation.clickable\n"); + mark = paints.length; + started = performance.now(); + await replace(galleryEditor, "fun DataPreview(@PreviewParameter(JourneyProvider::class) journey: Journey) {\n JourneyCard(journey)", [ + "fun DataPreview(@PreviewParameter(JourneyProvider::class) journey: Journey) {", + " val clicks = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(0) }", + " Column {", + ' BasicText("Clicks: ${clicks.value}", Modifier.clickable { clicks.value++ }.padding(16.dp))', + " JourneyCard(journey)", + " }", + ].join("\n")); + const first = await nextPaint(gallery, mark, "provider card edit", (paint) => data.some((id) => paint.cards[id] !== opened.cards[id])); + const providerEditMs = performance.now() - started; + log(`provider edit: first card painted in ${Math.round(providerEditMs)} ms`); + assert.ok(providerEditMs < LIVE_EDIT_BUDGET_MS, `provider edit painted in ${Math.round(providerEditMs)} ms, budget ${LIVE_EDIT_BUDGET_MS} ms`); + const pushed = await nextPaint(gallery, mark, "every provider card repainted", (paint) => data.every((id) => paint.cards[id] !== opened.cards[id]), 30_000); + assert.deepEqual(pushed.live.filter((id) => data.includes(id)).sort(), data, "provider cards stay live through a reload"); + assert.equal(first.live.length, 13, "a pushed edit changes no card's engine"); + assert.equal(new Set(data.map((id) => pushed.cards[id])).size, 3, "each provider card still shows its own value"); + assertLayout(pushed); + + // 7. A click on the second value's card recomposes that card alone. + api.setInspect(false); + mark = paints.length; + started = performance.now(); + assert.ok(api.pointer(data[1]!, "down", 40, 40), "a provider card takes pointer input"); + await sleep(100); + assert.ok(api.pointer(data[1]!, "up", 40, 40)); + const clickedProvider = await nextPaint(gallery, mark, "provider card click", (paint) => paint.cards[data[1]!] !== pushed.cards[data[1]!], 30_000); + log(`provider click: recomposed in ${Math.round(performance.now() - started)} ms`); + assert.equal(clickedProvider.cards[data[0]!], pushed.cards[data[0]!], "the first value's card did not change"); + assert.equal(clickedProvider.cards[data[2]!], pushed.cards[data[2]!], "the third value's card did not change"); + assertLayout(clickedProvider); + assert.deepEqual(clickedProvider.inspection.layouts[data[0]!], pushed.inspection.layouts[data[0]!], "untouched provider retains its measured layout"); + assert.deepEqual(clickedProvider.inspection.layouts[data[2]!], pushed.inspection.layouts[data[2]!], "third provider retains its measured layout"); + mark = paints.length; + api.setInspect(true); + await nextPaint(gallery, mark, "inspection after provider click", (paint) => paint.inspection.enabled); + + // 8. Compile diagnostics for the gallery's file. + await brokenEdit(galleryEditor, gallery, "clicks.value++", 'clicks.value += "one"'); + + // 9. Unsaved edits of a file the gallery calls into (JourneyCard.kt, another module) reach the gallery's cards: the file joins the + // daemon's program. Its first edit does not compile: a Komodo diagnostic in that file, not a file left out of the program. + // Edited without showing it, so the panel keeps previewing the gallery. + const journeyCard = await vscode.workspace.openTextDocument(card); + const editCard = async (before: string, after: string) => { + const at = journeyCard.getText().indexOf(before); + assert.ok(at >= 0, `JourneyCard.kt has ${before}`); + const change = new vscode.WorkspaceEdit(); + change.replace(card, new vscode.Range(journeyCard.positionAt(at), journeyCard.positionAt(at + before.length)), after); + assert.ok(await vscode.workspace.applyEdit(change)); + }; + const appearance = opened.live.find((id) => /\.AppearancePreview_/.test(id))!; + const beforeDependency = paints.at(-1)!.cards[appearance]; + const beforeDependencyBounds = bounds(paints.at(-1)!, appearance); + started = performance.now(); + await editCard("Spacer(Modifier.height(64.dp))", 'Spacer(Modifier.height("4".dp))'); + const brokenDependency = await waitFor("dependency compile diagnostic", () => komodoDiagnostics(card).length ? komodoDiagnostics(card) : undefined, 60_000, 50); + log(`broken dependency edit: ${brokenDependency.length} diagnostics in JourneyCard.kt after ${Math.round(performance.now() - started)} ms`); + mark = paints.length; + started = performance.now(); + await editCard('Spacer(Modifier.height("4".dp))', "Spacer(Modifier.height(4.dp))"); + const dependency = await nextPaint(gallery, mark, "dependency edit", (paint) => paint.cards[appearance] !== beforeDependency && paint.live.includes(appearance)); + log(`dependency edit: ${dependency.live.length} of ${dependency.images} live, painted in ${Math.round(performance.now() - started)} ms`); + await waitFor("dependency diagnostics cleared", () => komodoDiagnostics(card).length === 0 || undefined, 60_000, 50); + assertLayout(dependency); + assert.notDeepEqual(bounds(dependency, appearance), beforeDependencyBounds, + "recovered cross-module spacing edit publishes newly measured bounds"); + mark = paints.length; + started = performance.now(); + await editCard("Spacer(Modifier.height(4.dp))", "Spacer(Modifier.height(40.dp))"); + const secondDependency = await nextPaint(gallery, mark, "second dependency edit", (paint) => paint.cards[appearance] !== dependency.cards[appearance] && paint.live.includes(appearance)); + assertLayout(secondDependency); + assert.notDeepEqual(bounds(secondDependency, appearance), bounds(dependency, appearance), + "the next dependency edit updates measured bounds again"); + log(`second dependency edit: painted in ${Math.round(performance.now() - started)} ms`); + assert.equal(workspaceServerPid("gallery dependent-module edits"), serverPid, "gallery and cross-module edits share the original workspace daemon"); + } finally { + await revert(galleryEditor); + await revert(editor); + } + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(gallery), { preview: false }); + mark = paints.length; + await vscode.commands.executeCommand("elide.androidComposePreview"); + await nextPaint(gallery, mark, "reopened gallery", (paint) => paint.live.length === 13 && paint.failed === 0); + mark = paints.length; + api.setInspect(false); + api.setInspect(true); + const reopened = await nextPaint(gallery, mark, "reopened gallery inspection", (paint) => paint.inspection.enabled); + assertLayout(reopened); + assert.equal(reopened.inspection.images, 13, "reopened sessions retain all measured cards"); + assert.equal(workspaceServerPid("inspection session reopen"), serverPid, "inspection and session lifecycle reuse the native workspace daemon"); + for (const [file, bytes] of diskSources) assert.deepEqual(readFileSync(file), bytes, `sample source unchanged: ${file}`); + await previewEverySampleFile(sample, api, paints); + assert.equal(editor.document.isDirty, false); + assert.equal(galleryEditor.document.isDirty, false); + log("compose-komodo ok"); +} + +/** + * Every Kotlin source of the sample that declares a `@Preview` opens with all its cards live: the sample's screens bring + * library calls and language shapes the two files above do not, and one that Komodo cannot compile or run fails its cards. + */ +async function previewEverySampleFile(sample: string, api: ElideApi["composePreview"], paints: ComposePreviewPaint[]): Promise { + const files = readdirSync(sample, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".kt")) + .map((entry) => path.join(entry.parentPath, entry.name)) + .filter((file) => !file.split(path.sep).includes(".dev") && /^@Preview\b/m.test(readFileSync(file, "utf8"))) + .sort(); + assert.ok(files.length > 2, `the sample's preview files: ${files.join(", ")}`); + const errors: string[] = []; + const stop = api.onDidError((error) => errors.push(`${path.basename(error.source)}: ${error.message}`)); + const failures: string[] = []; + try { + for (const file of files) { + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(vscode.Uri.file(file)), { preview: false }); + const mark = paints.length; + const reported = errors.length; + await vscode.commands.executeCommand("elide.androidComposePreview"); + const paint = await waitFor(`${path.basename(file)} cards`, () => errors.length > reported ? null + : paints.slice(mark).find((candidate) => candidate.source === file && candidate.images > 0 + && (candidate.failed > 0 || candidate.live.length === candidate.images)), LIVE_TIMEOUT_MS, 50); + const summary = paint ? `${paint.live.length} of ${paint.images} live, ${paint.failed} failed` : errors.slice(reported).join(" | "); + log(`sample preview ${path.relative(sample, file)}: ${summary}`); + if (!paint || paint.failed > 0 || paint.live.length !== paint.images) failures.push(`${path.relative(sample, file)}: ${summary}`); + } + } finally { + stop.dispose(); + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + } + assert.deepEqual(failures, [], "every preview of the sample is live"); +} diff --git a/packages/vscode/test/integration/index.ts b/packages/vscode/test/integration/index.ts index 3a35eec..33ba91f 100644 --- a/packages/vscode/test/integration/index.ts +++ b/packages/vscode/test/integration/index.ts @@ -10,11 +10,15 @@ import path from "node:path"; import { compareElideVersions, parseElideVersion, resolveElideDistribution } from "@elide/ide-core"; import * as vscode from "vscode"; import type { ElideExtensionApi as ElideApi } from "../../src/extension.js"; +import { runObservedElide } from "../../src/tasks.js"; import { errorsOf, hoverAt, log, waitFor } from "./helpers.js"; import { runAndroidScenario } from "./android.js"; import { runComposeScenario } from "./compose.js"; +import { runComposeAgentScenario } from "./composeAgent.js"; +import { runComposeKomodoScenario } from "./composeKomodo.js"; import { runKomodoScenario } from "./komodo.js"; import { runKomodoDebugScenario } from "./komodoDebug.js"; +import { runKomodoAndroidDebugScenario } from "./komodoAndroidDebug.js"; import { runWorkspaceScenario } from "./workspace.js"; const sample = process.env.ELIDE_TEST_SAMPLE!; @@ -22,9 +26,12 @@ const sample = process.env.ELIDE_TEST_SAMPLE!; export async function run(): Promise { if (process.env.ELIDE_TEST_SCENARIO === "workspace") return await runWorkspaceScenario(); if (process.env.ELIDE_TEST_SCENARIO === "compose") return await runComposeScenario(); + if (process.env.ELIDE_TEST_SCENARIO === "compose-komodo") return await runComposeKomodoScenario(); + if (process.env.ELIDE_TEST_SCENARIO === "compose-agent") return await runComposeAgentScenario(); if (process.env.ELIDE_TEST_SCENARIO === "android") return await runAndroidScenario(); if (process.env.ELIDE_TEST_SCENARIO === "komodo") return await runKomodoScenario(); if (process.env.ELIDE_TEST_SCENARIO === "komodo-debug") return await runKomodoDebugScenario(); + if (process.env.ELIDE_TEST_SCENARIO === "komodo-android-debug") return await runKomodoAndroidDebugScenario(); const folder = vscode.workspace.workspaceFolders?.[0]; assert.ok(folder, "sample workspace folder open"); assert.equal(folder.uri.fsPath, sample); @@ -224,23 +231,35 @@ export async function run(): Promise { // 4d. Problem matcher: a kotlinc error from the `build` task becomes a diagnostic on the offending file and is // cleared by the next clean build. The Kotlin LSP reports the same error itself, so only markers owned by the // task matcher (`source: "elide"`) count here. - const brokenKt = path.join(sample, "src", "main", "sample", "Broken.kt"); + const brokenKt = path.join(sample, "src", "main", "sample", "Broken Source.kt"); const brokenUri = vscode.Uri.file(brokenKt); writeFileSync(brokenKt, "package sample\n\nfun broken() = undefinedSymbol\n"); - assert.notEqual(await runBuild(), 0, "build fails while Broken.kt is present"); + assert.notEqual(await runBuild(), 0, "build fails while Broken Source.kt is present"); const brokenDiag = await waitFor( - "elide diagnostic on Broken.kt", + "elide diagnostic on Broken Source.kt", () => vscode.languages .getDiagnostics(brokenUri) .find((d) => d.source === "elide" && d.severity === vscode.DiagnosticSeverity.Error && d.message.includes("undefinedSymbol")), 120_000, ); - // `[208ms] error: kotlinc: Unresolved reference 'undefinedSymbol'.` / `In file: src/main/sample/Broken.kt:3:16` + // `[208ms] error: kotlinc: Unresolved reference 'undefinedSymbol'.` / `In file: src/main/sample/Broken Source.kt:3:16` assert.equal(brokenDiag.range.start.line, 2, "diagnostic on the `fun broken()` line"); log("problem matcher diagnostic:", JSON.stringify(brokenDiag.message), `${brokenDiag.range.start.line}:${brokenDiag.range.start.character}`); + // Preview builds run as observed processes, not tasks (which would save dirty editors): their diagnostics come + // from Elide's own records, at the workspace's spelling of a spaced path, and the next such build replaces them. + writeFileSync(brokenKt, "package sample\n\nfun broken() = anotherUndefinedSymbol\n"); + const observedDiagnostics = vscode.languages.createDiagnosticCollection("elide-integration-observed"); + const observedBuild = () => runObservedElide({ root: sample, manifestPath: path.join(sample, "elide.pkl"), folder }, "build", {}, + { signal: new AbortController().signal, onCommand: () => undefined, diagnostics: observedDiagnostics }); + assert.notEqual(await observedBuild(), 0, "observed build reports the compiler failure"); + const observedDiag = observedDiagnostics.get(brokenUri)?.find((d) => d.source === "elide" && d.message.includes("anotherUndefinedSymbol")); + assert.equal(observedDiag?.range.start.line, 2, "observed build diagnostic on the spaced path's `fun broken()` line"); rmSync(brokenKt); - assert.equal(await runBuild(), 0, "build passes once Broken.kt is gone"); + assert.equal(await observedBuild(), 0, "observed build passes once Broken Source.kt is gone"); + assert.equal(observedDiagnostics.get(brokenUri)?.length ?? 0, 0, "the next observed build replaces its diagnostics"); + observedDiagnostics.dispose(); + assert.equal(await runBuild(), 0, "build passes once Broken Source.kt is gone"); await waitFor("elide diagnostics cleared", () => vscode.languages.getDiagnostics(brokenUri).every((d) => d.source !== "elide") || undefined, 60_000); log("problem matcher ok"); diff --git a/packages/vscode/test/integration/komodoAndroidDebug.ts b/packages/vscode/test/integration/komodoAndroidDebug.ts new file mode 100644 index 0000000..e0efb51 --- /dev/null +++ b/packages/vscode/test/integration/komodoAndroidDebug.ts @@ -0,0 +1,195 @@ +/** + * A real Android `elide-android` launch through Komodo, on the runner's clean sample copy. + * The explicitly selected emulator must already be ready and have no copy of this application installed. + * Only this run's application is removed afterward; the emulator and source checkout are left alone. + */ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { androidArtifactFile, androidKeystoreTask, androidSdkRoot, listAndroidDevices } from "@elide/ide-core"; +import * as vscode from "vscode"; +import type { ElideExtensionApi as ElideApi } from "../../src/extension.js"; +import { findRow, taskExit } from "./android.js"; +import { log, waitFor } from "./helpers.js"; + +interface DapEvent { + type: string; + event?: string; + body?: { threadId?: number; reason?: string }; +} + +interface StackFrame { + id: number; + line: number; + source?: { path?: string }; +} + +interface Variable { + name: string; + value: string; + type?: string; + variablesReference: number; +} + +export async function runKomodoAndroidDebugScenario(): Promise { + const sample = process.env.ELIDE_TEST_SAMPLE!; + const memberName = process.env.ELIDE_ANDROID_APP_MEMBER!; + const member = path.join(sample, memberName); + const artifact = process.env.ELIDE_ANDROID_APP_ARTIFACT!; + const applicationId = process.env.ELIDE_ANDROID_APP_ID!; + const serial = process.env.ELIDE_ANDROID_DEVICE!; + const folder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(folder, "sample workspace folder open"); + assert.equal(folder.uri.fsPath, sample); + assert.equal(vscode.workspace.getConfiguration("elide", folder).get("kotlinLsp.server"), "komodo"); + assert.match(serial, /^emulator-\d+$/, "select a disposable emulator explicitly, not a physical device"); + const manifest = readFileSync(path.join(member, "src", "main", "AndroidManifest.xml"), "utf8"); + assert.equal(manifest.match(/\bpackage\s*=\s*"([^"]+)"/)?.[1], applicationId, "application ID matches the fixture manifest"); + + const sdk = androidSdkRoot(); + assert.ok(sdk, "an Android SDK"); + assert.ok((await listAndroidDevices(sdk))?.some((device) => device.serial === serial && device.ready), `${serial} already ready`); + const adb = (...args: string[]) => execFileSync(path.join(sdk, "platform-tools", "adb"), ["-s", serial, ...args], { + encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024, + }).trim(); + const installed = () => adb("shell", "pm", "list", "packages", applicationId).split(/\r?\n/).includes(`package:${applicationId}`); + assert.equal(installed(), false, "refusing to replace an existing app or its data; use a clean disposable emulator"); + + const extension = vscode.extensions.all.find((candidate) => candidate.id.endsWith(".elide")); + assert.ok(extension, "elide extension found"); + const api = await waitFor("extension api", () => extension.exports as ElideApi | undefined, 60_000, 200); + const sync = await waitFor("startup sync", () => api.activity.getChildren().find((operation) => operation.label.startsWith("Sync ") && !operation.running), 1_200_000, 500); + assert.equal(sync.state, "succeeded", `sync ${sync.state}: ${sync.detail}`); + assert.ok(sync.children.some((step) => step.label === "Starting the Komodo language server" && /· (started|reloaded)$/.test(step.detail ?? "")), "startup sync started Komodo"); + + // The existing Android scenario's sidebar lookup and task listener: create the copied fixture's debug key. + const row = await waitFor("APK row", () => findRow(api.explorer, (candidate) => candidate.kind === "androidTarget" && candidate.root === member && candidate.artifact === artifact), 60_000); + let keyResult: { code: number | undefined } | undefined; + void taskExit(androidKeystoreTask(artifact)).then((code) => { keyResult = { code }; }); + await vscode.commands.executeCommand("elide.androidCreateKeystore", row); + assert.equal((await waitFor("debug signing keystore", () => keyResult, 120_000)).code, 0, "debug signing key created"); + + const source = vscode.Uri.file(path.join(member, "src", "main", "kotlin", "sample", "app", "MainActivity.kt")); + const document = await vscode.workspace.openTextDocument(source); + await vscode.window.showTextDocument(document, { preview: false }); + const lines = document.getText().split("\n"); + const breakpointLine = lines.findIndex((line) => line.includes("val dao = JourneyDatabase.get(this).journeys()")); + const contentLine = lines.findIndex((line) => line.includes("setContent { JourneyApp(dao) }")); + assert.ok(breakpointLine > 0 && contentLine > breakpointLine, "the sample's onCreate breakpoint and next source line exist"); + const breakpoint = new vscode.SourceBreakpoint(new vscode.Location(source, new vscode.Position(breakpointLine, 0))); + const events = new Map(); + let session: vscode.DebugSession | undefined; + let terminated = false; + let launched = false; + const subscriptions = [ + vscode.debug.registerDebugAdapterTrackerFactory("komodo-kotlin", { + createDebugAdapterTracker: (debugSession) => ({ + onDidSendMessage: (message: DapEvent) => { + if (message.type !== "event") return; + const list = events.get(debugSession.id) ?? []; + list.push(message); + events.set(debugSession.id, list); + }, + }), + }), + vscode.debug.onDidStartDebugSession((started) => { + if (started.type === "komodo-kotlin") session = started; + }), + vscode.debug.onDidTerminateDebugSession((ended) => { + if (ended.id === session?.id) terminated = true; + }), + ]; + vscode.debug.addBreakpoints([breakpoint]); + try { + launched = true; + // No forwarded port or direct adapter launch: this must build/install/start through the extension provider. + assert.equal(await vscode.debug.startDebugging(folder, { + type: "elide-android", request: "launch", name: "Elide: Komodo Android acceptance", + project: memberName, artifact, device: serial, + }), false, "the elide-android pseudo-session never starts itself"); + const debug = await waitFor("Komodo Android attach after APK build/install", () => session, 1_800_000, 500); + const attachedAt = Date.now(); + assert.equal(debug.configuration.request, "attach"); + assert.equal(debug.configuration.hostName, "127.0.0.1"); + const port = Number(debug.configuration.port); + assert.ok(Number.isInteger(port) && port > 0, "the attach uses a forwarded JDWP port"); + assert.ok(existsSync(androidArtifactFile(member, artifact, "apk", `${artifact}.apk`)), "the launch built the APK in the copied workspace"); + assert.ok(installed(), "the launch installed the application on the pinned emulator"); + const pid = adb("shell", "pidof", applicationId); + assert.match(pid, /^\d+$/, "one application process"); + assert.ok(adb("forward", "--list").split(/\r?\n/).some((line) => line.trim() === `${serial} tcp:${port} jdwp:${pid}`), "the adapter's port forwards to this emulator and application process"); + log("Android attach:", serial, applicationId, "pid", pid, "port", port); + + const sessionEvents = () => events.get(debug.id) ?? []; + const topFrame = async (threadId: number): Promise => { + const trace: { stackFrames: StackFrame[] } = await debug.customRequest("stackTrace", { threadId, startFrame: 0, levels: 1 }); + assert.ok(trace.stackFrames[0], "a stopped stack frame"); + return trace.stackFrames[0]; + }; + const locals = async (frameId: number): Promise => { + const response: { scopes: { variablesReference: number }[] } = await debug.customRequest("scopes", { frameId }); + return (await Promise.all(response.scopes.map(async (scope) => { + const result: { variables: Variable[] } = await debug.customRequest("variables", { variablesReference: scope.variablesReference }); + return result.variables; + }))).flat(); + }; + + // Binding asks only project-package classes for their sources, not the ~30,000 framework classes ART has loaded + // (a 55-79 s scan before): 30 s leaves a wide margin over the ~2 s measured, but fails such a regression. + const hit = await waitFor("Android Kotlin source breakpoint", () => sessionEvents().find((event) => event.event === "stopped"), 30_000, 200); + log("Android attach to first breakpoint:", Date.now() - attachedAt, "ms"); + assert.equal(hit.body?.reason, "breakpoint"); + const threadId = hit.body?.threadId; + assert.equal(typeof threadId, "number", "the breakpoint names its thread"); + const top = await topFrame(threadId!); + assert.equal(top.source?.path, source.fsPath, "Android source maps to the copied Kotlin file, not the original checkout"); + assert.equal(top.line, breakpointLine + 1, "stopped on the requested Kotlin line"); + const savedState = (await locals(top.id)).find((variable) => variable.name === "savedInstanceState"); + assert.ok(savedState, "onCreate's savedInstanceState parameter is visible in locals"); + assert.equal(savedState.value, "null", "a fresh launch has no saved state"); + const evaluated: { result: string } = await debug.customRequest("evaluate", { expression: "savedInstanceState", frameId: top.id, context: "watch" }); + assert.equal(evaluated.result, "null", "evaluation reads the real Android frame's local"); + log("Android breakpoint:", top.source?.path, top.line, "savedInstanceState =", evaluated.result); + + const beforeStep = sessionEvents().length; + await debug.customRequest("next", { threadId }); + const stepped = await waitFor("Android next step", () => sessionEvents().slice(beforeStep).find((event) => event.event === "stopped"), 120_000, 200); + assert.equal(stepped.body?.reason, "step"); + const steppedThread = stepped.body?.threadId ?? threadId!; + const after = await topFrame(steppedThread); + assert.equal(after.source?.path, source.fsPath); + assert.equal(after.line, contentLine + 1, "next executes the DAO assignment and reaches setContent"); + const dao = (await locals(after.id)).find((variable) => variable.name === "dao"); + assert.ok(dao, "the assigned DAO is visible after stepping"); + assert.match(`${dao.type ?? ""} ${dao.value}`, /JourneyDao/, "the local is the real Room DAO"); + const evaluatedDao: { result: string } = await debug.customRequest("evaluate", { expression: "dao", frameId: after.id, context: "watch" }); + assert.equal(evaluatedDao.result, dao.value, "evaluation and locals identify the same assigned DAO"); + log("Android step:", after.line, "dao =", evaluatedDao.result); + + const beforeContinue = sessionEvents().length; + await debug.customRequest("continue", { threadId: steppedThread }); + // Android doesn't exit like the JVM sample: onCreate must return and its app window must gain focus. + await waitFor("continued Activity window", () => adb("shell", "dumpsys", "window").split(/\r?\n/) + .some((line) => line.includes("mCurrentFocus=") && line.includes(`${applicationId}/`) && line.includes("MainActivity")), 60_000, 500); + assert.equal(adb("shell", "pidof", applicationId), pid, "the same Android process remains alive after continue"); + assert.equal(terminated, false, "the debugger remains attached to the running Android app"); + assert.ok(!sessionEvents().slice(beforeContinue).some((event) => event.event === "stopped"), "continue did not stop again"); + log("komodo Android debug ok:", serial, applicationId, "continued with focused Activity"); + } finally { + vscode.debug.removeBreakpoints([breakpoint]); + try { + if (session && !terminated) { + await vscode.debug.stopDebugging(session); + await waitFor("Android debug session termination", () => terminated, 30_000, 200); + } + } finally { + for (const subscription of subscriptions) subscription.dispose(); + // The precondition proved this package absent. Never uninstall an app that preceded the test. + if (launched && installed()) { + adb("shell", "am", "force-stop", applicationId); + assert.match(adb("uninstall", applicationId), /Success/, "remove only the application installed by this scenario"); + } + } + } +} diff --git a/packages/vscode/test/integration/runner.ts b/packages/vscode/test/integration/runner.ts index 2605b28..0dab3d0 100644 --- a/packages/vscode/test/integration/runner.ts +++ b/packages/vscode/test/integration/runner.ts @@ -8,12 +8,25 @@ * * `ELIDE_TEST_SCENARIO=compose` opens the `elide-android-sample` checkout beside this repository (or * `ELIDE_ANDROID_SAMPLE`) in place — its build records absolute paths, so a copy would not reuse them — and runs - * `./compose.ts`. That sample must have been built once with an Android-capable Elide. The Compose renderer - * runtime is reused from the regular VS Code profile when present, otherwise downloaded. + * `./compose.ts`. That sample must have been built once with an Android-capable Elide recording kapt/toolchain + * provenance for Room in `app`. The Compose renderer runtime is reused from the regular VS Code profile when + * present, otherwise downloaded. + * + * `ELIDE_TEST_SCENARIO=compose-komodo` opens the same sample with the `komodo` preview engine and runs + * `./composeKomodo.ts`. Preview and analysis share Elide's managed workspace Komodo language server. + * Install it explicitly through Elide; no Komodo payload is included in default distributions. It must support + * `komodo/preview/open` protocol 2. `ELIDE_KOMODO_LSP` selects another server launcher and + * `ELIDE_KOMODO_JAVA_HOME` selects its JDK inputs. No standalone preview launcher or CDS trainer is used. + * Native execution is required by default; `ELIDE_KOMODO_RUNTIME=jvm` explicitly selects the JVM comparison gate. + * + * `ELIDE_TEST_SCENARIO=compose-agent` opens the same sample and runs `./composeAgent.ts`: the Compose preview MCP tools over + * HTTP, as an agent uses them, with the engine `ELIDE_COMPOSE_ENGINE` names (`layoutlib`, the default, or `komodo`, which + * also puts the folder on Komodo's language server as `compose-komodo` does). * * `ELIDE_TEST_SCENARIO=android` opens a clean copy (no `.dev`, so nothing prebuilt) of `ELIDE_ANDROID_APP_SAMPLE` - * and runs `./android.ts`: sync, build the APK, and install and launch it on the attached device or a headless - * emulator. Set `ELIDE_ANDROID_APP_MEMBER`, `ELIDE_ANDROID_APP_ARTIFACT`, and `ELIDE_ANDROID_APP_ID` for that app. + * and runs `./android.ts`: sync, create its configured debug signing key, build the APK, and install and launch + * it on the attached device or a headless emulator. Set `ELIDE_ANDROID_APP_MEMBER`, `ELIDE_ANDROID_APP_ARTIFACT`, + * and `ELIDE_ANDROID_APP_ID` for a disposable app whose debug keystore is under `.dev`. * * Prerequisites: VS Code at /Applications/Visual Studio Code.app, `JetBrains.kotlin-server` installed in * ~/.vscode/extensions, `elide` installed. Usage: `bazel run //packages/vscode:integration_test`. @@ -25,11 +38,20 @@ * `ELIDE_TEST_SCENARIO=komodo` opens a copy of `samples/ktjvm` with `elide.kotlinLsp.server` set to `komodo` and the * JetBrains extension disabled. By default the selected Elide resolves its managed component, including JDK inputs. * For developer builds only, ELIDE_KOMODO_LSP, ELIDE_KOMODO_PLUGINS and ELIDE_KOMODO_JAVA_HOME override that identity. - * `ELIDE_TEST_SCENARIO=komodo-debug` debugs the same sample through Komodo's debug adapter. + * `ELIDE_KOMODO_SAMPLE=android` opens the built + * Elide Android sample (as `compose` does, in place) instead of `samples/ktjvm`: Compose plugin, cross-module navigation. + * `ELIDE_TEST_SCENARIO=komodo-debug` opens the same `samples/ktjvm` copy with the same settings and runs + * `./komodoDebug.ts`: `Elide: Run (debug)` through Komodo's debug adapter (`komodo --dap`). + * `ELIDE_TEST_SCENARIO=komodo-android-debug` instead copies `ELIDE_ANDROID_APP_SAMPLE` using the `android` + * fixture variables above and runs `./komodoAndroidDebug.ts`: signing/build, then an `elide-android` launch, + * Komodo attach, Kotlin breakpoint/locals/evaluate/step/continue. `ELIDE_ANDROID_DEVICE` must name a ready, + * disposable emulator; the fixture's application ID must not already be installed. The emulator is left running. * * `ELIDE_TEST_HOME` runs the scenario against another Elide distribution, such as a local build's * `.dev/artifacts/dist/current`, through the `elide.home` setting: the extension host does not see an `ELIDE_HOME` * set in the shell that starts the run. + * `ELIDE_TEST_COMPONENTS_HOME` forwards an isolated component store as `ELIDE_COMPONENTS_HOME` to the extension host. + * Install the candidate there explicitly before managed scenarios; omit all ELIDE_KOMODO_* path/JDK overrides. */ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; @@ -47,17 +69,27 @@ const extensionTestsPath = path.resolve(extensionDevelopmentPath, "dist-test", " const vscodeExecutablePath = process.env.VSCODE_EXECUTABLE ?? "/Applications/Visual Studio Code.app/Contents/MacOS/Code"; -const scenarios = ["workspace", "compose", "android", "komodo", "komodo-debug"] as const; +const scenarios = ["workspace", "compose", "compose-komodo", "compose-agent", "android", "komodo", "komodo-debug", "komodo-android-debug"] as const; const scenario = scenarios.find((name) => name === process.env.ELIDE_TEST_SCENARIO) ?? "ktjvm"; +const composeScenario = scenario === "compose" || scenario === "compose-komodo" || scenario === "compose-agent"; +/** The Komodo preview engine's scenarios. */ +const komodoPreview = scenario === "compose-komodo" || (scenario === "compose-agent" && process.env.ELIDE_COMPOSE_ENGINE === "komodo"); +const androidScenario = scenario === "android" || scenario === "komodo-android-debug"; /** Komodo's scenarios: the folder on Komodo's language server, every other extension disabled. */ -const komodo = scenario === "komodo" || scenario === "komodo-debug"; -if (scenario === "android") { +const komodo = scenario === "komodo" || scenario === "komodo-debug" || scenario === "komodo-android-debug"; +/** Every scenario on Komodo's language server: Komodo's own, and the Komodo preview engine's (it discovers previews through it). */ +const komodoServer = komodo || komodoPreview; +if (androidScenario) { const missing = ["ELIDE_ANDROID_APP_SAMPLE", "ELIDE_ANDROID_APP_MEMBER", "ELIDE_ANDROID_APP_ARTIFACT", "ELIDE_ANDROID_APP_ID"] .filter((name) => !process.env[name]); if (missing.length) throw new Error(`Android verification requires an explicit fixture; set ${missing.join(", ")}`); } +if (scenario === "komodo-android-debug" && !process.env.ELIDE_ANDROID_DEVICE) { + throw new Error("Android debugging requires ELIDE_ANDROID_DEVICE naming a ready disposable emulator"); +} -// The user's extensions dir is reused as-is; Komodo's scenarios disable other extensions for this instance. +// The user's extensions dir is used as-is so the already-installed JetBrains extension (1.2 GB) is reused. Komodo's +// scenarios run without it (it is disabled for the instance), so it need not be installed. const extensionsDir = path.join(homedir(), ".vscode", "extensions"); if (!komodo && !readdirSync(extensionsDir).some((d) => d.startsWith("jetbrains.kotlin-server-"))) throw new Error(`JetBrains.kotlin-server is not installed in ${extensionsDir}`); @@ -81,13 +113,20 @@ writeFileSync( "extensions.autoCheckUpdates": false, "telemetry.telemetryLevel": "off", // An emulator the test boots runs headless. - ...(scenario === "android" ? { "elide.android.emulatorWindow": false } : {}), - ...(komodo ? { + ...(androidScenario ? { "elide.android.emulatorWindow": false } : {}), + ...(komodoPreview ? { + "elide.android.composePreview.engine": "komodo", + } : {}), + // The Komodo preview engine discovers previews through Komodo's language server. + ...(komodoServer ? { "elide.kotlinLsp.server": "komodo", + // Without developer overrides, Elide resolves the managed executable, plugins and JDK inputs. ...(process.env.ELIDE_KOMODO_LSP ? { "elide.kotlinLsp.komodo.path": process.env.ELIDE_KOMODO_LSP } : {}), ...(process.env.ELIDE_KOMODO_PLUGINS ? { "elide.kotlinLsp.komodo.pluginDirectory": process.env.ELIDE_KOMODO_PLUGINS } : {}), ...(process.env.ELIDE_KOMODO_JAVA_HOME ? { "elide.kotlinLsp.komodo.javaHome": process.env.ELIDE_KOMODO_JAVA_HOME } : {}), + // The Android sample is opened in place: nothing the scenario edits may reach the disk. "files.autoSave": "off", + // The client's protocol trace (Elide: Komodo output channel), for diagnosing a failed check. ...(process.env.ELIDE_KOMODO_TRACE ? { "elide.komodo.trace.server": "messages" } : {}), } : {}), ...(process.env.ELIDE_TEST_HOME ? { "elide.home": process.env.ELIDE_TEST_HOME } : {}), @@ -97,14 +136,16 @@ writeFileSync( ), ); -// Work on a scratch copy of the sample so the test can mutate elide.pkl freely; the compose sample stays in place. -const sample = scenario === "compose" +// Work on a scratch copy of the sample so the test can mutate elide.pkl freely; the Android sample stays in place. +const inPlace = composeScenario || (scenario === "komodo" && process.env.ELIDE_KOMODO_SAMPLE === "android"); +const sample = inPlace ? composeSampleDirectory() : mkdtempSync(path.join(realpathSync(scratchRoot), `elide-${scenario}-`)); +let restoreComposeSettings: (() => void) | undefined; if (scenario === "workspace") prepareWorkspaceSample(); -else if (scenario === "compose") prepareComposeSample(); -else if (scenario === "android") prepareAndroidSample(); -else if (komodo) copyFiles(path.join(runfilesRoot, "samples", "ktjvm"), sample); +else if (composeScenario) prepareComposeSample(); +else if (androidScenario) prepareAndroidSample(); +else if (komodo) { if (!inPlace) copyFiles(path.join(runfilesRoot, "samples", "ktjvm"), sample); } else prepareKtjvmSample(); function composeSampleDirectory(): string { @@ -117,8 +158,38 @@ function composeSampleDirectory(): string { function prepareComposeSample(): void { if (!existsSync(path.join(sample, "app", ".dev", "jvm"))) throw new Error(`no built Elide Android sample at ${sample}; set ELIDE_ANDROID_SAMPLE`); + for (const record of ["kapt.args", "kotlinc.toolchain.json", "kapt.generated.json"]) { + if (!existsSync(path.join(sample, "app", ".dev", "jvm", "classes", "main", record))) { + throw new Error(`missing app processing record ${record}; build the same-module Room fixture with the updated Android-capable Elide before running compose`); + } + } const runtime = path.join(homedir(), "Library", "Application Support", "Code", "User", "globalStorage", "elide.elide", "compose-runtime"); if (existsSync(runtime)) cpSync(runtime, path.join(userDataDir, "User", "globalStorage", "elide.elide", "compose-runtime"), { recursive: true }); + if (process.env.ELIDE_TEST_HOME) { + // This fixture stays in place; its folder setting otherwise overrides the isolated profile's + // requested test distribution. Restore it afterward without undoing unrelated concurrent edits. + const settingsFile = path.join(sample, ".vscode", "settings.json"); + const before = existsSync(settingsFile) ? readFileSync(settingsFile, "utf8") : undefined; + const settings = before ? JSON.parse(before) as Record : {}; + const previousHome = settings["elide.home"]; + settings["elide.home"] = process.env.ELIDE_TEST_HOME; + const during = `${JSON.stringify(settings, null, 2)}\n`; + mkdirSync(path.dirname(settingsFile), { recursive: true }); + writeFileSync(settingsFile, during); + restoreComposeSettings = () => { + const current = readFileSync(settingsFile, "utf8"); + if (current === during) { + if (before === undefined) rmSync(settingsFile); + else writeFileSync(settingsFile, before); + } else { + const updated = JSON.parse(current) as Record; + if (updated["elide.home"] !== process.env.ELIDE_TEST_HOME) return; + if (previousHome === undefined) delete updated["elide.home"]; + else updated["elide.home"] = previousHome; + writeFileSync(settingsFile, `${JSON.stringify(updated, null, 2)}\n`); + } + }; + } } function prepareAndroidSample(): void { @@ -223,16 +294,28 @@ try { vscodeExecutablePath, extensionDevelopmentPath, extensionTestsPath, - // Webview paint confirmations use requestAnimationFrame, which Chromium suspends in occluded windows. + // Webview paint confirmations use requestAnimationFrame, which Chromium suspends in occluded windows unless started + // with these flags, and in minimized windows regardless. launchArgs: [sample, "--extensions-dir", extensionsDir, "--user-data-dir", userDataDir, "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", - ...(scenario === "compose" ? ["--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", "--disable-background-timer-throttling"] : []), - ...(komodo && !process.env.ELIDE_KOMODO_COEXIST ? ["--disable-extensions"] : [])], - extensionTestsEnv: { ELIDE_TEST_SAMPLE: sample, ELIDE_TEST_SCENARIO: scenario }, + ...(composeScenario ? ["--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", "--disable-background-timer-throttling"] : []), + // Komodo's answers alone, as on a machine without the JetBrains extension: every installed extension is disabled + // (the JetBrains server would contribute its own diagnostics, locations, `kotlin` grammar and debugger; others + // their own formatters and providers), leaving this extension under development. ELIDE_KOMODO_COEXIST keeps them + // enabled. + ...(komodoServer && !process.env.ELIDE_KOMODO_COEXIST ? ["--disable-extensions"] : [])], + extensionTestsEnv: { + ELIDE_TEST_SAMPLE: sample, + ELIDE_TEST_SCENARIO: scenario, + ...(process.env.ELIDE_TEST_COMPONENTS_HOME ? { ELIDE_COMPONENTS_HOME: path.resolve(process.env.ELIDE_TEST_COMPONENTS_HOME) } : {}), + ...(process.env.ELIDE_KOMODO_SAMPLE ? { ELIDE_KOMODO_SAMPLE: process.env.ELIDE_KOMODO_SAMPLE } : {}), + ...(process.env.ELIDE_KOMODO_COEXIST ? { ELIDE_KOMODO_COEXIST: process.env.ELIDE_KOMODO_COEXIST } : {}), + }, }); } finally { + restoreComposeSettings?.(); if (!process.env.KEEP_TEST_DIRS) { rmSync(userDataDir, { recursive: true, force: true }); - if (scenario !== "compose") rmSync(sample, { recursive: true, force: true }); + if (!inPlace) rmSync(sample, { recursive: true, force: true }); } else { console.log("kept", { userDataDir, sample }); } diff --git a/packages/vscode/test/jetbrains.test.ts b/packages/vscode/test/jetbrains.test.ts index d538d09..0324bb7 100644 --- a/packages/vscode/test/jetbrains.test.ts +++ b/packages/vscode/test/jetbrains.test.ts @@ -62,7 +62,7 @@ test("Kotlin launch settings survive lazy activation and in-flight companion sta expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); }); -test("Kotlin server plugin options are window-local, preserve quoting, and restore inherited options", () => { +test("Kotlin server launch defaults are window-local, preserve quoted options, and yield to explicit overrides", () => { const source = new URL("../src/jetbrains.ts", import.meta.url).pathname; const result = spawnSync(process.execPath, ["--eval", ` import assert from "node:assert/strict"; @@ -71,18 +71,57 @@ test("Kotlin server plugin options are window-local, preserve quoting, and resto // Load only after the child process has installed its VS Code module boundary. const { KotlinLspEnvironment } = await import(${JSON.stringify(source)}); const original = "-Xmx3g '-Dlabel=a b'"; + const environment = { IJ_JAVA_OPTIONS: original }; + const managed = new KotlinLspEnvironment(environment); + const userArgs = ["-Dplugin.path=/machine/local/compose", "-Xmx4g"]; + assert.equal(managed.configure({ androidDebugger: false }, userArgs), false); + assert.equal(managed.configure({ androidDebugger: true }, userArgs), true); + assert.equal(environment.IJ_JAVA_OPTIONS, original + " -Ddebugger.async.jdi=false"); + assert.deepEqual(userArgs, ["-Dplugin.path=/machine/local/compose", "-Xmx4g"]); + assert.equal(managed.configure({ androidDebugger: true }, userArgs), false, "another folder sync must not append the default twice"); + assert.equal(managed.configure({ androidDebugger: true }, [...userArgs, "-Ddebugger.async.jdi=true"]), true); + assert.equal(environment.IJ_JAVA_OPTIONS, original, "explicit JVM settings override the managed default"); + assert.equal(managed.configure({ androidDebugger: true }, [...userArgs, "-Ddebugger.async.jdi=false"]), false); + assert.equal(managed.configure({ androidDebugger: true }, userArgs), true); + assert.equal(managed.configure({ androidDebugger: false }, userArgs), true, "removing the last Android project restores inherited options"); + assert.equal(environment.IJ_JAVA_OPTIONS, original); + managed.configure({ androidDebugger: true }, userArgs); + managed.dispose(); + assert.equal(environment.IJ_JAVA_OPTIONS, original); + + for (const option of ["-Ddebugger.async.jdi=true", "'-Ddebugger.async.jdi=false'", '"-Ddebugger.async.jdi=true"']) { + const explicit = { IJ_JAVA_OPTIONS: "-Xmx2g " + option }; + const inherited = explicit.IJ_JAVA_OPTIONS; + const options = new KotlinLspEnvironment(explicit); + assert.equal(options.configure({ androidDebugger: true }, []), false); + options.dispose(); + assert.equal(explicit.IJ_JAVA_OPTIONS, inherited); + } + const otherWindow = {}; + const other = new KotlinLspEnvironment(otherWindow); + assert.equal(other.configure({ androidDebugger: false }, []), false); + other.configure({ androidDebugger: true }, []); + other.dispose(); + assert.equal("IJ_JAVA_OPTIONS" in otherWindow, false); + managed.configure({ androidDebugger: true }, []); + environment.IJ_JAVA_OPTIONS = "-Ddebugger.async.jdi=true -Xmx5g"; + managed.dispose(); + assert.equal(environment.IJ_JAVA_OPTIONS, "-Ddebugger.async.jdi=true -Xmx5g", "never overwrite a later environment owner"); + // Elide's Compose plugin rides along in the same window-local options, quoted as the companion quotes its own. const plugin = "/Users/u/Library/Application Support/Code/User/globalStorage/elide.elide/kotlin-lsp-plugin/abc/elide"; const compose = { IJ_JAVA_OPTIONS: original }; const withPlugin = new KotlinLspEnvironment(compose); - assert.equal(withPlugin.configure({ plugin }), true); + assert.equal(withPlugin.configure({ androidDebugger: false, plugin }, []), true); assert.equal(compose.IJ_JAVA_OPTIONS, original + ' "-Dplugin.path=' + plugin + '"'); - assert.equal(withPlugin.configure({ plugin }), false); - assert.equal(withPlugin.configure({}), true, "closing the last Compose project drops the plugin"); + assert.equal(withPlugin.configure({ androidDebugger: true, plugin }, []), true); + assert.equal(compose.IJ_JAVA_OPTIONS, original + ' -Ddebugger.async.jdi=false "-Dplugin.path=' + plugin + '"'); + assert.equal(withPlugin.configure({ androidDebugger: true, plugin }, []), false); + assert.equal(withPlugin.configure({ androidDebugger: false }, []), true, "closing the last Compose project drops the plugin"); assert.equal(compose.IJ_JAVA_OPTIONS, original); const plain = {}; const unquoted = new KotlinLspEnvironment(plain); - unquoted.configure({ plugin: "/opt/elide/plugin" }); + unquoted.configure({ androidDebugger: false, plugin: "/opt/elide/plugin" }, []); assert.equal(plain.IJ_JAVA_OPTIONS, "-Dplugin.path=/opt/elide/plugin"); unquoted.dispose(); assert.equal("IJ_JAVA_OPTIONS" in plain, false); @@ -90,7 +129,7 @@ test("Kotlin server plugin options are window-local, preserve quoting, and resto expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); }); -test("attach readiness waits for fresh daemon discovery and initialized debugger commands", () => { +test("Android attach readiness waits for fresh daemon discovery and initialized debugger commands", () => { const source = new URL("../src/jetbrains.ts", import.meta.url).pathname; const result = spawnSync(process.execPath, ["--eval", ` import assert from "node:assert/strict"; diff --git a/packages/vscode/test/komodoLsp.test.ts b/packages/vscode/test/komodoLsp.test.ts index a0b63bb..bfa0abb 100644 --- a/packages/vscode/test/komodoLsp.test.ts +++ b/packages/vscode/test/komodoLsp.test.ts @@ -40,6 +40,7 @@ function run(body: string, setup = ""): { status: number | null; stderr: string async dispose() { this.disposed = true; this.state = 1; } }, State: { Stopped: 1, Running: 2, Starting: 3 }, CloseAction: { DoNotRestart: 1 }, ErrorAction: { Shutdown: 2 }, + ResponseError: class extends Error {}, ErrorCodes: { MethodNotFound: -32601 }, })); mock.module(${JSON.stringify(debug)}, () => ({ requireKomodoDebugProtocol: async () => { if (dapFailure) throw dapFailure; } })); ${setup} @@ -82,6 +83,69 @@ test("the client sees only Kotlin files on disk under the Komodo folders, glob c }); +test("preview sessions require protocol 2 and report old servers instead of falling back", () => { + expect(run(` + const server = Object.create(komodo.KomodoLanguageServer.prototype); + const requests = []; + let answer = { protocol: 2, port: 12345, token: "secret", pid: 42 }; + server.clientFor = async (file) => { + assert.equal(file, "/workspace/Original.kt"); + return { uri: "file:///workspace/Original.kt", client: { + sendRequest: async (method, params) => { requests.push({ method, params }); return answer; } + } }; + }; + assert.deepEqual(await server.previewSession("/workspace/Original.kt"), answer); + assert.deepEqual(requests, [{ method: "komodo/preview/open", params: { textDocument: { uri: "file:///workspace/Original.kt" } } }]); + for (const invalid of [undefined, { ...answer, protocol: 1 }, { ...answer, port: 0 }, { ...answer, token: "" }, { ...answer, token: "bad\\nline" }]) { + answer = invalid; + await assert.rejects(server.previewSession("/workspace/Original.kt"), /invalid preview session/); + } + // Import after the harness installs its mocked language-client boundary. + const { ResponseError } = await import("vscode-languageclient/node"); + server.clientFor = async () => ({ uri: "file:///workspace/Original.kt", client: { + sendRequest: async () => { const error = new ResponseError("missing"); error.code = -32601; throw error; } + } }); + await assert.rejects(server.previewSession("/workspace/Original.kt"), /too old for the Komodo preview engine.*there is no fallback/); + `)).toEqual({ status: 0, stderr: "" }); +}); + +test("unsupported preview methods and invalid sessions latch preview unavailability without disabling native analysis", () => { + expect(run(` + // Import after the subprocess installs the mocked language-client module boundary. + const { ResponseError } = await import("vscode-languageclient/node"); + const server = new komodo.KomodoLanguageServer("/tmp/elide-komodo-test", () => {}); + const sync = () => server.sync(previewFolder, [{ root: "/work/app" }], {}); + for (const method of ["previews", "previewSession", "syncPreview"]) { + await sync(); + const client = server.client; + client.code2ProtocolConverter = { asUri: () => "file:///work/app/Screen.kt" }; + const reload = client.sendRequest.bind(client); + let requests = 0; + client.sendRequest = async () => { + requests++; + const error = new ResponseError("unsupported"); + error.code = -32601; + throw error; + }; + const call = () => server[method](method === "syncPreview" ? ["/work/app/Screen.kt"] : "/work/app/Screen.kt"); + await assert.rejects(call(), /compatible Komodo/); + assert.equal(server.previewAvailable, false); + assert.equal(server.available, true); + await server.debugLaunch(previewFolder); + await assert.rejects(call(), /disabled/); + assert.equal(requests, 1, "a rejected capability is not probed repeatedly"); + client.sendRequest = reload; + } + await sync(); + const client = server.client; + client.sendRequest = async () => ({ protocol: 1, port: 12345, token: "secret" }); + await assert.rejects(server.previewSession("/work/app/Screen.kt"), /invalid preview session/); + assert.equal(server.previewAvailable, false); + assert.equal(server.available, true); + server.dispose(); + `, lifecycleSetup)).toEqual({ status: 0, stderr: "" }); +}); + test("a client restarting its crashed server is stopped once it is up, and disposed so it never restarts one again", () => { expect(run(` const server = Object.create(komodo.KomodoLanguageServer.prototype); diff --git a/packages/vscode/test/mcpServer.test.ts b/packages/vscode/test/mcpServer.test.ts index fec71b6..91891da 100644 --- a/packages/vscode/test/mcpServer.test.ts +++ b/packages/vscode/test/mcpServer.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { request } from "node:http"; +import { connect } from "node:net"; import { McpHttpServer, type McpCallContext, type McpTool } from "../src/mcpServer.js"; const TOKEN = "test-token-0123456789"; @@ -216,6 +217,24 @@ describe("MCP server", () => { expect((await raw(server, "POST", { Host: host, ...auth }, "x".repeat(64 * 1024 * 1024))).status).toBe(413); }); + test("rejects malformed request targets without taking down the server", async () => { + const server = await start(); + for (const path of ["//", "http://["]) { + // Bun 1.3's node:http client normalizes the target through fetch before sending it. + const { promise, resolve, reject } = Promise.withResolvers(); + let data = ""; + const socket = connect(server.port, "127.0.0.1", () => { + socket.write(`POST ${path} HTTP/1.1\r\nHost: 127.0.0.1:${server.port}\r\nAuthorization: Bearer ${TOKEN}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`); + }); + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { data += chunk; }); + socket.on("end", () => resolve(data)); + socket.on("error", reject); + expect(await promise).toStartWith("HTTP/1.1 400 "); + } + expect((await rpc(server, "ping")).result).toEqual({}); + }); + test("aborts a call on notifications/cancelled", async () => { const { tool, started, ended } = waiting(); const server = await start([tool]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea014b1..8218f44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@elide/ide-core': specifier: workspace:* version: link:../core + qrcode: + specifier: ^1.5.4 + version: 1.5.4 vscode-languageclient: specifier: ^9.0.1 version: 9.0.1 @@ -36,6 +39,9 @@ importers: '@types/node': specifier: ^22.15.0 version: 22.20.5 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/vscode': specifier: ^1.105.0 version: 1.140.0 @@ -203,6 +209,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/sarif@2.1.7': resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} @@ -367,6 +376,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -393,6 +406,9 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cockatiel@3.2.1: resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==} engines: {node: '>=16'} @@ -431,6 +447,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -459,6 +479,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -548,6 +571,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -562,6 +589,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.7.0: resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} @@ -755,6 +786,10 @@ packages: linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -901,10 +936,22 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-map@7.1.1: resolution: {integrity: sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==} engines: {node: '>=18'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -924,6 +971,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -949,6 +1000,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + powershell-utils@0.2.1: resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} engines: {node: '>=20'} @@ -969,6 +1024,11 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.16.1: resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} engines: {node: '>=0.6'} @@ -998,10 +1058,17 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -1044,6 +1111,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -1250,6 +1320,13 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -1265,9 +1342,20 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yauzl@3.4.0: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} @@ -1504,6 +1592,10 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.20.5 + '@types/sarif@2.1.7': {} '@types/vscode@1.140.0': {} @@ -1700,6 +1792,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + camelcase@5.3.1: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -1739,6 +1833,12 @@ snapshots: cli-spinners@2.9.2: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cockatiel@3.2.1: {} color-convert@2.0.1: @@ -1769,6 +1869,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -1791,6 +1893,8 @@ snapshots: detect-libc@2.1.2: optional: true + dijkstrajs@1.0.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -1883,6 +1987,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -1902,6 +2011,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-east-asian-width@1.7.0: {} get-intrinsic@1.3.0: @@ -2108,6 +2219,10 @@ snapshots: dependencies: uc.micro: 2.1.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -2250,8 +2365,18 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-map@7.1.1: {} + p-try@2.2.0: {} + pako@1.0.11: {} parse-json@8.3.0: @@ -2277,6 +2402,8 @@ snapshots: dependencies: entities: 6.0.1 + path-exists@4.0.0: {} + path-scurry@2.0.2: dependencies: lru-cache: 11.5.3 @@ -2294,6 +2421,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@5.0.0: {} + powershell-utils@0.2.1: {} prebuild-install@7.1.3: @@ -2322,6 +2451,12 @@ snapshots: punycode.js@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.16.1: dependencies: es-define-property: 1.0.1 @@ -2375,8 +2510,12 @@ snapshots: util-deprecate: 1.0.2 optional: true + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -2414,6 +2553,8 @@ snapshots: semver@7.8.5: {} + set-blocking@2.0.0: {} + setimmediate@1.0.5: {} side-channel-list@1.0.1: @@ -2634,6 +2775,14 @@ snapshots: whatwg-mimetype@4.0.0: {} + which-module@2.0.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: optional: true @@ -2649,8 +2798,29 @@ snapshots: xmlbuilder@11.0.1: {} + y18n@4.0.3: {} + yallist@4.0.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yauzl@3.4.0: dependencies: pend: 1.2.0 diff --git a/tools/build-database-inspector.ts b/tools/build-database-inspector.ts new file mode 100755 index 0000000..ce6dac3 --- /dev/null +++ b/tools/build-database-inspector.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Release-time only: bazel run //tools/bazel/bun -- "$PWD/tools/build-database-inspector.ts" +// Node alternative (Node 22.6+): node --experimental-strip-types tools/build-database-inspector.ts +// Pin ANDROID_DATABASE_PLATFORM (e.g. android-35) and ANDROID_DATABASE_BUILD_TOOLS +// (e.g. 35.0.0) for reproducible builds with the same JDK and Android toolchain. +import { spawnSync } from 'node:child_process'; +import { copyFile, mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const output = join(root, 'packages', 'vscode', 'media', 'database'); +const source = join(output, 'DatabaseInspector.java'); + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + +async function sdkDirectory(): Promise { + const configured = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME; + if (configured) { + const sdk = resolve(configured); + if (!await exists(sdk)) throw new Error(`Android SDK does not exist: ${sdk}`); + return sdk; + } + const candidates = [ + join(homedir(), 'Library', 'Android', 'sdk'), + join(homedir(), 'Android', 'Sdk'), + ...(process.env.LOCALAPPDATA ? [join(process.env.LOCALAPPDATA, 'Android', 'Sdk')] : []), + ]; + for (const candidate of candidates) { + if (await exists(candidate)) return candidate; + } + throw new Error('Android SDK not found. Set ANDROID_SDK_ROOT or ANDROID_HOME to an installed SDK.'); +} + +function compareVersions(a: string, b: string): number { + const left = a.replace(/^android-/, '').split('.').map(Number); + const right = b.replace(/^android-/, '').split('.').map(Number); + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const difference = (right[index] ?? 0) - (left[index] ?? 0); + if (difference !== 0) return difference; + } + return a.localeCompare(b, 'en'); +} + +async function sdkComponent(directory: string, pin: string | undefined, + pattern: RegExp, requiredFile: string, label: string): Promise { + if (pin) { + if (!pattern.test(pin)) throw new Error(`Invalid ${label} version: ${pin}`); + const component = join(directory, pin); + if (!await exists(join(component, requiredFile))) { + throw new Error(`Requested ${label} is not installed: ${component}`); + } + return component; + } + if (!await exists(directory)) throw new Error(`No ${label} installed under ${directory}`); + const names = (await readdir(directory)).filter(name => pattern.test(name)).sort(compareVersions); + for (const name of names) { + const component = join(directory, name); + if (await exists(join(component, requiredFile))) return component; + } + throw new Error(`No usable ${label} installed under ${directory}`); +} + +function javaTool(name: string): string { + const executable = process.platform === 'win32' ? `${name}.exe` : name; + return process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', executable) : executable; +} + +function run(command: string, args: string[]): void { + const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', shell: false }); + if (result.error) { + throw new Error(`Could not launch ${command}: ${result.error.message}. Install a JDK and set JAVA_HOME if necessary.`); + } + if (result.status !== 0) { + throw new Error(`${command} failed (${result.signal ?? `exit ${result.status}`}).`); + } +} + +async function classFiles(directory: string): Promise { + const files: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await classFiles(path)); + else if (entry.isFile() && entry.name.endsWith('.class')) files.push(path); + } + return files.sort(); +} + +async function main(): Promise { + const sdk = await sdkDirectory(); + const platform = await sdkComponent(join(sdk, 'platforms'), process.env.ANDROID_DATABASE_PLATFORM, + /^android-(?:2[89]|[3-9]\d|[1-9]\d{2,})(?:\.\d+)?$/, 'android.jar', 'Android platform (API 28 or newer)'); + const buildTools = await sdkComponent(join(sdk, 'build-tools'), process.env.ANDROID_DATABASE_BUILD_TOOLS, + /^\d+\.\d+\.\d+$/, join('lib', 'd8.jar'), 'Android build-tools with d8'); + const androidJar = join(platform, 'android.jar'); + const temporary = await mkdtemp(join(tmpdir(), 'elide-database-inspector-')); + try { + const classes = join(temporary, 'classes'); + const dex = join(temporary, 'dex'); + await mkdir(classes); + await mkdir(dex); + console.log(`Compiling database inspector with ${platform}, ${buildTools}`); + run(javaTool('javac'), [ + '-encoding', 'UTF-8', '-source', '8', '-target', '8', '-g:none', + '-bootclasspath', androidJar, '-d', classes, source, + ]); + // Invoke the SDK's d8 entry point directly, avoiding platform-specific shell wrappers. + run(javaTool('java'), [ + '-cp', join(buildTools, 'lib', 'd8.jar'), 'com.android.tools.r8.D8', + '--release', '--min-api', '26', '--lib', androidJar, '--output', dex, + ...await classFiles(classes), + ]); + const dexFiles = (await readdir(dex)).filter(name => name.endsWith('.dex')); + if (dexFiles.length !== 1 || dexFiles[0] !== 'classes.dex') { + throw new Error('Expected exactly one classes.dex from d8; refusing an incomplete helper.'); + } + await copyFile(join(dex, 'classes.dex'), join(output, 'inspector.dex')); + console.log(`Wrote ${join(output, 'inspector.dex')} (minimum Android API 26).`); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +});