-
Notifications
You must be signed in to change notification settings - Fork 453
Expand file tree
/
Copy pathlongRunningApplication.ts
More file actions
265 lines (244 loc) · 8.23 KB
/
longRunningApplication.ts
File metadata and controls
265 lines (244 loc) · 8.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
import { parsePublishableKey } from '@clerk/shared/keys';
import { clerkSetup } from '@clerk/testing/playwright';
import { acquireProcessLock, awaitableTreekill, fs } from '../scripts';
import type { Application } from './application';
import type { ApplicationConfig } from './applicationConfig';
import type { EnvironmentConfig } from './environment';
import { environmentConfig } from './environment';
import { stateFile } from './stateFile';
const getPort = (_url: string) => {
if (!_url) {
return undefined;
}
const url = new URL(_url);
return Number.parseInt(url.port || (url.protocol === 'https:' ? '443' : '80'));
};
/**
* Check if a server is responding at the given URL.
*/
const isServerReady = async (url: string): Promise<boolean> => {
try {
const res = await fetch(url);
return res.ok;
} catch {
return false;
}
};
export type LongRunningApplication = ReturnType<typeof longRunningApplication>;
export type LongRunningApplicationParams = {
id: string;
config: ApplicationConfig;
env: EnvironmentConfig;
serverUrl?: string;
};
/**
* A long-running app is an app that is started once and then used for all tests.
* Its interface is the same as the Application and the ApplicationConfig interface,
* making it interchangeable with the Application and ApplicationConfig.
*
* init() is lazy and idempotent: it checks the state file first, and uses
* file-based locking to ensure only one process initializes each app.
*/
export const longRunningApplication = (params: LongRunningApplicationParams) => {
const { id } = params;
const name = `long-running--${params.id}`;
const config = params.config.clone().setName(name);
let app: Application;
let pid: number;
let port = getPort(params.serverUrl);
let serverUrl: string = params.serverUrl;
let appDir: string;
let env: EnvironmentConfig = params.env;
const readFromStateFile = () => {
if (!stateFile.getLongRunningApps() || [port, serverUrl, pid, appDir, env].filter(Boolean).length === 0) {
return;
}
const data = stateFile.getLongRunningApps()[id] || {};
port ||= data.port;
serverUrl ||= data.serverUrl;
pid ||= data.pid;
appDir ||= data.appDir;
env ||= environmentConfig().fromJson(data.env);
};
/**
* Try to adopt an already-running app from the state file.
* Returns true if the app is running and state was loaded.
*/
const tryAdoptFromStateFile = async (): Promise<boolean> => {
try {
const apps = stateFile.getLongRunningApps();
const data = apps?.[id];
if (!data?.serverUrl) {
return false;
}
const ready = await isServerReady(data.serverUrl);
if (ready) {
port = data.port;
serverUrl = data.serverUrl;
pid = data.pid;
appDir = data.appDir;
env = params.env;
return true;
}
return false;
} catch {
// State file may be partially written by another process — not an error
return false;
}
};
/**
* Perform the full app initialization: testing tokens, commit, install, build, serve.
*/
const doFullInit = async () => {
const log = (msg: string) => console.log(`[${name}] ${msg}`);
log('Starting full init...');
try {
const publishableKey = params.env.publicVariables.get('CLERK_PUBLISHABLE_KEY');
const secretKey = params.env.privateVariables.get('CLERK_SECRET_KEY');
const apiUrl = params.env.privateVariables.get('CLERK_API_URL');
const { instanceType, frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey);
if (instanceType !== 'development') {
log('Skipping setup of testing tokens for non-development instance');
} else {
log('Setting up testing tokens...');
await clerkSetup({
publishableKey,
frontendApiUrl,
secretKey,
// @ts-expect-error apiUrl is not a typed option for clerkSetup, but it is accepted at runtime.
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;
}
try {
await app.withEnv(params.env);
} catch (error) {
console.error('Error setting up environment:', error);
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 {
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 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;
}
};
const self = new Proxy(
{
/**
* Lazy, idempotent init. Safe to call from multiple Playwright workers.
* - If the app is already running (found in state file + server responds), reuses it.
* - Otherwise, acquires a file lock and initializes. Other workers wait for the lock.
*/
init: async () => {
const log = (msg: string) => console.log(`[${name}] ${msg}`);
// Fast path: already initialized in this process
if (serverUrl && (await isServerReady(serverUrl))) {
log('Already initialized in this process');
return;
}
// Check if another process already initialized this app
if (await tryAdoptFromStateFile()) {
log(`Adopted from state file: ${serverUrl}`);
return;
}
// Need to initialize — acquire lock to prevent duplicate work
log('Acquiring init lock...');
const releaseLock = await acquireProcessLock(id);
try {
// Double-check after acquiring lock (another process may have finished while we waited)
if (await tryAdoptFromStateFile()) {
log(`Adopted from state file after lock: ${serverUrl}`);
return;
}
// We hold the lock and the app is not running — do full init
await doFullInit();
} finally {
releaseLock();
}
},
// will be called by global.teardown.ts
destroy: async () => {
readFromStateFile();
console.log(`Destroying ${serverUrl}`);
await awaitableTreekill(pid, 'SIGKILL');
// TODO: Test whether this is necessary now that we have awaitableTreekill
await new Promise(res => setTimeout(res, 2000));
await fs.rm(appDir, { recursive: true, force: true });
},
// read the persisted state and behave like an app
commit: () => {
if (!serverUrl) {
readFromStateFile();
}
},
dev: () => ({ port, serverUrl, pid }),
setup: () => Promise.resolve(),
withEnv: () => Promise.resolve(),
teardown: () => Promise.resolve(),
build: () => Promise.resolve(),
get name() {
return name;
},
get id() {
return id;
},
get env() {
readFromStateFile();
return env;
},
get serverUrl() {
readFromStateFile();
return serverUrl;
},
},
{
get(target, prop: string) {
if (!(prop in target) && prop in config) {
return () => self;
}
return target[prop];
},
},
);
return self as any as Application & ApplicationConfig & typeof self;
};