@lenne.tech/cli 1.26.0 → 1.27.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.
@@ -165,6 +165,16 @@ function patchNuxtConfig(file) {
165
165
  * bracketed by `// >>> lt-dev:bridge >>>` markers.
166
166
  * 2. Hardcoded baseURL/host/url for `http://localhost:3001` →
167
167
  * `process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3001'`.
168
+ * 3. `webServer` wrapped in an `LT_DEV_ACTIVE` guard so Playwright reuses
169
+ * the App already served by `lt dev` / `lt dev test` instead of spawning
170
+ * its own (which would bind the wrong port and miss the isolated stack).
171
+ * 4. `ignoreHTTPSErrors: true` so Playwright's Chromium accepts the lt dev
172
+ * Caddy self-signed cert (required for `lt dev test` over HTTPS).
173
+ * 5. Shard-aware timeouts gated on `LT_DEV_TEST_SHARDS` — a `SHARDED` const
174
+ * plus relaxed `timeout` / `expect` / `navigationTimeout` / `actionTimeout`
175
+ * under sharded load only, so serial + CI keep their tight, fast-failing
176
+ * defaults. Each sub-patch is a graceful no-op on a non-standard config.
177
+ * 6. `slowMo: 10` → `0` (pointless per-action delay, multiplied across shards).
168
178
  */
169
179
  function patchPlaywrightConfig(file) {
170
180
  if (!(0, fs_1.existsSync)(file))
@@ -225,6 +235,67 @@ function patchPlaywrightConfig(file) {
225
235
  count++;
226
236
  }
227
237
  }
238
+ // 3. Wrap `webServer` in an `LT_DEV_ACTIVE` guard so Playwright does NOT
239
+ // start/manage its own server when the App is already served by
240
+ // `lt dev` / `lt dev test` (both export LT_DEV_ACTIVE + the App URL).
241
+ // The original array/object's closing `]`/`}` becomes the ternary's
242
+ // false branch, so no bracket-matching is needed. Idempotent.
243
+ if (!/webServer:\s*process\.env\.LT_DEV_ACTIVE/.test(after)) {
244
+ after = after.replace(/webServer:\s*([[{])/, (_match, open) => {
245
+ count++;
246
+ return `webServer: process.env.LT_DEV_ACTIVE ? undefined : ${open}`;
247
+ });
248
+ }
249
+ // 4. ignoreHTTPSErrors — accept the `lt dev` Caddy self-signed cert on
250
+ // `https://*.localhost` (Playwright's bundled Chromium uses its own trust
251
+ // store, so NODE_EXTRA_CA_CERTS alone is not enough). No-op in CI (http).
252
+ // Without this, `lt dev test` fails with ERR_CERT_AUTHORITY_INVALID.
253
+ if (!/ignoreHTTPSErrors/.test(after)) {
254
+ after = after.replace(/(\n(\s*)use:\s*\{)/, (_m, whole, indent) => {
255
+ count++;
256
+ return `${whole}\n${indent} ignoreHTTPSErrors: true,`;
257
+ });
258
+ }
259
+ // 5. Shard-aware timeouts — `lt dev test --shard N` runs N built stacks +
260
+ // N Chromium concurrently; the CPU saturates and SSR slows 2-3x. Relax
261
+ // timeouts ONLY under that load (the CLI exports LT_DEV_TEST_SHARDS), so
262
+ // serial + CI keep their tight values and fast-failure feedback. Each
263
+ // sub-patch is idempotent + a graceful no-op on non-standard configs.
264
+ if (!/const SHARDED\b/.test(after) && /export default defineConfig/.test(after)) {
265
+ const shardConst = '// `lt dev test --shard N` saturates the CPU (N built SSR servers + N Chromium),\n' +
266
+ '// slowing every navigation. Relax timeouts ONLY under that load — the CLI sets\n' +
267
+ "// LT_DEV_TEST_SHARDS — so serial + CI keep their tight, fast-failing defaults.\n" +
268
+ "const SHARDED = Number(process.env.LT_DEV_TEST_SHARDS || '0') > 1;\n\n";
269
+ after = after.replace(/(export default defineConfig)/, `${shardConst}$1`);
270
+ count++;
271
+ }
272
+ // 5a. per-test timeout (`isWindows ? A : B` form) → add the sharded branch.
273
+ if (/timeout:\s*isWindows\s*\?/.test(after) && !/timeout:\s*isWindows\s*\?[^,\n]*SHARDED/.test(after)) {
274
+ after = after.replace(/timeout:\s*isWindows\s*\?\s*([0-9_]+)\s*:\s*([0-9_]+|undefined)/, (_m, a, b) => {
275
+ count++;
276
+ return `timeout: isWindows ? ${a} : SHARDED ? 180_000 : ${b}`;
277
+ });
278
+ }
279
+ // 5b. expect.timeout (only when an `expect: { timeout: N }` already exists).
280
+ if (/expect:\s*\{\s*timeout:\s*[0-9_]+\s*\}/.test(after) && !/expect:\s*\{\s*timeout:\s*SHARDED/.test(after)) {
281
+ after = after.replace(/expect:\s*\{\s*timeout:\s*([0-9_]+)\s*\}/, (_m, t) => {
282
+ count++;
283
+ return `expect: { timeout: SHARDED ? 30_000 : ${t} }`;
284
+ });
285
+ }
286
+ // 5c. navigation/action ceilings under shard (inject into `use` if absent).
287
+ if (!/navigationTimeout/.test(after)) {
288
+ after = after.replace(/(\n(\s*)use:\s*\{)/, (_m, whole, indent) => {
289
+ count++;
290
+ return `${whole}\n${indent} actionTimeout: SHARDED ? 30_000 : undefined,\n${indent} navigationTimeout: SHARDED ? 60_000 : undefined,`;
291
+ });
292
+ }
293
+ // 6. slowMo: 10 → 0 — an artificial per-action delay, pointless and multiplied
294
+ // across N concurrent sharded browsers.
295
+ after = after.replace(/slowMo:\s*10\b/, () => {
296
+ count++;
297
+ return 'slowMo: 0';
298
+ });
228
299
  if (count === 0)
229
300
  return { file, patched: false, replacements: 0 };
230
301
  (0, fs_1.writeFileSync)(file, after, 'utf8');
@@ -13,7 +13,11 @@ exports.checkPortInUse = checkPortInUse;
13
13
  exports.killProcessGroup = killProcessGroup;
14
14
  exports.listenSnapshot = listenSnapshot;
15
15
  exports.rotateLogFile = rotateLogFile;
16
+ exports.runChildInherit = runChildInherit;
17
+ exports.runChildToFile = runChildToFile;
16
18
  exports.spawnDetached = spawnDetached;
19
+ exports.terminateProcessGroup = terminateProcessGroup;
20
+ exports.waitForHttp = waitForHttp;
17
21
  /**
18
22
  * Process + port helpers for `lt dev`.
19
23
  *
@@ -145,6 +149,60 @@ function rotateLogFile(logFile) {
145
149
  }
146
150
  return { archivePath, previousSize, rotated: true };
147
151
  }
152
+ /**
153
+ * Run a child to completion with inherited stdio. Resolves with the exit code.
154
+ *
155
+ * Counterpart of `spawnDetached`: foreground, synchronous-feeling, used for
156
+ * commands the user must see live output from (build, test runners).
157
+ * Errors during spawn resolve as exit code `1` so callers can branch on a
158
+ * single integer instead of try/catch.
159
+ */
160
+ function runChildInherit(cmd, args, opts) {
161
+ return new Promise((resolve) => {
162
+ const child = (0, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: 'inherit' });
163
+ child.on('error', () => resolve(1));
164
+ child.on('close', (code) => resolve(code));
165
+ });
166
+ }
167
+ /**
168
+ * Run a child to completion with stdout+stderr redirected to a log file.
169
+ * Resolves with the exit code (`1` on spawn error). Like `runChildInherit` but
170
+ * non-interleaving — used to run several children CONCURRENTLY (e.g. parallel
171
+ * Playwright shards) without their console output clobbering each other.
172
+ */
173
+ function runChildToFile(cmd, args, opts) {
174
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(opts.logFile), { recursive: true });
175
+ // Rotate so each run starts with a fresh log (one prior generation kept as
176
+ // `<logFile>.1`) instead of appending run-on-run.
177
+ rotateLogFile(opts.logFile);
178
+ const out = (0, fs_1.openSync)(opts.logFile, 'a');
179
+ const close = () => {
180
+ try {
181
+ (0, fs_1.closeSync)(out);
182
+ }
183
+ catch (_a) {
184
+ /* already closed */
185
+ }
186
+ };
187
+ return new Promise((resolve) => {
188
+ let child;
189
+ try {
190
+ child = (0, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['ignore', out, out] });
191
+ }
192
+ catch (_a) {
193
+ close();
194
+ return resolve(1);
195
+ }
196
+ child.on('error', () => {
197
+ close();
198
+ resolve(1);
199
+ });
200
+ child.on('close', (code) => {
201
+ close();
202
+ resolve(code);
203
+ });
204
+ });
205
+ }
148
206
  /**
149
207
  * Spawn a detached child whose stdio is redirected to a log file.
150
208
  *
@@ -188,3 +246,99 @@ function spawnDetached(cmd, args, opts) {
188
246
  }
189
247
  }
190
248
  }
249
+ /**
250
+ * Terminate a detached process group RELIABLY: SIGTERM the group, wait up to
251
+ * `graceMs` for it to exit, then SIGKILL anything still alive.
252
+ *
253
+ * Needed because a compiled NestJS API (`node dist`) installs shutdown hooks
254
+ * that catch SIGTERM and can hang on open Mongo connections — a single
255
+ * SIGTERM then "succeeds" (the call returns) while the process keeps
256
+ * listening on its port and holding DB connections. `lt dev test`'s
257
+ * residue-free teardown promise depends on the process actually being gone,
258
+ * so we escalate to SIGKILL after a grace period.
259
+ *
260
+ * Polls every 150ms so a process that exits cleanly returns near-instantly
261
+ * (only a hung process waits the full `graceMs`). Returns true if the process
262
+ * is gone by the end, false if it somehow survived even SIGKILL.
263
+ */
264
+ function terminateProcessGroup(pid_1) {
265
+ return __awaiter(this, arguments, void 0, function* (pid, graceMs = 4000) {
266
+ if (!(0, dev_state_1.isValidPid)(pid))
267
+ return false;
268
+ if (!(0, dev_state_1.isPidAlive)(pid))
269
+ return true;
270
+ // Phase 1 — graceful: SIGTERM the group (single-PID fallback inside).
271
+ killProcessGroup(pid);
272
+ const deadline = Date.now() + Math.max(0, graceMs);
273
+ while (Date.now() < deadline) {
274
+ if (!(0, dev_state_1.isPidAlive)(pid))
275
+ return true;
276
+ yield delay(150);
277
+ }
278
+ // Phase 2 — forced: SIGKILL the group, then the single PID.
279
+ if (!(0, dev_state_1.isPidAlive)(pid))
280
+ return true;
281
+ try {
282
+ process.kill(-pid, 'SIGKILL');
283
+ }
284
+ catch (_a) {
285
+ /* group already gone or pid is not a group leader */
286
+ }
287
+ try {
288
+ process.kill(pid, 'SIGKILL');
289
+ }
290
+ catch (_b) {
291
+ /* already dead */
292
+ }
293
+ yield delay(150);
294
+ return !(0, dev_state_1.isPidAlive)(pid);
295
+ });
296
+ }
297
+ /**
298
+ * Poll an HTTPS/HTTP URL until a matching response is observed or `timeoutMs`
299
+ * elapses.
300
+ *
301
+ * Used to wait for dev servers to become reachable before the next step
302
+ * (typically running a test suite). By default treats ANY HTTP status (1xx-5xx)
303
+ * as "up" — a 404 means the server is bound and answering, which is usually
304
+ * what we want to know. Pass `ready` to require a stricter status: an API
305
+ * readiness probe wants a real 2xx on `/meta`, because Caddy answers 502 while
306
+ * its upstream is still booting and the default predicate would accept that
307
+ * prematurely (the cause of the test-suite API-readiness race). Uses `curl`
308
+ * because it is universally available and handles HTTPS-with-self-signed-cert
309
+ * (Caddy) via `-k` for free.
310
+ *
311
+ * Resolves `true` on the first matching response, `false` on timeout. Never rejects.
312
+ */
313
+ function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status < 600) {
314
+ const start = Date.now();
315
+ return new Promise((resolve) => {
316
+ const tick = () => {
317
+ var _a;
318
+ const child = (0, child_process_1.spawn)('curl', ['-sk', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '2', url], {
319
+ stdio: ['ignore', 'pipe', 'ignore'],
320
+ });
321
+ let status = '';
322
+ (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (status += String(b)));
323
+ child.on('close', () => {
324
+ const code = Number(status.trim());
325
+ // `000` (curl could not connect) parses to 0 → never "ready".
326
+ if (Number.isFinite(code) && code > 0 && ready(code))
327
+ return resolve(true);
328
+ if (Date.now() - start > timeoutMs)
329
+ return resolve(false);
330
+ setTimeout(tick, 500);
331
+ });
332
+ child.on('error', () => {
333
+ if (Date.now() - start > timeoutMs)
334
+ return resolve(false);
335
+ setTimeout(tick, 500);
336
+ });
337
+ };
338
+ tick();
339
+ });
340
+ }
341
+ /** Promise-based delay used by the graceful→forced termination escalation. */
342
+ function delay(ms) {
343
+ return new Promise((resolve) => setTimeout(resolve, ms));
344
+ }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.apiNeedsPortPatch = apiNeedsPortPatch;
4
4
  exports.appNeedsPortPatch = appNeedsPortPatch;
5
5
  exports.deriveDbName = deriveDbName;
6
+ exports.deriveTestDbName = deriveTestDbName;
6
7
  exports.resolveLayout = resolveLayout;
7
8
  const fs_1 = require("fs");
8
9
  const path_1 = require("path");
@@ -33,7 +34,10 @@ function appNeedsPortPatch(appDir) {
33
34
  /target:\s*'http:\/\/localhost:3000'/.test(c) ||
34
35
  /baseURL:\s*'http:\/\/localhost:3001'/.test(c) ||
35
36
  /url:\s*'http:\/\/localhost:3001'/.test(c) ||
36
- /host:\s*'http:\/\/localhost:3001'/.test(c));
37
+ /host:\s*'http:\/\/localhost:3001'/.test(c) ||
38
+ // Unguarded Playwright `webServer` (no LT_DEV_ACTIVE guard) — patch it so
39
+ // `lt dev test`'s isolated stack is reused instead of a stray server.
40
+ (/webServer:\s*[[{]/.test(c) && !/webServer:\s*process\.env\.LT_DEV_ACTIVE/.test(c)));
37
41
  });
38
42
  }
39
43
  /** Read `dbName` from the API config (defaults to `<slug>-local`). */
@@ -49,6 +53,21 @@ function deriveDbName(apiDir, slug) {
49
53
  }
50
54
  return `${slug}-local`;
51
55
  }
56
+ /**
57
+ * Derive the dedicated database name for the `lt dev test` stack from the
58
+ * project's dev DB name. Distinct from both `<…>-local` (developer DB) and
59
+ * the API unit-test DB (`<…>-e2e`), so Playwright E2E never touches developer
60
+ * or API-test data.
61
+ *
62
+ * svl-sports-system-local → svl-sports-system-test
63
+ *
64
+ * Uses the `-test` suffix so it passes test-helper guards that only permit
65
+ * local/test databases (name ending in `-local` | `-ci` | `-e2e` | `-test`).
66
+ */
67
+ function deriveTestDbName(devDbName) {
68
+ const base = devDbName.replace(/-(local|dev)$/i, '');
69
+ return `${base}-test`;
70
+ }
52
71
  /**
53
72
  * Resolve layout starting from `cwd`. Walks up to find a workspace if
54
73
  * cwd is inside `projects/api/` or `projects/app/`.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.paths = void 0;
3
+ exports.paths = exports.TEST_SESSION_FILE = void 0;
4
4
  exports.allocateInternalPort = allocateInternalPort;
5
5
  exports.clearSession = clearSession;
6
6
  exports.isPidAlive = isPidAlive;
@@ -28,6 +28,8 @@ const path_1 = require("path");
28
28
  const REGISTRY_PATH = process.env.LT_DEV_REGISTRY_PATH || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'projects.json');
29
29
  const SESSION_DIR = '.lt-dev';
30
30
  const SESSION_FILE = 'state.json';
31
+ /** Session file for the ephemeral `lt dev test` stack (runs parallel to the dev session). */
32
+ exports.TEST_SESSION_FILE = 'state.test.json';
31
33
  /**
32
34
  * Allocate a free internal port for a Caddy upstream.
33
35
  *
@@ -44,8 +46,8 @@ function allocateInternalPort(start, taken) {
44
46
  throw new Error(`No free internal port in range [${start}, ${start + 1000})`);
45
47
  }
46
48
  /** Remove session state file (called by `lt dev down`). */
47
- function clearSession(root) {
48
- const file = (0, path_1.join)(root, SESSION_DIR, SESSION_FILE);
49
+ function clearSession(root, sessionFile = SESSION_FILE) {
50
+ const file = (0, path_1.join)(root, SESSION_DIR, sessionFile);
49
51
  if ((0, fs_1.existsSync)(file)) {
50
52
  try {
51
53
  (0, fs_1.rmSync)(file);
@@ -87,8 +89,8 @@ function loadRegistry() {
87
89
  return { projects: {}, version: 1 };
88
90
  }
89
91
  /** Load session state for a project root. */
90
- function loadSession(root) {
91
- const file = (0, path_1.join)(root, SESSION_DIR, SESSION_FILE);
92
+ function loadSession(root, sessionFile = SESSION_FILE) {
93
+ const file = (0, path_1.join)(root, SESSION_DIR, sessionFile);
92
94
  if (!(0, fs_1.existsSync)(file))
93
95
  return null;
94
96
  try {
@@ -126,10 +128,10 @@ function saveRegistry(reg) {
126
128
  }
127
129
  }
128
130
  /** Persist session state for a project root. */
129
- function saveSession(root, state) {
131
+ function saveSession(root, state, sessionFile = SESSION_FILE) {
130
132
  const dir = (0, path_1.join)(root, SESSION_DIR);
131
133
  (0, fs_1.mkdirSync)(dir, { recursive: true });
132
- (0, fs_1.writeFileSync)((0, path_1.join)(dir, SESSION_FILE), JSON.stringify(state, null, 2), 'utf8');
134
+ (0, fs_1.writeFileSync)((0, path_1.join)(dir, sessionFile), JSON.stringify(state, null, 2), 'utf8');
133
135
  }
134
136
  /** Collect all internal ports already claimed across the registry. */
135
137
  function takenInternalPorts(reg, excludeSlug) {