@lenne.tech/cli 1.41.1 → 1.41.3

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.
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.addToGitignore = addToGitignore;
4
4
  exports.autoPatch = autoPatch;
5
+ exports.canonicaliseBridgeSpan = canonicaliseBridgeSpan;
5
6
  exports.patchApiConfig = patchApiConfig;
6
7
  exports.patchClaudeMd = patchClaudeMd;
7
8
  exports.patchNuxtConfig = patchNuxtConfig;
@@ -13,10 +14,33 @@ exports.patchPlaywrightConfig = patchPlaywrightConfig;
13
14
  * defaults and make it env-aware so it can be served behind Caddy
14
15
  * under `https://<slug>.localhost`.
15
16
  *
16
- * Each patch is a regex-based replace that matches only the legacy
17
- * form. Already-patched files are no-ops.
17
+ * Most patches are regex-based replaces that match only the legacy
18
+ * form; the marker-bracketed `lt-dev:bridge` block in
19
+ * playwright.config.ts is located by its markers instead. Already-patched
20
+ * files are no-ops — including when the consumer's own formatter has since
21
+ * restyled an injected block: that block is compared SEMANTICALLY (see
22
+ * `BRIDGE_VERSION` / `normaliseBridgeBlock`), not byte-for-byte.
18
23
  */
19
24
  const fs_1 = require("fs");
25
+ /**
26
+ * Version of the `lt-dev:bridge` block emitted into a consumer's
27
+ * `playwright.config.ts`. It travels IN the markers
28
+ * (`// >>> lt-dev:bridge v2 >>>`), which is what makes a genuine upgrade
29
+ * detectable independently of formatting.
30
+ *
31
+ * **Bump this on every change to `bridgeBlock`** — including a
32
+ * formatting-only one. The content comparison deliberately ignores quote
33
+ * style and whitespace (the consumer's formatter owns that file), so
34
+ * without a bump a cosmetic fix would never reach already-patched
35
+ * projects. That is exactly what happened to 1.32.1's `"utf8"` → `'utf8'`
36
+ * fix, which is why the version exists.
37
+ */
38
+ const BRIDGE_VERSION = 2;
39
+ /** Matches any bridge marker, versioned or not — v1 shipped without one. */
40
+ const BRIDGE_START_RE = /\/\/ >>> lt-dev:bridge(?: v(\d+))? >>>/;
41
+ const BRIDGE_END_RE = /\/\/ <<< lt-dev:bridge(?: v(\d+))? <<</;
42
+ /** Imports the bridge block owns; a stray copy outside it must be dropped. */
43
+ const BRIDGE_IMPORT_RE = /^\s*import \{[^}]*__ltDev(?:Exists|Read|Dirname|Resolve)[^}]*\} from '[^']*';?\s*$/gm;
20
44
  /** Append entry to .gitignore if not already present. */
