fix(admin-vite-plugin): Normalize file paths and add tests (#9595)

**What**
- #9338 had a regression which caused the import path in some virtual modules to be invalid on Windows.
- This PR fixes the issue so we now again create the correct import paths, and adds tests to prevent this from slipping in again.
This commit is contained in:
Kasper Fabricius Kristensen authored and GitHub committed 2024-10-15 16:48:56 +00:00
1 parent 84fa6ccde5
commit 813efeae51
15 files changed
+1069 -14

No files matched your search

@@ -22,14 +22,17 @@
], ],
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"watch": "tsup --watch" "watch": "tsup --watch",
"test": "vitest --run",
"test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@babel/types": "7.25.6", "@babel/types": "7.25.6",
"@types/node": "^20.10.4", "@types/node": "^20.10.4",
"tsup": "8.0.1", "tsup": "8.0.1",
"typescript": "5.3.3", "typescript": "5.3.3",
"vite": "^5.2.11" "vite": "^5.2.11",
"vitest": "^2.1.3"
}, },
"peerDependencies": { "peerDependencies": {
"vite": "^5.0.0" "vite": "^5.0.0"
@@ -20,7 +20,7 @@ import {
traverse, traverse,
} from "../babel" } from "../babel"
import { logger } from "../logger" import { logger } from "../logger"
import { crawl, getParserOptions } from "../utils" import { crawl, getParserOptions, normalizePath } from "../utils"
import { getConfigArgument, getModel, validateLink } from "./helpers" import { getConfigArgument, getModel, validateLink } from "./helpers"
type CustomFieldDisplay = { type CustomFieldDisplay = {
@@ -288,5 +288,6 @@ function generateCustomFieldConfigName(index: number): string {
} }
function generateImport(file: string, index: number): string { function generateImport(file: string, index: number): string {
return `import ${generateCustomFieldConfigName(index)} from "${file}"` const path = normalizePath(file)
return `import ${generateCustomFieldConfigName(index)} from "${path}"`
} }
@@ -29,7 +29,7 @@ import {
traverse, traverse,
} from "../babel" } from "../babel"
import { logger } from "../logger" import { logger } from "../logger"
import { crawl, getParserOptions } from "../utils" import { crawl, getParserOptions, normalizePath } from "../utils"
import { getConfigArgument, getModel, validateLink } from "./helpers" import { getConfigArgument, getModel, validateLink } from "./helpers"
type CustomFieldConfigField = { type CustomFieldConfigField = {
@@ -263,7 +263,8 @@ function generateCustomFieldConfigName(index: number): string {
} }
function generateImport(file: string, index: number): string { function generateImport(file: string, index: number): string {
return `import ${generateCustomFieldConfigName(index)} from "${file}"` const path = normalizePath(file)
return `import ${generateCustomFieldConfigName(index)} from "${path}"`
} }
function getForms( function getForms(
@@ -12,7 +12,7 @@ import {
traverse, traverse,
} from "../babel" } from "../babel"
import { logger } from "../logger" import { logger } from "../logger"
import { crawl, getParserOptions } from "../utils" import { crawl, getParserOptions, normalizePath } from "../utils"
import { getConfigArgument, getModel } from "./helpers" import { getConfigArgument, getModel } from "./helpers"
type ParsedCustomFieldLink = { type ParsedCustomFieldLink = {
@@ -138,7 +138,8 @@ function generateCustomFieldConfigName(index: number): string {
} }
function generateImport(file: string, index: number): string { function generateImport(file: string, index: number): string {
return `import ${generateCustomFieldConfigName(index)} from "${file}"` const path = normalizePath(file)
return `import ${generateCustomFieldConfigName(index)} from "${path}"`
} }
function getLink( function getLink(
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest"
import fs from "fs/promises"
import * as utils from "../../utils"
import { generateMenuItems } from "../generate-menu-items"
vi.mock("../../utils", async () => {
const actual = await vi.importActual("../../utils")
return {
...actual,
crawl: vi.fn(),
}
})
vi.mock("fs/promises", () => ({
default: {
readFile: vi.fn(),
},
}))
const mockFileContents = [
`
import { defineRouteConfig } from "@medusajs/admin-sdk"
const Page = () => {
return <div>Page 1</div>
}
export const config = defineRouteConfig({
label: "Page 1",
icon: "icon1",
})
export default Page
`,
`
import { defineRouteConfig } from "@medusajs/admin-sdk"
const Page = () => {
return <div>Page 2</div>
}
export const config = defineRouteConfig({
label: "Page 2",
})
export default Page
`,
]
const expectedMenuItems = `
menuItems: [
{
label: RouteConfig0.label,
icon: RouteConfig0.icon,
path: "/one",
},
{
label: RouteConfig1.label,
icon: undefined,
path: "/two",
}
]
`
describe("generateMenuItems", () => {
it("should generate menu items", async () => {
const mockFiles = [
"Users/user/medusa/src/admin/routes/one/page.tsx",
"Users/user/medusa/src/admin/routes/two/page.tsx",
]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
vi.mocked(fs.readFile).mockImplementation(async (file) =>
Promise.resolve(mockFileContents[mockFiles.indexOf(file as string)])
)
const result = await generateMenuItems(
new Set(["Users/user/medusa/src/admin"])
)
expect(result.imports).toEqual([
`import { config as RouteConfig0 } from "Users/user/medusa/src/admin/routes/one/page.tsx"`,
`import { config as RouteConfig1 } from "Users/user/medusa/src/admin/routes/two/page.tsx"`,
])
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedMenuItems)
)
})
it("should handle windows paths", async () => {
// Setup mocks
const mockFiles = [
"C:\\medusa\\src\\admin\\routes\\one\\page.tsx",
"C:\\medusa\\src\\admin\\routes\\two\\page.tsx",
]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
vi.mocked(fs.readFile).mockImplementation(async (file) =>
Promise.resolve(mockFileContents[mockFiles.indexOf(file as string)])
)
const result = await generateMenuItems(new Set(["C:\\medusa\\src\\admin"]))
expect(result.imports).toEqual([
`import { config as RouteConfig0 } from "C:/medusa/src/admin/routes/one/page.tsx"`,
`import { config as RouteConfig1 } from "C:/medusa/src/admin/routes/two/page.tsx"`,
])
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedMenuItems)
)
})
})
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from "vitest"
import { Stats } from "fs"
import fs from "fs/promises"
import * as utils from "../../utils"
import { generateRoutes } from "../generate-routes"
// Mock the dependencies
vi.mock("../../utils", async () => {
const actual = await vi.importActual("../../utils")
return {
...actual,
crawl: vi.fn(),
}
})
vi.mock("fs/promises", () => ({
default: {
readFile: vi.fn(),
stat: vi.fn(),
},
}))
const mockFileContents = [
`
import { defineRouteConfig } from "@medusajs/admin-sdk"
const Page = () => {
return <div>Page 1</div>
}
export const config = defineRouteConfig({
label: "Page 1",
icon: "icon1",
})
export default Page
`,
`
import { defineRouteConfig } from "@medusajs/admin-sdk"
const Page = () => {
return <div>Page 2</div>
}
export const config = defineRouteConfig({
label: "Page 2",
})
export default Page
`,
]
const expectedRoutesWithoutLoaders = `
routes: [
{
Component: RouteComponent0,
loader: undefined,
path: "/one",
},
{
Component: RouteComponent1,
loader: undefined,
path: "/two",
}
]
`
const expectedRoutesWithLoaders = `
routes: [
{
Component: RouteComponent0,
loader: RouteLoader0,
path: "/one",
},
{
Component: RouteComponent1,
loader: RouteLoader1,
path: "/two",
}
]
`
describe("generateRoutes", () => {
it("should generate routes", async () => {
const mockFiles = [
"Users/user/medusa/src/admin/routes/one/page.tsx",
"Users/user/medusa/src/admin/routes/two/page.tsx",
]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
vi.mocked(fs.readFile).mockImplementation(async (file) =>
Promise.resolve(mockFileContents[mockFiles.indexOf(file as string)])
)
vi.mocked(fs.stat).mockRejectedValue(new Error("File not found"))
const result = await generateRoutes(
new Set(["Users/user/medusa/src/admin"])
)
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedRoutesWithoutLoaders)
)
})
it("should generate routes with loaders", async () => {
const mockFiles = [
"Users/user/medusa/src/admin/routes/one/page.tsx",
"Users/user/medusa/src/admin/routes/two/page.tsx",
]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
vi.mocked(fs.readFile).mockImplementation(async (file) =>
Promise.resolve(mockFileContents[mockFiles.indexOf(file as string)])
)
vi.mocked(fs.stat).mockResolvedValue({} as Stats) // We just want to mock that the check passes
const result = await generateRoutes(
new Set(["Users/user/medusa/src/admin"])
)
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedRoutesWithLoaders)
)
})
it("should handle windows paths", async () => {
const mockFiles = [
"C:\\medusa\\src\\admin\\routes\\one\\page.tsx",
"C:\\medusa\\src\\admin\\routes\\two\\page.tsx",
]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
vi.mocked(fs.readFile).mockImplementation(async (file) =>
Promise.resolve(mockFileContents[mockFiles.indexOf(file as string)])
)
vi.mocked(fs.stat).mockRejectedValue(new Error("File not found"))
const result = await generateRoutes(new Set(["C:\\medusa\\src\\admin"]))
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedRoutesWithoutLoaders)
)
})
})
@@ -2,7 +2,12 @@ import fs from "fs/promises"
import { outdent } from "outdent" import { outdent } from "outdent"
import { isIdentifier, isObjectProperty, parse, traverse } from "../babel" import { isIdentifier, isObjectProperty, parse, traverse } from "../babel"
import { logger } from "../logger" import { logger } from "../logger"
import { crawl, getConfigObjectProperties, getParserOptions } from "../utils" import {
crawl,
getConfigObjectProperties,
getParserOptions,
normalizePath,
} from "../utils"
import { getRoute } from "./helpers" import { getRoute } from "./helpers"
type MenuItem = { type MenuItem = {
@@ -90,7 +95,8 @@ async function parseFile(
} }
function generateImport(file: string, index: number): string { function generateImport(file: string, index: number): string {
return `import { config as ${generateRouteConfigName(index)} } from "${file}"` const path = normalizePath(file)
return `import { config as ${generateRouteConfigName(index)} } from "${path}"`
} }
function generateMenuItem( function generateMenuItem(
@@ -17,7 +17,7 @@ import {
} from "./babel" } from "./babel"
export function normalizePath(file: string) { export function normalizePath(file: string) {
return path.normalize(file).split(path.sep).join("/") return path.normalize(file.replace(/\\/g, "/"))
} }
/** /**
@@ -145,3 +145,11 @@ export function isFileInAdminSubdirectory(
const normalizedPath = normalizePath(file) const normalizedPath = normalizePath(file)
return normalizedPath.includes(`/src/admin/${subdirectory}/`) return normalizedPath.includes(`/src/admin/${subdirectory}/`)
} }
/**
* Test util to normalize strings, so they can be compared without taking
* whitespace into account.
*/
export function normalizeString(str: string): string {
return str.replace(/\s+/g, " ").trim()
}
@@ -0,0 +1,83 @@
import { vi } from "vitest"
import fs from "fs/promises"
import * as utils from "../../utils"
import { generateWidgets } from "../generate-widgets"
vi.mock("../../utils", async () => {
const actual = await vi.importActual("../../utils")
return {
...actual,
crawl: vi.fn(),
}
})
vi.mock("fs/promises", () => ({
default: {
readFile: vi.fn(),
},
}))
const mockFileContents = [
`
import { defineWidgetConfig } from "@medusajs/admin-sdk"
const Widget = () => {
return <div>Widget 1</div>
}
export const config = defineWidgetConfig({
zone: "product.details.after",
})
export default Widget
`,
]
const expectedWidgets = `
widgets: [
{
Component: WidgetComponent0,
zone: ["product.details.after"]
}
]
`
describe("generateWidgets", () => {
it("should generate widgets", async () => {
const mockFiles = ["Users/user/medusa/src/admin/widgets/widget.tsx"]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
vi.mocked(fs.readFile).mockImplementation(async (file) =>
Promise.resolve(mockFileContents[mockFiles.indexOf(file as string)])
)
const result = await generateWidgets(
new Set(["Users/user/medusa/src/admin"])
)
expect(result.imports).toEqual([
`import WidgetComponent0, { config as WidgetConfig0 } from "Users/user/medusa/src/admin/widgets/widget.tsx"`,
])
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedWidgets)
)
})
it("should handle windows paths", async () => {
const mockFiles = ["C:\\medusa\\src\\admin\\widgets\\widget.tsx"]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
vi.mocked(fs.readFile).mockImplementation(async (file) =>
Promise.resolve(mockFileContents[mockFiles.indexOf(file as string)])
)
const result = await generateWidgets(new Set(["C:\\medusa\\src\\admin"]))
expect(result.imports).toEqual([
`import WidgetComponent0, { config as WidgetConfig0 } from "C:/medusa/src/admin/widgets/widget.tsx"`,
])
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedWidgets)
)
})
})
@@ -16,6 +16,7 @@ import {
getConfigObjectProperties, getConfigObjectProperties,
getParserOptions, getParserOptions,
hasDefaultExport, hasDefaultExport,
normalizePath,
} from "../utils" } from "../utils"
import { getWidgetFilesFromSources } from "./helpers" import { getWidgetFilesFromSources } from "./helpers"
@@ -135,9 +136,10 @@ function generateWidgetConfigName(index: number): string {
} }
function generateImport(file: string, index: number): string { function generateImport(file: string, index: number): string {
const path = normalizePath(file)
return `import ${generateWidgetComponentName( return `import ${generateWidgetComponentName(
index index
)}, { config as ${generateWidgetConfigName(index)} } from "${file}"` )}, { config as ${generateWidgetConfigName(index)} } from "${path}"`
} }
function generateWidget(zone: InjectionZone[], index: number): WidgetConfig { function generateWidget(zone: InjectionZone[], index: number): WidgetConfig {
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["**/*.spec.ts", "vitest.config.ts", "tsup.config.ts"]
}
@@ -18,5 +18,5 @@
"@babel/types": ["../../../node_modules/@babel/types"] "@babel/types": ["../../../node_modules/@babel/types"]
} }
}, },
"include": ["src"] "include": ["src", "vitest.config.ts", "tsup.config.ts"]
} }
@@ -2,6 +2,7 @@ import { defineConfig } from "tsup"
export default defineConfig({ export default defineConfig({
entry: ["./src/index.ts"], entry: ["./src/index.ts"],
tsconfig: "tsconfig.build.json",
format: ["cjs", "esm"], format: ["cjs", "esm"],
dts: true, dts: true,
clean: true, clean: true,
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["**/*.spec.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
},
})
+680 -1
View File
File diff suppressed because it is too large. Load diff