@lenne.tech/cli
Version:
lenne.Tech CLI: lt
279 lines (278 loc) • 14 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 });
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;