21
45
  function addToGitignore(root, entry) {
22
46
  const path = `${root}/.gitignore`;
@@ -40,6 +64,29 @@ function autoPatch(file) {
40
64
  return patchPlaywrightConfig(file);
41
65
  return { file, patched: false, replacements: 0 };
42
66
  }
67
+ /**
68
+ * Replace the `lt-dev:bridge` span inside a whole file with its canonical
69
+ * comparison form, leaving every other byte untouched.
70
+ *
71
+ * For callers that must decide "is this file exactly what `autoPatch` would
72
+ * produce, or did a developer edit it?" — most importantly
73
+ * `dev-ticket.ts#isPristineLtDevPatch`, which gates whether `lt ticket stop`
74
+ * may discard a dirty config. Since the patcher deliberately tolerates the
75
+ * consumer formatter restyling the block, such a comparison MUST tolerate it
76
+ * too; otherwise a formatter-touched config reads as real developer work and
77
+ * the worktree removal is refused.
78
+ *
79
+ * Everything outside the markers stays byte-exact on purpose — being lenient
80
+ * there could let genuine work be silently discarded.
81
+ */
82
+ function canonicaliseBridgeSpan(content) {
83
+ const start = BRIDGE_START_RE.exec(content);
84
+ const end = BRIDGE_END_RE.exec(content);
85
+ if (!start || !end || end.index <= start.index)
86
+ return content;
87
+ const endsAt = end.index + end[0].length;
88
+ return (content.slice(0, start.index) + normaliseBridgeBlock(content.slice(start.index, endsAt)) + content.slice(endsAt));
89
+ }
43
90
  /**
44
91
  * API: make the server listen port honour `process.env.PORT` (injected by
45
92
  * `lt dev up` for its Caddy upstream). Handles two patterns found in
@@ -164,8 +211,12 @@ function patchNuxtConfig(file) {
164
211
  * any env.
165
212
  *
166
213
  * Patches applied (each idempotent):
167
- * 1. Top-of-file: `if (existsSync('.lt-dev/.env')) loadEnv(...)` block,
168
- * bracketed by `// >>> lt-dev:bridge >>>` markers.
214
+ * 1. Top-of-file: a dotenv loader that searches UP from cwd for
215
+ * `.lt-dev/.env`, bracketed by `// >>> lt-dev:bridge vN >>>` markers.
216
+ * Re-injected when the marker's version differs from `BRIDGE_VERSION`
217
+ * (a genuine upgrade) or when the block's CODE differs semantically
218
+ * (tampering / corruption) — but NOT when the consumer's formatter
219
+ * merely restyled it, which owns this file and must stay free to.
169
220
  * 2. Hardcoded baseURL/host/url for `http://localhost:3001` →
170
221
  * `process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3001'`.
171
222
  * 3. `webServer` wrapped in an `LT_DEV_ACTIVE` guard so Playwright reuses
@@ -180,6 +231,7 @@ function patchNuxtConfig(file) {
180
231
  * 6. `slowMo: 10` → `0` (pointless per-action delay, multiplied across shards).
181
232
  */
182
233
  function patchPlaywrightConfig(file) {
234
+ var _a;
183
235
  if (!(0, fs_1.existsSync)(file))
184
236
  return { file, patched: false, replacements: 0 };
185
237
  const before = (0, fs_1.readFileSync)(file, 'utf8');
@@ -198,8 +250,8 @@ function patchPlaywrightConfig(file) {
198
250
  // a direct `playwright test` run) usually sit in `projects/app`. The
199
251
  // original cwd-only resolve missed it, so direct runs fell back to
200
252
  // `localhost:3001` and could collide with a parallel project.
201
- const bridgeStart = '// >>> lt-dev:bridge >>>';
202
- const bridgeEnd = '// <<< lt-dev:bridge <<<';
253
+ const bridgeStart = `// >>> lt-dev:bridge v${BRIDGE_VERSION} >>>`;
254
+ const bridgeEnd = `// <<< lt-dev:bridge v${BRIDGE_VERSION} <<<`;
203
255
  const bridgeBlock = [
204
256
  bridgeStart,
205
257
  '// Auto-load <root>/.lt-dev/.env when `lt dev up` is active so',
@@ -228,16 +280,47 @@ function patchPlaywrightConfig(file) {
228
280
  '}',
229
281
  bridgeEnd,
230
282
  ].join('\n');
231
- const bridgeStartIdx = after.indexOf(bridgeStart);
232
- const bridgeEndIdx = after.indexOf(bridgeEnd);
233
- if (bridgeStartIdx === -1) {
283
+ // Locate ANY bridge marker, versioned or not — v1 shipped unversioned and
284
+ // must still be recognised (and upgraded) rather than double-injected.
285
+ const startMatch = BRIDGE_START_RE.exec(after);
286
+ const endMatch = BRIDGE_END_RE.exec(after);
287
+ const bridgeStartIdx = startMatch ? startMatch.index : -1;
288
+ const bridgeEndIdx = endMatch ? endMatch.index : -1;
289
+ // A start marker without a well-ordered end marker is a corrupted block: the
290
+ // slice arithmetic below would duplicate everything between the two (and the
291
+ // user's code with it). `patchClaudeMd` already guards its span this way.
292
+ const spanIsSane = bridgeStartIdx !== -1 && bridgeEndIdx > bridgeStartIdx;
293
+ if (bridgeStartIdx === -1 || !spanIsSane) {
294
+ // Markers present but unusable (e.g. reversed). Strip the STRAY MARKERS
295
+ // ONLY — never the text between them: with the markers out of order that
296
+ // text is the user's own code, not our block, so slicing the span out
297
+ // would delete their work (and slicing it in would duplicate it).
298
+ if (!spanIsSane)
299
+ after = after.replace(BRIDGE_START_RE, '').replace(BRIDGE_END_RE, '');
300
+ // A formatter with organize-imports may have hoisted the block's imports
301
+ // out of the markers; leaving them behind would duplicate the `__ltDev*`
302
+ // bindings and break the consumer's config with a duplicate-identifier
303
+ // error. They are ours to own, so it is safe to drop them.
304
+ after = after.replace(BRIDGE_IMPORT_RE, '').replace(/^\n+/, '');
234
305
  after = `${bridgeBlock}\n${after}`;
235
306
  count++;
236
307
  }
237
- else if (bridgeEndIdx !== -1) {
238
- const rebuilt = after.slice(0, bridgeStartIdx) + bridgeBlock + after.slice(bridgeEndIdx + bridgeEnd.length);
239
- if (rebuilt !== after) {
240
- after = rebuilt;
308
+ else {
309
+ const existing = after.slice(bridgeStartIdx, bridgeEndIdx + endMatch[0].length);
310
+ // Two independent reasons to re-inject:
311
+ // (a) VERSION — the marker carries the block's version, so a genuine
312
+ // upgrade is detected regardless of how the consumer formatted it.
313
+ // This is what keeps formatting-only fixes shippable (see
314
+ // BRIDGE_VERSION); an unversioned v1 marker yields `undefined` here.
315
+ // (b) CODE — the block's code differs semantically, i.e. it was tampered
316
+ // with or corrupted. Cosmetic reformatting by the consumer's own
317
+ // formatter is deliberately NOT a reason: rewriting it back on every
318
+ // run is what used to leave playwright.config.ts permanently dirty in
319
+ // the working tree, with formatter and patcher flipping it forever.
320
+ const existingVersion = Number((_a = startMatch[1]) !== null && _a !== void 0 ? _a : 0);
321
+ if (existingVersion !== BRIDGE_VERSION || normaliseBridgeBlock(existing) !== normaliseBridgeBlock(bridgeBlock)) {
322
+ const head = after.slice(0, bridgeStartIdx).replace(BRIDGE_IMPORT_RE, '');
323
+ after = head + bridgeBlock + after.slice(bridgeEndIdx + endMatch[0].length);
241
324
  count++;
242
325
  }
243
326
  }
@@ -307,3 +390,30 @@ function patchPlaywrightConfig(file) {
307
390
  (0, fs_1.writeFileSync)(file, after, 'utf8');
308
391
  return { file, patched: true, replacements: count };
309
392
  }
393
+ /**
394
+ * Canonical form of a bridge block for comparison purposes.
395
+ *
396
+ * Compares only the CODE: comment lines are dropped entirely, so rewording
397
+ * a comment never triggers a rewrite — and, crucially, code that a reflow
398
+ * folded behind a `//` VANISHES from the comparison and is therefore
399
+ * detected as changed. A plain `\s+ → ' '` collapse would erase newlines
400
+ * instead, which makes a fully commented-out (inert) loader normalise
401
+ * identically to a live one: the block would silently never load
402
+ * `.lt-dev/.env` again and Playwright would fall back to `localhost:3001`.
403
+ *
404
+ * Quote style and intra-line whitespace are normalised away because the
405
+ * consumer's formatter owns this file (which direction it flips is
406
+ * project-specific — do not assume). Blind spot to keep in mind: a change
407
+ * that differs ONLY in quotes or whitespace is invisible here, which is
408
+ * what `BRIDGE_VERSION` is for.
409
+ */
410
+ function normaliseBridgeBlock(s) {
411
+ return s
412
+ .split(/\r?\n/)
413
+ .map((l) => l.trim())
414
+ .filter((l) => l !== '' && !l.startsWith('//'))
415
+ .join(' ')
416
+ .replace(/['"]/g, '"')
417
+ .replace(/\s+/g, ' ')
418
+ .trim();
419
+ }
@@ -13,9 +13,11 @@ exports.TEST_INITIAL_ADMIN_ENV = void 0;
13
13
  exports.ensurePlaywrightBrowsers = ensurePlaywrightBrowsers;
14
14
  exports.autoShardCount = autoShardCount;
15
15
  exports.bringUpTestSession = bringUpTestSession;
16
+ exports.buildShardPlaywrightInvocation = buildShardPlaywrightInvocation;
16
17
  exports.hasTestSession = hasTestSession;
17
18
  exports.resolveTestSession = resolveTestSession;
18
19
  exports.runShardedTestSession = runShardedTestSession;
20
+ exports.shardReportDir = shardReportDir;
19
21
  exports.tearDownAllTestSessions = tearDownAllTestSessions;
20
22
  exports.tearDownTestSession = tearDownTestSession;
21
23
  /**
@@ -341,6 +343,45 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
341
343
  return { apiLogPath, apiUrl, appEnv: devEnv.app.env, appUrl, dbName, pids, testIdentity };
342
344
  });
343
345
  }
346
+ /**
347
+ * Build the Playwright CLI argv + per-shard env overrides for ONE shard of a
348
+ * `lt dev test --shard` run.
349
+ *
350
+ * DEV-2676 — reporter handling: this path must NOT pass `--reporter`. A CLI
351
+ * `--reporter` REPLACES the project's whole `reporter` list from
352
+ * playwright.config.ts (Playwright resolves CLI-over-config, it never appends —
353
+ * `Runner._parseConfig` in playwright's `lib/runner/index.js` does
354
+ * `result.reporter = [...configOverrides.reporter]`), silently dropping any
355
+ * release gate a project wires in AS a reporter. SVL's DEV-2098 gate (`./tests/no-skips.reporter.ts`)
356
+ * turns a run RED when a spec was skipped; the old `--reporter=line` here
357
+ * clobbered it, so a skipped test passed with exit 0 under `--shard` — the exact
358
+ * hole this closes. Omitting `--reporter` lets the configured reporters run (the
359
+ * gate included); Playwright auto-prepends a compact `line` (local) / `dot` (CI)
360
+ * reporter when none of them claim stdio, so the captured per-shard log stays
361
+ * readable without us overriding anything.
362
+ *
363
+ * The HTML reporter is the only common config reporter that is shard-hostile:
364
+ * every shard shares one project dir and would write the same
365
+ * `playwright-report/`, racing each other's report files. We hand each shard its
366
+ * own HTML output dir and force `open: never` (defensive — the non-TTY,
367
+ * file-captured child never auto-opens a browser anyway). Both env vars are
368
+ * inert for a project without an HTML reporter, so the fix stays generic.
369
+ *
370
+ * Playwright is invoked via the manager's `exec` (NOT `<pm> run test:e2e -- …`):
371
+ * forwarding option flags through `<pm> run`'s `--` is unreliable — pnpm passed
372
+ * the separator on to Playwright, which then read `--shard` as a file FILTER, so
373
+ * every shard ran the whole suite. `exec` hands args straight to the binary
374
+ * (mirrors CI); the helper inserts `--` for npm so those flags survive.
375
+ */
376
+ function buildShardPlaywrightInvocation(pm, shardIndex, total, forwarded, htmlReportDir) {
377
+ return {
378
+ args: pm.exec('playwright', ['test', `--shard=${shardIndex}/${total}`, ...forwarded]),
379
+ env: {
380
+ PLAYWRIGHT_HTML_OPEN: 'never',
381
+ PLAYWRIGHT_HTML_OUTPUT_DIR: htmlReportDir,
382
+ },
383
+ };
384
+ }
344
385
  /** True when a test session file exists (used by status/down). */
345
386
  function hasTestSession(root) {
346
387
  return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
@@ -396,21 +437,14 @@ function runShardedTestSession(layout, baseIdentity, log, opts) {
396
437
  // suite runs under concurrent sharded load, so it can relax navigation /
397
438
  // test timeouts (N built SSR servers + N Chromium saturate the CPU and slow
398
439
  // every navigation) without loosening them for serial runs.
399
- const env = Object.assign(Object.assign(Object.assign({}, ctx.appEnv), { LT_DEV_TEST_SHARDS: String(total), MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` }), (ctx.apiLogPath ? { NEST_SERVER_LOG: ctx.apiLogPath } : {}));
440
+ // Reporter + shard args come from the shared helper, which deliberately
441
+ // does NOT inject `--reporter` so the project's own release gate (e.g.
442
+ // SVL's DEV-2098 no-skips reporter) still runs under `--shard` (DEV-2676),
443
+ // and isolates the HTML report per shard.
444
+ const reportDir = shardReportDir(layout.root, index);
445
+ const { args, env: reporterEnv } = buildShardPlaywrightInvocation(opts.pm, index, total, opts.forwarded, reportDir);
446
+ const env = Object.assign(Object.assign(Object.assign(Object.assign({}, ctx.appEnv), { LT_DEV_TEST_SHARDS: String(total), MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` }), (ctx.apiLogPath ? { NEST_SERVER_LOG: ctx.apiLogPath } : {})), reporterEnv);
400
447
  const logFile = (0, path_1.join)(layout.root, '.lt-dev', `shard.${index}.test.log`);
401
- // Invoke Playwright DIRECTLY via the manager's `exec` (NOT `<pm> run
402
- // test:e2e -- …`): forwarding option flags through `<pm> run`'s `--`
403
- // is unreliable — pnpm passed the separator on to Playwright, which
404
- // then read `--shard` / `--reporter` as file FILTERS (not options) →
405
- // every shard ran the whole suite. `<pm> exec` hands args straight
406
- // to the binary (mirrors CI); the helper inserts `--` for npm so
407
- // those flags don't get re-parsed as npm's own.
408
- const args = opts.pm.exec('playwright', [
409
- 'test',
410
- `--shard=${index}/${total}`,
411
- '--reporter=line',
412
- ...opts.forwarded,
413
- ]);
414
448
  const code = yield (0, dev_process_1.runChildToFile)(opts.pm.bin, args, { cwd: appDir, env, logFile });
415
449
  return { code, index, logFile };
416
450
  })));
@@ -426,6 +460,16 @@ function runShardedTestSession(layout, baseIdentity, log, opts) {
426
460
  return failed === 0 ? 0 : 1;
427
461
  });
428
462
  }
463
+ /**
464
+ * The per-shard HTML report directory. Distinct per shard (the `<index>` in the
465
+ * path) so N shards never race on a shared `playwright-report/` — this is the
466
+ * actual isolation `buildShardPlaywrightInvocation`'s `PLAYWRIGHT_HTML_OUTPUT_DIR`
467
+ * relies on. Extracted as a pure helper so the shard-distinctness guarantee is
468
+ * unit-testable without booting the (deliberately untested) real orchestrator.
469
+ */
470
+ function shardReportDir(root, shardIndex) {
471
+ return (0, path_1.join)(root, '.lt-dev', `shard.${shardIndex}.playwright-report`);
472
+ }
429
473
  /**
430
474
  * Tear down the unsharded test stack AND every sharded stack discovered on disk
431
475
  * (`state.test.<i>.json` in `.lt-dev/`). Used by `lt dev test down` so a
@@ -824,7 +824,17 @@ function isPristineLtDevPatch(worktreePath, relPath) {
824
824
  try {
825
825
  (0, fs_1.writeFileSync)(tmp, head, 'utf8');
826
826
  (0, dev_patches_1.autoPatch)(tmp);
827
- return (0, fs_1.readFileSync)(tmp, 'utf8') === current;
827
+ const derived = (0, fs_1.readFileSync)(tmp, 'utf8');
828
+ if (derived === current)
829
+ return true;
830
+ // The patcher deliberately lets the consumer's formatter restyle the
831
+ // injected `lt-dev:bridge` block (quote style, wrapping) without rewriting
832
+ // it — so a byte-exact comparison would classify a merely reformatted
833
+ // playwright.config.ts as real developer work and make `lt ticket stop`
834
+ // refuse to remove the worktree. Compare the bridge span in its canonical
835
+ // form; everything OUTSIDE the markers stays byte-exact, so genuine edits
836
+ // are still never auto-discarded.
837
+ return (0, dev_patches_1.canonicaliseBridgeSpan)(derived) === (0, dev_patches_1.canonicaliseBridgeSpan)(current);
828
838
  }
829
839
  catch (_c) {
830
840
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.41.1",
3
+ "version": "1.41.3",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",
@@ -46,14 +46,10 @@
46
46
  "start:build": "npm run build && node bin/lt --compiled-build",
47
47
  "start:compiled": "node bin/lt --compiled-build",
48
48
  "test": "jest --testTimeout=60000",
49
- "watch": "jest --watch",
50
- "release": "standard-version && git push --follow-tags origin main",
51
- "release:minor": "standard-version --release-as minor && git push --follow-tags origin main",
52
- "release:major": "standard-version --release-as major && git push --follow-tags origin main"
49
+ "watch": "jest --watch"
53
50
  },
54
51
  "files": [
55
52
  "tsconfig.json",
56
- "tslint.json",
57
53
  "build",
58
54
  "LICENSE",
59
55
  "README.md",
@@ -96,7 +92,6 @@
96
92
  "jest": "30.4.2",
97
93
  "prettier": "3.8.3",
98
94
  "rimraf": "6.1.3",
99
- "standard-version": "9.5.0",
100
95
  "ts-jest": "29.4.11"
101
96
  },
102
97
  "//overrides": {