omkit 0.0.11 → 0.0.13
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/dist/actions/chrome-page.d.ts +7 -3
- package/dist/actions/chrome-page.js +14 -10
- package/dist/actions/command.d.ts +0 -2
- package/dist/actions/command.js +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/orchestration/action/action.js +1 -0
- package/dist/orchestration/action/types.d.ts +8 -1
- package/dist/orchestration/assert.d.ts +22 -0
- package/dist/orchestration/assert.js +26 -0
- package/dist/orchestration/spin/SpinHost.d.ts +4 -0
- package/dist/orchestration/spin/SpinHost.js +34 -5
- package/dist/orchestration/spin/spin.d.ts +1 -1
- package/dist/orchestration/spin/spin.js +2 -1
- package/dist/orchestration/spin/types.d.ts +7 -0
- package/dist/output/log/LogRenderer.d.ts +1 -1
- package/dist/output/log/LogRenderer.js +2 -0
- package/dist/output/log/RunLog.js +14 -2
- package/dist/output/log/console-capture.d.ts +24 -0
- package/dist/output/log/console-capture.js +53 -0
- package/dist/system/fs/FolderCache.js +3 -1
- package/dist/system/process/process.js +5 -1
- package/package.json +1 -1
|
@@ -33,12 +33,16 @@ export interface ChromePageOptions {
|
|
|
33
33
|
* from whatever {@link ChromePageSource} is given — a CDP endpoint to connect to,
|
|
34
34
|
* or an already-owned Playwright handle (an Electron `BrowserWindow` page, a
|
|
35
35
|
* `BrowserContext`, or a `Browser`). The source may be a promise, so it wires
|
|
36
|
-
* straight from another action's `.ref` (e.g. `chromePage(
|
|
37
|
-
* `chromePage(electronApp.ref)`).
|
|
36
|
+
* straight from another action's `.ref` (e.g. `chromePage("Explorer",
|
|
37
|
+
* chromedriver.ref)` or `chromePage("App", electronApp.ref)`).
|
|
38
38
|
*
|
|
39
39
|
* Daemon-shaped like the watch actions: acquire the page, attach it, then stay
|
|
40
40
|
* alive on a promise that only resolves when the run's `signal` aborts. On
|
|
41
41
|
* teardown it closes the CDP connection *only* when it opened one — a handed-in
|
|
42
42
|
* browser or window is left untouched for its owner to close.
|
|
43
|
+
*
|
|
44
|
+
* `label` names this page in the log (its `source`), so several `chromePage`
|
|
45
|
+
* instances in one run stay distinguishable — e.g. `chromePage("Explorer",
|
|
46
|
+
* chrome.ref)` logs its lines under `Explorer` rather than a shared default.
|
|
43
47
|
*/
|
|
44
|
-
export declare const chromePage: import("../index.ts").Action<[source: ChromePageSource | Promise<ChromePageSource>, opts?: ChromePageOptions | undefined], void, ChromePageEvents, Page>;
|
|
48
|
+
export declare const chromePage: import("../index.ts").Action<[label: string, source: ChromePageSource | Promise<ChromePageSource>, opts?: ChromePageOptions | undefined], void, ChromePageEvents, Page>;
|
|
@@ -13,18 +13,22 @@ const pageOf = (context, fresh) => fresh ? context.newPage() : (context.pages()[
|
|
|
13
13
|
* from whatever {@link ChromePageSource} is given — a CDP endpoint to connect to,
|
|
14
14
|
* or an already-owned Playwright handle (an Electron `BrowserWindow` page, a
|
|
15
15
|
* `BrowserContext`, or a `Browser`). The source may be a promise, so it wires
|
|
16
|
-
* straight from another action's `.ref` (e.g. `chromePage(
|
|
17
|
-
* `chromePage(electronApp.ref)`).
|
|
16
|
+
* straight from another action's `.ref` (e.g. `chromePage("Explorer",
|
|
17
|
+
* chromedriver.ref)` or `chromePage("App", electronApp.ref)`).
|
|
18
18
|
*
|
|
19
19
|
* Daemon-shaped like the watch actions: acquire the page, attach it, then stay
|
|
20
20
|
* alive on a promise that only resolves when the run's `signal` aborts. On
|
|
21
21
|
* teardown it closes the CDP connection *only* when it opened one — a handed-in
|
|
22
22
|
* browser or window is left untouched for its owner to close.
|
|
23
|
+
*
|
|
24
|
+
* `label` names this page in the log (its `source`), so several `chromePage`
|
|
25
|
+
* instances in one run stay distinguishable — e.g. `chromePage("Explorer",
|
|
26
|
+
* chrome.ref)` logs its lines under `Explorer` rather than a shared default.
|
|
23
27
|
*/
|
|
24
28
|
export const chromePage = action("Chrome Page")
|
|
25
29
|
.emits()
|
|
26
30
|
.ref()
|
|
27
|
-
.run(async ({ logs, signal, emit, attach }, source, opts = {}) => {
|
|
31
|
+
.run(async ({ logs, signal, emit, attach }, label, source, opts = {}) => {
|
|
28
32
|
const { url } = opts;
|
|
29
33
|
const resolved = await source;
|
|
30
34
|
// Resolve the source to a Page, recording what (if anything) we own. Only a
|
|
@@ -60,23 +64,23 @@ export const chromePage = action("Chrome Page")
|
|
|
60
64
|
page = await pageOf(resolved, url !== undefined);
|
|
61
65
|
}
|
|
62
66
|
page.on("console", (msg) => {
|
|
63
|
-
logs.append({ source:
|
|
64
|
-
emit("console", msg.text());
|
|
67
|
+
logs.append({ source: label, level: "info", message: `console: ${msg.text()}` });
|
|
68
|
+
emit("console", `from: ${label}: ${msg.text()}`);
|
|
65
69
|
});
|
|
66
70
|
page.on("pageerror", (err) => {
|
|
67
|
-
logs.append({ source:
|
|
68
|
-
emit("pageerror", err.message);
|
|
71
|
+
logs.append({ source: label, level: "error", message: err.message });
|
|
72
|
+
emit("pageerror", `from: ${label}: ${err.message}`);
|
|
69
73
|
});
|
|
70
74
|
page.on("framenavigated", (frame) => {
|
|
71
75
|
if (frame === page.mainFrame())
|
|
72
|
-
emit("navigated", frame.url());
|
|
76
|
+
emit("navigated", `${label}: ${frame.url()}`);
|
|
73
77
|
});
|
|
74
78
|
if (url) {
|
|
75
79
|
await page.goto(url);
|
|
76
|
-
logs.append({ source:
|
|
80
|
+
logs.append({ source: label, level: "info", message: `navigated ${url}` });
|
|
77
81
|
}
|
|
78
82
|
attach(page); // resolves instance.ref for every downstream action
|
|
79
|
-
logs.append({ source:
|
|
83
|
+
logs.append({ source: label, level: "info", message: `attached ${from}` });
|
|
80
84
|
// Daemon: hold the page open until teardown, then drop the CDP session if it
|
|
81
85
|
// was ours; never close a handed-in browser/window.
|
|
82
86
|
return new Promise((resolveRun) => {
|
|
@@ -6,8 +6,6 @@ export interface CommandOptions {
|
|
|
6
6
|
* node was executed from (process.cwd()). Default ".".
|
|
7
7
|
*/
|
|
8
8
|
cwd?: string;
|
|
9
|
-
/** Label the child's output is logged under. Defaults to the action's name. */
|
|
10
|
-
label?: string;
|
|
11
9
|
/**
|
|
12
10
|
* Environment for the child. Pass the full set you want (e.g.
|
|
13
11
|
* `{ ...process.env, NODE_ENV: "development" }`) — it's handed to the child
|
package/dist/actions/command.js
CHANGED
|
@@ -21,7 +21,7 @@ export function command(name, cmdOrFile, argsOrOptions, maybeOptions) {
|
|
|
21
21
|
// against process.cwd() — exactly the "absolute from where node runs" rule.
|
|
22
22
|
const cwd = path.resolve(options.cwd ?? ".");
|
|
23
23
|
// proc sources the LogsCollector from async context and streams output into it.
|
|
24
|
-
return proc(
|
|
24
|
+
return proc(name, {
|
|
25
25
|
cwd,
|
|
26
26
|
...(options.env ? { env: options.env } : {}),
|
|
27
27
|
})(verbatim(cmdOrFile));
|
|
@@ -36,7 +36,7 @@ export function command(name, cmdOrFile, argsOrOptions, maybeOptions) {
|
|
|
36
36
|
*/
|
|
37
37
|
const spawnDirect = (snapshots, logs, signal, name, file, args, options) => {
|
|
38
38
|
const cwd = path.resolve(options.cwd ?? ".");
|
|
39
|
-
const source =
|
|
39
|
+
const source = name;
|
|
40
40
|
// windowsHide keeps a stray console window from flashing for the child.
|
|
41
41
|
// env omitted -> spawn inherits the parent's process.env (Node's default).
|
|
42
42
|
const child = spawn(file, args, { cwd, windowsHide: true, env: options.env });
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export { action } from "./orchestration/action/action.ts";
|
|
12
12
|
export type { Action, ActionBuilder, ActionInstance, AnyActionInstance, ActionContext, SystemGlobal, SystemEvents, InstanceEvents, NoEvents, Awaitable, Outcome, } from "./orchestration/action/types.ts";
|
|
13
|
+
export { AssertionError } from "./orchestration/assert.ts";
|
|
14
|
+
export type { Assert } from "./orchestration/assert.ts";
|
|
13
15
|
export { spin } from "./orchestration/spin/spin.ts";
|
|
14
16
|
export type { Nod, SpinContext, SpinBody } from "./orchestration/spin/types.ts";
|
|
15
17
|
export { events } from "./orchestration/events/events.ts";
|
|
@@ -17,7 +19,7 @@ export type { Emitter, EventHandler, EventMeta } from "./orchestration/events/ev
|
|
|
17
19
|
export { createProc } from "./system/process/process.ts";
|
|
18
20
|
export type { Proc } from "./system/process/process.ts";
|
|
19
21
|
export { LogsCollector } from "./output/log/LogsCollector.ts";
|
|
20
|
-
export type { Logger, LogEntry, LogInput, SubscribeOptions
|
|
22
|
+
export type { Logger, LogEntry, LogInput, SubscribeOptions } from "./output/log/LogsCollector.ts";
|
|
21
23
|
export { Output } from "./output/index.ts";
|
|
22
24
|
export type { SnapshotRef } from "./output/index.ts";
|
|
23
25
|
export { FolderCache } from "./system/fs/FolderCache.ts";
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
// ── Actions ────────────────────────────────────────────────────────────────
|
|
12
12
|
export { action } from "./orchestration/action/action.js";
|
|
13
|
+
export { AssertionError } from "./orchestration/assert.js";
|
|
13
14
|
// ── Spin (linear orchestration) ──────────────────────────────────────────────
|
|
14
15
|
export { spin } from "./orchestration/spin/spin.js";
|
|
15
16
|
// ── Events ─────────────────────────────────────────────────────────────────
|
|
@@ -123,6 +123,7 @@ class ActionRun {
|
|
|
123
123
|
signal: ctx.signal,
|
|
124
124
|
nod: ctx.nod,
|
|
125
125
|
output: ctx.output,
|
|
126
|
+
assert: ctx.assert,
|
|
126
127
|
});
|
|
127
128
|
// Re-throw the inner failure so the wrapper fails too (the framework re-wraps
|
|
128
129
|
// it into this wrapper's own Outcome); only record on success.
|
|
@@ -2,6 +2,7 @@ import type { Logger } from "../../output/log/LogsCollector.ts";
|
|
|
2
2
|
import type { Output } from "../../output/Output.ts";
|
|
3
3
|
import type { Emitter, EventHandler } from "../events/events.ts";
|
|
4
4
|
import type { Proc } from "../../system/process/process.ts";
|
|
5
|
+
import type { Assert } from "../assert.ts";
|
|
5
6
|
/** An action that declares no events. */
|
|
6
7
|
export type NoEvents = Record<never, never>;
|
|
7
8
|
/** A value an action's `exec` may return: a result, or a promise of one. */
|
|
@@ -67,6 +68,12 @@ export interface SystemGlobal {
|
|
|
67
68
|
* capture their own artifacts.
|
|
68
69
|
*/
|
|
69
70
|
readonly output: Output;
|
|
71
|
+
/**
|
|
72
|
+
* Assert a boolean invariant for this action. Logs a `⊨` line every call; a
|
|
73
|
+
* mismatch is recorded into the run's verdict (never throws, never stops the
|
|
74
|
+
* run). Bound to this action's path for attribution.
|
|
75
|
+
*/
|
|
76
|
+
readonly assert: Assert;
|
|
70
77
|
}
|
|
71
78
|
/** What an action's implementation receives: the system bag, plus `emit`, `attach`, and `proc`. */
|
|
72
79
|
export interface ActionContext<Events extends object, Handle = void> extends SystemGlobal {
|
|
@@ -113,7 +120,7 @@ export interface ActionInstance<Result = unknown, Events extends object = NoEven
|
|
|
113
120
|
readonly done: Promise<Outcome<Result>>;
|
|
114
121
|
/**
|
|
115
122
|
* Resolves with the handle the action attached via `ctx.attach` (set once).
|
|
116
|
-
* Used to thread a handle into another action (`chromePage(driver.ref)`). If
|
|
123
|
+
* Used to thread a handle into another action (`chromePage("Explorer", driver.ref)`). If
|
|
117
124
|
* the action settles *without* attaching, this resolves (`undefined` for the
|
|
118
125
|
* default `void` handle) on success and **rejects on failure** — so, unlike
|
|
119
126
|
* `done`, awaiting `ref` in a body can throw. It's meant to be consumed as a
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Logger } from "../output/log/LogsCollector.ts";
|
|
2
|
+
/**
|
|
3
|
+
* An inline check injected into every action's `ctx` and the `spin` body. Passes
|
|
4
|
+
* when `value` is `true`. Every call is logged; a failure is recorded into the
|
|
5
|
+
* run's verdict — as data, not a throw — so the run keeps going and later
|
|
6
|
+
* assertions still fire. Returns the pass boolean so a body can branch on it.
|
|
7
|
+
*
|
|
8
|
+
* assert(page.isVisible(), "page loaded")
|
|
9
|
+
*/
|
|
10
|
+
export type Assert = (value: boolean, what: string) => boolean;
|
|
11
|
+
/** Marks a failed assertion in the spin's verdict, distinct from an action's own error. */
|
|
12
|
+
export declare class AssertionError extends Error {
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build an {@link Assert} bound to one action (or the spin body): `name` is its
|
|
17
|
+
* launch path (used for attribution in the log line and the error), `logs` is the
|
|
18
|
+
* logger to append to, and `onFail` records a genuine failure into the verdict. The
|
|
19
|
+
* factory is file-agnostic — it only appends `level: "assert"` entries; the
|
|
20
|
+
* dedicated `assertions.log` is a downstream projection of that stream.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createAssert(name: string, logs: Logger, onFail: (error: AssertionError) => void): Assert;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Marks a failed assertion in the spin's verdict, distinct from an action's own error. */
|
|
2
|
+
export class AssertionError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "AssertionError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Build an {@link Assert} bound to one action (or the spin body): `name` is its
|
|
10
|
+
* launch path (used for attribution in the log line and the error), `logs` is the
|
|
11
|
+
* logger to append to, and `onFail` records a genuine failure into the verdict. The
|
|
12
|
+
* factory is file-agnostic — it only appends `level: "assert"` entries; the
|
|
13
|
+
* dedicated `assertions.log` is a downstream projection of that stream.
|
|
14
|
+
*/
|
|
15
|
+
export function createAssert(name, logs, onFail) {
|
|
16
|
+
return (value, what) => {
|
|
17
|
+
const pass = value;
|
|
18
|
+
const glyph = pass ? "✓" : "✗";
|
|
19
|
+
const message = `${name} · ${glyph} ${what}`;
|
|
20
|
+
logs.append({ source: "assert", level: "assert", message });
|
|
21
|
+
if (!pass) {
|
|
22
|
+
onFail(new AssertionError(`${name}: ${what}`));
|
|
23
|
+
}
|
|
24
|
+
return pass;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -32,6 +32,10 @@ export declare class SpinHost {
|
|
|
32
32
|
readonly signal: AbortSignal;
|
|
33
33
|
/** Settles once the spin reaches `closed`, carrying the terminal verdict. Never rejects. */
|
|
34
34
|
readonly done: Promise<SpinResult>;
|
|
35
|
+
/** The original `console.log`, captured before patching — the run's terminal writer. */
|
|
36
|
+
private readonly term;
|
|
37
|
+
/** Reinstalls the pre-run console methods; called once at finalize. */
|
|
38
|
+
private readonly restoreConsole;
|
|
35
39
|
private readonly controller;
|
|
36
40
|
private phase;
|
|
37
41
|
/** The current lifecycle state — see {@link SpinState}. */
|
|
@@ -2,7 +2,9 @@ import { relative } from "node:path";
|
|
|
2
2
|
import { producerOfRef } from "../action/action.js";
|
|
3
3
|
import { LogsCollector, ScopedLogger } from "../../output/log/LogsCollector.js";
|
|
4
4
|
import { LogRenderer } from "../../output/log/LogRenderer.js";
|
|
5
|
+
import { actionScope, captureConsole } from "../../output/log/console-capture.js";
|
|
5
6
|
import { Output } from "../../output/Output.js";
|
|
7
|
+
import { createAssert } from "../assert.js";
|
|
6
8
|
/** The entry script, relative to where node was invoked (so the log says what produced it). */
|
|
7
9
|
const scriptPath = () => {
|
|
8
10
|
const entry = process.argv[1];
|
|
@@ -58,6 +60,10 @@ export class SpinHost {
|
|
|
58
60
|
signal;
|
|
59
61
|
/** Settles once the spin reaches `closed`, carrying the terminal verdict. Never rejects. */
|
|
60
62
|
done;
|
|
63
|
+
/** The original `console.log`, captured before patching — the run's terminal writer. */
|
|
64
|
+
term;
|
|
65
|
+
/** Reinstalls the pre-run console methods; called once at finalize. */
|
|
66
|
+
restoreConsole;
|
|
61
67
|
controller = new AbortController();
|
|
62
68
|
// ── Lifecycle state ──────────────────────────────────────────────────────
|
|
63
69
|
// The single source of truth for "where in its life is this run". Only
|
|
@@ -89,6 +95,12 @@ export class SpinHost {
|
|
|
89
95
|
for (const instance of actions)
|
|
90
96
|
assertInstance(instance);
|
|
91
97
|
this.signal = this.controller.signal;
|
|
98
|
+
// Capture the ORIGINAL console.log as this run's terminal writer BEFORE patching,
|
|
99
|
+
// or `drain`/finalize would re-enter the patched method and feedback-loop. Then
|
|
100
|
+
// patch console so every console.* during the run folds into this run's log,
|
|
101
|
+
// attributed via `actionScope` (see `launch`).
|
|
102
|
+
this.term = console.log.bind(console);
|
|
103
|
+
this.restoreConsole = captureConsole(this.logs);
|
|
92
104
|
process.once("SIGINT", this.onSigint);
|
|
93
105
|
process.on("uncaughtException", this.onUncaught);
|
|
94
106
|
process.on("unhandledRejection", this.onUnhandled);
|
|
@@ -150,7 +162,7 @@ export class SpinHost {
|
|
|
150
162
|
void (async () => {
|
|
151
163
|
const renderer = new LogRenderer();
|
|
152
164
|
for await (const entry of this.logs.subscribe({ replay: true }))
|
|
153
|
-
|
|
165
|
+
this.term(renderer.render(entry));
|
|
154
166
|
})().catch(() => { });
|
|
155
167
|
}
|
|
156
168
|
/** Narrate a run-lifecycle milestone into this run's log (renders as a `[run]` line). */
|
|
@@ -206,7 +218,7 @@ export class SpinHost {
|
|
|
206
218
|
};
|
|
207
219
|
}
|
|
208
220
|
launch(instance, parentPath) {
|
|
209
|
-
// A consumer built with another action's `.ref` (e.g. `chromePage(driver.ref)`)
|
|
221
|
+
// A consumer built with another action's `.ref` (e.g. `chromePage("Explorer", driver.ref)`)
|
|
210
222
|
// must be launched *after* that producer — otherwise it awaits a handle that
|
|
211
223
|
// never arrives and the spin hangs silently. Catch it and say how to fix it.
|
|
212
224
|
for (const arg of instance.args) {
|
|
@@ -228,6 +240,16 @@ export class SpinHost {
|
|
|
228
240
|
this.nodInto(child, path);
|
|
229
241
|
return child;
|
|
230
242
|
};
|
|
243
|
+
// A boolean check bound to this action's path: it logs a `⊨` milestone, and a
|
|
244
|
+
// mismatch records a verdict failure the same way an action's own failure does
|
|
245
|
+
// — skipped during teardown (cascade, not a fault). Uses `this.logs` (not
|
|
246
|
+
// `scoped`) the same way `record` below does: the path is already embedded in
|
|
247
|
+
// the message via `name`, so routing through `scoped` would double-prefix the
|
|
248
|
+
// `source` field for a nodded child.
|
|
249
|
+
const assert = createAssert(path, this.logs, (error) => {
|
|
250
|
+
if (!this.controller.signal.aborted)
|
|
251
|
+
this.recordFailure(path, error);
|
|
252
|
+
});
|
|
231
253
|
this.narrate(`launch ${path}`);
|
|
232
254
|
// The action logs into this run's shared store (entries carry their `source`;
|
|
233
255
|
// grouping is applied at render time) and gets the spin's signal directly; the
|
|
@@ -247,8 +269,14 @@ export class SpinHost {
|
|
|
247
269
|
if (!this.controller.signal.aborted)
|
|
248
270
|
this.recordFailure(path, error);
|
|
249
271
|
};
|
|
250
|
-
const promise =
|
|
251
|
-
.
|
|
272
|
+
const promise = actionScope
|
|
273
|
+
.run(path, () => instance.start({
|
|
274
|
+
logs: scoped,
|
|
275
|
+
signal: this.controller.signal,
|
|
276
|
+
nod,
|
|
277
|
+
output: this.output,
|
|
278
|
+
assert,
|
|
279
|
+
}))
|
|
252
280
|
.then((outcome) => {
|
|
253
281
|
if (!outcome.ok)
|
|
254
282
|
record(outcome.error);
|
|
@@ -282,6 +310,7 @@ export class SpinHost {
|
|
|
282
310
|
process.off("SIGINT", this.onSigint);
|
|
283
311
|
process.off("uncaughtException", this.onUncaught);
|
|
284
312
|
process.off("unhandledRejection", this.onUnhandled);
|
|
313
|
+
this.restoreConsole();
|
|
285
314
|
this.narrate("finished");
|
|
286
315
|
// Close the log now (after the last entry) so `streamLog` drains and finishes
|
|
287
316
|
// writing the complete run.jsonl, then wait for it to fully close its files —
|
|
@@ -291,7 +320,7 @@ export class SpinHost {
|
|
|
291
320
|
await this.streaming;
|
|
292
321
|
const file = await this.output.runLog.write();
|
|
293
322
|
// Direct to the terminal — the file's already written, so this line isn't in it.
|
|
294
|
-
|
|
323
|
+
this.term(LogRenderer.marker("run", `log → ${file}`));
|
|
295
324
|
const result = { ok: this.failures.length === 0, failures: this.failures };
|
|
296
325
|
// One process = one run = one verdict; reflect pass/fail in the exit code so
|
|
297
326
|
// shells and CI see it without parsing the log. Only set it on failure, so a
|
|
@@ -16,7 +16,7 @@ import type { SpinBody } from "./types.ts";
|
|
|
16
16
|
* if (ans.ok && ans.value === "yes") await nod(test).done;
|
|
17
17
|
* nod(devServer); // fire-and-forget daemon
|
|
18
18
|
* const chrome = nod(chromedriver({ … }));
|
|
19
|
-
* nod(chromePage(chrome.ref));
|
|
19
|
+
* nod(chromePage("Explorer", chrome.ref)); // data deps via .ref
|
|
20
20
|
* });
|
|
21
21
|
*
|
|
22
22
|
* Auto-drains by default;
|
|
@@ -17,7 +17,7 @@ import { SpinHost } from "./SpinHost.js";
|
|
|
17
17
|
* if (ans.ok && ans.value === "yes") await nod(test).done;
|
|
18
18
|
* nod(devServer); // fire-and-forget daemon
|
|
19
19
|
* const chrome = nod(chromedriver({ … }));
|
|
20
|
-
* nod(chromePage(chrome.ref));
|
|
20
|
+
* nod(chromePage("Explorer", chrome.ref)); // data deps via .ref
|
|
21
21
|
* });
|
|
22
22
|
*
|
|
23
23
|
* Auto-drains by default;
|
|
@@ -41,6 +41,7 @@ export function spin(body) {
|
|
|
41
41
|
// the body from there rather than reaching for a module singleton.
|
|
42
42
|
artifactsFolder: ctx.artifactsFolder,
|
|
43
43
|
snapshot: (name, value) => ctx.output.snapshots.snapshot(name, value),
|
|
44
|
+
assert: ctx.assert,
|
|
44
45
|
});
|
|
45
46
|
});
|
|
46
47
|
const mainSpinAction = hostAction();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { Nod } from "../action/types.ts";
|
|
2
2
|
import type { Nod } from "../action/types.ts";
|
|
3
|
+
import type { Assert } from "../assert.ts";
|
|
3
4
|
/** What a {@link spin} body receives. */
|
|
4
5
|
export interface SpinContext {
|
|
5
6
|
/** Launch an action into the spin; returns the instance for awaiting/chaining. */
|
|
@@ -25,6 +26,12 @@ export interface SpinContext {
|
|
|
25
26
|
* run-scoped counterpart to the action `ctx.output.snapshots`.
|
|
26
27
|
*/
|
|
27
28
|
readonly snapshot: (name: string, value: unknown) => Promise<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Assert a boolean invariant from the spin body. Same semantics as the action
|
|
31
|
+
* `ctx.assert`; body assertions are attributed to the host action path
|
|
32
|
+
* (`omkit:Spin`).
|
|
33
|
+
*/
|
|
34
|
+
readonly assert: Assert;
|
|
28
35
|
}
|
|
29
36
|
/** The body of a {@link spin}: any (async) function — it isn't named or wrapped by you. */
|
|
30
37
|
export type SpinBody = (ctx: SpinContext) => void | Promise<void>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LogEntry } from "./LogsCollector.ts";
|
|
2
2
|
/** The special "title" log lines that get a distinguishing prefix. */
|
|
3
|
-
export type MarkerKind = "event" | "action" | "run" | "snapshot";
|
|
3
|
+
export type MarkerKind = "event" | "action" | "run" | "snapshot" | "assert";
|
|
4
4
|
/** A run set aside for collapsing: its source and every output line, in order. */
|
|
5
5
|
export interface CollapsedRun {
|
|
6
6
|
readonly source: string;
|
|
@@ -3,6 +3,7 @@ const PREFIX = {
|
|
|
3
3
|
action: "▸",
|
|
4
4
|
run: "●",
|
|
5
5
|
snapshot: "📎",
|
|
6
|
+
assert: "⊨",
|
|
6
7
|
};
|
|
7
8
|
/**
|
|
8
9
|
* Levels that render as their own top-level milestone line — marker-prefixed and
|
|
@@ -13,6 +14,7 @@ const MILESTONE = {
|
|
|
13
14
|
run: "run",
|
|
14
15
|
event: "event",
|
|
15
16
|
snapshot: "snapshot",
|
|
17
|
+
assert: "assert",
|
|
16
18
|
};
|
|
17
19
|
/** Only runs *longer than* this collapse, so a trigger always hides a meaningful amount. */
|
|
18
20
|
const COLLAPSE_THRESHOLD = 15;
|
|
@@ -37,10 +37,18 @@ export class RunLog {
|
|
|
37
37
|
const pretty = createWriteStream(this.folder.resolve("run.log"));
|
|
38
38
|
const renderer = new LogRenderer();
|
|
39
39
|
pretty.write(this.header() + "\n");
|
|
40
|
+
// The dedicated assertions file is a filtered projection of the same stream:
|
|
41
|
+
// opened lazily on the first `assert` entry (so runs with none produce no file),
|
|
42
|
+
// one `⊨ …` line each. Assertions still ride the main run.jsonl / run.log too.
|
|
43
|
+
let assertions;
|
|
40
44
|
try {
|
|
41
45
|
for await (const entry of entries) {
|
|
42
46
|
jsonl.write(JSON.stringify(entry) + "\n");
|
|
43
47
|
pretty.write(renderer.render(entry) + "\n");
|
|
48
|
+
if (entry.level === "assert") {
|
|
49
|
+
assertions ??= createWriteStream(this.folder.resolve("assertions.log"));
|
|
50
|
+
assertions.write(LogRenderer.marker("assert", entry.message) + "\n");
|
|
51
|
+
}
|
|
44
52
|
}
|
|
45
53
|
}
|
|
46
54
|
finally {
|
|
@@ -49,7 +57,11 @@ export class RunLog {
|
|
|
49
57
|
// this stream landed *after* write() truncated run.log, the OS would zero-fill the
|
|
50
58
|
// gap — a multi-MB run of NUL bytes. `run` awaits this promise before calling
|
|
51
59
|
// write(), so this writer is guaranteed done first (and run.jsonl is complete).
|
|
52
|
-
await Promise.all([
|
|
60
|
+
await Promise.all([
|
|
61
|
+
closeStream(jsonl),
|
|
62
|
+
closeStream(pretty),
|
|
63
|
+
...(assertions ? [closeStream(assertions)] : []),
|
|
64
|
+
]);
|
|
53
65
|
}
|
|
54
66
|
}
|
|
55
67
|
/**
|
|
@@ -112,7 +124,7 @@ export class RunLog {
|
|
|
112
124
|
const now = new Date();
|
|
113
125
|
return [
|
|
114
126
|
`─── ${this.folder.name()} · ${ymd(now)} ${hms(now)} ───`,
|
|
115
|
-
`● run ⚡ event ▸ action 📎 snapshot ⇥ output`,
|
|
127
|
+
`● run ⚡ event ▸ action 📎 snapshot ⇥ output ⊨ assert`,
|
|
116
128
|
"",
|
|
117
129
|
].join("\n");
|
|
118
130
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { Logger } from "./LogsCollector.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The path of the action whose async context is currently executing (e.g.
|
|
5
|
+
* "Build", "Dev › Server"). `SpinHost.launch` wraps each action's `start` in
|
|
6
|
+
* `actionScope.run(path, …)`, so any `console.*` call inside that action's
|
|
7
|
+
* (a)synchronous body reads its path here. No store → the call ran outside any
|
|
8
|
+
* action (the spin body, top-level, a third-party module init).
|
|
9
|
+
*/
|
|
10
|
+
export declare const actionScope: AsyncLocalStorage<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Patch `console.log/info/debug/warn/error` so each call appends into `logs`
|
|
13
|
+
* instead of writing to the terminal, attributing every line to the current
|
|
14
|
+
* action via {@link actionScope}. Returns a `restore()` that reinstalls the
|
|
15
|
+
* methods that were installed when this ran — so sequential runs nest cleanly
|
|
16
|
+
* (save-current / restore-saved).
|
|
17
|
+
*
|
|
18
|
+
* The patched methods do **not** echo to the terminal: their output reaches it
|
|
19
|
+
* through the run's log renderer (the caller keeps the original `console.log`
|
|
20
|
+
* for the renderer's own writes). Args are rendered with `util.format` (exact
|
|
21
|
+
* console semantics), split on newlines, and appended line-by-line; an empty
|
|
22
|
+
* render is skipped.
|
|
23
|
+
*/
|
|
24
|
+
export declare function captureConsole(logs: Logger): () => void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { format } from "node:util";
|
|
3
|
+
/**
|
|
4
|
+
* The path of the action whose async context is currently executing (e.g.
|
|
5
|
+
* "Build", "Dev › Server"). `SpinHost.launch` wraps each action's `start` in
|
|
6
|
+
* `actionScope.run(path, …)`, so any `console.*` call inside that action's
|
|
7
|
+
* (a)synchronous body reads its path here. No store → the call ran outside any
|
|
8
|
+
* action (the spin body, top-level, a third-party module init).
|
|
9
|
+
*/
|
|
10
|
+
export const actionScope = new AsyncLocalStorage();
|
|
11
|
+
/** The console methods we fold into the log, each mapped to its log level. */
|
|
12
|
+
const LEVELS = {
|
|
13
|
+
log: "info",
|
|
14
|
+
info: "info",
|
|
15
|
+
debug: "debug",
|
|
16
|
+
warn: "warn",
|
|
17
|
+
error: "error",
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Patch `console.log/info/debug/warn/error` so each call appends into `logs`
|
|
21
|
+
* instead of writing to the terminal, attributing every line to the current
|
|
22
|
+
* action via {@link actionScope}. Returns a `restore()` that reinstalls the
|
|
23
|
+
* methods that were installed when this ran — so sequential runs nest cleanly
|
|
24
|
+
* (save-current / restore-saved).
|
|
25
|
+
*
|
|
26
|
+
* The patched methods do **not** echo to the terminal: their output reaches it
|
|
27
|
+
* through the run's log renderer (the caller keeps the original `console.log`
|
|
28
|
+
* for the renderer's own writes). Args are rendered with `util.format` (exact
|
|
29
|
+
* console semantics), split on newlines, and appended line-by-line; an empty
|
|
30
|
+
* render is skipped.
|
|
31
|
+
*/
|
|
32
|
+
export function captureConsole(logs) {
|
|
33
|
+
const c = console;
|
|
34
|
+
const saved = {};
|
|
35
|
+
const methods = Object.keys(LEVELS);
|
|
36
|
+
for (const method of methods) {
|
|
37
|
+
saved[method] = c[method];
|
|
38
|
+
const level = LEVELS[method];
|
|
39
|
+
c[method] = (...args) => {
|
|
40
|
+
const path = actionScope.getStore();
|
|
41
|
+
const source = path ? `${path} › console` : "console";
|
|
42
|
+
const text = format(...args);
|
|
43
|
+
for (const line of text.split("\n")) {
|
|
44
|
+
if (line)
|
|
45
|
+
logs.append({ source, level, message: line });
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return () => {
|
|
50
|
+
for (const method of methods)
|
|
51
|
+
c[method] = saved[method];
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -111,7 +111,9 @@ export class FolderCache {
|
|
|
111
111
|
}
|
|
112
112
|
/** The on-disk filename for a set of targets — a hash of their absolute paths. */
|
|
113
113
|
static entryPath(paths) {
|
|
114
|
-
const key = createHash("sha1")
|
|
114
|
+
const key = createHash("sha1")
|
|
115
|
+
.update(FolderCache.resolvePaths(paths).sort().join("\0"))
|
|
116
|
+
.digest("hex");
|
|
115
117
|
return join(FolderCache.dir, `${key}.json`);
|
|
116
118
|
}
|
|
117
119
|
}
|
|
@@ -63,7 +63,11 @@ export const createProc = (logs, signal, snapshots) => (name, opts) => {
|
|
|
63
63
|
// spread sits before `cwd` so this resolved value wins even when opts omits
|
|
64
64
|
// it). This is the "where" that makes a launch line reproducible.
|
|
65
65
|
const cwd = opts?.cwd ?? process.cwd();
|
|
66
|
-
const snap = snapshots.captureJson(`proc-${name}-start`, {
|
|
66
|
+
const snap = snapshots.captureJson(`proc-${name}-start`, {
|
|
67
|
+
cmd: child.cmd,
|
|
68
|
+
...safeOpts,
|
|
69
|
+
cwd,
|
|
70
|
+
});
|
|
67
71
|
logs.append({
|
|
68
72
|
source: name,
|
|
69
73
|
level: "event",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omkit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|