@specific.dev/spectest 0.107.1 → 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/harness/ingress-server.js +7 -1
- 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/harness/ingress-server.test.ts +57 -5
- package/src/harness/ingress-server.ts +7 -1
- 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
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { annotate } from "./annotate.js";
|
|
3
|
+
import { expect as assertValue, type ServiceHandlesFor, type Wrapped } from "./index.js";
|
|
4
|
+
import { readRaw, readTag, wrap } from "./inspect.js";
|
|
5
|
+
import { trackHelpers } from "./helper-tracking.js";
|
|
6
|
+
import {
|
|
7
|
+
recordHttp, recordWait, recorderEventCount, recorderMarkChildren, recorderTruncate,
|
|
8
|
+
startRecording, stopRecording,
|
|
9
|
+
} from "./recorder.js";
|
|
10
|
+
import { createInstrumentationScope, runInstrumented } from "./harness/instrumentation-scope.js";
|
|
11
|
+
import { installFetchWrapper } from "./harness/fetch.js";
|
|
12
|
+
|
|
13
|
+
function observe(subject = "Invoice") {
|
|
14
|
+
const raw = { subject, to: "owner@test", text: "Please pay" };
|
|
15
|
+
const seq = recordHttp({ method: "GET", url: "https://mail.test/latest", status: 200,
|
|
16
|
+
responseBody: JSON.stringify(raw), durationMs: 2 });
|
|
17
|
+
return wrap(raw, seq) as unknown as Wrapped<typeof raw>;
|
|
18
|
+
}
|
|
19
|
+
function deferred() {
|
|
20
|
+
let resolve!: () => void;
|
|
21
|
+
const promise = new Promise<void>((r) => { resolve = r; });
|
|
22
|
+
return { promise, resolve };
|
|
23
|
+
}
|
|
24
|
+
afterEach(() => stopRecording());
|
|
25
|
+
|
|
26
|
+
for (const kind of ["fake", "service"] as const) {
|
|
27
|
+
test(`${kind} consumes the same annotation box and links assertions to one helper step`, async () => {
|
|
28
|
+
startRecording();
|
|
29
|
+
const helper = trackHelpers(kind, "mail", {
|
|
30
|
+
async latest() {
|
|
31
|
+
const value = observe();
|
|
32
|
+
await Promise.resolve();
|
|
33
|
+
recordWait({ description: "internal", attempts: 1, durationMs: 2, passed: true });
|
|
34
|
+
return annotate(value, "email");
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const mail = await helper.latest() as unknown as Wrapped<{ subject: string }>;
|
|
38
|
+
assertValue(mail.subject).toBe("Invoice");
|
|
39
|
+
const events = stopRecording();
|
|
40
|
+
expect(events.map((e) => e.kind)).toEqual([kind, "email", "assertion"]);
|
|
41
|
+
expect(events[1]).toMatchObject({ parentSeq: events[0]!.seq, annotation: true,
|
|
42
|
+
message: { subject: "Invoice", to: ["owner@test"], text: "Please pay" } });
|
|
43
|
+
expect(events[2]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["subject"] });
|
|
44
|
+
expect<unknown>(readRaw(mail)).toEqual({ subject: "Invoice", to: "owner@test", text: "Please pay" });
|
|
45
|
+
expect(events[0]).toMatchObject({ [kind === "fake" ? "fake" : "service"]: "mail",
|
|
46
|
+
member: "latest", args: [], result: readRaw(mail) });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("ordinary service returns preserve value identity and operation provenance", async () => {
|
|
51
|
+
startRecording();
|
|
52
|
+
let observed: unknown;
|
|
53
|
+
const helper = trackHelpers("service", "mail", {
|
|
54
|
+
async latest() { return observed = observe(); },
|
|
55
|
+
count: () => 42,
|
|
56
|
+
});
|
|
57
|
+
const mail = await helper.latest() as Wrapped<{subject: string}>;
|
|
58
|
+
expect<unknown>(mail).toBe(observed);
|
|
59
|
+
expect(helper.count()).toBe(42);
|
|
60
|
+
assertValue(mail.subject).toBe("Invoice");
|
|
61
|
+
const events = stopRecording();
|
|
62
|
+
expect(events.map((e) => e.kind)).toEqual(["http", "assertion"]);
|
|
63
|
+
expect(events[1]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["subject"] });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a helper can choose an annotated or ordinary return per call", () => {
|
|
67
|
+
startRecording();
|
|
68
|
+
const helper = trackHelpers("service", "mail", {
|
|
69
|
+
latest(render: boolean) { const value = observe(); return render ? annotate(value, "email") : value; },
|
|
70
|
+
});
|
|
71
|
+
helper.latest(false);
|
|
72
|
+
helper.latest(true);
|
|
73
|
+
expect(stopRecording().map((e) => e.kind)).toEqual(["http", "service", "email"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("concurrent test work and sibling helpers survive an annotated return", async () => {
|
|
77
|
+
startRecording();
|
|
78
|
+
const gate = deferred();
|
|
79
|
+
const helper = trackHelpers("service", "mail", {
|
|
80
|
+
async annotated() { observe("hidden"); await gate.promise; observe("also hidden"); return annotate({subject: "shown"}, "email"); },
|
|
81
|
+
async plain() { await Promise.resolve(); return observe("sibling"); },
|
|
82
|
+
});
|
|
83
|
+
const pending = helper.annotated();
|
|
84
|
+
const outside = observe("outside");
|
|
85
|
+
const sibling = await helper.plain();
|
|
86
|
+
gate.resolve();
|
|
87
|
+
await pending;
|
|
88
|
+
assertValue(outside.subject).toBe("outside");
|
|
89
|
+
assertValue(sibling.subject).toBe("sibling");
|
|
90
|
+
const events = stopRecording();
|
|
91
|
+
expect(events.filter((e) => e.kind === "http").map((e: any) => JSON.parse(e.responseBody).subject)).toEqual(["outside", "sibling"]);
|
|
92
|
+
expect(events.filter((e) => e.kind === "service")).toHaveLength(1);
|
|
93
|
+
expect(new Set(events.map((e) => e.seq)).size).toBe(events.length);
|
|
94
|
+
const seqs = new Set(events.map((e) => e.seq));
|
|
95
|
+
for (const e of events.filter((e) => e.kind === "assertion") as any[]) expect(seqs.has(e.sourceSeq)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("nested annotated helpers collapse only when the outer helper annotates", async () => {
|
|
99
|
+
startRecording();
|
|
100
|
+
const inner = trackHelpers("service", "inner", { latest: () => annotate(observe(), "email") });
|
|
101
|
+
const outer = trackHelpers("service", "outer", {
|
|
102
|
+
async latest(render: boolean) { const value = await inner.latest(); return render ? annotate(value, "email") : value; },
|
|
103
|
+
});
|
|
104
|
+
await outer.latest(false);
|
|
105
|
+
await outer.latest(true);
|
|
106
|
+
const events = stopRecording();
|
|
107
|
+
expect(events.map((e) => e.kind)).toEqual(["service", "email", "service", "email"]);
|
|
108
|
+
expect(events[0]).toMatchObject({ service: "inner", member: "latest", args: [] });
|
|
109
|
+
expect(events[2]).toMatchObject({ service: "outer", member: "latest", args: [true] });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("polling inside a helper cannot truncate unrelated concurrent events", async () => {
|
|
113
|
+
startRecording();
|
|
114
|
+
const gate = deferred();
|
|
115
|
+
const helper = trackHelpers("service", "mail", {
|
|
116
|
+
async latest() {
|
|
117
|
+
const start = recorderEventCount();
|
|
118
|
+
observe("discarded attempt");
|
|
119
|
+
await gate.promise;
|
|
120
|
+
recorderTruncate(start);
|
|
121
|
+
const result = observe("kept attempt");
|
|
122
|
+
const wait = recordWait({ description: "ready", attempts: 2, passed: true, durationMs: 3 })!;
|
|
123
|
+
recorderMarkChildren(start, wait);
|
|
124
|
+
return result;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const pending = helper.latest();
|
|
128
|
+
observe("outside");
|
|
129
|
+
gate.resolve();
|
|
130
|
+
await pending;
|
|
131
|
+
const events = stopRecording();
|
|
132
|
+
expect(events.filter((e) => e.kind === "http").map((e: any) => JSON.parse(e.responseBody).subject)).toEqual(["outside", "kept attempt"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("an annotated service helper is one child of a poll's kept attempt", () => {
|
|
136
|
+
startRecording();
|
|
137
|
+
const helper = trackHelpers("service", "mail", { latest: () => annotate(observe(), "email") });
|
|
138
|
+
const start = recorderEventCount();
|
|
139
|
+
helper.latest();
|
|
140
|
+
recorderTruncate(start);
|
|
141
|
+
helper.latest();
|
|
142
|
+
const wait = recordWait({ description: "mail arrives", attempts: 2, passed: true, durationMs: 10 })!;
|
|
143
|
+
recorderMarkChildren(start, wait);
|
|
144
|
+
const events = stopRecording();
|
|
145
|
+
expect(events.map((e) => e.kind)).toEqual(["service", "email", "wait"]);
|
|
146
|
+
expect(events[0]!.parentSeq).toBe(wait);
|
|
147
|
+
expect(events[1]!.parentSeq).toBe(events[0]!.seq);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("errors retain service diagnostics and fake error behavior", async () => {
|
|
151
|
+
for (const kind of ["fake", "service"] as const) {
|
|
152
|
+
startRecording();
|
|
153
|
+
const failure = new Error("mail unavailable");
|
|
154
|
+
const helper = trackHelpers(kind, "mail", {
|
|
155
|
+
async latest() { observe(); await Promise.resolve(); throw failure; },
|
|
156
|
+
});
|
|
157
|
+
await expect(helper.latest()).rejects.toBe(failure);
|
|
158
|
+
expect(stopRecording().map((e) => e.kind)).toEqual([kind === "fake" ? "fake" : "http"]);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("live objects, callable clients, function properties and receivers survive", async () => {
|
|
163
|
+
startRecording();
|
|
164
|
+
const client = { query: () => 1 };
|
|
165
|
+
const sql = Object.assign((input: string) => observe(input), { unsafe: () => 2 });
|
|
166
|
+
const raw = { client, sql, subject: "Invoice", latest() { return annotate({subject: this.subject}, "email"); } };
|
|
167
|
+
const helper = trackHelpers("service", "mail", raw);
|
|
168
|
+
expect(helper.client).toBe(client);
|
|
169
|
+
expect(helper.sql.unsafe).toBe(sql.unsafe);
|
|
170
|
+
expect(helper.sql.unsafe()).toBe(2);
|
|
171
|
+
expect(helper.sql).toBe(helper.sql);
|
|
172
|
+
expect(helper.latest).toBe(helper.latest);
|
|
173
|
+
expect(helper.sql("query").subject.unwrap()).toBe("query");
|
|
174
|
+
expect<unknown>(readRaw(helper.latest())).toEqual({subject: "Invoice"});
|
|
175
|
+
expect<unknown>(readRaw(helper.latest.call({...raw, subject: "Other"}))).toEqual({subject: "Other"});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("setup/eval unwrap annotated returns without recording", async () => {
|
|
179
|
+
const scope = createInstrumentationScope(false);
|
|
180
|
+
const helper = trackHelpers("service", "mail", { latest: () => annotate({subject: "Invoice"}, "email") });
|
|
181
|
+
const value = runInstrumented(scope, () => helper.latest());
|
|
182
|
+
expect<unknown>(readRaw(value)).toEqual({subject: "Invoice"});
|
|
183
|
+
expect(readTag(value)?.sourceSeq).toBeUndefined();
|
|
184
|
+
expect(stopRecording()).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("frozen helper records retain enumeration and can return chat annotations", () => {
|
|
188
|
+
startRecording();
|
|
189
|
+
const client = { query: () => 1 };
|
|
190
|
+
const helper = trackHelpers("service", "support", Object.freeze({
|
|
191
|
+
client,
|
|
192
|
+
conversation: () => annotate([{ side: "other", text: "Hello" }], "chat"),
|
|
193
|
+
}));
|
|
194
|
+
expect(Object.keys(helper)).toEqual(["client", "conversation"]);
|
|
195
|
+
expect("client" in helper).toBe(true);
|
|
196
|
+
expect(helper.client).toBe(client);
|
|
197
|
+
helper.conversation();
|
|
198
|
+
const events = stopRecording();
|
|
199
|
+
expect(events.map((e) => e.kind)).toEqual(["service", "chat"]);
|
|
200
|
+
expect(events[1]).toMatchObject({ parentSeq: events[0]!.seq, annotation: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("timed-out helpers retain diagnostics and cannot write into the next test", async () => {
|
|
204
|
+
startRecording();
|
|
205
|
+
const gate = deferred();
|
|
206
|
+
const helper = trackHelpers("service", "mail", {
|
|
207
|
+
async latest() { observe("before timeout"); await gate.promise; observe("late"); return annotate({subject: "late"}, "email"); },
|
|
208
|
+
});
|
|
209
|
+
const pending = helper.latest();
|
|
210
|
+
expect(stopRecording().map((e) => e.kind)).toEqual(["http"]);
|
|
211
|
+
startRecording();
|
|
212
|
+
observe("next test");
|
|
213
|
+
gate.resolve();
|
|
214
|
+
await pending;
|
|
215
|
+
expect(stopRecording().map((e) => e.kind)).toEqual(["http"]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("detached work from an annotated helper stays suppressed after return", async () => {
|
|
219
|
+
startRecording();
|
|
220
|
+
const gate = deferred();
|
|
221
|
+
let detached!: Promise<unknown>;
|
|
222
|
+
const helper = trackHelpers("service", "mail", {
|
|
223
|
+
latest() { detached = gate.promise.then(() => observe("late")); return annotate({subject: "Invoice"}, "email"); },
|
|
224
|
+
});
|
|
225
|
+
helper.latest();
|
|
226
|
+
gate.resolve();
|
|
227
|
+
await detached;
|
|
228
|
+
expect(stopRecording().map((e) => e.kind)).toEqual(["service", "email"]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("stopping a test does not resurrect pending descendants of a collapsed helper", async () => {
|
|
232
|
+
startRecording();
|
|
233
|
+
const gate = deferred();
|
|
234
|
+
const inner = trackHelpers("service", "inner", {
|
|
235
|
+
async latest() { observe("hidden"); await gate.promise; return observe("also hidden"); },
|
|
236
|
+
});
|
|
237
|
+
let pending!: Promise<unknown>;
|
|
238
|
+
const outer = trackHelpers("service", "outer", {
|
|
239
|
+
latest() { pending = inner.latest(); return annotate({subject: "Invoice"}, "email"); },
|
|
240
|
+
});
|
|
241
|
+
outer.latest();
|
|
242
|
+
expect(stopRecording().map((e) => e.kind)).toEqual(["service", "email"]);
|
|
243
|
+
gate.resolve();
|
|
244
|
+
await pending;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
for (const annotated of [false, true]) test(`real HTTP service helper preserves internal recording unless annotated (${annotated})`, async () => {
|
|
248
|
+
let requests = 0;
|
|
249
|
+
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch() { requests++; return Response.json({subject: "Invoice"}); } });
|
|
250
|
+
const restore = installFetchWrapper();
|
|
251
|
+
const scope = createInstrumentationScope();
|
|
252
|
+
startRecording();
|
|
253
|
+
try {
|
|
254
|
+
const helper = trackHelpers("service", "mail", {
|
|
255
|
+
async latest() {
|
|
256
|
+
const response = await fetch(`http://127.0.0.1:${server.port}/mail`);
|
|
257
|
+
const value = await response.json();
|
|
258
|
+
return annotated ? annotate(value, "email") : value;
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
await runInstrumented(scope, async () => {
|
|
262
|
+
const value = await helper.latest() as unknown as Wrapped<{subject:string}>;
|
|
263
|
+
assertValue(value.subject).toBe("Invoice");
|
|
264
|
+
});
|
|
265
|
+
expect(requests).toBe(1);
|
|
266
|
+
const events = stopRecording();
|
|
267
|
+
expect(events.map((e) => e.kind)).toEqual(annotated ? ["service", "email", "assertion"] : ["http", "assertion"]);
|
|
268
|
+
expect(events.at(-1)).toMatchObject({ sourceSeq: events[0]!.seq });
|
|
269
|
+
} finally { scope.active = false; restore(); server.stop(true); }
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Compile-time contract, including inferred annotated unions and generic live clients.
|
|
273
|
+
function types(ctx: ServiceHandlesFor<{ mail: { image: string; helpers: () => {
|
|
274
|
+
plain(): number;
|
|
275
|
+
latest(): ReturnType<typeof annotation>;
|
|
276
|
+
maybe(): ReturnType<typeof annotation> | undefined;
|
|
277
|
+
asyncLatest(): Promise<ReturnType<typeof annotation>>;
|
|
278
|
+
generic<T>(value: T): T;
|
|
279
|
+
} } }>) {
|
|
280
|
+
const plain: number = ctx.mail.plain();
|
|
281
|
+
assertValue(ctx.mail.latest().subject).toBe("Invoice");
|
|
282
|
+
const maybe: Wrapped<{subject:string}> | undefined = ctx.mail.maybe();
|
|
283
|
+
const promise: Promise<Wrapped<{subject:string}>> = ctx.mail.asyncLatest();
|
|
284
|
+
const literal: "kept" = ctx.mail.generic("kept" as const);
|
|
285
|
+
// @ts-expect-error annotated leaves remain wrapped at the caller
|
|
286
|
+
const invalid: string = ctx.mail.latest().subject;
|
|
287
|
+
return [plain, maybe, promise, literal, invalid];
|
|
288
|
+
}
|
|
289
|
+
function annotation() { return annotate({subject: "Invoice"}, "email"); }
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { readAnnotation } from "./annotate.js";
|
|
2
|
+
import type { RenderAnnotation } from "./annotate.js";
|
|
3
|
+
import { deepUnwrap, wrap } from "./inspect.js";
|
|
4
|
+
import { runUninstrumented } from "./harness/instrumentation-scope.js";
|
|
5
|
+
import {
|
|
6
|
+
captureRecording, recordEmail, recordFake, recordServiceHelper, recordStep, reserveEvent, safeSerialize,
|
|
7
|
+
type EventReservation,
|
|
8
|
+
} from "./recorder.js";
|
|
9
|
+
|
|
10
|
+
type HelperKind = "fake" | "service";
|
|
11
|
+
|
|
12
|
+
/** Both helper namespaces consume the same annotation box. Only direct own
|
|
13
|
+
* function properties are intercepted: live clients, getters and inherited
|
|
14
|
+
* methods pass through; callable clients retain their properties. Service functions without annotations
|
|
15
|
+
* retain their original results, types and underlying recorded operations. */
|
|
16
|
+
export function trackHelpers<H extends Record<string, unknown>>(kind: HelperKind, name: string, helpers: H): H {
|
|
17
|
+
const methods = new Map<string, { original: Function; tracked: Function }>();
|
|
18
|
+
// A separate, extensible target also supports frozen helper records: a proxy
|
|
19
|
+
// over the original cannot substitute a non-writable function property.
|
|
20
|
+
const proxy: H = new Proxy(Object.create(Object.getPrototypeOf(helpers)), {
|
|
21
|
+
get(_target, prop) {
|
|
22
|
+
if (typeof prop !== "string") return Reflect.get(helpers, prop, helpers);
|
|
23
|
+
const descriptor = Object.getOwnPropertyDescriptor(helpers, prop);
|
|
24
|
+
if (!descriptor || typeof descriptor.value !== "function") return Reflect.get(helpers, prop, helpers);
|
|
25
|
+
const fn = descriptor.value as (...args: unknown[]) => unknown;
|
|
26
|
+
const cached = methods.get(prop);
|
|
27
|
+
if (cached?.original === fn) return cached.tracked;
|
|
28
|
+
// A callable handle (e.g. tagged-template SQL) can also have methods.
|
|
29
|
+
// Proxy its call rather than replacing it with a bare arrow function.
|
|
30
|
+
const tracked = new Proxy(fn, {
|
|
31
|
+
apply(original, thisArg, args) {
|
|
32
|
+
return invokeHelper(kind, name, prop, original,
|
|
33
|
+
kind === "fake" || thisArg === proxy ? helpers : thisArg, args);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
methods.set(prop, { original: fn, tracked });
|
|
37
|
+
return tracked;
|
|
38
|
+
},
|
|
39
|
+
has: (_target, prop) => Reflect.has(helpers, prop),
|
|
40
|
+
ownKeys: () => Reflect.ownKeys(helpers),
|
|
41
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
42
|
+
const descriptor = Object.getOwnPropertyDescriptor(helpers, prop);
|
|
43
|
+
return descriptor && { ...descriptor, configurable: true };
|
|
44
|
+
},
|
|
45
|
+
set: (_target, prop, value) => Reflect.set(helpers, prop, value, helpers),
|
|
46
|
+
deleteProperty: (_target, prop) => Reflect.deleteProperty(helpers, prop),
|
|
47
|
+
// Virtual properties must remain configurable on this view.
|
|
48
|
+
defineProperty: (_target, prop, descriptor) => descriptor.configurable === true &&
|
|
49
|
+
Reflect.defineProperty(helpers, prop, descriptor),
|
|
50
|
+
preventExtensions: () => false,
|
|
51
|
+
});
|
|
52
|
+
return proxy;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function invokeHelper(
|
|
56
|
+
kind: HelperKind, name: string, member: string,
|
|
57
|
+
fn: (...args: unknown[]) => unknown, receiver: unknown, callArgs: unknown[],
|
|
58
|
+
): unknown {
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
const reservation = reserveEvent();
|
|
61
|
+
const recording = captureRecording();
|
|
62
|
+
// Fake bodies have always received raw args. Services keep their existing
|
|
63
|
+
// input contract: they may intentionally accept wrapped values or clients.
|
|
64
|
+
const args = kind === "fake" ? callArgs.map((arg) => deepUnwrap(arg)) : callArgs;
|
|
65
|
+
const safeArgs = kind === "fake" ? args.map((arg) => safeSerialize(arg)) : undefined;
|
|
66
|
+
const finish = (value: unknown): unknown => {
|
|
67
|
+
const annotated = readAnnotation(value);
|
|
68
|
+
if (kind === "service" && !annotated) {
|
|
69
|
+
recording.finish(true);
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
recording.finish(false);
|
|
73
|
+
const raw = deepUnwrap(annotated ? annotated.value : value);
|
|
74
|
+
const durationMs = Date.now() - start;
|
|
75
|
+
const seq = recording.isCurrent()
|
|
76
|
+
? recordHelper(kind, name, member,
|
|
77
|
+
safeArgs ?? args.map((arg) => safeSerialize(deepUnwrap(arg))), raw, durationMs, reservation)
|
|
78
|
+
: undefined;
|
|
79
|
+
if (annotated && seq !== undefined) {
|
|
80
|
+
recordAnnotationChild(name, member, annotated.annotation, durationMs, seq);
|
|
81
|
+
}
|
|
82
|
+
return wrap(raw, seq);
|
|
83
|
+
};
|
|
84
|
+
const fail = (error: unknown): never => {
|
|
85
|
+
// There was no annotated return to replace the implementation. Preserve
|
|
86
|
+
// service failures' underlying diagnostics; fake internals stay hidden.
|
|
87
|
+
recording.finish(kind === "service");
|
|
88
|
+
if (kind === "fake" && recording.isCurrent()) {
|
|
89
|
+
recordFake({ fake: name, member, args: safeArgs!, durationMs: Date.now() - start,
|
|
90
|
+
error: error instanceof Error ? error.message : String(error) }, reservation);
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
};
|
|
94
|
+
let result: unknown;
|
|
95
|
+
try {
|
|
96
|
+
result = recording.run(() => kind === "fake"
|
|
97
|
+
? runUninstrumented(() => fn.apply(receiver, args))
|
|
98
|
+
: fn.apply(receiver, args));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return fail(error);
|
|
101
|
+
}
|
|
102
|
+
return result instanceof Promise ? result.then(finish, fail) : finish(result);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function recordHelper(
|
|
106
|
+
kind: HelperKind, name: string, member: string, args: unknown[], result: unknown,
|
|
107
|
+
durationMs: number, reservation: EventReservation | undefined,
|
|
108
|
+
): number | undefined {
|
|
109
|
+
if (kind === "fake") {
|
|
110
|
+
return recordFake({ fake: name, member, args, result: safeSerialize(result), durationMs }, reservation);
|
|
111
|
+
}
|
|
112
|
+
return recordServiceHelper({ service: name, member, args, result: safeSerialize(result), durationMs }, reservation);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Record a helper call's render annotation as a child of the call's
|
|
116
|
+
* own event — the same `parentSeq` grouping `ctx.poll` uses for the
|
|
117
|
+
* iteration it kept, so the annotated view folds into the helper step's
|
|
118
|
+
* detail panel instead of taking a timeline row of its own.
|
|
119
|
+
*
|
|
120
|
+
* The child is the event kind that already knows how to draw this: an
|
|
121
|
+
* `email` annotation records the very event the built-in `email()`
|
|
122
|
+
* component's mailbox helpers record, so it renders with no new code. It
|
|
123
|
+
* carries the helper's name and member as its service/op, and the parent's
|
|
124
|
+
* duration, since it describes that same call.
|
|
125
|
+
*
|
|
126
|
+
* Only the success path is annotated: a helper that threw returned no value
|
|
127
|
+
* to annotate. */
|
|
128
|
+
function recordAnnotationChild(
|
|
129
|
+
name: string,
|
|
130
|
+
member: string,
|
|
131
|
+
annotation: RenderAnnotation,
|
|
132
|
+
durationMs: number,
|
|
133
|
+
parentSeq: number,
|
|
134
|
+
): void {
|
|
135
|
+
switch (annotation.kind) {
|
|
136
|
+
case "email":
|
|
137
|
+
recordEmail({
|
|
138
|
+
parentSeq,
|
|
139
|
+
annotation: true,
|
|
140
|
+
service: name,
|
|
141
|
+
op: member,
|
|
142
|
+
message: annotation.message,
|
|
143
|
+
messages: annotation.messages,
|
|
144
|
+
count: annotation.count,
|
|
145
|
+
durationMs,
|
|
146
|
+
});
|
|
147
|
+
break;
|
|
148
|
+
case "chat":
|
|
149
|
+
// A chat has no event kind of its own: it describes itself in
|
|
150
|
+
// presentation terms (title + blocks), which is the seam a new step
|
|
151
|
+
// type is supposed to use. `kind` stays an opaque grouping label.
|
|
152
|
+
recordStep({
|
|
153
|
+
kind: "chat",
|
|
154
|
+
parentSeq,
|
|
155
|
+
annotation: true,
|
|
156
|
+
title: annotation.title ?? `${name}.${member}`,
|
|
157
|
+
blocks: annotation.blocks,
|
|
158
|
+
durationMs,
|
|
159
|
+
});
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -36,7 +36,7 @@ export type {
|
|
|
36
36
|
SpectestRequestInit,
|
|
37
37
|
Unwrap,
|
|
38
38
|
} from "./inspect.js";
|
|
39
|
-
import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
39
|
+
import type { OpTag, Provenanced, SpectestFetch, Wrapped, Unwrap } from "./inspect.js";
|
|
40
40
|
|
|
41
41
|
// `field` — a provenance-preserving, null-safe selector: a `null`/`undefined`
|
|
42
42
|
// leaf read off a wrapped op result is raw and untagged, so an `expect(...)` on
|
|
@@ -49,13 +49,9 @@ import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
|
49
49
|
// `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
|
|
50
50
|
export { field } from "./inspect.js";
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// nested event drawing it — an email mail-client style, a conversation as
|
|
56
|
-
// bubbles — which the step's panel leads with, tabbing to the raw JSON
|
|
57
|
-
// value. The kind picks the options' type, and the test still gets the raw
|
|
58
|
-
// value, with the raw value's type — see `annotate.ts`.
|
|
52
|
+
// Fake and service helpers return annotate(value, kind) to render their own
|
|
53
|
+
// result as email/chat. Their boundary opens the box and links assertions to
|
|
54
|
+
// the helper step. Annotated service returns suppress internal operation events.
|
|
59
55
|
export { annotate } from "./annotate.js";
|
|
60
56
|
export type {
|
|
61
57
|
Annotated,
|
|
@@ -768,6 +764,11 @@ export interface ServiceDefinition<
|
|
|
768
764
|
* user chose in the services map and matches the in-VM DNS name; the
|
|
769
765
|
* rest of the {@link ServiceHelpersContext} (exec, projectRoot) lets a
|
|
770
766
|
* component reach into its containers without daemon internals.
|
|
767
|
+
*
|
|
768
|
+
* Direct helper functions may return `annotate(value, "email" | "chat")`.
|
|
769
|
+
* That call becomes one named service step, its internal events are hidden,
|
|
770
|
+
* and the caller receives `Wrapped<T>` linked to the helper. Ordinary returns
|
|
771
|
+
* retain their existing value/type and underlying operation events.
|
|
771
772
|
*/
|
|
772
773
|
helpers?: (args: ServiceHelpersContext) => H | Promise<H>;
|
|
773
774
|
/**
|
|
@@ -816,13 +817,26 @@ type HelpersOf<D> = "helpers" extends keyof D
|
|
|
816
817
|
/**
|
|
817
818
|
* Per-service handles derived from a concrete services map. Only
|
|
818
819
|
* services that ship a `helpers` factory appear here; `ctx.svc.<name>`
|
|
819
|
-
*
|
|
820
|
-
*
|
|
820
|
+
* preserves the factory's live handles and ordinary function results. A
|
|
821
|
+
* function returning `annotate(value, kind)` instead returns a wrapped value
|
|
822
|
+
* linked to one named service-helper step. Services without helpers don't show up,
|
|
821
823
|
* so plain `services: { api: { image: ... } }` adds no noise.
|
|
822
824
|
*/
|
|
823
825
|
export type ServiceHandlesFor<S extends ServicesMap> = {
|
|
824
|
-
[K in keyof S as HelpersOf<S[K]> extends never ? never : K]: HelpersOf<S[K]
|
|
826
|
+
[K in keyof S as HelpersOf<S[K]> extends never ? never : K]: ServiceHelpers<HelpersOf<S[K]>>;
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
/** Only annotated returns change type. Keeping other function types verbatim
|
|
830
|
+
* preserves generics, overloads, and callable clients with attached methods. */
|
|
831
|
+
type ServiceHelpers<H> = {
|
|
832
|
+
[K in keyof H]: H[K] extends (...args: infer A) => infer R
|
|
833
|
+
? Extract<Awaited<R>, Annotated<unknown>> extends never
|
|
834
|
+
? H[K]
|
|
835
|
+
: (...args: A) => R extends Promise<infer V>
|
|
836
|
+
? Promise<ServiceHelperResult<V>> : ServiceHelperResult<R>
|
|
837
|
+
: H[K];
|
|
825
838
|
};
|
|
839
|
+
type ServiceHelperResult<R> = R extends Annotated<infer V> ? Wrapped<Unwrap<V>> : R;
|
|
826
840
|
|
|
827
841
|
/** Loose, runtime-friendly shape of `ctx.svc` for code that doesn't
|
|
828
842
|
* know the concrete services map (the daemon, generic helpers). The
|
|
@@ -2265,7 +2279,7 @@ export function defineFake<
|
|
|
2265
2279
|
export type FakesMap = Record<string, FakeDefinition<any, any>>;
|
|
2266
2280
|
|
|
2267
2281
|
/** Rewrite a helpers record so each function's result is inspect-wrapped
|
|
2268
|
-
* ({@link Wrapped}) — mirroring the runtime, where `
|
|
2282
|
+
* ({@link Wrapped}) — mirroring the runtime, where `trackHelpers` wraps
|
|
2269
2283
|
* every helper return value for assertion provenance. Without this the raw
|
|
2270
2284
|
* return type (e.g. `Charge[]`) reaches `expect`, which the `Provenanced`
|
|
2271
2285
|
* gate rejects. Handles sync and async helpers; non-function members (and
|
|
@@ -2273,9 +2287,9 @@ export type FakesMap = Record<string, FakeDefinition<any, any>>;
|
|
|
2273
2287
|
* `components/k3s.ts`, extended to cover synchronous returns. */
|
|
2274
2288
|
type WrappedHelpers<H> = {
|
|
2275
2289
|
[K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R>
|
|
2276
|
-
? (...args: A) => Promise<Wrapped<Unannotated<R
|
|
2290
|
+
? (...args: A) => Promise<Wrapped<Unwrap<Unannotated<R>>>>
|
|
2277
2291
|
: H[K] extends (...args: infer A) => infer R
|
|
2278
|
-
? (...args: A) => [R] extends [void] ? void : Wrapped<Unannotated<R
|
|
2292
|
+
? (...args: A) => [R] extends [void] ? void : Wrapped<Unwrap<Unannotated<R>>>
|
|
2279
2293
|
: H[K];
|
|
2280
2294
|
};
|
|
2281
2295
|
|