UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

279 lines (278 loc) 14 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 }); const fs_1 = require("fs"); const caddy_1 = require("../../lib/caddy"); const dev_env_bridge_1 = require("../../lib/dev-env-bridge"); const dev_process_1 = require("../../lib/dev-process"); const dev_project_1 = require("../../lib/dev-project"); const dev_test_session_1 = require("../../lib/dev-test-session"); const dev_ticket_1 = require("../../lib/dev-ticket"); /** * One-shot E2E convenience wrapper. * * App mode (default) runs the Playwright suite against a fully ISOLATED test * stack (own URLs / ports / Caddy block / dedicated `<…>-test` database) that * runs parallel to — and never touches — the developer's `lt dev up` session. * Playwright's global-setup resets that dedicated DB once before the first test. * The stack is torn down automatically when the run finishes (residue-free). * * API mode (`--api`) runs the standalone API test suite (`pnpm test:e2e` in the * API), which already isolates itself on its own DB — no stack is brought up. * * Usage: * lt dev test # isolated Playwright E2E (auto teardown) * lt dev test --keep # leave the test stack up afterwards (debug) * lt dev test down # tear the test stack down (residue-free) * lt dev test --api # run API tests instead (already isolated) * lt dev test --debug # PWDEBUG=1 + headed * lt dev test --shard # shard across 2 isolated stacks (default — stable) * lt dev test --shard N # shard across N isolated stacks (parallel) * lt dev test --shard auto # size N from this machine's CPU + RAM * lt dev test -- <args> # forward args to Playwright */ const TestCommand = { alias: ['t'], description: 'Run E2E tests in an isolated, parallel test stack (auto teardown)', hidden: false, name: 'test', run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () { var _a; const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox; const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem); if (!layout.apiDir && !layout.appDir) { error('No API or App project detected at this path.'); if (!parameters.options.fromGluegunMenu) process.exit(1); return 'dev test: not a project'; } // Ticket-aware: a ticket worktree tests its OWN isolated stack + DB // (`<slug>-<id>-test`), so resolve the ticket identity + pass the ticket dev // DB so the test DB is derived per ticket (never shared between tickets). const { dbName: devDbName, identity } = (0, dev_ticket_1.resolveDevIdentity)(layout, { ticket: parameters.options.ticket }); const log = { dim: colors.dim, info, warn: warning }; // Sub-command: `lt dev test down` — tear the test stack(s) down + exit. // Reclaims both the unsharded stack and any leftover sharded stacks. if (parameters.first === 'down') { const { stopped } = yield (0, dev_test_session_1.tearDownAllTestSessions)(layout, identity, log); if (stopped.length > 0) success(`Test stack down: ${stopped.join(', ')}`); else info(colors.dim('No test stack was running.')); if (!parameters.options.fromGluegunMenu) process.exit(); return 'dev test: down'; } const apiMode = Boolean(parameters.options.api); const keep = Boolean(parameters.options.keep) || parameters.options.teardown === false; const debug = Boolean(parameters.options.debug); const forwarded = parameters.array || []; const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm'; // `--shard N` → run the suite split across N fully-isolated stacks in // parallel. A bare `--shard` defaults to 2 — the stable sweet spot for a // heavy built-SSR suite (N>=3 over-subscribes the perf cores → flaky; see // autoShardCount). `--shard auto` instead sizes N from this machine's CPU+RAM. const SHARD_DEFAULT = 2; const shardRaw = (_a = parameters.options.shard) !== null && _a !== void 0 ? _a : parameters.options.shards; let shardTotal; if (shardRaw !== undefined) { if (shardRaw === true) shardTotal = SHARD_DEFAULT; else if (String(shardRaw).toLowerCase() === 'auto') shardTotal = (0, dev_test_session_1.autoShardCount)(); else shardTotal = Math.floor(Number(shardRaw)); } // ------------------------------------------------------------------------- // API mode — standalone, already isolated on its own DB. No stack needed. // ------------------------------------------------------------------------- if (apiMode) { if (!layout.apiDir) { error('No API project in this layout.'); if (!parameters.options.fromGluegunMenu) process.exit(1); return 'dev test: no api'; } info(colors.bold(`Running API tests for "${identity.slug}" (isolated DB)`)); const code = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'test:e2e', ...forwarded], { cwd: layout.apiDir, env: process.env, }); if (code === 0) success('API tests passed.'); else error(`API tests failed (exit ${code}).`); if (!parameters.options.fromGluegunMenu) process.exit(code !== null && code !== void 0 ? code : 1); return `dev test: api exit=${code}`; } // ------------------------------------------------------------------------- // App mode — isolated Playwright stack. // ------------------------------------------------------------------------- if (!layout.appDir) { error('No App project in this layout.'); if (!parameters.options.fromGluegunMenu) process.exit(1); return 'dev test: no app'; } if (!(yield (0, caddy_1.caddyAvailable)())) { error('caddy is not installed. Run `lt dev install` first.'); if (!parameters.options.fromGluegunMenu) process.exit(1); return 'dev test: caddy missing'; } if (!(yield (0, caddy_1.caddyDaemonRunning)())) { error('caddy daemon not running. Run `lt dev install` first.'); if (!parameters.options.fromGluegunMenu) process.exit(1); return 'dev test: caddy daemon down'; } // Pre-flight (#3): if the project's playwright.config is not env-aware // (hardcoded baseURL or an unguarded `webServer`), the suite would IGNORE // the isolated stack we are about to build and hit localhost:3001 instead — // wasting a full build on a stack nothing uses. Abort early with the fix, // unless `--force`. const unpatched = (0, dev_project_1.appNeedsPortPatch)(layout.appDir).some((f) => f.endsWith('playwright.config.ts')); if (unpatched && !parameters.options.force) { error("This project's playwright.config.ts is not env-aware (hardcoded baseURL or unguarded webServer)."); error('`lt dev test` would build an isolated stack the suite then IGNORES (tests would hit localhost:3001).'); info(colors.dim('Fix: run `lt dev init` to make baseURL env-aware + guard the webServer — or pass --force.')); if (!parameters.options.fromGluegunMenu) process.exit(1); return 'dev test: playwright.config not env-aware (run lt dev init or --force)'; } // ------------------------------------------------------------------------- // Sharded mode (`--shard N`) — N fully-isolated stacks + parallel // `--shard=i/N`, the local CI-parity matrix. Each shard has its own DB so // there is no cross-shard contention. Auto-teardown of ALL shards (or // `--keep` them for `lt dev test down`). // ------------------------------------------------------------------------- if (shardTotal !== undefined && shardTotal > 1) { let allTornDown = false; const teardownAll = () => __awaiter(void 0, void 0, void 0, function* () { if (allTornDown || keep) return; allTornDown = true; yield (0, dev_test_session_1.tearDownAllTestSessions)(layout, identity, log, { silent: true }); }); const onShardSignal = () => { teardownAll() .catch(() => undefined) .finally(() => process.exit(130)); }; process.on('SIGINT', onShardSignal); process.on('SIGTERM', onShardSignal); let shardExit = 1; try { info(''); info(colors.bold(`Running isolated Playwright E2E for "${identity.slug}" sharded across ${shardTotal} stacks`)); shardExit = yield (0, dev_test_session_1.runShardedTestSession)(layout, identity, log, { devDbName, forwarded, pnpmBin, total: shardTotal, }); } catch (e) { error(`Failed to run sharded E2E: ${e.message}`); shardExit = 1; } finally { process.off('SIGINT', onShardSignal); process.off('SIGTERM', onShardSignal); if (keep) { info(''); info(colors.dim('Test stacks left running (--keep). Stop them with: `lt dev test down`.')); } else { yield teardownAll(); } } if (shardExit === 0) success('Tests passed (all shards).'); else error(`Tests failed (exit ${shardExit}).`); if (!parameters.options.fromGluegunMenu) process.exit(shardExit !== null && shardExit !== void 0 ? shardExit : 1); return `dev test: sharded exit=${shardExit}`; } let tornDown = false; const teardown = () => __awaiter(void 0, void 0, void 0, function* () { if (tornDown || keep) return; tornDown = true; yield (0, dev_test_session_1.tearDownTestSession)(layout, identity, log); }); // Residue-free teardown even on Ctrl-C / kill. const onSignal = () => { teardown() .catch(() => undefined) .finally(() => process.exit(130)); }; process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal); let exitCode = 1; try { const ctx = yield (0, dev_test_session_1.bringUpTestSession)(layout, identity, log, { devDbName }); const env = Object.assign(Object.assign(Object.assign({}, process.env), readBridgeEnv(layout.root)), { // Playwright global-setup resets THIS db (allow-listed) before the suite. MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` }); if (debug) { env.PWDEBUG = '1'; env.HEADED = '1'; } info(''); info(colors.bold(`Running isolated Playwright E2E for "${identity.slug}"`)); info(colors.dim(` app: ${ctx.appUrl} db: ${ctx.dbName}`)); info(''); exitCode = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'test:e2e', ...forwarded], { cwd: layout.appDir, env }); } catch (e) { error(`Failed to run isolated E2E: ${e.message}`); exitCode = 1; } finally { process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); if (keep) { info(''); info(colors.dim('Test stack left running (--keep). Stop it with: `lt dev test down`.')); } else { yield teardown(); } } if (exitCode === 0) success('Tests passed.'); else error(`Tests failed (exit ${exitCode}).`); if (!parameters.options.fromGluegunMenu) process.exit(exitCode !== null && exitCode !== void 0 ? exitCode : 1); return `dev test: exit=${exitCode}`; }), }; /** * Read the test ENV bridge (`.lt-dev/.env.test`) as a key/value map. * Returns an empty object if missing. */ function readBridgeEnv(root) { const file = (0, dev_env_bridge_1.envBridgePath)(root, '.env.test'); if (!(0, fs_1.existsSync)(file)) return {}; const out = {}; for (const line of (0, fs_1.readFileSync)(file, 'utf8').split(/\r?\n/)) { const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/); if (m) out[m[1]] = m[2]; } return out; } module.exports = TestCommand;