@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.
- package/build/commands/claude/plugins.js +13 -10
- package/build/commands/dev/down.js +12 -0
- package/build/commands/dev/status.js +19 -1
- package/build/commands/dev/test.js +197 -118
- package/build/lib/dev-env-bridge.js +6 -6
- package/build/lib/dev-identity.js +18 -0
- package/build/lib/dev-migrate-helper.js +16 -6
- package/build/lib/dev-patches.js +71 -0
- package/build/lib/dev-process.js +154 -0
- package/build/lib/dev-project.js +20 -1
- package/build/lib/dev-state.js +9 -7
- package/build/lib/dev-test-session.js +410 -0
- package/package.json +1 -1
|
@@ -0,0 +1,410 @@
|
|
|
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 { shardIndex, skipBuild } = opts;
|
|
95
|
+
const names = testStackNames(shardIndex);
|
|
96
|
+
const { dbName, testIdentity } = resolveTestSession(layout, baseIdentity, shardIndex);
|
|
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 (avoid every other registry entry incl. the dev
|
|
100
|
+
// session AND already-running sibling shards, plus anything currently
|
|
101
|
+
// listening). Sibling shards are registered before the next one allocates,
|
|
102
|
+
// so each shard lands on its own port pair.
|
|
103
|
+
const reg = (0, dev_state_1.loadRegistry)();
|
|
104
|
+
const taken = (0, dev_state_1.takenInternalPorts)(reg, testIdentity.slug);
|
|
105
|
+
const apiPort = layout.apiDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
|
|
106
|
+
if (apiPort)
|
|
107
|
+
taken.add(apiPort);
|
|
108
|
+
const appPort = layout.appDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
|
|
109
|
+
const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
|
|
110
|
+
const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
|
|
111
|
+
for (const p of portsToCheck) {
|
|
112
|
+
const r = snap.get(p);
|
|
113
|
+
if (r)
|
|
114
|
+
throw new Error(`test internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
|
|
115
|
+
}
|
|
116
|
+
// Caddy block for the test URLs.
|
|
117
|
+
const routes = [];
|
|
118
|
+
if (testIdentity.subdomains.api && apiPort)
|
|
119
|
+
routes.push({ hostname: testIdentity.subdomains.api.hostname, upstreamPort: apiPort });
|
|
120
|
+
if (testIdentity.subdomains.app && appPort)
|
|
121
|
+
routes.push({ hostname: testIdentity.subdomains.app.hostname, upstreamPort: appPort });
|
|
122
|
+
if (routes.length === 0)
|
|
123
|
+
throw new Error('test session has no subdomains to expose (need an app project).');
|
|
124
|
+
(0, caddy_1.upsertProjectBlock)(testIdentity.slug, routes);
|
|
125
|
+
const reload = yield (0, caddy_1.reloadCaddy)();
|
|
126
|
+
if (!reload.ok)
|
|
127
|
+
throw new Error(`caddy reload failed:\n${reload.stderr}`);
|
|
128
|
+
const apiUrl = testIdentity.subdomains.api ? `https://${testIdentity.subdomains.api.hostname}` : '';
|
|
129
|
+
const appUrl = testIdentity.subdomains.app ? `https://${testIdentity.subdomains.app.hostname}` : '';
|
|
130
|
+
log.info('');
|
|
131
|
+
log.info(`Starting isolated test stack "${testIdentity.slug}"`);
|
|
132
|
+
if (appUrl)
|
|
133
|
+
log.info(` app: ${appUrl} → 127.0.0.1:${appPort}`);
|
|
134
|
+
if (apiUrl)
|
|
135
|
+
log.info(` api: ${apiUrl} → 127.0.0.1:${apiPort}`);
|
|
136
|
+
log.info(` db: mongodb://127.0.0.1/${dbName} (reset before the suite by Playwright global-setup)`);
|
|
137
|
+
log.info('');
|
|
138
|
+
const devEnv = (0, dev_env_1.buildDevEnv)({
|
|
139
|
+
apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
|
|
140
|
+
appInternalPort: appPort !== null && appPort !== void 0 ? appPort : 0,
|
|
141
|
+
baseEnv: process.env,
|
|
142
|
+
dbName,
|
|
143
|
+
identity: testIdentity,
|
|
144
|
+
});
|
|
145
|
+
const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
|
|
146
|
+
const pids = {};
|
|
147
|
+
// --- API: compiled (`node dist`) for stability; fall back to `pnpm start`.
|
|
148
|
+
// `skipBuild` (sibling shards) reuses the dist the first shard produced. ---
|
|
149
|
+
if (layout.apiDir && apiPort) {
|
|
150
|
+
let build = 0;
|
|
151
|
+
if (!skipBuild) {
|
|
152
|
+
log.info(log.dim('Building API (compiled, for stable long runs) …'));
|
|
153
|
+
build = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'build'], { cwd: layout.apiDir, env: process.env });
|
|
154
|
+
}
|
|
155
|
+
const entry = ['dist/src/main.js', 'dist/main.js']
|
|
156
|
+
.map((rel) => (0, path_1.join)(layout.apiDir, rel))
|
|
157
|
+
.find((p) => (0, fs_1.existsSync)(p));
|
|
158
|
+
const apiEnv = Object.assign(Object.assign({}, devEnv.api.env), { NODE_ENV: 'local' });
|
|
159
|
+
let apiSpawn;
|
|
160
|
+
if (build === 0 && entry) {
|
|
161
|
+
apiSpawn = (0, dev_process_1.spawnDetached)('node', [entry], {
|
|
162
|
+
cwd: layout.apiDir,
|
|
163
|
+
env: apiEnv,
|
|
164
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
log.warn('compiled API not available — falling back to `pnpm start` (ts-node).');
|
|
169
|
+
apiSpawn = (0, dev_process_1.spawnDetached)(pnpmBin, ['start'], {
|
|
170
|
+
cwd: layout.apiDir,
|
|
171
|
+
env: apiEnv,
|
|
172
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (apiSpawn)
|
|
176
|
+
pids.api = apiSpawn.pid;
|
|
177
|
+
}
|
|
178
|
+
// --- App: BUILT (`nuxt build` → `node .output/server/index.mjs`) for speed +
|
|
179
|
+
// prod-fidelity; fall back to the Nuxt dev server when no build output exists.
|
|
180
|
+
// The built server has no Vite cold-compile (which dominates a dev-mode suite
|
|
181
|
+
// — ~84% of runtime) and runs the SAME production bundle a deployment ships.
|
|
182
|
+
// buildDevEnv sets NUXT_PUBLIC_API_PROXY=false, so the built app talks
|
|
183
|
+
// cross-origin to the test API exactly like prod (the injected session cookie
|
|
184
|
+
// must be a cross-subdomain DOMAIN cookie — see the project's parseCookieHeader).
|
|
185
|
+
// Rebuilt every run so the suite never hits stale code (no build-skip / reuse). ---
|
|
186
|
+
if (layout.appDir && appPort) {
|
|
187
|
+
let appBuild = 0;
|
|
188
|
+
if (!skipBuild) {
|
|
189
|
+
log.info(log.dim('Building App (nuxt build, for speed + prod-fidelity) …'));
|
|
190
|
+
appBuild = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'build'], { cwd: layout.appDir, env: devEnv.app.env });
|
|
191
|
+
}
|
|
192
|
+
const appEntry = ['.output/server/index.mjs']
|
|
193
|
+
.map((rel) => (0, path_1.join)(layout.appDir, rel))
|
|
194
|
+
.find((p) => (0, fs_1.existsSync)(p));
|
|
195
|
+
let appSpawn;
|
|
196
|
+
if (appBuild === 0 && appEntry) {
|
|
197
|
+
appSpawn = (0, dev_process_1.spawnDetached)('node', [appEntry], {
|
|
198
|
+
cwd: layout.appDir,
|
|
199
|
+
env: devEnv.app.env,
|
|
200
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
log.warn('built app not available — falling back to `pnpm dev` (slower: cold-compiles routes).');
|
|
205
|
+
appSpawn = (0, dev_process_1.spawnDetached)(pnpmBin, ['dev'], {
|
|
206
|
+
cwd: layout.appDir,
|
|
207
|
+
env: devEnv.app.env,
|
|
208
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (appSpawn)
|
|
212
|
+
pids.app = appSpawn.pid;
|
|
213
|
+
}
|
|
214
|
+
// Persist test session + registry entry (so ports are reserved + status sees it).
|
|
215
|
+
(0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() }, names.sessionFile);
|
|
216
|
+
const subdomainMap = {};
|
|
217
|
+
for (const [k, v] of Object.entries(testIdentity.subdomains))
|
|
218
|
+
subdomainMap[k] = v.hostname;
|
|
219
|
+
reg.projects[testIdentity.slug] = {
|
|
220
|
+
dbName,
|
|
221
|
+
internalPorts: { api: apiPort, app: appPort },
|
|
222
|
+
lastUsedAt: new Date().toISOString(),
|
|
223
|
+
path: layout.root,
|
|
224
|
+
subdomains: subdomainMap,
|
|
225
|
+
};
|
|
226
|
+
(0, dev_state_1.saveRegistry)(reg);
|
|
227
|
+
// ENV bridge for external tooling (kept separate from the dev `.env`).
|
|
228
|
+
(0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName, names.bridgeFile);
|
|
229
|
+
// Wait for the test App to answer (best-effort).
|
|
230
|
+
if (appUrl) {
|
|
231
|
+
log.info(log.dim(`Waiting for ${appUrl} …`));
|
|
232
|
+
yield (0, dev_process_1.waitForHttp)(appUrl, 90000);
|
|
233
|
+
}
|
|
234
|
+
// Wait for the test API to actually SERVE (real 2xx on /meta) before handing
|
|
235
|
+
// off to Playwright. Previously bringUp only waited for the App, so a compiled
|
|
236
|
+
// API still connecting to Mongo made the first specs skip via the suite's
|
|
237
|
+
// `ensureApiReachableOrSkip` guard (the API-readiness race). A strict 2xx is
|
|
238
|
+
// required: Caddy answers 502 while its upstream is still booting, which the
|
|
239
|
+
// default (lenient) predicate would accept as "up".
|
|
240
|
+
if (apiUrl) {
|
|
241
|
+
log.info(log.dim(`Waiting for ${apiUrl}/meta …`));
|
|
242
|
+
const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, (status) => status >= 200 && status < 300);
|
|
243
|
+
if (!apiReady)
|
|
244
|
+
log.warn(`Test API did not answer 2xx on ${apiUrl}/meta within 120s — the first specs may skip.`);
|
|
245
|
+
}
|
|
246
|
+
return { apiUrl, appEnv: devEnv.app.env, appUrl, dbName, pids, testIdentity };
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** True when a test session file exists (used by status/down). */
|
|
250
|
+
function hasTestSession(root) {
|
|
251
|
+
return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
|
|
252
|
+
}
|
|
253
|
+
/** Build the dedicated test identity + test DB name for a project. */
|
|
254
|
+
function resolveTestSession(layout, baseIdentity, shardIndex) {
|
|
255
|
+
const names = testStackNames(shardIndex);
|
|
256
|
+
const testIdentity = (0, dev_identity_1.buildTestIdentity)(baseIdentity, names.identitySuffix);
|
|
257
|
+
const dbName = (0, dev_project_1.deriveTestDbName)((0, dev_project_1.deriveDbName)(layout.apiDir, baseIdentity.slug)) + names.dbSuffix;
|
|
258
|
+
return { dbName, testIdentity };
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Run the suite SHARDED across `total` fully-isolated stacks in parallel — the
|
|
262
|
+
* local equivalent of the CI `parallel: N` + `--shard=i/N` matrix, but on one
|
|
263
|
+
* machine: each shard gets its own URLs/ports/Caddy block AND its own DB
|
|
264
|
+
* (`<…>-test-<i>`), so there is zero cross-shard data contention (the reason
|
|
265
|
+
* in-process `workers > 1` against a single stack produces false results —
|
|
266
|
+
* `cleanupTestEntities` / "pick any active season" collide).
|
|
267
|
+
*
|
|
268
|
+
* The first shard builds the API + App; siblings reuse that build (`skipBuild`),
|
|
269
|
+
* since the bundles are shard-agnostic (URLs come from runtime env). Stacks are
|
|
270
|
+
* brought up sequentially (builds + Caddy reloads serialise cleanly), then the
|
|
271
|
+
* N Playwright `--shard=i/N` processes run CONCURRENTLY, each against its own
|
|
272
|
+
* stack, output captured to `.lt-dev/shard.<i>.test.log`. Returns 0 iff every
|
|
273
|
+
* shard passed. Teardown is the caller's responsibility (so `--keep` works).
|
|
274
|
+
*/
|
|
275
|
+
function runShardedTestSession(layout, baseIdentity, log, opts) {
|
|
276
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
277
|
+
const total = Math.max(2, Math.floor(opts.total));
|
|
278
|
+
const contexts = [];
|
|
279
|
+
// Bring up the N isolated stacks sequentially (shard 1 builds; 2..N reuse).
|
|
280
|
+
for (let index = 1; index <= total; index++) {
|
|
281
|
+
log.info('');
|
|
282
|
+
log.info(`▶ shard ${index}/${total}: bringing up isolated stack …`);
|
|
283
|
+
const ctx = yield bringUpTestSession(layout, baseIdentity, log, { shardIndex: index, skipBuild: index > 1 });
|
|
284
|
+
contexts.push({ ctx, index });
|
|
285
|
+
}
|
|
286
|
+
// Run the N Playwright shards CONCURRENTLY, each against its own stack/DB.
|
|
287
|
+
log.info('');
|
|
288
|
+
log.info(`Running ${total} Playwright shards in parallel (one isolated stack each) …`);
|
|
289
|
+
const appDir = layout.appDir;
|
|
290
|
+
const results = yield Promise.all(contexts.map((_a) => __awaiter(this, [_a], void 0, function* ({ ctx, index }) {
|
|
291
|
+
// `LT_DEV_TEST_SHARDS` signals to the project's playwright.config that the
|
|
292
|
+
// suite runs under concurrent sharded load, so it can relax navigation /
|
|
293
|
+
// test timeouts (N built SSR servers + N Chromium saturate the CPU and slow
|
|
294
|
+
// every navigation) without loosening them for serial runs.
|
|
295
|
+
const env = Object.assign(Object.assign({}, ctx.appEnv), { LT_DEV_TEST_SHARDS: String(total), MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` });
|
|
296
|
+
const logFile = (0, path_1.join)(layout.root, '.lt-dev', `shard.${index}.test.log`);
|
|
297
|
+
// Invoke Playwright DIRECTLY via `pnpm exec` (NOT `pnpm run test:e2e -- …`):
|
|
298
|
+
// forwarding option flags through `pnpm run`'s `--` is unreliable — pnpm
|
|
299
|
+
// passed the separator on to Playwright, which then read `--shard`/
|
|
300
|
+
// `--reporter` as file FILTERS (not options) → every shard ran the whole
|
|
301
|
+
// suite. `pnpm exec` hands args straight to the binary (mirrors CI).
|
|
302
|
+
const args = ['exec', 'playwright', 'test', `--shard=${index}/${total}`, '--reporter=line', ...opts.forwarded];
|
|
303
|
+
const code = yield (0, dev_process_1.runChildToFile)(opts.pnpmBin, args, { cwd: appDir, env, logFile });
|
|
304
|
+
return { code, index, logFile };
|
|
305
|
+
})));
|
|
306
|
+
// Aggregate per-shard exit codes into a single result.
|
|
307
|
+
let failed = 0;
|
|
308
|
+
log.info('');
|
|
309
|
+
for (const r of results.sort((a, b) => a.index - b.index)) {
|
|
310
|
+
const ok = r.code === 0;
|
|
311
|
+
if (!ok)
|
|
312
|
+
failed++;
|
|
313
|
+
log.info(` shard ${r.index}/${total}: ${ok ? 'passed' : `FAILED (exit ${r.code})`} (log: ${r.logFile})`);
|
|
314
|
+
}
|
|
315
|
+
return failed === 0 ? 0 : 1;
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Tear down the unsharded test stack AND every sharded stack discovered on disk
|
|
320
|
+
* (`state.test.<i>.json` in `.lt-dev/`). Used by `lt dev test down` so a
|
|
321
|
+
* `--keep`-ed sharded run is fully reclaimed by one command.
|
|
322
|
+
*/
|
|
323
|
+
function tearDownAllTestSessions(layout_1, baseIdentity_1, log_1) {
|
|
324
|
+
return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
|
|
325
|
+
const stopped = [];
|
|
326
|
+
// Unsharded session first.
|
|
327
|
+
const base = yield tearDownTestSession(layout, baseIdentity, log, { silent: opts.silent });
|
|
328
|
+
stopped.push(...base.stopped);
|
|
329
|
+
// Then any sharded sessions still on disk.
|
|
330
|
+
let entries = [];
|
|
331
|
+
try {
|
|
332
|
+
entries = (0, fs_1.readdirSync)((0, path_1.join)(layout.root, '.lt-dev'));
|
|
333
|
+
}
|
|
334
|
+
catch (_a) {
|
|
335
|
+
/* no .lt-dev dir → nothing sharded to reclaim */
|
|
336
|
+
}
|
|
337
|
+
const shardIndices = entries
|
|
338
|
+
.map((f) => f.match(/^state\.test\.(\d+)\.json$/))
|
|
339
|
+
.filter((m) => m !== null)
|
|
340
|
+
.map((m) => Number(m[1]))
|
|
341
|
+
.sort((a, b) => a - b);
|
|
342
|
+
for (const shardIndex of shardIndices) {
|
|
343
|
+
const r = yield tearDownTestSession(layout, baseIdentity, log, { shardIndex, silent: opts.silent });
|
|
344
|
+
stopped.push(...r.stopped);
|
|
345
|
+
}
|
|
346
|
+
return { stopped };
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Tear down the test stack: stop processes, remove the Caddy block, clear the
|
|
351
|
+
* env bridge + session file + registry entry. Idempotent + residue-free.
|
|
352
|
+
*/
|
|
353
|
+
function tearDownTestSession(layout_1, baseIdentity_1, log_1) {
|
|
354
|
+
return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
|
|
355
|
+
const names = testStackNames(opts.shardIndex);
|
|
356
|
+
const testIdentity = (0, dev_identity_1.buildTestIdentity)(baseIdentity, names.identitySuffix);
|
|
357
|
+
const stopped = [];
|
|
358
|
+
const session = (0, dev_state_1.loadSession)(layout.root, names.sessionFile);
|
|
359
|
+
if (session) {
|
|
360
|
+
for (const [name, pid] of Object.entries(session.pids)) {
|
|
361
|
+
if (!pid)
|
|
362
|
+
continue;
|
|
363
|
+
if (!(0, dev_state_1.isPidAlive)(pid)) {
|
|
364
|
+
stopped.push(`${name} (pid ${pid}, already dead)`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
// SIGTERM → wait → SIGKILL. A compiled `node dist` API catches SIGTERM
|
|
368
|
+
// for graceful shutdown and can hang on open Mongo connections, so a
|
|
369
|
+
// single SIGTERM would leave it listening + holding the test DB. Escalate
|
|
370
|
+
// to guarantee the residue-free teardown promise.
|
|
371
|
+
const gone = yield (0, dev_process_1.terminateProcessGroup)(pid);
|
|
372
|
+
stopped.push(gone ? `${name} (pid ${pid})` : `${name} (pid ${pid}, SURVIVED SIGKILL!)`);
|
|
373
|
+
}
|
|
374
|
+
(0, dev_state_1.clearSession)(layout.root, names.sessionFile);
|
|
375
|
+
}
|
|
376
|
+
const removed = (0, caddy_1.removeProjectBlock)(testIdentity.slug);
|
|
377
|
+
if (removed) {
|
|
378
|
+
const r = yield (0, caddy_1.reloadCaddy)();
|
|
379
|
+
if (!r.ok && !opts.silent)
|
|
380
|
+
log.warn(`Removed test Caddy block but reload failed: ${r.stderr.split('\n')[0]}`);
|
|
381
|
+
}
|
|
382
|
+
(0, dev_env_bridge_1.clearEnvBridge)(layout.root, names.bridgeFile);
|
|
383
|
+
// Drop the registry entry so the test slug + ports are reclaimed.
|
|
384
|
+
const reg = (0, dev_state_1.loadRegistry)();
|
|
385
|
+
if (reg.projects[testIdentity.slug]) {
|
|
386
|
+
delete reg.projects[testIdentity.slug];
|
|
387
|
+
(0, dev_state_1.saveRegistry)(reg);
|
|
388
|
+
}
|
|
389
|
+
if (!opts.silent && stopped.length > 0)
|
|
390
|
+
log.info(`Stopped test stack: ${stopped.join(', ')}`);
|
|
391
|
+
return { stopped };
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Resolve the per-stack file/identity names. For a sharded run (`shardIndex`
|
|
396
|
+
* given) everything gets a `.<i>` / `-<i>` suffix so N stacks coexist without
|
|
397
|
+
* clobbering each other's session file, env bridge, logs, Caddy block or DB.
|
|
398
|
+
* Unsharded (shardIndex undefined) keeps the original single-stack names.
|
|
399
|
+
*/
|
|
400
|
+
function testStackNames(shardIndex) {
|
|
401
|
+
const sharded = shardIndex !== undefined;
|
|
402
|
+
return {
|
|
403
|
+
apiLog: sharded ? `api.test.${shardIndex}.log` : TEST_API_LOG,
|
|
404
|
+
appLog: sharded ? `app.test.${shardIndex}.log` : TEST_APP_LOG,
|
|
405
|
+
bridgeFile: sharded ? `${TEST_BRIDGE_FILE}.${shardIndex}` : TEST_BRIDGE_FILE,
|
|
406
|
+
dbSuffix: sharded ? `-${shardIndex}` : '',
|
|
407
|
+
identitySuffix: sharded ? `-test-${shardIndex}` : '-test',
|
|
408
|
+
sessionFile: sharded ? `state.test.${shardIndex}.json` : dev_state_1.TEST_SESSION_FILE,
|
|
409
|
+
};
|
|
410
|
+
}
|