@multiplatform.one/cli 5.0.26 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/multiplatformOne.mjs +0 -21
- package/lib/bin/multiplatformOne.mjs +820 -552
- package/lib/commands/e2e.mjs +409 -0
- package/lib/commands/init.mjs +480 -0
- package/lib/generateVscode.mjs +167 -0
- package/lib/index.mjs +1 -0
- package/lib/types.mjs +1 -0
- package/package.json +37 -43
- package/scripts/clone.sh +17 -0
- package/scripts/frappe-bench.sh +9 -0
- package/scripts/frappe-bootstrap.sh +495 -0
- package/scripts/frappe-clean.sh +31 -0
- package/scripts/frappe-dev.sh +41 -0
- package/scripts/frappe-helpers.sh +94 -0
- package/scripts/frappe.sh +72 -0
- package/scripts/update.sh +29 -25
- package/src/bin/multiplatformOne.ts +604 -539
- package/src/commands/e2e.ts +515 -0
- package/src/commands/init.ts +682 -0
- package/src/generateVscode.ts +216 -0
- package/src/index.ts +0 -21
- package/src/types.ts +3 -26
- package/scripts/cookiecutter.sh +0 -20
- package/scripts/init.sh +0 -5
- package/types/.tsbuildinfo +0 -1
- package/types/bin/multiplatformOne.d.ts +0 -2
- package/types/bin/multiplatformOne.d.ts.map +0 -1
- package/types/index.d.ts +0 -2
- package/types/index.d.ts.map +0 -1
- package/types/types.d.ts +0 -28
- package/types/types.d.ts.map +0 -1
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import spawn from "nano-spawn";
|
|
6
|
+
import yoctoSpinner from "yocto-spinner";
|
|
7
|
+
import YAML from "yaml";
|
|
8
|
+
|
|
9
|
+
export interface E2EOptions {
|
|
10
|
+
upOnly?: boolean;
|
|
11
|
+
down?: boolean;
|
|
12
|
+
portOffset?: number;
|
|
13
|
+
project?: string;
|
|
14
|
+
noBuild?: boolean;
|
|
15
|
+
filter?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ServiceConfig {
|
|
19
|
+
image: string;
|
|
20
|
+
ports?: string[];
|
|
21
|
+
environment?: Record<string, string>;
|
|
22
|
+
volumes?: string[];
|
|
23
|
+
command?: string[];
|
|
24
|
+
depends_on?: string[];
|
|
25
|
+
restart?: string;
|
|
26
|
+
privileged?: boolean;
|
|
27
|
+
healthcheck?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sessionFileName(portOffset: number): string {
|
|
31
|
+
return `.e2e-session-${portOffset}.json`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const defaultPorts: Record<string, { host: number; container: number }[]> = {
|
|
35
|
+
mailpit: [
|
|
36
|
+
{ host: 8025, container: 8025 },
|
|
37
|
+
{ host: 1025, container: 1025 },
|
|
38
|
+
],
|
|
39
|
+
keycloak: [{ host: 8080, container: 8080 }],
|
|
40
|
+
"redis-cache": [{ host: 6379, container: 6379 }],
|
|
41
|
+
"redis-queue": [{ host: 6380, container: 6379 }],
|
|
42
|
+
postgres: [{ host: 5432, container: 5432 }],
|
|
43
|
+
mariadb: [{ host: 3306, container: 3306 }],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
async function getPresentAppNames(projectRoot: string): Promise<string[]> {
|
|
47
|
+
const appsDir = path.join(projectRoot, "apps");
|
|
48
|
+
try {
|
|
49
|
+
const entries = (await fs.readdir(appsDir, { withFileTypes: true })).filter((e) =>
|
|
50
|
+
e.isDirectory(),
|
|
51
|
+
);
|
|
52
|
+
const names: string[] = [];
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
try {
|
|
55
|
+
await fs.access(path.join(appsDir, e.name, "package.json"));
|
|
56
|
+
names.push(e.name);
|
|
57
|
+
} catch {
|
|
58
|
+
// no package.json
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return names;
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Discover app that has Playwright config (for E2E test cwd). Returns app name (e.g. "one") or null. */
|
|
68
|
+
export async function discoverE2EApp(projectRoot: string): Promise<string | null> {
|
|
69
|
+
const names = await getPresentAppNames(projectRoot);
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
const appPath = path.join(projectRoot, "apps", name);
|
|
72
|
+
for (const config of [
|
|
73
|
+
"playwright.config.ts",
|
|
74
|
+
"playwright.config.js",
|
|
75
|
+
"playwright.config.mjs",
|
|
76
|
+
]) {
|
|
77
|
+
try {
|
|
78
|
+
await fs.access(path.join(appPath, config));
|
|
79
|
+
return name;
|
|
80
|
+
} catch {
|
|
81
|
+
// continue
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function detectServices(projectRoot: string): Promise<string[]> {
|
|
89
|
+
const services: string[] = [];
|
|
90
|
+
|
|
91
|
+
// Always include base infrastructure
|
|
92
|
+
services.push("mailpit");
|
|
93
|
+
|
|
94
|
+
// Frappe needs MariaDB + Redis
|
|
95
|
+
if (await dirExists(path.join(projectRoot, "apps", "frappe"))) {
|
|
96
|
+
services.push("mariadb", "redis-cache", "redis-queue");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Keycloak needs Postgres (discover from present apps)
|
|
100
|
+
const presentApps = await getPresentAppNames(projectRoot);
|
|
101
|
+
if (presentApps.includes("keycloak")) {
|
|
102
|
+
services.push("keycloak", "postgres");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return services;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function generateCompose(
|
|
109
|
+
services: string[],
|
|
110
|
+
portOffset: number,
|
|
111
|
+
projectRoot: string,
|
|
112
|
+
): string {
|
|
113
|
+
const compose: {
|
|
114
|
+
services: Record<string, ServiceConfig>;
|
|
115
|
+
volumes: Record<string, Record<string, never>>;
|
|
116
|
+
} = {
|
|
117
|
+
services: {},
|
|
118
|
+
volumes: {},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
for (const service of services) {
|
|
122
|
+
const cfg = buildServiceConfig(service, portOffset, projectRoot);
|
|
123
|
+
if (cfg) {
|
|
124
|
+
compose.services[service] = cfg.service;
|
|
125
|
+
if (cfg.volumes) {
|
|
126
|
+
for (const vol of cfg.volumes) {
|
|
127
|
+
compose.volumes[vol] = {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return YAML.stringify(compose);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildServiceConfig(
|
|
137
|
+
service: string,
|
|
138
|
+
portOffset: number,
|
|
139
|
+
_projectRoot: string,
|
|
140
|
+
): { service: ServiceConfig; volumes?: string[] } | null {
|
|
141
|
+
switch (service) {
|
|
142
|
+
case "mailpit":
|
|
143
|
+
return {
|
|
144
|
+
service: {
|
|
145
|
+
image: "axllent/mailpit",
|
|
146
|
+
restart: "unless-stopped",
|
|
147
|
+
ports: offsetPorts("mailpit", portOffset),
|
|
148
|
+
environment: {
|
|
149
|
+
MP_MAX_MESSAGES: "5000",
|
|
150
|
+
MP_SMTP_AUTH_ACCEPT_ANY: "1",
|
|
151
|
+
MP_SMTP_AUTH_ALLOW_INSECURE: "1",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
volumes: ["mailpit"],
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
case "keycloak":
|
|
158
|
+
return {
|
|
159
|
+
service: {
|
|
160
|
+
image: "quay.io/keycloak/keycloak:latest",
|
|
161
|
+
restart: "unless-stopped",
|
|
162
|
+
ports: offsetPorts("keycloak", portOffset),
|
|
163
|
+
command: [
|
|
164
|
+
"start-dev",
|
|
165
|
+
"--http-relative-path=/auth",
|
|
166
|
+
`--hostname=http://localhost:${8080 + portOffset}`,
|
|
167
|
+
"--hostname-strict=false",
|
|
168
|
+
"--db=postgres",
|
|
169
|
+
"--db-url-host=postgres",
|
|
170
|
+
"--db-url-database=keycloak",
|
|
171
|
+
"--db-username=keycloak",
|
|
172
|
+
"--db-password=keycloak",
|
|
173
|
+
],
|
|
174
|
+
environment: {
|
|
175
|
+
KEYCLOAK_ADMIN: "admin",
|
|
176
|
+
KEYCLOAK_ADMIN_PASSWORD: "admin",
|
|
177
|
+
},
|
|
178
|
+
depends_on: ["postgres"],
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
case "postgres":
|
|
183
|
+
return {
|
|
184
|
+
service: {
|
|
185
|
+
image: "postgres:16.10-alpine",
|
|
186
|
+
restart: "unless-stopped",
|
|
187
|
+
ports: offsetPorts("postgres", portOffset),
|
|
188
|
+
environment: {
|
|
189
|
+
POSTGRES_DB: "keycloak",
|
|
190
|
+
POSTGRES_USER: "keycloak",
|
|
191
|
+
POSTGRES_PASSWORD: "keycloak",
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
volumes: ["postgres"],
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
case "redis-cache":
|
|
198
|
+
return {
|
|
199
|
+
service: {
|
|
200
|
+
image: "docker.io/redis:alpine",
|
|
201
|
+
restart: "unless-stopped",
|
|
202
|
+
ports: offsetPorts("redis-cache", portOffset),
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
case "redis-queue":
|
|
207
|
+
return {
|
|
208
|
+
service: {
|
|
209
|
+
image: "docker.io/redis:alpine",
|
|
210
|
+
restart: "unless-stopped",
|
|
211
|
+
ports: offsetPorts("redis-queue", portOffset),
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
case "mariadb":
|
|
216
|
+
return {
|
|
217
|
+
service: {
|
|
218
|
+
image: "mariadb:10.6",
|
|
219
|
+
restart: "unless-stopped",
|
|
220
|
+
ports: offsetPorts("mariadb", portOffset),
|
|
221
|
+
environment: {
|
|
222
|
+
MARIADB_DATABASE: "frappe",
|
|
223
|
+
MARIADB_ROOT_PASSWORD: "root",
|
|
224
|
+
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "no",
|
|
225
|
+
},
|
|
226
|
+
healthcheck: {
|
|
227
|
+
test: ["CMD", "mariadb-admin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"],
|
|
228
|
+
interval: "5s",
|
|
229
|
+
timeout: "5s",
|
|
230
|
+
retries: 10,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
volumes: ["mariadb"],
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
default:
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function offsetPorts(service: string, offset: number): string[] {
|
|
242
|
+
const portDefs = defaultPorts[service];
|
|
243
|
+
if (!portDefs) return [];
|
|
244
|
+
return portDefs.map((p) => `${p.host + offset}:${p.container}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function runE2ESession(projectRoot: string, options: E2EOptions): Promise<void> {
|
|
248
|
+
const portOffset = options.portOffset ?? 1000;
|
|
249
|
+
const projectName = options.project ?? `mp1-test-${crypto.randomBytes(3).toString("hex")}`;
|
|
250
|
+
const sessionFilePath = path.join(projectRoot, sessionFileName(portOffset));
|
|
251
|
+
|
|
252
|
+
// Handle --down: tear down existing session
|
|
253
|
+
if (options.down) {
|
|
254
|
+
if (await fileExists(sessionFilePath)) {
|
|
255
|
+
await tearDown(projectRoot, sessionFilePath);
|
|
256
|
+
} else {
|
|
257
|
+
console.log("No active e2e session found.");
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Detect services
|
|
263
|
+
const spinner = yoctoSpinner({ text: "Detecting services..." }).start();
|
|
264
|
+
const services = await detectServices(projectRoot);
|
|
265
|
+
spinner.success(`Detected services: ${services.join(", ")}`);
|
|
266
|
+
|
|
267
|
+
// Generate compose
|
|
268
|
+
const composeContent = generateCompose(services, portOffset, projectRoot);
|
|
269
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "mp1-e2e-"));
|
|
270
|
+
const composeFile = path.join(tmpDir, "docker-compose.yaml");
|
|
271
|
+
await fs.writeFile(composeFile, composeContent);
|
|
272
|
+
|
|
273
|
+
// Save session info
|
|
274
|
+
const sessionInfo = { projectName, composeFile, tmpDir, portOffset };
|
|
275
|
+
await fs.writeFile(sessionFilePath, JSON.stringify(sessionInfo, null, 2));
|
|
276
|
+
|
|
277
|
+
// Build images if needed
|
|
278
|
+
if (!options.noBuild) {
|
|
279
|
+
const buildSpinner = yoctoSpinner({ text: "Building images..." }).start();
|
|
280
|
+
try {
|
|
281
|
+
await spawn("docker", ["compose", "-f", composeFile, "-p", projectName, "build"], {
|
|
282
|
+
stdio: "pipe",
|
|
283
|
+
cwd: projectRoot,
|
|
284
|
+
});
|
|
285
|
+
buildSpinner.success("Images built");
|
|
286
|
+
} catch {
|
|
287
|
+
buildSpinner.info("No custom images to build");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Start services
|
|
292
|
+
const startSpinner = yoctoSpinner({ text: "Starting test environment..." }).start();
|
|
293
|
+
try {
|
|
294
|
+
await spawn("docker", ["compose", "-f", composeFile, "-p", projectName, "up", "-d"], {
|
|
295
|
+
stdio: "pipe",
|
|
296
|
+
cwd: projectRoot,
|
|
297
|
+
});
|
|
298
|
+
startSpinner.success("Test environment started");
|
|
299
|
+
} catch (err) {
|
|
300
|
+
startSpinner.error("Failed to start test environment");
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Wait for services to be ready with health checks
|
|
305
|
+
const waitSpinner = yoctoSpinner({ text: "Waiting for services to be ready..." }).start();
|
|
306
|
+
await waitForServices(services, portOffset);
|
|
307
|
+
waitSpinner.success("Services ready");
|
|
308
|
+
|
|
309
|
+
// Print URLs
|
|
310
|
+
const urls = getServiceUrls(services, portOffset);
|
|
311
|
+
console.log("\nTest environment is ready!\n");
|
|
312
|
+
for (const [name, url] of Object.entries(urls)) {
|
|
313
|
+
console.log(` ${name}: ${url}`);
|
|
314
|
+
}
|
|
315
|
+
console.log("\nTo tear down: mpo test e2e --down\n");
|
|
316
|
+
|
|
317
|
+
// If --up-only, stop here
|
|
318
|
+
if (options.upOnly) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Run Playwright tests (discover app that has playwright.config)
|
|
323
|
+
const e2eApp = await discoverE2EApp(projectRoot);
|
|
324
|
+
if (!e2eApp) {
|
|
325
|
+
console.error(
|
|
326
|
+
"No E2E app found: no app under apps/ has playwright.config.ts or playwright.config.js.",
|
|
327
|
+
);
|
|
328
|
+
await tearDown(projectRoot, sessionFilePath);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const playwrightArgs = ["playwright", "test"];
|
|
334
|
+
if (options.filter) {
|
|
335
|
+
playwrightArgs.push(options.filter);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const baseUrl = `http://localhost:${3000 + portOffset}`;
|
|
339
|
+
const frappeUrl = `http://localhost:${8000 + portOffset}`;
|
|
340
|
+
const e2eCwd = path.join(projectRoot, "apps", e2eApp);
|
|
341
|
+
|
|
342
|
+
await spawn("pnpm", playwrightArgs, {
|
|
343
|
+
stdio: "inherit",
|
|
344
|
+
cwd: e2eCwd,
|
|
345
|
+
env: {
|
|
346
|
+
...process.env,
|
|
347
|
+
BASE_URL: baseUrl,
|
|
348
|
+
FRAPPE_URL: frappeUrl,
|
|
349
|
+
FRAPPE_SOCKETIO_PORT: String(9000 + portOffset),
|
|
350
|
+
MARIADB_PORT: String(3306 + portOffset),
|
|
351
|
+
REDIS_CACHE_PORT: String(6379 + portOffset),
|
|
352
|
+
REDIS_QUEUE_PORT: String(6380 + portOffset),
|
|
353
|
+
E2E_PORT_OFFSET: String(portOffset),
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
} finally {
|
|
357
|
+
// Always tear down after running tests
|
|
358
|
+
await tearDown(projectRoot, sessionFilePath);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function tearDown(projectRoot: string, sessionFilePath: string): Promise<void> {
|
|
363
|
+
const spinner = yoctoSpinner({ text: "Tearing down test environment..." }).start();
|
|
364
|
+
try {
|
|
365
|
+
const sessionData = JSON.parse(await fs.readFile(sessionFilePath, "utf8"));
|
|
366
|
+
const { projectName, composeFile, tmpDir } = sessionData;
|
|
367
|
+
|
|
368
|
+
await spawn(
|
|
369
|
+
"docker",
|
|
370
|
+
["compose", "-f", composeFile, "-p", projectName, "down", "-v", "--remove-orphans"],
|
|
371
|
+
{ stdio: "pipe", cwd: projectRoot },
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
// Clean up temp files
|
|
375
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
376
|
+
await fs.rm(sessionFilePath, { force: true });
|
|
377
|
+
|
|
378
|
+
spinner.success("Test environment torn down");
|
|
379
|
+
} catch {
|
|
380
|
+
spinner.error("Failed to tear down (session may not exist)");
|
|
381
|
+
// Clean up session file even on failure
|
|
382
|
+
await fs.rm(sessionFilePath, { force: true }).catch(() => {});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function getServiceUrls(services: string[], portOffset: number): Record<string, string> {
|
|
387
|
+
const urls: Record<string, string> = {};
|
|
388
|
+
|
|
389
|
+
urls.Frontend = `http://localhost:${3000 + portOffset}`;
|
|
390
|
+
|
|
391
|
+
if (services.includes("keycloak")) {
|
|
392
|
+
urls.Keycloak = `http://localhost:${8080 + portOffset}/auth`;
|
|
393
|
+
}
|
|
394
|
+
if (services.includes("mailpit")) {
|
|
395
|
+
urls.Mailpit = `http://localhost:${8025 + portOffset}`;
|
|
396
|
+
}
|
|
397
|
+
if (services.includes("mariadb")) {
|
|
398
|
+
urls["Frappe (MariaDB)"] = `localhost:${3306 + portOffset}`;
|
|
399
|
+
}
|
|
400
|
+
if (services.includes("redis-cache")) {
|
|
401
|
+
urls["Redis Cache"] = `localhost:${6379 + portOffset}`;
|
|
402
|
+
}
|
|
403
|
+
if (services.includes("postgres")) {
|
|
404
|
+
urls.Postgres = `localhost:${5432 + portOffset}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return urls;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function dirExists(p: string): Promise<boolean> {
|
|
411
|
+
try {
|
|
412
|
+
const stat = await fs.stat(p);
|
|
413
|
+
return stat.isDirectory();
|
|
414
|
+
} catch {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
420
|
+
try {
|
|
421
|
+
await fs.access(p);
|
|
422
|
+
return true;
|
|
423
|
+
} catch {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function sleep(ms: number): Promise<void> {
|
|
429
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Poll services for readiness instead of relying on a fixed sleep.
|
|
434
|
+
* Checks TCP connectivity for MariaDB, Redis, Postgres, and HTTP for Mailpit/Keycloak.
|
|
435
|
+
*/
|
|
436
|
+
async function waitForServices(services: string[], portOffset: number): Promise<void> {
|
|
437
|
+
const maxRetries = 30;
|
|
438
|
+
const retryInterval = 2000;
|
|
439
|
+
|
|
440
|
+
const checks: { name: string; check: () => Promise<boolean> }[] = [];
|
|
441
|
+
|
|
442
|
+
for (const service of services) {
|
|
443
|
+
const ports = defaultPorts[service];
|
|
444
|
+
if (!ports) continue;
|
|
445
|
+
|
|
446
|
+
for (const portDef of ports) {
|
|
447
|
+
const hostPort = portDef.host + portOffset;
|
|
448
|
+
|
|
449
|
+
if (service === "mailpit" && portDef.container === 8025) {
|
|
450
|
+
checks.push({
|
|
451
|
+
name: `${service}:${hostPort}`,
|
|
452
|
+
check: () => checkHttp(`http://localhost:${hostPort}/api/v1/info`),
|
|
453
|
+
});
|
|
454
|
+
} else if (service === "keycloak") {
|
|
455
|
+
checks.push({
|
|
456
|
+
name: `${service}:${hostPort}`,
|
|
457
|
+
check: () => checkHttp(`http://localhost:${hostPort}/auth/realms/master`),
|
|
458
|
+
});
|
|
459
|
+
} else {
|
|
460
|
+
checks.push({
|
|
461
|
+
name: `${service}:${hostPort}`,
|
|
462
|
+
check: () => checkTcp("localhost", hostPort),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
for (const { name, check } of checks) {
|
|
469
|
+
let ready = false;
|
|
470
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
471
|
+
try {
|
|
472
|
+
ready = await check();
|
|
473
|
+
if (ready) break;
|
|
474
|
+
} catch {
|
|
475
|
+
// not ready yet
|
|
476
|
+
}
|
|
477
|
+
await sleep(retryInterval);
|
|
478
|
+
}
|
|
479
|
+
if (!ready) {
|
|
480
|
+
console.warn(
|
|
481
|
+
`Warning: ${name} did not become ready within ${(maxRetries * retryInterval) / 1000}s`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function checkTcp(host: string, port: number): Promise<boolean> {
|
|
488
|
+
const net = await import("node:net");
|
|
489
|
+
return new Promise((resolve) => {
|
|
490
|
+
const socket = new net.Socket();
|
|
491
|
+
socket.setTimeout(2000);
|
|
492
|
+
socket.on("connect", () => {
|
|
493
|
+
socket.destroy();
|
|
494
|
+
resolve(true);
|
|
495
|
+
});
|
|
496
|
+
socket.on("error", () => {
|
|
497
|
+
socket.destroy();
|
|
498
|
+
resolve(false);
|
|
499
|
+
});
|
|
500
|
+
socket.on("timeout", () => {
|
|
501
|
+
socket.destroy();
|
|
502
|
+
resolve(false);
|
|
503
|
+
});
|
|
504
|
+
socket.connect(port, host);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function checkHttp(url: string): Promise<boolean> {
|
|
509
|
+
try {
|
|
510
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
511
|
+
return response.ok;
|
|
512
|
+
} catch {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
}
|