Skip to content

Commit e8276ab

Browse files
Add tests for InstallRecipes and use @latest when no version specified (#6672)
- Add comprehensive test suite for install-recipes.ts covering: - Local file path installation - Error handling (missing activate, missing file, syntax errors) - npm package installation (package.json creation, upgrades) - Change package spec to use @latest when no version is specified, ensuring npm always installs the latest version for upgrades Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cd0a6ac commit e8276ab

File tree

2 files changed

+291
-1
lines changed

2 files changed

+291
-1
lines changed

rewrite-javascript/rewrite/src/rpc/request/install-recipes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export class InstallRecipes {
111111

112112
// Rather than using npm on PATH, use `node_cli.js`.
113113
// https://stackoverflow.com/questions/15957529/can-i-install-a-npm-package-from-javascript-running-in-node-js
114-
const packageSpec = recipePackage.packageName + (recipePackage.version ? `@${recipePackage.version}` : "");
114+
const packageSpec = recipePackage.packageName + (recipePackage.version ? `@${recipePackage.version}` : "@latest");
115115
await spawnNpmCommand("npm", ["install", packageSpec, "--no-fund"], absoluteInstallDir, logger);
116116
resolvedPath = require.resolve(path.join(absoluteInstallDir, "node_modules", recipePackage.packageName));
117117
recipesName = request.recipes.packageName;
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://docs.moderne.io/licensing/moderne-source-available-license
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import {withDir} from "tmp-promise";
17+
import * as fs from "fs";
18+
import * as path from "path";
19+
import {RecipeMarketplace} from "../../src";
20+
import {InstallRecipes, InstallRecipesResponse} from "../../src/rpc/request/install-recipes";
21+
22+
describe("InstallRecipes", () => {
23+
24+
type RequestHandler = (request: InstallRecipes) => Promise<InstallRecipesResponse>;
25+
26+
function captureHandler(installDir: string, marketplace: RecipeMarketplace): RequestHandler {
27+
let capturedHandler: RequestHandler | undefined;
28+
29+
const dummyConnection = {
30+
onRequest: (_requestType: any, handler: RequestHandler) => {
31+
capturedHandler = handler;
32+
}
33+
} as any;
34+
35+
InstallRecipes.handle(dummyConnection, installDir, marketplace);
36+
37+
if (!capturedHandler) {
38+
throw new Error("Handler was not registered");
39+
}
40+
41+
return capturedHandler;
42+
}
43+
44+
describe("local file path installation", () => {
45+
46+
test("installs recipe module from file path", async () => {
47+
await withDir(async (dir) => {
48+
// given
49+
const recipeModulePath = path.join(dir.path, "test-recipe.js");
50+
fs.writeFileSync(recipeModulePath, `
51+
module.exports = {
52+
activate: async function(marketplace) {
53+
await marketplace.install(
54+
class TestRecipe {
55+
async descriptor() {
56+
return {
57+
name: "test.recipe",
58+
displayName: "Test Recipe",
59+
description: "A test recipe"
60+
};
61+
}
62+
},
63+
[{displayName: "Test"}]
64+
);
65+
}
66+
};
67+
`);
68+
69+
const installDir = path.join(dir.path, "recipes");
70+
const marketplace = new RecipeMarketplace();
71+
const handler = captureHandler(installDir, marketplace);
72+
73+
// when
74+
const response = await handler({recipes: recipeModulePath} as any);
75+
76+
// then
77+
expect(response.recipesInstalled).toBe(1);
78+
expect(marketplace.allRecipes().length).toBe(1);
79+
expect(marketplace.allRecipes()[0].name).toBe("test.recipe");
80+
expect(marketplace.allRecipes()[0].options).toBeUndefined();
81+
}, {unsafeCleanup: true});
82+
});
83+
84+
test("does not return version for file path installation", async () => {
85+
await withDir(async (dir) => {
86+
// given
87+
const recipeModulePath = path.join(dir.path, "simple-recipe.js");
88+
fs.writeFileSync(recipeModulePath, `
89+
module.exports = {
90+
activate: function(marketplace) {}
91+
};
92+
`);
93+
94+
const installDir = path.join(dir.path, "recipes");
95+
const marketplace = new RecipeMarketplace();
96+
const handler = captureHandler(installDir, marketplace);
97+
98+
// when
99+
const response = await handler({recipes: recipeModulePath} as any);
100+
101+
// then
102+
expect(response.version).toBeUndefined();
103+
}, {unsafeCleanup: true});
104+
});
105+
106+
test("returns correct count for multiple recipes", async () => {
107+
await withDir(async (dir) => {
108+
// given
109+
const recipeModulePath = path.join(dir.path, "multi-recipe.js");
110+
fs.writeFileSync(recipeModulePath, `
111+
module.exports = {
112+
activate: async function(marketplace) {
113+
await marketplace.install(
114+
class Recipe1 {
115+
async descriptor() {
116+
return { name: "multi.recipe1", displayName: "Recipe 1", description: "" };
117+
}
118+
},
119+
[{displayName: "Multi"}]
120+
);
121+
await marketplace.install(
122+
class Recipe2 {
123+
async descriptor() {
124+
return { name: "multi.recipe2", displayName: "Recipe 2", description: "" };
125+
}
126+
},
127+
[{displayName: "Multi"}]
128+
);
129+
await marketplace.install(
130+
class Recipe3 {
131+
async descriptor() {
132+
return { name: "multi.recipe3", displayName: "Recipe 3", description: "" };
133+
}
134+
},
135+
[{displayName: "Multi"}]
136+
);
137+
}
138+
};
139+
`);
140+
141+
const installDir = path.join(dir.path, "recipes");
142+
const marketplace = new RecipeMarketplace();
143+
const handler = captureHandler(installDir, marketplace);
144+
145+
// when
146+
const response = await handler({recipes: recipeModulePath} as any);
147+
148+
// then
149+
expect(response.recipesInstalled).toBe(3);
150+
expect(marketplace.allRecipes().length).toBe(3);
151+
}, {unsafeCleanup: true});
152+
});
153+
});
154+
155+
describe("error handling", () => {
156+
157+
test("throws error when module does not export activate function", async () => {
158+
await withDir(async (dir) => {
159+
// given
160+
const recipeModulePath = path.join(dir.path, "bad-recipe.js");
161+
fs.writeFileSync(recipeModulePath, `
162+
module.exports = {
163+
notActivate: function() {}
164+
};
165+
`);
166+
167+
const installDir = path.join(dir.path, "recipes");
168+
const marketplace = new RecipeMarketplace();
169+
const handler = captureHandler(installDir, marketplace);
170+
171+
// when/then
172+
await expect(handler({recipes: recipeModulePath} as any))
173+
.rejects.toThrow("does not export an 'activate' function");
174+
}, {unsafeCleanup: true});
175+
});
176+
177+
test("throws error when module file does not exist", async () => {
178+
await withDir(async (dir) => {
179+
// given
180+
const nonExistentPath = path.join(dir.path, "nonexistent.js");
181+
const installDir = path.join(dir.path, "recipes");
182+
const marketplace = new RecipeMarketplace();
183+
const handler = captureHandler(installDir, marketplace);
184+
185+
// when/then
186+
await expect(handler({recipes: nonExistentPath} as any))
187+
.rejects.toThrow(/Failed to load recipe module/);
188+
}, {unsafeCleanup: true});
189+
});
190+
191+
test("throws error when module has syntax error", async () => {
192+
await withDir(async (dir) => {
193+
// given
194+
const recipeModulePath = path.join(dir.path, "syntax-error.js");
195+
fs.writeFileSync(recipeModulePath, `
196+
module.exports = {
197+
activate: function( { // syntax error - missing closing paren
198+
`);
199+
200+
const installDir = path.join(dir.path, "recipes");
201+
const marketplace = new RecipeMarketplace();
202+
const handler = captureHandler(installDir, marketplace);
203+
204+
// when/then
205+
await expect(handler({recipes: recipeModulePath} as any))
206+
.rejects.toThrow(/Failed to load recipe module/);
207+
}, {unsafeCleanup: true});
208+
});
209+
});
210+
211+
describe("npm package installation", () => {
212+
213+
test("creates package.json when installing npm package", async () => {
214+
await withDir(async (dir) => {
215+
// given
216+
const installDir = path.join(dir.path, "recipes");
217+
const packageJsonPath = path.join(installDir, "package.json");
218+
const marketplace = new RecipeMarketplace();
219+
const handler = captureHandler(installDir, marketplace);
220+
221+
// when
222+
try {
223+
await handler({recipes: {packageName: "nonexistent-pkg"}} as any);
224+
} catch {
225+
// Expected to fail - package doesn't exist on npm
226+
}
227+
228+
// then - package.json should be created before npm install runs
229+
expect(fs.existsSync(packageJsonPath)).toBe(true);
230+
const createdPackageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
231+
expect(createdPackageJson.name).toBe("openrewrite-recipes");
232+
expect(createdPackageJson.private).toBe(true);
233+
}, {unsafeCleanup: true});
234+
}, 60000);
235+
236+
test("installs @openrewrite/recipes-nodejs from npm", async () => {
237+
await withDir(async (dir) => {
238+
// given
239+
const installDir = path.join(dir.path, "recipes");
240+
const marketplace = new RecipeMarketplace();
241+
const handler = captureHandler(installDir, marketplace);
242+
243+
// when
244+
const response = await handler({recipes: {packageName: "@openrewrite/recipes-nodejs"}} as any);
245+
246+
// then
247+
expect(response.recipesInstalled).toBeGreaterThan(0);
248+
expect(response.version).toBeDefined();
249+
expect(marketplace.allRecipes().length).toBeGreaterThan(0);
250+
251+
const packageJsonPath = path.join(installDir, "package.json");
252+
expect(fs.existsSync(packageJsonPath)).toBe(true);
253+
254+
const nodeModulesPath = path.join(installDir, "node_modules", "@openrewrite", "recipes-nodejs");
255+
expect(fs.existsSync(nodeModulesPath)).toBe(true);
256+
}, {unsafeCleanup: true});
257+
}, 120000);
258+
259+
test("upgrades @openrewrite/recipes-nodejs from 0.36.0 to a later version", async () => {
260+
await withDir(async (dir) => {
261+
// given
262+
const installDir = path.join(dir.path, "recipes");
263+
fs.mkdirSync(installDir, {recursive: true});
264+
const packageJsonPath = path.join(installDir, "package.json");
265+
fs.writeFileSync(packageJsonPath, JSON.stringify({
266+
name: "openrewrite-recipes",
267+
version: "1.0.0",
268+
private: true,
269+
dependencies: {
270+
"@openrewrite/recipes-nodejs": "0.36.0"
271+
}
272+
}, null, 2));
273+
274+
const marketplace = new RecipeMarketplace();
275+
const handler = captureHandler(installDir, marketplace);
276+
277+
// when
278+
await handler({recipes: {packageName: "@openrewrite/recipes-nodejs"}} as any);
279+
280+
// then
281+
const updatedPackageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
282+
const versionStr = updatedPackageJson.dependencies["@openrewrite/recipes-nodejs"];
283+
const match = versionStr.match(/(\d+)\.(\d+)\.(\d+)/);
284+
expect(match).not.toBeNull();
285+
const minorVersion = parseInt(match![2], 10);
286+
expect(minorVersion).toBeGreaterThan(36);
287+
}, {unsafeCleanup: true});
288+
}, 120000);
289+
});
290+
});

0 commit comments

Comments
 (0)