Skip to content

JavaScript: spawn Java RPC server for TS integration tests#7806

Merged
knutwannheden merged 3 commits into
mainfrom
ts-java-rpc-client-for-java-recipes
May 28, 2026
Merged

JavaScript: spawn Java RPC server for TS integration tests#7806
knutwannheden merged 3 commits into
mainfrom
ts-java-rpc-client-for-java-recipes

Conversation

@knutwannheden
Copy link
Copy Markdown
Contributor

@knutwannheden knutwannheden commented May 28, 2026

Motivation

Both rewrite-python and rewrite-csharp already ship a small test-only client that spawns org.openrewrite.maven.rpc.JavaRewriteRpc and talks to it over JSON-RPC. This gives those languages an easy way to drive real Java recipes (e.g. ChangeType, ChangeMethodName) from their native test runners — and, in production, lets a TS recipe delegate part of its work to a Java recipe.

rewrite-javascript did not have an equivalent. Adding it unblocks TS-side tests for Java-ported recipes — most immediately the dependency-management recipes on #7795, where the TS-side recipe files have been deleted in favor of Java recipes and the tests need to verify them through RPC. It also gives TS recipes a stable way to delegate to Java recipes that works identically in test (TS spawned the JVM) and production (Java spawned the TS server).

The key insight that keeps this small: RewriteRpc is already symmetric — when given a MessageConnection wired to a child process's stdio, the same class drives Java as a client. No new sender/receiver/codec logic needed; just the process lifecycle, classpath discovery, a vitest fixture for test ergonomics, and a small helper for the recipe-author surface.

Examples

Tests

The intended consumer API uses the vitest fixture from @openrewrite/rewrite/test/java-rpc:

import {describeJavaRpc, testJavaRpc} from "@openrewrite/rewrite/test/java-rpc";
import {RecipeSpec} from "@openrewrite/rewrite/test";
import {text} from "@openrewrite/rewrite/text";

describeJavaRpc("Java recipe via RPC", () => {
    testJavaRpc("FindAndReplace edits a parsed source", async ({javaRpc: _}) => {
        const spec = new RecipeSpec();
        spec.recipe = await prepareJavaRecipe(
            "org.openrewrite.text.FindAndReplace",
            {find: "Hello", replace: "Goodbye"},
        );
        await spec.rewriteRun({
            ...text("Hello, world!", "Goodbye, world!"),
            path: "greeting.txt",
        });
    });
});

testJavaRpc is a test.extend-based vitest API. The javaRpc fixture combines:

  • A worker-scoped JavaRpcTestServer — one JVM per vitest worker, spawned lazily on first use, disposed on worker exit.
  • A test-scoped wrapper that calls RewriteRpc.reset() before every test so each test starts with both Java-side and TS-side state cleared.

describeJavaRpc is a describe that auto-skips when no Java classpath is configured. No boilerplate beforeAll / afterAll per file.

TS recipes delegating to Java

prepareJavaRecipe(id, options?) resolves the active RewriteRpc via the static accessor and routes a PrepareRecipe request to whichever side hosts the Java implementation:

  • in tests, the JVM spawned via the testJavaRpc fixture
  • in production, the Java host that spawned dist/rpc/server.js

Same code, both topologies:

import {Recipe, ExecutionContext, TreeVisitor} from "@openrewrite/rewrite";
import {prepareJavaRecipe} from "@openrewrite/rewrite/rpc";

export class MigrateLegacyApi extends Recipe {
    readonly name = "org.openrewrite.javascript.MigrateLegacyApi";
    readonly displayName = "Migrate legacy API";
    readonly description = "...";

    async editor(): Promise<TreeVisitor<any, ExecutionContext>> {
        const renameType = await prepareJavaRecipe(
            "org.openrewrite.java.ChangeType",
            {oldFullyQualifiedTypeName: "legacy.Api", newFullyQualifiedTypeName: "modern.Api"},
        );
        return await renameType.editor();
    }
}

The RpcRecipe returned by prepareJavaRecipe already extends Recipe, so it composes naturally — as the editor of a check(usesType(...), ...) precondition, as a step inside a recipeList(), or assigned directly to RecipeSpec.recipe.

