Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/*
* Copyright 2025 the original author or authors.
* <p>
* 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
* <p>
* https://docs.moderne.io/licensing/moderne-source-available-license
* <p>
* 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.";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Loading