@lenne.tech/cli 1.26.1 → 1.27.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/down.js +12 -0
- package/build/commands/dev/status.js +19 -1
- package/build/commands/dev/test.js +197 -118
- package/build/lib/dev-env-bridge.js +6 -6
- package/build/lib/dev-identity.js +18 -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 +20 -1
- package/build/lib/dev-state.js +9 -7
- package/build/lib/dev-test-session.js +410 -0
- package/package.json +1 -1
|
@@ -15,6 +15,7 @@ const dev_identity_1 = require("../../lib/dev-identity");
|
|
|
15
15
|
const dev_process_1 = require("../../lib/dev-process");
|
|
16
16
|
const dev_project_1 = require("../../lib/dev-project");
|
|
17
17
|
const dev_state_1 = require("../../lib/dev-state");
|
|
18
|
+
const dev_test_session_1 = require("../../lib/dev-test-session");
|
|
18
19
|
/**
|
|
19
20
|
* Stop the processes started by `lt dev up` and remove the project's
|
|
20
21
|
* Caddy block.
|
|
@@ -66,6 +67,17 @@ const DownCommand = {
|
|
|
66
67
|
// do not pick up stale URLs.
|
|
67
68
|
if ((0, dev_env_bridge_1.clearEnvBridge)(layout.root))
|
|
68
69
|
info(colors.dim('Removed .lt-dev/.env bridge.'));
|
|
70
|
+
// Also tear down any isolated test stack (`lt dev test`) for this project,
|
|
71
|
+
// so `lt dev down` always leaves a clean slate.
|
|
72
|
+
if ((0, dev_test_session_1.hasTestSession)(layout.root)) {
|
|
73
|
+
const { stopped: testStopped } = yield (0, dev_test_session_1.tearDownTestSession)(layout, identity, {
|
|
74
|
+
dim: colors.dim,
|
|
75
|
+
info,
|
|
76
|
+
warn: warning,
|
|
77
|
+
});
|
|
78
|
+
if (testStopped.length > 0)
|
|
79
|
+
success(`Stopped test stack: ${testStopped.join(', ')}`);
|
|
80
|
+
}
|
|
69
81
|
if (stopped.length > 0)
|
|
70
82
|
success(`Stopped: ${stopped.join(', ')}`);
|
|
71
83
|
if (!parameters.options.fromGluegunMenu)
|
|
@@ -26,7 +26,7 @@ const StatusCommand = {
|
|
|
26
26
|
hidden: false,
|
|
27
27
|
name: 'status',
|
|
28
28
|
run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
|
|
29
|
-
var _a, _b;
|
|
29
|
+
var _a, _b, _c, _d;
|
|
30
30
|
const { filesystem, parameters, print: { colors, info, warning }, } = toolbox;
|
|
31
31
|
const all = Boolean(parameters.options.all);
|
|
32
32
|
const reg = (0, dev_state_1.loadRegistry)();
|
|
@@ -144,6 +144,24 @@ const StatusCommand = {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
|
+
// Isolated test stack (`lt dev test`), if one is up / left with --keep.
|
|
148
|
+
const testSession = (0, dev_state_1.loadSession)(layout.root, dev_state_1.TEST_SESSION_FILE);
|
|
149
|
+
if (testSession) {
|
|
150
|
+
const testEntry = reg.projects[`${identity.slug}-test`];
|
|
151
|
+
info('');
|
|
152
|
+
info(colors.bold(' Isolated test stack (lt dev test)'));
|
|
153
|
+
if (testEntry) {
|
|
154
|
+
for (const [sub, host] of Object.entries(testEntry.subdomains))
|
|
155
|
+
info(` ${sub.padEnd(6)} https://${host}`);
|
|
156
|
+
if (testEntry.dbName)
|
|
157
|
+
info(` db mongodb://127.0.0.1/${testEntry.dbName}`);
|
|
158
|
+
}
|
|
159
|
+
const apiAlive = testSession.pids.api ? (0, dev_state_1.isPidAlive)(testSession.pids.api) : false;
|
|
160
|
+
const appAlive = testSession.pids.app ? (0, dev_state_1.isPidAlive)(testSession.pids.app) : false;
|
|
161
|
+
info(` api: ${apiAlive ? colors.green('running') : colors.red('dead')} (pid ${(_c = testSession.pids.api) !== null && _c !== void 0 ? _c : '-'})`);
|
|
162
|
+
info(` app: ${appAlive ? colors.green('running') : colors.red('dead')} (pid ${(_d = testSession.pids.app) !== null && _d !== void 0 ? _d : '-'})`);
|
|
163
|
+
info(colors.dim(' stop: lt dev test down'));
|
|
164
|
+
}
|
|
147
165
|
info('');
|
|
148
166
|
if (!parameters.options.fromGluegunMenu)
|
|
149
167
|
process.exit();
|
|
@@ -9,45 +9,44 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
const child_process_1 = require("child_process");
|
|
13
12
|
const fs_1 = require("fs");
|
|
14
13
|
const caddy_1 = require("../../lib/caddy");
|
|
15
14
|
const dev_env_bridge_1 = require("../../lib/dev-env-bridge");
|
|
16
15
|
const dev_identity_1 = require("../../lib/dev-identity");
|
|
16
|
+
const dev_process_1 = require("../../lib/dev-process");
|
|
17
17
|
const dev_project_1 = require("../../lib/dev-project");
|
|
18
|
-
const
|
|
18
|
+
const dev_test_session_1 = require("../../lib/dev-test-session");
|
|
19
19
|
/**
|
|
20
20
|
* One-shot E2E convenience wrapper.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* App mode (default) runs the Playwright suite against a fully ISOLATED test
|
|
23
|
+
* stack (own URLs / ports / Caddy block / dedicated `<…>-test` database) that
|
|
24
|
+
* runs parallel to — and never touches — the developer's `lt dev up` session.
|
|
25
|
+
* Playwright's global-setup resets that dedicated DB once before the first test.
|
|
26
|
+
* The stack is torn down automatically when the run finishes (residue-free).
|
|
26
27
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* 2. Wait for the App URL to respond (best-effort, ~30s timeout).
|
|
30
|
-
* 3. Inherit the env from `<root>/.lt-dev/.env` (so VS Code IDE-style
|
|
31
|
-
* runners get the same setup) and spawn the test command in the
|
|
32
|
-
* App project.
|
|
33
|
-
* 4. With `--teardown` (default false): run `lt dev down` after.
|
|
28
|
+
* API mode (`--api`) runs the standalone API test suite (`pnpm test:e2e` in the
|
|
29
|
+
* API), which already isolates itself on its own DB — no stack is brought up.
|
|
34
30
|
*
|
|
35
31
|
* Usage:
|
|
36
|
-
* lt dev test
|
|
37
|
-
* lt dev test --
|
|
38
|
-
* lt dev test
|
|
39
|
-
* lt dev test --
|
|
40
|
-
* lt dev test --
|
|
41
|
-
* lt dev test --
|
|
32
|
+
* lt dev test # isolated Playwright E2E (auto teardown)
|
|
33
|
+
* lt dev test --keep # leave the test stack up afterwards (debug)
|
|
34
|
+
* lt dev test down # tear the test stack down (residue-free)
|
|
35
|
+
* lt dev test --api # run API tests instead (already isolated)
|
|
36
|
+
* lt dev test --debug # PWDEBUG=1 + headed
|
|
37
|
+
* lt dev test --shard # shard across 2 isolated stacks (default — stable)
|
|
38
|
+
* lt dev test --shard N # shard across N isolated stacks (parallel)
|
|
39
|
+
* lt dev test --shard auto # size N from this machine's CPU + RAM
|
|
40
|
+
* lt dev test -- <args> # forward args to Playwright
|
|
42
41
|
*/
|
|
43
42
|
const TestCommand = {
|
|
44
43
|
alias: ['t'],
|
|
45
|
-
description: '
|
|
44
|
+
description: 'Run E2E tests in an isolated, parallel test stack (auto teardown)',
|
|
46
45
|
hidden: false,
|
|
47
46
|
name: 'test',
|
|
48
47
|
run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
|
|
49
48
|
var _a;
|
|
50
|
-
const { filesystem, parameters, print: { colors, error, info, success }, } = toolbox;
|
|
49
|
+
const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
|
|
51
50
|
const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
|
|
52
51
|
if (!layout.apiDir && !layout.appDir) {
|
|
53
52
|
error('No API or App project detected at this path.');
|
|
@@ -55,74 +54,193 @@ const TestCommand = {
|
|
|
55
54
|
process.exit(1);
|
|
56
55
|
return 'dev test: not a project';
|
|
57
56
|
}
|
|
57
|
+
const identity = (0, dev_identity_1.buildIdentity)(layout.root);
|
|
58
|
+
const log = { dim: colors.dim, info, warn: warning };
|
|
59
|
+
// Sub-command: `lt dev test down` — tear the test stack(s) down + exit.
|
|
60
|
+
// Reclaims both the unsharded stack and any leftover sharded stacks.
|
|
61
|
+
if (parameters.first === 'down') {
|
|
62
|
+
const { stopped } = yield (0, dev_test_session_1.tearDownAllTestSessions)(layout, identity, log);
|
|
63
|
+
if (stopped.length > 0)
|
|
64
|
+
success(`Test stack down: ${stopped.join(', ')}`);
|
|
65
|
+
else
|
|
66
|
+
info(colors.dim('No test stack was running.'));
|
|
67
|
+
if (!parameters.options.fromGluegunMenu)
|
|
68
|
+
process.exit();
|
|
69
|
+
return 'dev test: down';
|
|
70
|
+
}
|
|
58
71
|
const apiMode = Boolean(parameters.options.api);
|
|
59
|
-
const
|
|
72
|
+
const keep = Boolean(parameters.options.keep) || parameters.options.teardown === false;
|
|
60
73
|
const debug = Boolean(parameters.options.debug);
|
|
61
|
-
// Forwarded args after `--` (gluegun puts them in parameters.array).
|
|
62
74
|
const forwarded = parameters.array || [];
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
|
|
76
|
+
// `--shard N` → run the suite split across N fully-isolated stacks in
|
|
77
|
+
// parallel. A bare `--shard` defaults to 2 — the stable sweet spot for a
|
|
78
|
+
// heavy built-SSR suite (N>=3 over-subscribes the perf cores → flaky; see
|
|
79
|
+
// autoShardCount). `--shard auto` instead sizes N from this machine's CPU+RAM.
|
|
80
|
+
const SHARD_DEFAULT = 2;
|
|
81
|
+
const shardRaw = (_a = parameters.options.shard) !== null && _a !== void 0 ? _a : parameters.options.shards;
|
|
82
|
+
let shardTotal;
|
|
83
|
+
if (shardRaw !== undefined) {
|
|
84
|
+
if (shardRaw === true)
|
|
85
|
+
shardTotal = SHARD_DEFAULT;
|
|
86
|
+
else if (String(shardRaw).toLowerCase() === 'auto')
|
|
87
|
+
shardTotal = (0, dev_test_session_1.autoShardCount)();
|
|
88
|
+
else
|
|
89
|
+
shardTotal = Math.floor(Number(shardRaw));
|
|
69
90
|
}
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
91
|
+
// -------------------------------------------------------------------------
|
|
92
|
+
// API mode — standalone, already isolated on its own DB. No stack needed.
|
|
93
|
+
// -------------------------------------------------------------------------
|
|
94
|
+
if (apiMode) {
|
|
95
|
+
if (!layout.apiDir) {
|
|
96
|
+
error('No API project in this layout.');
|
|
75
97
|
if (!parameters.options.fromGluegunMenu)
|
|
76
98
|
process.exit(1);
|
|
77
|
-
return 'dev test:
|
|
99
|
+
return 'dev test: no api';
|
|
78
100
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
101
|
+
info(colors.bold(`Running API tests for "${identity.slug}" (isolated DB)`));
|
|
102
|
+
const code = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'test:e2e', ...forwarded], {
|
|
103
|
+
cwd: layout.apiDir,
|
|
104
|
+
env: process.env,
|
|
105
|
+
});
|
|
106
|
+
if (code === 0)
|
|
107
|
+
success('API tests passed.');
|
|
108
|
+
else
|
|
109
|
+
error(`API tests failed (exit ${code}).`);
|
|
110
|
+
if (!parameters.options.fromGluegunMenu)
|
|
111
|
+
process.exit(code !== null && code !== void 0 ? code : 1);
|
|
112
|
+
return `dev test: api exit=${code}`;
|
|
113
|
+
}
|
|
114
|
+
// -------------------------------------------------------------------------
|
|
115
|
+
// App mode — isolated Playwright stack.
|
|
116
|
+
// -------------------------------------------------------------------------
|
|
117
|
+
if (!layout.appDir) {
|
|
118
|
+
error('No App project in this layout.');
|
|
119
|
+
if (!parameters.options.fromGluegunMenu)
|
|
120
|
+
process.exit(1);
|
|
121
|
+
return 'dev test: no app';
|
|
122
|
+
}
|
|
123
|
+
if (!(yield (0, caddy_1.caddyAvailable)())) {
|
|
124
|
+
error('caddy is not installed. Run `lt dev install` first.');
|
|
125
|
+
if (!parameters.options.fromGluegunMenu)
|
|
126
|
+
process.exit(1);
|
|
127
|
+
return 'dev test: caddy missing';
|
|
128
|
+
}
|
|
129
|
+
if (!(yield (0, caddy_1.caddyDaemonRunning)())) {
|
|
130
|
+
error('caddy daemon not running. Run `lt dev install` first.');
|
|
131
|
+
if (!parameters.options.fromGluegunMenu)
|
|
132
|
+
process.exit(1);
|
|
133
|
+
return 'dev test: caddy daemon down';
|
|
134
|
+
}
|
|
135
|
+
// Pre-flight (#3): if the project's playwright.config is not env-aware
|
|
136
|
+
// (hardcoded baseURL or an unguarded `webServer`), the suite would IGNORE
|
|
137
|
+
// the isolated stack we are about to build and hit localhost:3001 instead —
|
|
138
|
+
// wasting a full build on a stack nothing uses. Abort early with the fix,
|
|
139
|
+
// unless `--force`.
|
|
140
|
+
const unpatched = (0, dev_project_1.appNeedsPortPatch)(layout.appDir).some((f) => f.endsWith('playwright.config.ts'));
|
|
141
|
+
if (unpatched && !parameters.options.force) {
|
|
142
|
+
error("This project's playwright.config.ts is not env-aware (hardcoded baseURL or unguarded webServer).");
|
|
143
|
+
error('`lt dev test` would build an isolated stack the suite then IGNORES (tests would hit localhost:3001).');
|
|
144
|
+
info(colors.dim('Fix: run `lt dev init` to make baseURL env-aware + guard the webServer — or pass --force.'));
|
|
145
|
+
if (!parameters.options.fromGluegunMenu)
|
|
146
|
+
process.exit(1);
|
|
147
|
+
return 'dev test: playwright.config not env-aware (run lt dev init or --force)';
|
|
148
|
+
}
|
|
149
|
+
// -------------------------------------------------------------------------
|
|
150
|
+
// Sharded mode (`--shard N`) — N fully-isolated stacks + parallel
|
|
151
|
+
// `--shard=i/N`, the local CI-parity matrix. Each shard has its own DB so
|
|
152
|
+
// there is no cross-shard contention. Auto-teardown of ALL shards (or
|
|
153
|
+
// `--keep` them for `lt dev test down`).
|
|
154
|
+
// -------------------------------------------------------------------------
|
|
155
|
+
if (shardTotal !== undefined && shardTotal > 1) {
|
|
156
|
+
let allTornDown = false;
|
|
157
|
+
const teardownAll = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
158
|
+
if (allTornDown || keep)
|
|
159
|
+
return;
|
|
160
|
+
allTornDown = true;
|
|
161
|
+
yield (0, dev_test_session_1.tearDownAllTestSessions)(layout, identity, log, { silent: true });
|
|
162
|
+
});
|
|
163
|
+
const onShardSignal = () => {
|
|
164
|
+
teardownAll()
|
|
165
|
+
.catch(() => undefined)
|
|
166
|
+
.finally(() => process.exit(130));
|
|
167
|
+
};
|
|
168
|
+
process.on('SIGINT', onShardSignal);
|
|
169
|
+
process.on('SIGTERM', onShardSignal);
|
|
170
|
+
let shardExit = 1;
|
|
171
|
+
try {
|
|
172
|
+
info('');
|
|
173
|
+
info(colors.bold(`Running isolated Playwright E2E for "${identity.slug}" sharded across ${shardTotal} stacks`));
|
|
174
|
+
shardExit = yield (0, dev_test_session_1.runShardedTestSession)(layout, identity, log, { forwarded, pnpmBin, total: shardTotal });
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
error(`Failed to run sharded E2E: ${e.message}`);
|
|
178
|
+
shardExit = 1;
|
|
84
179
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
info(colors.dim('Project not running — invoking `lt dev up` first ...'));
|
|
92
|
-
const upResult = yield runChild('lt', ['dev', 'up'], { cwd: layout.root, env: process.env, inherit: true });
|
|
93
|
-
if (upResult !== 0) {
|
|
94
|
-
error(`lt dev up failed (exit ${upResult}).`);
|
|
95
|
-
if (!parameters.options.fromGluegunMenu)
|
|
96
|
-
process.exit(upResult !== null && upResult !== void 0 ? upResult : 1);
|
|
97
|
-
return 'dev test: up failed';
|
|
180
|
+
finally {
|
|
181
|
+
process.off('SIGINT', onShardSignal);
|
|
182
|
+
process.off('SIGTERM', onShardSignal);
|
|
183
|
+
if (keep) {
|
|
184
|
+
info('');
|
|
185
|
+
info(colors.dim('Test stacks left running (--keep). Stop them with: `lt dev test down`.'));
|
|
98
186
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (appHost) {
|
|
102
|
-
info(colors.dim(`Waiting for https://${appHost} to respond ...`));
|
|
103
|
-
yield waitForUrl(`https://${appHost}`, 30000);
|
|
187
|
+
else {
|
|
188
|
+
yield teardownAll();
|
|
104
189
|
}
|
|
105
190
|
}
|
|
191
|
+
if (shardExit === 0)
|
|
192
|
+
success('Tests passed (all shards).');
|
|
193
|
+
else
|
|
194
|
+
error(`Tests failed (exit ${shardExit}).`);
|
|
195
|
+
if (!parameters.options.fromGluegunMenu)
|
|
196
|
+
process.exit(shardExit !== null && shardExit !== void 0 ? shardExit : 1);
|
|
197
|
+
return `dev test: sharded exit=${shardExit}`;
|
|
106
198
|
}
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
199
|
+
let tornDown = false;
|
|
200
|
+
const teardown = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
201
|
+
if (tornDown || keep)
|
|
202
|
+
return;
|
|
203
|
+
tornDown = true;
|
|
204
|
+
yield (0, dev_test_session_1.tearDownTestSession)(layout, identity, log);
|
|
205
|
+
});
|
|
206
|
+
// Residue-free teardown even on Ctrl-C / kill.
|
|
207
|
+
const onSignal = () => {
|
|
208
|
+
teardown()
|
|
209
|
+
.catch(() => undefined)
|
|
210
|
+
.finally(() => process.exit(130));
|
|
211
|
+
};
|
|
212
|
+
process.on('SIGINT', onSignal);
|
|
213
|
+
process.on('SIGTERM', onSignal);
|
|
214
|
+
let exitCode = 1;
|
|
215
|
+
try {
|
|
216
|
+
const ctx = yield (0, dev_test_session_1.bringUpTestSession)(layout, identity, log);
|
|
217
|
+
const env = Object.assign(Object.assign(Object.assign({}, process.env), readBridgeEnv(layout.root)), {
|
|
218
|
+
// Playwright global-setup resets THIS db (allow-listed) before the suite.
|
|
219
|
+
MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` });
|
|
220
|
+
if (debug) {
|
|
221
|
+
env.PWDEBUG = '1';
|
|
222
|
+
env.HEADED = '1';
|
|
223
|
+
}
|
|
123
224
|
info('');
|
|
124
|
-
info(colors.
|
|
125
|
-
|
|
225
|
+
info(colors.bold(`Running isolated Playwright E2E for "${identity.slug}"`));
|
|
226
|
+
info(colors.dim(` app: ${ctx.appUrl} db: ${ctx.dbName}`));
|
|
227
|
+
info('');
|
|
228
|
+
exitCode = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'test:e2e', ...forwarded], { cwd: layout.appDir, env });
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
error(`Failed to run isolated E2E: ${e.message}`);
|
|
232
|
+
exitCode = 1;
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
process.off('SIGINT', onSignal);
|
|
236
|
+
process.off('SIGTERM', onSignal);
|
|
237
|
+
if (keep) {
|
|
238
|
+
info('');
|
|
239
|
+
info(colors.dim('Test stack left running (--keep). Stop it with: `lt dev test down`.'));
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
yield teardown();
|
|
243
|
+
}
|
|
126
244
|
}
|
|
127
245
|
if (exitCode === 0)
|
|
128
246
|
success('Tests passed.');
|
|
@@ -134,11 +252,11 @@ const TestCommand = {
|
|
|
134
252
|
}),
|
|
135
253
|
};
|
|
136
254
|
/**
|
|
137
|
-
* Read the
|
|
138
|
-
* Returns an empty object if
|
|
255
|
+
* Read the test ENV bridge (`.lt-dev/.env.test`) as a key/value map.
|
|
256
|
+
* Returns an empty object if missing.
|
|
139
257
|
*/
|
|
140
258
|
function readBridgeEnv(root) {
|
|
141
|
-
const file = (0, dev_env_bridge_1.envBridgePath)(root);
|
|
259
|
+
const file = (0, dev_env_bridge_1.envBridgePath)(root, '.env.test');
|
|
142
260
|
if (!(0, fs_1.existsSync)(file))
|
|
143
261
|
return {};
|
|
144
262
|
const out = {};
|
|
@@ -149,43 +267,4 @@ function readBridgeEnv(root) {
|
|
|
149
267
|
}
|
|
150
268
|
return out;
|
|
151
269
|
}
|
|
152
|
-
/** Spawn a child synchronously (waits for exit), inheriting stdio when requested. */
|
|
153
|
-
function runChild(cmd, args, opts) {
|
|
154
|
-
return new Promise((resolve) => {
|
|
155
|
-
const child = (0, child_process_1.spawn)(cmd, args, {
|
|
156
|
-
cwd: opts.cwd,
|
|
157
|
-
env: opts.env,
|
|
158
|
-
stdio: opts.inherit ? 'inherit' : 'pipe',
|
|
159
|
-
});
|
|
160
|
-
child.on('error', () => resolve(1));
|
|
161
|
-
child.on('close', (code) => resolve(code));
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
/** Poll a URL until it responds or timeout elapses. */
|
|
165
|
-
function waitForUrl(url, timeoutMs) {
|
|
166
|
-
const start = Date.now();
|
|
167
|
-
return new Promise((resolve) => {
|
|
168
|
-
const tick = () => {
|
|
169
|
-
var _a;
|
|
170
|
-
const child = (0, child_process_1.spawn)('curl', ['-sk', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '2', url], {
|
|
171
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
172
|
-
});
|
|
173
|
-
let status = '';
|
|
174
|
-
(_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (status += String(b)));
|
|
175
|
-
child.on('close', () => {
|
|
176
|
-
if (/^[1-5]\d\d$/.test(status.trim()))
|
|
177
|
-
return resolve(true);
|
|
178
|
-
if (Date.now() - start > timeoutMs)
|
|
179
|
-
return resolve(false);
|
|
180
|
-
setTimeout(tick, 500);
|
|
181
|
-
});
|
|
182
|
-
child.on('error', () => {
|
|
183
|
-
if (Date.now() - start > timeoutMs)
|
|
184
|
-
return resolve(false);
|
|
185
|
-
setTimeout(tick, 500);
|
|
186
|
-
});
|
|
187
|
-
};
|
|
188
|
-
tick();
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
270
|
module.exports = TestCommand;
|
|
@@ -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,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.buildIdentity = buildIdentity;
|
|
4
|
+
exports.buildTestIdentity = buildTestIdentity;
|
|
4
5
|
exports.projectSlug = projectSlug;
|
|
5
6
|
exports.slugify = slugify;
|
|
6
7
|
/**
|
|
@@ -67,6 +68,23 @@ function buildIdentity(root) {
|
|
|
67
68
|
}
|
|
68
69
|
return { root, slug, subdomains };
|
|
69
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Derive an ephemeral "test" identity from a base identity (used by
|
|
73
|
+
* `lt dev test`). Suffixes the slug and every subdomain hostname with
|
|
74
|
+
* `-test`, so the test stack runs on its own URLs / ports / Caddy block,
|
|
75
|
+
* fully parallel to (and isolated from) the dev session.
|
|
76
|
+
*
|
|
77
|
+
* svl.localhost → svl-test.localhost
|
|
78
|
+
* api.svl.localhost → api.svl-test.localhost
|
|
79
|
+
*/
|
|
80
|
+
function buildTestIdentity(base, suffix = '-test') {
|
|
81
|
+
const slug = `${base.slug}${suffix}`;
|
|
82
|
+
const subdomains = {};
|
|
83
|
+
for (const [sub, value] of Object.entries(base.subdomains)) {
|
|
84
|
+
subdomains[sub] = Object.assign(Object.assign({}, value), { hostname: value.isPrimaryApp ? `${slug}.localhost` : `${sub}.${slug}.localhost` });
|
|
85
|
+
}
|
|
86
|
+
return { root: base.root, slug, subdomains };
|
|
87
|
+
}
|
|
70
88
|
/**
|
|
71
89
|
* Read the bare project name from package.json (scope stripped).
|
|
72
90
|
* 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,7 @@ 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;
|
|
6
7
|
exports.resolveLayout = resolveLayout;
|
|
7
8
|
const fs_1 = require("fs");
|
|
8
9
|
const path_1 = require("path");
|
|
@@ -33,7 +34,10 @@ function appNeedsPortPatch(appDir) {
|
|
|
33
34
|
/target:\s*'http:\/\/localhost:3000'/.test(c) ||
|
|
34
35
|
/baseURL:\s*'http:\/\/localhost:3001'/.test(c) ||
|
|
35
36
|
/url:\s*'http:\/\/localhost:3001'/.test(c) ||
|
|
36
|
-
/host:\s*'http:\/\/localhost:3001'/.test(c)
|
|
37
|
+
/host:\s*'http:\/\/localhost:3001'/.test(c) ||
|
|
38
|
+
// Unguarded Playwright `webServer` (no LT_DEV_ACTIVE guard) — patch it so
|
|
39
|
+
// `lt dev test`'s isolated stack is reused instead of a stray server.
|
|
40
|
+
(/webServer:\s*[[{]/.test(c) && !/webServer:\s*process\.env\.LT_DEV_ACTIVE/.test(c)));
|
|
37
41
|
});
|
|
38
42
|
}
|
|
39
43
|
/** Read `dbName` from the API config (defaults to `<slug>-local`). */
|
|
@@ -49,6 +53,21 @@ function deriveDbName(apiDir, slug) {
|
|
|
49
53
|
}
|
|
50
54
|
return `${slug}-local`;
|
|
51
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Derive the dedicated database name for the `lt dev test` stack from the
|
|
58
|
+
* project's dev DB name. Distinct from both `<…>-local` (developer DB) and
|
|
59
|
+
* the API unit-test DB (`<…>-e2e`), so Playwright E2E never touches developer
|
|
60
|
+
* or API-test data.
|
|
61
|
+
*
|
|
62
|
+
* svl-sports-system-local → svl-sports-system-test
|
|
63
|
+
*
|
|
64
|
+
* Uses the `-test` suffix so it passes test-helper guards that only permit
|
|
65
|
+
* local/test databases (name ending in `-local` | `-ci` | `-e2e` | `-test`).
|
|
66
|
+
*/
|
|
67
|
+
function deriveTestDbName(devDbName) {
|
|
68
|
+
const base = devDbName.replace(/-(local|dev)$/i, '');
|
|
69
|
+
return `${base}-test`;
|
|
70
|
+
}
|
|
52
71
|
/**
|
|
53
72
|
* Resolve layout starting from `cwd`. Walks up to find a workspace if
|
|
54
73
|
* cwd is inside `projects/api/` or `projects/app/`.
|
package/build/lib/dev-state.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.paths = void 0;
|
|
3
|
+
exports.paths = exports.TEST_SESSION_FILE = void 0;
|
|
4
4
|
exports.allocateInternalPort = allocateInternalPort;
|
|
5
5
|
exports.clearSession = clearSession;
|
|
6
6
|
exports.isPidAlive = isPidAlive;
|
|
@@ -28,6 +28,8 @@ const path_1 = require("path");
|
|
|
28
28
|
const REGISTRY_PATH = process.env.LT_DEV_REGISTRY_PATH || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'projects.json');
|
|
29
29
|
const SESSION_DIR = '.lt-dev';
|
|
30
30
|
const SESSION_FILE = 'state.json';
|
|
31
|
+
/** Session file for the ephemeral `lt dev test` stack (runs parallel to the dev session). */
|
|
32
|
+
exports.TEST_SESSION_FILE = 'state.test.json';
|
|
31
33
|
/**
|
|
32
34
|
* Allocate a free internal port for a Caddy upstream.
|
|
33
35
|
*
|
|
@@ -44,8 +46,8 @@ function allocateInternalPort(start, taken) {
|
|
|
44
46
|
throw new Error(`No free internal port in range [${start}, ${start + 1000})`);
|
|
45
47
|
}
|
|
46
48
|
/** Remove session state file (called by `lt dev down`). */
|
|
47
|
-
function clearSession(root) {
|
|
48
|
-
const file = (0, path_1.join)(root, SESSION_DIR,
|
|
49
|
+
function clearSession(root, sessionFile = SESSION_FILE) {
|
|
50
|
+
const file = (0, path_1.join)(root, SESSION_DIR, sessionFile);
|
|
49
51
|
if ((0, fs_1.existsSync)(file)) {
|
|
50
52
|
try {
|
|
51
53
|
(0, fs_1.rmSync)(file);
|
|
@@ -87,8 +89,8 @@ function loadRegistry() {
|
|
|
87
89
|
return { projects: {}, version: 1 };
|
|
88
90
|
}
|
|
89
91
|
/** Load session state for a project root. */
|
|
90
|
-
function loadSession(root) {
|
|
91
|
-
const file = (0, path_1.join)(root, SESSION_DIR,
|
|
92
|
+
function loadSession(root, sessionFile = SESSION_FILE) {
|
|
93
|
+
const file = (0, path_1.join)(root, SESSION_DIR, sessionFile);
|
|
92
94
|
if (!(0, fs_1.existsSync)(file))
|
|
93
95
|
return null;
|
|
94
96
|
try {
|
|
@@ -126,10 +128,10 @@ function saveRegistry(reg) {
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
/** Persist session state for a project root. */
|
|
129
|
-
function saveSession(root, state) {
|
|
131
|
+
function saveSession(root, state, sessionFile = SESSION_FILE) {
|
|
130
132
|
const dir = (0, path_1.join)(root, SESSION_DIR);
|
|
131
133
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
132
|
-
(0, fs_1.writeFileSync)((0, path_1.join)(dir,
|
|
134
|
+
(0, fs_1.writeFileSync)((0, path_1.join)(dir, sessionFile), JSON.stringify(state, null, 2), 'utf8');
|
|
133
135
|
}
|
|
134
136
|
/** Collect all internal ports already claimed across the registry. */
|
|
135
137
|
function takenInternalPorts(reg, excludeSlug) {
|
|
@@ -0,0 +1,410 @@
|
|
|
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
|
+
exports.autoShardCount = autoShardCount;
|
|
13
|
+
exports.bringUpTestSession = bringUpTestSession;
|
|
14
|
+
exports.hasTestSession = hasTestSession;
|
|
15
|
+
exports.resolveTestSession = resolveTestSession;
|
|
16
|
+
exports.runShardedTestSession = runShardedTestSession;
|
|
17
|
+
exports.tearDownAllTestSessions = tearDownAllTestSessions;
|
|
18
|
+
exports.tearDownTestSession = tearDownTestSession;
|
|
19
|
+
/**
|
|
20
|
+
* Ephemeral, isolated test session for `lt dev test`.
|
|
21
|
+
*
|
|
22
|
+
* Brings up a SECOND, fully separate stack (own URLs, own internal ports,
|
|
23
|
+
* own Caddy block, own database) that runs PARALLEL to — and never touches —
|
|
24
|
+
* the developer's `lt dev up` session. Used to run the Playwright E2E suite
|
|
25
|
+
* against a clean, dedicated database so a developer can keep working in their
|
|
26
|
+
* own environment while tests run, and so a test run never pollutes dev data.
|
|
27
|
+
*
|
|
28
|
+
* Topology (for slug `svl`):
|
|
29
|
+
* - dev session : svl.localhost / api.svl.localhost → db `<…>-local`
|
|
30
|
+
* - test session : svl-test.localhost / api.svl-test.… → db `<…>-test`
|
|
31
|
+
*
|
|
32
|
+
* Both halves run BUILT for speed + prod-fidelity: the API COMPILED (`node dist`,
|
|
33
|
+
* ts-node intermittently dies mid-run) and the App as the production Nitro output
|
|
34
|
+
* (`nuxt build` → `node .output/server/index.mjs`, no Vite cold-compile). Each
|
|
35
|
+
* falls back to its dev runner (`pnpm start` / `pnpm dev`) when no build output is
|
|
36
|
+
* found. bringUp waits for a real 2xx on the API `/meta` before returning so the
|
|
37
|
+
* suite never starts against a not-yet-serving API.
|
|
38
|
+
*
|
|
39
|
+
* Lifecycle: `bringUpTestSession` → run Playwright → `tearDownTestSession`.
|
|
40
|
+
* Teardown is idempotent and residue-free (processes, Caddy block, env bridge,
|
|
41
|
+
* session file, registry entry), so a stale session is always safely reclaimed.
|
|
42
|
+
*/
|
|
43
|
+
const fs_1 = require("fs");
|
|
44
|
+
const os_1 = require("os");
|
|
45
|
+
const path_1 = require("path");
|
|
46
|
+
const caddy_1 = require("./caddy");
|
|
47
|
+
const dev_env_1 = require("./dev-env");
|
|
48
|
+
const dev_env_bridge_1 = require("./dev-env-bridge");
|
|
49
|
+
const dev_identity_1 = require("./dev-identity");
|
|
50
|
+
const dev_process_1 = require("./dev-process");
|
|
51
|
+
const dev_project_1 = require("./dev-project");
|
|
52
|
+
const dev_state_1 = require("./dev-state");
|
|
53
|
+
const TEST_API_LOG = 'api.test.log';
|
|
54
|
+
const TEST_APP_LOG = 'app.test.log';
|
|
55
|
+
const TEST_BRIDGE_FILE = '.env.test';
|
|
56
|
+
/** Internal port band for the test stack — distinct from the dev band (4000+). */
|
|
57
|
+
const TEST_PORT_BASE = 4500;
|
|
58
|
+
/**
|
|
59
|
+
* Heuristic for the default local shard count (`--shard auto` / bare `--shard`).
|
|
60
|
+
*
|
|
61
|
+
* Unlike CI — where each shard gets its OWN container (CPU + RAM), so N is just
|
|
62
|
+
* the runner-matrix width — local shards all share ONE machine: every shard runs
|
|
63
|
+
* a built Nuxt/Nitro server + headless Chromium + a compiled API, which together
|
|
64
|
+
* peak at ~2 PERFORMANCE cores during SSR render. The catch is headroom: once the
|
|
65
|
+
* shards' peak demand reaches the perf-core count there is nothing left for the
|
|
66
|
+
* OS / mongod / orchestrator, SSR slows 2-3x, and timing-sensitive navigations
|
|
67
|
+
* FAIL no matter how generous their timeout (true over-subscription).
|
|
68
|
+
*
|
|
69
|
+
* Measured on an M2 Max (8 perf + 4 eff cores, 12 logical) on a heavy built-SSR
|
|
70
|
+
* suite: N=2 → 7.4 min, 0 failures (stable); N=3 → 8.7 min, flaky; N=4 → 6-10 min
|
|
71
|
+
* (high variance), flaky. So the stable optimum is ~perfCores/4 — half the perf
|
|
72
|
+
* cores busy, half free as headroom. On Apple silicon ~2/3 of logical cores are
|
|
73
|
+
* perf cores, so `logical/6 ≈ perfCores/4`. This default deliberately FAVOURS a
|
|
74
|
+
* green, repeatable run over the fastest-on-paper N. Cap by RAM (~4 GB/shard),
|
|
75
|
+
* clamp to [2, 8].
|
|
76
|
+
*
|
|
77
|
+
* A LIGHTER suite (no built SSR, fast tests) or a bigger box can take more —
|
|
78
|
+
* override with an explicit `--shard N`. Always measure N vs N±1 (wall-clock AND
|
|
79
|
+
* flakes) to tune. Higher N also needs generous navigation timeouts under load
|
|
80
|
+
* (see the project's shard-aware `LT_DEV_TEST_SHARDS` timeout handling).
|
|
81
|
+
*/
|
|
82
|
+
function autoShardCount() {
|
|
83
|
+
const logical = (0, os_1.cpus)().length || 4;
|
|
84
|
+
const byCpu = Math.floor(logical / 6);
|
|
85
|
+
const byRam = Math.floor((0, os_1.totalmem)() / Math.pow(1024, 3) / 4);
|
|
86
|
+
return Math.max(2, Math.min(byCpu, byRam, 8));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Bring up the isolated test stack. Tears down any stale test session first,
|
|
90
|
+
* so this is safe to call even if a previous run crashed.
|
|
91
|
+
*/
|
|
92
|
+
function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
93
|
+
return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
|
|
94
|
+
const { shardIndex, skipBuild } = opts;
|
|
95
|
+
const names = testStackNames(shardIndex);
|
|
96
|
+
const { dbName, testIdentity } = resolveTestSession(layout, baseIdentity, shardIndex);
|
|
97
|
+
// Always start from a clean slate — reclaim a stale/crashed test session.
|
|
98
|
+
yield tearDownTestSession(layout, baseIdentity, log, { shardIndex, silent: true });
|
|
99
|
+
// Allocate internal ports (avoid every other registry entry incl. the dev
|
|
100
|
+
// session AND already-running sibling shards, plus anything currently
|
|
101
|
+
// listening). Sibling shards are registered before the next one allocates,
|
|
102
|
+
// so each shard lands on its own port pair.
|
|
103
|
+
const reg = (0, dev_state_1.loadRegistry)();
|
|
104
|
+
const taken = (0, dev_state_1.takenInternalPorts)(reg, testIdentity.slug);
|
|
105
|
+
const apiPort = layout.apiDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
|
|
106
|
+
if (apiPort)
|
|
107
|
+
taken.add(apiPort);
|
|
108
|
+
const appPort = layout.appDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
|
|
109
|
+
const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
|
|
110
|
+
const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
|
|
111
|
+
for (const p of portsToCheck) {
|
|
112
|
+
const r = snap.get(p);
|
|
113
|
+
if (r)
|
|
114
|
+
throw new Error(`test internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
|
|
115
|
+
}
|
|
116
|
+
// Caddy block for the test URLs.
|
|
117
|
+
const routes = [];
|
|
118
|
+
if (testIdentity.subdomains.api && apiPort)
|
|
119
|
+
routes.push({ hostname: testIdentity.subdomains.api.hostname, upstreamPort: apiPort });
|
|
120
|
+
if (testIdentity.subdomains.app && appPort)
|
|
121
|
+
routes.push({ hostname: testIdentity.subdomains.app.hostname, upstreamPort: appPort });
|
|
122
|
+
if (routes.length === 0)
|
|
123
|
+
throw new Error('test session has no subdomains to expose (need an app project).');
|
|
124
|
+
(0, caddy_1.upsertProjectBlock)(testIdentity.slug, routes);
|
|
125
|
+
const reload = yield (0, caddy_1.reloadCaddy)();
|
|
126
|
+
if (!reload.ok)
|
|
127
|
+
throw new Error(`caddy reload failed:\n${reload.stderr}`);
|
|
128
|
+
const apiUrl = testIdentity.subdomains.api ? `https://${testIdentity.subdomains.api.hostname}` : '';
|
|
129
|
+
const appUrl = testIdentity.subdomains.app ? `https://${testIdentity.subdomains.app.hostname}` : '';
|
|
130
|
+
log.info('');
|
|
131
|
+
log.info(`Starting isolated test stack "${testIdentity.slug}"`);
|
|
132
|
+
if (appUrl)
|
|
133
|
+
log.info(` app: ${appUrl} → 127.0.0.1:${appPort}`);
|
|
134
|
+
if (apiUrl)
|
|
135
|
+
log.info(` api: ${apiUrl} → 127.0.0.1:${apiPort}`);
|
|
136
|
+
log.info(` db: mongodb://127.0.0.1/${dbName} (reset before the suite by Playwright global-setup)`);
|
|
137
|
+
log.info('');
|
|
138
|
+
const devEnv = (0, dev_env_1.buildDevEnv)({
|
|
139
|
+
apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
|
|
140
|
+
appInternalPort: appPort !== null && appPort !== void 0 ? appPort : 0,
|
|
141
|
+
baseEnv: process.env,
|
|
142
|
+
dbName,
|
|
143
|
+
identity: testIdentity,
|
|
144
|
+
});
|
|
145
|
+
const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
|
|
146
|
+
const pids = {};
|
|
147
|
+
// --- API: compiled (`node dist`) for stability; fall back to `pnpm start`.
|
|
148
|
+
// `skipBuild` (sibling shards) reuses the dist the first shard produced. ---
|
|
149
|
+
if (layout.apiDir && apiPort) {
|
|
150
|
+
let build = 0;
|
|
151
|
+
if (!skipBuild) {
|
|
152
|
+
log.info(log.dim('Building API (compiled, for stable long runs) …'));
|
|
153
|
+
build = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'build'], { cwd: layout.apiDir, env: process.env });
|
|
154
|
+
}
|
|
155
|
+
const entry = ['dist/src/main.js', 'dist/main.js']
|
|
156
|
+
.map((rel) => (0, path_1.join)(layout.apiDir, rel))
|
|
157
|
+
.find((p) => (0, fs_1.existsSync)(p));
|
|
158
|
+
const apiEnv = Object.assign(Object.assign({}, devEnv.api.env), { NODE_ENV: 'local' });
|
|
159
|
+
let apiSpawn;
|
|
160
|
+
if (build === 0 && entry) {
|
|
161
|
+
apiSpawn = (0, dev_process_1.spawnDetached)('node', [entry], {
|
|
162
|
+
cwd: layout.apiDir,
|
|
163
|
+
env: apiEnv,
|
|
164
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
log.warn('compiled API not available — falling back to `pnpm start` (ts-node).');
|
|
169
|
+
apiSpawn = (0, dev_process_1.spawnDetached)(pnpmBin, ['start'], {
|
|
170
|
+
cwd: layout.apiDir,
|
|
171
|
+
env: apiEnv,
|
|
172
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (apiSpawn)
|
|
176
|
+
pids.api = apiSpawn.pid;
|
|
177
|
+
}
|
|
178
|
+
// --- App: BUILT (`nuxt build` → `node .output/server/index.mjs`) for speed +
|
|
179
|
+
// prod-fidelity; fall back to the Nuxt dev server when no build output exists.
|
|
180
|
+
// The built server has no Vite cold-compile (which dominates a dev-mode suite
|
|
181
|
+
// — ~84% of runtime) and runs the SAME production bundle a deployment ships.
|
|
182
|
+
// buildDevEnv sets NUXT_PUBLIC_API_PROXY=false, so the built app talks
|
|
183
|
+
// cross-origin to the test API exactly like prod (the injected session cookie
|
|
184
|
+
// must be a cross-subdomain DOMAIN cookie — see the project's parseCookieHeader).
|
|
185
|
+
// Rebuilt every run so the suite never hits stale code (no build-skip / reuse). ---
|
|
186
|
+
if (layout.appDir && appPort) {
|
|
187
|
+
let appBuild = 0;
|
|
188
|
+
if (!skipBuild) {
|
|
189
|
+
log.info(log.dim('Building App (nuxt build, for speed + prod-fidelity) …'));
|
|
190
|
+
appBuild = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'build'], { cwd: layout.appDir, env: devEnv.app.env });
|
|
191
|
+
}
|
|
192
|
+
const appEntry = ['.output/server/index.mjs']
|
|
193
|
+
.map((rel) => (0, path_1.join)(layout.appDir, rel))
|
|
194
|
+
.find((p) => (0, fs_1.existsSync)(p));
|
|
195
|
+
let appSpawn;
|
|
196
|
+
if (appBuild === 0 && appEntry) {
|
|
197
|
+
appSpawn = (0, dev_process_1.spawnDetached)('node', [appEntry], {
|
|
198
|
+
cwd: layout.appDir,
|
|
199
|
+
env: devEnv.app.env,
|
|
200
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
log.warn('built app not available — falling back to `pnpm dev` (slower: cold-compiles routes).');
|
|
205
|
+
appSpawn = (0, dev_process_1.spawnDetached)(pnpmBin, ['dev'], {
|
|
206
|
+
cwd: layout.appDir,
|
|
207
|
+
env: devEnv.app.env,
|
|
208
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (appSpawn)
|
|
212
|
+
pids.app = appSpawn.pid;
|
|
213
|
+
}
|
|
214
|
+
// Persist test session + registry entry (so ports are reserved + status sees it).
|
|
215
|
+
(0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() }, names.sessionFile);
|
|
216
|
+
const subdomainMap = {};
|
|
217
|
+
for (const [k, v] of Object.entries(testIdentity.subdomains))
|
|
218
|
+
subdomainMap[k] = v.hostname;
|
|
219
|
+
reg.projects[testIdentity.slug] = {
|
|
220
|
+
dbName,
|
|
221
|
+
internalPorts: { api: apiPort, app: appPort },
|
|
222
|
+
lastUsedAt: new Date().toISOString(),
|
|
223
|
+
path: layout.root,
|
|
224
|
+
subdomains: subdomainMap,
|
|
225
|
+
};
|
|
226
|
+
(0, dev_state_1.saveRegistry)(reg);
|
|
227
|
+
// ENV bridge for external tooling (kept separate from the dev `.env`).
|
|
228
|
+
(0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName, names.bridgeFile);
|
|
229
|
+
// Wait for the test App to answer (best-effort).
|
|
230
|
+
if (appUrl) {
|
|
231
|
+
log.info(log.dim(`Waiting for ${appUrl} …`));
|
|
232
|
+
yield (0, dev_process_1.waitForHttp)(appUrl, 90000);
|
|
233
|
+
}
|
|
234
|
+
// Wait for the test API to actually SERVE (real 2xx on /meta) before handing
|
|
235
|
+
// off to Playwright. Previously bringUp only waited for the App, so a compiled
|
|
236
|
+
// API still connecting to Mongo made the first specs skip via the suite's
|
|
237
|
+
// `ensureApiReachableOrSkip` guard (the API-readiness race). A strict 2xx is
|
|
238
|
+
// required: Caddy answers 502 while its upstream is still booting, which the
|
|
239
|
+
// default (lenient) predicate would accept as "up".
|
|
240
|
+
if (apiUrl) {
|
|
241
|
+
log.info(log.dim(`Waiting for ${apiUrl}/meta …`));
|
|
242
|
+
const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, (status) => status >= 200 && status < 300);
|
|
243
|
+
if (!apiReady)
|
|
244
|
+
log.warn(`Test API did not answer 2xx on ${apiUrl}/meta within 120s — the first specs may skip.`);
|
|
245
|
+
}
|
|
246
|
+
return { apiUrl, appEnv: devEnv.app.env, appUrl, dbName, pids, testIdentity };
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** True when a test session file exists (used by status/down). */
|
|
250
|
+
function hasTestSession(root) {
|
|
251
|
+
return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
|
|
252
|
+
}
|
|
253
|
+
/** Build the dedicated test identity + test DB name for a project. */
|
|
254
|
+
function resolveTestSession(layout, baseIdentity, shardIndex) {
|
|
255
|
+
const names = testStackNames(shardIndex);
|
|
256
|
+
const testIdentity = (0, dev_identity_1.buildTestIdentity)(baseIdentity, names.identitySuffix);
|
|
257
|
+
const dbName = (0, dev_project_1.deriveTestDbName)((0, dev_project_1.deriveDbName)(layout.apiDir, baseIdentity.slug)) + names.dbSuffix;
|
|
258
|
+
return { dbName, testIdentity };
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Run the suite SHARDED across `total` fully-isolated stacks in parallel — the
|
|
262
|
+
* local equivalent of the CI `parallel: N` + `--shard=i/N` matrix, but on one
|
|
263
|
+
* machine: each shard gets its own URLs/ports/Caddy block AND its own DB
|
|
264
|
+
* (`<…>-test-<i>`), so there is zero cross-shard data contention (the reason
|
|
265
|
+
* in-process `workers > 1` against a single stack produces false results —
|
|
266
|
+
* `cleanupTestEntities` / "pick any active season" collide).
|
|
267
|
+
*
|
|
268
|
+
* The first shard builds the API + App; siblings reuse that build (`skipBuild`),
|
|
269
|
+
* since the bundles are shard-agnostic (URLs come from runtime env). Stacks are
|
|
270
|
+
* brought up sequentially (builds + Caddy reloads serialise cleanly), then the
|
|
271
|
+
* N Playwright `--shard=i/N` processes run CONCURRENTLY, each against its own
|
|
272
|
+
* stack, output captured to `.lt-dev/shard.<i>.test.log`. Returns 0 iff every
|
|
273
|
+
* shard passed. Teardown is the caller's responsibility (so `--keep` works).
|
|
274
|
+
*/
|
|
275
|
+
function runShardedTestSession(layout, baseIdentity, log, opts) {
|
|
276
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
277
|
+
const total = Math.max(2, Math.floor(opts.total));
|
|
278
|
+
const contexts = [];
|
|
279
|
+
// Bring up the N isolated stacks sequentially (shard 1 builds; 2..N reuse).
|
|
280
|
+
for (let index = 1; index <= total; index++) {
|
|
281
|
+
log.info('');
|
|
282
|
+
log.info(`▶ shard ${index}/${total}: bringing up isolated stack …`);
|
|
283
|
+
const ctx = yield bringUpTestSession(layout, baseIdentity, log, { shardIndex: index, skipBuild: index > 1 });
|
|
284
|
+
contexts.push({ ctx, index });
|
|
285
|
+
}
|
|
286
|
+
// Run the N Playwright shards CONCURRENTLY, each against its own stack/DB.
|
|
287
|
+
log.info('');
|
|
288
|
+
log.info(`Running ${total} Playwright shards in parallel (one isolated stack each) …`);
|
|
289
|
+
const appDir = layout.appDir;
|
|
290
|
+
const results = yield Promise.all(contexts.map((_a) => __awaiter(this, [_a], void 0, function* ({ ctx, index }) {
|
|
291
|
+
// `LT_DEV_TEST_SHARDS` signals to the project's playwright.config that the
|
|
292
|
+
// suite runs under concurrent sharded load, so it can relax navigation /
|
|
293
|
+
// test timeouts (N built SSR servers + N Chromium saturate the CPU and slow
|
|
294
|
+
// every navigation) without loosening them for serial runs.
|
|
295
|
+
const env = Object.assign(Object.assign({}, ctx.appEnv), { LT_DEV_TEST_SHARDS: String(total), MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` });
|
|
296
|
+
const logFile = (0, path_1.join)(layout.root, '.lt-dev', `shard.${index}.test.log`);
|
|
297
|
+
// Invoke Playwright DIRECTLY via `pnpm exec` (NOT `pnpm run test:e2e -- …`):
|
|
298
|
+
// forwarding option flags through `pnpm run`'s `--` is unreliable — pnpm
|
|
299
|
+
// passed the separator on to Playwright, which then read `--shard`/
|
|
300
|
+
// `--reporter` as file FILTERS (not options) → every shard ran the whole
|
|
301
|
+
// suite. `pnpm exec` hands args straight to the binary (mirrors CI).
|
|
302
|
+
const args = ['exec', 'playwright', 'test', `--shard=${index}/${total}`, '--reporter=line', ...opts.forwarded];
|
|
303
|
+
const code = yield (0, dev_process_1.runChildToFile)(opts.pnpmBin, args, { cwd: appDir, env, logFile });
|
|
304
|
+
return { code, index, logFile };
|
|
305
|
+
})));
|
|
306
|
+
// Aggregate per-shard exit codes into a single result.
|
|
307
|
+
let failed = 0;
|
|
308
|
+
log.info('');
|
|
309
|
+
for (const r of results.sort((a, b) => a.index - b.index)) {
|
|
310
|
+
const ok = r.code === 0;
|
|
311
|
+
if (!ok)
|
|
312
|
+
failed++;
|
|
313
|
+
log.info(` shard ${r.index}/${total}: ${ok ? 'passed' : `FAILED (exit ${r.code})`} (log: ${r.logFile})`);
|
|
314
|
+
}
|
|
315
|
+
return failed === 0 ? 0 : 1;
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Tear down the unsharded test stack AND every sharded stack discovered on disk
|
|
320
|
+
* (`state.test.<i>.json` in `.lt-dev/`). Used by `lt dev test down` so a
|
|
321
|
+
* `--keep`-ed sharded run is fully reclaimed by one command.
|
|
322
|
+
*/
|
|
323
|
+
function tearDownAllTestSessions(layout_1, baseIdentity_1, log_1) {
|
|
324
|
+
return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
|
|
325
|
+
const stopped = [];
|
|
326
|
+
// Unsharded session first.
|
|
327
|
+
const base = yield tearDownTestSession(layout, baseIdentity, log, { silent: opts.silent });
|
|
328
|
+
stopped.push(...base.stopped);
|
|
329
|
+
// Then any sharded sessions still on disk.
|
|
330
|
+
let entries = [];
|
|
331
|
+
try {
|
|
332
|
+
entries = (0, fs_1.readdirSync)((0, path_1.join)(layout.root, '.lt-dev'));
|
|
333
|
+
}
|
|
334
|
+
catch (_a) {
|
|
335
|
+
/* no .lt-dev dir → nothing sharded to reclaim */
|
|
336
|
+
}
|
|
337
|
+
const shardIndices = entries
|
|
338
|
+
.map((f) => f.match(/^state\.test\.(\d+)\.json$/))
|
|
339
|
+
.filter((m) => m !== null)
|
|
340
|
+
.map((m) => Number(m[1]))
|
|
341
|
+
.sort((a, b) => a - b);
|
|
342
|
+
for (const shardIndex of shardIndices) {
|
|
343
|
+
const r = yield tearDownTestSession(layout, baseIdentity, log, { shardIndex, silent: opts.silent });
|
|
344
|
+
stopped.push(...r.stopped);
|
|
345
|
+
}
|
|
346
|
+
return { stopped };
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Tear down the test stack: stop processes, remove the Caddy block, clear the
|
|
351
|
+
* env bridge + session file + registry entry. Idempotent + residue-free.
|
|
352
|
+
*/
|
|
353
|
+
function tearDownTestSession(layout_1, baseIdentity_1, log_1) {
|
|
354
|
+
return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
|
|
355
|
+
const names = testStackNames(opts.shardIndex);
|
|
356
|
+
const testIdentity = (0, dev_identity_1.buildTestIdentity)(baseIdentity, names.identitySuffix);
|
|
357
|
+
const stopped = [];
|
|
358
|
+
const session = (0, dev_state_1.loadSession)(layout.root, names.sessionFile);
|
|
359
|
+
if (session) {
|
|
360
|
+
for (const [name, pid] of Object.entries(session.pids)) {
|
|
361
|
+
if (!pid)
|
|
362
|
+
continue;
|
|
363
|
+
if (!(0, dev_state_1.isPidAlive)(pid)) {
|
|
364
|
+
stopped.push(`${name} (pid ${pid}, already dead)`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
// SIGTERM → wait → SIGKILL. A compiled `node dist` API catches SIGTERM
|
|
368
|
+
// for graceful shutdown and can hang on open Mongo connections, so a
|
|
369
|
+
// single SIGTERM would leave it listening + holding the test DB. Escalate
|
|
370
|
+
// to guarantee the residue-free teardown promise.
|
|
371
|
+
const gone = yield (0, dev_process_1.terminateProcessGroup)(pid);
|
|
372
|
+
stopped.push(gone ? `${name} (pid ${pid})` : `${name} (pid ${pid}, SURVIVED SIGKILL!)`);
|
|
373
|
+
}
|
|
374
|
+
(0, dev_state_1.clearSession)(layout.root, names.sessionFile);
|
|
375
|
+
}
|
|
376
|
+
const removed = (0, caddy_1.removeProjectBlock)(testIdentity.slug);
|
|
377
|
+
if (removed) {
|
|
378
|
+
const r = yield (0, caddy_1.reloadCaddy)();
|
|
379
|
+
if (!r.ok && !opts.silent)
|
|
380
|
+
log.warn(`Removed test Caddy block but reload failed: ${r.stderr.split('\n')[0]}`);
|
|
381
|
+
}
|
|
382
|
+
(0, dev_env_bridge_1.clearEnvBridge)(layout.root, names.bridgeFile);
|
|
383
|
+
// Drop the registry entry so the test slug + ports are reclaimed.
|
|
384
|
+
const reg = (0, dev_state_1.loadRegistry)();
|
|
385
|
+
if (reg.projects[testIdentity.slug]) {
|
|
386
|
+
delete reg.projects[testIdentity.slug];
|
|
387
|
+
(0, dev_state_1.saveRegistry)(reg);
|
|
388
|
+
}
|
|
389
|
+
if (!opts.silent && stopped.length > 0)
|
|
390
|
+
log.info(`Stopped test stack: ${stopped.join(', ')}`);
|
|
391
|
+
return { stopped };
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Resolve the per-stack file/identity names. For a sharded run (`shardIndex`
|
|
396
|
+
* given) everything gets a `.<i>` / `-<i>` suffix so N stacks coexist without
|
|
397
|
+
* clobbering each other's session file, env bridge, logs, Caddy block or DB.
|
|
398
|
+
* Unsharded (shardIndex undefined) keeps the original single-stack names.
|
|
399
|
+
*/
|
|
400
|
+
function testStackNames(shardIndex) {
|
|
401
|
+
const sharded = shardIndex !== undefined;
|
|
402
|
+
return {
|
|
403
|
+
apiLog: sharded ? `api.test.${shardIndex}.log` : TEST_API_LOG,
|
|
404
|
+
appLog: sharded ? `app.test.${shardIndex}.log` : TEST_APP_LOG,
|
|
405
|
+
bridgeFile: sharded ? `${TEST_BRIDGE_FILE}.${shardIndex}` : TEST_BRIDGE_FILE,
|
|
406
|
+
dbSuffix: sharded ? `-${shardIndex}` : '',
|
|
407
|
+
identitySuffix: sharded ? `-test-${shardIndex}` : '-test',
|
|
408
|
+
sessionFile: sharded ? `state.test.${shardIndex}.json` : dev_state_1.TEST_SESSION_FILE,
|
|
409
|
+
};
|
|
410
|
+
}
|