@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
|
@@ -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
|
|
|
@@ -2363,18 +2377,26 @@ export interface Project<
|
|
|
2363
2377
|
* appear in the timeline.
|
|
2364
2378
|
*/
|
|
2365
2379
|
setup?: ProjectSetupFn<S, F>;
|
|
2380
|
+
/**
|
|
2381
|
+
* Prepare a new interactive environment, after normal setup and the shared
|
|
2382
|
+
* warm-template snapshot. Runs on both cold starts and warm restores from
|
|
2383
|
+
* the dashboard or `spectest env start`, before the environment is ready.
|
|
2384
|
+
* Never runs for tests, snapshot forks, or resuming a stopped environment.
|
|
2385
|
+
* Uses the same context as `setup`; throw to fail the start.
|
|
2386
|
+
*/
|
|
2387
|
+
setupInteractive?: ProjectSetupFn<S, F>;
|
|
2366
2388
|
tests?: TestSuite<S, F>;
|
|
2367
2389
|
}
|
|
2368
2390
|
|
|
2369
2391
|
/**
|
|
2370
|
-
* Context handed to the project-level `setup`
|
|
2392
|
+
* Context handed to the project-level `setup` and `setupInteractive` hooks: the full
|
|
2371
2393
|
* {@link SpectestContext}, minus only what a timeline gives a test
|
|
2372
2394
|
* (testName, parent, browser/mobile/terminal). Everything a test can do
|
|
2373
2395
|
* to the environment — exec, fetch, `svc`/`fakes` helpers, `poll`,
|
|
2374
2396
|
* `dnsName`, `certificate`, `startService`, and project-file access via
|
|
2375
|
-
* `projectRoot`/`readProjectFile` — is available here too
|
|
2376
|
-
*
|
|
2377
|
-
*
|
|
2397
|
+
* `projectRoot`/`readProjectFile` — is available here too. Normal `setup`
|
|
2398
|
+
* state is captured into the shared warm template; `setupInteractive`
|
|
2399
|
+
* state belongs only to the new interactive environment.
|
|
2378
2400
|
*/
|
|
2379
2401
|
export interface ProjectSetupContext<
|
|
2380
2402
|
S extends ServicesMap = ServicesMap,
|
|
@@ -2423,6 +2445,8 @@ export interface ProjectOpts<
|
|
|
2423
2445
|
> {
|
|
2424
2446
|
/** Optional project-level setup. See `Project.setup`. */
|
|
2425
2447
|
setup?: ProjectSetupFn<S, F>;
|
|
2448
|
+
/** Optional interactive-only setup. See `Project.setupInteractive`. */
|
|
2449
|
+
setupInteractive?: ProjectSetupFn<S, F>;
|
|
2426
2450
|
/** Test cases for this project's suite. */
|
|
2427
2451
|
tests?: TestCase<unknown, S, F>[];
|
|
2428
2452
|
}
|
|
@@ -2487,7 +2511,7 @@ export interface DefinedEnvironment<
|
|
|
2487
2511
|
): TestGroup<P, S, F>;
|
|
2488
2512
|
/** Bundle this environment with a test suite into the project default
|
|
2489
2513
|
* export. Pass tests as a plain array for the common case, or an
|
|
2490
|
-
* options bag to attach
|
|
2514
|
+
* options bag to attach `setup` or `setupInteractive`. (Fakes are declared on
|
|
2491
2515
|
* `defineEnvironment`, not here.) */
|
|
2492
2516
|
project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
|
|
2493
2517
|
project(opts: ProjectOpts<S, F>): Project<S, F>;
|
|
@@ -2639,6 +2663,7 @@ export function defineEnvironment<
|
|
|
2639
2663
|
: (arg ?? {});
|
|
2640
2664
|
const proj: Project<S, F> = { environment: config };
|
|
2641
2665
|
if (opts.setup) proj.setup = opts.setup;
|
|
2666
|
+
if (opts.setupInteractive) proj.setupInteractive = opts.setupInteractive;
|
|
2642
2667
|
if (fakes) proj.fakes = fakes;
|
|
2643
2668
|
if (opts.tests && opts.tests.length > 0) {
|
|
2644
2669
|
// Explicit suite (single-file / legacy layout): the tests are listed
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// The daemon owns globals and snapshot-resident state. Use a fresh process,
|
|
7
|
+
// just as each freshly built environment has its own harness.
|
|
8
|
+
for (const withHook of [false, true]) {
|
|
9
|
+
test(`interactive setup: ${withHook ? "context, failure, exclusion and one-shot state" : "optional hook"}`, async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), "spectest-interactive-setup-"));
|
|
11
|
+
try {
|
|
12
|
+
await mkdir(join(root, "spectest"));
|
|
13
|
+
await mkdir(join(root, "bin"));
|
|
14
|
+
await writeFile(join(root, "bin/docker"), '#!/bin/sh\nprintf "%s\\n" "$*"\n', { mode: 0o755 });
|
|
15
|
+
const entry = join(root, "spectest/index.ts");
|
|
16
|
+
await writeFile(entry, `
|
|
17
|
+
import { defineEnvironment } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)};
|
|
18
|
+
export const calls = [];
|
|
19
|
+
const env = defineEnvironment({ name: "seed", services: {
|
|
20
|
+
app: { image: { type: "registry", reference: "busybox" },
|
|
21
|
+
helpers: () => ({ label: "typed helper" }) },
|
|
22
|
+
}});
|
|
23
|
+
export default env.project({
|
|
24
|
+
setup: () => { calls.push("baseline"); },
|
|
25
|
+
${withHook ? `setupInteractive: async (ctx) => {
|
|
26
|
+
calls.push("interactive");
|
|
27
|
+
console.log("seeding demo data");
|
|
28
|
+
await globalThis.seedGate;
|
|
29
|
+
if (globalThis.failSeed) throw new Error("seed rejected");
|
|
30
|
+
const response = await ctx.fetch("data:application/json,%7B%22ok%22%3Atrue%7D");
|
|
31
|
+
if (response.status.unwrap() !== 200) throw new Error("fetch not wrapped");
|
|
32
|
+
if (ctx.svc.app.label !== "typed helper") throw new Error("missing helpers");
|
|
33
|
+
if ("browser" in ctx || "testName" in ctx) throw new Error("test context leaked");
|
|
34
|
+
const result = await ctx.exec("app", ["seed", "demo"]);
|
|
35
|
+
if (typeof result.exitCode !== "number" || !result.stdout.includes("seed demo")) {
|
|
36
|
+
throw new Error("exec result not plain: " + JSON.stringify(result));
|
|
37
|
+
}
|
|
38
|
+
},` : ""}
|
|
39
|
+
});
|
|
40
|
+
`);
|
|
41
|
+
const script = `
|
|
42
|
+
import { strict as assert } from "node:assert";
|
|
43
|
+
import { METHODS } from ${JSON.stringify(new URL("./daemon.ts", import.meta.url).href)};
|
|
44
|
+
import { calls } from ${JSON.stringify(entry)};
|
|
45
|
+
const nativeFetch = globalThis.fetch;
|
|
46
|
+
await METHODS.load({});
|
|
47
|
+
assert.equal((await METHODS.health({})).setupInteractive, ${withHook});
|
|
48
|
+
await METHODS.projectSetup({});
|
|
49
|
+
assert.deepEqual(calls, ["baseline"]);
|
|
50
|
+
${withHook ? `
|
|
51
|
+
let release;
|
|
52
|
+
globalThis.seedGate = new Promise(r => { release = r; });
|
|
53
|
+
globalThis.failSeed = true;
|
|
54
|
+
const failed = METHODS.interactiveSetup({});
|
|
55
|
+
await assert.rejects(METHODS.interactiveSetup({}), /already in progress/);
|
|
56
|
+
await assert.rejects(METHODS.projectSetup({}), /already in progress/);
|
|
57
|
+
release();
|
|
58
|
+
await assert.rejects(failed, /seed rejected/);
|
|
59
|
+
assert.equal(globalThis.fetch, nativeFetch);
|
|
60
|
+
globalThis.failSeed = false;
|
|
61
|
+
assert.equal((await METHODS.interactiveSetup({})).ran, true);
|
|
62
|
+
assert.deepEqual(calls, ["baseline", "interactive", "interactive"]);
|
|
63
|
+
// A repeated request (or a resumed seeded snapshot) cannot reseed.
|
|
64
|
+
assert.equal((await METHODS.interactiveSetup({})).ran, false);
|
|
65
|
+
assert.deepEqual(calls, ["baseline", "interactive", "interactive"]);
|
|
66
|
+
const logs = await METHODS.envLogs({});
|
|
67
|
+
assert.ok(logs.bootLog.includes("seeding demo data"));
|
|
68
|
+
` : `
|
|
69
|
+
assert.equal((await METHODS.interactiveSetup({})).ran, false);
|
|
70
|
+
assert.deepEqual(calls, ["baseline"]);
|
|
71
|
+
`}
|
|
72
|
+
assert.equal(globalThis.fetch, nativeFetch);
|
|
73
|
+
// envLogs' subprocess timeout keeps a timer alive after it returns;
|
|
74
|
+
// this fixture tests hook behavior, not daemon process lifetime.
|
|
75
|
+
process.exit(0);
|
|
76
|
+
`;
|
|
77
|
+
const child = Bun.spawn([process.execPath, "--eval", script], {
|
|
78
|
+
env: { ...process.env, SPECTEST_APP_DIR: root, PATH: `${root}/bin:${process.env.PATH}` },
|
|
79
|
+
stdout: "pipe", stderr: "pipe",
|
|
80
|
+
});
|
|
81
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
82
|
+
child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
|
|
83
|
+
]);
|
|
84
|
+
expect(code, stdout + stderr).toBe(0);
|
|
85
|
+
} finally {
|
|
86
|
+
await rm(root, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
}, 20_000);
|
|
89
|
+
}
|
package/src/recorder.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// current recorder is `null` and the `record*` helpers are no-ops, so
|
|
9
9
|
// callers can invoke them unconditionally.
|
|
10
10
|
|
|
11
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
12
|
import type { AndroidStepDetails } from "./android-step.js";
|
|
12
13
|
import { clearPendingNullish } from "./inspect.js";
|
|
13
14
|
import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
|
|
@@ -27,6 +28,7 @@ export type TestEvent =
|
|
|
27
28
|
| TerminalStepEvent
|
|
28
29
|
| WaitEvent
|
|
29
30
|
| FakeEvent
|
|
31
|
+
| ServiceHelperEvent
|
|
30
32
|
| EnvEvent
|
|
31
33
|
| EmailEvent
|
|
32
34
|
// Open `kind`, so it goes last: narrowing the union by a literal kind
|
|
@@ -339,6 +341,14 @@ export interface FakeEvent extends BaseEvent {
|
|
|
339
341
|
error?: string;
|
|
340
342
|
}
|
|
341
343
|
|
|
344
|
+
/** An annotated service helper has the same call/return shape as a fake.
|
|
345
|
+
* Only helpers returning an annotation emit this event. */
|
|
346
|
+
export interface ServiceHelperEvent extends Omit<FakeEvent, "kind" | "fake"> {
|
|
347
|
+
kind: "service";
|
|
348
|
+
/** Service name — the key in `ctx.svc`. */
|
|
349
|
+
service: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
342
352
|
/**
|
|
343
353
|
* One captured email, as embedded on an {@link EmailEvent}. The HTML and
|
|
344
354
|
* text bodies ride along (truncated to the output cap) so the dashboard
|
|
@@ -631,7 +641,12 @@ export interface EventReservation {
|
|
|
631
641
|
}
|
|
632
642
|
|
|
633
643
|
class Recorder {
|
|
634
|
-
private
|
|
644
|
+
private rootEvents: TestEvent[] = [];
|
|
645
|
+
readonly pendingHelpers = new Set<RecordingBranch>();
|
|
646
|
+
private get events(): TestEvent[] {
|
|
647
|
+
const branch = recordingBranches.getStore();
|
|
648
|
+
return branch?.owner === this ? branchEvents(branch) : this.rootEvents;
|
|
649
|
+
}
|
|
635
650
|
private seq = 0;
|
|
636
651
|
constructor(private readonly start = Date.now()) {}
|
|
637
652
|
|
|
@@ -733,6 +748,60 @@ class Recorder {
|
|
|
733
748
|
|
|
734
749
|
let current: Recorder | null = null;
|
|
735
750
|
|
|
751
|
+
interface RecordingBranch {
|
|
752
|
+
owner: Recorder;
|
|
753
|
+
events: TestEvent[];
|
|
754
|
+
parent: RecordingBranch | undefined;
|
|
755
|
+
destination: TestEvent[];
|
|
756
|
+
state: "open" | "kept" | "discarded";
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const recordingBranches = new AsyncLocalStorage<RecordingBranch>();
|
|
760
|
+
|
|
761
|
+
function branchEvents(branch: RecordingBranch): TestEvent[] {
|
|
762
|
+
return branch.state === "kept"
|
|
763
|
+
? branch.parent ? branchEvents(branch.parent) : branch.destination
|
|
764
|
+
: branch.events;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function branchAllowsRecording(branch: RecordingBranch | undefined): boolean {
|
|
768
|
+
return !branch || (branch.owner === current && branch.state !== "discarded" &&
|
|
769
|
+
branchAllowsRecording(branch.parent));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Buffer a helper's async descendants until its return chooses between the
|
|
773
|
+
* implementation's events and one annotated helper call. Seqs still come from
|
|
774
|
+
* the test recorder, so concurrent calls and linked assertions stay unique.
|
|
775
|
+
* Poll truncation/grouping inside the helper applies only to its own buffer. */
|
|
776
|
+
export function captureRecording(): {
|
|
777
|
+
run<T>(fn: () => T): T;
|
|
778
|
+
finish(keep: boolean): void;
|
|
779
|
+
isCurrent(): boolean;
|
|
780
|
+
} {
|
|
781
|
+
if (!active()) return { run: (fn) => fn(), finish: () => {}, isCurrent: () => false };
|
|
782
|
+
const branch: RecordingBranch = {
|
|
783
|
+
owner: current!, events: [], parent: recordingBranches.getStore(),
|
|
784
|
+
destination: current!.drain(), state: "open",
|
|
785
|
+
};
|
|
786
|
+
current!.pendingHelpers.add(branch);
|
|
787
|
+
return {
|
|
788
|
+
run: (fn) => recordingBranches.run(branch, fn),
|
|
789
|
+
isCurrent: () => branch.owner === current && active(),
|
|
790
|
+
finish(keep) {
|
|
791
|
+
if (branch.state !== "open") return;
|
|
792
|
+
branch.owner.pendingHelpers.delete(branch);
|
|
793
|
+
if (keep && branchAllowsRecording(branch)) {
|
|
794
|
+
const destination = branch.parent ? branchEvents(branch.parent) : branch.destination;
|
|
795
|
+
for (const event of branch.events) destination.push(event);
|
|
796
|
+
branch.state = "kept";
|
|
797
|
+
} else {
|
|
798
|
+
branch.state = "discarded";
|
|
799
|
+
}
|
|
800
|
+
branch.events.length = 0;
|
|
801
|
+
},
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
736
805
|
// Counter-based pause: `pauseRecording`/`resumeRecording` nest safely
|
|
737
806
|
// (a nested `ctx.poll` inside another `ctx.poll`'s predicate stays
|
|
738
807
|
// suppressed until its own resume balances out). Active record* sites
|
|
@@ -751,7 +820,7 @@ export function resumeRecording(): void {
|
|
|
751
820
|
}
|
|
752
821
|
|
|
753
822
|
function active(): boolean {
|
|
754
|
-
return current !== null && paused === 0 && scopeAllowsRecording();
|
|
823
|
+
return current !== null && paused === 0 && scopeAllowsRecording() && branchAllowsRecording(recordingBranches.getStore());
|
|
755
824
|
}
|
|
756
825
|
|
|
757
826
|
export function startRecording(startedAt = Date.now()): void {
|
|
@@ -762,6 +831,18 @@ export function startRecording(startedAt = Date.now()): void {
|
|
|
762
831
|
export function stopRecording(): TestEvent[] {
|
|
763
832
|
if (!current) return [];
|
|
764
833
|
const evs = current.drain();
|
|
834
|
+
// A timeout can stop the test before a helper returns. Keep its available
|
|
835
|
+
// diagnostics, then fence detached work out of any subsequent recording.
|
|
836
|
+
for (const branch of current.pendingHelpers) {
|
|
837
|
+
if (branchAllowsRecording(branch)) {
|
|
838
|
+
for (const event of branch.events) evs.push(event);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
for (const branch of current.pendingHelpers) {
|
|
842
|
+
branch.events.length = 0;
|
|
843
|
+
branch.state = "discarded";
|
|
844
|
+
}
|
|
845
|
+
current.pendingHelpers.clear();
|
|
765
846
|
current = null;
|
|
766
847
|
paused = 0;
|
|
767
848
|
return evs;
|
|
@@ -862,6 +943,13 @@ export function recordFake(
|
|
|
862
943
|
return active() ? current!.push({ kind: "fake", ...ev }, reservation) : undefined;
|
|
863
944
|
}
|
|
864
945
|
|
|
946
|
+
export function recordServiceHelper(
|
|
947
|
+
ev: Omit<ServiceHelperEvent, "seq" | "tOffsetMs" | "kind">,
|
|
948
|
+
reservation?: EventReservation,
|
|
949
|
+
): number | undefined {
|
|
950
|
+
return active() ? current!.push({ kind: "service", ...ev }, reservation) : undefined;
|
|
951
|
+
}
|
|
952
|
+
|
|
865
953
|
export function recordEnv(
|
|
866
954
|
ev: Omit<EnvEvent, "seq" | "tOffsetMs" | "kind">,
|
|
867
955
|
reservation?: EventReservation,
|
|
@@ -992,11 +1080,7 @@ export function recordWait(
|
|
|
992
1080
|
ev: Omit<WaitEvent, "seq" | "tOffsetMs" | "kind">,
|
|
993
1081
|
reservation?: EventReservation,
|
|
994
1082
|
): number | undefined {
|
|
995
|
-
|
|
996
|
-
// end of a `ctx.poll` block lands even though the surrounding code
|
|
997
|
-
// just resumed from `paused`. Pushing this event is the whole point
|
|
998
|
-
// of the poll primitive.
|
|
999
|
-
return current?.push({ kind: "wait", ...ev }, reservation);
|
|
1083
|
+
return active() ? current!.push({ kind: "wait", ...ev }, reservation) : undefined;
|
|
1000
1084
|
}
|
|
1001
1085
|
|
|
1002
1086
|
/** Number of events the active recorder has accumulated. Returns 0
|