@lenne.tech/cli
Version:
lenne.Tech CLI: lt
431 lines (430 loc) • 24.3 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.autoShardCount = autoShardCount;
exports.bringUpTestSession = bringUpTestSession;
exports.hasTestSession = hasTestSession;
exports.resolveTestSession = resolveTestSession;
exports.runShardedTestSession = runShardedTestSession;
exports.tearDownAllTestSessions = tearDownAllTestSessions;
exports.tearDownTestSession = tearDownTestSession;
/**
* Ephemeral, isolated test session for `lt dev test`.
*
* Brings up a SECOND, fully separate stack (own URLs, own internal ports,
* own Caddy block, own database) that runs PARALLEL to — and never touches —
* the developer's `lt dev up` session. Used to run the Playwright E2E suite
* against a clean, dedicated database so a developer can keep working in their
* own environment while tests run, and so a test run never pollutes dev data.
*
* Topology (for slug `svl`):
* - dev session : svl.localhost / api.svl.localhost → db `<…>-local`
* - test session : svl-test.localhost / api.svl-test.… → db `<…>-test`
*
* Both halves run BUILT for speed + prod-fidelity: the API COMPILED (`node dist`,
* ts-node intermittently dies mid-run) and the App as the production Nitro output
* (`nuxt build` → `node .output/server/index.mjs`, no Vite cold-compile). Each
* falls back to its dev runner (`pnpm start` / `pnpm dev`) when no build output is
* found. bringUp waits for a real 2xx on the API `/meta` before returning so the
* suite never starts against a not-yet-serving API.
*
* Lifecycle: `bringUpTestSession` → run Playwright → `tearDownTestSession`.
* Teardown is idempotent and residue-free (processes, Caddy block, env bridge,
* session file, registry entry), so a stale session is always safely reclaimed.
*/
const fs_1 = require("fs");
const os_1 = require("os");
const path_1 = require("path");
const caddy_1 = require("./caddy");
const dev_env_1 = require("./dev-env");
const dev_env_bridge_1 = require("./dev-env-bridge");
const dev_identity_1 = require("./dev-identity");
const dev_process_1 = require("./dev-process");
const dev_project_1 = require("./dev-project");
const dev_state_1 = require("./dev-state");
const TEST_API_LOG = 'api.test.log';
const TEST_APP_LOG = 'app.test.log';
const TEST_BRIDGE_FILE = '.env.test';
/** Internal port band for the test stack — distinct from the dev band (4000+). */
const TEST_PORT_BASE = 4500;
/**
* Heuristic for the default local shard count (`--shard auto` / bare `--shard`).
*
* Unlike CI — where each shard gets its OWN container (CPU + RAM), so N is just
* the runner-matrix width — local shards all share ONE machine: every shard runs
* a built Nuxt/Nitro server + headless Chromium + a compiled API, which together
* peak at ~2 PERFORMANCE cores during SSR render. The catch is headroom: once the
* shards' peak demand reaches the perf-core count there is nothing left for the
* OS / mongod / orchestrator, SSR slows 2-3x, and timing-sensitive navigations
* FAIL no matter how generous their timeout (true over-subscription).
*
* Measured on an M2 Max (8 perf + 4 eff cores, 12 logical) on a heavy built-SSR
* suite: N=2 → 7.4 min, 0 failures (stable); N=3 → 8.7 min, flaky; N=4 → 6-10 min
* (high variance), flaky. So the stable optimum is ~perfCores/4 — half the perf
* cores busy, half free as headroom. On Apple silicon ~2/3 of logical cores are
* perf cores, so `logical/6 ≈ perfCores/4`. This default deliberately FAVOURS a
* green, repeatable run over the fastest-on-paper N. Cap by RAM (~4 GB/shard),
* clamp to [2, 8].
*
* A LIGHTER suite (no built SSR, fast tests) or a bigger box can take more —
* override with an explicit `--shard N`. Always measure N vs N±1 (wall-clock AND
* flakes) to tune. Higher N also needs generous navigation timeouts under load
* (see the project's shard-aware `LT_DEV_TEST_SHARDS` timeout handling).
*/
function autoShardCount() {
const logical = (0, os_1.cpus)().length || 4;
const byCpu = Math.floor(logical / 6);
const byRam = Math.floor((0, os_1.totalmem)() / Math.pow(1024, 3) / 4);
return Math.max(2, Math.min(byCpu, byRam, 8));
}
/**
* Bring up the isolated test stack. Tears down any stale test session first,
* so this is safe to call even if a previous run crashed.
*/
function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
const { devDbName, shardIndex, skipBuild } = opts;
const names = testStackNames(shardIndex);
const { dbName, testIdentity } = resolveTestSession(layout, baseIdentity, shardIndex, devDbName);
// Always start from a clean slate — reclaim a stale/crashed test session.
yield tearDownTestSession(layout, baseIdentity, log, { shardIndex, silent: true });
// Allocate internal ports AND reserve them in the registry ATOMICALLY, under a
// cross-process lock — so two parallel `lt dev test` runs (different ticket
// worktrees) can never read the registry, pick the same free ports, and both
// try to bind them during the long build window (the port-allocation race).
// The lock is held ONLY for this fast section; the build below runs unlocked +
// fully in parallel. Allocation avoids every other registry entry (dev session
// + sibling shards) plus anything currently listening.
let apiPort;
let appPort;
yield (0, dev_state_1.withRegistryLock)(() => __awaiter(this, void 0, void 0, function* () {
const reg = (0, dev_state_1.loadRegistry)();
const taken = (0, dev_state_1.takenInternalPorts)(reg, testIdentity.slug);
apiPort = layout.apiDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
if (apiPort)
taken.add(apiPort);
appPort = layout.appDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
for (const p of portsToCheck) {
const r = snap.get(p);
if (r)
throw new Error(`test internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
}
// Reserve immediately (still under the lock) so a concurrent run sees these
// ports as taken. PIDs are written to the session file after spawn, below.
const subdomainMap = {};
for (const [k, v] of Object.entries(testIdentity.subdomains))
subdomainMap[k] = v.hostname;
reg.projects[testIdentity.slug] = {
dbName,
internalPorts: { api: apiPort, app: appPort },
lastUsedAt: new Date().toISOString(),
path: layout.root,
subdomains: subdomainMap,
};
(0, dev_state_1.saveRegistry)(reg);
}));
// Caddy block for the test URLs (slug-keyed → no cross-stack conflict; safe
// outside the lock).
const routes = [];
if (testIdentity.subdomains.api && apiPort)
routes.push({ hostname: testIdentity.subdomains.api.hostname, upstreamPort: apiPort });
if (testIdentity.subdomains.app && appPort)
routes.push({ hostname: testIdentity.subdomains.app.hostname, upstreamPort: appPort });
if (routes.length === 0)
throw new Error('test session has no subdomains to expose (need an app project).');
(0, caddy_1.upsertProjectBlock)(testIdentity.slug, routes);
const reload = yield (0, caddy_1.reloadCaddy)();
if (!reload.ok)
throw new Error(`caddy reload failed:\n${reload.stderr}`);
const apiUrl = testIdentity.subdomains.api ? `https://${testIdentity.subdomains.api.hostname}` : '';
const appUrl = testIdentity.subdomains.app ? `https://${testIdentity.subdomains.app.hostname}` : '';
log.info('');
log.info(`Starting isolated test stack "${testIdentity.slug}"`);
if (appUrl)
log.info(` app: ${appUrl} → 127.0.0.1:${appPort}`);
if (apiUrl)
log.info(` api: ${apiUrl} → 127.0.0.1:${apiPort}`);
log.info(` db: mongodb://127.0.0.1/${dbName} (reset before the suite by Playwright global-setup)`);
log.info('');
const devEnv = (0, dev_env_1.buildDevEnv)({
apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
appInternalPort: appPort !== null && appPort !== void 0 ? appPort : 0,
baseEnv: process.env,
dbName,
identity: testIdentity,
});
const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
const pids = {};
// --- API: compiled (`node dist`) for stability; fall back to `pnpm start`.
// `skipBuild` (sibling shards) reuses the dist the first shard produced. ---
if (layout.apiDir && apiPort) {
let build = 0;
if (!skipBuild) {
log.info(log.dim('Building API (compiled, for stable long runs) …'));
build = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'build'], { cwd: layout.apiDir, env: process.env });
}
const entry = ['dist/src/main.js', 'dist/main.js']
.map((rel) => (0, path_1.join)(layout.apiDir, rel))
.find((p) => (0, fs_1.existsSync)(p));
const apiEnv = Object.assign(Object.assign({}, devEnv.api.env), { NODE_ENV: 'local' });
let apiSpawn;
if (build === 0 && entry) {
apiSpawn = (0, dev_process_1.spawnDetached)('node', [entry], {
cwd: layout.apiDir,
env: apiEnv,
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
});
}
else {
log.warn('compiled API not available — falling back to `pnpm start` (ts-node).');
apiSpawn = (0, dev_process_1.spawnDetached)(pnpmBin, ['start'], {
cwd: layout.apiDir,
env: apiEnv,
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
});
}
if (apiSpawn)
pids.api = apiSpawn.pid;
}
// --- App: BUILT (`nuxt build` → `node .output/server/index.mjs`) for speed +
// prod-fidelity; fall back to the Nuxt dev server when no build output exists.
// The built server has no Vite cold-compile (which dominates a dev-mode suite
// — ~84% of runtime) and runs the SAME production bundle a deployment ships.
// buildDevEnv sets NUXT_PUBLIC_API_PROXY=false, so the built app talks
// cross-origin to the test API exactly like prod (the injected session cookie
// must be a cross-subdomain DOMAIN cookie — see the project's parseCookieHeader).
// Rebuilt every run so the suite never hits stale code (no build-skip / reuse). ---
if (layout.appDir && appPort) {
let appBuild = 0;
if (!skipBuild) {
log.info(log.dim('Building App (nuxt build, for speed + prod-fidelity) …'));
appBuild = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'build'], { cwd: layout.appDir, env: devEnv.app.env });
}
const appEntry = ['.output/server/index.mjs']
.map((rel) => (0, path_1.join)(layout.appDir, rel))
.find((p) => (0, fs_1.existsSync)(p));
let appSpawn;
if (appBuild === 0 && appEntry) {
appSpawn = (0, dev_process_1.spawnDetached)('node', [appEntry], {
cwd: layout.appDir,
env: devEnv.app.env,
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
});
}
else {
log.warn('built app not available — falling back to `pnpm dev` (slower: cold-compiles routes).');
appSpawn = (0, dev_process_1.spawnDetached)(pnpmBin, ['dev'], {
cwd: layout.appDir,
env: devEnv.app.env,
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
});
}
if (appSpawn)
pids.app = appSpawn.pid;
}
// Persist the session (PIDs are known now). The registry entry (ports) was
// already reserved BEFORE the build, above, to avoid a concurrent-allocation
// race between parallel ticket test runs.
(0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() }, names.sessionFile);
// ENV bridge for external tooling (kept separate from the dev `.env`).
(0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName, names.bridgeFile);
// Wait for the test App to answer (best-effort).
if (appUrl) {
log.info(log.dim(`Waiting for ${appUrl} …`));
yield (0, dev_process_1.waitForHttp)(appUrl, 90000);
}
// Wait for the test API to actually SERVE (real 2xx on /meta) before handing
// off to Playwright. Previously bringUp only waited for the App, so a compiled
// API still connecting to Mongo made the first specs skip via the suite's
// `ensureApiReachableOrSkip` guard (the API-readiness race). A strict 2xx is
// required: Caddy answers 502 while its upstream is still booting, which the
// default (lenient) predicate would accept as "up".
if (apiUrl) {
log.info(log.dim(`Waiting for ${apiUrl}/meta …`));
const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, (status) => status >= 200 && status < 300);
if (!apiReady)
log.warn(`Test API did not answer 2xx on ${apiUrl}/meta within 120s — the first specs may skip.`);
}
return { apiUrl, appEnv: devEnv.app.env, appUrl, dbName, pids, testIdentity };
});
}
/** True when a test session file exists (used by status/down). */
function hasTestSession(root) {
return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
}
/** Build the dedicated test identity + test DB name for a project. */
function resolveTestSession(layout, baseIdentity, shardIndex, devDbName) {
const names = testStackNames(shardIndex);
const testIdentity = (0, dev_identity_1.buildTestIdentity)(baseIdentity, names.identitySuffix);
// For a ticket worktree the caller passes the ticket dev DB (e.g.
// `svl-sports-system-2200`), so each ticket's test DB is its own
// (`…-2200-test[-<shard>]`) and tickets never share a test database.
const baseDb = devDbName !== null && devDbName !== void 0 ? devDbName : (0, dev_project_1.deriveDbName)(layout.apiDir, baseIdentity.slug);
const dbName = (0, dev_project_1.deriveTestDbName)(baseDb) + names.dbSuffix;
return { dbName, testIdentity };
}
/**
* Run the suite SHARDED across `total` fully-isolated stacks in parallel — the
* local equivalent of the CI `parallel: N` + `--shard=i/N` matrix, but on one
* machine: each shard gets its own URLs/ports/Caddy block AND its own DB
* (`<…>-test-<i>`), so there is zero cross-shard data contention (the reason
* in-process `workers > 1` against a single stack produces false results —
* `cleanupTestEntities` / "pick any active season" collide).
*
* The first shard builds the API + App; siblings reuse that build (`skipBuild`),
* since the bundles are shard-agnostic (URLs come from runtime env). Stacks are
* brought up sequentially (builds + Caddy reloads serialise cleanly), then the
* N Playwright `--shard=i/N` processes run CONCURRENTLY, each against its own
* stack, output captured to `.lt-dev/shard.<i>.test.log`. Returns 0 iff every
* shard passed. Teardown is the caller's responsibility (so `--keep` works).
*/
function runShardedTestSession(layout, baseIdentity, log, opts) {
return __awaiter(this, void 0, void 0, function* () {
const total = Math.max(2, Math.floor(opts.total));
const contexts = [];
// Bring up the N isolated stacks sequentially (shard 1 builds; 2..N reuse).
for (let index = 1; index <= total; index++) {
log.info('');
log.info(`▶ shard ${index}/${total}: bringing up isolated stack …`);
const ctx = yield bringUpTestSession(layout, baseIdentity, log, {
devDbName: opts.devDbName,
shardIndex: index,
skipBuild: index > 1,
});
contexts.push({ ctx, index });
}
// Run the N Playwright shards CONCURRENTLY, each against its own stack/DB.
log.info('');
log.info(`Running ${total} Playwright shards in parallel (one isolated stack each) …`);
const appDir = layout.appDir;
const results = yield Promise.all(contexts.map((_a) => __awaiter(this, [_a], void 0, function* ({ ctx, index }) {
// `LT_DEV_TEST_SHARDS` signals to the project's playwright.config that the
// suite runs under concurrent sharded load, so it can relax navigation /
// test timeouts (N built SSR servers + N Chromium saturate the CPU and slow
// every navigation) without loosening them for serial runs.
const env = Object.assign(Object.assign({}, ctx.appEnv), { LT_DEV_TEST_SHARDS: String(total), MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` });
const logFile = (0, path_1.join)(layout.root, '.lt-dev', `shard.${index}.test.log`);
// Invoke Playwright DIRECTLY via `pnpm exec` (NOT `pnpm run test:e2e -- …`):
// forwarding option flags through `pnpm run`'s `--` is unreliable — pnpm
// passed the separator on to Playwright, which then read `--shard`/
// `--reporter` as file FILTERS (not options) → every shard ran the whole
// suite. `pnpm exec` hands args straight to the binary (mirrors CI).
const args = ['exec', 'playwright', 'test', `--shard=${index}/${total}`, '--reporter=line', ...opts.forwarded];
const code = yield (0, dev_process_1.runChildToFile)(opts.pnpmBin, args, { cwd: appDir, env, logFile });
return { code, index, logFile };
})));
// Aggregate per-shard exit codes into a single result.
let failed = 0;
log.info('');
for (const r of results.sort((a, b) => a.index - b.index)) {
const ok = r.code === 0;
if (!ok)
failed++;
log.info(` shard ${r.index}/${total}: ${ok ? 'passed' : `FAILED (exit ${r.code})`} (log: ${r.logFile})`);
}
return failed === 0 ? 0 : 1;
});
}
/**
* Tear down the unsharded test stack AND every sharded stack discovered on disk
* (`state.test.<i>.json` in `.lt-dev/`). Used by `lt dev test down` so a
* `--keep`-ed sharded run is fully reclaimed by one command.
*/
function tearDownAllTestSessions(layout_1, baseIdentity_1, log_1) {
return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
const stopped = [];
// Unsharded session first.
const base = yield tearDownTestSession(layout, baseIdentity, log, { silent: opts.silent });
stopped.push(...base.stopped);
// Then any sharded sessions still on disk.
let entries = [];
try {
entries = (0, fs_1.readdirSync)((0, path_1.join)(layout.root, '.lt-dev'));
}
catch (_a) {
/* no .lt-dev dir → nothing sharded to reclaim */
}
const shardIndices = entries
.map((f) => f.match(/^state\.test\.(\d+)\.json$/))
.filter((m) => m !== null)
.map((m) => Number(m[1]))
.sort((a, b) => a - b);
for (const shardIndex of shardIndices) {
const r = yield tearDownTestSession(layout, baseIdentity, log, { shardIndex, silent: opts.silent });
stopped.push(...r.stopped);
}
return { stopped };
});
}
/**
* Tear down the test stack: stop processes, remove the Caddy block, clear the
* env bridge + session file + registry entry. Idempotent + residue-free.
*/
function tearDownTestSession(layout_1, baseIdentity_1, log_1) {
return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
const names = testStackNames(opts.shardIndex);
const testIdentity = (0, dev_identity_1.buildTestIdentity)(baseIdentity, names.identitySuffix);
const stopped = [];
const session = (0, dev_state_1.loadSession)(layout.root, names.sessionFile);
if (session) {
for (const [name, pid] of Object.entries(session.pids)) {
if (!pid)
continue;
if (!(0, dev_state_1.isPidAlive)(pid)) {
stopped.push(`${name} (pid ${pid}, already dead)`);
continue;
}
// SIGTERM → wait → SIGKILL. A compiled `node dist` API catches SIGTERM
// for graceful shutdown and can hang on open Mongo connections, so a
// single SIGTERM would leave it listening + holding the test DB. Escalate
// to guarantee the residue-free teardown promise.
const gone = yield (0, dev_process_1.terminateProcessGroup)(pid);
stopped.push(gone ? `${name} (pid ${pid})` : `${name} (pid ${pid}, SURVIVED SIGKILL!)`);
}
(0, dev_state_1.clearSession)(layout.root, names.sessionFile);
}
const removed = (0, caddy_1.removeProjectBlock)(testIdentity.slug);
if (removed) {
const r = yield (0, caddy_1.reloadCaddy)();
if (!r.ok && !opts.silent)
log.warn(`Removed test Caddy block but reload failed: ${r.stderr.split('\n')[0]}`);
}
(0, dev_env_bridge_1.clearEnvBridge)(layout.root, names.bridgeFile);
// Drop the registry entry so the test slug + ports are reclaimed.
const reg = (0, dev_state_1.loadRegistry)();
if (reg.projects[testIdentity.slug]) {
delete reg.projects[testIdentity.slug];
(0, dev_state_1.saveRegistry)(reg);
}
if (!opts.silent && stopped.length > 0)
log.info(`Stopped test stack: ${stopped.join(', ')}`);
return { stopped };
});
}
/**
* Resolve the per-stack file/identity names. For a sharded run (`shardIndex`
* given) everything gets a `.<i>` / `-<i>` suffix so N stacks coexist without
* clobbering each other's session file, env bridge, logs, Caddy block or DB.
* Unsharded (shardIndex undefined) keeps the original single-stack names.
*/
function testStackNames(shardIndex) {
const sharded = shardIndex !== undefined;
return {
apiLog: sharded ? `api.test.${shardIndex}.log` : TEST_API_LOG,
appLog: sharded ? `app.test.${shardIndex}.log` : TEST_APP_LOG,
bridgeFile: sharded ? `${TEST_BRIDGE_FILE}.${shardIndex}` : TEST_BRIDGE_FILE,
dbSuffix: sharded ? `-${shardIndex}` : '',
identitySuffix: sharded ? `-test-${shardIndex}` : '-test',
sessionFile: sharded ? `state.test.${shardIndex}.json` : dev_state_1.TEST_SESSION_FILE,
};
}