UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

431 lines (430 loc) 24.3 kB
"use strict"; 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, }; }