11import { mkdir, symlink } from "node:fs/promises";
22import { basename, join } from "node:path";
3- import { describe, expect, it, vi } from "vitest";
3+ import { afterEach, describe, expect, it, vi } from "vitest";
44import { detectProject } from "./detect.js";
55import { mapFeatures } from "./mapper.js";
66import * as projectsModule from "./mappers/projects.js";
77import { discoverNodeProjects, scriptCommand } from "./mappers/projects.js";
8+ import * as turboModule from "./mappers/turbo.js";
89import { turboTaskGraph } from "./mappers/turbo.js";
910import { fixtureRoot, writeFixture } from "./test-helpers.js";
1011
1112const symlinkIt = process.platform === "win32" ? it.skip : it;
1213
14+ afterEach(() => {
15+ vi.restoreAllMocks();
16+ });
17+
1318describe("mapFeatures", () => {
1419 it("quotes dynamic Node validation command parts", () => {
1520 expect(scriptCommand("pnpm", "packages/app; touch INJECTED", "test")).toBe(
@@ -1121,13 +1126,17 @@ describe("mapFeatures", () => {
11211126 );
11221127 await writeFixture(root, "apps/web/src/pages/HomePage.test.tsx", "test('home', () => {});\n");
11231128
1129+ const projectsSpy = vi.spyOn(projectsModule, "discoverNodeProjects");
1130+ const taskGraphSpy = vi.spyOn(turboModule, "turboTaskGraph");
11241131 const project = await detectProject(root);
11251132 const result = await mapFeatures(root, project, []);
11261133 const route = result.features.find((feature) => feature.title === "React route /home");
11271134
11281135 expect(route?.tests).toEqual([
11291136 { path: "apps/web/src/pages/HomePage.test.tsx", command: "pnpm turbo run test --filter web" },
11301137 ]);
1138+ expect(projectsSpy).toHaveBeenCalledTimes(1);
1139+ expect(taskGraphSpy).toHaveBeenCalledTimes(1);
11311140 });
11321141
11331142 it("suppresses fallback validation commands for persistent Turbo tasks", async () => {
@@ -12924,19 +12933,19 @@ exclude = ["packages/legacy"]
1292412933 root,
1292512934 "routes/web.php",
1292612935 "<?php\n" +
12927- "use App\\Http\\Controllers\\LandingPageController;\n" +
12928- "use App\\Http\\Controllers\\TrackController;\n" +
12929- "Route::get('/', LandingPageController::class);\n" +
12930- "Route::post('/tracks', [TrackController::class, 'store']);\n" +
12931- "Route::resource('catalog', TrackController::class);\n",
12936+ "use App\\Http\\Controllers\\LandingPageController;\n" +
12937+ "use App\\Http\\Controllers\\TrackController;\n" +
12938+ "Route::get('/', LandingPageController::class);\n" +
12939+ "Route::post('/tracks', [TrackController::class, 'store']);\n" +
12940+ "Route::resource('catalog', TrackController::class);\n",
1293212941 );
1293312942 await writeFixture(
1293412943 root,
1293512944 "app/Http/Controllers/TrackController.php",
1293612945 "<?php\nnamespace App\\Http\\Controllers;\n" +
12937- "use App\\Http\\Requests\\StoreTrackRequest;\n" +
12938- "use App\\Services\\TrackUploadService;\n" +
12939- "final class TrackController {}\n",
12946+ "use App\\Http\\Requests\\StoreTrackRequest;\n" +
12947+ "use App\\Services\\TrackUploadService;\n" +
12948+ "final class TrackController {}\n",
1294012949 );
1294112950 await writeFixture(
1294212951 root,
@@ -13056,15 +13065,15 @@ exclude = ["packages/legacy"]
1305613065 root,
1305713066 "routes/admin.php",
1305813067 "<?php\n" +
13059- "use App\\Http\\Controllers\\Admin\\{UserController};\n" +
13060- "Route::prefix('admin')->middleware('auth')->get('/users', UserController::class);\n",
13068+ "use App\\Http\\Controllers\\Admin\\{UserController};\n" +
13069+ "Route::prefix('admin')->middleware('auth')->get('/users', UserController::class);\n",
1306113070 );
1306213071 await writeFixture(
1306313072 root,
1306413073 "routes/api.php",
1306513074 "<?php\n" +
13066- "use App\\Http\\Controllers\\Api\\UserController;\n" +
13067- "Route::get('/users', UserController::class);\n",
13075+ "use App\\Http\\Controllers\\Api\\UserController;\n" +
13076+ "Route::get('/users', UserController::class);\n",
1306813077 );
1306913078 await writeFixture(
1307013079 root,
@@ -13127,8 +13136,8 @@ exclude = ["packages/legacy"]
1312713136 root,
1312813137 "routes/web.php",
1312913138 "<?php\n" +
13130- "Route::get('/qualified', \\App\\Http\\Controllers\\QualifiedController::class);\n" +
13131- "Route::post('/qualified-array', [App\\Http\\Controllers\\ArrayQualifiedController::class, 'store']);\n",
13139+ "Route::get('/qualified', \\App\\Http\\Controllers\\QualifiedController::class);\n" +
13140+ "Route::post('/qualified-array', [App\\Http\\Controllers\\ArrayQualifiedController::class, 'store']);\n",
1313213141 );
1313313142 await writeFixture(
1313413143 root,
@@ -13184,10 +13193,10 @@ exclude = ["packages/legacy"]
1318413193 root,
1318513194 "routes/web.php",
1318613195 "<?php\n" +
13187- "use App\\Http\\Controllers\\Admin\\UserController as AdminUserController;\n" +
13188- "use App\\Http\\Controllers\\Api\\UserController as ApiUserController;\n" +
13189- "Route::get('/admin/users', AdminUserController::class);\n" +
13190- "Route::get('/api/users', ApiUserController::class);\n",
13196+ "use App\\Http\\Controllers\\Admin\\UserController as AdminUserController;\n" +
13197+ "use App\\Http\\Controllers\\Api\\UserController as ApiUserController;\n" +
13198+ "Route::get('/admin/users', AdminUserController::class);\n" +
13199+ "Route::get('/api/users', ApiUserController::class);\n",
1319113200 );
1319213201 await writeFixture(
1319313202 root,
@@ -13234,8 +13243,8 @@ exclude = ["packages/legacy"]
1323413243 root,
1323513244 "routes/web.php",
1323613245 "<?php\n" +
13237- "use App\\Http\\Controllers\\Api;\n" +
13238- "Route::get('/api/users', Api\\UserController::class);\n",
13246+ "use App\\Http\\Controllers\\Api;\n" +
13247+ "Route::get('/api/users', Api\\UserController::class);\n",
1323913248 );
1324013249 await writeFixture(
1324113250 root,
@@ -13277,8 +13286,8 @@ exclude = ["packages/legacy"]
1327713286 root,
1327813287 "routes/web.php",
1327913288 "<?php\n" +
13280- "use App\\Http\\Controllers\\DashboardController;\n" +
13281- "Route::prefix('{tenant}')->get('/dashboard', DashboardController::class);\n",
13289+ "use App\\Http\\Controllers\\DashboardController;\n" +
13290+ "Route::prefix('{tenant}')->get('/dashboard', DashboardController::class);\n",
1328213291 );
1328313292 await writeFixture(
1328413293 root,
@@ -13316,10 +13325,10 @@ exclude = ["packages/legacy"]
1331613325 root,
1331713326 "routes/web.php",
1331813327 "<?php\n" +
13319- "use App\\Http\\Controllers\\UserController;\n" +
13320- 'Route::group(["prefix" => "admin"], function () {\n' +
13321- ' Route::get("/users", UserController::class);\n' +
13322- "});\n",
13328+ "use App\\Http\\Controllers\\UserController;\n" +
13329+ 'Route::group(["prefix" => "admin"], function () {\n' +
13330+ ' Route::get("/users", UserController::class);\n' +
13331+ "});\n",
1332313332 );
1332413333 await writeFixture(
1332513334 root,
@@ -13362,12 +13371,12 @@ exclude = ["packages/legacy"]
1336213371 root,
1336313372 "routes/web.php",
1336413373 "<?php\n" +
13365- "use App\\Http\\Controllers\\UserController;\n" +
13366- "Route::group(['prefix' => 'admin'], function () {\n" +
13367- " Route::controller(UserController::class)->group(function () {\n" +
13368- " Route::get('/users', 'index');\n" +
13369- " });\n" +
13370- "});\n",
13374+ "use App\\Http\\Controllers\\UserController;\n" +
13375+ "Route::group(['prefix' => 'admin'], function () {\n" +
13376+ " Route::controller(UserController::class)->group(function () {\n" +
13377+ " Route::get('/users', 'index');\n" +
13378+ " });\n" +
13379+ "});\n",
1337113380 );
1337213381 await writeFixture(
1337313382 root,
@@ -13406,12 +13415,12 @@ exclude = ["packages/legacy"]
1340613415 root,
1340713416 "routes/web.php",
1340813417 "<?php\n" +
13409- "use App\\Http\\Controllers\\UserController;\n" +
13410- "Route::group(['middleware' => 'auth'], function () {\n" +
13411- " Route::group(['prefix' => 'admin'], function () {\n" +
13412- " Route::get('/users', UserController::class);\n" +
13413- " });\n" +
13414- "});\n",
13418+ "use App\\Http\\Controllers\\UserController;\n" +
13419+ "Route::group(['middleware' => 'auth'], function () {\n" +
13420+ " Route::group(['prefix' => 'admin'], function () {\n" +
13421+ " Route::get('/users', UserController::class);\n" +
13422+ " });\n" +
13423+ "});\n",
1341513424 );
1341613425 await writeFixture(
1341713426 root,
@@ -13450,11 +13459,11 @@ exclude = ["packages/legacy"]
1345013459 root,
1345113460 "routes/web.php",
1345213461 "<?php\n" +
13453- "use App\\Http\\Controllers\\UserController;\n" +
13454- "Route::prefix('admin')->controller(UserController::class)->group(function () {\n" +
13455- " Route::get('/users', 'index');\n" +
13456- " Route::post('/users', 'store');\n" +
13457- "});\n",
13462+ "use App\\Http\\Controllers\\UserController;\n" +
13463+ "Route::prefix('admin')->controller(UserController::class)->group(function () {\n" +
13464+ " Route::get('/users', 'index');\n" +
13465+ " Route::post('/users', 'store');\n" +
13466+ "});\n",
1345813467 );
1345913468 await writeFixture(
1346013469 root,
@@ -13503,9 +13512,9 @@ exclude = ["packages/legacy"]
1350313512 root,
1350413513 "routes/web.php",
1350513514 "<?php\n" +
13506- "use App\\Http\\Controllers\\TrackController;\n" +
13507- "Route::get('/tracks', TrackController::class);\n" +
13508- "Route::post('/tracks', [TrackController::class, 'store']);\n",
13515+ "use App\\Http\\Controllers\\TrackController;\n" +
13516+ "Route::get('/tracks', TrackController::class);\n" +
13517+ "Route::post('/tracks', [TrackController::class, 'store']);\n",
1350913518 );
1351013519
1351113520 const project = await detectProject(root);
@@ -13517,10 +13526,10 @@ exclude = ["packages/legacy"]
1351713526 root,
1351813527 "routes/web.php",
1351913528 "<?php\n" +
13520- "use App\\Http\\Controllers\\TrackController;\n" +
13521- "Route::get('/catalog/tracks', TrackController::class);\n" +
13522- "Route::get('/tracks', TrackController::class);\n" +
13523- "Route::post('/tracks', [TrackController::class, 'store']);\n",
13529+ "use App\\Http\\Controllers\\TrackController;\n" +
13530+ "Route::get('/catalog/tracks', TrackController::class);\n" +
13531+ "Route::get('/tracks', TrackController::class);\n" +
13532+ "Route::post('/tracks', [TrackController::class, 'store']);\n",
1352413533 );
1352513534
1352613535 const second = await mapFeatures(root, project, []);
@@ -13554,10 +13563,10 @@ exclude = ["packages/legacy"]
1355413563 root,
1355513564 "routes/web.php",
1355613565 "<?php\n" +
13557- "use App\\Http\\Controllers\\ArchiveController;\n" +
13558- "// Route::get('/old', ArchiveController::class);\n" +
13559- "/*\nRoute::get('/blocked', ArchiveController::class);\n*/\n" +
13560- "Route::get('/current', ArchiveController::class);\n",
13566+ "use App\\Http\\Controllers\\ArchiveController;\n" +
13567+ "// Route::get('/old', ArchiveController::class);\n" +
13568+ "/*\nRoute::get('/blocked', ArchiveController::class);\n*/\n" +
13569+ "Route::get('/current', ArchiveController::class);\n",
1356113570 );
1356213571 await writeFixture(
1356313572 root,
@@ -13599,9 +13608,9 @@ exclude = ["packages/legacy"]
1359913608 root,
1360013609 "app/Console/Commands/SyncCatalog.php",
1360113610 "<?php\nnamespace App\\Console\\Commands;\n" +
13602- "// protected $signature = 'app:old-sync';\n" +
13603- "/* #[Signature('app:blocked-sync')] */\n" +
13604- "final class SyncCatalog { protected $signature = 'app:sync-catalog {--force}'; }\n",
13611+ "// protected $signature = 'app:old-sync';\n" +
13612+ "/* #[Signature('app:blocked-sync')] */\n" +
13613+ "final class SyncCatalog { protected $signature = 'app:sync-catalog {--force}'; }\n",
1360513614 );
1360613615
1360713616 const project = await detectProject(root);
@@ -17191,16 +17200,81 @@ EndProject
1719117200 expect(ownedFiles).not.toContain("testdata/Example/Example.cs");
1719217201 });
1719317202
17194- it("skips Node.js project discovery I/O for pure Go projects", async () => {
17195- const root = await fixtureRoot("clawpatch-map-node-coupling-");
17203+ it.each([
17204+ {
17205+ name: "Go",
17206+ manifest: ["go.mod", "module example.com/go-app\n"],
17207+ source: ["main.go", "package main\nfunc main() {}\n"],
17208+ },
17209+ {
17210+ name: "Python",
17211+ manifest: ["pyproject.toml", "[project]\nname = 'python-app'\nversion = '1.0.0'\n"],
17212+ source: ["src/app.py", "def main(): pass\n"],
17213+ },
17214+ ] as const)(
17215+ "skips Node project and Turbo I/O for pure $name projects",
17216+ async ({ manifest, source }) => {
17217+ const root = await fixtureRoot("clawpatch-map-node-coupling-");
17218+ await writeFixture(root, manifest[0], manifest[1]);
17219+ await writeFixture(root, source[0], source[1]);
17220+ const projectsSpy = vi.spyOn(projectsModule, "discoverNodeProjects");
17221+ const taskGraphSpy = vi.spyOn(turboModule, "turboTaskGraph");
17222+
17223+ const project = await detectProject(root);
17224+ await mapFeatures(root, project, []);
17225+
17226+ expect(projectsSpy).not.toHaveBeenCalled();
17227+ expect(taskGraphSpy).not.toHaveBeenCalled();
17228+ },
17229+ );
17230+
17231+ it("does not block non-Node mappers on the fallback Node signal", async () => {
17232+ const root = await fixtureRoot("clawpatch-map-node-signal-");
1719617233 await writeFixture(root, "go.mod", "module example.com/go-app\n");
1719717234 await writeFixture(root, "main.go", "package main\nfunc main() {}\n");
17235+ const signal = Promise.withResolvers<boolean>();
17236+ vi.spyOn(projectsModule, "hasFallbackNodeProjectSignal").mockReturnValue(signal.promise);
17237+ const projectsSpy = vi.spyOn(projectsModule, "discoverNodeProjects");
17238+ const goDone = Promise.withResolvers<void>();
17239+ const project = await detectProject(root);
17240+
17241+ const mapping = mapFeatures(root, project, [], {
17242+ onProgress(event) {
17243+ if (event.event === "mapper-done" && event.mapper === "go") {
17244+ goDone.resolve();
17245+ }
17246+ },
17247+ });
17248+ await goDone.promise;
17249+
17250+ expect(projectsSpy).not.toHaveBeenCalled();
17251+ signal.resolve(false);
17252+ await expect(mapping).resolves.toBeDefined();
17253+ });
1719817254
17199- const spy = vi.spyOn(projectsModule, "discoverNodeProjects");
17255+ it("preserves package-less Node projects under conventional roots", async () => {
17256+ const root = await fixtureRoot("clawpatch-map-package-less-node-");
17257+ await writeFixture(root, "apps/web/src/index.ts", "export function start() {}\n");
1720017258
1720117259 const project = await detectProject(root);
17202- await mapFeatures(root, project, []);
17260+ const result = await mapFeatures(root, project, []);
17261+
17262+ expect(project.detected.languages).not.toContain("typescript");
17263+ expect(result.features.map((feature) => feature.title)).toContain("Node source apps/web/src");
17264+ });
17265+
17266+ it("propagates Turbo failures and retries with a fresh mapping context", async () => {
17267+ const root = await fixtureRoot("clawpatch-map-turbo-failure-");
17268+ await writeFixture(root, "package.json", JSON.stringify({ name: "node-app" }));
17269+ await writeFixture(root, "src/index.ts", "export const value = true;\n");
17270+ await writeFixture(root, "turbo.json", "not-json\n");
17271+ const taskGraphSpy = vi.spyOn(turboModule, "turboTaskGraph");
17272+ const project = await detectProject(root);
17273+
17274+ await expect(mapFeatures(root, project, [])).rejects.toThrow();
17275+ await writeFixture(root, "turbo.json", JSON.stringify({ tasks: {} }));
17276+ await expect(mapFeatures(root, project, [])).resolves.toBeDefined();
1720317277
17204- expect(spy ).toHaveBeenCalledTimes(1 );
17278+ expect(taskGraphSpy ).toHaveBeenCalledTimes(2 );
1720517279 });
1720617280});
0 commit comments