@specific.dev/spectest 0.107.2 → 0.109.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 +33 -158
- package/dist/harness/protocol.d.ts +1 -1
- package/dist/harness/protocol.js +1 -0
- package/dist/helper-tracking.d.ts +7 -0
- package/dist/helper-tracking.js +142 -0
- package/dist/index.d.ts +33 -11
- package/dist/index.js +5 -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 +40 -180
- package/src/harness/protocol.ts +2 -0
- package/src/helper-tracking.test.ts +289 -0
- package/src/helper-tracking.ts +162 -0
- package/src/index.ts +44 -19
- package/src/interactive-setup.test.ts +89 -0
- package/src/recorder.ts +91 -7
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
|
}
|
|
@@ -3633,30 +3473,35 @@ interface ProjectSetupResult {
|
|
|
3633
3473
|
durationMs: number;
|
|
3634
3474
|
}
|
|
3635
3475
|
|
|
3476
|
+
// Captured as false in the shared template, true in a seeded interactive
|
|
3477
|
+
// snapshot. Repeated requests must not apply a successful seed twice.
|
|
3478
|
+
let interactiveSetupCompleted = false;
|
|
3479
|
+
|
|
3636
3480
|
/**
|
|
3637
|
-
* Run
|
|
3638
|
-
*
|
|
3639
|
-
*
|
|
3640
|
-
* exactly once and inherited by every later snapshot/fork.
|
|
3481
|
+
* Run a project setup hook. Normal `setup` runs before the shared warm
|
|
3482
|
+
* snapshot; `setupInteractive` runs only after it, in a fresh user start.
|
|
3483
|
+
* Both use the same context and boot-log capture.
|
|
3641
3484
|
*
|
|
3642
3485
|
* Unlike test runs, this is NOT instrumented — no recorder, no event
|
|
3643
3486
|
* timeline, no timeout from the test runner. Setup failures abort the
|
|
3644
3487
|
* env bring-up; the control plane surfaces them as a start failure.
|
|
3645
3488
|
*/
|
|
3646
|
-
async function runProjectSetup(): Promise<ProjectSetupResult> {
|
|
3489
|
+
async function runProjectSetup(hook: "setup" | "setupInteractive" = "setup"): Promise<ProjectSetupResult> {
|
|
3647
3490
|
// Same tee as bootstrap: a project `setup` hook's console output is
|
|
3648
3491
|
// part of explaining the boot, not something to lose on success.
|
|
3649
3492
|
const restoreBootLog = captureConsole(BOOT_LOG_SINK);
|
|
3650
3493
|
try {
|
|
3651
|
-
return await runProjectSetupInner();
|
|
3494
|
+
return await runProjectSetupInner(hook);
|
|
3652
3495
|
} finally {
|
|
3653
3496
|
restoreBootLog();
|
|
3654
3497
|
}
|
|
3655
3498
|
}
|
|
3656
3499
|
|
|
3657
|
-
async function runProjectSetupInner(): Promise<ProjectSetupResult> {
|
|
3658
|
-
const
|
|
3659
|
-
if (!
|
|
3500
|
+
async function runProjectSetupInner(hook: "setup" | "setupInteractive"): Promise<ProjectSetupResult> {
|
|
3501
|
+
const setup = requireLoaded().project[hook];
|
|
3502
|
+
if (!setup || (hook === "setupInteractive" && interactiveSetupCompleted)) {
|
|
3503
|
+
return { ran: false, durationMs: 0 };
|
|
3504
|
+
}
|
|
3660
3505
|
const start = Date.now();
|
|
3661
3506
|
// Install the fetch wrapper for the duration of setup so `ctx.fetch` (and any
|
|
3662
3507
|
// client routed through `globalThis.fetch`) returns a wrapped Response, same
|
|
@@ -3667,9 +3512,10 @@ async function runProjectSetupInner(): Promise<ProjectSetupResult> {
|
|
|
3667
3512
|
// The full context, unscoped: every service is up by project-setup time,
|
|
3668
3513
|
// so `ctx.svc` is the same map tests see (and shares helper instances with
|
|
3669
3514
|
// them — a Bun.SQL pool created here is reused later).
|
|
3670
|
-
const ctx: ProjectSetupContext = await spectestContext();
|
|
3671
3515
|
try {
|
|
3672
|
-
|
|
3516
|
+
const ctx: ProjectSetupContext = await spectestContext();
|
|
3517
|
+
await runInstrumented(fetchScope, () => setup(ctx));
|
|
3518
|
+
if (hook === "setupInteractive") interactiveSetupCompleted = true;
|
|
3673
3519
|
} finally {
|
|
3674
3520
|
fetchScope.active = false;
|
|
3675
3521
|
restoreFetch();
|
|
@@ -4805,10 +4651,10 @@ async function ensureHelpers(
|
|
|
4805
4651
|
try {
|
|
4806
4652
|
HELPERS_CACHE.set(
|
|
4807
4653
|
name,
|
|
4808
|
-
await def.helpers({
|
|
4654
|
+
trackHelpers("service", name, await def.helpers({
|
|
4809
4655
|
name,
|
|
4810
4656
|
...(await spectestContext({ service: name, includeSelf: false })),
|
|
4811
|
-
}),
|
|
4657
|
+
})),
|
|
4812
4658
|
);
|
|
4813
4659
|
} finally {
|
|
4814
4660
|
HELPERS_BUILDING.delete(name);
|
|
@@ -4822,8 +4668,8 @@ const HELPERS_BUILDING = new Set<string>();
|
|
|
4822
4668
|
|
|
4823
4669
|
/**
|
|
4824
4670
|
* Build the `svc` map for one test/eval. The value at `svc[name]` is
|
|
4825
|
-
*
|
|
4826
|
-
*
|
|
4671
|
+
* a shallow helper proxy: annotated returns become named service steps;
|
|
4672
|
+
* ordinary returns and live client handles pass through. Services without a
|
|
4827
4673
|
* `helpers` factory don't appear in the map at all.
|
|
4828
4674
|
*/
|
|
4829
4675
|
async function buildServiceHandles(cfg: EnvironmentConfig): Promise<ServiceHandles> {
|
|
@@ -6308,7 +6154,13 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6308
6154
|
setRecordSecrets(secrets as Record<string, string>);
|
|
6309
6155
|
};
|
|
6310
6156
|
return {
|
|
6311
|
-
health: async () => ({
|
|
6157
|
+
health: async () => ({
|
|
6158
|
+
ok: true,
|
|
6159
|
+
capabilities: ["prepared-images-v1"],
|
|
6160
|
+
// Optional discovery: old SDKs omit this field and are never sent the
|
|
6161
|
+
// new method. Check the loaded project, including after warm restore.
|
|
6162
|
+
setupInteractive: typeof loaded?.project.setupInteractive === "function",
|
|
6163
|
+
}),
|
|
6312
6164
|
|
|
6313
6165
|
// Live bootstrap progress, polled by the control plane during
|
|
6314
6166
|
// `bootstrap` and streamed into the test-run row. `{}` before the
|
|
@@ -6445,6 +6297,14 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6445
6297
|
() => runProjectSetup(),
|
|
6446
6298
|
),
|
|
6447
6299
|
|
|
6300
|
+
interactiveSetup: async () =>
|
|
6301
|
+
exclusive(
|
|
6302
|
+
state,
|
|
6303
|
+
"inFlightProjectSetup",
|
|
6304
|
+
"project-setup already in progress",
|
|
6305
|
+
() => runProjectSetup("setupInteractive"),
|
|
6306
|
+
),
|
|
6307
|
+
|
|
6448
6308
|
eval: async (params) => {
|
|
6449
6309
|
const code = requireString(params, "code");
|
|
6450
6310
|
return exclusive(state, "inFlightTest", "a test or eval is already running", () => {
|
package/src/harness/protocol.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type Method =
|
|
|
32
32
|
| "loadTests"
|
|
33
33
|
| "fingerprint"
|
|
34
34
|
| "bootstrap"
|
|
35
|
+
| "interactiveSetup"
|
|
35
36
|
| "run"
|
|
36
37
|
| "eval"
|
|
37
38
|
| "setRecordSecrets"
|
|
@@ -51,6 +52,7 @@ export const KNOWN_METHODS: ReadonlySet<string> = new Set<Method>([
|
|
|
51
52
|
"loadTests",
|
|
52
53
|
"fingerprint",
|
|
53
54
|
"bootstrap",
|
|
55
|
+
"interactiveSetup",
|
|
54
56
|
"run",
|
|
55
57
|
"eval",
|
|
56
58
|
"setRecordSecrets",
|
|
@@ -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"); }
|