diff --git a/rewrite-javascript/src/integTest/java/org/openrewrite/javascript/DependencyWorkspaceTest.java b/rewrite-javascript/src/integTest/java/org/openrewrite/javascript/DependencyWorkspaceTest.java new file mode 100644 index 0000000000..7000a88b15 --- /dev/null +++ b/rewrite-javascript/src/integTest/java/org/openrewrite/javascript/DependencyWorkspaceTest.java @@ -0,0 +1,207 @@ +/* + * Copyright 2025 the original author or authors. + *
+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *
+ * https://docs.moderne.io/licensing/moderne-source-available-license + *
+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.javascript; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openrewrite.Recipe; +import org.openrewrite.test.RewriteTest; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class DependencyWorkspaceTest implements RewriteTest { + + @BeforeEach + void setUp() { + DependencyWorkspace.clearCache(); + } + + @Test + void cachesSamePackageJson() { + String packageJson = """ + { + "name": "test", + "dependencies": { + "lodash": "^4.17.21" + } + } + """; + + Path workspace1 = DependencyWorkspace.getOrCreateWorkspace(packageJson); + Path workspace2 = DependencyWorkspace.getOrCreateWorkspace(packageJson); + + // Should return the same cached workspace + assertThat(workspace1).isEqualTo(workspace2); + assertThat(Files.exists(workspace1.resolve("node_modules"))).isTrue(); + assertThat(Files.exists(workspace1.resolve("package.json"))).isTrue(); + } + + @Test + void createsDifferentWorkspacesForDifferentPackageJson() { + String packageJson1 = """ + { + "name": "test1", + "dependencies": { + "lodash": "^4.17.21" + } + } + """; + + String packageJson2 = """ + { + "name": "test2", + "dependencies": { + "axios": "^1.0.0" + } + } + """; + + Path workspace1 = DependencyWorkspace.getOrCreateWorkspace(packageJson1); + Path workspace2 = DependencyWorkspace.getOrCreateWorkspace(packageJson2); + + // Should create different workspaces + assertThat(workspace1).isNotEqualTo(workspace2); + assertThat(Files.exists(workspace1.resolve("node_modules"))).isTrue(); + assertThat(Files.exists(workspace2.resolve("node_modules"))).isTrue(); + } + + @Test + void reusesWorkspaceAfterCacheClear() { + String packageJson = """ + { + "name": "test", + "dependencies": { + "lodash": "^4.17.21" + } + } + """; + + Path workspace1 = DependencyWorkspace.getOrCreateWorkspace(packageJson); + + // Clear in-memory cache + DependencyWorkspace.clearCache(); + + // Should still reuse the workspace from disk + Path workspace2 = DependencyWorkspace.getOrCreateWorkspace(packageJson); + + assertThat(workspace1).isEqualTo(workspace2); + assertThat(Files.exists(workspace2.resolve("node_modules"))).isTrue(); + } + + @Test + void npmIntegrationWithSymlink(@TempDir Path tempDir) throws Exception { + rewriteRun( + spec -> spec.recipe(new NoOpRecipe()), + Assertions.npm(tempDir, + Assertions.packageJson(""" + { + "name": "test-project", + "dependencies": { + "lodash": "^4.17.21" + } + } + """), + Assertions.javascript(""" + import _ from 'lodash'; + console.log(_.VERSION); + """) + ) + ); + + // Verify symlink was created + Path nodeModules = tempDir.resolve("node_modules"); + assertThat(Files.isSymbolicLink(nodeModules)).isTrue(); + assertThat(Files.exists(nodeModules)).isTrue(); + assertThat(Files.exists(nodeModules.resolve("lodash"))).isTrue(); + } + + @Test + void lruCacheEvictsOldEntries() { + // This test verifies LRU eviction, but we can't easily test with MAX_CACHE_SIZE (100) + // entries because npm install is slow. Instead, we verify the cache mechanism works + // by checking that recently used entries are retained. + + String packageJson1 = """ + { + "name": "test1", + "dependencies": { + "lodash": "^4.17.21" + } + } + """; + + String packageJson2 = """ + { + "name": "test2", + "dependencies": { + "axios": "^1.0.0" + } + } + """; + + // Create two workspaces + Path workspace1 = DependencyWorkspace.getOrCreateWorkspace(packageJson1); + Path workspace2 = DependencyWorkspace.getOrCreateWorkspace(packageJson2); + + // Access workspace1 again (should move it to most recently used) + Path workspace1Again = DependencyWorkspace.getOrCreateWorkspace(packageJson1); + + // Both should still be cached (we're well under MAX_CACHE_SIZE) + assertThat(workspace1).isEqualTo(workspace1Again); + assertThat(Files.exists(workspace1)).isTrue(); + assertThat(Files.exists(workspace2)).isTrue(); + } + + @Test + void initializesFromDiskOnStartup() { + String packageJson = """ + { + "name": "test-init", + "dependencies": { + "lodash": "^4.17.21" + } + } + """; + + // Create a workspace + Path workspace = DependencyWorkspace.getOrCreateWorkspace(packageJson); + assertThat(Files.exists(workspace.resolve("node_modules"))).isTrue(); + + // Clear the in-memory cache (simulating a JVM restart) + DependencyWorkspace.clearCache(); + + // The workspace should still be reused from disk + Path workspaceAfterClear = DependencyWorkspace.getOrCreateWorkspace(packageJson); + assertThat(workspaceAfterClear).isEqualTo(workspace); + assertThat(Files.exists(workspaceAfterClear.resolve("node_modules"))).isTrue(); + } + + private static class NoOpRecipe extends Recipe { + @Override + public String getDisplayName() { + return "No-op recipe"; + } + + @Override + public String getDescription() { + return "Does nothing, used for testing."; + } + } +} diff --git a/rewrite-javascript/src/main/java/org/openrewrite/javascript/Assertions.java b/rewrite-javascript/src/main/java/org/openrewrite/javascript/Assertions.java index 2d4944ea75..582e34a320 100644 --- a/rewrite-javascript/src/main/java/org/openrewrite/javascript/Assertions.java +++ b/rewrite-javascript/src/main/java/org/openrewrite/javascript/Assertions.java @@ -39,61 +39,38 @@ private Assertions() { } public static SourceSpecs npm(Path relativeTo, SourceSpecs... sources) { - // Second pass: run npm install if needed - boolean alreadyInstalled = false; + String packageJsonContent = null; - // First pass: write package.json files + // First pass: find package.json content and write it to relativeTo for (SourceSpecs multiSpec : sources) { if (multiSpec instanceof SourceSpec) { SourceSpec> spec = (SourceSpec>) multiSpec; Path sourcePath = spec.getSourcePath(); if (sourcePath != null && "package.json".equals(sourcePath.toFile().getName())) { + packageJsonContent = spec.getBefore(); try { Path packageJson = relativeTo.resolve(sourcePath); - if (Files.exists(packageJson)) { - // If relativeTo is a non-transient directory we can optimize not having - // to do npm install if the package.json hasn't changed. - if (new String(Files.readAllBytes(packageJson), StandardCharsets.UTF_8).equals(spec.getBefore())) { - alreadyInstalled = true; - continue; - } - } - Files.write(packageJson, requireNonNull(spec.getBefore()).getBytes(StandardCharsets.UTF_8)); + Files.write(packageJson, requireNonNull(packageJsonContent).getBytes(StandardCharsets.UTF_8)); } catch (IOException e) { throw new UncheckedIOException(e); } + break; } } } - for (SourceSpecs multiSpec : sources) { - if (multiSpec instanceof SourceSpec) { - SourceSpec> spec = (SourceSpec>) multiSpec; - if (!alreadyInstalled && spec.getParser() instanceof JavaScriptParser.Builder) { - // Execute npm install to ensure dependencies are available - // First check if package.json exists - Path packageJsonPath = relativeTo.resolve("package.json"); - if (!Files.exists(packageJsonPath)) { - // Skip npm install if no package.json exists - alreadyInstalled = true; - continue; - } - - try { - ProcessBuilder pb = new ProcessBuilder("npm", "install"); - pb.directory(relativeTo.toFile()); - pb.inheritIO(); - Process process = pb.start(); - int exitCode = process.waitFor(); - if (exitCode != 0) { - throw new RuntimeException("npm install failed with exit code: " + exitCode + " in directory: " + relativeTo.toFile().getAbsolutePath()); - } - } catch (IOException | InterruptedException e) { - throw new RuntimeException("Failed to run npm install in directory: " + relativeTo.toFile().getAbsolutePath(), e); - } + // Second pass: get or create cached workspace and symlink node_modules + if (packageJsonContent != null) { + Path workspaceDir = DependencyWorkspace.getOrCreateWorkspace(packageJsonContent); + Path nodeModulesSource = workspaceDir.resolve("node_modules"); + Path nodeModulesTarget = relativeTo.resolve("node_modules"); - alreadyInstalled = true; + try { + if (Files.exists(nodeModulesSource) && !Files.exists(nodeModulesTarget)) { + Files.createSymbolicLink(nodeModulesTarget, nodeModulesSource); } + } catch (IOException e) { + throw new UncheckedIOException("Failed to create symlink for node_modules", e); } } diff --git a/rewrite-javascript/src/main/java/org/openrewrite/javascript/DependencyWorkspace.java b/rewrite-javascript/src/main/java/org/openrewrite/javascript/DependencyWorkspace.java new file mode 100644 index 0000000000..77ccf54832 --- /dev/null +++ b/rewrite-javascript/src/main/java/org/openrewrite/javascript/DependencyWorkspace.java @@ -0,0 +1,229 @@ +/* + * Copyright 2025 the original author or authors. + *
+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *
+ * https://docs.moderne.io/licensing/moderne-source-available-license + *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.javascript;
+
+import lombok.experimental.UtilityClass;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static java.util.Collections.synchronizedMap;
+
+/**
+ * Manages workspace directories for JavaScript/TypeScript compilation with dependencies.
+ * Creates cached workspaces with package.json and installed node_modules to enable
+ * proper type attribution and dependency resolution.
+ */
+@UtilityClass
+class DependencyWorkspace {
+ private static final Path WORKSPACE_BASE = Paths.get(
+ System.getProperty("java.io.tmpdir"),
+ "openrewrite-js-workspaces"
+ );
+ private static final int MAX_CACHE_SIZE = 100;
+ private static final Map