diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.gitignore new file mode 100644 index 000000000000..0c60c8eeaee8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.gitignore @@ -0,0 +1,48 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +!*.d.ts + +# Sentry +.sentryclirc + +.vscode + +test-results +event-dumps + +.tmp_dev_server_logs diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.npmrc new file mode 100644 index 000000000000..a3160f4de175 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.npmrc @@ -0,0 +1,4 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +public-hoist-pattern[]=*import-in-the-middle* +public-hoist-pattern[]=*require-in-the-middle* diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[...parameters]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[...parameters]/page.tsx new file mode 100644 index 000000000000..dab69e234139 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[...parameters]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[parameter]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[parameter]/page.tsx new file mode 100644 index 000000000000..dab69e234139 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[parameter]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/link/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/link/page.tsx new file mode 100644 index 000000000000..de789f9af524 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/link/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

hello world

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/router-push/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/router-push/page.tsx new file mode 100644 index 000000000000..de789f9af524 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/router-push/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

hello world

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx new file mode 100644 index 000000000000..918c03de3d0a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx @@ -0,0 +1,25 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +export default function Page() { + const router = useRouter(); + + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/page.tsx new file mode 100644 index 000000000000..4f3c471f2ad3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/page.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( +
+

Nextjs basePath Test App

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/globals.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/globals.d.ts new file mode 100644 index 000000000000..109dbcd55648 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/globals.d.ts @@ -0,0 +1,4 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation-client.ts new file mode 100644 index 000000000000..4870c64e7959 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation-client.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next-env.d.ts new file mode 100644 index 000000000000..4f11a03dc6cc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next.config.js new file mode 100644 index 000000000000..591aec7c1ce0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next.config.js @@ -0,0 +1,10 @@ +const { withSentryConfig } = require('@sentry/nextjs'); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + basePath: '/my-app', +}; + +module.exports = withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json new file mode 100644 index 000000000000..48a0c69ae38a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json @@ -0,0 +1,30 @@ +{ + "name": "nextjs-15-basepath", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "@types/node": "^18.19.1", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.9", + "next": "15.4.2-canary.1", + "react": "beta", + "react-dom": "beta", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/playwright.config.mjs new file mode 100644 index 000000000000..38548e975851 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/playwright.config.mjs @@ -0,0 +1,25 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development') { + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts new file mode 100644 index 000000000000..067d2ead0b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.server.config.ts new file mode 100644 index 000000000000..067d2ead0b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.server.config.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/start-event-proxy.mjs new file mode 100644 index 000000000000..e8834a451788 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-15-basepath', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-15-basepath-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-transaction.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-transaction.test.ts new file mode 100644 index 000000000000..12fe814d2fff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-transaction.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Creates a pageload transaction for basePath root route with prefix', async ({ page }) => { + const clientPageloadTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => { + return transactionEvent?.transaction === '/my-app' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/my-app'); + + expect(await clientPageloadTransactionPromise).toBeDefined(); +}); + +test('Creates a dynamic pageload transaction for basePath dynamic route with prefix', async ({ page }) => { + const randomRoute = String(Math.random()); + + const clientPageloadTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => { + return ( + transactionEvent?.transaction === '/my-app/dynamic/:parameter' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/my-app/dynamic/${randomRoute}`); + + expect(await clientPageloadTransactionPromise).toBeDefined(); +}); + +test('Creates a dynamic pageload transaction for basePath dynamic catch-all route with prefix', async ({ page }) => { + const randomRoute = String(Math.random()); + + const clientPageloadTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => { + return ( + transactionEvent?.transaction === '/my-app/dynamic/:parameters*' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/my-app/dynamic/${randomRoute}/foo/bar/baz`); + + expect(await clientPageloadTransactionPromise).toBeDefined(); +}); + +test('Creates a navigation transaction for basePath router with prefix', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => { + return ( + transactionEvent?.transaction === '/my-app/navigation/:param/router-push' && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' + ); + }); + + await page.goto('/my-app/navigation'); + await page.waitForTimeout(1000); + await page.getByText('router.push()').click(); + + expect(await navigationTransactionPromise).toBeDefined(); +}); + +test('Creates a navigation transaction for basePath with prefix', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => { + return ( + transactionEvent?.transaction === '/my-app/navigation/:param/link' && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' + ); + }); + + await page.goto('/my-app/navigation'); + await page.waitForTimeout(1000); + await page.getByText('Normal Link').click(); + + expect(await navigationTransactionPromise).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tsconfig.json new file mode 100644 index 000000000000..a2672ddb4974 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "es2018", + "allowImportingTsExtensions": true, + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ], + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js", ".next/types/**/*.ts"], + "exclude": ["node_modules", "playwright.config.ts"] +} diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 425daeb3e558..4006496d4a23 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -72,6 +72,10 @@ const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { }; }; +const globalWithInjectedBasePath = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath: string | undefined; +}; + /* * The routing instrumentation needs to handle a few cases: * - Router operations: @@ -87,7 +91,9 @@ const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { /** Instruments the Next.js app router for navigation. */ export function appRouterInstrumentNavigation(client: Client): void { routerTransitionHandler = (href, navigationType) => { - const unparameterizedPathname = new URL(href, WINDOW.location.href).pathname; + const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; + const normalizedHref = basePath && !href.startsWith(basePath) ? `${basePath}${href}` : href; + const unparameterizedPathname = new URL(normalizedHref, WINDOW.location.href).pathname; const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname); const pathname = parameterizedPathname ?? unparameterizedPathname; @@ -206,11 +212,15 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', }; + const href = argArray[0]; + const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; + const normalizedHref = + basePath && typeof href === 'string' && !href.startsWith(basePath) ? `${basePath}${href}` : href; if (routerFunctionName === 'push') { - transactionName = transactionNameifyRouterArgument(argArray[0]); + transactionName = transactionNameifyRouterArgument(normalizedHref); transactionAttributes['navigation.type'] = 'router.push'; } else if (routerFunctionName === 'replace') { - transactionName = transactionNameifyRouterArgument(argArray[0]); + transactionName = transactionNameifyRouterArgument(normalizedHref); transactionAttributes['navigation.type'] = 'router.replace'; } else if (routerFunctionName === 'back') { transactionAttributes['navigation.type'] = 'router.back'; diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index 1e905d858f73..32e7db61b57b 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -10,6 +10,10 @@ export type CreateRouteManifestOptions = { * By default, route groups are stripped from paths following Next.js convention. */ includeRouteGroups?: boolean; + /** + * Base path for the application, if any. This will be prefixed to all routes. + */ + basePath?: string; }; let manifestCache: RouteManifest | null = null; @@ -192,7 +196,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route return manifestCache; } - const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, '', options?.includeRouteGroups); + const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, options?.basePath, options?.includeRouteGroups); const manifest: RouteManifest = { dynamicRoutes, diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index ddf761998e50..9c82e3af017c 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -147,7 +147,9 @@ function getFinalConfigObject( let routeManifest: RouteManifest | undefined; if (!userSentryOptions.disableManifestInjection) { - routeManifest = createRouteManifest(); + routeManifest = createRouteManifest({ + basePath: incomingUserNextConfigObject.basePath, + }); } setUpBuildTimeVariables(incomingUserNextConfigObject, userSentryOptions, releaseName); diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/about/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/about/page.tsx new file mode 100644 index 000000000000..e5752fa903b7 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/base-path/app/about/page.tsx @@ -0,0 +1 @@ +// about page diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/api/test/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/api/test/page.tsx new file mode 100644 index 000000000000..ec89ef596f93 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/base-path/app/api/test/page.tsx @@ -0,0 +1 @@ +// API test page diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/page.tsx new file mode 100644 index 000000000000..768d7a4f7757 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/base-path/app/page.tsx @@ -0,0 +1 @@ +// root page diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/users/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/users/[id]/page.tsx new file mode 100644 index 000000000000..a7307090717b --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/base-path/app/users/[id]/page.tsx @@ -0,0 +1 @@ +// users id dynamic page diff --git a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts new file mode 100644 index 000000000000..a1014b05c32c --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts @@ -0,0 +1,45 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; + +describe('basePath', () => { + test('should generate routes with base path prefix', () => { + const manifest = createRouteManifest({ + basePath: '/my-app', + appDirPath: path.join(__dirname, 'app'), + }); + + expect(manifest).toEqual({ + staticRoutes: [{ path: '/my-app' }, { path: '/my-app/about' }, { path: '/my-app/api/test' }], + dynamicRoutes: [ + { + path: '/my-app/users/:id', + regex: '^/my-app/users/([^/]+)$', + paramNames: ['id'], + }, + ], + }); + }); + + test('should validate dynamic route regex with base path', () => { + const manifest = createRouteManifest({ + basePath: '/my-app', + appDirPath: path.join(__dirname, 'app'), + }); + + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path === '/my-app/users/:id'); + const regex = new RegExp(dynamicRoute?.regex ?? ''); + + // Should match valid paths with base path + expect(regex.test('/my-app/users/123')).toBe(true); + expect(regex.test('/my-app/users/john-doe')).toBe(true); + + // Should not match paths without base path + expect(regex.test('/users/123')).toBe(false); + + // Should not match invalid paths + expect(regex.test('/my-app/users/')).toBe(false); + expect(regex.test('/my-app/users/123/extra')).toBe(false); + expect(regex.test('/my-app/user/123')).toBe(false); + }); +});