@unotest/core 0.20.0 → 0.22.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 +76 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.js +67 -42
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.22.0] - 2026-08-26
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @unotest/dsl@0.22.0
|
|
8
|
+
- @unotest/protocol@0.22.0
|
|
9
|
+
|
|
10
|
+
## [0.21.0] - 2026-08-25
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 174d27e: feat: environments are first-class in the viewer — a switcher, per-env run history
|
|
15
|
+
|
|
16
|
+
**Run artifacts are laid out per environment, as folders.** Each
|
|
17
|
+
environment's history lives in its own root, mirroring the env-file
|
|
18
|
+
scheme: `unotest/.runs` (base), `unotest/.runs.<env>` (e.g.
|
|
19
|
+
`.runs.staging` for `--env staging` / `UNOTEST_ENV=staging`); the axes
|
|
20
|
+
compose — `.runs-mobile.staging`. Indexes (`_day.jsonl`, `_latest.json`)
|
|
21
|
+
are per-env automatically: switching environments is a change of root,
|
|
22
|
+
not a filter and not a rebuild. Old runs stay in `.runs` (= base), no
|
|
23
|
+
migration needed. `runsDirFor` / `projectRunsRoot` / `projectRunDirFor`
|
|
24
|
+
gained an optional `envName` parameter; the run manifest carries `env`
|
|
25
|
+
for self-description. Make sure your `.gitignore` uses the wildcard
|
|
26
|
+
form — `unotest/.runs*/` (`init` already writes it that way for new
|
|
27
|
+
projects).
|
|
28
|
+
|
|
29
|
+
**The viewer knows about environments.** A new switcher sits at the top
|
|
30
|
+
of the Variables panel and in the status bar: base plus every
|
|
31
|
+
environment discovered from `unotest/.env.<name>` / `.secrets.<name>`
|
|
32
|
+
(`.env.example` and other templates do not count). The active
|
|
33
|
+
environment is server-side (`GET/POST /api/environments`), and all tabs
|
|
34
|
+
converge via the `env:changed` WS message:
|
|
35
|
+
|
|
36
|
+
- the Variables panel shows layers WITH the active environment's
|
|
37
|
+
overlay (previously base only); values coming from `.env.<name>`
|
|
38
|
+
carry a badge; an edit goes to the file where the key is defined; new
|
|
39
|
+
variables go to base;
|
|
40
|
+
- Overview and the Runs list show only the active environment — tiles
|
|
41
|
+
are colored by the latest run in that environment;
|
|
42
|
+
- a run started from the viewer gets the active environment's
|
|
43
|
+
`UNOTEST_ENV` (`RunRequest.env` is a per-request override);
|
|
44
|
+
- a run opened from another environment still resolves by runId across
|
|
45
|
+
all `.runs*` roots.
|
|
46
|
+
|
|
47
|
+
MCP: `run_test {env}` already switched the child process's environment;
|
|
48
|
+
its artifacts are now correctly found by the server in `.runs.<env>`
|
|
49
|
+
(inspect/step/attach/list_runtimes scan all roots).
|
|
50
|
+
|
|
51
|
+
Four fixes uncovered while shaking this down:
|
|
52
|
+
|
|
53
|
+
- **The `--env` overlay actually reaches the run.** Long-lived hosts
|
|
54
|
+
(viewer, MCP server) flattened the base `.env` into their own
|
|
55
|
+
`process.env` on `loadConfig`; children inherited it as ambient
|
|
56
|
+
(ambient beats files) — the overlay's `APP_BASE_URL` was silently
|
|
57
|
+
clobbered by the base value. The viewer now rolls its env back after
|
|
58
|
+
loading the config; MCP spawns children from a clean pre-flatten
|
|
59
|
+
snapshot.
|
|
60
|
+
- **Pause/Abort from the viewer work again.** Debug commands were
|
|
61
|
+
written to the flat `<root>/<runId>/commands.jsonl`, while runs have
|
|
62
|
+
lived in date shards since 0.19 — the write 404'd and abort was
|
|
63
|
+
silently ignored. Order fixed too: SIGTERM to the own process first
|
|
64
|
+
(tests AND collections), then the command file.
|
|
65
|
+
- **A collection no longer "dies" in the UI after 30 seconds.** The
|
|
66
|
+
parent run wrote no heartbeat — the monitor declared it interrupted,
|
|
67
|
+
the row vanished from ACTIVE and the tail detached. The orchestrator
|
|
68
|
+
now maintains the heartbeat (shared machinery in `@unotest/core`),
|
|
69
|
+
and the viewer additionally treats a flowing steps.jsonl as a sign of
|
|
70
|
+
life (compatibility with older runners).
|
|
71
|
+
- **No phantom `failed` after a clean exit.** The "child exited without
|
|
72
|
+
artifacts" check looked at the flat path and fired bogus
|
|
73
|
+
`run-finished: failed` events plus an error toast on every exit.
|
|
74
|
+
|
|
75
|
+
- Updated dependencies [174d27e]
|
|
76
|
+
- @unotest/protocol@0.21.0
|
|
77
|
+
- @unotest/dsl@0.21.0
|
|
78
|
+
|
|
3
79
|
## [0.20.0] - 2026-08-24
|
|
4
80
|
|
|
5
81
|
### Patch Changes
|
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
|
|
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 {
|
|
6
|
-
import { appendFile, mkdir
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
134
|
+
import { join as join3 } from "path";
|
|
111
135
|
async function writeRunManifest(input) {
|
|
112
136
|
await mkdir2(input.runDir, { recursive: true });
|
|
113
|
-
const dest =
|
|
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
|
|
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 =
|
|
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
|
|
238
|
+
import { join as join5 } from "path";
|
|
215
239
|
function createRuntimeStateWriter(deps) {
|
|
216
|
-
const path =
|
|
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
|
|
494
|
+
import { join as join6 } from "path";
|
|
471
495
|
function readDebuggerBreakpoints(cwd, suffix, scenarioName) {
|
|
472
|
-
const filePath =
|
|
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
|
|
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(
|
|
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.
|
|
3
|
+
"version": "0.22.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.
|
|
33
|
-
"@unotest/protocol": "^0.
|
|
32
|
+
"@unotest/dsl": "^0.22.0",
|
|
33
|
+
"@unotest/protocol": "^0.22.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/node": "^22.10.0",
|