From edb386f658266e35502845fba2672f14bd7076e0 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Fri, 6 Dec 2024 23:36:29 +0000 Subject: [PATCH 1/2] fix: wip --- package-lock.json | 4 +- package.json | 15 +- src/js/src/index.ts | 76 ++++++--- src/js/tests/index.test.ts | 319 +++++++++++++++++-------------------- 4 files changed, 214 insertions(+), 200 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9756d9e..c0e7eb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,10 +25,10 @@ "vitest": "^2.1.6" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "peerDependencies": { - "vite": ">=5.0.0" + "vite": "^5.0.0 || ^6.0.0" } }, "node_modules/@biomejs/biome": { diff --git a/package.json b/package.json index 35b44c9..f7c41e4 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,11 @@ "version": "0.8.3", "type": "module", "description": "Litestar plugin for Vite.", - "keywords": ["litestar", "vite", "vite-plugin"], + "keywords": [ + "litestar", + "vite", + "vite-plugin" + ], "homepage": "https://github.com/litestar-org/litestar-vite", "repository": { "type": "git", @@ -22,7 +26,10 @@ } }, "types": "./dist/js/index.d.ts", - "files": ["dist/js/**/*", "tools/clean.js"], + "files": [ + "dist/js/**/*", + "tools/clean.js" + ], "bin": { "clean-orphaned-assets": "tools/clean.js" }, @@ -45,10 +52,10 @@ "vitest": "^2.1.6" }, "peerDependencies": { - "vite": ">=5.0.0" + "vite": "^5.0.0 || ^6.0.0" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "dependencies": { "picocolors": "^1.1.1", diff --git a/src/js/src/index.ts b/src/js/src/index.ts index 8eca81b..c9c7cad 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -63,6 +63,10 @@ interface PluginConfig { * @default null */ detectTls?: string | boolean | null + /** + * Automatically detect the index.html file. + */ + autoDetectIndex?: boolean; /** * Transform the code while serving. */ @@ -82,7 +86,11 @@ type DevServerUrl = `${"http" | "https"}://${string}:${number}` let exitHandlersBound = false -export const refreshPaths = ["**/*.py", "**/*.j2", "**/*.html.j2", "**/*.html", "**/assets/**/*"] +export const refreshPaths = [ + "src/**", + "resources/**", + "assets/**" +].filter(path => fs.existsSync(path.replace(/\*\*$/, ''))) /** * Litestar plugin for Vite. @@ -184,9 +192,29 @@ function resolveLitestarPlugin(pluginConfig: Required): LitestarPl } return undefined }, - configureServer(server) { + async configureServer(server) { const envDir = resolvedConfig.envDir || process.cwd() - const appUrl = loadEnv(resolvedConfig.mode, envDir, "APP_URL").APP_URL ?? "undefined" + const appUrl = loadEnv(resolvedConfig.mode, envDir, 'APP_URL').APP_URL ?? 'undefined' + + // Check if we should serve SPA directly + const shouldServeIndex = () => { + if (!pluginConfig.autoDetectIndex) return false + + // Check various common locations for index.html + const possiblePaths = [ + path.join(server.config.root, 'index.html'), + path.join(server.config.root, pluginConfig.resourceDirectory, 'index.html'), + path.join(server.config.root, 'public', 'index.html') + ] + + for (const indexPath of possiblePaths) { + try { + fs.accessSync(indexPath) + return true + } catch {} + } + return false + } server.httpServer?.once("listening", () => { const address = server.httpServer?.address() @@ -197,14 +225,23 @@ function resolveLitestarPlugin(pluginConfig: Required): LitestarPl fs.mkdirSync(path.dirname(pluginConfig.hotFile), { recursive: true }) fs.writeFileSync(pluginConfig.hotFile, viteDevServerUrl) + const hasIndex = shouldServeIndex() + setTimeout(() => { server.config.logger.info(`\n ${colors.red(`${colors.bold("LITESTAR")} ${litestarVersion()}`)} ${colors.dim("plugin")} ${colors.bold(`v${pluginVersion()}`)}`) server.config.logger.info("") - server.config.logger.info(` ${colors.green("➜")} ${colors.bold("APP_URL")}: ${colors.cyan(appUrl.replace(/:(\d+)/, (_, port) => `:${colors.bold(port)}`))}`) + if (hasIndex) { + server.config.logger.info(` ${colors.green("➜")} ${colors.bold("Serve Index")}: Serving application index with Vite`) + server.config.logger.info(` ${colors.green("➜")} ${colors.bold("DEV URL")}: ${colors.cyan(viteDevServerUrl)}`) + server.config.logger.info(` ${colors.green("➜")} ${colors.bold("APP_URL")}: ${colors.cyan(appUrl.replace(/:(\d+)/, (_, port) => `:${colors.bold(port)}`))}`) + } else { + server.config.logger.info(` ${colors.green("➜")} ${colors.bold("Serve Index")}: Serving Litestar index with Vite`) + server.config.logger.info(` ${colors.green("➜")} ${colors.bold("DEV URL")}: ${colors.cyan(viteDevServerUrl)}`) + server.config.logger.info(` ${colors.green("➜")} ${colors.bold("APP_URL")}: ${colors.cyan(appUrl.replace(/:(\d+)/, (_, port) => `:${colors.bold(port)}`))}`) + } }, 100) } }) - if (!exitHandlersBound) { const clean = () => { if (fs.existsSync(pluginConfig.hotFile)) { @@ -222,7 +259,7 @@ function resolveLitestarPlugin(pluginConfig: Required): LitestarPl return () => server.middlewares.use((req, res, next) => { - if (req.url === "/index.html") { + if (!shouldServeIndex() && req.url === "/index.html") { res.statusCode = 404 res.end( @@ -242,23 +279,19 @@ function resolveLitestarPlugin(pluginConfig: Required): LitestarPl /** * Validate the command can run in the given environment. */ -function ensureCommandShouldRunInEnvironment(command: "build" | "serve", env: Record): void { - const validEnvironmentNames = ["dev", "development", "local", "docker"] - if (command === "build" || env.LITESTAR_BYPASS_ENV_CHECK === "1") { - return - } +function ensureCommandShouldRunInEnvironment(command: 'build'|'serve', env: Record): void { + const validEnvironmentNames = ["dev", "development", "local", "docker"] + if (command === 'build' || env.LITESTAR_BYPASS_ENV_CHECK === '1') { + return + } - if (typeof env.LITESTAR_MODE !== "undefined" && validEnvironmentNames.some((e) => e === env.LITESTAR_MODE)) { - throw Error( - "You should only run Vite dev server when Litestar is development mode. You should build your assets for production instead. To disable this ENV check you may set LITESTAR_BYPASS_ENV_CHECK=1", - ) - } + if (typeof env.LITESTAR_MODE !== 'undefined' && validEnvironmentNames.some(e => e === env.LITESTAR_MODE)) { + throw Error('You should only run Vite dev server when Litestar is development mode. You should build your assets for production instead. To disable this ENV check you may set LITESTAR_BYPASS_ENV_CHECK=1') + } - if (typeof env.CI !== "undefined") { - throw Error( - "You should not run the Vite HMR server in CI environments. You should build your assets for production instead. To disable this ENV check you may set LITESTAR_BYPASS_ENV_CHECK=1", - ) - } + if (typeof env.CI !== 'undefined') { + throw Error('You should not run the Vite HMR server in CI environments. You should build your assets for production instead. To disable this ENV check you may set LITESTAR_BYPASS_ENV_CHECK=1') + } } /** @@ -326,6 +359,7 @@ function resolvePluginConfig(config: string | string[] | PluginConfig): Required hotFile: resolvedConfig.hotFile ?? path.join(resolvedConfig.bundleDirectory ?? "public", "hot"), detectTls: resolvedConfig.detectTls ?? false, transformOnServe: resolvedConfig.transformOnServe ?? ((code) => code), + autoDetectIndex: resolvedConfig.autoDetectIndex ?? true, } } diff --git a/src/js/tests/index.test.ts b/src/js/tests/index.test.ts index 7cd4af9..efecb15 100644 --- a/src/js/tests/index.test.ts +++ b/src/js/tests/index.test.ts @@ -1,13 +1,98 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import litestar from "../src" -import { currentRoute, getRelativeUrlPath, isCurrentRoute, isRoute, resolvePageComponent, route, toRoute } from "../src/inertia-helpers" +import { resolvePageComponent, route, getRelativeUrlPath, isCurrentRoute, isRoute, toRoute } from "../src/inertia-helpers" +import { loadEnv } from "vite" +import { Plugin } from 'vite' +import fs from 'node:fs' +import path from 'node:path' + +// Mock the fs module +vi.mock('fs', async () => { + const actual = await vi.importActual('fs') + + return { + default: { + ...actual, + existsSync: (path: string) => [ + 'resources/', + 'assets/', + 'src/' + ].includes(path) || actual.existsSync(path) + } + } +}) +// Mock process.env +const originalEnv = process.env +beforeEach(() => { + vi.resetModules() + process.env = { ...originalEnv } +}) + +afterEach(() => { + process.env = originalEnv + vi.clearAllMocks() +}) + + +// Mock routes for testing +beforeEach(() => { + globalThis.routes = { + home: "/", + about: "/about", + "users:assign-role": "/api/roles/{role_slug:str}/assign", + "users:revoke-role": "/api/roles/{role_slug:str}/revoke", + "tags:create": "/api/tags", + "tags:list": "/api/tags", + "tags:get": "/api/tags/{tag_id:uuid}", + "tags:update": "/api/tags/{tag_id:uuid}", + "tags:delete": "/api/tags/{tag_id:uuid}", + "teams:add-member": "/api/teams/{team_id:uuid}/members/add", + "teams:remove-member": "/api/teams/{team_id:uuid}/members/remove", + "users:create": "/api/users/create", + "users:list": "/api/users/list", + "users:update": "/api/users/update/{user_id:uuid}", + "users:delete": "/api/users/delete/{user_id:uuid}", + "users:get": "/api/users/get/{user_id:uuid}", + dashboard: "/dashboard", + favicon: "/favicon.ico", + landing: "/landing", + "login.check": "/login/check", + login: "/login", + logout: "/logout", + "github.complete": "/o/github/complete", + "google.complete": "/o/google/complete", + "privacy-policy": "/privacy-policy", + "account.remove": "/profile/remove", + "profile.show": "/profile", + "profile.update": "/profile/update", + "password.update": "/profile/password-update", + register: "/register", + "register.add": "/register/add", + "github.register": "/register/github", + "google.register": "/register/google", + "worker:index": "/saq/queues/{queue_id:str}/jobs/{job_id:str}", + "worker:queue-list": "/saq/api/queues/list", + "worker:queue-detail": "/saq/api/queues/{queue_id:str}", + "worker:job-detail": "/saq/api/queues/{queue_id:str}/jobs/{job_id:str}", + "worker:job-abort": "/saq/api/queues/{queue_id:str}/jobs/{job_id:str}/abort", + "worker:job-retry": "/saq/api/queues/{queue_id:str}/jobs/{job_id:str}/retry", + saq: "/saq/static/{file_path:path}", + vite: "/static/{file_path:path}", + "teams.add": "/teams/add", + "teams.list": "/teams/list", + "teams.show": "/teams/show/{team_id:uuid}", + "teams.remove": "/teams/remove/{team_id:uuid}", + "teams.edit": "/teams/edit/{team_id:uuid}", + "terms-of-service": "/terms-of-service", + } +}) describe("litestar-vite-plugin", () => { const originalEnv = { ...process.env } afterEach(() => { process.env = { ...originalEnv } - vi.clearAllMocks() + vi.resetAllMocks() }) it("handles missing configuration", () => { @@ -42,21 +127,21 @@ describe("litestar-vite-plugin", () => { it("accepts a full configuration", () => { const plugin = litestar({ - input: "resources/js/app.ts", - assetUrl: "other-build", - bundleDirectory: "other-build", - ssr: "resources/js/ssr.ts", - ssrOutputDirectory: "other-ssr-output", + input: "resources/js/app.ts", + assetUrl: "other-static", + bundleDirectory: "other-build", + ssr: "resources/js/ssr.ts", + ssrOutputDirectory: "other-ssr-output", })[0] const config = plugin.config({}, { command: "build", mode: "production" }) - expect(config.base).toBe("other-build/") + expect(config.base).toBe("other-static/") expect(config.build?.manifest).toBe("manifest.json") expect(config.build?.outDir).toBe("other-build") expect(config.build?.rollupOptions?.input).toBe("resources/js/app.ts") const ssrConfig = plugin.config({ build: { ssr: true } }, { command: "build", mode: "production" }) - expect(ssrConfig.base).toBe("other-build/") + expect(ssrConfig.base).toBe("other-static/") expect(ssrConfig.build?.manifest).toBe(false) expect(ssrConfig.build?.outDir).toBe("other-ssr-output") expect(ssrConfig.build?.rollupOptions?.input).toBe("resources/js/ssr.ts") @@ -233,7 +318,7 @@ describe("litestar-vite-plugin", () => { ]) }) - it("configures the Vite server when running remotely or in a container", () => { + it("configures the Vite server when running remotely", () => { process.env.VITE_ALLOW_REMOTE = "1" const plugin = litestar("resources/js/app.js")[0] @@ -241,9 +326,11 @@ describe("litestar-vite-plugin", () => { expect(config.server?.host).toBe("0.0.0.0") expect(config.server?.port).toBe(5173) expect(config.server?.strictPort).toBe(true) + + delete process.env.VITE_ALLOW_REMOTE }) - it("allows the Vite port to be configured when inside a container", () => { + it("allows the Vite port to be configured when running remotely", () => { process.env.VITE_ALLOW_REMOTE = "1" process.env.VITE_PORT = "1234" const plugin = litestar("resources/js/app.js")[0] @@ -328,16 +415,20 @@ describe("litestar-vite-plugin", () => { expect(plugins.length).toBe(1) }) - it("configures full reload with routes and views when refresh is true", () => { + it("configures full reload with python and template files when refresh is true", () => { const plugins = litestar({ - input: "resources/js/app.js", - refresh: true, + input: "resources/js/app.js", + refresh: true, }) expect(plugins.length).toBe(2) /** @ts-ignore */ expect(plugins[1].__litestar_plugin_config).toEqual({ - paths: ["**/*.py", "**/*.j2", "**/*.html.j2", "**/*.html", "**/assets/**/*"], + paths: [ + "src/**", + "resources/**", + "assets/**", + ], }) }) @@ -411,123 +502,73 @@ describe("litestar-vite-plugin", () => { config: { delay: 123 }, }) }) -}) + +}) describe("inertia-helpers", () => { - const path = "./__data__/dummy.ts" + const testPath = "./__data__/dummy.ts" + + beforeEach(() => { + vi.resetModules() + // Mock the import.meta.glob functionality + vi.mock('./__data__/dummy.ts', () => ({ + default: "Dummy File" + })) + }) + it("pass glob value to resolvePageComponent", async () => { - const file = await resolvePageComponent<{ default: string }>( - path, - /* @ts-ignore */ - import.meta.glob("./__data__/*.ts"), - ) + const pages = { + [testPath]: Promise.resolve({ default: "Dummy File" }) + } + + const file = await resolvePageComponent<{ default: string }>(testPath, pages) expect(file.default).toBe("Dummy File") }) it("pass eagerly globed value to resolvePageComponent", async () => { - const file = await resolvePageComponent<{ default: string }>( - path, - /* @ts-ignore */ - import.meta.glob("./__data__/*.ts", { eager: true }), - ) + const pages = { + [testPath]: { default: "Dummy File" } + } + // @ts-ignore + const file = await resolvePageComponent<{ default: string }>(testPath, pages) expect(file.default).toBe("Dummy File") }) it("accepts array of paths", async () => { + const pages = { + [testPath]: { default: "Dummy File" } + } + const file = await resolvePageComponent<{ default: string }>( - ["missing-page", path], - /* @ts-ignore */ - import.meta.glob("./__data__/*.ts", { eager: true }), - /* @ts-ignore */ - path, + ["missing-page", testPath], + // @ts-ignore + pages ) expect(file.default).toBe("Dummy File") }) it("throws an error when a page is not found", async () => { - const callback = () => - resolvePageComponent<{ default: string }>( - "missing-page", - /* @ts-ignore */ - import.meta.glob("./__data__/*.ts"), - ) - await expect(callback).rejects.toThrowError(new Error("Page not found: missing-page")) - }) - - it("throws an error when a page is not found", async () => { - const callback = () => - resolvePageComponent<{ default: string }>( - ["missing-page-1", "missing-page-2"], - /* @ts-ignore */ - import.meta.glob("./__data__/*.ts"), - ) - await expect(callback).rejects.toThrowError(new Error("Page not found: missing-page-1,missing-page-2")) + const pages = {} + await expect( + resolvePageComponent<{ default: string }>("missing-page", pages) + ).rejects.toThrow("Page not found: missing-page") }) }) describe("route helpers", () => { beforeEach(() => { - // Setup mock routes based on the provided JSON globalThis.routes = { home: "/", - about: "/about", - "users:assign-role": "/api/roles/{role_slug:str}/assign", - "users:revoke-role": "/api/roles/{role_slug:str}/revoke", - "tags:create": "/api/tags", - "tags:list": "/api/tags", - "tags:get": "/api/tags/{tag_id:uuid}", - "tags:update": "/api/tags/{tag_id:uuid}", - "tags:delete": "/api/tags/{tag_id:uuid}", - "teams:add-member": "/api/teams/{team_id:uuid}/members/add", - "teams:remove-member": "/api/teams/{team_id:uuid}/members/remove", - "users:create": "/api/users/create", - "users:list": "/api/users/list", - "users:update": "/api/users/update/{user_id:uuid}", - "users:delete": "/api/users/delete/{user_id:uuid}", "users:get": "/api/users/get/{user_id:uuid}", - dashboard: "/dashboard", - favicon: "/favicon.ico", - landing: "/landing", - "login.check": "/login/check", - login: "/login", - logout: "/logout", - "github.complete": "/o/github/complete", - "google.complete": "/o/google/complete", - "privacy-policy": "/privacy-policy", - "account.remove": "/profile/remove", - "profile.show": "/profile", - "profile.update": "/profile/update", - "password.update": "/profile/password-update", - register: "/register", - "register.add": "/register/add", - "github.register": "/register/github", - "google.register": "/register/google", - "worker:index": "/saq/queues/{queue_id:str}/jobs/{job_id:str}", - "worker:queue-list": "/saq/api/queues/list", - "worker:queue-detail": "/saq/api/queues/{queue_id:str}", - "worker:job-detail": "/saq/api/queues/{queue_id:str}/jobs/{job_id:str}", - "worker:job-abort": "/saq/api/queues/{queue_id:str}/jobs/{job_id:str}/abort", - "worker:job-retry": "/saq/api/queues/{queue_id:str}/jobs/{job_id:str}/retry", - saq: "/saq/static/{file_path:path}", - vite: "/static/{file_path:path}", - "teams.add": "/teams/add", - "teams.list": "/teams/list", - "teams.show": "/teams/show/{team_id:uuid}", - "teams.remove": "/teams/remove/{team_id:uuid}", - "teams.edit": "/teams/edit/{team_id:uuid}", - "terms-of-service": "/terms-of-service", + "users:list": "/api/users/list", + // ... other routes ... } }) describe("route()", () => { it("generates URLs from route names with named parameters", () => { - expect(route("users:get", { user_id: "123e4567-e89b-12d3-a456-426614174000" })).toContain("/api/users/get/123e4567-e89b-12d3-a456-426614174000") - - expect(route("worker:queue-detail", { queue_id: "default" })).toContain("/saq/api/queues/default") - }) - - it('returns "#" for invalid routes', () => { - expect(route("invalid.route")).toBe("#") + const result = route("users:get", { user_id: "123e4567-e89b-12d3-a456-426614174000" }) + expect(result).toContain("/api/users/get/123e4567-e89b-12d3-a456-426614174000") }) it("handles missing parameters", () => { @@ -537,78 +578,10 @@ describe("route helpers", () => { describe("getRelativeUrlPath()", () => { it("extracts relative path from full URL", () => { - expect(getRelativeUrlPath("http://example.com/api/users/get/123e4567-e89b-12d3-a456-426614174000?page=1#section")).toBe( - "/api/users/get/123e4567-e89b-12d3-a456-426614174000?page=1#section", - ) - }) - - it("returns original string for relative paths", () => { - expect(getRelativeUrlPath("/api/users/get/123e4567-e89b-12d3-a456-426614174000")).toBe("/api/users/get/123e4567-e89b-12d3-a456-426614174000") + const result = getRelativeUrlPath("http://example.com/api/users/get/123e4567-e89b-12d3-a456-426614174000?page=1#section") + expect(result).toBe("/api/users/get/123e4567-e89b-12d3-a456-426614174000?page=1#section") }) }) - describe("toRoute()", () => { - it("matches URLs to route names", () => { - expect(toRoute("/api/users/list")).toBe("users:list") - expect(toRoute("/api/users/get/123e4567-e89b-12d3-a456-426614174000")).toBe("users:get") - expect(toRoute("/teams/list")).toBe("teams.list") - expect(toRoute("/teams/show/123e4567-e89b-12d3-a456-426614174000")).toBe("teams.show") - expect(toRoute("/saq/api/queues/list")).toBe("worker:queue-list") - expect(toRoute("/saq/api/queues/default/jobs/123")).toBe("worker:job-detail") - expect(toRoute("/saq/static/path/to/file.pdf")).toBe("saq") - }) - - it("returns null for unmatched routes", () => { - expect(toRoute("/not/a/real/route")).toBeNull() - }) - }) - - describe("currentRoute()", () => { - it("returns current route name based on window location", () => { - // Mock window.location - Object.defineProperty(window, "location", { - value: { pathname: "/api/users/get/123e4567-e89b-12d3-a456-426614174000" }, - writable: true, - }) - - expect(currentRoute()).toBe("users:get") - }) - - it("returns null when current location does not match any route", () => { - Object.defineProperty(window, "location", { - value: { pathname: "/not/a/real/route" }, - writable: true, - }) - - expect(currentRoute()).toBeNull() - }) - }) - - describe("isRoute()", () => { - it("checks if URL matches route pattern", () => { - expect(isRoute("/api/users/get/123e4567-e89b-12d3-a456-426614174000", "users:get")).toBe(true) - expect(isRoute("/invalid/path", "users:get")).toBe(false) - }) - }) - - describe("isCurrentRoute()", () => { - it("checks if current location matches route pattern", () => { - Object.defineProperty(window, "location", { - value: { pathname: "/api/users/get/123e4567-e89b-12d3-a456-426614174000" }, - writable: true, - }) - - expect(isCurrentRoute("users:get")).toBe(true) - expect(isCurrentRoute("users.*")).toBe(true) - }) - - it("returns false when current location does not match route pattern", () => { - Object.defineProperty(window, "location", { - value: { pathname: "/not/a/real/route" }, - writable: true, - }) - - expect(isCurrentRoute("users:get")).toBe(false) - }) - }) + // ... other route helper tests ... }) From fad8526bcd49568a126055c24fea47d4c8da3f4c Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sat, 7 Dec 2024 00:01:13 +0000 Subject: [PATCH 2/2] feat: adds tests --- Makefile | 4 +- package.json | 11 +- src/js/src/index.ts | 46 +++--- src/js/src/inertia-helpers/index.ts | 3 +- src/js/tests/index.test.ts | 224 ++++++++++++++++++++-------- uv.lock | 17 ++- 6 files changed, 203 insertions(+), 102 deletions(-) diff --git a/Makefile b/Makefile index 4bbd092..66db47f 100644 --- a/Makefile +++ b/Makefile @@ -127,7 +127,7 @@ clean: ## Cleanup temporary build a .PHONY: test test: ## Run the tests @echo "${INFO} Running test cases... 🧪" - @npm run test >/dev/null 2>&1 + @NODE_OPTIONS="--no-deprecation --disable-warning=ExperimentalWarning" npm run test @uv run pytest -n 2 --quiet @echo "${OK} Tests passed ✨" @@ -177,7 +177,7 @@ type-check: mypy pyright ## Run all type checking .PHONY: pre-commit pre-commit: ## Run pre-commit hooks @echo "${INFO} Running pre-commit checks... 🔎" - @uv run pre-commit run --color=always --all-files + @NODE_OPTIONS="--no-deprecation --disable-warning=ExperimentalWarning" uv run pre-commit run --color=always --all-files @echo "${OK} Pre-commit checks passed ✨" .PHONY: slotscheck diff --git a/package.json b/package.json index f7c41e4..285cf8f 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,7 @@ "version": "0.8.3", "type": "module", "description": "Litestar plugin for Vite.", - "keywords": [ - "litestar", - "vite", - "vite-plugin" - ], + "keywords": ["litestar", "vite", "vite-plugin"], "homepage": "https://github.com/litestar-org/litestar-vite", "repository": { "type": "git", @@ -26,10 +22,7 @@ } }, "types": "./dist/js/index.d.ts", - "files": [ - "dist/js/**/*", - "tools/clean.js" - ], + "files": ["dist/js/**/*", "tools/clean.js"], "bin": { "clean-orphaned-assets": "tools/clean.js" }, diff --git a/src/js/src/index.ts b/src/js/src/index.ts index c9c7cad..0b6c7fd 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -65,8 +65,10 @@ interface PluginConfig { detectTls?: string | boolean | null /** * Automatically detect the index.html file. + * + * @default true */ - autoDetectIndex?: boolean; + autoDetectIndex?: boolean /** * Transform the code while serving. */ @@ -86,11 +88,7 @@ type DevServerUrl = `${"http" | "https"}://${string}:${number}` let exitHandlersBound = false -export const refreshPaths = [ - "src/**", - "resources/**", - "assets/**" -].filter(path => fs.existsSync(path.replace(/\*\*$/, ''))) +export const refreshPaths = ["src/**", "resources/**", "assets/**"].filter((path) => fs.existsSync(path.replace(/\*\*$/, ""))) /** * Litestar plugin for Vite. @@ -194,7 +192,7 @@ function resolveLitestarPlugin(pluginConfig: Required): LitestarPl }, async configureServer(server) { const envDir = resolvedConfig.envDir || process.cwd() - const appUrl = loadEnv(resolvedConfig.mode, envDir, 'APP_URL').APP_URL ?? 'undefined' + const appUrl = loadEnv(resolvedConfig.mode, envDir, "APP_URL").APP_URL ?? "undefined" // Check if we should serve SPA directly const shouldServeIndex = () => { @@ -202,9 +200,9 @@ function resolveLitestarPlugin(pluginConfig: Required): LitestarPl // Check various common locations for index.html const possiblePaths = [ - path.join(server.config.root, 'index.html'), - path.join(server.config.root, pluginConfig.resourceDirectory, 'index.html'), - path.join(server.config.root, 'public', 'index.html') + path.join(server.config.root, "index.html"), + path.join(server.config.root, pluginConfig.resourceDirectory, "index.html"), + path.join(server.config.root, "public", "index.html"), ] for (const indexPath of possiblePaths) { @@ -279,19 +277,23 @@ function resolveLitestarPlugin(pluginConfig: Required): LitestarPl /** * Validate the command can run in the given environment. */ -function ensureCommandShouldRunInEnvironment(command: 'build'|'serve', env: Record): void { - const validEnvironmentNames = ["dev", "development", "local", "docker"] - if (command === 'build' || env.LITESTAR_BYPASS_ENV_CHECK === '1') { - return - } +function ensureCommandShouldRunInEnvironment(command: "build" | "serve", env: Record): void { + const validEnvironmentNames = ["dev", "development", "local", "docker"] + if (command === "build" || env.LITESTAR_BYPASS_ENV_CHECK === "1") { + return + } - if (typeof env.LITESTAR_MODE !== 'undefined' && validEnvironmentNames.some(e => e === env.LITESTAR_MODE)) { - throw Error('You should only run Vite dev server when Litestar is development mode. You should build your assets for production instead. To disable this ENV check you may set LITESTAR_BYPASS_ENV_CHECK=1') - } + if (typeof env.LITESTAR_MODE !== "undefined" && validEnvironmentNames.some((e) => e === env.LITESTAR_MODE)) { + throw Error( + "You should only run Vite dev server when Litestar is development mode. You should build your assets for production instead. To disable this ENV check you may set LITESTAR_BYPASS_ENV_CHECK=1", + ) + } - if (typeof env.CI !== 'undefined') { - throw Error('You should not run the Vite HMR server in CI environments. You should build your assets for production instead. To disable this ENV check you may set LITESTAR_BYPASS_ENV_CHECK=1') - } + if (typeof env.CI !== "undefined") { + throw Error( + "You should not run the Vite HMR server in CI environments. You should build your assets for production instead. To disable this ENV check you may set LITESTAR_BYPASS_ENV_CHECK=1", + ) + } } /** @@ -358,8 +360,8 @@ function resolvePluginConfig(config: string | string[] | PluginConfig): Required refresh: resolvedConfig.refresh ?? false, hotFile: resolvedConfig.hotFile ?? path.join(resolvedConfig.bundleDirectory ?? "public", "hot"), detectTls: resolvedConfig.detectTls ?? false, - transformOnServe: resolvedConfig.transformOnServe ?? ((code) => code), autoDetectIndex: resolvedConfig.autoDetectIndex ?? true, + transformOnServe: resolvedConfig.transformOnServe ?? ((code) => code), } } diff --git a/src/js/src/inertia-helpers/index.ts b/src/js/src/inertia-helpers/index.ts index e5ed88f..c23de32 100644 --- a/src/js/src/inertia-helpers/index.ts +++ b/src/js/src/inertia-helpers/index.ts @@ -125,8 +125,9 @@ export function isRoute(url: string, routeName: string): boolean { const regexPattern = routePattern.replace(/\//g, "\\/").replace(/\{([^}]+):([^}]+)\}/g, (match, paramName, paramType) => { switch (paramType) { case "str": - case "path": return "([^/]+)" + case "path": + return "(.*)" case "uuid": return "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" default: diff --git a/src/js/tests/index.test.ts b/src/js/tests/index.test.ts index efecb15..1a8fea2 100644 --- a/src/js/tests/index.test.ts +++ b/src/js/tests/index.test.ts @@ -1,25 +1,21 @@ +import fs from "node:fs" +import path from "node:path" +import { loadEnv } from "vite" +import { Plugin } from "vite" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import litestar from "../src" -import { resolvePageComponent, route, getRelativeUrlPath, isCurrentRoute, isRoute, toRoute } from "../src/inertia-helpers" -import { loadEnv } from "vite" -import { Plugin } from 'vite' -import fs from 'node:fs' -import path from 'node:path' +import { getRelativeUrlPath, isCurrentRoute, isRoute, resolvePageComponent, route, toRoute } from "../src/inertia-helpers" // Mock the fs module -vi.mock('fs', async () => { - const actual = await vi.importActual('fs') - - return { - default: { - ...actual, - existsSync: (path: string) => [ - 'resources/', - 'assets/', - 'src/' - ].includes(path) || actual.existsSync(path) - } - } +vi.mock("fs", async () => { + const actual = await vi.importActual("fs") + + return { + default: { + ...actual, + existsSync: (path: string) => ["resources/", "assets/", "src/"].includes(path) || actual.existsSync(path), + }, + } }) // Mock process.env const originalEnv = process.env @@ -33,7 +29,6 @@ afterEach(() => { vi.clearAllMocks() }) - // Mock routes for testing beforeEach(() => { globalThis.routes = { @@ -127,11 +122,11 @@ describe("litestar-vite-plugin", () => { it("accepts a full configuration", () => { const plugin = litestar({ - input: "resources/js/app.ts", - assetUrl: "other-static", - bundleDirectory: "other-build", - ssr: "resources/js/ssr.ts", - ssrOutputDirectory: "other-ssr-output", + input: "resources/js/app.ts", + assetUrl: "other-static", + bundleDirectory: "other-build", + ssr: "resources/js/ssr.ts", + ssrOutputDirectory: "other-ssr-output", })[0] const config = plugin.config({}, { command: "build", mode: "production" }) @@ -327,7 +322,7 @@ describe("litestar-vite-plugin", () => { expect(config.server?.port).toBe(5173) expect(config.server?.strictPort).toBe(true) - delete process.env.VITE_ALLOW_REMOTE + process.env.VITE_ALLOW_REMOTE = undefined }) it("allows the Vite port to be configured when running remotely", () => { @@ -417,18 +412,14 @@ describe("litestar-vite-plugin", () => { it("configures full reload with python and template files when refresh is true", () => { const plugins = litestar({ - input: "resources/js/app.js", - refresh: true, + input: "resources/js/app.js", + refresh: true, }) expect(plugins.length).toBe(2) /** @ts-ignore */ expect(plugins[1].__litestar_plugin_config).toEqual({ - paths: [ - "src/**", - "resources/**", - "assets/**", - ], + paths: ["src/**", "resources/**", "assets/**"], }) }) @@ -503,7 +494,34 @@ describe("litestar-vite-plugin", () => { }) }) + it("handles TLS configuration", () => { + process.env.VITE_SERVER_KEY = "path/to/key" + process.env.VITE_SERVER_CERT = "path/to/cert" + process.env.APP_URL = "https://example.com" + + const plugin = litestar("resources/js/app.js")[0] + + expect(() => plugin.config({}, { command: "serve", mode: "development" })).toThrow(/Unable to find the certificate files/) + }) + + it("handles invalid APP_URL", () => { + process.env.VITE_SERVER_KEY = "path/to/key" + process.env.VITE_SERVER_CERT = "path/to/cert" + process.env.APP_URL = "invalid-url" + + const plugin = litestar("resources/js/app.js")[0] + expect(() => plugin.config({}, { command: "serve", mode: "development" })).toThrow(/Unable to find the certificate files specified in your environment/) + }) + + it("handles missing config directory", () => { + const plugin = litestar({ + input: "resources/js/app.js", + detectTls: true, + })[0] + + expect(() => plugin.config({}, { command: "serve", mode: "development" })).toThrow(/Unable to find the configuration file/) + }) }) describe("inertia-helpers", () => { const testPath = "./__data__/dummy.ts" @@ -511,14 +529,14 @@ describe("inertia-helpers", () => { beforeEach(() => { vi.resetModules() // Mock the import.meta.glob functionality - vi.mock('./__data__/dummy.ts', () => ({ - default: "Dummy File" + vi.mock("./__data__/dummy.ts", () => ({ + default: "Dummy File", })) }) it("pass glob value to resolvePageComponent", async () => { const pages = { - [testPath]: Promise.resolve({ default: "Dummy File" }) + [testPath]: Promise.resolve({ default: "Dummy File" }), } const file = await resolvePageComponent<{ default: string }>(testPath, pages) @@ -527,7 +545,7 @@ describe("inertia-helpers", () => { it("pass eagerly globed value to resolvePageComponent", async () => { const pages = { - [testPath]: { default: "Dummy File" } + [testPath]: { default: "Dummy File" }, } // @ts-ignore const file = await resolvePageComponent<{ default: string }>(testPath, pages) @@ -536,52 +554,138 @@ describe("inertia-helpers", () => { it("accepts array of paths", async () => { const pages = { - [testPath]: { default: "Dummy File" } + [testPath]: { default: "Dummy File" }, } const file = await resolvePageComponent<{ default: string }>( ["missing-page", testPath], // @ts-ignore - pages + pages, ) expect(file.default).toBe("Dummy File") }) it("throws an error when a page is not found", async () => { const pages = {} - await expect( - resolvePageComponent<{ default: string }>("missing-page", pages) - ).rejects.toThrow("Page not found: missing-page") + await expect(resolvePageComponent<{ default: string }>("missing-page", pages)).rejects.toThrow("Page not found: missing-page") }) -}) -describe("route helpers", () => { - beforeEach(() => { - globalThis.routes = { - home: "/", - "users:get": "/api/users/get/{user_id:uuid}", - "users:list": "/api/users/list", - // ... other routes ... - } - }) + describe("route() edge cases", () => { + it("handles missing route names", () => { + expect(route("non-existent-route")).toBe("#") + }) - describe("route()", () => { - it("generates URLs from route names with named parameters", () => { - const result = route("users:get", { user_id: "123e4567-e89b-12d3-a456-426614174000" }) + it("handles array arguments", () => { + const result = route("users:get", ["123e4567-e89b-12d3-a456-426614174000"]) expect(result).toContain("/api/users/get/123e4567-e89b-12d3-a456-426614174000") }) - it("handles missing parameters", () => { - expect(route("users:get")).toBe("#") + it("handles wrong number of arguments", () => { + expect(route("users:get", [])).toBe("#") + }) + + it("handles missing arguments in object", () => { + expect(route("users:get", { wrong_id: "123" })).toBe("#") }) }) describe("getRelativeUrlPath()", () => { - it("extracts relative path from full URL", () => { - const result = getRelativeUrlPath("http://example.com/api/users/get/123e4567-e89b-12d3-a456-426614174000?page=1#section") - expect(result).toBe("/api/users/get/123e4567-e89b-12d3-a456-426614174000?page=1#section") + it("handles invalid URLs", () => { + expect(getRelativeUrlPath("invalid-url")).toBe("invalid-url") + }) + + it("preserves query parameters and hash", () => { + expect(getRelativeUrlPath("http://example.com/path?query=1#hash")).toBe("/path?query=1#hash") }) }) - // ... other route helper tests ... + describe("toRoute()", () => { + it("handles root path", () => { + expect(toRoute("/")).toBe("home") + }) + + it("handles UUID parameters", () => { + expect(toRoute("/api/users/get/123e4567-e89b-12d3-a456-426614174000")).toBe("users:get") + }) + + it("handles path parameters", () => { + expect(toRoute("/saq/static/some/deep/path")).toBe("saq") + }) + + it("handles non-matching routes", () => { + expect(toRoute("/non-existent")).toBe(null) + }) + + it("handles trailing slashes", () => { + expect(toRoute("/api/users/list/")).toBe("users:list") + }) + }) + + describe("currentRoute()", () => { + beforeEach(() => { + // Mock window.location + Object.defineProperty(window, "location", { + value: { + pathname: "/api/users/list", + }, + writable: true, + }) + }) + + it("returns current route name", () => { + expect(currentRoute()).toBe("users:list") + }) + + it("returns null for non-matching routes", () => { + window.location.pathname = "/non-existent" + expect(currentRoute()).toBe(null) + }) + }) + + describe("isRoute()", () => { + it("matches exact routes", () => { + expect(isRoute("/api/users/list", "users:list")).toBe(true) + }) + + it("matches routes with parameters", () => { + expect(isRoute("/api/users/get/123e4567-e89b-12d3-a456-426614174000", "users:*")).toBe(true) + }) + + it("handles non-matching routes", () => { + expect(isRoute("/non-existent", "users:*")).toBe(false) + }) + + it("matches routes with path parameters", () => { + expect(isRoute("/saq/static/deep/nested/path", "saq")).toBe(true) + }) + }) + + describe("isCurrentRoute()", () => { + beforeEach(() => { + // Mock window.location + Object.defineProperty(window, "location", { + value: { + pathname: "/api/users/list", + }, + writable: true, + }) + }) + + it("matches current route with pattern", () => { + expect(isCurrentRoute("users:*")).toBe(true) + }) + + it("handles exact matches", () => { + expect(isCurrentRoute("users:list")).toBe(true) + }) + + it("handles non-matching routes", () => { + expect(isCurrentRoute("teams:*")).toBe(false) + }) + + it("handles invalid current route", () => { + window.location.pathname = "/non-existent" + expect(isCurrentRoute("users:*")).toBe(false) + }) + }) }) diff --git a/uv.lock b/uv.lock index 443dd31..ac11f5c 100644 --- a/uv.lock +++ b/uv.lock @@ -1359,16 +1359,17 @@ wheels = [ [[package]] name = "nodejs-wheel-binaries" -version = "22.11.0" +version = "22.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/29/1ae5b1d715cb0a3d72ca09cacc4f75d274010bddb2372c61b981a068e47d/nodejs_wheel_binaries-22.11.0.tar.gz", hash = "sha256:e67f4e4a646bba24baa2150460c9cfbde0f75169ba37e58a2341930a5c1456ee", size = 7004 } +sdist = { url = "https://files.pythonhosted.org/packages/4b/a0/e9c4a93a09da08395044219af1adceb8c7e9bfca2b6b3ec35374a265851a/nodejs_wheel_binaries-22.12.0.tar.gz", hash = "sha256:e9b707766498322bb2b79fb4f6f425ef86904feccc72cc426f8d96f75488518e", size = 7869 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/16/4cd2c0791567ee7b0203c3c6b59341854f0aeecb7315159d634c2b54b6d4/nodejs_wheel_binaries-22.11.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:00afada277fd6e945a74f881831aaf1bb7f853a15e15e8c998238ab88d327f6a", size = 50288370 }, - { url = "https://files.pythonhosted.org/packages/c9/94/0677ace2cdd1a7ce61d7b2fedafb76818aaee94c14e817fd990f45f4f125/nodejs_wheel_binaries-22.11.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:f29471263d65a66520a04a0e74ff641a775df1135283f0b4d1826048932b289d", size = 51107812 }, - { url = "https://files.pythonhosted.org/packages/d4/69/a3d4c761044de8cedd962b20ed4ae4d8a574b4e3e015f8a111549b815b62/nodejs_wheel_binaries-22.11.0-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2ff0e20389f22927e311ccab69c1ecb34c3431fa809d1548e7000dc8248680", size = 56384790 }, - { url = "https://files.pythonhosted.org/packages/d8/16/e34cf573096e7b25c85829e99f7e47d6cda0a6cdc4bd078d6bcdcb4dc979/nodejs_wheel_binaries-22.11.0-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9545cc43f1ba2c9f467f3444e9cd7f8db059933be1a5215135610dee5b38bf3", size = 56713946 }, - { url = "https://files.pythonhosted.org/packages/a6/8a/ec28fe41159f51d9147d141d446305ce0494cb79966b79cd308c6bea9911/nodejs_wheel_binaries-22.11.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:43a277cbabf4b68e0a4798578a4f17e5f518ada1f79a174bfde06eb2bb47e730", size = 58814450 }, - { url = "https://files.pythonhosted.org/packages/68/69/f0dbbf72c8bdd9149ee00427c282d392da7fad9c53bd96f4844c2ab9021c/nodejs_wheel_binaries-22.11.0-py2.py3-none-win_amd64.whl", hash = "sha256:8310ab182ee159141e08c85bc07f11e67ac3044922e6e4958f4a8f3ba6860185", size = 39331516 }, + { url = "https://files.pythonhosted.org/packages/41/9e/d3208b2f3e8962b3abc3c623f21a8d242a1babe7770137f54b2fe76729ea/nodejs_wheel_binaries-22.12.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ed26eac5a96f3bfcd36f1eb9c397f07392e8b166895e0a65eb6033baefa0217", size = 51050283 }, + { url = "https://files.pythonhosted.org/packages/62/61/e90e791462aab3c0dedbdd96bd8973fc61629a96c82ea13a62ffad00d559/nodejs_wheel_binaries-22.12.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ca853c919e18860eb75ac3563985391d07eb50f6ab0aadcb48842bcc0ee8dd45", size = 51855510 }, + { url = "https://files.pythonhosted.org/packages/87/f1/3b35a777b9206fa99d690de24f1c003a1448ff14567d80d8bf2ab9994ec5/nodejs_wheel_binaries-22.12.0-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6df3f2606e26d3334de1fc15c4e6a833980a02ed2eae528d90ddaacaaab2dd1", size = 57176964 }, + { url = "https://files.pythonhosted.org/packages/6c/3e/9a925efa56fbbdc6c262e6a5b5e2d8ec3695f840822a1d58b69444e155e1/nodejs_wheel_binaries-22.12.0-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:335176ffa5f753c4d447af83f70987f5f82becdf84fadf56d5c0036379826cff", size = 57510549 }, + { url = "https://files.pythonhosted.org/packages/a2/76/2b6d0fdb3a1cc47fc95c3f6d98376844870e2e46881749a65177689ff2fa/nodejs_wheel_binaries-22.12.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:44f0b372a9ea218be885d14b370e774366d09697ae193b741b83cf04c6753884", size = 59632749 }, + { url = "https://files.pythonhosted.org/packages/7f/7e/6ef2d8a0cd18075a669c06d1e07c5790997ac79d57609e97aec0a536b347/nodejs_wheel_binaries-22.12.0-py2.py3-none-win_amd64.whl", hash = "sha256:d1a63fc84b0a5a94b19f428ecc1eb1bf4d09c9fc26390db52231c01c3667fe82", size = 40123839 }, + { url = "https://files.pythonhosted.org/packages/6e/f8/52c23c74ffe1b5f6f17d69b6193e2cf6a766929934e52409b377214a1b6a/nodejs_wheel_binaries-22.12.0-py2.py3-none-win_arm64.whl", hash = "sha256:cc363752be784bf6496329060c53c4d8a993dca73815bb179b37dba5c6d6db1c", size = 35999369 }, ] [[package]]