@lenne.tech/cli 1.26.1 → 1.28.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/dev/doctor.js +27 -1
- package/build/commands/dev/down.js +34 -10
- package/build/commands/dev/status.js +23 -4
- package/build/commands/dev/test.js +206 -119
- package/build/commands/dev/up.js +90 -50
- package/build/commands/ticket/list.js +78 -0
- package/build/commands/ticket/start.js +141 -0
- package/build/commands/ticket/stop.js +166 -0
- package/build/commands/ticket/switch.js +70 -0
- package/build/commands/ticket/test.js +80 -0
- package/build/commands/ticket/ticket.js +36 -0
- package/build/lib/dev-env-bridge.js +6 -6
- package/build/lib/dev-identity.js +36 -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 +34 -1
- package/build/lib/dev-state.js +105 -7
- package/build/lib/dev-test-session.js +430 -0
- package/build/lib/dev-ticket.js +343 -0
- package/docs/lt-dev-ticket-workflow.html +603 -0
- package/docs/lt-dev-ticket-workflow.pdf +0 -0
- package/package.json +32 -1
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
const child_process_1 = require("child_process");
|
|
13
|
+
const dev_project_1 = require("../../lib/dev-project");
|
|
14
|
+
const dev_ticket_1 = require("../../lib/dev-ticket");
|
|
15
|
+
/**
|
|
16
|
+
* `lt ticket switch <id>` — show a ticket worktree's path and open it in your
|
|
17
|
+
* editor (best-effort). A CLI cannot change the parent shell's directory, so it
|
|
18
|
+
* also prints the `cd` line to copy. `--no-open` only prints.
|
|
19
|
+
*/
|
|
20
|
+
const SwitchCommand = {
|
|
21
|
+
alias: ['sw', 'open'],
|
|
22
|
+
description: 'Show a ticket worktree path + open it in your editor',
|
|
23
|
+
name: 'switch',
|
|
24
|
+
run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
|
|
25
|
+
var _a;
|
|
26
|
+
const { filesystem, parameters, print: { colors, error, info, success }, } = toolbox;
|
|
27
|
+
const id = String((_a = parameters.first) !== null && _a !== void 0 ? _a : '').trim();
|
|
28
|
+
if (!id) {
|
|
29
|
+
error('Usage: lt ticket switch <id>');
|
|
30
|
+
if (!parameters.options.fromGluegunMenu)
|
|
31
|
+
process.exit(1);
|
|
32
|
+
return 'ticket switch: missing id';
|
|
33
|
+
}
|
|
34
|
+
const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
|
|
35
|
+
let mainRepoRoot;
|
|
36
|
+
try {
|
|
37
|
+
mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
|
|
38
|
+
}
|
|
39
|
+
catch (_b) {
|
|
40
|
+
error('Not inside a git repository.');
|
|
41
|
+
if (!parameters.options.fromGluegunMenu)
|
|
42
|
+
process.exit(1);
|
|
43
|
+
return 'ticket switch: not a git repo';
|
|
44
|
+
}
|
|
45
|
+
const wt = (0, dev_ticket_1.listWorktrees)(mainRepoRoot).find((w) => w.ticket === id);
|
|
46
|
+
if (!wt) {
|
|
47
|
+
error(`No ticket worktree "${id}" found. See \`lt ticket list\`.`);
|
|
48
|
+
if (!parameters.options.fromGluegunMenu)
|
|
49
|
+
process.exit(1);
|
|
50
|
+
return 'ticket switch: not found';
|
|
51
|
+
}
|
|
52
|
+
info('');
|
|
53
|
+
info(`Ticket "${id}" → ${wt.path}`);
|
|
54
|
+
info(colors.dim(` cd ${wt.path}`));
|
|
55
|
+
if (parameters.options.open !== false) {
|
|
56
|
+
const editor = process.env.LT_EDITOR || 'code';
|
|
57
|
+
try {
|
|
58
|
+
(0, child_process_1.execFileSync)(editor, [wt.path], { stdio: 'ignore' });
|
|
59
|
+
success(`Opened in ${editor}.`);
|
|
60
|
+
}
|
|
61
|
+
catch (_c) {
|
|
62
|
+
info(colors.dim(` (could not run \`${editor}\` — open the folder manually, or set LT_EDITOR)`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!parameters.options.fromGluegunMenu)
|
|
66
|
+
process.exit();
|
|
67
|
+
return `ticket switch: ${id}`;
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
module.exports = SwitchCommand;
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
const dev_process_1 = require("../../lib/dev-process");
|
|
13
|
+
const dev_project_1 = require("../../lib/dev-project");
|
|
14
|
+
const dev_ticket_1 = require("../../lib/dev-ticket");
|
|
15
|
+
/**
|
|
16
|
+
* `lt ticket test <id> [--shard N] [-- <args>]` — run the E2E suite for a ticket
|
|
17
|
+
* in ITS isolated stack + DB, by delegating to `lt dev test` inside the ticket
|
|
18
|
+
* worktree (which is ticket-aware via the marker → test DB `<base>-<id>-test`).
|
|
19
|
+
*/
|
|
20
|
+
const TestCommand = {
|
|
21
|
+
alias: ['t'],
|
|
22
|
+
description: 'Run the E2E suite for a ticket in its isolated stack',
|
|
23
|
+
name: 'test',
|
|
24
|
+
run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
|
|
25
|
+
var _a;
|
|
26
|
+
const { filesystem, parameters, print: { colors, error, info }, } = toolbox;
|
|
27
|
+
const id = String((_a = parameters.first) !== null && _a !== void 0 ? _a : '').trim();
|
|
28
|
+
if (!id) {
|
|
29
|
+
error('Usage: lt ticket test <id> [--shard N] [--keep] [-- <playwright args>]');
|
|
30
|
+
if (!parameters.options.fromGluegunMenu)
|
|
31
|
+
process.exit(1);
|
|
32
|
+
return 'ticket test: missing id';
|
|
33
|
+
}
|
|
34
|
+
const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
|
|
35
|
+
let mainRepoRoot;
|
|
36
|
+
try {
|
|
37
|
+
mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
|
|
38
|
+
}
|
|
39
|
+
catch (_b) {
|
|
40
|
+
error('Not inside a git repository.');
|
|
41
|
+
if (!parameters.options.fromGluegunMenu)
|
|
42
|
+
process.exit(1);
|
|
43
|
+
return 'ticket test: not a git repo';
|
|
44
|
+
}
|
|
45
|
+
const wt = (0, dev_ticket_1.listWorktrees)(mainRepoRoot).find((w) => w.ticket === id);
|
|
46
|
+
if (!wt) {
|
|
47
|
+
error(`No ticket worktree "${id}" found. See \`lt ticket list\`.`);
|
|
48
|
+
if (!parameters.options.fromGluegunMenu)
|
|
49
|
+
process.exit(1);
|
|
50
|
+
return 'ticket test: not found';
|
|
51
|
+
}
|
|
52
|
+
// Reconstruct `lt dev test` args: forward the common flags + the `--` array.
|
|
53
|
+
const devArgs = ['dev', 'test'];
|
|
54
|
+
if (parameters.options.shard !== undefined) {
|
|
55
|
+
const s = parameters.options.shard;
|
|
56
|
+
devArgs.push(s === true ? '--shard' : `--shard=${s}`);
|
|
57
|
+
}
|
|
58
|
+
if (parameters.options.keep === true)
|
|
59
|
+
devArgs.push('--keep');
|
|
60
|
+
if (parameters.options.debug === true)
|
|
61
|
+
devArgs.push('--debug');
|
|
62
|
+
// Playwright args after `--` (e.g. a spec path / `--grep`): read them from
|
|
63
|
+
// the RAW argv. gluegun's parsed `parameters.array` does not reliably carry
|
|
64
|
+
// post-`--` tokens through the `ticket <id>` positional, which silently ran
|
|
65
|
+
// the WHOLE suite instead of the requested spec.
|
|
66
|
+
const dashIdx = process.argv.indexOf('--');
|
|
67
|
+
const forwarded = dashIdx >= 0 ? process.argv.slice(dashIdx + 1) : [];
|
|
68
|
+
if (forwarded.length > 0)
|
|
69
|
+
devArgs.push('--', ...forwarded);
|
|
70
|
+
info(colors.dim(`(cd ${wt.path} && lt ${devArgs.join(' ')})`));
|
|
71
|
+
const code = yield (0, dev_process_1.runChildInherit)(process.execPath, [process.argv[1], ...devArgs], {
|
|
72
|
+
cwd: wt.path,
|
|
73
|
+
env: process.env,
|
|
74
|
+
});
|
|
75
|
+
if (!parameters.options.fromGluegunMenu)
|
|
76
|
+
process.exit(code !== null && code !== void 0 ? code : 1);
|
|
77
|
+
return `ticket test: ${id} exit=${code}`;
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
module.exports = TestCommand;
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
/**
|
|
13
|
+
* Parallel ticket dev environments (`lt ticket <subcommand>`).
|
|
14
|
+
*
|
|
15
|
+
* Each ticket runs in its OWN git worktree (a fresh branch from `origin/dev`)
|
|
16
|
+
* with its OWN isolated `lt dev` stack — own URLs, ports, Caddy block and empty
|
|
17
|
+
* database — so several tickets can be developed, browser-tested and E2E-tested
|
|
18
|
+
* fully in parallel without ever influencing each other.
|
|
19
|
+
*
|
|
20
|
+
* Subcommands:
|
|
21
|
+
* - `start <name>` — create the worktree + bring the isolated stack up
|
|
22
|
+
* - `list` — dashboard of all ticket envs (URLs, branch, status, DB)
|
|
23
|
+
* - `switch <id>` — show + open a ticket worktree in your editor
|
|
24
|
+
* - `test <id>` — run the E2E suite in the ticket's isolated stack
|
|
25
|
+
* - `stop <id>` — tear the env down + remove the worktree (branch kept)
|
|
26
|
+
*/
|
|
27
|
+
module.exports = {
|
|
28
|
+
alias: ['tk'],
|
|
29
|
+
description: 'Parallel ticket dev environments (worktree + isolated lt dev stack)',
|
|
30
|
+
hidden: false,
|
|
31
|
+
name: 'ticket',
|
|
32
|
+
run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
|
|
33
|
+
yield toolbox.helper.showMenu('ticket');
|
|
34
|
+
return 'ticket';
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
@@ -24,8 +24,8 @@ const os_1 = require("os");
|
|
|
24
24
|
const path_1 = require("path");
|
|
25
25
|
const HEADER = `# Managed by \`lt dev up\` — do NOT edit, will be overwritten.\n# Removed by \`lt dev down\`. Loaded by Playwright + other tools.\n`;
|
|
26
26
|
/** Remove the ENV bridge file. No-op if missing. */
|
|
27
|
-
function clearEnvBridge(projectRoot) {
|
|
28
|
-
const file = envBridgePath(projectRoot);
|
|
27
|
+
function clearEnvBridge(projectRoot, fileName = '.env') {
|
|
28
|
+
const file = envBridgePath(projectRoot, fileName);
|
|
29
29
|
if (!(0, fs_1.existsSync)(file))
|
|
30
30
|
return false;
|
|
31
31
|
try {
|
|
@@ -61,16 +61,16 @@ function detectCaddyRootCa() {
|
|
|
61
61
|
return null;
|
|
62
62
|
}
|
|
63
63
|
/** Resolve the path to the ENV bridge file for a project. */
|
|
64
|
-
function envBridgePath(projectRoot) {
|
|
65
|
-
return (0, path_1.join)(projectRoot, '.lt-dev',
|
|
64
|
+
function envBridgePath(projectRoot, fileName = '.env') {
|
|
65
|
+
return (0, path_1.join)(projectRoot, '.lt-dev', fileName);
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
68
|
* Write the ENV bridge file. Idempotent — same content = no rewrite.
|
|
69
69
|
*
|
|
70
70
|
* Returns the absolute path that was written.
|
|
71
71
|
*/
|
|
72
|
-
function writeEnvBridge(projectRoot, devEnv, dbName) {
|
|
73
|
-
const file = envBridgePath(projectRoot);
|
|
72
|
+
function writeEnvBridge(projectRoot, devEnv, dbName, fileName = '.env') {
|
|
73
|
+
const file = envBridgePath(projectRoot, fileName);
|
|
74
74
|
const lines = [];
|
|
75
75
|
// The App-side env is the more "external" one (Playwright, browser tools).
|
|
76
76
|
// We expose every URL/storage/api key the App env carries.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.buildIdentity = buildIdentity;
|
|
4
|
+
exports.buildTestIdentity = buildTestIdentity;
|
|
5
|
+
exports.buildTicketIdentity = buildTicketIdentity;
|
|
4
6
|
exports.projectSlug = projectSlug;
|
|
5
7
|
exports.slugify = slugify;
|
|
6
8
|
/**
|
|
@@ -67,6 +69,40 @@ function buildIdentity(root) {
|
|
|
67
69
|
}
|
|
68
70
|
return { root, slug, subdomains };
|
|
69
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Derive an ephemeral "test" identity from a base identity (used by
|
|
74
|
+
* `lt dev test`). Suffixes the slug and every subdomain hostname with
|
|
75
|
+
* `-test`, so the test stack runs on its own URLs / ports / Caddy block,
|
|
76
|
+
* fully parallel to (and isolated from) the dev session.
|
|
77
|
+
*
|
|
78
|
+
* svl.localhost → svl-test.localhost
|
|
79
|
+
* api.svl.localhost → api.svl-test.localhost
|
|
80
|
+
*/
|
|
81
|
+
function buildTestIdentity(base, suffix = '-test') {
|
|
82
|
+
const slug = `${base.slug}${suffix}`;
|
|
83
|
+
const subdomains = {};
|
|
84
|
+
for (const [sub, value] of Object.entries(base.subdomains)) {
|
|
85
|
+
subdomains[sub] = Object.assign(Object.assign({}, value), { hostname: value.isPrimaryApp ? `${slug}.localhost` : `${sub}.${slug}.localhost` });
|
|
86
|
+
}
|
|
87
|
+
return { root: base.root, slug, subdomains };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Derive a per-TICKET identity from a base identity (used by `lt ticket` /
|
|
91
|
+
* `lt dev up --ticket`). Suffixes the slug + every subdomain hostname with the
|
|
92
|
+
* ticket id, so each ticket worktree runs on its OWN URLs / ports / Caddy block
|
|
93
|
+
* / DB — fully parallel to and isolated from every other ticket and the base
|
|
94
|
+
* dev session.
|
|
95
|
+
*
|
|
96
|
+
* svl.localhost → svl-2200.localhost
|
|
97
|
+
* api.svl.localhost → api.svl-2200.localhost
|
|
98
|
+
*
|
|
99
|
+
* Mechanically identical to {@link buildTestIdentity} (a named wrapper for
|
|
100
|
+
* readability + intent at the call sites). `id` is already a clean slug (see
|
|
101
|
+
* `deriveTicketId` in dev-ticket.ts).
|
|
102
|
+
*/
|
|
103
|
+
function buildTicketIdentity(base, id) {
|
|
104
|
+
return buildTestIdentity(base, `-${id}`);
|
|
105
|
+
}
|
|
70
106
|
/**
|
|
71
107
|
* Read the bare project name from package.json (scope stripped).
|
|
72
108
|
* Falls back to directory basename if no package.json or no `name`.
|
|
@@ -78,15 +78,25 @@ function runMigrate(input) {
|
|
|
78
78
|
});
|
|
79
79
|
const identity = (0, dev_identity_1.buildIdentity)(layout.root);
|
|
80
80
|
const dbName = (0, dev_project_1.deriveDbName)(layout.apiDir, identity.slug);
|
|
81
|
-
// 1. Code patches
|
|
81
|
+
// 1. Code patches. Run `autoPatch` over EVERY existing config file (not just
|
|
82
|
+
// the ones a port-detector flags): the patches are idempotent, and some —
|
|
83
|
+
// e.g. the playwright.config `ignoreHTTPSErrors` + shard-aware
|
|
84
|
+
// `LT_DEV_TEST_SHARDS` timeout block — apply to configs that are already
|
|
85
|
+
// env-aware. So `lt dev init` makes any project fully `lt dev test --shard`
|
|
86
|
+
// ready in one command; an up-to-date config is a no-op (`patched: false`).
|
|
82
87
|
const filesToPatch = [];
|
|
83
88
|
if (layout.apiDir) {
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
-
filesToPatch.push(
|
|
89
|
+
const apiCfg = (0, path_1.join)(layout.apiDir, 'src', 'config.env.ts');
|
|
90
|
+
if ((0, fs_1.existsSync)(apiCfg))
|
|
91
|
+
filesToPatch.push(apiCfg);
|
|
92
|
+
}
|
|
93
|
+
if (layout.appDir) {
|
|
94
|
+
for (const rel of ['nuxt.config.ts', 'playwright.config.ts']) {
|
|
95
|
+
const f = (0, path_1.join)(layout.appDir, rel);
|
|
96
|
+
if ((0, fs_1.existsSync)(f))
|
|
97
|
+
filesToPatch.push(f);
|
|
98
|
+
}
|
|
87
99
|
}
|
|
88
|
-
if (layout.appDir)
|
|
89
|
-
filesToPatch.push(...(0, dev_project_1.appNeedsPortPatch)(layout.appDir));
|
|
90
100
|
const codePatches = filesToPatch.map((f) => (0, dev_patches_1.autoPatch)(f));
|
|
91
101
|
// 2. CLAUDE.md URL block (root + each subproject — only patches existing files).
|
|
92
102
|
const claudeCandidates = [
|
package/build/lib/dev-patches.js
CHANGED
|
@@ -165,6 +165,16 @@ function patchNuxtConfig(file) {
|
|
|
165
165
|
* bracketed by `// >>> lt-dev:bridge >>>` markers.
|
|
166
166
|
* 2. Hardcoded baseURL/host/url for `http://localhost:3001` →
|
|
167
167
|
* `process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3001'`.
|
|
168
|
+
* 3. `webServer` wrapped in an `LT_DEV_ACTIVE` guard so Playwright reuses
|
|
169
|
+
* the App already served by `lt dev` / `lt dev test` instead of spawning
|
|
170
|
+
* its own (which would bind the wrong port and miss the isolated stack).
|
|
171
|
+
* 4. `ignoreHTTPSErrors: true` so Playwright's Chromium accepts the lt dev
|
|
172
|
+
* Caddy self-signed cert (required for `lt dev test` over HTTPS).
|
|
173
|
+
* 5. Shard-aware timeouts gated on `LT_DEV_TEST_SHARDS` — a `SHARDED` const
|
|
174
|
+
* plus relaxed `timeout` / `expect` / `navigationTimeout` / `actionTimeout`
|
|
175
|
+
* under sharded load only, so serial + CI keep their tight, fast-failing
|
|
176
|
+
* defaults. Each sub-patch is a graceful no-op on a non-standard config.
|
|
177
|
+
* 6. `slowMo: 10` → `0` (pointless per-action delay, multiplied across shards).
|
|
168
178
|
*/
|
|
169
179
|
function patchPlaywrightConfig(file) {
|
|
170
180
|
if (!(0, fs_1.existsSync)(file))
|
|
@@ -225,6 +235,67 @@ function patchPlaywrightConfig(file) {
|
|
|
225
235
|
count++;
|
|
226
236
|
}
|
|
227
237
|
}
|
|
238
|
+
// 3. Wrap `webServer` in an `LT_DEV_ACTIVE` guard so Playwright does NOT
|
|
239
|
+
// start/manage its own server when the App is already served by
|
|
240
|
+
// `lt dev` / `lt dev test` (both export LT_DEV_ACTIVE + the App URL).
|
|
241
|
+
// The original array/object's closing `]`/`}` becomes the ternary's
|
|
242
|
+
// false branch, so no bracket-matching is needed. Idempotent.
|
|
243
|
+
if (!/webServer:\s*process\.env\.LT_DEV_ACTIVE/.test(after)) {
|
|
244
|
+
after = after.replace(/webServer:\s*([[{])/, (_match, open) => {
|
|
245
|
+
count++;
|
|
246
|
+
return `webServer: process.env.LT_DEV_ACTIVE ? undefined : ${open}`;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
// 4. ignoreHTTPSErrors — accept the `lt dev` Caddy self-signed cert on
|
|
250
|
+
// `https://*.localhost` (Playwright's bundled Chromium uses its own trust
|
|
251
|
+
// store, so NODE_EXTRA_CA_CERTS alone is not enough). No-op in CI (http).
|
|
252
|
+
// Without this, `lt dev test` fails with ERR_CERT_AUTHORITY_INVALID.
|
|
253
|
+
if (!/ignoreHTTPSErrors/.test(after)) {
|
|
254
|
+
after = after.replace(/(\n(\s*)use:\s*\{)/, (_m, whole, indent) => {
|
|
255
|
+
count++;
|
|
256
|
+
return `${whole}\n${indent} ignoreHTTPSErrors: true,`;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
// 5. Shard-aware timeouts — `lt dev test --shard N` runs N built stacks +
|
|
260
|
+
// N Chromium concurrently; the CPU saturates and SSR slows 2-3x. Relax
|
|
261
|
+
// timeouts ONLY under that load (the CLI exports LT_DEV_TEST_SHARDS), so
|
|
262
|
+
// serial + CI keep their tight values and fast-failure feedback. Each
|
|
263
|
+
// sub-patch is idempotent + a graceful no-op on non-standard configs.
|
|
264
|
+
if (!/const SHARDED\b/.test(after) && /export default defineConfig/.test(after)) {
|
|
265
|
+
const shardConst = '// `lt dev test --shard N` saturates the CPU (N built SSR servers + N Chromium),\n' +
|
|
266
|
+
'// slowing every navigation. Relax timeouts ONLY under that load — the CLI sets\n' +
|
|
267
|
+
'// LT_DEV_TEST_SHARDS — so serial + CI keep their tight, fast-failing defaults.\n' +
|
|
268
|
+
"const SHARDED = Number(process.env.LT_DEV_TEST_SHARDS || '0') > 1;\n\n";
|
|
269
|
+
after = after.replace(/(export default defineConfig)/, `${shardConst}$1`);
|
|
270
|
+
count++;
|
|
271
|
+
}
|
|
272
|
+
// 5a. per-test timeout (`isWindows ? A : B` form) → add the sharded branch.
|
|
273
|
+
if (/timeout:\s*isWindows\s*\?/.test(after) && !/timeout:\s*isWindows\s*\?[^,\n]*SHARDED/.test(after)) {
|
|
274
|
+
after = after.replace(/timeout:\s*isWindows\s*\?\s*([0-9_]+)\s*:\s*([0-9_]+|undefined)/, (_m, a, b) => {
|
|
275
|
+
count++;
|
|
276
|
+
return `timeout: isWindows ? ${a} : SHARDED ? 180_000 : ${b}`;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
// 5b. expect.timeout (only when an `expect: { timeout: N }` already exists).
|
|
280
|
+
if (/expect:\s*\{\s*timeout:\s*[0-9_]+\s*\}/.test(after) && !/expect:\s*\{\s*timeout:\s*SHARDED/.test(after)) {
|
|
281
|
+
after = after.replace(/expect:\s*\{\s*timeout:\s*([0-9_]+)\s*\}/, (_m, t) => {
|
|
282
|
+
count++;
|
|
283
|
+
return `expect: { timeout: SHARDED ? 30_000 : ${t} }`;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
// 5c. navigation/action ceilings under shard (inject into `use` if absent).
|
|
287
|
+
if (!/navigationTimeout/.test(after)) {
|
|
288
|
+
after = after.replace(/(\n(\s*)use:\s*\{)/, (_m, whole, indent) => {
|
|
289
|
+
count++;
|
|
290
|
+
return `${whole}\n${indent} actionTimeout: SHARDED ? 30_000 : undefined,\n${indent} navigationTimeout: SHARDED ? 60_000 : undefined,`;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
// 6. slowMo: 10 → 0 — an artificial per-action delay, pointless and multiplied
|
|
294
|
+
// across N concurrent sharded browsers.
|
|
295
|
+
after = after.replace(/slowMo:\s*10\b/, () => {
|
|
296
|
+
count++;
|
|
297
|
+
return 'slowMo: 0';
|
|
298
|
+
});
|
|
228
299
|
if (count === 0)
|
|
229
300
|
return { file, patched: false, replacements: 0 };
|
|
230
301
|
(0, fs_1.writeFileSync)(file, after, 'utf8');
|
package/build/lib/dev-process.js
CHANGED
|
@@ -13,7 +13,11 @@ exports.checkPortInUse = checkPortInUse;
|
|
|
13
13
|
exports.killProcessGroup = killProcessGroup;
|
|
14
14
|
exports.listenSnapshot = listenSnapshot;
|
|
15
15
|
exports.rotateLogFile = rotateLogFile;
|
|
16
|
+
exports.runChildInherit = runChildInherit;
|
|
17
|
+
exports.runChildToFile = runChildToFile;
|
|
16
18
|
exports.spawnDetached = spawnDetached;
|
|
19
|
+
exports.terminateProcessGroup = terminateProcessGroup;
|
|
20
|
+
exports.waitForHttp = waitForHttp;
|
|
17
21
|
/**
|
|
18
22
|
* Process + port helpers for `lt dev`.
|
|
19
23
|
*
|
|
@@ -145,6 +149,60 @@ function rotateLogFile(logFile) {
|
|
|
145
149
|
}
|
|
146
150
|
return { archivePath, previousSize, rotated: true };
|
|
147
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Run a child to completion with inherited stdio. Resolves with the exit code.
|
|
154
|
+
*
|
|
155
|
+
* Counterpart of `spawnDetached`: foreground, synchronous-feeling, used for
|
|
156
|
+
* commands the user must see live output from (build, test runners).
|
|
157
|
+
* Errors during spawn resolve as exit code `1` so callers can branch on a
|
|
158
|
+
* single integer instead of try/catch.
|
|
159
|
+
*/
|
|
160
|
+
function runChildInherit(cmd, args, opts) {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const child = (0, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: 'inherit' });
|
|
163
|
+
child.on('error', () => resolve(1));
|
|
164
|
+
child.on('close', (code) => resolve(code));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Run a child to completion with stdout+stderr redirected to a log file.
|
|
169
|
+
* Resolves with the exit code (`1` on spawn error). Like `runChildInherit` but
|
|
170
|
+
* non-interleaving — used to run several children CONCURRENTLY (e.g. parallel
|
|
171
|
+
* Playwright shards) without their console output clobbering each other.
|
|
172
|
+
*/
|
|
173
|
+
function runChildToFile(cmd, args, opts) {
|
|
174
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(opts.logFile), { recursive: true });
|
|
175
|
+
// Rotate so each run starts with a fresh log (one prior generation kept as
|
|
176
|
+
// `<logFile>.1`) instead of appending run-on-run.
|
|
177
|
+
rotateLogFile(opts.logFile);
|
|
178
|
+
const out = (0, fs_1.openSync)(opts.logFile, 'a');
|
|
179
|
+
const close = () => {
|
|
180
|
+
try {
|
|
181
|
+
(0, fs_1.closeSync)(out);
|
|
182
|
+
}
|
|
183
|
+
catch (_a) {
|
|
184
|
+
/* already closed */
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
let child;
|
|
189
|
+
try {
|
|
190
|
+
child = (0, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['ignore', out, out] });
|
|
191
|
+
}
|
|
192
|
+
catch (_a) {
|
|
193
|
+
close();
|
|
194
|
+
return resolve(1);
|
|
195
|
+
}
|
|
196
|
+
child.on('error', () => {
|
|
197
|
+
close();
|
|
198
|
+
resolve(1);
|
|
199
|
+
});
|
|
200
|
+
child.on('close', (code) => {
|
|
201
|
+
close();
|
|
202
|
+
resolve(code);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
148
206
|
/**
|
|
149
207
|
* Spawn a detached child whose stdio is redirected to a log file.
|
|
150
208
|
*
|
|
@@ -188,3 +246,99 @@ function spawnDetached(cmd, args, opts) {
|
|
|
188
246
|
}
|
|
189
247
|
}
|
|
190
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Terminate a detached process group RELIABLY: SIGTERM the group, wait up to
|
|
251
|
+
* `graceMs` for it to exit, then SIGKILL anything still alive.
|
|
252
|
+
*
|
|
253
|
+
* Needed because a compiled NestJS API (`node dist`) installs shutdown hooks
|
|
254
|
+
* that catch SIGTERM and can hang on open Mongo connections — a single
|
|
255
|
+
* SIGTERM then "succeeds" (the call returns) while the process keeps
|
|
256
|
+
* listening on its port and holding DB connections. `lt dev test`'s
|
|
257
|
+
* residue-free teardown promise depends on the process actually being gone,
|
|
258
|
+
* so we escalate to SIGKILL after a grace period.
|
|
259
|
+
*
|
|
260
|
+
* Polls every 150ms so a process that exits cleanly returns near-instantly
|
|
261
|
+
* (only a hung process waits the full `graceMs`). Returns true if the process
|
|
262
|
+
* is gone by the end, false if it somehow survived even SIGKILL.
|
|
263
|
+
*/
|
|
264
|
+
function terminateProcessGroup(pid_1) {
|
|
265
|
+
return __awaiter(this, arguments, void 0, function* (pid, graceMs = 4000) {
|
|
266
|
+
if (!(0, dev_state_1.isValidPid)(pid))
|
|
267
|
+
return false;
|
|
268
|
+
if (!(0, dev_state_1.isPidAlive)(pid))
|
|
269
|
+
return true;
|
|
270
|
+
// Phase 1 — graceful: SIGTERM the group (single-PID fallback inside).
|
|
271
|
+
killProcessGroup(pid);
|
|
272
|
+
const deadline = Date.now() + Math.max(0, graceMs);
|
|
273
|
+
while (Date.now() < deadline) {
|
|
274
|
+
if (!(0, dev_state_1.isPidAlive)(pid))
|
|
275
|
+
return true;
|
|
276
|
+
yield delay(150);
|
|
277
|
+
}
|
|
278
|
+
// Phase 2 — forced: SIGKILL the group, then the single PID.
|
|
279
|
+
if (!(0, dev_state_1.isPidAlive)(pid))
|
|
280
|
+
return true;
|
|
281
|
+
try {
|
|
282
|
+
process.kill(-pid, 'SIGKILL');
|
|
283
|
+
}
|
|
284
|
+
catch (_a) {
|
|
285
|
+
/* group already gone or pid is not a group leader */
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
process.kill(pid, 'SIGKILL');
|
|
289
|
+
}
|
|
290
|
+
catch (_b) {
|
|
291
|
+
/* already dead */
|
|
292
|
+
}
|
|
293
|
+
yield delay(150);
|
|
294
|
+
return !(0, dev_state_1.isPidAlive)(pid);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Poll an HTTPS/HTTP URL until a matching response is observed or `timeoutMs`
|
|
299
|
+
* elapses.
|
|
300
|
+
*
|
|
301
|
+
* Used to wait for dev servers to become reachable before the next step
|
|
302
|
+
* (typically running a test suite). By default treats ANY HTTP status (1xx-5xx)
|
|
303
|
+
* as "up" — a 404 means the server is bound and answering, which is usually
|
|
304
|
+
* what we want to know. Pass `ready` to require a stricter status: an API
|
|
305
|
+
* readiness probe wants a real 2xx on `/meta`, because Caddy answers 502 while
|
|
306
|
+
* its upstream is still booting and the default predicate would accept that
|
|
307
|
+
* prematurely (the cause of the test-suite API-readiness race). Uses `curl`
|
|
308
|
+
* because it is universally available and handles HTTPS-with-self-signed-cert
|
|
309
|
+
* (Caddy) via `-k` for free.
|
|
310
|
+
*
|
|
311
|
+
* Resolves `true` on the first matching response, `false` on timeout. Never rejects.
|
|
312
|
+
*/
|
|
313
|
+
function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status < 600) {
|
|
314
|
+
const start = Date.now();
|
|
315
|
+
return new Promise((resolve) => {
|
|
316
|
+
const tick = () => {
|
|
317
|
+
var _a;
|
|
318
|
+
const child = (0, child_process_1.spawn)('curl', ['-sk', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '2', url], {
|
|
319
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
320
|
+
});
|
|
321
|
+
let status = '';
|
|
322
|
+
(_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (status += String(b)));
|
|
323
|
+
child.on('close', () => {
|
|
324
|
+
const code = Number(status.trim());
|
|
325
|
+
// `000` (curl could not connect) parses to 0 → never "ready".
|
|
326
|
+
if (Number.isFinite(code) && code > 0 && ready(code))
|
|
327
|
+
return resolve(true);
|
|
328
|
+
if (Date.now() - start > timeoutMs)
|
|
329
|
+
return resolve(false);
|
|
330
|
+
setTimeout(tick, 500);
|
|
331
|
+
});
|
|
332
|
+
child.on('error', () => {
|
|
333
|
+
if (Date.now() - start > timeoutMs)
|
|
334
|
+
return resolve(false);
|
|
335
|
+
setTimeout(tick, 500);
|
|
336
|
+
});
|
|
337
|
+
};
|
|
338
|
+
tick();
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
/** Promise-based delay used by the graceful→forced termination escalation. */
|
|
342
|
+
function delay(ms) {
|
|
343
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
344
|
+
}
|
package/build/lib/dev-project.js
CHANGED
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.apiNeedsPortPatch = apiNeedsPortPatch;
|
|
4
4
|
exports.appNeedsPortPatch = appNeedsPortPatch;
|
|
5
5
|
exports.deriveDbName = deriveDbName;
|
|
6
|
+
exports.deriveTestDbName = deriveTestDbName;
|
|
7
|
+
exports.deriveTicketDbName = deriveTicketDbName;
|
|
6
8
|
exports.resolveLayout = resolveLayout;
|
|
7
9
|
const fs_1 = require("fs");
|
|
8
10
|
const path_1 = require("path");
|
|
@@ -33,7 +35,10 @@ function appNeedsPortPatch(appDir) {
|
|
|
33
35
|
/target:\s*'http:\/\/localhost:3000'/.test(c) ||
|
|
34
36
|
/baseURL:\s*'http:\/\/localhost:3001'/.test(c) ||
|
|
35
37
|
/url:\s*'http:\/\/localhost:3001'/.test(c) ||
|
|
36
|
-
/host:\s*'http:\/\/localhost:3001'/.test(c)
|
|
38
|
+
/host:\s*'http:\/\/localhost:3001'/.test(c) ||
|
|
39
|
+
// Unguarded Playwright `webServer` (no LT_DEV_ACTIVE guard) — patch it so
|
|
40
|
+
// `lt dev test`'s isolated stack is reused instead of a stray server.
|
|
41
|
+
(/webServer:\s*[[{]/.test(c) && !/webServer:\s*process\.env\.LT_DEV_ACTIVE/.test(c)));
|
|
37
42
|
});
|
|
38
43
|
}
|
|
39
44
|
/** Read `dbName` from the API config (defaults to `<slug>-local`). */
|
|
@@ -49,6 +54,34 @@ function deriveDbName(apiDir, slug) {
|
|
|
49
54
|
}
|
|
50
55
|
return `${slug}-local`;
|
|
51
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Derive the dedicated database name for the `lt dev test` stack from the
|
|
59
|
+
* project's dev DB name. Distinct from both `<…>-local` (developer DB) and
|
|
60
|
+
* the API unit-test DB (`<…>-e2e`), so Playwright E2E never touches developer
|
|
61
|
+
* or API-test data.
|
|
62
|
+
*
|
|
63
|
+
* svl-sports-system-local → svl-sports-system-test
|
|
64
|
+
*
|
|
65
|
+
* Uses the `-test` suffix so it passes test-helper guards that only permit
|
|
66
|
+
* local/test databases (name ending in `-local` | `-ci` | `-e2e` | `-test`).
|
|
67
|
+
*/
|
|
68
|
+
function deriveTestDbName(devDbName) {
|
|
69
|
+
const base = devDbName.replace(/-(local|dev)$/i, '');
|
|
70
|
+
return `${base}-test`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Derive the per-TICKET database name from the project's dev DB name, so each
|
|
74
|
+
* ticket worktree reads/writes its OWN database and tickets never collide.
|
|
75
|
+
*
|
|
76
|
+
* svl-sports-system-local + "2200" → svl-sports-system-2200
|
|
77
|
+
*
|
|
78
|
+
* The ticket's isolated `lt dev test` stack then derives its test DB from this
|
|
79
|
+
* via {@link deriveTestDbName} → `svl-sports-system-2200-test`.
|
|
80
|
+
*/
|
|
81
|
+
function deriveTicketDbName(devDbName, ticketId) {
|
|
82
|
+
const base = devDbName.replace(/-(local|dev)$/i, '');
|
|
83
|
+
return `${base}-${ticketId}`;
|
|
84
|
+
}
|
|
52
85
|
/**
|
|
53
86
|
* Resolve layout starting from `cwd`. Walks up to find a workspace if
|
|
54
87
|
* cwd is inside `projects/api/` or `projects/app/`.
|