Skip to content

Commit 20a5620

Browse files
authored
feat(e2e): switch long-running test apps from dev to build+serve (#7795)
1 parent 14c1ed7 commit 20a5620

13 files changed

Lines changed: 166 additions & 38 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { resolveServerUrl } from '../application';
4+
5+
describe('resolveServerUrl', () => {
6+
describe('with opts.serverUrl', () => {
7+
it('appends port to a URL without an explicit port', () => {
8+
expect(resolveServerUrl('http://localhost', undefined, 3000)).toBe('http://localhost:3000');
9+
});
10+
11+
it('appends port to an https URL without an explicit port', () => {
12+
expect(resolveServerUrl('https://example.com', undefined, 4000)).toBe('https://example.com:4000');
13+
});
14+
15+
it('preserves an explicit port in the URL', () => {
16+
expect(resolveServerUrl('http://localhost:8080', undefined, 3000)).toBe('http://localhost:8080');
17+
});
18+
19+
it('handles a URL with a path (returns origin only)', () => {
20+
expect(resolveServerUrl('http://localhost/some/path', undefined, 3000)).toBe('http://localhost:3000');
21+
});
22+
23+
it('handles a bare hostname by appending port', () => {
24+
expect(resolveServerUrl('myhost', undefined, 5000)).toBe('myhost:5000');
25+
});
26+
27+
it('handles a bare IP address by appending port', () => {
28+
expect(resolveServerUrl('127.0.0.1', undefined, 5000)).toBe('127.0.0.1:5000');
29+
});
30+
});
31+
32+
describe('with fallback serverUrl', () => {
33+
it('uses fallback when opts.serverUrl is undefined', () => {
34+
expect(resolveServerUrl(undefined, 'http://fallback:9000', 3000)).toBe('http://fallback:9000');
35+
});
36+
37+
it('prefers opts.serverUrl over fallback', () => {
38+
expect(resolveServerUrl('http://localhost', 'http://fallback:9000', 3000)).toBe('http://localhost:3000');
39+
});
40+
});
41+
42+
describe('with no serverUrl at all', () => {
43+
it('defaults to http://localhost with the given port', () => {
44+
expect(resolveServerUrl(undefined, undefined, 4567)).toBe('http://localhost:4567');
45+
});
46+
47+
it('defaults when fallback is empty string', () => {
48+
expect(resolveServerUrl(undefined, '', 4567)).toBe('http://localhost:4567');
49+
});
50+
});
51+
});

integration/models/application.ts

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,31 @@ import type { EnvironmentConfig } from './environment.js';
99

1010
export type Application = ReturnType<typeof application>;
1111

12+
/**
13+
* Resolves the server URL for a dev/serve process, ensuring the runtime port
14+
* is always reflected in the URL. Uses the URL constructor to detect whether
15+
* an explicit port is present (avoiding false positives from the scheme colon).
16+
*/
17+
export const resolveServerUrl = (
18+
optsServerUrl: string | undefined,
19+
fallbackServerUrl: string | undefined,
20+
port: number,
21+
): string => {
22+
if (optsServerUrl) {
23+
try {
24+
const parsed = new URL(optsServerUrl);
25+
if (!parsed.port) {
26+
parsed.port = String(port);
27+
}
28+
return parsed.origin;
29+
} catch {
30+
// Bare host (e.g. "localhost"), not a full URL
31+
return `${optsServerUrl}:${port}`;
32+
}
33+
}
34+
return fallbackServerUrl || `http://localhost:${port}`;
35+
};
36+
1237
export const application = (
1338
config: ApplicationConfig,
1439
appDirPath: string,
@@ -64,13 +89,7 @@ export const application = (
6489
dev: async (opts: { port?: number; manualStart?: boolean; detached?: boolean; serverUrl?: string } = {}) => {
6590
const log = logger.child({ prefix: 'dev' }).info;
6691
const port = opts.port || (await getPort());
67-
const getServerUrl = () => {
68-
if (opts.serverUrl) {
69-
return opts.serverUrl.includes(':') ? opts.serverUrl : `${opts.serverUrl}:${port}`;
70-
}
71-
return serverUrl || `http://localhost:${port}`;
72-
};
73-
const runtimeServerUrl = getServerUrl();
92+
const runtimeServerUrl = resolveServerUrl(opts.serverUrl, serverUrl, port);
7493
log(`Will try to serve app at ${runtimeServerUrl}`);
7594
if (opts.manualStart) {
7695
// for debugging, you can start the dev server manually by cd'ing into the temp dir
@@ -150,25 +169,59 @@ export const application = (
150169
get serveOutput() {
151170
return serveOutput;
152171
},
153-
serve: async (opts: { port?: number; manualStart?: boolean } = {}) => {
172+
serve: async (opts: { port?: number; manualStart?: boolean; detached?: boolean; serverUrl?: string } = {}) => {
154173
const log = logger.child({ prefix: 'serve' }).info;
155174
const port = opts.port || (await getPort());
156-
// TODO: get serverUrl as in dev()
157-
const serverUrl = `http://localhost:${port}`;
158-
// If this is ever used as a background process, we need to make sure
159-
// it's not using the log function. See the dev() method above
175+
const runtimeServerUrl = resolveServerUrl(opts.serverUrl, serverUrl, port);
176+
log(`Will try to serve app at ${runtimeServerUrl}`);
177+
178+
if (opts.manualStart) {
179+
state.serverUrl = runtimeServerUrl;
180+
return { port, serverUrl: runtimeServerUrl };
181+
}
182+
183+
// Read .env file and pass as process env vars since production servers
184+
// may not auto-load .env files (e.g., react-router-serve)
185+
const envFromFile: Record<string, string> = {};
186+
const envFilePath = path.resolve(appDirPath, '.env');
187+
if (fs.existsSync(envFilePath)) {
188+
const envContent = fs.readFileSync(envFilePath, 'utf-8');
189+
for (const line of envContent.split('\n')) {
190+
const trimmed = line.trim();
191+
if (trimmed && !trimmed.startsWith('#')) {
192+
const eqIdx = trimmed.indexOf('=');
193+
if (eqIdx > 0) {
194+
envFromFile[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
195+
}
196+
}
197+
}
198+
}
199+
160200
const proc = run(scripts.serve, {
161201
cwd: appDirPath,
162-
env: { PORT: port.toString() },
163-
log: (msg: string) => {
164-
serveOutput += `\n${msg}`;
165-
log(msg);
166-
},
202+
env: { ...envFromFile, PORT: port.toString() },
203+
detached: opts.detached,
204+
stdout: opts.detached ? fs.openSync(stdoutFilePath, 'a') : undefined,
205+
stderr: opts.detached ? fs.openSync(stderrFilePath, 'a') : undefined,
206+
log: opts.detached
207+
? undefined
208+
: (msg: string) => {
209+
serveOutput += `\n${msg}`;
210+
log(msg);
211+
},
167212
});
213+
214+
if (opts.detached) {
215+
const shouldExit = () => !!proc.exitCode && proc.exitCode !== 0;
216+
await waitForServer(runtimeServerUrl, { log, maxAttempts: Infinity, shouldExit });
217+
} else {
218+
await waitForIdleProcess(proc);
219+
}
220+
221+
log(`Server started at ${runtimeServerUrl}, pid: ${proc.pid}`);
168222
cleanupFns.push(() => awaitableTreekill(proc.pid, 'SIGKILL'));
169-
await waitForIdleProcess(proc);
170-
state.serverUrl = serverUrl;
171-
return { port, serverUrl, pid: proc };
223+
state.serverUrl = runtimeServerUrl;
224+
return { port, serverUrl: runtimeServerUrl, pid: proc.pid };
172225
},
173226
stop: async () => {
174227
logger.info('Stopping...');

integration/models/longRunningApplication.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,18 @@ export const longRunningApplication = (params: LongRunningApplicationParams) =>
5959
// will be called by global.setup.ts and by the test runner
6060
// the first time this is called, the app starts and the state is persisted in the state file
6161
init: async () => {
62+
const log = (msg: string) => console.log(`[${name}] ${msg}`);
63+
log('Starting init...');
6264
try {
6365
const publishableKey = params.env.publicVariables.get('CLERK_PUBLISHABLE_KEY');
6466
const secretKey = params.env.privateVariables.get('CLERK_SECRET_KEY');
6567
const apiUrl = params.env.privateVariables.get('CLERK_API_URL');
6668
const { instanceType, frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey);
6769

6870
if (instanceType !== 'development') {
69-
console.log('Clerk: skipping setup of testing tokens for non-development instance');
71+
log('Skipping setup of testing tokens for non-development instance');
7072
} else {
73+
log('Setting up testing tokens...');
7174
await clerkSetup({
7275
publishableKey,
7376
frontendApiUrl,
@@ -76,13 +79,16 @@ export const longRunningApplication = (params: LongRunningApplicationParams) =>
7679
apiUrl,
7780
dotenv: false,
7881
});
82+
log('Testing tokens setup complete');
7983
}
8084
} catch (error) {
8185
console.error('Error setting up testing tokens:', error);
8286
throw error;
8387
}
8488
try {
89+
log('Committing config...');
8590
app = await config.commit();
91+
log(`Config committed, appDir: ${app.appDir}`);
8692
} catch (error) {
8793
console.error('Error committing config:', error);
8894
throw error;
@@ -94,16 +100,35 @@ export const longRunningApplication = (params: LongRunningApplicationParams) =>
94100
throw error;
95101
}
96102
try {
103+
log('Running setup (pnpm install)...');
97104
await app.setup();
105+
log('Setup complete');
98106
} catch (error) {
99107
console.error('Error during app setup:', error);
100108
throw error;
101109
}
102110
try {
103-
const { port, serverUrl, pid } = await app.dev({ detached: true });
104-
stateFile.addLongRunningApp({ port, serverUrl, pid, id, appDir: app.appDir, env: params.env.toJson() });
111+
log('Building app...');
112+
const buildTimeout = new Promise((_, reject) =>
113+
setTimeout(() => reject(new Error(`Build timed out after 120s for ${name}`)), 120_000),
114+
);
115+
await Promise.race([app.build(), buildTimeout]);
116+
log('Build complete');
105117
} catch (error) {
106-
console.error('Error during app dev:', error);
118+
console.error('Error during app build:', error);
119+
throw error;
120+
}
121+
try {
122+
log('Starting serve (detached)...');
123+
const serveResult = await app.serve({ detached: true });
124+
port = serveResult.port;
125+
serverUrl = serveResult.serverUrl;
126+
pid = serveResult.pid;
127+
appDir = app.appDir;
128+
log(`Serve complete: port=${port}, serverUrl=${serverUrl}, pid=${pid}`);
129+
stateFile.addLongRunningApp({ port, serverUrl, pid, id, appDir, env: params.env.toJson() });
130+
} catch (error) {
131+
console.error('Error during app serve:', error);
107132
throw error;
108133
}
109134
},
@@ -126,9 +151,7 @@ export const longRunningApplication = (params: LongRunningApplicationParams) =>
126151
setup: () => Promise.resolve(),
127152
withEnv: () => Promise.resolve(),
128153
teardown: () => Promise.resolve(),
129-
build: () => {
130-
throw new Error('build for long running apps is not supported yet');
131-
},
154+
build: () => Promise.resolve(),
132155
get name() {
133156
return name;
134157
},

integration/templates/astro-hybrid/astro.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ export default defineConfig({
1818
react(),
1919
],
2020
server: {
21-
port: Number(process.env.PORT),
21+
port: process.env.PORT ? Number(process.env.PORT) : undefined,
2222
},
2323
});

integration/templates/astro-hybrid/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"type": "module",
55
"scripts": {
66
"astro": "astro",
7-
"build": "astro check && astro build",
7+
"build": "astro build",
88
"dev": "astro dev",
99
"preview": "astro preview --port $PORT",
1010
"start": "astro dev --port $PORT"

integration/templates/astro-node/astro.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ export default defineConfig({
2222
tailwind(),
2323
],
2424
server: {
25-
port: Number(process.env.PORT),
25+
port: process.env.PORT ? Number(process.env.PORT) : undefined,
2626
},
2727
});

integration/templates/astro-node/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"type": "module",
55
"scripts": {
66
"astro": "astro",
7-
"build": "astro check && astro build",
7+
"build": "astro build",
88
"dev": "astro dev",
99
"preview": "astro preview --port $PORT",
1010
"start": "astro dev --port $PORT"

integration/templates/react-router-node/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"scripts": {
66
"build": "react-router build",
77
"dev": "react-router dev --port $PORT",
8-
"start": "react-router-serve ./build/server/index.js",
8+
"start": "NODE_ENV=production react-router-serve ./build/server/index.js",
99
"typecheck": "react-router typegen && tsc --build --noEmit"
1010
},
1111
"dependencies": {

integration/templates/react-router-node/vite.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ export default defineConfig({
1010
}),
1111
],
1212
server: {
13-
port: Number(process.env.PORT),
13+
port: process.env.PORT ? Number(process.env.PORT) : undefined,
1414
},
1515
});

integration/templates/react-vite/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"build": "tsc && vite build",
7+
"build": "vite build",
88
"dev": "vite --port $PORT --no-open",
99
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
1010
"preview": "vite preview --port $PORT --no-open"

0 commit comments

Comments
 (0)