@specific.dev/spectest 0.107.2 → 0.108.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/README.md +23 -0
- package/dist/annotate.d.ts +15 -14
- package/dist/annotate.js +12 -34
- package/dist/daemon.js +9 -146
- package/dist/helper-tracking.d.ts +7 -0
- package/dist/helper-tracking.js +142 -0
- package/dist/index.d.ts +18 -6
- package/dist/index.js +3 -7
- package/dist/recorder.d.ts +18 -1
- package/dist/recorder.js +67 -7
- package/package.json +1 -1
- package/src/annotate.ts +22 -45
- package/src/daemon.ts +8 -168
- package/src/helper-tracking.test.ts +289 -0
- package/src/helper-tracking.ts +162 -0
- package/src/index.ts +28 -14
- package/src/recorder.ts +91 -7
package/dist/recorder.js
CHANGED
|
@@ -7,12 +7,18 @@
|
|
|
7
7
|
// When no test is running (production code paths, eval, etc.) the
|
|
8
8
|
// current recorder is `null` and the `record*` helpers are no-ops, so
|
|
9
9
|
// callers can invoke them unconditionally.
|
|
10
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
11
|
import { clearPendingNullish } from "./inspect.js";
|
|
11
12
|
import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
|
|
12
13
|
const OUTPUT_SNIPPET_BYTES = 256 * 1024;
|
|
13
14
|
class Recorder {
|
|
14
15
|
start;
|
|
15
|
-
|
|
16
|
+
rootEvents = [];
|
|
17
|
+
pendingHelpers = new Set();
|
|
18
|
+
get events() {
|
|
19
|
+
const branch = recordingBranches.getStore();
|
|
20
|
+
return branch?.owner === this ? branchEvents(branch) : this.rootEvents;
|
|
21
|
+
}
|
|
16
22
|
seq = 0;
|
|
17
23
|
constructor(start = Date.now()) {
|
|
18
24
|
this.start = start;
|
|
@@ -106,6 +112,48 @@ class Recorder {
|
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
let current = null;
|
|
115
|
+
const recordingBranches = new AsyncLocalStorage();
|
|
116
|
+
function branchEvents(branch) {
|
|
117
|
+
return branch.state === "kept"
|
|
118
|
+
? branch.parent ? branchEvents(branch.parent) : branch.destination
|
|
119
|
+
: branch.events;
|
|
120
|
+
}
|
|
121
|
+
function branchAllowsRecording(branch) {
|
|
122
|
+
return !branch || (branch.owner === current && branch.state !== "discarded" &&
|
|
123
|
+
branchAllowsRecording(branch.parent));
|
|
124
|
+
}
|
|
125
|
+
/** Buffer a helper's async descendants until its return chooses between the
|
|
126
|
+
* implementation's events and one annotated helper call. Seqs still come from
|
|
127
|
+
* the test recorder, so concurrent calls and linked assertions stay unique.
|
|
128
|
+
* Poll truncation/grouping inside the helper applies only to its own buffer. */
|
|
129
|
+
export function captureRecording() {
|
|
130
|
+
if (!active())
|
|
131
|
+
return { run: (fn) => fn(), finish: () => { }, isCurrent: () => false };
|
|
132
|
+
const branch = {
|
|
133
|
+
owner: current, events: [], parent: recordingBranches.getStore(),
|
|
134
|
+
destination: current.drain(), state: "open",
|
|
135
|
+
};
|
|
136
|
+
current.pendingHelpers.add(branch);
|
|
137
|
+
return {
|
|
138
|
+
run: (fn) => recordingBranches.run(branch, fn),
|
|
139
|
+
isCurrent: () => branch.owner === current && active(),
|
|
140
|
+
finish(keep) {
|
|
141
|
+
if (branch.state !== "open")
|
|
142
|
+
return;
|
|
143
|
+
branch.owner.pendingHelpers.delete(branch);
|
|
144
|
+
if (keep && branchAllowsRecording(branch)) {
|
|
145
|
+
const destination = branch.parent ? branchEvents(branch.parent) : branch.destination;
|
|
146
|
+
for (const event of branch.events)
|
|
147
|
+
destination.push(event);
|
|
148
|
+
branch.state = "kept";
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
branch.state = "discarded";
|
|
152
|
+
}
|
|
153
|
+
branch.events.length = 0;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
109
157
|
// Counter-based pause: `pauseRecording`/`resumeRecording` nest safely
|
|
110
158
|
// (a nested `ctx.poll` inside another `ctx.poll`'s predicate stays
|
|
111
159
|
// suppressed until its own resume balances out). Active record* sites
|
|
@@ -121,7 +169,7 @@ export function resumeRecording() {
|
|
|
121
169
|
paused = Math.max(0, paused - 1);
|
|
122
170
|
}
|
|
123
171
|
function active() {
|
|
124
|
-
return current !== null && paused === 0 && scopeAllowsRecording();
|
|
172
|
+
return current !== null && paused === 0 && scopeAllowsRecording() && branchAllowsRecording(recordingBranches.getStore());
|
|
125
173
|
}
|
|
126
174
|
export function startRecording(startedAt = Date.now()) {
|
|
127
175
|
current = new Recorder(startedAt);
|
|
@@ -131,6 +179,19 @@ export function stopRecording() {
|
|
|
131
179
|
if (!current)
|
|
132
180
|
return [];
|
|
133
181
|
const evs = current.drain();
|
|
182
|
+
// A timeout can stop the test before a helper returns. Keep its available
|
|
183
|
+
// diagnostics, then fence detached work out of any subsequent recording.
|
|
184
|
+
for (const branch of current.pendingHelpers) {
|
|
185
|
+
if (branchAllowsRecording(branch)) {
|
|
186
|
+
for (const event of branch.events)
|
|
187
|
+
evs.push(event);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
for (const branch of current.pendingHelpers) {
|
|
191
|
+
branch.events.length = 0;
|
|
192
|
+
branch.state = "discarded";
|
|
193
|
+
}
|
|
194
|
+
current.pendingHelpers.clear();
|
|
134
195
|
current = null;
|
|
135
196
|
paused = 0;
|
|
136
197
|
return evs;
|
|
@@ -189,6 +250,9 @@ export function recordTerminalStep(ev, reservation) {
|
|
|
189
250
|
export function recordFake(ev, reservation) {
|
|
190
251
|
return active() ? current.push({ kind: "fake", ...ev }, reservation) : undefined;
|
|
191
252
|
}
|
|
253
|
+
export function recordServiceHelper(ev, reservation) {
|
|
254
|
+
return active() ? current.push({ kind: "service", ...ev }, reservation) : undefined;
|
|
255
|
+
}
|
|
192
256
|
export function recordEnv(ev, reservation) {
|
|
193
257
|
return active() ? current.push({ kind: "env", ...ev }, reservation) : undefined;
|
|
194
258
|
}
|
|
@@ -199,11 +263,7 @@ export function recordEmail(ev, reservation) {
|
|
|
199
263
|
return active() ? current.push({ kind: "email", ...ev }, reservation) : undefined;
|
|
200
264
|
}
|
|
201
265
|
export function recordWait(ev, reservation) {
|
|
202
|
-
|
|
203
|
-
// end of a `ctx.poll` block lands even though the surrounding code
|
|
204
|
-
// just resumed from `paused`. Pushing this event is the whole point
|
|
205
|
-
// of the poll primitive.
|
|
206
|
-
return current?.push({ kind: "wait", ...ev }, reservation);
|
|
266
|
+
return active() ? current.push({ kind: "wait", ...ev }, reservation) : undefined;
|
|
207
267
|
}
|
|
208
268
|
/** Number of events the active recorder has accumulated. Returns 0
|
|
209
269
|
* when no recorder is active. */
|
package/package.json
CHANGED
package/src/annotate.ts
CHANGED
|
@@ -1,34 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// The call is still recorded as the `fake` step it is; the annotation rides
|
|
9
|
-
// *under* it as a child event (`parentSeq`, marked `annotation`) of the kind
|
|
10
|
-
// that already knows how to draw this — for `"email"`, the very event the
|
|
11
|
-
// built-in `email()` component's mailbox helpers record — so the dashboard
|
|
12
|
-
// renders it mail-client style (header block + sandboxed HTML body) with no
|
|
13
|
-
// new rendering code. It leads the step's detail panel; the raw JSON value
|
|
14
|
-
// is a tab away.
|
|
15
|
-
//
|
|
16
|
-
// One function, keyed by kind: the kind is a key of `AnnotationOptions`, so
|
|
17
|
-
// the options argument is typed against the kind that was named, and a new
|
|
18
|
-
// kind is an entry in that interface (+ a case in `lower`, + an arm in
|
|
19
|
-
// `daemon.ts::recordAnnotatedCall`) rather than a new export.
|
|
20
|
-
//
|
|
21
|
-
// The test is unaffected. `annotate` returns a box that the daemon opens at
|
|
22
|
-
// the helper boundary (`daemon.ts::invokeFakeHelper`), so the caller gets
|
|
23
|
-
// the raw value back, inspect-wrapped as always, and the *type* it sees is
|
|
24
|
-
// still the raw value's (`WrappedHelpers` in index.ts unwraps `Annotated`).
|
|
25
|
-
// Assertions keep linking to the fake step, exactly as for an unannotated
|
|
26
|
-
// helper. The annotation is a rendering hint and nothing else.
|
|
27
|
-
//
|
|
28
|
-
// This is a fake-helper mechanism: those calls go through a tracking proxy
|
|
29
|
-
// that can open the box. A *service* helper (`ctx.svc.<name>.…`) is the raw
|
|
30
|
-
// record its factory returned, so an annotation there would reach the test
|
|
31
|
-
// as an opaque value — don't.
|
|
1
|
+
// A helper returns an annotation box to choose a richer view of its result.
|
|
2
|
+
// Both fake and service helper boundaries open it, record one named helper
|
|
3
|
+
// operation with an annotation child, and wrap the value for linked assertions.
|
|
4
|
+
// annotate() itself does not record or attach anything to an existing operation.
|
|
5
|
+
// Fake helpers always hide their implementation; service helpers opt into that
|
|
6
|
+
// behavior by returning this box. Ordinary service returns keep their internal
|
|
7
|
+
// recorded operations and their existing types.
|
|
32
8
|
|
|
33
9
|
import { deepUnwrap } from "./inspect.js";
|
|
34
10
|
import {
|
|
@@ -50,15 +26,14 @@ import {
|
|
|
50
26
|
const ANNOTATION = Symbol.for("spectest.render-annotation");
|
|
51
27
|
|
|
52
28
|
/** Phantom brand — `Annotated<T>` is nominally distinct from `T`, so the
|
|
53
|
-
* box can't be read as the value by accident inside the
|
|
29
|
+
* box can't be read as the value by accident inside the helper, and the
|
|
54
30
|
* helper types can recognise it and hand the *test* back a plain `T`. */
|
|
55
31
|
declare const ANNOTATED: unique symbol;
|
|
56
32
|
|
|
57
33
|
/**
|
|
58
34
|
* A value plus a rendering hint, as returned by {@link annotate}. Return it
|
|
59
|
-
* straight from a fake helper: the
|
|
60
|
-
* the value
|
|
61
|
-
* annotation weren't there.
|
|
35
|
+
* straight from a fake or service helper: the boundary opens it and the test
|
|
36
|
+
* receives the value wrapped for assertions. The box never reaches the test.
|
|
62
37
|
*/
|
|
63
38
|
export interface Annotated<T> {
|
|
64
39
|
readonly [ANNOTATED]: T;
|
|
@@ -71,7 +46,7 @@ export interface Annotated<T> {
|
|
|
71
46
|
* another kind is a compile error at the call site.
|
|
72
47
|
*
|
|
73
48
|
* A new kind is an entry here, a case in `lower`, and an arm in
|
|
74
|
-
* `
|
|
49
|
+
* `helper-tracking.ts::recordAnnotationChild`.
|
|
75
50
|
*/
|
|
76
51
|
export interface AnnotationOptions {
|
|
77
52
|
/** Draw the value as an email — one message, or a list of them for a
|
|
@@ -87,7 +62,7 @@ export type AnnotationKind = keyof AnnotationOptions;
|
|
|
87
62
|
|
|
88
63
|
/**
|
|
89
64
|
* What an annotation lowers to: the fields of the child event the daemon
|
|
90
|
-
* records under the call's own
|
|
65
|
+
* records under the helper call's own event. Tagged by that event's `kind` —
|
|
91
66
|
* which needn't be the annotation kind's name, since an annotation names
|
|
92
67
|
* what the value *is* and the event names how it's drawn.
|
|
93
68
|
*/
|
|
@@ -113,13 +88,13 @@ type ChatRenderAnnotation = {
|
|
|
113
88
|
};
|
|
114
89
|
|
|
115
90
|
/**
|
|
116
|
-
* The email fields to render. Every one is optional — pass what the
|
|
117
|
-
*
|
|
91
|
+
* The email fields to render. Every one is optional — pass what the helper
|
|
92
|
+
* observed. Addresses take a single string or a list.
|
|
118
93
|
*
|
|
119
94
|
* The event can also carry a date, per-row previews and attachment
|
|
120
|
-
* metadata, which the built-in `email()` component fills from a
|
|
121
|
-
*
|
|
122
|
-
*
|
|
95
|
+
* metadata, which the built-in `email()` component fills from a captured
|
|
96
|
+
* message. Annotations currently cover the header/body fields below. A
|
|
97
|
+
* listing row's preview is derived from the body.
|
|
123
98
|
*/
|
|
124
99
|
export interface EmailAnnotation {
|
|
125
100
|
from?: string;
|
|
@@ -248,10 +223,12 @@ const NO_FIELDS =
|
|
|
248
223
|
"html: value.body }).";
|
|
249
224
|
|
|
250
225
|
/**
|
|
251
|
-
* Say what a
|
|
226
|
+
* Say what a helper's return value *is*, so the timeline can draw it
|
|
252
227
|
* as that on top of the JSON it always shows. The call stays an ordinary
|
|
253
|
-
*
|
|
254
|
-
* the
|
|
228
|
+
* helper step; the rendered view nests inside it. Return this box from the
|
|
229
|
+
* helper: the caller receives the value wrapped for assertions. An annotated
|
|
230
|
+
* service return suppresses the helper's internal events; an ordinary service
|
|
231
|
+
* return keeps them. Calling annotate without returning the box has no effect.
|
|
255
232
|
*
|
|
256
233
|
* ```ts
|
|
257
234
|
* helpers: ({ state }) => ({
|
package/src/daemon.ts
CHANGED
|
@@ -196,13 +196,11 @@ import { openTerminal } from "./terminal.js";
|
|
|
196
196
|
// `ctx.mcp(url)`. One client per call, no registry: an authenticated
|
|
197
197
|
// client is passed to descendants as a test's return value.
|
|
198
198
|
import { openMcp } from "./mcp.js";
|
|
199
|
-
import {
|
|
199
|
+
import { trackHelpers } from "./helper-tracking.js";
|
|
200
200
|
import {
|
|
201
201
|
isRecording,
|
|
202
|
-
recordEmail,
|
|
203
202
|
recordEnv,
|
|
204
203
|
recordExec,
|
|
205
|
-
recordFake,
|
|
206
204
|
recordStep,
|
|
207
205
|
recordTerminal,
|
|
208
206
|
recordWait,
|
|
@@ -216,7 +214,7 @@ import {
|
|
|
216
214
|
type StepBlock,
|
|
217
215
|
type TestEvent,
|
|
218
216
|
} from "./recorder.js";
|
|
219
|
-
import {
|
|
217
|
+
import { readRaw, wrap } from "./inspect.js";
|
|
220
218
|
import type { Wrapped } from "./inspect.js";
|
|
221
219
|
import { setRecordSecrets } from "./record-secrets.js";
|
|
222
220
|
import { generateId } from "./ids.js";
|
|
@@ -3141,7 +3139,7 @@ const FAKE_CTX: FakeContext = {
|
|
|
3141
3139
|
/** Build (or fetch from cache) the helpers record for a fake — the
|
|
3142
3140
|
* value that ends up at `ctx.fakes.<name>`. Defaults to `{}` (a fake
|
|
3143
3141
|
* with no `helpers` exposes nothing — tests never touch private state
|
|
3144
|
-
* directly). Returns a tracking proxy (see `
|
|
3142
|
+
* directly). Returns a tracking proxy (see `trackHelpers`) so helper
|
|
3145
3143
|
* calls land in the test timeline. */
|
|
3146
3144
|
async function ensureFakeHelpers(name: string): Promise<Record<string, unknown>> {
|
|
3147
3145
|
const fake = FAKES.get(name);
|
|
@@ -3154,168 +3152,10 @@ async function ensureFakeHelpers(name: string): Promise<Record<string, unknown>>
|
|
|
3154
3152
|
ctx: FAKE_CTX,
|
|
3155
3153
|
}))) as Record<string, unknown>)
|
|
3156
3154
|
: {};
|
|
3157
|
-
fake.trackedHelpers =
|
|
3155
|
+
fake.trackedHelpers = trackHelpers("fake", name, fake.helpers);
|
|
3158
3156
|
return fake.trackedHelpers;
|
|
3159
3157
|
}
|
|
3160
3158
|
|
|
3161
|
-
/** Wrap a fake's helpers so each call becomes a recorded `fake` event,
|
|
3162
|
-
* its arguments are `deepUnwrap`ped, and its return value is `wrap()`ped
|
|
3163
|
-
* for assertion provenance. Helpers
|
|
3164
|
-
* are functions that read/mutate the fake's private state via closure;
|
|
3165
|
-
* tests only ever see what those functions return. The proxy is built
|
|
3166
|
-
* once and shared across tests; it consults the recorder at call time,
|
|
3167
|
-
* so it's a transparent no-op when nothing is recording (eval / project
|
|
3168
|
-
* setup).
|
|
3169
|
-
*
|
|
3170
|
-
* Only own function properties are intercepted — inherited members
|
|
3171
|
-
* (`toString`, etc.), symbols, and any stray non-function property pass
|
|
3172
|
-
* straight through untouched. */
|
|
3173
|
-
function trackFakeHelpers(
|
|
3174
|
-
fakeName: string,
|
|
3175
|
-
helpers: Record<string, unknown>,
|
|
3176
|
-
): Record<string, unknown> {
|
|
3177
|
-
return new Proxy(helpers, {
|
|
3178
|
-
get(target, prop, receiver) {
|
|
3179
|
-
if (typeof prop === "symbol") return Reflect.get(target, prop, receiver);
|
|
3180
|
-
const desc = Object.getOwnPropertyDescriptor(target, prop);
|
|
3181
|
-
if (!desc || typeof desc.value !== "function") {
|
|
3182
|
-
return Reflect.get(target, prop, receiver);
|
|
3183
|
-
}
|
|
3184
|
-
const fn = desc.value as (...a: unknown[]) => unknown;
|
|
3185
|
-
const member = String(prop);
|
|
3186
|
-
return (...args: unknown[]) =>
|
|
3187
|
-
invokeFakeHelper(fakeName, member, fn, target, args);
|
|
3188
|
-
},
|
|
3189
|
-
});
|
|
3190
|
-
}
|
|
3191
|
-
|
|
3192
|
-
/** Invoke a fake helper function, recording a `fake` event and wrapping
|
|
3193
|
-
* the return value. Handles both sync and async helpers, and records an
|
|
3194
|
-
* error event (then rethrows) if the helper throws.
|
|
3195
|
-
*
|
|
3196
|
-
* Arguments are `deepUnwrap`ped on the way in, so a helper body is plain
|
|
3197
|
-
* user code operating on plain values: a test that feeds one helper's
|
|
3198
|
-
* return (or a `ctx.fetch` field) to another passes a provenance wrapper,
|
|
3199
|
-
* and `msg.html.matchAll(...)` inside the fake would otherwise throw a
|
|
3200
|
-
* `TypeError` from a stack the author can't see. Same treatment the k8s
|
|
3201
|
-
* client's arguments get (`components/k3s.ts::withTagging`). The types
|
|
3202
|
-
* still say the value is a handle — passing a wrapped value where the
|
|
3203
|
-
* parameter type is known is a compile error asking for `.unwrap()`; this
|
|
3204
|
-
* catches the case where it isn't known (an `any` off `req.json()`). */
|
|
3205
|
-
function invokeFakeHelper(
|
|
3206
|
-
fakeName: string,
|
|
3207
|
-
member: string,
|
|
3208
|
-
fn: (...a: unknown[]) => unknown,
|
|
3209
|
-
thisArg: unknown,
|
|
3210
|
-
callArgs: unknown[],
|
|
3211
|
-
): unknown {
|
|
3212
|
-
const t = Date.now();
|
|
3213
|
-
const resv = reserveEvent();
|
|
3214
|
-
const args = callArgs.map((a) => deepUnwrap(a));
|
|
3215
|
-
const safeArgs = args.map((a) => safeSerialize(a));
|
|
3216
|
-
const recordResult = (value: unknown): unknown => {
|
|
3217
|
-
// A helper may box its return in a render annotation
|
|
3218
|
-
// (`annotate(v, "email", …)`). The box never reaches the test, and it
|
|
3219
|
-
// never replaces the step either: the call is recorded as the ordinary
|
|
3220
|
-
// `fake` event it is, and the annotation rides *under* it as a child
|
|
3221
|
-
// event (`parentSeq`), which the dashboard folds into the fake step's
|
|
3222
|
-
// detail panel. So the timeline reads the same as any other helper
|
|
3223
|
-
// call, with the richer view one click in.
|
|
3224
|
-
const annotated = readAnnotation(value);
|
|
3225
|
-
const raw = annotated ? annotated.value : value;
|
|
3226
|
-
const durationMs = Date.now() - t;
|
|
3227
|
-
const seq = recordFake({
|
|
3228
|
-
fake: fakeName,
|
|
3229
|
-
member,
|
|
3230
|
-
args: safeArgs,
|
|
3231
|
-
result: safeSerialize(raw),
|
|
3232
|
-
durationMs,
|
|
3233
|
-
}, resv);
|
|
3234
|
-
if (annotated && seq !== undefined) {
|
|
3235
|
-
recordAnnotationChild(fakeName, member, annotated.annotation, durationMs, seq);
|
|
3236
|
-
}
|
|
3237
|
-
return wrap(raw, seq);
|
|
3238
|
-
};
|
|
3239
|
-
const recordError = (err: unknown): void => {
|
|
3240
|
-
recordFake({
|
|
3241
|
-
fake: fakeName,
|
|
3242
|
-
member,
|
|
3243
|
-
args: safeArgs,
|
|
3244
|
-
durationMs: Date.now() - t,
|
|
3245
|
-
error: errMessage(err),
|
|
3246
|
-
}, resv);
|
|
3247
|
-
};
|
|
3248
|
-
|
|
3249
|
-
// Record the public helper call in the caller's scope, while its body
|
|
3250
|
-
// and async descendants use native fetch and emit no internal events.
|
|
3251
|
-
let result: unknown;
|
|
3252
|
-
try {
|
|
3253
|
-
result = runUninstrumented(() => fn.apply(thisArg, args));
|
|
3254
|
-
} catch (err) {
|
|
3255
|
-
recordError(err);
|
|
3256
|
-
throw err;
|
|
3257
|
-
}
|
|
3258
|
-
if (result instanceof Promise) {
|
|
3259
|
-
return result.then(
|
|
3260
|
-
recordResult,
|
|
3261
|
-
(err) => {
|
|
3262
|
-
recordError(err);
|
|
3263
|
-
throw err;
|
|
3264
|
-
},
|
|
3265
|
-
);
|
|
3266
|
-
}
|
|
3267
|
-
return recordResult(result);
|
|
3268
|
-
}
|
|
3269
|
-
|
|
3270
|
-
/** Record a fake-helper call's render annotation as a child of the call's
|
|
3271
|
-
* own `fake` event — the same `parentSeq` grouping `ctx.poll` uses for the
|
|
3272
|
-
* iteration it kept, so the annotated view folds into the fake step's
|
|
3273
|
-
* detail panel instead of taking a timeline row of its own.
|
|
3274
|
-
*
|
|
3275
|
-
* The child is the event kind that already knows how to draw this: an
|
|
3276
|
-
* `email` annotation records the very event the built-in `email()`
|
|
3277
|
-
* component's mailbox helpers record, so it renders with no new code. It
|
|
3278
|
-
* carries the fake's name and member as its service/op, and the parent's
|
|
3279
|
-
* duration, since it describes that same call.
|
|
3280
|
-
*
|
|
3281
|
-
* Only the success path is annotated: a helper that threw returned no value
|
|
3282
|
-
* to annotate, so it's a plain `fake` error event. */
|
|
3283
|
-
function recordAnnotationChild(
|
|
3284
|
-
fakeName: string,
|
|
3285
|
-
member: string,
|
|
3286
|
-
annotation: RenderAnnotation,
|
|
3287
|
-
durationMs: number,
|
|
3288
|
-
parentSeq: number,
|
|
3289
|
-
): void {
|
|
3290
|
-
switch (annotation.kind) {
|
|
3291
|
-
case "email":
|
|
3292
|
-
recordEmail({
|
|
3293
|
-
parentSeq,
|
|
3294
|
-
annotation: true,
|
|
3295
|
-
service: fakeName,
|
|
3296
|
-
op: member,
|
|
3297
|
-
message: annotation.message,
|
|
3298
|
-
messages: annotation.messages,
|
|
3299
|
-
count: annotation.count,
|
|
3300
|
-
durationMs,
|
|
3301
|
-
});
|
|
3302
|
-
break;
|
|
3303
|
-
case "chat":
|
|
3304
|
-
// A chat has no event kind of its own: it describes itself in
|
|
3305
|
-
// presentation terms (title + blocks), which is the seam a new step
|
|
3306
|
-
// type is supposed to use. `kind` stays an opaque grouping label.
|
|
3307
|
-
recordStep({
|
|
3308
|
-
kind: "chat",
|
|
3309
|
-
parentSeq,
|
|
3310
|
-
annotation: true,
|
|
3311
|
-
title: annotation.title ?? `${fakeName}.${member}`,
|
|
3312
|
-
blocks: annotation.blocks,
|
|
3313
|
-
durationMs,
|
|
3314
|
-
});
|
|
3315
|
-
break;
|
|
3316
|
-
}
|
|
3317
|
-
}
|
|
3318
|
-
|
|
3319
3159
|
function errMessage(err: unknown): string {
|
|
3320
3160
|
return (err as Error)?.message ?? String(err);
|
|
3321
3161
|
}
|
|
@@ -4805,10 +4645,10 @@ async function ensureHelpers(
|
|
|
4805
4645
|
try {
|
|
4806
4646
|
HELPERS_CACHE.set(
|
|
4807
4647
|
name,
|
|
4808
|
-
await def.helpers({
|
|
4648
|
+
trackHelpers("service", name, await def.helpers({
|
|
4809
4649
|
name,
|
|
4810
4650
|
...(await spectestContext({ service: name, includeSelf: false })),
|
|
4811
|
-
}),
|
|
4651
|
+
})),
|
|
4812
4652
|
);
|
|
4813
4653
|
} finally {
|
|
4814
4654
|
HELPERS_BUILDING.delete(name);
|
|
@@ -4822,8 +4662,8 @@ const HELPERS_BUILDING = new Set<string>();
|
|
|
4822
4662
|
|
|
4823
4663
|
/**
|
|
4824
4664
|
* Build the `svc` map for one test/eval. The value at `svc[name]` is
|
|
4825
|
-
*
|
|
4826
|
-
*
|
|
4665
|
+
* a shallow helper proxy: annotated returns become named service steps;
|
|
4666
|
+
* ordinary returns and live client handles pass through. Services without a
|
|
4827
4667
|
* `helpers` factory don't appear in the map at all.
|
|
4828
4668
|
*/
|
|
4829
4669
|
async function buildServiceHandles(cfg: EnvironmentConfig): Promise<ServiceHandles> {
|