@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,409 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import spawn from "nano-spawn";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import yoctoSpinner from "yocto-spinner";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
|
|
9
|
+
//#region src/commands/e2e.ts
|
|
10
|
+
function sessionFileName(portOffset) {
|
|
11
|
+
return `.e2e-session-${portOffset}.json`;
|
|
12
|
+
}
|
|
13
|
+
const defaultPorts = {
|
|
14
|
+
mailpit: [{
|
|
15
|
+
host: 8025,
|
|
16
|
+
container: 8025
|
|
17
|
+
}, {
|
|
18
|
+
host: 1025,
|
|
19
|
+
container: 1025
|
|
20
|
+
}],
|
|
21
|
+
keycloak: [{
|
|
22
|
+
host: 8080,
|
|
23
|
+
container: 8080
|
|
24
|
+
}],
|
|
25
|
+
"redis-cache": [{
|
|
26
|
+
host: 6379,
|
|
27
|
+
container: 6379
|
|
28
|
+
}],
|
|
29
|
+
"redis-queue": [{
|
|
30
|
+
host: 6380,
|
|
31
|
+
container: 6379
|
|
32
|
+
}],
|
|
33
|
+
postgres: [{
|
|
34
|
+
host: 5432,
|
|
35
|
+
container: 5432
|
|
36
|
+
}],
|
|
37
|
+
mariadb: [{
|
|
38
|
+
host: 3306,
|
|
39
|
+
container: 3306
|
|
40
|
+
}]
|
|
41
|
+
};
|
|
42
|
+
async function getPresentAppNames(projectRoot) {
|
|
43
|
+
const appsDir = path.join(projectRoot, "apps");
|
|
44
|
+
try {
|
|
45
|
+
const entries = (await fs.readdir(appsDir, { withFileTypes: true })).filter((e) => e.isDirectory());
|
|
46
|
+
const names = [];
|
|
47
|
+
for (const e of entries) try {
|
|
48
|
+
await fs.access(path.join(appsDir, e.name, "package.json"));
|
|
49
|
+
names.push(e.name);
|
|
50
|
+
} catch {}
|
|
51
|
+
return names;
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Discover app that has Playwright config (for E2E test cwd). Returns app name (e.g. "one") or null. */
|
|
57
|
+
async function discoverE2EApp(projectRoot) {
|
|
58
|
+
const names = await getPresentAppNames(projectRoot);
|
|
59
|
+
for (const name of names) {
|
|
60
|
+
const appPath = path.join(projectRoot, "apps", name);
|
|
61
|
+
for (const config of [
|
|
62
|
+
"playwright.config.ts",
|
|
63
|
+
"playwright.config.js",
|
|
64
|
+
"playwright.config.mjs"
|
|
65
|
+
]) try {
|
|
66
|
+
await fs.access(path.join(appPath, config));
|
|
67
|
+
return name;
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
async function detectServices(projectRoot) {
|
|
73
|
+
const services = [];
|
|
74
|
+
services.push("mailpit");
|
|
75
|
+
if (await dirExists(path.join(projectRoot, "apps", "frappe"))) services.push("mariadb", "redis-cache", "redis-queue");
|
|
76
|
+
if ((await getPresentAppNames(projectRoot)).includes("keycloak")) services.push("keycloak", "postgres");
|
|
77
|
+
return services;
|
|
78
|
+
}
|
|
79
|
+
function generateCompose(services, portOffset, projectRoot) {
|
|
80
|
+
const compose = {
|
|
81
|
+
services: {},
|
|
82
|
+
volumes: {}
|
|
83
|
+
};
|
|
84
|
+
for (const service of services) {
|
|
85
|
+
const cfg = buildServiceConfig(service, portOffset, projectRoot);
|
|
86
|
+
if (cfg) {
|
|
87
|
+
compose.services[service] = cfg.service;
|
|
88
|
+
if (cfg.volumes) for (const vol of cfg.volumes) compose.volumes[vol] = {};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return YAML.stringify(compose);
|
|
92
|
+
}
|
|
93
|
+
function buildServiceConfig(service, portOffset, _projectRoot) {
|
|
94
|
+
switch (service) {
|
|
95
|
+
case "mailpit": return {
|
|
96
|
+
service: {
|
|
97
|
+
image: "axllent/mailpit",
|
|
98
|
+
restart: "unless-stopped",
|
|
99
|
+
ports: offsetPorts("mailpit", portOffset),
|
|
100
|
+
environment: {
|
|
101
|
+
MP_MAX_MESSAGES: "5000",
|
|
102
|
+
MP_SMTP_AUTH_ACCEPT_ANY: "1",
|
|
103
|
+
MP_SMTP_AUTH_ALLOW_INSECURE: "1"
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
volumes: ["mailpit"]
|
|
107
|
+
};
|
|
108
|
+
case "keycloak": return { service: {
|
|
109
|
+
image: "quay.io/keycloak/keycloak:latest",
|
|
110
|
+
restart: "unless-stopped",
|
|
111
|
+
ports: offsetPorts("keycloak", portOffset),
|
|
112
|
+
command: [
|
|
113
|
+
"start-dev",
|
|
114
|
+
"--http-relative-path=/auth",
|
|
115
|
+
`--hostname=http://localhost:${8080 + portOffset}`,
|
|
116
|
+
"--hostname-strict=false",
|
|
117
|
+
"--db=postgres",
|
|
118
|
+
"--db-url-host=postgres",
|
|
119
|
+
"--db-url-database=keycloak",
|
|
120
|
+
"--db-username=keycloak",
|
|
121
|
+
"--db-password=keycloak"
|
|
122
|
+
],
|
|
123
|
+
environment: {
|
|
124
|
+
KEYCLOAK_ADMIN: "admin",
|
|
125
|
+
KEYCLOAK_ADMIN_PASSWORD: "admin"
|
|
126
|
+
},
|
|
127
|
+
depends_on: ["postgres"]
|
|
128
|
+
} };
|
|
129
|
+
case "postgres": return {
|
|
130
|
+
service: {
|
|
131
|
+
image: "postgres:16.10-alpine",
|
|
132
|
+
restart: "unless-stopped",
|
|
133
|
+
ports: offsetPorts("postgres", portOffset),
|
|
134
|
+
environment: {
|
|
135
|
+
POSTGRES_DB: "keycloak",
|
|
136
|
+
POSTGRES_USER: "keycloak",
|
|
137
|
+
POSTGRES_PASSWORD: "keycloak"
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
volumes: ["postgres"]
|
|
141
|
+
};
|
|
142
|
+
case "redis-cache": return { service: {
|
|
143
|
+
image: "docker.io/redis:alpine",
|
|
144
|
+
restart: "unless-stopped",
|
|
145
|
+
ports: offsetPorts("redis-cache", portOffset)
|
|
146
|
+
} };
|
|
147
|
+
case "redis-queue": return { service: {
|
|
148
|
+
image: "docker.io/redis:alpine",
|
|
149
|
+
restart: "unless-stopped",
|
|
150
|
+
ports: offsetPorts("redis-queue", portOffset)
|
|
151
|
+
} };
|
|
152
|
+
case "mariadb": return {
|
|
153
|
+
service: {
|
|
154
|
+
image: "mariadb:10.6",
|
|
155
|
+
restart: "unless-stopped",
|
|
156
|
+
ports: offsetPorts("mariadb", portOffset),
|
|
157
|
+
environment: {
|
|
158
|
+
MARIADB_DATABASE: "frappe",
|
|
159
|
+
MARIADB_ROOT_PASSWORD: "root",
|
|
160
|
+
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "no"
|
|
161
|
+
},
|
|
162
|
+
healthcheck: {
|
|
163
|
+
test: [
|
|
164
|
+
"CMD",
|
|
165
|
+
"mariadb-admin",
|
|
166
|
+
"ping",
|
|
167
|
+
"-h",
|
|
168
|
+
"127.0.0.1",
|
|
169
|
+
"-uroot",
|
|
170
|
+
"-proot"
|
|
171
|
+
],
|
|
172
|
+
interval: "5s",
|
|
173
|
+
timeout: "5s",
|
|
174
|
+
retries: 10
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
volumes: ["mariadb"]
|
|
178
|
+
};
|
|
179
|
+
default: return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function offsetPorts(service, offset) {
|
|
183
|
+
const portDefs = defaultPorts[service];
|
|
184
|
+
if (!portDefs) return [];
|
|
185
|
+
return portDefs.map((p) => `${p.host + offset}:${p.container}`);
|
|
186
|
+
}
|
|
187
|
+
async function runE2ESession(projectRoot, options) {
|
|
188
|
+
const portOffset = options.portOffset ?? 1e3;
|
|
189
|
+
const projectName = options.project ?? `mp1-test-${crypto.randomBytes(3).toString("hex")}`;
|
|
190
|
+
const sessionFilePath = path.join(projectRoot, sessionFileName(portOffset));
|
|
191
|
+
if (options.down) {
|
|
192
|
+
if (await fileExists(sessionFilePath)) await tearDown(projectRoot, sessionFilePath);
|
|
193
|
+
else console.log("No active e2e session found.");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const spinner = yoctoSpinner({ text: "Detecting services..." }).start();
|
|
197
|
+
const services = await detectServices(projectRoot);
|
|
198
|
+
spinner.success(`Detected services: ${services.join(", ")}`);
|
|
199
|
+
const composeContent = generateCompose(services, portOffset, projectRoot);
|
|
200
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "mp1-e2e-"));
|
|
201
|
+
const composeFile = path.join(tmpDir, "docker-compose.yaml");
|
|
202
|
+
await fs.writeFile(composeFile, composeContent);
|
|
203
|
+
const sessionInfo = {
|
|
204
|
+
projectName,
|
|
205
|
+
composeFile,
|
|
206
|
+
tmpDir,
|
|
207
|
+
portOffset
|
|
208
|
+
};
|
|
209
|
+
await fs.writeFile(sessionFilePath, JSON.stringify(sessionInfo, null, 2));
|
|
210
|
+
if (!options.noBuild) {
|
|
211
|
+
const buildSpinner = yoctoSpinner({ text: "Building images..." }).start();
|
|
212
|
+
try {
|
|
213
|
+
await spawn("docker", [
|
|
214
|
+
"compose",
|
|
215
|
+
"-f",
|
|
216
|
+
composeFile,
|
|
217
|
+
"-p",
|
|
218
|
+
projectName,
|
|
219
|
+
"build"
|
|
220
|
+
], {
|
|
221
|
+
stdio: "pipe",
|
|
222
|
+
cwd: projectRoot
|
|
223
|
+
});
|
|
224
|
+
buildSpinner.success("Images built");
|
|
225
|
+
} catch {
|
|
226
|
+
buildSpinner.info("No custom images to build");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const startSpinner = yoctoSpinner({ text: "Starting test environment..." }).start();
|
|
230
|
+
try {
|
|
231
|
+
await spawn("docker", [
|
|
232
|
+
"compose",
|
|
233
|
+
"-f",
|
|
234
|
+
composeFile,
|
|
235
|
+
"-p",
|
|
236
|
+
projectName,
|
|
237
|
+
"up",
|
|
238
|
+
"-d"
|
|
239
|
+
], {
|
|
240
|
+
stdio: "pipe",
|
|
241
|
+
cwd: projectRoot
|
|
242
|
+
});
|
|
243
|
+
startSpinner.success("Test environment started");
|
|
244
|
+
} catch (err) {
|
|
245
|
+
startSpinner.error("Failed to start test environment");
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
const waitSpinner = yoctoSpinner({ text: "Waiting for services to be ready..." }).start();
|
|
249
|
+
await waitForServices(services, portOffset);
|
|
250
|
+
waitSpinner.success("Services ready");
|
|
251
|
+
const urls = getServiceUrls(services, portOffset);
|
|
252
|
+
console.log("\nTest environment is ready!\n");
|
|
253
|
+
for (const [name, url] of Object.entries(urls)) console.log(` ${name}: ${url}`);
|
|
254
|
+
console.log("\nTo tear down: mpo test e2e --down\n");
|
|
255
|
+
if (options.upOnly) return;
|
|
256
|
+
const e2eApp = await discoverE2EApp(projectRoot);
|
|
257
|
+
if (!e2eApp) {
|
|
258
|
+
console.error("No E2E app found: no app under apps/ has playwright.config.ts or playwright.config.js.");
|
|
259
|
+
await tearDown(projectRoot, sessionFilePath);
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const playwrightArgs = ["playwright", "test"];
|
|
264
|
+
if (options.filter) playwrightArgs.push(options.filter);
|
|
265
|
+
const baseUrl = `http://localhost:${3e3 + portOffset}`;
|
|
266
|
+
const frappeUrl = `http://localhost:${8e3 + portOffset}`;
|
|
267
|
+
await spawn("pnpm", playwrightArgs, {
|
|
268
|
+
stdio: "inherit",
|
|
269
|
+
cwd: path.join(projectRoot, "apps", e2eApp),
|
|
270
|
+
env: {
|
|
271
|
+
...process.env,
|
|
272
|
+
BASE_URL: baseUrl,
|
|
273
|
+
FRAPPE_URL: frappeUrl,
|
|
274
|
+
FRAPPE_SOCKETIO_PORT: String(9e3 + portOffset),
|
|
275
|
+
MARIADB_PORT: String(3306 + portOffset),
|
|
276
|
+
REDIS_CACHE_PORT: String(6379 + portOffset),
|
|
277
|
+
REDIS_QUEUE_PORT: String(6380 + portOffset),
|
|
278
|
+
E2E_PORT_OFFSET: String(portOffset)
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
} finally {
|
|
282
|
+
await tearDown(projectRoot, sessionFilePath);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async function tearDown(projectRoot, sessionFilePath) {
|
|
286
|
+
const spinner = yoctoSpinner({ text: "Tearing down test environment..." }).start();
|
|
287
|
+
try {
|
|
288
|
+
const { projectName, composeFile, tmpDir } = JSON.parse(await fs.readFile(sessionFilePath, "utf8"));
|
|
289
|
+
await spawn("docker", [
|
|
290
|
+
"compose",
|
|
291
|
+
"-f",
|
|
292
|
+
composeFile,
|
|
293
|
+
"-p",
|
|
294
|
+
projectName,
|
|
295
|
+
"down",
|
|
296
|
+
"-v",
|
|
297
|
+
"--remove-orphans"
|
|
298
|
+
], {
|
|
299
|
+
stdio: "pipe",
|
|
300
|
+
cwd: projectRoot
|
|
301
|
+
});
|
|
302
|
+
await fs.rm(tmpDir, {
|
|
303
|
+
recursive: true,
|
|
304
|
+
force: true
|
|
305
|
+
});
|
|
306
|
+
await fs.rm(sessionFilePath, { force: true });
|
|
307
|
+
spinner.success("Test environment torn down");
|
|
308
|
+
} catch {
|
|
309
|
+
spinner.error("Failed to tear down (session may not exist)");
|
|
310
|
+
await fs.rm(sessionFilePath, { force: true }).catch(() => {});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function getServiceUrls(services, portOffset) {
|
|
314
|
+
const urls = {};
|
|
315
|
+
urls.Frontend = `http://localhost:${3e3 + portOffset}`;
|
|
316
|
+
if (services.includes("keycloak")) urls.Keycloak = `http://localhost:${8080 + portOffset}/auth`;
|
|
317
|
+
if (services.includes("mailpit")) urls.Mailpit = `http://localhost:${8025 + portOffset}`;
|
|
318
|
+
if (services.includes("mariadb")) urls["Frappe (MariaDB)"] = `localhost:${3306 + portOffset}`;
|
|
319
|
+
if (services.includes("redis-cache")) urls["Redis Cache"] = `localhost:${6379 + portOffset}`;
|
|
320
|
+
if (services.includes("postgres")) urls.Postgres = `localhost:${5432 + portOffset}`;
|
|
321
|
+
return urls;
|
|
322
|
+
}
|
|
323
|
+
async function dirExists(p) {
|
|
324
|
+
try {
|
|
325
|
+
return (await fs.stat(p)).isDirectory();
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
async function fileExists(p) {
|
|
331
|
+
try {
|
|
332
|
+
await fs.access(p);
|
|
333
|
+
return true;
|
|
334
|
+
} catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function sleep(ms) {
|
|
339
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Poll services for readiness instead of relying on a fixed sleep.
|
|
343
|
+
* Checks TCP connectivity for MariaDB, Redis, Postgres, and HTTP for Mailpit/Keycloak.
|
|
344
|
+
*/
|
|
345
|
+
async function waitForServices(services, portOffset) {
|
|
346
|
+
const maxRetries = 30;
|
|
347
|
+
const retryInterval = 2e3;
|
|
348
|
+
const checks = [];
|
|
349
|
+
for (const service of services) {
|
|
350
|
+
const ports = defaultPorts[service];
|
|
351
|
+
if (!ports) continue;
|
|
352
|
+
for (const portDef of ports) {
|
|
353
|
+
const hostPort = portDef.host + portOffset;
|
|
354
|
+
if (service === "mailpit" && portDef.container === 8025) checks.push({
|
|
355
|
+
name: `${service}:${hostPort}`,
|
|
356
|
+
check: () => checkHttp(`http://localhost:${hostPort}/api/v1/info`)
|
|
357
|
+
});
|
|
358
|
+
else if (service === "keycloak") checks.push({
|
|
359
|
+
name: `${service}:${hostPort}`,
|
|
360
|
+
check: () => checkHttp(`http://localhost:${hostPort}/auth/realms/master`)
|
|
361
|
+
});
|
|
362
|
+
else checks.push({
|
|
363
|
+
name: `${service}:${hostPort}`,
|
|
364
|
+
check: () => checkTcp("localhost", hostPort)
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const { name, check } of checks) {
|
|
369
|
+
let ready = false;
|
|
370
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
371
|
+
try {
|
|
372
|
+
ready = await check();
|
|
373
|
+
if (ready) break;
|
|
374
|
+
} catch {}
|
|
375
|
+
await sleep(retryInterval);
|
|
376
|
+
}
|
|
377
|
+
if (!ready) console.warn(`Warning: ${name} did not become ready within ${maxRetries * retryInterval / 1e3}s`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function checkTcp(host, port) {
|
|
381
|
+
const net = await import("node:net");
|
|
382
|
+
return new Promise((resolve) => {
|
|
383
|
+
const socket = new net.Socket();
|
|
384
|
+
socket.setTimeout(2e3);
|
|
385
|
+
socket.on("connect", () => {
|
|
386
|
+
socket.destroy();
|
|
387
|
+
resolve(true);
|
|
388
|
+
});
|
|
389
|
+
socket.on("error", () => {
|
|
390
|
+
socket.destroy();
|
|
391
|
+
resolve(false);
|
|
392
|
+
});
|
|
393
|
+
socket.on("timeout", () => {
|
|
394
|
+
socket.destroy();
|
|
395
|
+
resolve(false);
|
|
396
|
+
});
|
|
397
|
+
socket.connect(port, host);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async function checkHttp(url) {
|
|
401
|
+
try {
|
|
402
|
+
return (await fetch(url, { signal: AbortSignal.timeout(2e3) })).ok;
|
|
403
|
+
} catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
//#endregion
|
|
409
|
+
export { detectServices, discoverE2EApp, generateCompose, runE2ESession };
|