diff --git a/integration/models/__tests__/application.test.ts b/integration/models/__tests__/application.test.ts new file mode 100644 index 00000000000..6e2d52d0e2e --- /dev/null +++ b/integration/models/__tests__/application.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveServerUrl } from '../application'; + +describe('resolveServerUrl', () => { + describe('with opts.serverUrl', () => { + it('appends port to a URL without an explicit port', () => { + expect(resolveServerUrl('http://localhost', undefined, 3000)).toBe('http://localhost:3000'); + }); + + it('appends port to an https URL without an explicit port', () => { + expect(resolveServerUrl('https://example.com', undefined, 4000)).toBe('https://example.com:4000'); + }); + + it('preserves an explicit port in the URL', () => { + expect(resolveServerUrl('http://localhost:8080', undefined, 3000)).toBe('http://localhost:8080'); + }); + + it('handles a URL with a path (returns origin only)', () => { + expect(resolveServerUrl('http://localhost/some/path', undefined, 3000)).toBe('http://localhost:3000'); + }); + + it('handles a bare hostname by appending port', () => { + expect(resolveServerUrl('myhost', undefined, 5000)).toBe('myhost:5000'); + }); + + it('handles a bare IP address by appending port', () => { + expect(resolveServerUrl('127.0.0.1', undefined, 5000)).toBe('127.0.0.1:5000'); + }); + }); + + describe('with fallback serverUrl', () => { + it('uses fallback when opts.serverUrl is undefined', () => { + expect(resolveServerUrl(undefined, 'http://fallback:9000', 3000)).toBe('http://fallback:9000'); + }); + + it('prefers opts.serverUrl over fallback', () => { + expect(resolveServerUrl('http://localhost', 'http://fallback:9000', 3000)).toBe('http://localhost:3000'); + }); + }); + + describe('with no serverUrl at all', () => { + it('defaults to http://localhost with the given port', () => { + expect(resolveServerUrl(undefined, undefined, 4567)).toBe('http://localhost:4567'); + }); + + it('defaults when fallback is empty string', () => { + expect(resolveServerUrl(undefined, '', 4567)).toBe('http://localhost:4567'); + }); + }); +}); diff --git a/integration/models/application.ts b/integration/models/application.ts index fd4a4d00fa6..d0662225eb7 100644 --- a/integration/models/application.ts +++ b/integration/models/application.ts @@ -9,6 +9,31 @@ import type { EnvironmentConfig } from './environment.js'; export type Application = ReturnType; +/** + * Resolves the server URL for a dev/serve process, ensuring the runtime port + * is always reflected in the URL. Uses the URL constructor to detect whether + * an explicit port is present (avoiding false positives from the scheme colon). + */ +export const resolveServerUrl = ( + optsServerUrl: string | undefined, + fallbackServerUrl: string | undefined, + port: number, +): string => { + if (optsServerUrl) { + try { + const parsed = new URL(optsServerUrl); + if (!parsed.port) { + parsed.port = String(port); + } + return parsed.origin; + } catch { + // Bare host (e.g. "localhost"), not a full URL + return `${optsServerUrl}:${port}`; + } + } + return fallbackServerUrl || `http://localhost:${port}`; +}; + export const application = ( config: ApplicationConfig, appDirPath: string, @@ -58,13 +83,7 @@ export const application = ( dev: async (opts: { port?: number; manualStart?: boolean; detached?: boolean; serverUrl?: string } = {}) => { const log = logger.child({ prefix: 'dev' }).info; const port = opts.port || (await getPort()); - const getServerUrl = () => { - if (opts.serverUrl) { - return opts.serverUrl.includes(':') ? opts.serverUrl : `${opts.serverUrl}:${port}`; - } - return serverUrl || `http://localhost:${port}`; - }; - const runtimeServerUrl = getServerUrl(); + const runtimeServerUrl = resolveServerUrl(opts.serverUrl, serverUrl, port); log(`Will try to serve app at ${runtimeServerUrl}`); if (opts.manualStart) { // for debugging, you can start the dev server manually by cd'ing into the temp dir @@ -144,25 +163,59 @@ export const application = ( get serveOutput() { return serveOutput; }, - serve: async (opts: { port?: number; manualStart?: boolean } = {}) => { + serve: async (opts: { port?: number; manualStart?: boolean; detached?: boolean; serverUrl?: string } = {}) => { const log = logger.child({ prefix: 'serve' }).info; const port = opts.port || (await getPort()); - // TODO: get serverUrl as in dev() - const serverUrl = `http://localhost:${port}`; - // If this is ever used as a background process, we need to make sure - // it's not using the log function. See the dev() method above + const runtimeServerUrl = resolveServerUrl(opts.serverUrl, serverUrl, port); + log(`Will try to serve app at ${runtimeServerUrl}`); + + if (opts.manualStart) { + state.serverUrl = runtimeServerUrl; + return { port, serverUrl: runtimeServerUrl }; + } + + // Read .env file and pass as process env vars since production servers + // may not auto-load .env files (e.g., react-router-serve) + const envFromFile: Record = {}; + const envFilePath = path.resolve(appDirPath, '.env'); + if (fs.existsSync(envFilePath)) { + const envContent = fs.readFileSync(envFilePath, 'utf-8'); + for (const line of envContent.split('\n')) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) { + envFromFile[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1); + } + } + } + } + const proc = run(scripts.serve, { cwd: appDirPath, - env: { PORT: port.toString() }, - log: (msg: string) => { - serveOutput += `\n${msg}`; - log(msg); - }, + env: { ...envFromFile, PORT: port.toString() }, + detached: opts.detached, + stdout: opts.detached ? fs.openSync(stdoutFilePath, 'a') : undefined, + stderr: opts.detached ? fs.openSync(stderrFilePath, 'a') : undefined, + log: opts.detached + ? undefined + : (msg: string) => { + serveOutput += `\n${msg}`; + log(msg); + }, }); + + if (opts.detached) { + const shouldExit = () => !!proc.exitCode && proc.exitCode !== 0; + await waitForServer(runtimeServerUrl, { log, maxAttempts: Infinity, shouldExit }); + } else { + await waitForIdleProcess(proc); + } + + log(`Server started at ${runtimeServerUrl}, pid: ${proc.pid}`); cleanupFns.push(() => awaitableTreekill(proc.pid, 'SIGKILL')); - await waitForIdleProcess(proc); - state.serverUrl = serverUrl; - return { port, serverUrl, pid: proc }; + state.serverUrl = runtimeServerUrl; + return { port, serverUrl: runtimeServerUrl, pid: proc.pid }; }, stop: async () => { logger.info('Stopping...'); diff --git a/integration/models/longRunningApplication.ts b/integration/models/longRunningApplication.ts index b7e17ced2df..18be6c14204 100644 --- a/integration/models/longRunningApplication.ts +++ b/integration/models/longRunningApplication.ts @@ -59,6 +59,8 @@ export const longRunningApplication = (params: LongRunningApplicationParams) => // will be called by global.setup.ts and by the test runner // the first time this is called, the app starts and the state is persisted in the state file init: async () => { + const log = (msg: string) => console.log(`[${name}] ${msg}`); + log('Starting init...'); try { const publishableKey = params.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'); const secretKey = params.env.privateVariables.get('CLERK_SECRET_KEY'); @@ -66,8 +68,9 @@ export const longRunningApplication = (params: LongRunningApplicationParams) => const { instanceType, frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey); if (instanceType !== 'development') { - console.log('Clerk: skipping setup of testing tokens for non-development instance'); + log('Skipping setup of testing tokens for non-development instance'); } else { + log('Setting up testing tokens...'); await clerkSetup({ publishableKey, frontendApiUrl, @@ -76,13 +79,16 @@ export const longRunningApplication = (params: LongRunningApplicationParams) => apiUrl, dotenv: false, }); + log('Testing tokens setup complete'); } } catch (error) { console.error('Error setting up testing tokens:', error); throw error; } try { + log('Committing config...'); app = await config.commit(); + log(`Config committed, appDir: ${app.appDir}`); } catch (error) { console.error('Error committing config:', error); throw error; @@ -94,16 +100,35 @@ export const longRunningApplication = (params: LongRunningApplicationParams) => throw error; } try { + log('Running setup (pnpm install)...'); await app.setup(); + log('Setup complete'); } catch (error) { console.error('Error during app setup:', error); throw error; } try { - const { port, serverUrl, pid } = await app.dev({ detached: true }); - stateFile.addLongRunningApp({ port, serverUrl, pid, id, appDir: app.appDir, env: params.env.toJson() }); + log('Building app...'); + const buildTimeout = new Promise((_, reject) => + setTimeout(() => reject(new Error(`Build timed out after 120s for ${name}`)), 120_000), + ); + await Promise.race([app.build(), buildTimeout]); + log('Build complete'); } catch (error) { - console.error('Error during app dev:', error); + console.error('Error during app build:', error); + throw error; + } + try { + log('Starting serve (detached)...'); + const serveResult = await app.serve({ detached: true }); + port = serveResult.port; + serverUrl = serveResult.serverUrl; + pid = serveResult.pid; + appDir = app.appDir; + log(`Serve complete: port=${port}, serverUrl=${serverUrl}, pid=${pid}`); + stateFile.addLongRunningApp({ port, serverUrl, pid, id, appDir, env: params.env.toJson() }); + } catch (error) { + console.error('Error during app serve:', error); throw error; } }, @@ -126,9 +151,7 @@ export const longRunningApplication = (params: LongRunningApplicationParams) => setup: () => Promise.resolve(), withEnv: () => Promise.resolve(), teardown: () => Promise.resolve(), - build: () => { - throw new Error('build for long running apps is not supported yet'); - }, + build: () => Promise.resolve(), get name() { return name; }, diff --git a/integration/templates/astro-hybrid/astro.config.mjs b/integration/templates/astro-hybrid/astro.config.mjs index 8568979754d..9e5347065c2 100644 --- a/integration/templates/astro-hybrid/astro.config.mjs +++ b/integration/templates/astro-hybrid/astro.config.mjs @@ -1,9 +1,13 @@ import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; import clerk from '@clerk/astro'; import react from '@astrojs/react'; export default defineConfig({ output: 'hybrid', + adapter: node({ + mode: 'standalone', + }), integrations: [ clerk({ appearance: { @@ -15,6 +19,6 @@ export default defineConfig({ react(), ], server: { - port: Number(process.env.PORT), + port: process.env.PORT ? Number(process.env.PORT) : undefined, }, }); diff --git a/integration/templates/astro-hybrid/package.json b/integration/templates/astro-hybrid/package.json index 0577b94f676..deba74ac54c 100644 --- a/integration/templates/astro-hybrid/package.json +++ b/integration/templates/astro-hybrid/package.json @@ -4,14 +4,14 @@ "type": "module", "scripts": { "astro": "astro", - "build": "astro check && astro build", + "build": "astro build", "dev": "astro dev", "preview": "astro preview --port $PORT", "start": "astro dev --port $PORT" }, "dependencies": { "@astrojs/check": "^0.9.4", - "@astrojs/node": "^9.4.2", + "@astrojs/node": "^8.0.0", "@astrojs/react": "^3.6.2", "@types/react": "18.3.12", "@types/react-dom": "18.3.1", diff --git a/integration/templates/astro-node/astro.config.mjs b/integration/templates/astro-node/astro.config.mjs index 41e54f926f8..54bd79e7f1c 100644 --- a/integration/templates/astro-node/astro.config.mjs +++ b/integration/templates/astro-node/astro.config.mjs @@ -22,6 +22,6 @@ export default defineConfig({ tailwind(), ], server: { - port: Number(process.env.PORT), + port: process.env.PORT ? Number(process.env.PORT) : undefined, }, }); diff --git a/integration/templates/astro-node/package.json b/integration/templates/astro-node/package.json index d459a625002..9642a60ceac 100644 --- a/integration/templates/astro-node/package.json +++ b/integration/templates/astro-node/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "astro": "astro", - "build": "astro check && astro build", + "build": "astro build", "dev": "astro dev", "preview": "astro preview --port $PORT", "start": "astro dev --port $PORT" diff --git a/integration/templates/react-router-node/package.json b/integration/templates/react-router-node/package.json index 3bcf6de6ba8..5c1d1d77262 100644 --- a/integration/templates/react-router-node/package.json +++ b/integration/templates/react-router-node/package.json @@ -5,7 +5,7 @@ "scripts": { "build": "react-router build", "dev": "react-router dev --port $PORT", - "start": "react-router-serve ./build/server/index.js", + "start": "NODE_ENV=production react-router-serve ./build/server/index.js", "typecheck": "react-router typegen && tsc --build --noEmit" }, "dependencies": { diff --git a/integration/templates/react-router-node/vite.config.ts b/integration/templates/react-router-node/vite.config.ts index fb860e8215f..df191826314 100644 --- a/integration/templates/react-router-node/vite.config.ts +++ b/integration/templates/react-router-node/vite.config.ts @@ -10,6 +10,6 @@ export default defineConfig({ }), ], server: { - port: Number(process.env.PORT), + port: process.env.PORT ? Number(process.env.PORT) : undefined, }, }); diff --git a/integration/templates/react-vite/package.json b/integration/templates/react-vite/package.json index 97ace6085d8..9c68b22de69 100644 --- a/integration/templates/react-vite/package.json +++ b/integration/templates/react-vite/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "tsc && vite build", + "build": "vite build", "dev": "vite --port $PORT --no-open", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview --port $PORT --no-open" diff --git a/integration/templates/tanstack-react-start/package.json b/integration/templates/tanstack-react-start/package.json index 7121e87e555..e830bac5bdf 100644 --- a/integration/templates/tanstack-react-start/package.json +++ b/integration/templates/tanstack-react-start/package.json @@ -3,9 +3,9 @@ "private": true, "type": "module", "scripts": { - "build": "vite build && tsc --noEmit", + "build": "vite build", "dev": "vite dev --port=$PORT", - "start": "vite start --port=$PORT" + "start": "srvx --static ../client dist/server/server.js" }, "dependencies": { "@tanstack/react-router": "1.157.16", @@ -21,6 +21,7 @@ "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", + "srvx": "^0.11.2", "tailwindcss": "^4.0.8", "typescript": "^5.7.2", "vite": "^7.1.7", diff --git a/integration/templates/vue-vite/package.json b/integration/templates/vue-vite/package.json index 98cf8d6d186..c15b18cdcef 100644 --- a/integration/templates/vue-vite/package.json +++ b/integration/templates/vue-vite/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "vue-tsc -b && vite build", + "build": "vite build", "dev": "vite --port $PORT", "preview": "vite preview --port $PORT" }, diff --git a/integration/tests/global.setup.ts b/integration/tests/global.setup.ts index 377477b4979..9125fab770d 100644 --- a/integration/tests/global.setup.ts +++ b/integration/tests/global.setup.ts @@ -5,7 +5,7 @@ import { appConfigs } from '../presets'; import { fs, parseEnvOptions, startClerkJsHttpServer, startClerkUiHttpServer } from '../scripts'; setup('start long running apps', async () => { - setup.setTimeout(90_000); + setup.setTimeout(300_000); await fs.ensureDir(constants.TMP_DIR);