@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/README.md
CHANGED
|
@@ -273,3 +273,26 @@ origin allowlists and cookie relationships. Different subdomains are still
|
|
|
273
273
|
different origins; a synthetic response must still supply the intended CORS
|
|
274
274
|
headers. Permissive mode is a convenience override and should not be used to
|
|
275
275
|
verify application CORS restrictions.
|
|
276
|
+
|
|
277
|
+
## Annotated service helpers (0.108.0)
|
|
278
|
+
|
|
279
|
+
Service helpers support the same `return annotate(value, "email" | "chat", options?)`
|
|
280
|
+
contract as fake helpers. The SDK opens the annotation box, records one named
|
|
281
|
+
service call, and returns a wrapped value whose assertions link to that call.
|
|
282
|
+
The annotated call's internal operations are suppressed; concurrent work is
|
|
283
|
+
unaffected. Ordinary helper returns retain their values and underlying events.
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
helpers: ({ fetch }) => ({
|
|
287
|
+
async latest() {
|
|
288
|
+
const response = await fetch("http://mail:8080/messages/latest");
|
|
289
|
+
const mail = (await response.json<{ subject: string; text: string }>()).unwrap();
|
|
290
|
+
return annotate(mail, "email");
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The classic viewer shows `mail.latest()` with Email/JSON tabs; JSON contains
|
|
296
|
+
helper arguments and the returned value. The SPA retains its nested presentation.
|
|
297
|
+
`annotate` always returns a box for the helper boundary; it does not attach views
|
|
298
|
+
to arbitrary recorded results. See `spectest docs /services` for a complete example.
|
package/dist/annotate.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { type EmailEventMessage, type EmailEventSummary, type StepBlock } from "./recorder.js";
|
|
2
2
|
/** Phantom brand — `Annotated<T>` is nominally distinct from `T`, so the
|
|
3
|
-
* box can't be read as the value by accident inside the
|
|
3
|
+
* box can't be read as the value by accident inside the helper, and the
|
|
4
4
|
* helper types can recognise it and hand the *test* back a plain `T`. */
|
|
5
5
|
declare const ANNOTATED: unique symbol;
|
|
6
6
|
/**
|
|
7
7
|
* A value plus a rendering hint, as returned by {@link annotate}. Return it
|
|
8
|
-
* straight from a fake helper: the
|
|
9
|
-
* the value
|
|
10
|
-
* annotation weren't there.
|
|
8
|
+
* straight from a fake or service helper: the boundary opens it and the test
|
|
9
|
+
* receives the value wrapped for assertions. The box never reaches the test.
|
|
11
10
|
*/
|
|
12
11
|
export interface Annotated<T> {
|
|
13
12
|
readonly [ANNOTATED]: T;
|
|
@@ -19,7 +18,7 @@ export interface Annotated<T> {
|
|
|
19
18
|
* another kind is a compile error at the call site.
|
|
20
19
|
*
|
|
21
20
|
* A new kind is an entry here, a case in `lower`, and an arm in
|
|
22
|
-
* `
|
|
21
|
+
* `helper-tracking.ts::recordAnnotationChild`.
|
|
23
22
|
*/
|
|
24
23
|
export interface AnnotationOptions {
|
|
25
24
|
/** Draw the value as an email — one message, or a list of them for a
|
|
@@ -33,7 +32,7 @@ export interface AnnotationOptions {
|
|
|
33
32
|
export type AnnotationKind = keyof AnnotationOptions;
|
|
34
33
|
/**
|
|
35
34
|
* What an annotation lowers to: the fields of the child event the daemon
|
|
36
|
-
* records under the call's own
|
|
35
|
+
* records under the helper call's own event. Tagged by that event's `kind` —
|
|
37
36
|
* which needn't be the annotation kind's name, since an annotation names
|
|
38
37
|
* what the value *is* and the event names how it's drawn.
|
|
39
38
|
*/
|
|
@@ -56,13 +55,13 @@ type ChatRenderAnnotation = {
|
|
|
56
55
|
blocks: StepBlock[];
|
|
57
56
|
};
|
|
58
57
|
/**
|
|
59
|
-
* The email fields to render. Every one is optional — pass what the
|
|
60
|
-
*
|
|
58
|
+
* The email fields to render. Every one is optional — pass what the helper
|
|
59
|
+
* observed. Addresses take a single string or a list.
|
|
61
60
|
*
|
|
62
61
|
* The event can also carry a date, per-row previews and attachment
|
|
63
|
-
* metadata, which the built-in `email()` component fills from a
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* metadata, which the built-in `email()` component fills from a captured
|
|
63
|
+
* message. Annotations currently cover the header/body fields below. A
|
|
64
|
+
* listing row's preview is derived from the body.
|
|
66
65
|
*/
|
|
67
66
|
export interface EmailAnnotation {
|
|
68
67
|
from?: string;
|
|
@@ -175,10 +174,12 @@ export interface ChatReactionAnnotation {
|
|
|
175
174
|
new?: boolean;
|
|
176
175
|
}
|
|
177
176
|
/**
|
|
178
|
-
* Say what a
|
|
177
|
+
* Say what a helper's return value *is*, so the timeline can draw it
|
|
179
178
|
* as that on top of the JSON it always shows. The call stays an ordinary
|
|
180
|
-
*
|
|
181
|
-
* the
|
|
179
|
+
* helper step; the rendered view nests inside it. Return this box from the
|
|
180
|
+
* helper: the caller receives the value wrapped for assertions. An annotated
|
|
181
|
+
* service return suppresses the helper's internal events; an ordinary service
|
|
182
|
+
* return keeps them. Calling annotate without returning the box has no effect.
|
|
182
183
|
*
|
|
183
184
|
* ```ts
|
|
184
185
|
* helpers: ({ state }) => ({
|
package/dist/annotate.js
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
|
import { deepUnwrap } from "./inspect.js";
|
|
33
9
|
import { truncateUtf8, } from "./recorder.js";
|
|
34
10
|
/**
|
|
@@ -48,10 +24,12 @@ const NO_FIELDS = 'annotate(value, "email"): nothing to render — no email fiel
|
|
|
48
24
|
'annotate(value, "email", { to: value.recipient, subject: value.title, ' +
|
|
49
25
|
"html: value.body }).";
|
|
50
26
|
/**
|
|
51
|
-
* Say what a
|
|
27
|
+
* Say what a helper's return value *is*, so the timeline can draw it
|
|
52
28
|
* as that on top of the JSON it always shows. The call stays an ordinary
|
|
53
|
-
*
|
|
54
|
-
* the
|
|
29
|
+
* helper step; the rendered view nests inside it. Return this box from the
|
|
30
|
+
* helper: the caller receives the value wrapped for assertions. An annotated
|
|
31
|
+
* service return suppresses the helper's internal events; an ordinary service
|
|
32
|
+
* return keeps them. Calling annotate without returning the box has no effect.
|
|
55
33
|
*
|
|
56
34
|
* ```ts
|
|
57
35
|
* helpers: ({ state }) => ({
|
package/dist/daemon.js
CHANGED
|
@@ -73,9 +73,9 @@ import { openTerminal } from "./terminal.js";
|
|
|
73
73
|
// `ctx.mcp(url)`. One client per call, no registry: an authenticated
|
|
74
74
|
// client is passed to descendants as a test's return value.
|
|
75
75
|
import { openMcp } from "./mcp.js";
|
|
76
|
-
import {
|
|
77
|
-
import { isRecording,
|
|
78
|
-
import {
|
|
76
|
+
import { trackHelpers } from "./helper-tracking.js";
|
|
77
|
+
import { isRecording, recordEnv, recordExec, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
|
|
78
|
+
import { readRaw, wrap } from "./inspect.js";
|
|
79
79
|
import { setRecordSecrets } from "./record-secrets.js";
|
|
80
80
|
import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
|
|
81
81
|
function namedServices(cfg) {
|
|
@@ -2623,7 +2623,7 @@ const FAKE_CTX = {
|
|
|
2623
2623
|
/** Build (or fetch from cache) the helpers record for a fake — the
|
|
2624
2624
|
* value that ends up at `ctx.fakes.<name>`. Defaults to `{}` (a fake
|
|
2625
2625
|
* with no `helpers` exposes nothing — tests never touch private state
|
|
2626
|
-
* directly). Returns a tracking proxy (see `
|
|
2626
|
+
* directly). Returns a tracking proxy (see `trackHelpers`) so helper
|
|
2627
2627
|
* calls land in the test timeline. */
|
|
2628
2628
|
async function ensureFakeHelpers(name) {
|
|
2629
2629
|
const fake = FAKES.get(name);
|
|
@@ -2638,146 +2638,9 @@ async function ensureFakeHelpers(name) {
|
|
|
2638
2638
|
ctx: FAKE_CTX,
|
|
2639
2639
|
})))
|
|
2640
2640
|
: {};
|
|
2641
|
-
fake.trackedHelpers =
|
|
2641
|
+
fake.trackedHelpers = trackHelpers("fake", name, fake.helpers);
|
|
2642
2642
|
return fake.trackedHelpers;
|
|
2643
2643
|
}
|
|
2644
|
-
/** Wrap a fake's helpers so each call becomes a recorded `fake` event,
|
|
2645
|
-
* its arguments are `deepUnwrap`ped, and its return value is `wrap()`ped
|
|
2646
|
-
* for assertion provenance. Helpers
|
|
2647
|
-
* are functions that read/mutate the fake's private state via closure;
|
|
2648
|
-
* tests only ever see what those functions return. The proxy is built
|
|
2649
|
-
* once and shared across tests; it consults the recorder at call time,
|
|
2650
|
-
* so it's a transparent no-op when nothing is recording (eval / project
|
|
2651
|
-
* setup).
|
|
2652
|
-
*
|
|
2653
|
-
* Only own function properties are intercepted — inherited members
|
|
2654
|
-
* (`toString`, etc.), symbols, and any stray non-function property pass
|
|
2655
|
-
* straight through untouched. */
|
|
2656
|
-
function trackFakeHelpers(fakeName, helpers) {
|
|
2657
|
-
return new Proxy(helpers, {
|
|
2658
|
-
get(target, prop, receiver) {
|
|
2659
|
-
if (typeof prop === "symbol")
|
|
2660
|
-
return Reflect.get(target, prop, receiver);
|
|
2661
|
-
const desc = Object.getOwnPropertyDescriptor(target, prop);
|
|
2662
|
-
if (!desc || typeof desc.value !== "function") {
|
|
2663
|
-
return Reflect.get(target, prop, receiver);
|
|
2664
|
-
}
|
|
2665
|
-
const fn = desc.value;
|
|
2666
|
-
const member = String(prop);
|
|
2667
|
-
return (...args) => invokeFakeHelper(fakeName, member, fn, target, args);
|
|
2668
|
-
},
|
|
2669
|
-
});
|
|
2670
|
-
}
|
|
2671
|
-
/** Invoke a fake helper function, recording a `fake` event and wrapping
|
|
2672
|
-
* the return value. Handles both sync and async helpers, and records an
|
|
2673
|
-
* error event (then rethrows) if the helper throws.
|
|
2674
|
-
*
|
|
2675
|
-
* Arguments are `deepUnwrap`ped on the way in, so a helper body is plain
|
|
2676
|
-
* user code operating on plain values: a test that feeds one helper's
|
|
2677
|
-
* return (or a `ctx.fetch` field) to another passes a provenance wrapper,
|
|
2678
|
-
* and `msg.html.matchAll(...)` inside the fake would otherwise throw a
|
|
2679
|
-
* `TypeError` from a stack the author can't see. Same treatment the k8s
|
|
2680
|
-
* client's arguments get (`components/k3s.ts::withTagging`). The types
|
|
2681
|
-
* still say the value is a handle — passing a wrapped value where the
|
|
2682
|
-
* parameter type is known is a compile error asking for `.unwrap()`; this
|
|
2683
|
-
* catches the case where it isn't known (an `any` off `req.json()`). */
|
|
2684
|
-
function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
|
|
2685
|
-
const t = Date.now();
|
|
2686
|
-
const resv = reserveEvent();
|
|
2687
|
-
const args = callArgs.map((a) => deepUnwrap(a));
|
|
2688
|
-
const safeArgs = args.map((a) => safeSerialize(a));
|
|
2689
|
-
const recordResult = (value) => {
|
|
2690
|
-
// A helper may box its return in a render annotation
|
|
2691
|
-
// (`annotate(v, "email", …)`). The box never reaches the test, and it
|
|
2692
|
-
// never replaces the step either: the call is recorded as the ordinary
|
|
2693
|
-
// `fake` event it is, and the annotation rides *under* it as a child
|
|
2694
|
-
// event (`parentSeq`), which the dashboard folds into the fake step's
|
|
2695
|
-
// detail panel. So the timeline reads the same as any other helper
|
|
2696
|
-
// call, with the richer view one click in.
|
|
2697
|
-
const annotated = readAnnotation(value);
|
|
2698
|
-
const raw = annotated ? annotated.value : value;
|
|
2699
|
-
const durationMs = Date.now() - t;
|
|
2700
|
-
const seq = recordFake({
|
|
2701
|
-
fake: fakeName,
|
|
2702
|
-
member,
|
|
2703
|
-
args: safeArgs,
|
|
2704
|
-
result: safeSerialize(raw),
|
|
2705
|
-
durationMs,
|
|
2706
|
-
}, resv);
|
|
2707
|
-
if (annotated && seq !== undefined) {
|
|
2708
|
-
recordAnnotationChild(fakeName, member, annotated.annotation, durationMs, seq);
|
|
2709
|
-
}
|
|
2710
|
-
return wrap(raw, seq);
|
|
2711
|
-
};
|
|
2712
|
-
const recordError = (err) => {
|
|
2713
|
-
recordFake({
|
|
2714
|
-
fake: fakeName,
|
|
2715
|
-
member,
|
|
2716
|
-
args: safeArgs,
|
|
2717
|
-
durationMs: Date.now() - t,
|
|
2718
|
-
error: errMessage(err),
|
|
2719
|
-
}, resv);
|
|
2720
|
-
};
|
|
2721
|
-
// Record the public helper call in the caller's scope, while its body
|
|
2722
|
-
// and async descendants use native fetch and emit no internal events.
|
|
2723
|
-
let result;
|
|
2724
|
-
try {
|
|
2725
|
-
result = runUninstrumented(() => fn.apply(thisArg, args));
|
|
2726
|
-
}
|
|
2727
|
-
catch (err) {
|
|
2728
|
-
recordError(err);
|
|
2729
|
-
throw err;
|
|
2730
|
-
}
|
|
2731
|
-
if (result instanceof Promise) {
|
|
2732
|
-
return result.then(recordResult, (err) => {
|
|
2733
|
-
recordError(err);
|
|
2734
|
-
throw err;
|
|
2735
|
-
});
|
|
2736
|
-
}
|
|
2737
|
-
return recordResult(result);
|
|
2738
|
-
}
|
|
2739
|
-
/** Record a fake-helper call's render annotation as a child of the call's
|
|
2740
|
-
* own `fake` event — the same `parentSeq` grouping `ctx.poll` uses for the
|
|
2741
|
-
* iteration it kept, so the annotated view folds into the fake step's
|
|
2742
|
-
* detail panel instead of taking a timeline row of its own.
|
|
2743
|
-
*
|
|
2744
|
-
* The child is the event kind that already knows how to draw this: an
|
|
2745
|
-
* `email` annotation records the very event the built-in `email()`
|
|
2746
|
-
* component's mailbox helpers record, so it renders with no new code. It
|
|
2747
|
-
* carries the fake's name and member as its service/op, and the parent's
|
|
2748
|
-
* duration, since it describes that same call.
|
|
2749
|
-
*
|
|
2750
|
-
* Only the success path is annotated: a helper that threw returned no value
|
|
2751
|
-
* to annotate, so it's a plain `fake` error event. */
|
|
2752
|
-
function recordAnnotationChild(fakeName, member, annotation, durationMs, parentSeq) {
|
|
2753
|
-
switch (annotation.kind) {
|
|
2754
|
-
case "email":
|
|
2755
|
-
recordEmail({
|
|
2756
|
-
parentSeq,
|
|
2757
|
-
annotation: true,
|
|
2758
|
-
service: fakeName,
|
|
2759
|
-
op: member,
|
|
2760
|
-
message: annotation.message,
|
|
2761
|
-
messages: annotation.messages,
|
|
2762
|
-
count: annotation.count,
|
|
2763
|
-
durationMs,
|
|
2764
|
-
});
|
|
2765
|
-
break;
|
|
2766
|
-
case "chat":
|
|
2767
|
-
// A chat has no event kind of its own: it describes itself in
|
|
2768
|
-
// presentation terms (title + blocks), which is the seam a new step
|
|
2769
|
-
// type is supposed to use. `kind` stays an opaque grouping label.
|
|
2770
|
-
recordStep({
|
|
2771
|
-
kind: "chat",
|
|
2772
|
-
parentSeq,
|
|
2773
|
-
annotation: true,
|
|
2774
|
-
title: annotation.title ?? `${fakeName}.${member}`,
|
|
2775
|
-
blocks: annotation.blocks,
|
|
2776
|
-
durationMs,
|
|
2777
|
-
});
|
|
2778
|
-
break;
|
|
2779
|
-
}
|
|
2780
|
-
}
|
|
2781
2644
|
function errMessage(err) {
|
|
2782
2645
|
return err?.message ?? String(err);
|
|
2783
2646
|
}
|
|
@@ -3969,10 +3832,10 @@ async function ensureHelpers(name, def) {
|
|
|
3969
3832
|
}
|
|
3970
3833
|
HELPERS_BUILDING.add(name);
|
|
3971
3834
|
try {
|
|
3972
|
-
HELPERS_CACHE.set(name, await def.helpers({
|
|
3835
|
+
HELPERS_CACHE.set(name, trackHelpers("service", name, await def.helpers({
|
|
3973
3836
|
name,
|
|
3974
3837
|
...(await spectestContext({ service: name, includeSelf: false })),
|
|
3975
|
-
}));
|
|
3838
|
+
})));
|
|
3976
3839
|
}
|
|
3977
3840
|
finally {
|
|
3978
3841
|
HELPERS_BUILDING.delete(name);
|
|
@@ -3984,8 +3847,8 @@ async function ensureHelpers(name, def) {
|
|
|
3984
3847
|
const HELPERS_BUILDING = new Set();
|
|
3985
3848
|
/**
|
|
3986
3849
|
* Build the `svc` map for one test/eval. The value at `svc[name]` is
|
|
3987
|
-
*
|
|
3988
|
-
*
|
|
3850
|
+
* a shallow helper proxy: annotated returns become named service steps;
|
|
3851
|
+
* ordinary returns and live client handles pass through. Services without a
|
|
3989
3852
|
* `helpers` factory don't appear in the map at all.
|
|
3990
3853
|
*/
|
|
3991
3854
|
async function buildServiceHandles(cfg) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
type HelperKind = "fake" | "service";
|
|
2
|
+
/** Both helper namespaces consume the same annotation box. Only direct own
|
|
3
|
+
* function properties are intercepted: live clients, getters and inherited
|
|
4
|
+
* methods pass through; callable clients retain their properties. Service functions without annotations
|
|
5
|
+
* retain their original results, types and underlying recorded operations. */
|
|
6
|
+
export declare function trackHelpers<H extends Record<string, unknown>>(kind: HelperKind, name: string, helpers: H): H;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readAnnotation } from "./annotate.js";
|
|
2
|
+
import { deepUnwrap, wrap } from "./inspect.js";
|
|
3
|
+
import { runUninstrumented } from "./harness/instrumentation-scope.js";
|
|
4
|
+
import { captureRecording, recordEmail, recordFake, recordServiceHelper, recordStep, reserveEvent, safeSerialize, } from "./recorder.js";
|
|
5
|
+
/** Both helper namespaces consume the same annotation box. Only direct own
|
|
6
|
+
* function properties are intercepted: live clients, getters and inherited
|
|
7
|
+
* methods pass through; callable clients retain their properties. Service functions without annotations
|
|
8
|
+
* retain their original results, types and underlying recorded operations. */
|
|
9
|
+
export function trackHelpers(kind, name, helpers) {
|
|
10
|
+
const methods = new Map();
|
|
11
|
+
// A separate, extensible target also supports frozen helper records: a proxy
|
|
12
|
+
// over the original cannot substitute a non-writable function property.
|
|
13
|
+
const proxy = new Proxy(Object.create(Object.getPrototypeOf(helpers)), {
|
|
14
|
+
get(_target, prop) {
|
|
15
|
+
if (typeof prop !== "string")
|
|
16
|
+
return Reflect.get(helpers, prop, helpers);
|
|
17
|
+
const descriptor = Object.getOwnPropertyDescriptor(helpers, prop);
|
|
18
|
+
if (!descriptor || typeof descriptor.value !== "function")
|
|
19
|
+
return Reflect.get(helpers, prop, helpers);
|
|
20
|
+
const fn = descriptor.value;
|
|
21
|
+
const cached = methods.get(prop);
|
|
22
|
+
if (cached?.original === fn)
|
|
23
|
+
return cached.tracked;
|
|
24
|
+
// A callable handle (e.g. tagged-template SQL) can also have methods.
|
|
25
|
+
// Proxy its call rather than replacing it with a bare arrow function.
|
|
26
|
+
const tracked = new Proxy(fn, {
|
|
27
|
+
apply(original, thisArg, args) {
|
|
28
|
+
return invokeHelper(kind, name, prop, original, kind === "fake" || thisArg === proxy ? helpers : thisArg, args);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
methods.set(prop, { original: fn, tracked });
|
|
32
|
+
return tracked;
|
|
33
|
+
},
|
|
34
|
+
has: (_target, prop) => Reflect.has(helpers, prop),
|
|
35
|
+
ownKeys: () => Reflect.ownKeys(helpers),
|
|
36
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
37
|
+
const descriptor = Object.getOwnPropertyDescriptor(helpers, prop);
|
|
38
|
+
return descriptor && { ...descriptor, configurable: true };
|
|
39
|
+
},
|
|
40
|
+
set: (_target, prop, value) => Reflect.set(helpers, prop, value, helpers),
|
|
41
|
+
deleteProperty: (_target, prop) => Reflect.deleteProperty(helpers, prop),
|
|
42
|
+
// Virtual properties must remain configurable on this view.
|
|
43
|
+
defineProperty: (_target, prop, descriptor) => descriptor.configurable === true &&
|
|
44
|
+
Reflect.defineProperty(helpers, prop, descriptor),
|
|
45
|
+
preventExtensions: () => false,
|
|
46
|
+
});
|
|
47
|
+
return proxy;
|
|
48
|
+
}
|
|
49
|
+
function invokeHelper(kind, name, member, fn, receiver, callArgs) {
|
|
50
|
+
const start = Date.now();
|
|
51
|
+
const reservation = reserveEvent();
|
|
52
|
+
const recording = captureRecording();
|
|
53
|
+
// Fake bodies have always received raw args. Services keep their existing
|
|
54
|
+
// input contract: they may intentionally accept wrapped values or clients.
|
|
55
|
+
const args = kind === "fake" ? callArgs.map((arg) => deepUnwrap(arg)) : callArgs;
|
|
56
|
+
const safeArgs = kind === "fake" ? args.map((arg) => safeSerialize(arg)) : undefined;
|
|
57
|
+
const finish = (value) => {
|
|
58
|
+
const annotated = readAnnotation(value);
|
|
59
|
+
if (kind === "service" && !annotated) {
|
|
60
|
+
recording.finish(true);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
recording.finish(false);
|
|
64
|
+
const raw = deepUnwrap(annotated ? annotated.value : value);
|
|
65
|
+
const durationMs = Date.now() - start;
|
|
66
|
+
const seq = recording.isCurrent()
|
|
67
|
+
? recordHelper(kind, name, member, safeArgs ?? args.map((arg) => safeSerialize(deepUnwrap(arg))), raw, durationMs, reservation)
|
|
68
|
+
: undefined;
|
|
69
|
+
if (annotated && seq !== undefined) {
|
|
70
|
+
recordAnnotationChild(name, member, annotated.annotation, durationMs, seq);
|
|
71
|
+
}
|
|
72
|
+
return wrap(raw, seq);
|
|
73
|
+
};
|
|
74
|
+
const fail = (error) => {
|
|
75
|
+
// There was no annotated return to replace the implementation. Preserve
|
|
76
|
+
// service failures' underlying diagnostics; fake internals stay hidden.
|
|
77
|
+
recording.finish(kind === "service");
|
|
78
|
+
if (kind === "fake" && recording.isCurrent()) {
|
|
79
|
+
recordFake({ fake: name, member, args: safeArgs, durationMs: Date.now() - start,
|
|
80
|
+
error: error instanceof Error ? error.message : String(error) }, reservation);
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
};
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
result = recording.run(() => kind === "fake"
|
|
87
|
+
? runUninstrumented(() => fn.apply(receiver, args))
|
|
88
|
+
: fn.apply(receiver, args));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return fail(error);
|
|
92
|
+
}
|
|
93
|
+
return result instanceof Promise ? result.then(finish, fail) : finish(result);
|
|
94
|
+
}
|
|
95
|
+
function recordHelper(kind, name, member, args, result, durationMs, reservation) {
|
|
96
|
+
if (kind === "fake") {
|
|
97
|
+
return recordFake({ fake: name, member, args, result: safeSerialize(result), durationMs }, reservation);
|
|
98
|
+
}
|
|
99
|
+
return recordServiceHelper({ service: name, member, args, result: safeSerialize(result), durationMs }, reservation);
|
|
100
|
+
}
|
|
101
|
+
/** Record a helper call's render annotation as a child of the call's
|
|
102
|
+
* own event — the same `parentSeq` grouping `ctx.poll` uses for the
|
|
103
|
+
* iteration it kept, so the annotated view folds into the helper step's
|
|
104
|
+
* detail panel instead of taking a timeline row of its own.
|
|
105
|
+
*
|
|
106
|
+
* The child is the event kind that already knows how to draw this: an
|
|
107
|
+
* `email` annotation records the very event the built-in `email()`
|
|
108
|
+
* component's mailbox helpers record, so it renders with no new code. It
|
|
109
|
+
* carries the helper's name and member as its service/op, and the parent's
|
|
110
|
+
* duration, since it describes that same call.
|
|
111
|
+
*
|
|
112
|
+
* Only the success path is annotated: a helper that threw returned no value
|
|
113
|
+
* to annotate. */
|
|
114
|
+
function recordAnnotationChild(name, member, annotation, durationMs, parentSeq) {
|
|
115
|
+
switch (annotation.kind) {
|
|
116
|
+
case "email":
|
|
117
|
+
recordEmail({
|
|
118
|
+
parentSeq,
|
|
119
|
+
annotation: true,
|
|
120
|
+
service: name,
|
|
121
|
+
op: member,
|
|
122
|
+
message: annotation.message,
|
|
123
|
+
messages: annotation.messages,
|
|
124
|
+
count: annotation.count,
|
|
125
|
+
durationMs,
|
|
126
|
+
});
|
|
127
|
+
break;
|
|
128
|
+
case "chat":
|
|
129
|
+
// A chat has no event kind of its own: it describes itself in
|
|
130
|
+
// presentation terms (title + blocks), which is the seam a new step
|
|
131
|
+
// type is supposed to use. `kind` stays an opaque grouping label.
|
|
132
|
+
recordStep({
|
|
133
|
+
kind: "chat",
|
|
134
|
+
parentSeq,
|
|
135
|
+
annotation: true,
|
|
136
|
+
title: annotation.title ?? `${name}.${member}`,
|
|
137
|
+
blocks: annotation.blocks,
|
|
138
|
+
durationMs,
|
|
139
|
+
});
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Android, AndroidApp } from "./android.js";
|
|
|
2
2
|
export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
|
|
3
3
|
import { strict as nodeAssert } from "node:assert";
|
|
4
4
|
export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, WrappedBuffer, WrappedBinaryView, WrappedBytes, WrappedBlob, WrappedFile, WrappedFormData, WrappedStream, WrappedReader, WrappedBYOBReader, WrappedReadResult, WrappedResponse, Provenanced, SpectestFetch, SpectestRequestInit, Unwrap, } from "./inspect.js";
|
|
5
|
-
import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
5
|
+
import type { Provenanced, SpectestFetch, Wrapped, Unwrap } from "./inspect.js";
|
|
6
6
|
export { field } from "./inspect.js";
|
|
7
7
|
export { annotate } from "./annotate.js";
|
|
8
8
|
export type { Annotated, AnnotationKind, AnnotationOptions, ChatAnnotation, ChatMessageAnnotation, ChatReactionAnnotation, EmailAnnotation, } from "./annotate.js";
|
|
@@ -563,6 +563,11 @@ export interface ServiceDefinition<H extends Record<string, any> = Record<string
|
|
|
563
563
|
* user chose in the services map and matches the in-VM DNS name; the
|
|
564
564
|
* rest of the {@link ServiceHelpersContext} (exec, projectRoot) lets a
|
|
565
565
|
* component reach into its containers without daemon internals.
|
|
566
|
+
*
|
|
567
|
+
* Direct helper functions may return `annotate(value, "email" | "chat")`.
|
|
568
|
+
* That call becomes one named service step, its internal events are hidden,
|
|
569
|
+
* and the caller receives `Wrapped<T>` linked to the helper. Ordinary returns
|
|
570
|
+
* retain their existing value/type and underlying operation events.
|
|
566
571
|
*/
|
|
567
572
|
helpers?: (args: ServiceHelpersContext) => H | Promise<H>;
|
|
568
573
|
/**
|
|
@@ -600,13 +605,20 @@ type HelpersOf<D> = "helpers" extends keyof D ? [NonNullable<D["helpers"]>] exte
|
|
|
600
605
|
/**
|
|
601
606
|
* Per-service handles derived from a concrete services map. Only
|
|
602
607
|
* services that ship a `helpers` factory appear here; `ctx.svc.<name>`
|
|
603
|
-
*
|
|
604
|
-
*
|
|
608
|
+
* preserves the factory's live handles and ordinary function results. A
|
|
609
|
+
* function returning `annotate(value, kind)` instead returns a wrapped value
|
|
610
|
+
* linked to one named service-helper step. Services without helpers don't show up,
|
|
605
611
|
* so plain `services: { api: { image: ... } }` adds no noise.
|
|
606
612
|
*/
|
|
607
613
|
export type ServiceHandlesFor<S extends ServicesMap> = {
|
|
608
|
-
[K in keyof S as HelpersOf<S[K]> extends never ? never : K]: HelpersOf<S[K]
|
|
614
|
+
[K in keyof S as HelpersOf<S[K]> extends never ? never : K]: ServiceHelpers<HelpersOf<S[K]>>;
|
|
615
|
+
};
|
|
616
|
+
/** Only annotated returns change type. Keeping other function types verbatim
|
|
617
|
+
* preserves generics, overloads, and callable clients with attached methods. */
|
|
618
|
+
type ServiceHelpers<H> = {
|
|
619
|
+
[K in keyof H]: H[K] extends (...args: infer A) => infer R ? Extract<Awaited<R>, Annotated<unknown>> extends never ? H[K] : (...args: A) => R extends Promise<infer V> ? Promise<ServiceHelperResult<V>> : ServiceHelperResult<R> : H[K];
|
|
609
620
|
};
|
|
621
|
+
type ServiceHelperResult<R> = R extends Annotated<infer V> ? Wrapped<Unwrap<V>> : R;
|
|
610
622
|
/** Loose, runtime-friendly shape of `ctx.svc` for code that doesn't
|
|
611
623
|
* know the concrete services map (the daemon, generic helpers). The
|
|
612
624
|
* typed `ctx.svc` in test bodies is `ServiceHandlesFor<S>`. */
|
|
@@ -1543,14 +1555,14 @@ export declare function defineFake<S = any, H extends Record<string, unknown> =
|
|
|
1543
1555
|
* at use sites. */
|
|
1544
1556
|
export type FakesMap = Record<string, FakeDefinition<any, any>>;
|
|
1545
1557
|
/** Rewrite a helpers record so each function's result is inspect-wrapped
|
|
1546
|
-
* ({@link Wrapped}) — mirroring the runtime, where `
|
|
1558
|
+
* ({@link Wrapped}) — mirroring the runtime, where `trackHelpers` wraps
|
|
1547
1559
|
* every helper return value for assertion provenance. Without this the raw
|
|
1548
1560
|
* return type (e.g. `Charge[]`) reaches `expect`, which the `Provenanced`
|
|
1549
1561
|
* gate rejects. Handles sync and async helpers; non-function members (and
|
|
1550
1562
|
* `void` side-effect helpers) pass through untouched. Mirrors `Tagged<T>` in
|
|
1551
1563
|
* `components/k3s.ts`, extended to cover synchronous returns. */
|
|
1552
1564
|
type WrappedHelpers<H> = {
|
|
1553
|
-
[K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Wrapped<Unannotated<R
|
|
1565
|
+
[K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Wrapped<Unwrap<Unannotated<R>>>> : H[K] extends (...args: infer A) => infer R ? (...args: A) => [R] extends [void] ? void : Wrapped<Unwrap<Unannotated<R>>> : H[K];
|
|
1554
1566
|
};
|
|
1555
1567
|
/** A helper's return type as the *test* sees it. {@link annotate} boxes the
|
|
1556
1568
|
* value in an {@link Annotated} to pick how the step renders; the daemon
|
package/dist/index.js
CHANGED
|
@@ -17,13 +17,9 @@ import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
|
|
|
17
17
|
// always wrapped (in every context), so the method is always there; there is no
|
|
18
18
|
// `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
|
|
19
19
|
export { field } from "./inspect.js";
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// nested event drawing it — an email mail-client style, a conversation as
|
|
24
|
-
// bubbles — which the step's panel leads with, tabbing to the raw JSON
|
|
25
|
-
// value. The kind picks the options' type, and the test still gets the raw
|
|
26
|
-
// value, with the raw value's type — see `annotate.ts`.
|
|
20
|
+
// Fake and service helpers return annotate(value, kind) to render their own
|
|
21
|
+
// result as email/chat. Their boundary opens the box and links assertions to
|
|
22
|
+
// the helper step. Annotated service returns suppress internal operation events.
|
|
27
23
|
export { annotate } from "./annotate.js";
|
|
28
24
|
// Instrumented client primitives — drop-in replacements for Bun's native
|
|
29
25
|
// clients that record each operation on the test event log and return their
|
package/dist/recorder.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AndroidStepDetails } from "./android-step.js";
|
|
2
|
-
export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | EnvEvent | EmailEvent | StepEvent;
|
|
2
|
+
export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | ServiceHelperEvent | EnvEvent | EmailEvent | StepEvent;
|
|
3
3
|
interface BaseEvent {
|
|
4
4
|
/** Order of *start* within the test. Reserved when an op begins (see
|
|
5
5
|
* `reserveEvent`), so an op whose nested children finish — and record —
|
|
@@ -294,6 +294,13 @@ export interface FakeEvent extends BaseEvent {
|
|
|
294
294
|
/** Set if the helper threw. */
|
|
295
295
|
error?: string;
|
|
296
296
|
}
|
|
297
|
+
/** An annotated service helper has the same call/return shape as a fake.
|
|
298
|
+
* Only helpers returning an annotation emit this event. */
|
|
299
|
+
export interface ServiceHelperEvent extends Omit<FakeEvent, "kind" | "fake"> {
|
|
300
|
+
kind: "service";
|
|
301
|
+
/** Service name — the key in `ctx.svc`. */
|
|
302
|
+
service: string;
|
|
303
|
+
}
|
|
297
304
|
/**
|
|
298
305
|
* One captured email, as embedded on an {@link EmailEvent}. The HTML and
|
|
299
306
|
* text bodies ride along (truncated to the output cap) so the dashboard
|
|
@@ -563,6 +570,15 @@ export interface EventReservation {
|
|
|
563
570
|
seq: number;
|
|
564
571
|
tOffsetMs: number;
|
|
565
572
|
}
|
|
573
|
+
/** Buffer a helper's async descendants until its return chooses between the
|
|
574
|
+
* implementation's events and one annotated helper call. Seqs still come from
|
|
575
|
+
* the test recorder, so concurrent calls and linked assertions stay unique.
|
|
576
|
+
* Poll truncation/grouping inside the helper applies only to its own buffer. */
|
|
577
|
+
export declare function captureRecording(): {
|
|
578
|
+
run<T>(fn: () => T): T;
|
|
579
|
+
finish(keep: boolean): void;
|
|
580
|
+
isCurrent(): boolean;
|
|
581
|
+
};
|
|
566
582
|
export declare function pauseRecording(): void;
|
|
567
583
|
export declare function resumeRecording(): void;
|
|
568
584
|
export declare function startRecording(startedAt?: number): void;
|
|
@@ -592,6 +608,7 @@ export declare function recordS3(ev: Omit<S3Event, "seq" | "tOffsetMs" | "kind">
|
|
|
592
608
|
export declare function recordTerminal(ev: Omit<TerminalEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
593
609
|
export declare function recordTerminalStep(ev: Omit<TerminalStepEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
594
610
|
export declare function recordFake(ev: Omit<FakeEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
611
|
+
export declare function recordServiceHelper(ev: Omit<ServiceHelperEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
595
612
|
export declare function recordEnv(ev: Omit<EnvEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
596
613
|
/**
|
|
597
614
|
* One block of step detail, in the dashboard's presentation vocabulary
|