Top-level functions per Java recipe (downstream pattern)

For Java recipes that are commonly used as building blocks, the natural pattern is to expose a top-level function:

// e.g. src/javascript/recipes/dependencies.ts (to land on #7795)
export interface AddDependencyOptions {
    name: string;
    version: string;
    type?: "dependencies" | "devDependencies" | "peerDependencies" | "optionalDependencies";
}
export function addDependency(options: AddDependencyOptions): Promise<Recipe> {
    return prepareJavaRecipe("org.openrewrite.javascript.AddDependency", options);
}

Consumers compose these as recipes or as recipe-list steps:

export class MigrateToLodashEs extends Recipe {
    async recipeList(): Promise<Recipe[]> {
        return [
            await removeDependency({name: "lodash"}),
            await addDependency({name: "lodash-es", version: "^4.17.21"}),
        ];
    }
}

This PR ships only the prepareJavaRecipe primitive — wrapper functions for specific recipes belong with the recipes they wrap and will land on #7795.

Setup

./gradlew :rewrite-javascript:generateTestClasspath   # explicit
./gradlew :rewrite-javascript:test                    # implicit — npmTest now depends on it

./gradlew :rewrite-javascript:npmTest is wired to depend on generateTestClasspath, mirroring the existing C# setup. Devs running npx vitest directly still need to invoke the Gradle task once (or use REWRITE_JAVASCRIPT_CLASSPATH to override).

Summary

  • New :rewrite-javascript:generateTestClasspath Gradle task writes the runtime + test classpath to rewrite-javascript/rewrite/test-classpath.txt (gitignored). Adds testRuntimeOnly(project(":rewrite-maven")) so JavaRewriteRpc's main class is included.
  • :rewrite-javascript:npmTest now depends on generateTestClasspath, so the standard Gradle test entry point picks up the RPC tests automatically.
  • New rewrite-javascript/rewrite/src/rpc/java-rpc-client.ts:
    • JavaRpcTestServer.start(opts?) spawns java -cp <classpath> org.openrewrite.maven.rpc.JavaRewriteRpc, wraps stdio in a vscode-jsonrpc MessageConnection, and constructs a RewriteRpc against it.
    • reset() delegates to RewriteRpc.reset(); dispose() ends the connection, waits with a grace period before SIGKILL.
    • Forwards Java stderr line-by-line with a [Java RPC] prefix; detects early JVM exit and surfaces it rather than letting the first request hang.
    • findTestClasspath() checks REWRITE_JAVASCRIPT_CLASSPATH then walks for test-classpath.txt.
  • New RewriteRpc.reset() method mirroring Java's pattern: send Reset to the remote and clear local caches. The existing inbound Reset handler still only clears local — no recursion.
  • New rewrite-javascript/rewrite/src/rpc/java-recipe.tsprepareJavaRecipe(id, options?) helper for TS recipes that delegate to a Java recipe. Exported from @openrewrite/rewrite/rpc.
  • New rewrite-javascript/rewrite/src/test/java-rpc.tstestJavaRpc + describeJavaRpc vitest fixture, exposed as the @openrewrite/rewrite/test/java-rpc subpath export. Worker-scoped JVM, per-test reset, auto-skip when no classpath.
  • Smoke test at rewrite-javascript/rewrite/test/rpc/java-recipe-via-rpc.test.ts exercising both the direct (javaRpc.rpc.prepareRecipe) and recipe-author (prepareJavaRecipe) surfaces against org.openrewrite.text.FindAndReplace end-to-end.
  • Short "Java RPC Integration Tests" section in rewrite-javascript/rewrite/CLAUDE.md.

Out of scope: wrapper functions / classes for specific Java recipes (addDependency, changeType, etc.) — those land with the recipes themselves.

Test plan

  • ./gradlew :rewrite-javascript:generateTestClasspath produces rewrite-javascript/rewrite/test-classpath.txt.
  • ./gradlew :rewrite-javascript:npmTest auto-runs generateTestClasspath first.
  • Smoke test passes against a real Java JVM (npx vitest run test/rpc/java-recipe-via-rpc.test.ts).
  • Full test/rpc/ suite still green (43 tests, 1 pre-existing skipped).
  • npm run typecheck is clean.
  • Test skips with a helpful warning when classpath is absent (REWRITE_JAVASCRIPT_CLASSPATH= rm -f test-classpath.txt && npx vitest run test/rpc/java-recipe-via-rpc.test.ts).
  • No orphan java ... JavaRewriteRpc processes after a test run.

