@lenne.tech/cli 1.26.1 → 1.28.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.
@@ -1,8 +1,18 @@
1
1
  "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
2
11
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.paths = void 0;
12
+ exports.paths = exports.TEST_SESSION_FILE = void 0;
4
13
  exports.allocateInternalPort = allocateInternalPort;
5
14
  exports.clearSession = clearSession;
15
+ exports.detectSlugConflict = detectSlugConflict;
6
16
  exports.isPidAlive = isPidAlive;
7
17
  exports.isValidPid = isValidPid;
8
18
  exports.loadRegistry = loadRegistry;
@@ -10,6 +20,7 @@ exports.loadSession = loadSession;
10
20
  exports.saveRegistry = saveRegistry;
11
21
  exports.saveSession = saveSession;
12
22
  exports.takenInternalPorts = takenInternalPorts;
23
+ exports.withRegistryLock = withRegistryLock;
13
24
  /**
14
25
  * State persistence for `lt dev`.
15
26
  *
@@ -28,6 +39,8 @@ const path_1 = require("path");
28
39
  const REGISTRY_PATH = process.env.LT_DEV_REGISTRY_PATH || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'projects.json');
29
40
  const SESSION_DIR = '.lt-dev';
30
41
  const SESSION_FILE = 'state.json';
42
+ /** Session file for the ephemeral `lt dev test` stack (runs parallel to the dev session). */
43
+ exports.TEST_SESSION_FILE = 'state.test.json';
31
44
  /**
32
45
  * Allocate a free internal port for a Caddy upstream.
33
46
  *
@@ -44,8 +57,8 @@ function allocateInternalPort(start, taken) {
44
57
  throw new Error(`No free internal port in range [${start}, ${start + 1000})`);
45
58
  }
46
59
  /** 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);
60
+ function clearSession(root, sessionFile = SESSION_FILE) {
61
+ const file = (0, path_1.join)(root, SESSION_DIR, sessionFile);
49
62
  if ((0, fs_1.existsSync)(file)) {
50
63
  try {
51
64
  (0, fs_1.rmSync)(file);
@@ -55,6 +68,21 @@ function clearSession(root) {
55
68
  }
56
69
  }
57
70
  }
71
+ /**
72
+ * Detect when `slug` is registered to a DIFFERENT checkout than `root`. Two
73
+ * clones of the same project share a package.json "name" → the same slug → the
74
+ * same Caddy block / internal ports / database, so running both via `lt dev`
75
+ * collides (and one's `lt dev down` can unroute the other). Returns null when
76
+ * there is no conflict (no registry entry, or the entry belongs to THIS checkout).
77
+ */
78
+ function detectSlugConflict(slug, root) {
79
+ const entry = loadRegistry().projects[slug];
80
+ if (!(entry === null || entry === void 0 ? void 0 : entry.path) || sameRealPath(entry.path, root))
81
+ return null;
82
+ const session = loadSession(entry.path);
83
+ const otherSessionAlive = !!session && [session.pids.api, session.pids.app].some((p) => typeof p === 'number' && isPidAlive(p));
84
+ return { otherPath: entry.path, otherSessionAlive };
85
+ }
58
86
  /** Check whether a process with the given PID is currently alive. */
59
87
  function isPidAlive(pid) {
60
88
  if (!isValidPid(pid))
@@ -87,8 +115,8 @@ function loadRegistry() {
87
115
  return { projects: {}, version: 1 };
88
116
  }
89
117
  /** Load session state for a project root. */
90
- function loadSession(root) {
91
- const file = (0, path_1.join)(root, SESSION_DIR, SESSION_FILE);
118
+ function loadSession(root, sessionFile = SESSION_FILE) {
119
+ const file = (0, path_1.join)(root, SESSION_DIR, sessionFile);
92
120
  if (!(0, fs_1.existsSync)(file))
93
121
  return null;
94
122
  try {
@@ -126,10 +154,10 @@ function saveRegistry(reg) {
126
154
  }
127
155
  }
128
156
  /** Persist session state for a project root. */
129
- function saveSession(root, state) {
157
+ function saveSession(root, state, sessionFile = SESSION_FILE) {
130
158
  const dir = (0, path_1.join)(root, SESSION_DIR);
131
159
  (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');
160
+ (0, fs_1.writeFileSync)((0, path_1.join)(dir, sessionFile), JSON.stringify(state, null, 2), 'utf8');
133
161
  }
134
162
  /** Collect all internal ports already claimed across the registry. */
135
163
  function takenInternalPorts(reg, excludeSlug) {
@@ -144,6 +172,76 @@ function takenInternalPorts(reg, excludeSlug) {
144
172
  }
145
173
  return ports;
146
174
  }
175
+ /** True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var). */
176
+ function sameRealPath(a, b) {
177
+ try {
178
+ return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
179
+ }
180
+ catch (_a) {
181
+ return a === b;
182
+ }
183
+ }
184
+ const LOCK_PATH = `${REGISTRY_PATH}.lock`;
185
+ /**
186
+ * Run `fn` while holding an EXCLUSIVE lock on the registry, so concurrent
187
+ * `lt dev` invocations — e.g. two parallel `lt dev test` in different ticket
188
+ * worktrees — cannot read-modify-write the registry, or allocate the SAME
189
+ * internal ports, at the same time. (Without this, two simultaneous test runs
190
+ * both read the registry before either saves, both pick the same free ports,
191
+ * and the second server fails to bind — the port-allocation race.)
192
+ *
193
+ * The lock is a single atomically-created lock file (`openSync(..,'wx')`); a
194
+ * stale lock left by a crashed process (older than `staleMs`) is reclaimed.
195
+ * Keep `fn` SHORT — allocation + reservation only, NEVER across a build/spawn.
196
+ */
197
+ function withRegistryLock(fn_1) {
198
+ return __awaiter(this, arguments, void 0, function* (fn, opts = {}) {
199
+ var _a, _b;
200
+ const staleMs = (_a = opts.staleMs) !== null && _a !== void 0 ? _a : 30000;
201
+ const timeoutMs = (_b = opts.timeoutMs) !== null && _b !== void 0 ? _b : 20000;
202
+ const start = Date.now();
203
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(LOCK_PATH), { recursive: true });
204
+ let fd = null;
205
+ while (fd === null) {
206
+ try {
207
+ fd = (0, fs_1.openSync)(LOCK_PATH, 'wx'); // atomic exclusive create — throws if held
208
+ }
209
+ catch (_c) {
210
+ try {
211
+ if (Date.now() - (0, fs_1.statSync)(LOCK_PATH).mtimeMs > staleMs)
212
+ (0, fs_1.unlinkSync)(LOCK_PATH); // reclaim a crashed holder
213
+ }
214
+ catch (_d) {
215
+ /* lock vanished between calls — just retry */
216
+ }
217
+ if (Date.now() - start > timeoutMs)
218
+ throw new Error(`registry lock busy for >${timeoutMs}ms (${LOCK_PATH})`);
219
+ yield delay(40 + Math.floor(Math.random() * 60)); // jittered backoff
220
+ }
221
+ }
222
+ try {
223
+ return yield fn();
224
+ }
225
+ finally {
226
+ try {
227
+ (0, fs_1.closeSync)(fd);
228
+ }
229
+ catch (_e) {
230
+ /* ignore */
231
+ }
232
+ try {
233
+ (0, fs_1.unlinkSync)(LOCK_PATH);
234
+ }
235
+ catch (_f) {
236
+ /* ignore */
237
+ }
238
+ }
239
+ });
240
+ }
241
+ /** Promise-based delay for the lock retry loop. */
242
+ function delay(ms) {
243
+ return new Promise((resolve) => setTimeout(resolve, ms));
244
+ }
147
245
  /** Path constants exported for tests + status displays. */
148
246
  exports.paths = {
149
247
  registry: REGISTRY_PATH,
@@ -0,0 +1,430 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.autoShardCount = autoShardCount;
13
+ exports.bringUpTestSession = bringUpTestSession;
14
+ exports.hasTestSession = hasTestSession;
15
+ exports.resolveTestSession = resolveTestSession;
16
+ exports.runShardedTestSession = runShardedTestSession;
17
+ exports.tearDownAllTestSessions = tearDownAllTestSessions;
18
+ exports.tearDownTestSession = tearDownTestSession;
19
+ /**
20
+ * Ephemeral, isolated test session for `lt dev test`.
21
+ *
22
+ * Brings up a SECOND, fully separate stack (own URLs, own internal ports,
23
+ * own Caddy block, own database) that runs PARALLEL to — and never touches —
24
+ * the developer's `lt dev up` session. Used to run the Playwright E2E suite
25
+ * against a clean, dedicated database so a developer can keep working in their
26
+ * own environment while tests run, and so a test run never pollutes dev data.
27
+ *
28
+ * Topology (for slug `svl`):
29
+ * - dev session : svl.localhost / api.svl.localhost → db `<…>-local`
30
+ * - test session : svl-test.localhost / api.svl-test.… → db `<…>-test`
31
+ *
32
+ * Both halves run BUILT for speed + prod-fidelity: the API COMPILED (`node dist`,
33
+ * ts-node intermittently dies mid-run) and the App as the production Nitro output
34
+ * (`nuxt build` → `node .output/server/index.mjs`, no Vite cold-compile). Each
35
+ * falls back to its dev runner (`pnpm start` / `pnpm dev`) when no build output is
36
+ * found. bringUp waits for a real 2xx on the API `/meta` before returning so the
37
+ * suite never starts against a not-yet-serving API.
38
+ *
39
+ * Lifecycle: `bringUpTestSession` → run Playwright → `tearDownTestSession`.
40
+ * Teardown is idempotent and residue-free (processes, Caddy block, env bridge,
41
+ * session file, registry entry), so a stale session is always safely reclaimed.
42
+ */
43
+ const fs_1 = require("fs");
44
+ const os_1 = require("os");
45
+ const path_1 = require("path");
46
+ const caddy_1 = require("./caddy");
47
+ const dev_env_1 = require("./dev-env");
48
+ const dev_env_bridge_1 = require("./dev-env-bridge");
49
+ const dev_identity_1 = require("./dev-identity");
50
+ const dev_process_1 = require("./dev-process");
51
+ const dev_project_1 = require("./dev-project");
52
+ const dev_state_1 = require("./dev-state");
53
+ const TEST_API_LOG = 'api.test.log';
54
+ const TEST_APP_LOG = 'app.test.log';
55
+ const TEST_BRIDGE_FILE = '.env.test';
56
+ /** Internal port band for the test stack — distinct from the dev band (4000+). */
57
+ const TEST_PORT_BASE = 4500;
58
+ /**
59
+ * Heuristic for the default local shard count (`--shard auto` / bare `--shard`).
60
+ *
61
+ * Unlike CI — where each shard gets its OWN container (CPU + RAM), so N is just
62
+ * the runner-matrix width — local shards all share ONE machine: every shard runs
63
+ * a built Nuxt/Nitro server + headless Chromium + a compiled API, which together
64
+ * peak at ~2 PERFORMANCE cores during SSR render. The catch is headroom: once the
65
+ * shards' peak demand reaches the perf-core count there is nothing left for the
66
+ * OS / mongod / orchestrator, SSR slows 2-3x, and timing-sensitive navigations
67
+ * FAIL no matter how generous their timeout (true over-subscription).
68
+ *
69
+ * Measured on an M2 Max (8 perf + 4 eff cores, 12 logical) on a heavy built-SSR
70
+ * suite: N=2 → 7.4 min, 0 failures (stable); N=3 → 8.7 min, flaky; N=4 → 6-10 min
71
+ * (high variance), flaky. So the stable optimum is ~perfCores/4 — half the perf
72
+ * cores busy, half free as headroom. On Apple silicon ~2/3 of logical cores are
73
+ * perf cores, so `logical/6 ≈ perfCores/4`. This default deliberately FAVOURS a
74
+ * green, repeatable run over the fastest-on-paper N. Cap by RAM (~4 GB/shard),
75
+ * clamp to [2, 8].
76
+ *
77
+ * A LIGHTER suite (no built SSR, fast tests) or a bigger box can take more —
78
+ * override with an explicit `--shard N`. Always measure N vs N±1 (wall-clock AND
79
+ * flakes) to tune. Higher N also needs generous navigation timeouts under load
80
+ * (see the project's shard-aware `LT_DEV_TEST_SHARDS` timeout handling).
81
+ */
82
+ function autoShardCount() {
83
+ const logical = (0, os_1.cpus)().length || 4;
84
+ const byCpu = Math.floor(logical / 6);
85
+ const byRam = Math.floor((0, os_1.totalmem)() / Math.pow(1024, 3) / 4);
86
+ return Math.max(2, Math.min(byCpu, byRam, 8));
87
+ }
88
+ /**
89
+ * Bring up the isolated test stack. Tears down any stale test session first,
90
+ * so this is safe to call even if a previous run crashed.
91
+ */
92
+ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
93
+ return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
94
+ const { devDbName, shardIndex, skipBuild } = opts;
95
+ const names = testStackNames(shardIndex);
96
+ const { dbName, testIdentity } = resolveTestSession(layout, baseIdentity, shardIndex, devDbName);
97
+ // Always start from a clean slate — reclaim a stale/crashed test session.
98
+ yield tearDownTestSession(layout, baseIdentity, log, { shardIndex, silent: true });
99
+ // Allocate internal ports AND reserve them in the registry ATOMICALLY, under a
100
+ // cross-process lock — so two parallel `lt dev test` runs (different ticket
101
+ // worktrees) can never read the registry, pick the same free ports, and both
102
+ // try to bind them during the long build window (the port-allocation race).
103
+ // The lock is held ONLY for this fast section; the build below runs unlocked +
104
+ // fully in parallel. Allocation avoids every other registry entry (dev session
105
+ // + sibling shards) plus anything currently listening.
106
+ let apiPort;
107
+ let appPort;
108
+ yield (0, dev_state_1.withRegistryLock)(() => __awaiter(this, void 0, void 0, function* () {
109
+ const reg = (0, dev_state_1.loadRegistry)();
110
+ const taken = (0, dev_state_1.takenInternalPorts)(reg, testIdentity.slug);
111
+ apiPort = layout.apiDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
112
+ if (apiPort)
113
+ taken.add(apiPort);
114
+ appPort = layout.appDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
115
+ const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
116
+ const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
117
+ for (const p of portsToCheck) {
118
+ const r = snap.get(p);
119
+ if (r)
120
+ throw new Error(`test internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
121
+ }
122
+ // Reserve immediately (still under the lock) so a concurrent run sees these
123
+ // ports as taken. PIDs are written to the session file after spawn, below.
124
+ const subdomainMap = {};
125
+ for (const [k, v] of Object.entries(testIdentity.subdomains))
126
+ subdomainMap[k] = v.hostname;
127
+ reg.projects[testIdentity.slug] = {
128
+ dbName,
129
+ internalPorts: { api: apiPort, app: appPort },
130
+ lastUsedAt: new Date().toISOString(),
131
+ path: layout.root,
132
+ subdomains: subdomainMap,
133
+ };
134
+ (0, dev_state_1.saveRegistry)(reg);
135
+ }));
136
+ // Caddy block for the test URLs (slug-keyed → no cross-stack conflict; safe
137
+ // outside the lock).
138
+ const routes = [];
139
+ if (testIdentity.subdomains.api && apiPort)
140
+ routes.push({ hostname: testIdentity.subdomains.api.hostname, upstreamPort: apiPort });
141
+ if (testIdentity.subdomains.app && appPort)
142
+ routes.push({ hostname: testIdentity.subdomains.app.hostname, upstreamPort: appPort });
143
+ if (routes.length === 0)
144
+ throw new Error('test session has no subdomains to expose (need an app project).');
145
+ (0, caddy_1.upsertProjectBlock)(testIdentity.slug, routes);
146
+ const reload = yield (0, caddy_1.reloadCaddy)();
147
+ if (!reload.ok)
148
+ throw new Error(`caddy reload failed:\n${reload.stderr}`);
149
+ const apiUrl = testIdentity.subdomains.api ? `https://${testIdentity.subdomains.api.hostname}` : '';
150
+ const appUrl = testIdentity.subdomains.app ? `https://${testIdentity.subdomains.app.hostname}` : '';
151
+ log.info('');
152
+ log.info(`Starting isolated test stack "${testIdentity.slug}"`);
153
+ if (appUrl)
154
+ log.info(` app: ${appUrl} → 127.0.0.1:${appPort}`);
155
+ if (apiUrl)
156
+ log.info(` api: ${apiUrl} → 127.0.0.1:${apiPort}`);
157
+ log.info(` db: mongodb://127.0.0.1/${dbName} (reset before the suite by Playwright global-setup)`);
158
+ log.info('');
159
+ const devEnv = (0, dev_env_1.buildDevEnv)({
160
+ apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
161
+ appInternalPort: appPort !== null && appPort !== void 0 ? appPort : 0,
162
+ baseEnv: process.env,
163
+ dbName,
164
+ identity: testIdentity,
165
+ });
166
+ const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
167
+ const pids = {};
168
+ // --- API: compiled (`node dist`) for stability; fall back to `pnpm start`.
169
+ // `skipBuild` (sibling shards) reuses the dist the first shard produced. ---
170
+ if (layout.apiDir && apiPort) {
171
+ let build = 0;
172
+ if (!skipBuild) {
173
+ log.info(log.dim('Building API (compiled, for stable long runs) …'));
174
+ build = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'build'], { cwd: layout.apiDir, env: process.env });
175
+ }
176
+ const entry = ['dist/src/main.js', 'dist/main.js']
177
+ .map((rel) => (0, path_1.join)(layout.apiDir, rel))
178
+ .find((p) => (0, fs_1.existsSync)(p));
179
+ const apiEnv = Object.assign(Object.assign({}, devEnv.api.env), { NODE_ENV: 'local' });
180
+ let apiSpawn;
181
+ if (build === 0 && entry) {
182
+ apiSpawn = (0, dev_process_1.spawnDetached)('node', [entry], {
183
+ cwd: layout.apiDir,
184
+ env: apiEnv,
185
+ logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
186
+ });
187
+ }
188
+ else {
189
+ log.warn('compiled API not available — falling back to `pnpm start` (ts-node).');
190
+ apiSpawn = (0, dev_process_1.spawnDetached)(pnpmBin, ['start'], {
191
+ cwd: layout.apiDir,
192
+ env: apiEnv,
193
+ logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
194
+ });
195
+ }
196
+ if (apiSpawn)
197
+ pids.api = apiSpawn.pid;
198
+ }
199
+ // --- App: BUILT (`nuxt build` → `node .output/server/index.mjs`) for speed +
200
+ // prod-fidelity; fall back to the Nuxt dev server when no build output exists.
201
+ // The built server has no Vite cold-compile (which dominates a dev-mode suite
202
+ // — ~84% of runtime) and runs the SAME production bundle a deployment ships.
203
+ // buildDevEnv sets NUXT_PUBLIC_API_PROXY=false, so the built app talks
204
+ // cross-origin to the test API exactly like prod (the injected session cookie
205
+ // must be a cross-subdomain DOMAIN cookie — see the project's parseCookieHeader).
206
+ // Rebuilt every run so the suite never hits stale code (no build-skip / reuse). ---
207
+ if (layout.appDir && appPort) {
208
+ let appBuild = 0;
209
+ if (!skipBuild) {
210
+ log.info(log.dim('Building App (nuxt build, for speed + prod-fidelity) …'));
211
+ appBuild = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'build'], { cwd: layout.appDir, env: devEnv.app.env });
212
+ }
213
+ const appEntry = ['.output/server/index.mjs']
214
+ .map((rel) => (0, path_1.join)(layout.appDir, rel))
215
+ .find((p) => (0, fs_1.existsSync)(p));
216
+ let appSpawn;
217
+ if (appBuild === 0 && appEntry) {
218
+ appSpawn = (0, dev_process_1.spawnDetached)('node', [appEntry], {
219
+ cwd: layout.appDir,
220
+ env: devEnv.app.env,
221
+ logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
222
+ });
223
+ }
224
+ else {
225
+ log.warn('built app not available — falling back to `pnpm dev` (slower: cold-compiles routes).');
226
+ appSpawn = (0, dev_process_1.spawnDetached)(pnpmBin, ['dev'], {
227
+ cwd: layout.appDir,
228
+ env: devEnv.app.env,
229
+ logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
230
+ });
231
+ }
232
+ if (appSpawn)
233
+ pids.app = appSpawn.pid;
234
+ }
235
+ // Persist the session (PIDs are known now). The registry entry (ports) was
236
+ // already reserved BEFORE the build, above, to avoid a concurrent-allocation
237
+ // race between parallel ticket test runs.
238
+ (0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() }, names.sessionFile);
239
+ // ENV bridge for external tooling (kept separate from the dev `.env`).
240
+ (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName, names.bridgeFile);
241
+ // Wait for the test App to answer (best-effort).
242
+ if (appUrl) {
243
+ log.info(log.dim(`Waiting for ${appUrl} …`));
244
+ yield (0, dev_process_1.waitForHttp)(appUrl, 90000);
245
+ }
246
+ // Wait for the test API to actually SERVE (real 2xx on /meta) before handing
247
+ // off to Playwright. Previously bringUp only waited for the App, so a compiled
248
+ // API still connecting to Mongo made the first specs skip via the suite's
249
+ // `ensureApiReachableOrSkip` guard (the API-readiness race). A strict 2xx is
250
+ // required: Caddy answers 502 while its upstream is still booting, which the
251
+ // default (lenient) predicate would accept as "up".
252
+ if (apiUrl) {
253
+ log.info(log.dim(`Waiting for ${apiUrl}/meta …`));
254
+ const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, (status) => status >= 200 && status < 300);
255
+ if (!apiReady)
256
+ log.warn(`Test API did not answer 2xx on ${apiUrl}/meta within 120s — the first specs may skip.`);
257
+ }
258
+ return { apiUrl, appEnv: devEnv.app.env, appUrl, dbName, pids, testIdentity };
259
+ });
260
+ }
261
+ /** True when a test session file exists (used by status/down). */
262
+ function hasTestSession(root) {
263
+ return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
264
+ }
265
+ /** Build the dedicated test identity + test DB name for a project. */
266
+ function resolveTestSession(layout, baseIdentity, shardIndex, devDbName) {
267
+ const names = testStackNames(shardIndex);
268
+ const testIdentity = (0, dev_identity_1.buildTestIdentity)(baseIdentity, names.identitySuffix);
269
+ // For a ticket worktree the caller passes the ticket dev DB (e.g.
270
+ // `svl-sports-system-2200`), so each ticket's test DB is its own
271
+ // (`…-2200-test[-<shard>]`) and tickets never share a test database.
272
+ const baseDb = devDbName !== null && devDbName !== void 0 ? devDbName : (0, dev_project_1.deriveDbName)(layout.apiDir, baseIdentity.slug);
273
+ const dbName = (0, dev_project_1.deriveTestDbName)(baseDb) + names.dbSuffix;
274
+ return { dbName, testIdentity };
275
+ }
276
+ /**
277
+ * Run the suite SHARDED across `total` fully-isolated stacks in parallel — the
278
+ * local equivalent of the CI `parallel: N` + `--shard=i/N` matrix, but on one
279
+ * machine: each shard gets its own URLs/ports/Caddy block AND its own DB
280
+ * (`<…>-test-<i>`), so there is zero cross-shard data contention (the reason
281
+ * in-process `workers > 1` against a single stack produces false results —
282
+ * `cleanupTestEntities` / "pick any active season" collide).
283
+ *
284
+ * The first shard builds the API + App; siblings reuse that build (`skipBuild`),
285
+ * since the bundles are shard-agnostic (URLs come from runtime env). Stacks are
286
+ * brought up sequentially (builds + Caddy reloads serialise cleanly), then the
287
+ * N Playwright `--shard=i/N` processes run CONCURRENTLY, each against its own
288
+ * stack, output captured to `.lt-dev/shard.<i>.test.log`. Returns 0 iff every
289
+ * shard passed. Teardown is the caller's responsibility (so `--keep` works).
290
+ */
291
+ function runShardedTestSession(layout, baseIdentity, log, opts) {
292
+ return __awaiter(this, void 0, void 0, function* () {
293
+ const total = Math.max(2, Math.floor(opts.total));
294
+ const contexts = [];
295
+ // Bring up the N isolated stacks sequentially (shard 1 builds; 2..N reuse).
296
+ for (let index = 1; index <= total; index++) {
297
+ log.info('');
298
+ log.info(`▶ shard ${index}/${total}: bringing up isolated stack …`);
299
+ const ctx = yield bringUpTestSession(layout, baseIdentity, log, {
300
+ devDbName: opts.devDbName,
301
+ shardIndex: index,
302
+ skipBuild: index > 1,
303
+ });
304
+ contexts.push({ ctx, index });
305
+ }
306
+ // Run the N Playwright shards CONCURRENTLY, each against its own stack/DB.
307
+ log.info('');
308
+ log.info(`Running ${total} Playwright shards in parallel (one isolated stack each) …`);
309
+ const appDir = layout.appDir;
310
+ const results = yield Promise.all(contexts.map((_a) => __awaiter(this, [_a], void 0, function* ({ ctx, index }) {
311
+ // `LT_DEV_TEST_SHARDS` signals to the project's playwright.config that the
312
+ // suite runs under concurrent sharded load, so it can relax navigation /
313
+ // test timeouts (N built SSR servers + N Chromium saturate the CPU and slow
314
+ // every navigation) without loosening them for serial runs.
315
+ const env = Object.assign(Object.assign({}, ctx.appEnv), { LT_DEV_TEST_SHARDS: String(total), MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` });
316
+ const logFile = (0, path_1.join)(layout.root, '.lt-dev', `shard.${index}.test.log`);
317
+ // Invoke Playwright DIRECTLY via `pnpm exec` (NOT `pnpm run test:e2e -- …`):
318
+ // forwarding option flags through `pnpm run`'s `--` is unreliable — pnpm
319
+ // passed the separator on to Playwright, which then read `--shard`/
320
+ // `--reporter` as file FILTERS (not options) → every shard ran the whole
321
+ // suite. `pnpm exec` hands args straight to the binary (mirrors CI).
322
+ const args = ['exec', 'playwright', 'test', `--shard=${index}/${total}`, '--reporter=line', ...opts.forwarded];
323
+ const code = yield (0, dev_process_1.runChildToFile)(opts.pnpmBin, args, { cwd: appDir, env, logFile });
324
+ return { code, index, logFile };
325
+ })));
326
+ // Aggregate per-shard exit codes into a single result.
327
+ let failed = 0;
328
+ log.info('');
329
+ for (const r of results.sort((a, b) => a.index - b.index)) {
330
+ const ok = r.code === 0;
331
+ if (!ok)
332
+ failed++;
333
+ log.info(` shard ${r.index}/${total}: ${ok ? 'passed' : `FAILED (exit ${r.code})`} (log: ${r.logFile})`);
334
+ }
335
+ return failed === 0 ? 0 : 1;
336
+ });
337
+ }
338
+ /**
339
+ * Tear down the unsharded test stack AND every sharded stack discovered on disk
340
+ * (`state.test.<i>.json` in `.lt-dev/`). Used by `lt dev test down` so a
341
+ * `--keep`-ed sharded run is fully reclaimed by one command.
342
+ */
343
+ function tearDownAllTestSessions(layout_1, baseIdentity_1, log_1) {
344
+ return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
345
+ const stopped = [];
346
+ // Unsharded session first.
347
+ const base = yield tearDownTestSession(layout, baseIdentity, log, { silent: opts.silent });
348
+ stopped.push(...base.stopped);
349
+ // Then any sharded sessions still on disk.
350
+ let entries = [];
351
+ try {
352
+ entries = (0, fs_1.readdirSync)((0, path_1.join)(layout.root, '.lt-dev'));
353
+ }
354
+ catch (_a) {
355
+ /* no .lt-dev dir → nothing sharded to reclaim */
356
+ }
357
+ const shardIndices = entries
358
+ .map((f) => f.match(/^state\.test\.(\d+)\.json$/))
359
+ .filter((m) => m !== null)
360
+ .map((m) => Number(m[1]))
361
+ .sort((a, b) => a - b);
362
+ for (const shardIndex of shardIndices) {
363
+ const r = yield tearDownTestSession(layout, baseIdentity, log, { shardIndex, silent: opts.silent });
364
+ stopped.push(...r.stopped);
365
+ }
366
+ return { stopped };
367
+ });
368
+ }
369
+ /**
370
+ * Tear down the test stack: stop processes, remove the Caddy block, clear the
371
+ * env bridge + session file + registry entry. Idempotent + residue-free.
372
+ */
373
+ function tearDownTestSession(layout_1, baseIdentity_1, log_1) {
374
+ return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
375
+ const names = testStackNames(opts.shardIndex);
376
+ const testIdentity = (0, dev_identity_1.buildTestIdentity)(baseIdentity, names.identitySuffix);
377
+ const stopped = [];
378
+ const session = (0, dev_state_1.loadSession)(layout.root, names.sessionFile);
379
+ if (session) {
380
+ for (const [name, pid] of Object.entries(session.pids)) {
381
+ if (!pid)
382
+ continue;
383
+ if (!(0, dev_state_1.isPidAlive)(pid)) {
384
+ stopped.push(`${name} (pid ${pid}, already dead)`);
385
+ continue;
386
+ }
387
+ // SIGTERM → wait → SIGKILL. A compiled `node dist` API catches SIGTERM
388
+ // for graceful shutdown and can hang on open Mongo connections, so a
389
+ // single SIGTERM would leave it listening + holding the test DB. Escalate
390
+ // to guarantee the residue-free teardown promise.
391
+ const gone = yield (0, dev_process_1.terminateProcessGroup)(pid);
392
+ stopped.push(gone ? `${name} (pid ${pid})` : `${name} (pid ${pid}, SURVIVED SIGKILL!)`);
393
+ }
394
+ (0, dev_state_1.clearSession)(layout.root, names.sessionFile);
395
+ }
396
+ const removed = (0, caddy_1.removeProjectBlock)(testIdentity.slug);
397
+ if (removed) {
398
+ const r = yield (0, caddy_1.reloadCaddy)();
399
+ if (!r.ok && !opts.silent)
400
+ log.warn(`Removed test Caddy block but reload failed: ${r.stderr.split('\n')[0]}`);
401
+ }
402
+ (0, dev_env_bridge_1.clearEnvBridge)(layout.root, names.bridgeFile);
403
+ // Drop the registry entry so the test slug + ports are reclaimed.
404
+ const reg = (0, dev_state_1.loadRegistry)();
405
+ if (reg.projects[testIdentity.slug]) {
406
+ delete reg.projects[testIdentity.slug];
407
+ (0, dev_state_1.saveRegistry)(reg);
408
+ }
409
+ if (!opts.silent && stopped.length > 0)
410
+ log.info(`Stopped test stack: ${stopped.join(', ')}`);
411
+ return { stopped };
412
+ });
413
+ }
414
+ /**
415
+ * Resolve the per-stack file/identity names. For a sharded run (`shardIndex`
416
+ * given) everything gets a `.<i>` / `-<i>` suffix so N stacks coexist without
417
+ * clobbering each other's session file, env bridge, logs, Caddy block or DB.
418
+ * Unsharded (shardIndex undefined) keeps the original single-stack names.
419
+ */
420
+ function testStackNames(shardIndex) {
421
+ const sharded = shardIndex !== undefined;
422
+ return {
423
+ apiLog: sharded ? `api.test.${shardIndex}.log` : TEST_API_LOG,
424
+ appLog: sharded ? `app.test.${shardIndex}.log` : TEST_APP_LOG,
425
+ bridgeFile: sharded ? `${TEST_BRIDGE_FILE}.${shardIndex}` : TEST_BRIDGE_FILE,
426
+ dbSuffix: sharded ? `-${shardIndex}` : '',
427
+ identitySuffix: sharded ? `-test-${shardIndex}` : '-test',
428
+ sessionFile: sharded ? `state.test.${shardIndex}.json` : dev_state_1.TEST_SESSION_FILE,
429
+ };
430
+ }