@unotest/core 0.19.0 → 0.21.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/CHANGELOG.md CHANGED
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.21.0] - 2026-08-25
4
+
5
+ ### Patch Changes
6
+
7
+ - 174d27e: feat: environments are first-class in the viewer — a switcher, per-env run history
8
+
9
+ **Run artifacts are laid out per environment, as folders.** Each
10
+ environment's history lives in its own root, mirroring the env-file
11
+ scheme: `unotest/.runs` (base), `unotest/.runs.<env>` (e.g.
12
+ `.runs.staging` for `--env staging` / `UNOTEST_ENV=staging`); the axes
13
+ compose — `.runs-mobile.staging`. Indexes (`_day.jsonl`, `_latest.json`)
14
+ are per-env automatically: switching environments is a change of root,
15
+ not a filter and not a rebuild. Old runs stay in `.runs` (= base), no
16
+ migration needed. `runsDirFor` / `projectRunsRoot` / `projectRunDirFor`
17
+ gained an optional `envName` parameter; the run manifest carries `env`
18
+ for self-description. Make sure your `.gitignore` uses the wildcard
19
+ form — `unotest/.runs*/` (`init` already writes it that way for new
20
+ projects).
21
+
22
+ **The viewer knows about environments.** A new switcher sits at the top
23
+ of the Variables panel and in the status bar: base plus every
24
+ environment discovered from `unotest/.env.<name>` / `.secrets.<name>`
25
+ (`.env.example` and other templates do not count). The active
26
+ environment is server-side (`GET/POST /api/environments`), and all tabs
27
+ converge via the `env:changed` WS message:
28
+
29
+ - the Variables panel shows layers WITH the active environment's
30
+ overlay (previously base only); values coming from `.env.<name>`
31
+ carry a badge; an edit goes to the file where the key is defined; new
32
+ variables go to base;
33
+ - Overview and the Runs list show only the active environment — tiles
34
+ are colored by the latest run in that environment;
35
+ - a run started from the viewer gets the active environment's
36
+ `UNOTEST_ENV` (`RunRequest.env` is a per-request override);
37
+ - a run opened from another environment still resolves by runId across
38
+ all `.runs*` roots.
39
+
40
+ MCP: `run_test {env}` already switched the child process's environment;
41
+ its artifacts are now correctly found by the server in `.runs.<env>`
42
+ (inspect/step/attach/list_runtimes scan all roots).
43
+
44
+ Four fixes uncovered while shaking this down:
45
+
46
+ - **The `--env` overlay actually reaches the run.** Long-lived hosts
47
+ (viewer, MCP server) flattened the base `.env` into their own
48
+ `process.env` on `loadConfig`; children inherited it as ambient
49
+ (ambient beats files) — the overlay's `APP_BASE_URL` was silently
50
+ clobbered by the base value. The viewer now rolls its env back after
51
+ loading the config; MCP spawns children from a clean pre-flatten
52
+ snapshot.
53
+ - **Pause/Abort from the viewer work again.** Debug commands were
54
+ written to the flat `<root>/<runId>/commands.jsonl`, while runs have
55
+ lived in date shards since 0.19 — the write 404'd and abort was
56
+ silently ignored. Order fixed too: SIGTERM to the own process first
57
+ (tests AND collections), then the command file.
58
+ - **A collection no longer "dies" in the UI after 30 seconds.** The
59
+ parent run wrote no heartbeat — the monitor declared it interrupted,
60
+ the row vanished from ACTIVE and the tail detached. The orchestrator
61
+ now maintains the heartbeat (shared machinery in `@unotest/core`),
62
+ and the viewer additionally treats a flowing steps.jsonl as a sign of
63
+ life (compatibility with older runners).
64
+ - **No phantom `failed` after a clean exit.** The "child exited without
65
+ artifacts" check looked at the flat path and fired bogus
66
+ `run-finished: failed` events plus an error toast on every exit.
67
+
68
+ - Updated dependencies [174d27e]
69
+ - @unotest/protocol@0.21.0
70
+ - @unotest/dsl@0.21.0
71
+
72
+ ## [0.20.0] - 2026-08-24
73
+
74
+ ### Patch Changes
75
+
76
+ - Updated dependencies [7957469]
77
+ - Updated dependencies [c6efd92]
78
+ - @unotest/protocol@0.20.0
79
+ - @unotest/dsl@0.20.0
80
+
3
81
  ## [0.19.0] - 2026-08-23
4
82
 
5
83
  ## [0.18.0] - 2026-08-23
package/dist/index.d.ts CHANGED
@@ -24,10 +24,9 @@ interface JsonlRunArtifactWriterOptions {
24
24
  }
25
25
  declare class JsonlRunArtifactWriter implements IRunArtifactWriter {
26
26
  private readonly stepsPath;
27
- private readonly heartbeatPath;
28
27
  private readonly nowFn;
29
28
  private readonly redactor;
30
- private heartbeatTimer;
29
+ private readonly heartbeat;
31
30
  private closed;
32
31
  /** Tracks whether a `run:finished` event has been emitted. close()
33
32
  * synthesizes one with outcome='aborted' if not — covers abort-paths
@@ -42,7 +41,6 @@ declare class JsonlRunArtifactWriter implements IRunArtifactWriter {
42
41
  constructor(opts: JsonlRunArtifactWriterOptions);
43
42
  emit(event: RunArtifact): Promise<void>;
44
43
  close(): Promise<void>;
45
- private touchHeartbeat;
46
44
  }
47
45
  /** Convenience: create `runDir`, emit `run:started`, return a writer
48
46
  * ready for subsequent events. Callers compute `runDir` themselves —
@@ -55,6 +53,19 @@ declare function createRunArtifactWriter(opts: {
55
53
  redactor?: ArtifactRedactor;
56
54
  }): Promise<IRunArtifactWriter>;
57
55
 
56
+ interface RunHeartbeat {
57
+ /** Bump the file's mtime now (creating it if missing). */
58
+ touch(): Promise<void>;
59
+ /** Stop bumping and BACKDATE the file so liveness readers see the run
60
+ * as stale immediately — "had a heartbeat, now stale" stays distinct
61
+ * from "never started (no heartbeat)". Idempotent. */
62
+ stop(): Promise<void>;
63
+ }
64
+ declare function startRunHeartbeat(runDir: string, opts?: {
65
+ intervalMs?: number;
66
+ nowFn?: () => number;
67
+ }): RunHeartbeat;
68
+
58
69
  interface WriteManifestInput {
59
70
  /** Absolute path to the run dir (`unotest/.runs<suffix>/<runId>/`).
60
71
  * Created if absent. */
@@ -248,4 +259,4 @@ declare class UnotestError extends Error {
248
259
  constructor(message: string, context?: Record<string, unknown>);
249
260
  }
250
261
 
251
- export { type ArtifactRedactor, type DebugCommandsWatcherDeps, type DebugControlTarget, type DebugWatcherLogger, type E2EFlags, type GitignoreUpdate, type IRunArtifactWriter, JsonlRunArtifactWriter, type JsonlRunArtifactWriterOptions, type RuntimeExecState, type RuntimeInspection, type RuntimeInspectionInput, type RuntimeStateExtra, type RuntimeStateWriter, type RuntimeStateWriterDeps, UnotestError, type WriteManifestInput, type WriteRunSourcesInput, appendUniqueLines, applyEnvLayers, buildRuntimeInspection, createRunArtifactWriter, createRuntimeStateWriter, currentLocation, extractCallStack, levenshteinDistance, parseE2EFlags, readDebuggerBreakpoints, readEnvFile, readEnvLayers, startDebugCommandsWatcher, suggestClosest, toProtocolRuntimeState, writeRunManifest, writeRunSources };
262
+ export { type ArtifactRedactor, type DebugCommandsWatcherDeps, type DebugControlTarget, type DebugWatcherLogger, type E2EFlags, type GitignoreUpdate, type IRunArtifactWriter, JsonlRunArtifactWriter, type JsonlRunArtifactWriterOptions, type RunHeartbeat, type RuntimeExecState, type RuntimeInspection, type RuntimeInspectionInput, type RuntimeStateExtra, type RuntimeStateWriter, type RuntimeStateWriterDeps, UnotestError, type WriteManifestInput, type WriteRunSourcesInput, appendUniqueLines, applyEnvLayers, buildRuntimeInspection, createRunArtifactWriter, createRuntimeStateWriter, currentLocation, extractCallStack, levenshteinDistance, parseE2EFlags, readDebuggerBreakpoints, readEnvFile, readEnvLayers, startDebugCommandsWatcher, startRunHeartbeat, suggestClosest, toProtocolRuntimeState, writeRunManifest, writeRunSources };
package/dist/index.js CHANGED
@@ -2,18 +2,64 @@ var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
4
  // src/run-artifact-writer.ts
5
- import { HEARTBEAT_FILE, STEPS_FILE } from "@unotest/protocol";
6
- import { appendFile, mkdir, utimes, writeFile } from "fs/promises";
5
+ import { STEPS_FILE } from "@unotest/protocol";
6
+ import { appendFile, mkdir } from "fs/promises";
7
+ import { join as join2 } from "path";
8
+
9
+ // src/heartbeat.ts
10
+ import { HEARTBEAT_FILE } from "@unotest/protocol";
11
+ import { utimes, writeFile } from "fs/promises";
7
12
  import { join } from "path";
13
+ function startRunHeartbeat(runDir, opts = {}) {
14
+ const path = join(runDir, HEARTBEAT_FILE);
15
+ const nowFn = opts.nowFn ?? Date.now;
16
+ const intervalMs = opts.intervalMs ?? 5e3;
17
+ let stopped = false;
18
+ let timer = null;
19
+ const touch = /* @__PURE__ */ __name(async () => {
20
+ if (stopped) return;
21
+ const ts = nowFn();
22
+ try {
23
+ await writeFile(path, "", { flag: "a" });
24
+ await utimes(path, new Date(ts), new Date(ts));
25
+ } catch {
26
+ }
27
+ }, "touch");
28
+ if (intervalMs > 0) {
29
+ void touch();
30
+ timer = setInterval(() => {
31
+ void touch();
32
+ }, intervalMs);
33
+ timer.unref();
34
+ }
35
+ return {
36
+ touch,
37
+ async stop() {
38
+ if (stopped) return;
39
+ stopped = true;
40
+ if (timer) {
41
+ clearInterval(timer);
42
+ timer = null;
43
+ }
44
+ try {
45
+ const stale = new Date(nowFn() - 36e5);
46
+ await utimes(path, stale, stale);
47
+ } catch {
48
+ }
49
+ }
50
+ };
51
+ }
52
+ __name(startRunHeartbeat, "startRunHeartbeat");
53
+
54
+ // src/run-artifact-writer.ts
8
55
  var JsonlRunArtifactWriter = class {
9
56
  static {
10
57
  __name(this, "JsonlRunArtifactWriter");
11
58
  }
12
59
  stepsPath;
13
- heartbeatPath;
14
60
  nowFn;
15
61
  redactor;
16
- heartbeatTimer = null;
62
+ heartbeat;
17
63
  closed = false;
18
64
  /** Tracks whether a `run:finished` event has been emitted. close()
19
65
  * synthesizes one with outcome='aborted' if not — covers abort-paths
@@ -26,18 +72,13 @@ var JsonlRunArtifactWriter = class {
26
72
  * HAD a real failure, the user just gave up at the pause. */
27
73
  sawStepFail = false;
28
74
  constructor(opts) {
29
- this.stepsPath = join(opts.runDir, STEPS_FILE);
30
- this.heartbeatPath = join(opts.runDir, HEARTBEAT_FILE);
75
+ this.stepsPath = join2(opts.runDir, STEPS_FILE);
31
76
  this.nowFn = opts.nowFn ?? Date.now;
32
77
  this.redactor = opts.redactor;
33
- const interval = opts.heartbeatIntervalMs ?? 5e3;
34
- if (interval > 0) {
35
- void this.touchHeartbeat();
36
- this.heartbeatTimer = setInterval(() => {
37
- void this.touchHeartbeat();
38
- }, interval);
39
- this.heartbeatTimer.unref();
40
- }
78
+ this.heartbeat = startRunHeartbeat(opts.runDir, {
79
+ intervalMs: opts.heartbeatIntervalMs ?? 5e3,
80
+ nowFn: this.nowFn
81
+ });
41
82
  }
42
83
  async emit(event) {
43
84
  if (this.closed) return;
@@ -68,24 +109,7 @@ var JsonlRunArtifactWriter = class {
68
109
  this.runFinishedEmitted = true;
69
110
  }
70
111
  this.closed = true;
71
- if (this.heartbeatTimer) {
72
- clearInterval(this.heartbeatTimer);
73
- this.heartbeatTimer = null;
74
- }
75
- try {
76
- const stale = new Date(this.nowFn() - 36e5);
77
- await utimes(this.heartbeatPath, stale, stale);
78
- } catch {
79
- }
80
- }
81
- async touchHeartbeat() {
82
- if (this.closed) return;
83
- const ts = this.nowFn();
84
- try {
85
- await writeFile(this.heartbeatPath, "", { flag: "a" });
86
- await utimes(this.heartbeatPath, new Date(ts), new Date(ts));
87
- } catch {
88
- }
112
+ await this.heartbeat.stop();
89
113
  }
90
114
  };
91
115
  async function createRunArtifactWriter(opts) {
@@ -107,10 +131,10 @@ __name(createRunArtifactWriter, "createRunArtifactWriter");
107
131
  // src/run-manifest-writer.ts
108
132
  import { RUN_MANIFEST_FILE } from "@unotest/protocol";
109
133
  import { mkdir as mkdir2, rename, writeFile as writeFile2 } from "fs/promises";
110
- import { join as join2 } from "path";
134
+ import { join as join3 } from "path";
111
135
  async function writeRunManifest(input) {
112
136
  await mkdir2(input.runDir, { recursive: true });
113
- const dest = join2(input.runDir, RUN_MANIFEST_FILE);
137
+ const dest = join3(input.runDir, RUN_MANIFEST_FILE);
114
138
  const tmp = `${dest}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
115
139
  await writeFile2(tmp, JSON.stringify(input.manifest, null, 2), "utf8");
116
140
  await rename(tmp, dest);
@@ -122,10 +146,10 @@ import {
122
146
  RUN_SOURCES_FILE
123
147
  } from "@unotest/protocol";
124
148
  import { mkdir as mkdir3, rename as rename2, writeFile as writeFile3 } from "fs/promises";
125
- import { dirname, join as join3 } from "path";
149
+ import { dirname, join as join4 } from "path";
126
150
  async function writeRunSources(input) {
127
151
  await mkdir3(input.runDir, { recursive: true });
128
- const dest = join3(input.runDir, RUN_SOURCES_FILE);
152
+ const dest = join4(input.runDir, RUN_SOURCES_FILE);
129
153
  const tmp = `${dest}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
130
154
  await mkdir3(dirname(tmp), { recursive: true });
131
155
  await writeFile3(tmp, JSON.stringify(input.sources, null, 2), "utf8");
@@ -211,9 +235,9 @@ __name(truncate, "truncate");
211
235
  // src/runtime-state-writer.ts
212
236
  import { RUNTIME_FILE } from "@unotest/protocol";
213
237
  import { rename as rename3, writeFile as writeFile4 } from "fs/promises";
214
- import { join as join4 } from "path";
238
+ import { join as join5 } from "path";
215
239
  function createRuntimeStateWriter(deps) {
216
- const path = join4(deps.runDir, RUNTIME_FILE);
240
+ const path = join5(deps.runDir, RUNTIME_FILE);
217
241
  const tmpPath = `${path}.tmp`;
218
242
  const throttleMs = deps.throttleMs ?? 50;
219
243
  const now = deps.nowFn ?? Date.now;
@@ -467,9 +491,9 @@ __name(suggestClosest, "suggestClosest");
467
491
  // src/debugger-file.ts
468
492
  import { debuggerFileFor } from "@unotest/protocol";
469
493
  import { existsSync, readFileSync } from "fs";
470
- import { join as join5 } from "path";
494
+ import { join as join6 } from "path";
471
495
  function readDebuggerBreakpoints(cwd, suffix, scenarioName) {
472
- const filePath = join5(cwd, debuggerFileFor(suffix));
496
+ const filePath = join6(cwd, debuggerFileFor(suffix));
473
497
  if (!existsSync(filePath)) return [];
474
498
  try {
475
499
  const raw = readFileSync(filePath, "utf8");
@@ -517,7 +541,7 @@ import {
517
541
  parseEnvContent
518
542
  } from "@unotest/protocol";
519
543
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
520
- import { join as join6 } from "path";
544
+ import { join as join7 } from "path";
521
545
  function readEnvFile(absPath) {
522
546
  if (!existsSync2(absPath)) return {};
523
547
  try {
@@ -530,7 +554,7 @@ __name(readEnvFile, "readEnvFile");
530
554
  function readEnvLayers(projectRoot, suffix, envName) {
531
555
  const out = {};
532
556
  for (const rel of envLayerFilesFor(suffix, envName)) {
533
- Object.assign(out, readEnvFile(join6(projectRoot, rel)));
557
+ Object.assign(out, readEnvFile(join7(projectRoot, rel)));
534
558
  }
535
559
  return out;
536
560
  }
@@ -578,6 +602,7 @@ export {
578
602
  readEnvFile,
579
603
  readEnvLayers,
580
604
  startDebugCommandsWatcher,
605
+ startRunHeartbeat,
581
606
  suggestClosest,
582
607
  toProtocolRuntimeState,
583
608
  writeRunManifest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unotest/core",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Shared runner-side utilities for the @unotest ecosystem: JSONL run-artifact writer (steps.jsonl + heartbeat), atomic run-manifest + run-sources writers, atomic runtime-state writer with a protocol-normalizing runtime-inspection helper, layered .env reader/applier (target + environment axes), idempotent .gitignore updater. Used by @unotest/web and @unotest/mobile; depends on @unotest/protocol plus a type-only import of @unotest/dsl/executor event types (M-20). Unlike protocol (pure data), this package owns the thin filesystem layer both runners need.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -29,8 +29,8 @@
29
29
  ],
30
30
  "dependencies": {
31
31
  "chokidar": "^4.0.3",
32
- "@unotest/dsl": "^0.19.0",
33
- "@unotest/protocol": "^0.19.0"
32
+ "@unotest/dsl": "^0.21.0",
33
+ "@unotest/protocol": "^0.21.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^22.10.0",