Add `JavaRpcTestServer` to let vitest tests drive real Java recipes
through the existing RewriteRpc bidirectional protocol over a spawned
`org.openrewrite.maven.rpc.JavaRewriteRpc` process. Mirrors the Python
and C# client infrastructure.
@github-project-automation github-project-automation Bot moved this to In Progress in OpenRewrite May 28, 2026
@knutwannheden knutwannheden changed the title rewrite-javascript: spawn Java RPC server for TS integration tests Javascript: spawn Java RPC server for TS integration tests May 28, 2026
Adds a vitest fixture (`testJavaRpc`/`describeJavaRpc`) at the new
`@openrewrite/rewrite/test/java-rpc` subpath so downstream tests can
declare the JVM lifecycle without boilerplate. The JVM is worker-scoped
(one process per vitest worker); state is reset before every test.

Mirrors the Java pattern on RewriteRpc itself: `reset()` clears local
caches AND sends `Reset` to the remote, while the inbound `Reset`
handler only clears local — no recursion.

Wires `:rewrite-javascript:npmTest` to depend on `generateTestClasspath`
so `./gradlew :rewrite-javascript:test` picks up the RPC tests
automatically (matches the C# `csharpTest` setup).

Fixes from the simplify pass on the previous commit:
 - removeListener("exit") on the early-exit watchdog so a normal exit
   during dispose() doesn't reject an unobserved promise
 - dispose(): attach the exit listener before signalling shutdown to
   close a TOCTOU race; drop the spurious `child.killed` early-return
 - replace the hand-rolled stderr line-buffer with readline so a final
   non-newline-terminated line gets flushed on process close
 - trim the classpath candidate list to layouts that actually exist
@knutwannheden knutwannheden marked this pull request as draft May 28, 2026 12:12
Adds a top-level `prepareJavaRecipe(id, options?)` that resolves the
active RewriteRpc (via the existing static accessor) and calls
prepareRecipe on it. The returned RpcRecipe already extends Recipe, so
it can be used directly or composed inside a TS recipe.

Designed for two use cases:
 1. A TS recipe that delegates its editor to a Java recipe — works in
    test (with JavaRpcTestServer) and in production (where Java spawned
    the TS server). The active connection is resolved via
    RewriteRpc.get() in both cases.
 2. Top-level convenience functions like:

        export function addDependency(opts: AddDependencyOptions): Promise<Recipe> {
            return prepareJavaRecipe("org.openrewrite.javascript.AddDependency", opts);
        }

    Sit naturally on top of this primitive. Those wrappers themselves
    are not added here — they belong with the recipes they wrap.

No in-process fallback by design: the Java recipe's editor is
authoritative — we don't reimplement editing recipes in TS. For
preconditions that need to degrade gracefully without RPC, the existing
RecipeRef pattern (usesType / usesMethod) carries a localVisitor and
remains the right tool.

The smoke test exercises both surfaces: one test calls
JavaRpcTestServer.rpc.prepareRecipe directly (low-level API); the other
uses prepareJavaRecipe (recipe-author API).
@knutwannheden knutwannheden changed the title Javascript: spawn Java RPC server for TS integration tests JavaScript: spawn Java RPC server for TS integration tests May 28, 2026
@knutwannheden knutwannheden marked this pull request as ready for review May 28, 2026 12:46
@knutwannheden knutwannheden merged commit 2b1b3bf into main May 28, 2026
1 check passed
@knutwannheden knutwannheden deleted the ts-java-rpc-client-for-java-recipes branch May 28, 2026 12:46
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite May 28, 2026
knutwannheden added a commit that referenced this pull request May 28, 2026
… recipes

Adds addDependency / removeDependency / upgradeDependencyVersion /
changeDependency / upgradeTransitiveDependencyVersion as top-level TS
functions that delegate to the Java implementations via prepareJavaRecipe
(#7806). Lets TS recipes compose the Java dependency recipes without
boilerplate.
knutwannheden added a commit that referenced this pull request May 28, 2026
…s overlay (#7795)

* rewrite-javascript: bootstrap Java test sourceset, promote rewrite-yaml to implementation

* rewrite-javascript: add PackageManagerExecutor for npm/yarn/pnpm/bun

* rewrite-javascript: add LockFileRegeneration with NPM/YARN_CLASSIC/YARN_BERRY/PNPM/BUN

* rewrite-javascript: add PackageJsonHelper foundations (lock detection, ctx, reparse)

* rewrite-python, rewrite-javascript: wrap Files.walk/list in try-with-resources

Stream-returning Files.* APIs hold an underlying DirectoryStream that is only
closed when the terminal operation completes normally. If the lambda throws
(or short-circuits, in future code), the stream leaks. Wrap the four
cleanupDirectory / initializeCacheFromDisk sites in try-with-resources to
match the JDK-recommended idiom.

* rewrite-javascript: PackageJsonHelper.refreshMarker rebuilds declared-dep lists

* rewrite-javascript: PackageJsonHelper.addDependency

* rewrite-javascript: fix Space.EMPTY check to use value equality

Use isEmpty() on whitespace rather than == against the singleton, so
makeMember works correctly when called with literals from a parsed
tree (where Space instances may be semantically equal but distinct).

* rewrite-javascript: PackageJsonHelper.removeDependency

* rewrite-javascript: PackageJsonHelper.upgradeVersion + MatchedDependency

* rewrite-javascript: PackageJsonHelper.changeDependency

* rewrite-javascript: scope per-iteration changed flag in upgradeVersion/changeDependency

Avoid spuriously rewriting unchanged scopes when a previous outer-loop
iteration already set the global changed flag. Track per-scope changes
locally and roll up to the global flag only when something actually
changed in that scope.

* rewrite-javascript: PackageJsonHelper.upgradeTransitive + PackageJsonOverrides

* rewrite-javascript: PackageJsonHelper.editAndRegenerate orchestration

* rewrite-javascript: add AddDependency recipe + tests

Also fix PackageJsonHelper.appendMember to handle RPC-parsed JSON trees
where whitespace is stored on the key literal's prefix rather than on
the member node itself (difference between Java JSON parser and
TypeScript PackageJsonParser via RPC).

* rewrite-javascript: add RemoveDependency recipe + tests

* rewrite-javascript: add ChangeDependency recipe + tests

* rewrite-javascript: add UpgradeDependencyVersion recipe + tests

* rewrite-javascript: add UpgradeTransitiveDependencyVersion recipe + tests

* rewrite-javascript: add cross-recipe chain test for AddDependency → UpgradeDependencyVersion

Fix three bugs surfaced by the test:
- UpgradeDependencyVersion: re-scan live tree for matches when the scanner
  found no matches on the original tree (enables cross-recipe chaining)
- PackageJsonHelper.addDependency: handle empty {} scope (Json.Empty placeholder)
  with proper indentation instead of producing {,lodash:...}
- DependencyWorkspace.isWorkspaceValid: don't require node_modules (absent
  for empty-deps packages), preventing FileAlreadyExistsException on reuse

* rewrite-javascript: register dependency recipes in recipes.csv

* rewrite-javascript: remove TS dependency recipes (replaced by Java implementations)

* rewrite-javascript: LockFileParser foundations + top-level parsing

* rewrite-javascript: LockFileParser scoped + nested package tests

* rewrite-javascript: tighten parsesScopedTopLevelPackage with isSameAs

* rewrite-javascript: LockFileParser metadata extraction (transitive deps, engines, license)

* rewrite-javascript: LockFileParser robustness tests (forward compat + error paths)

* rewrite-javascript: BunLockAdapter (bun.lock -> npm v3)

* rewrite-javascript: BunLockAdapter scoped + transitive + JSONC tests

* rewrite-javascript: PackageJsonHelper.overlayResolvedDeps with Dependency relinking

* rewrite-javascript: editAndRegenerate calls overlayResolvedDeps for npm/Bun

* rewrite-javascript: preserve cause chain in lock parse Markup.warn

* rewrite-javascript: TS-Java parity tests for LockFileParser (npm)

* rewrite-javascript: make LockFileParser and BunLockAdapter public for testability

* rewrite-javascript: pin is-even version + use Files.readString in parity test

* rewrite-javascript: end-to-end test for v2.0 resolved-deps overlay

* rewrite-javascript: YarnClassicLockAdapter foundations + happy-path test

* rewrite-javascript: tolerate variable comma spacing in yarn.lock block headers

* rewrite-javascript: YarnClassicLockAdapter multi-key + scoped + transitive deps tests

* rewrite-javascript: YarnClassicLockAdapter multi-version + edge-case tests

* rewrite-javascript: YarnClassicLockAdapter CRLF tolerance test

* rewrite-javascript: add SnakeYAML dep + YarnBerryLockAdapter foundation

* rewrite-javascript: YarnBerryLockAdapter scoped + multi-version + transitive tests

* rewrite-javascript: PnpmLockAdapter foundation + happy-path

* rewrite-javascript: PnpmLockAdapter scoped + transitive + peer-dep tests

* rewrite-javascript: PnpmLockAdapter parsePackageKey corner-case unit tests

* rewrite-javascript: dispatch overlayResolvedDeps to yarn + pnpm adapters

* rewrite-javascript: refresh overlayResolvedDeps Javadoc for v2.1 PMs

* rewrite-javascript: DependencyWorkspace.getOrCreateWorkspace(String, PackageManager)

Add PM-aware overload that keys the cache on hash+"_"+pm.name(), seeds .yarnrc.yml
for yarn-berry (nodeLinker: node-modules), and dispatches to the correct install
command per PM via the new private runInstall() method.  The single-arg overload
is preserved and delegates to Npm.  Rename hash→key in initializeCacheFromDisk
to reflect the broader key shape.

* rewrite-javascript: nodePackageManager() helper + yarnBerry/pnpm wrappers

* rewrite-javascript: deduplicate per-PM lockfile mapping via LockFileRegeneration

* rewrite-javascript: parity test for yarn classic

* rewrite-javascript: normalize FQCN imports in parity helper

* rewrite-javascript: parity test for yarn berry

* rewrite-javascript: parity test for pnpm

* rewrite-javascript: e2e overlay test for yarn berry

* rewrite-javascript: e2e overlay test for pnpm

* rewrite-javascript: yarn berry full-install uses --mode skip-build

Matches LockFileRegeneration.YARN_BERRY's args. The previous shared
case with YarnClassic used --ignore-scripts, which is a yarn classic
flag; yarn berry 4.x uses --mode skip-build for the same intent.

* rewrite-javascript: delegate runInstall to PackageManagerExecutor

Adds 120s timeout, stream draining, and process destroy-on-failure that
the inline ProcessBuilder lacked. Also removes the dead replace('/', '_')
/ replace('+', '-') calls in hashContent: Base64.getUrlEncoder() already
produces URL-safe output.

* rewrite-javascript: symlink .yarnrc.yml for YarnBerry workspaces

Without .yarnrc.yml in the test directory, any tool re-invoking yarn
there defaults to PnP mode and ignores the symlinked node_modules.

* rewrite-javascript: guard cachesPerPackageManagerSeparately on npm

The test exercises both npm and pnpm but previously only skipped on
missing pnpm — on a box without npm it would crash instead of skip.

* rewrite-javascript: dedupe parityNpm helpers via ForPm delegates

* rewrite-javascript: TS convenience wrappers for dependency-management recipes

Adds addDependency / removeDependency / upgradeDependencyVersion /
changeDependency / upgradeTransitiveDependencyVersion as top-level TS
functions that delegate to the Java implementations via prepareJavaRecipe
(#7806). Lets TS recipes compose the Java dependency recipes without
boilerplate.

* rewrite-docker: regenerate recipes.csv for ChangeFrom description drift

* Revert "rewrite-docker: regenerate recipes.csv for ChangeFrom description drift"

This reverts commit 6a41c1e.

* rewrite-javascript: regenerate recipes.csv

* rewrite-javascript: sort recipes.csv alphabetically (#7815)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant