@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/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Android, AndroidApp } from "./android.js";
|
|
|
2
2
|
export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
|
|
3
3
|
import { strict as nodeAssert } from "node:assert";
|
|
4
4
|
export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, WrappedBuffer, WrappedBinaryView, WrappedBytes, WrappedBlob, WrappedFile, WrappedFormData, WrappedStream, WrappedReader, WrappedBYOBReader, WrappedReadResult, WrappedResponse, Provenanced, SpectestFetch, SpectestRequestInit, Unwrap, } from "./inspect.js";
|
|
5
|
-
import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
5
|
+
import type { Provenanced, SpectestFetch, Wrapped, Unwrap } from "./inspect.js";
|
|
6
6
|
export { field } from "./inspect.js";
|
|
7
7
|
export { annotate } from "./annotate.js";
|
|
8
8
|
export type { Annotated, AnnotationKind, AnnotationOptions, ChatAnnotation, ChatMessageAnnotation, ChatReactionAnnotation, EmailAnnotation, } from "./annotate.js";
|
|
@@ -563,6 +563,11 @@ export interface ServiceDefinition<H extends Record<string, any> = Record<string
|
|
|
563
563
|
* user chose in the services map and matches the in-VM DNS name; the
|
|
564
564
|
* rest of the {@link ServiceHelpersContext} (exec, projectRoot) lets a
|
|
565
565
|
* component reach into its containers without daemon internals.
|
|
566
|
+
*
|
|
567
|
+
* Direct helper functions may return `annotate(value, "email" | "chat")`.
|
|
568
|
+
* That call becomes one named service step, its internal events are hidden,
|
|
569
|
+
* and the caller receives `Wrapped<T>` linked to the helper. Ordinary returns
|
|
570
|
+
* retain their existing value/type and underlying operation events.
|
|
566
571
|
*/
|
|
567
572
|
helpers?: (args: ServiceHelpersContext) => H | Promise<H>;
|
|
568
573
|
/**
|
|
@@ -600,13 +605,20 @@ type HelpersOf<D> = "helpers" extends keyof D ? [NonNullable<D["helpers"]>] exte
|
|
|
600
605
|
/**
|
|
601
606
|
* Per-service handles derived from a concrete services map. Only
|
|
602
607
|
* services that ship a `helpers` factory appear here; `ctx.svc.<name>`
|
|
603
|
-
*
|
|
604
|
-
*
|
|
608
|
+
* preserves the factory's live handles and ordinary function results. A
|
|
609
|
+
* function returning `annotate(value, kind)` instead returns a wrapped value
|
|
610
|
+
* linked to one named service-helper step. Services without helpers don't show up,
|
|
605
611
|
* so plain `services: { api: { image: ... } }` adds no noise.
|
|
606
612
|
*/
|
|
607
613
|
export type ServiceHandlesFor<S extends ServicesMap> = {
|
|
608
|
-
[K in keyof S as HelpersOf<S[K]> extends never ? never : K]: HelpersOf<S[K]
|
|
614
|
+
[K in keyof S as HelpersOf<S[K]> extends never ? never : K]: ServiceHelpers<HelpersOf<S[K]>>;
|
|
615
|
+
};
|
|
616
|
+
/** Only annotated returns change type. Keeping other function types verbatim
|
|
617
|
+
* preserves generics, overloads, and callable clients with attached methods. */
|
|
618
|
+
type ServiceHelpers<H> = {
|
|
619
|
+
[K in keyof H]: H[K] extends (...args: infer A) => infer R ? Extract<Awaited<R>, Annotated<unknown>> extends never ? H[K] : (...args: A) => R extends Promise<infer V> ? Promise<ServiceHelperResult<V>> : ServiceHelperResult<R> : H[K];
|
|
609
620
|
};
|
|
621
|
+
type ServiceHelperResult<R> = R extends Annotated<infer V> ? Wrapped<Unwrap<V>> : R;
|
|
610
622
|
/** Loose, runtime-friendly shape of `ctx.svc` for code that doesn't
|
|
611
623
|
* know the concrete services map (the daemon, generic helpers). The
|
|
612
624
|
* typed `ctx.svc` in test bodies is `ServiceHandlesFor<S>`. */
|
|
@@ -1543,14 +1555,14 @@ export declare function defineFake<S = any, H extends Record<string, unknown> =
|
|
|
1543
1555
|
* at use sites. */
|
|
1544
1556
|
export type FakesMap = Record<string, FakeDefinition<any, any>>;
|
|
1545
1557
|
/** Rewrite a helpers record so each function's result is inspect-wrapped
|
|
1546
|
-
* ({@link Wrapped}) — mirroring the runtime, where `
|
|
1558
|
+
* ({@link Wrapped}) — mirroring the runtime, where `trackHelpers` wraps
|
|
1547
1559
|
* every helper return value for assertion provenance. Without this the raw
|
|
1548
1560
|
* return type (e.g. `Charge[]`) reaches `expect`, which the `Provenanced`
|
|
1549
1561
|
* gate rejects. Handles sync and async helpers; non-function members (and
|
|
1550
1562
|
* `void` side-effect helpers) pass through untouched. Mirrors `Tagged<T>` in
|
|
1551
1563
|
* `components/k3s.ts`, extended to cover synchronous returns. */
|
|
1552
1564
|
type WrappedHelpers<H> = {
|
|
1553
|
-
[K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Wrapped<Unannotated<R
|
|
1565
|
+
[K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Wrapped<Unwrap<Unannotated<R>>>> : H[K] extends (...args: infer A) => infer R ? (...args: A) => [R] extends [void] ? void : Wrapped<Unwrap<Unannotated<R>>> : H[K];
|
|
1554
1566
|
};
|
|
1555
1567
|
/** A helper's return type as the *test* sees it. {@link annotate} boxes the
|
|
1556
1568
|
* value in an {@link Annotated} to pick how the step renders; the daemon
|
|
@@ -1612,17 +1624,25 @@ export interface Project<S extends ServicesMap = ServicesMap, F extends FakesMap
|
|
|
1612
1624
|
* appear in the timeline.
|
|
1613
1625
|
*/
|
|
1614
1626
|
setup?: ProjectSetupFn<S, F>;
|
|
1627
|
+
/**
|
|
1628
|
+
* Prepare a new interactive environment, after normal setup and the shared
|
|
1629
|
+
* warm-template snapshot. Runs on both cold starts and warm restores from
|
|
1630
|
+
* the dashboard or `spectest env start`, before the environment is ready.
|
|
1631
|
+
* Never runs for tests, snapshot forks, or resuming a stopped environment.
|
|
1632
|
+
* Uses the same context as `setup`; throw to fail the start.
|
|
1633
|
+
*/
|
|
1634
|
+
setupInteractive?: ProjectSetupFn<S, F>;
|
|
1615
1635
|
tests?: TestSuite<S, F>;
|
|
1616
1636
|
}
|
|
1617
1637
|
/**
|
|
1618
|
-
* Context handed to the project-level `setup`
|
|
1638
|
+
* Context handed to the project-level `setup` and `setupInteractive` hooks: the full
|
|
1619
1639
|
* {@link SpectestContext}, minus only what a timeline gives a test
|
|
1620
1640
|
* (testName, parent, browser/mobile/terminal). Everything a test can do
|
|
1621
1641
|
* to the environment — exec, fetch, `svc`/`fakes` helpers, `poll`,
|
|
1622
1642
|
* `dnsName`, `certificate`, `startService`, and project-file access via
|
|
1623
|
-
* `projectRoot`/`readProjectFile` — is available here too
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1643
|
+
* `projectRoot`/`readProjectFile` — is available here too. Normal `setup`
|
|
1644
|
+
* state is captured into the shared warm template; `setupInteractive`
|
|
1645
|
+
* state belongs only to the new interactive environment.
|
|
1626
1646
|
*/
|
|
1627
1647
|
export interface ProjectSetupContext<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> extends SpectestContext<S, F> {
|
|
1628
1648
|
}
|
|
@@ -1661,6 +1681,8 @@ export type ProjectSetupFn<S extends ServicesMap = ServicesMap, F extends FakesM
|
|
|
1661
1681
|
export interface ProjectOpts<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
|
|
1662
1682
|
/** Optional project-level setup. See `Project.setup`. */
|
|
1663
1683
|
setup?: ProjectSetupFn<S, F>;
|
|
1684
|
+
/** Optional interactive-only setup. See `Project.setupInteractive`. */
|
|
1685
|
+
setupInteractive?: ProjectSetupFn<S, F>;
|
|
1664
1686
|
/** Test cases for this project's suite. */
|
|
1665
1687
|
tests?: TestCase<unknown, S, F>[];
|
|
1666
1688
|
}
|
|
@@ -1714,7 +1736,7 @@ export interface DefinedEnvironment<S extends ServicesMap, F extends FakesMap =
|
|
|
1714
1736
|
group<P = undefined>(name: string, opts?: GroupOpts<P, S, F>): TestGroup<P, S, F>;
|
|
1715
1737
|
/** Bundle this environment with a test suite into the project default
|
|
1716
1738
|
* export. Pass tests as a plain array for the common case, or an
|
|
1717
|
-
* options bag to attach
|
|
1739
|
+
* options bag to attach `setup` or `setupInteractive`. (Fakes are declared on
|
|
1718
1740
|
* `defineEnvironment`, not here.) */
|
|
1719
1741
|
project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
|
|
1720
1742
|
project(opts: ProjectOpts<S, F>): Project<S, F>;
|
package/dist/index.js
CHANGED
|
@@ -17,13 +17,9 @@ import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
|
|
|
17
17
|
// always wrapped (in every context), so the method is always there; there is no
|
|
18
18
|
// `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
|
|
19
19
|
export { field } from "./inspect.js";
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// nested event drawing it — an email mail-client style, a conversation as
|
|
24
|
-
// bubbles — which the step's panel leads with, tabbing to the raw JSON
|
|
25
|
-
// value. The kind picks the options' type, and the test still gets the raw
|
|
26
|
-
// value, with the raw value's type — see `annotate.ts`.
|
|
20
|
+
// Fake and service helpers return annotate(value, kind) to render their own
|
|
21
|
+
// result as email/chat. Their boundary opens the box and links assertions to
|
|
22
|
+
// the helper step. Annotated service returns suppress internal operation events.
|
|
27
23
|
export { annotate } from "./annotate.js";
|
|
28
24
|
// Instrumented client primitives — drop-in replacements for Bun's native
|
|
29
25
|
// clients that record each operation on the test event log and return their
|
|
@@ -488,6 +484,8 @@ export function defineEnvironment(input) {
|
|
|
488
484
|
const proj = { environment: config };
|
|
489
485
|
if (opts.setup)
|
|
490
486
|
proj.setup = opts.setup;
|
|
487
|
+
if (opts.setupInteractive)
|
|
488
|
+
proj.setupInteractive = opts.setupInteractive;
|
|
491
489
|
if (fakes)
|
|
492
490
|
proj.fakes = fakes;
|
|
493
491
|
if (opts.tests && opts.tests.length > 0) {
|
package/dist/recorder.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AndroidStepDetails } from "./android-step.js";
|
|
2
|
-
export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | EnvEvent | EmailEvent | StepEvent;
|
|
2
|
+
export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | ServiceHelperEvent | EnvEvent | EmailEvent | StepEvent;
|
|
3
3
|
interface BaseEvent {
|
|
4
4
|
/** Order of *start* within the test. Reserved when an op begins (see
|
|
5
5
|
* `reserveEvent`), so an op whose nested children finish — and record —
|
|
@@ -294,6 +294,13 @@ export interface FakeEvent extends BaseEvent {
|
|
|
294
294
|
/** Set if the helper threw. */
|
|
295
295
|
error?: string;
|
|
296
296
|
}
|
|
297
|
+
/** An annotated service helper has the same call/return shape as a fake.
|
|
298
|
+
* Only helpers returning an annotation emit this event. */
|
|
299
|
+
export interface ServiceHelperEvent extends Omit<FakeEvent, "kind" | "fake"> {
|
|
300
|
+
kind: "service";
|
|
301
|
+
/** Service name — the key in `ctx.svc`. */
|
|
302
|
+
service: string;
|
|
303
|
+
}
|
|
297
304
|
/**
|
|
298
305
|
* One captured email, as embedded on an {@link EmailEvent}. The HTML and
|
|
299
306
|
* text bodies ride along (truncated to the output cap) so the dashboard
|
|
@@ -563,6 +570,15 @@ export interface EventReservation {
|
|
|
563
570
|
seq: number;
|
|
564
571
|
tOffsetMs: number;
|
|
565
572
|
}
|
|
573
|
+
/** Buffer a helper's async descendants until its return chooses between the
|
|
574
|
+
* implementation's events and one annotated helper call. Seqs still come from
|
|
575
|
+
* the test recorder, so concurrent calls and linked assertions stay unique.
|
|
576
|
+
* Poll truncation/grouping inside the helper applies only to its own buffer. */
|
|
577
|
+
export declare function captureRecording(): {
|
|
578
|
+
run<T>(fn: () => T): T;
|
|
579
|
+
finish(keep: boolean): void;
|
|
580
|
+
isCurrent(): boolean;
|
|
581
|
+
};
|
|
566
582
|
export declare function pauseRecording(): void;
|
|
567
583
|
export declare function resumeRecording(): void;
|
|
568
584
|
export declare function startRecording(startedAt?: number): void;
|
|
@@ -592,6 +608,7 @@ export declare function recordS3(ev: Omit<S3Event, "seq" | "tOffsetMs" | "kind">
|
|
|
592
608
|
export declare function recordTerminal(ev: Omit<TerminalEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
593
609
|
export declare function recordTerminalStep(ev: Omit<TerminalStepEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
594
610
|
export declare function recordFake(ev: Omit<FakeEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
611
|
+
export declare function recordServiceHelper(ev: Omit<ServiceHelperEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
595
612
|
export declare function recordEnv(ev: Omit<EnvEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
596
613
|
/**
|
|
597
614
|
* One block of step detail, in the dashboard's presentation vocabulary
|
package/dist/recorder.js
CHANGED
|
@@ -7,12 +7,18 @@
|
|
|
7
7
|
// When no test is running (production code paths, eval, etc.) the
|
|
8
8
|
// current recorder is `null` and the `record*` helpers are no-ops, so
|
|
9
9
|
// callers can invoke them unconditionally.
|
|
10
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
11
|
import { clearPendingNullish } from "./inspect.js";
|
|
11
12
|
import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
|
|
12
13
|
const OUTPUT_SNIPPET_BYTES = 256 * 1024;
|
|
13
14
|
class Recorder {
|
|
14
15
|
start;
|
|
15
|
-
|
|
16
|
+
rootEvents = [];
|
|
17
|
+
pendingHelpers = new Set();
|
|
18
|
+
get events() {
|
|
19
|
+
const branch = recordingBranches.getStore();
|
|
20
|
+
return branch?.owner === this ? branchEvents(branch) : this.rootEvents;
|
|
21
|
+
}
|
|
16
22
|
seq = 0;
|
|
17
23
|
constructor(start = Date.now()) {
|
|
18
24
|
this.start = start;
|
|
@@ -106,6 +112,48 @@ class Recorder {
|
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
let current = null;
|
|
115
|
+
const recordingBranches = new AsyncLocalStorage();
|
|
116
|
+
function branchEvents(branch) {
|
|
117
|
+
return branch.state === "kept"
|
|
118
|
+
? branch.parent ? branchEvents(branch.parent) : branch.destination
|
|
119
|
+
: branch.events;
|
|
120
|
+
}
|
|
121
|
+
function branchAllowsRecording(branch) {
|
|
122
|
+
return !branch || (branch.owner === current && branch.state !== "discarded" &&
|
|
123
|
+
branchAllowsRecording(branch.parent));
|
|
124
|
+
}
|
|
125
|
+
/** Buffer a helper's async descendants until its return chooses between the
|
|
126
|
+
* implementation's events and one annotated helper call. Seqs still come from
|
|
127
|
+
* the test recorder, so concurrent calls and linked assertions stay unique.
|
|
128
|
+
* Poll truncation/grouping inside the helper applies only to its own buffer. */
|
|
129
|
+
export function captureRecording() {
|
|
130
|
+
if (!active())
|
|
131
|
+
return { run: (fn) => fn(), finish: () => { }, isCurrent: () => false };
|
|
132
|
+
const branch = {
|
|
133
|
+
owner: current, events: [], parent: recordingBranches.getStore(),
|
|
134
|
+
destination: current.drain(), state: "open",
|
|
135
|
+
};
|
|
136
|
+
current.pendingHelpers.add(branch);
|
|
137
|
+
return {
|
|
138
|
+
run: (fn) => recordingBranches.run(branch, fn),
|
|
139
|
+
isCurrent: () => branch.owner === current && active(),
|
|
140
|
+
finish(keep) {
|
|
141
|
+
if (branch.state !== "open")
|
|
142
|
+
return;
|
|
143
|
+
branch.owner.pendingHelpers.delete(branch);
|
|
144
|
+
if (keep && branchAllowsRecording(branch)) {
|
|
145
|
+
const destination = branch.parent ? branchEvents(branch.parent) : branch.destination;
|
|
146
|
+
for (const event of branch.events)
|
|
147
|
+
destination.push(event);
|
|
148
|
+
branch.state = "kept";
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
branch.state = "discarded";
|
|
152
|
+
}
|
|
153
|
+
branch.events.length = 0;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
109
157
|
// Counter-based pause: `pauseRecording`/`resumeRecording` nest safely
|
|
110
158
|
// (a nested `ctx.poll` inside another `ctx.poll`'s predicate stays
|
|
111
159
|
// suppressed until its own resume balances out). Active record* sites
|
|
@@ -121,7 +169,7 @@ export function resumeRecording() {
|
|
|
121
169
|
paused = Math.max(0, paused - 1);
|
|
122
170
|
}
|
|
123
171
|
function active() {
|
|
124
|
-
return current !== null && paused === 0 && scopeAllowsRecording();
|
|
172
|
+
return current !== null && paused === 0 && scopeAllowsRecording() && branchAllowsRecording(recordingBranches.getStore());
|
|
125
173
|
}
|
|
126
174
|
export function startRecording(startedAt = Date.now()) {
|
|
127
175
|
current = new Recorder(startedAt);
|
|
@@ -131,6 +179,19 @@ export function stopRecording() {
|
|
|
131
179
|
if (!current)
|
|
132
180
|
return [];
|
|
133
181
|
const evs = current.drain();
|
|
182
|
+
// A timeout can stop the test before a helper returns. Keep its available
|
|
183
|
+
// diagnostics, then fence detached work out of any subsequent recording.
|
|
184
|
+
for (const branch of current.pendingHelpers) {
|
|
185
|
+
if (branchAllowsRecording(branch)) {
|
|
186
|
+
for (const event of branch.events)
|
|
187
|
+
evs.push(event);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
for (const branch of current.pendingHelpers) {
|
|
191
|
+
branch.events.length = 0;
|
|
192
|
+
branch.state = "discarded";
|
|
193
|
+
}
|
|
194
|
+
current.pendingHelpers.clear();
|
|
134
195
|
current = null;
|
|
135
196
|
paused = 0;
|
|
136
197
|
return evs;
|
|
@@ -189,6 +250,9 @@ export function recordTerminalStep(ev, reservation) {
|
|
|
189
250
|
export function recordFake(ev, reservation) {
|
|
190
251
|
return active() ? current.push({ kind: "fake", ...ev }, reservation) : undefined;
|
|
191
252
|
}
|
|
253
|
+
export function recordServiceHelper(ev, reservation) {
|
|
254
|
+
return active() ? current.push({ kind: "service", ...ev }, reservation) : undefined;
|
|
255
|
+
}
|
|
192
256
|
export function recordEnv(ev, reservation) {
|
|
193
257
|
return active() ? current.push({ kind: "env", ...ev }, reservation) : undefined;
|
|
194
258
|
}
|
|
@@ -199,11 +263,7 @@ export function recordEmail(ev, reservation) {
|
|
|
199
263
|
return active() ? current.push({ kind: "email", ...ev }, reservation) : undefined;
|
|
200
264
|
}
|
|
201
265
|
export function recordWait(ev, reservation) {
|
|
202
|
-
|
|
203
|
-
// end of a `ctx.poll` block lands even though the surrounding code
|
|
204
|
-
// just resumed from `paused`. Pushing this event is the whole point
|
|
205
|
-
// of the poll primitive.
|
|
206
|
-
return current?.push({ kind: "wait", ...ev }, reservation);
|
|
266
|
+
return active() ? current.push({ kind: "wait", ...ev }, reservation) : undefined;
|
|
207
267
|
}
|
|
208
268
|
/** Number of events the active recorder has accumulated. Returns 0
|
|
209
269
|
* when no recorder is active. */
|
package/package.json
CHANGED
package/src/annotate.ts
CHANGED
|
@@ -1,34 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// The call is still recorded as the `fake` step it is; the annotation rides
|
|
9
|
-
// *under* it as a child event (`parentSeq`, marked `annotation`) of the kind
|
|
10
|
-
// that already knows how to draw this — for `"email"`, the very event the
|
|
11
|
-
// built-in `email()` component's mailbox helpers record — so the dashboard
|
|
12
|
-
// renders it mail-client style (header block + sandboxed HTML body) with no
|
|
13
|
-
// new rendering code. It leads the step's detail panel; the raw JSON value
|
|
14
|
-
// is a tab away.
|
|
15
|
-
//
|
|
16
|
-
// One function, keyed by kind: the kind is a key of `AnnotationOptions`, so
|
|
17
|
-
// the options argument is typed against the kind that was named, and a new
|
|
18
|
-
// kind is an entry in that interface (+ a case in `lower`, + an arm in
|
|
19
|
-
// `daemon.ts::recordAnnotatedCall`) rather than a new export.
|
|
20
|
-
//
|
|
21
|
-
// The test is unaffected. `annotate` returns a box that the daemon opens at
|
|
22
|
-
// the helper boundary (`daemon.ts::invokeFakeHelper`), so the caller gets
|
|
23
|
-
// the raw value back, inspect-wrapped as always, and the *type* it sees is
|
|
24
|
-
// still the raw value's (`WrappedHelpers` in index.ts unwraps `Annotated`).
|
|
25
|
-
// Assertions keep linking to the fake step, exactly as for an unannotated
|
|
26
|
-
// helper. The annotation is a rendering hint and nothing else.
|
|
27
|
-
//
|
|
28
|
-
// This is a fake-helper mechanism: those calls go through a tracking proxy
|
|
29
|
-
// that can open the box. A *service* helper (`ctx.svc.<name>.…`) is the raw
|
|
30
|
-
// record its factory returned, so an annotation there would reach the test
|
|
31
|
-
// as an opaque value — don't.
|
|
1
|
+
// A helper returns an annotation box to choose a richer view of its result.
|
|
2
|
+
// Both fake and service helper boundaries open it, record one named helper
|
|
3
|
+
// operation with an annotation child, and wrap the value for linked assertions.
|
|
4
|
+
// annotate() itself does not record or attach anything to an existing operation.
|
|
5
|
+
// Fake helpers always hide their implementation; service helpers opt into that
|
|
6
|
+
// behavior by returning this box. Ordinary service returns keep their internal
|
|
7
|
+
// recorded operations and their existing types.
|
|
32
8
|
|
|
33
9
|
import { deepUnwrap } from "./inspect.js";
|
|
34
10
|
import {
|
|
@@ -50,15 +26,14 @@ import {
|
|
|
50
26
|
const ANNOTATION = Symbol.for("spectest.render-annotation");
|
|
51
27
|
|
|
52
28
|
/** Phantom brand — `Annotated<T>` is nominally distinct from `T`, so the
|
|
53
|
-
* box can't be read as the value by accident inside the
|
|
29
|
+
* box can't be read as the value by accident inside the helper, and the
|
|
54
30
|
* helper types can recognise it and hand the *test* back a plain `T`. */
|
|
55
31
|
declare const ANNOTATED: unique symbol;
|
|
56
32
|
|
|
57
33
|
/**
|
|
58
34
|
* A value plus a rendering hint, as returned by {@link annotate}. Return it
|
|
59
|
-
* straight from a fake helper: the
|
|
60
|
-
* the value
|
|
61
|
-
* annotation weren't there.
|
|
35
|
+
* straight from a fake or service helper: the boundary opens it and the test
|
|
36
|
+
* receives the value wrapped for assertions. The box never reaches the test.
|
|
62
37
|
*/
|
|
63
38
|
export interface Annotated<T> {
|
|
64
39
|
readonly [ANNOTATED]: T;
|
|
@@ -71,7 +46,7 @@ export interface Annotated<T> {
|
|
|
71
46
|
* another kind is a compile error at the call site.
|
|
72
47
|
*
|
|
73
48
|
* A new kind is an entry here, a case in `lower`, and an arm in
|
|
74
|
-
* `
|
|
49
|
+
* `helper-tracking.ts::recordAnnotationChild`.
|
|
75
50
|
*/
|
|
76
51
|
export interface AnnotationOptions {
|
|
77
52
|
/** Draw the value as an email — one message, or a list of them for a
|
|
@@ -87,7 +62,7 @@ export type AnnotationKind = keyof AnnotationOptions;
|
|
|
87
62
|
|
|
88
63
|
/**
|
|
89
64
|
* What an annotation lowers to: the fields of the child event the daemon
|
|
90
|
-
* records under the call's own
|
|
65
|
+
* records under the helper call's own event. Tagged by that event's `kind` —
|
|
91
66
|
* which needn't be the annotation kind's name, since an annotation names
|
|
92
67
|
* what the value *is* and the event names how it's drawn.
|
|
93
68
|
*/
|
|
@@ -113,13 +88,13 @@ type ChatRenderAnnotation = {
|
|
|
113
88
|
};
|
|
114
89
|
|
|
115
90
|
/**
|
|
116
|
-
* The email fields to render. Every one is optional — pass what the
|
|
117
|
-
*
|
|
91
|
+
* The email fields to render. Every one is optional — pass what the helper
|
|
92
|
+
* observed. Addresses take a single string or a list.
|
|
118
93
|
*
|
|
119
94
|
* The event can also carry a date, per-row previews and attachment
|
|
120
|
-
* metadata, which the built-in `email()` component fills from a
|
|
121
|
-
*
|
|
122
|
-
*
|
|
95
|
+
* metadata, which the built-in `email()` component fills from a captured
|
|
96
|
+
* message. Annotations currently cover the header/body fields below. A
|
|
97
|
+
* listing row's preview is derived from the body.
|
|
123
98
|
*/
|
|
124
99
|
export interface EmailAnnotation {
|
|
125
100
|
from?: string;
|
|
@@ -248,10 +223,12 @@ const NO_FIELDS =
|
|
|
248
223
|
"html: value.body }).";
|
|
249
224
|
|
|
250
225
|
/**
|
|
251
|
-
* Say what a
|
|
226
|
+
* Say what a helper's return value *is*, so the timeline can draw it
|
|
252
227
|
* as that on top of the JSON it always shows. The call stays an ordinary
|
|
253
|
-
*
|
|
254
|
-
* the
|
|
228
|
+
* helper step; the rendered view nests inside it. Return this box from the
|
|
229
|
+
* helper: the caller receives the value wrapped for assertions. An annotated
|
|
230
|
+
* service return suppresses the helper's internal events; an ordinary service
|
|
231
|
+
* return keeps them. Calling annotate without returning the box has no effect.
|
|
255
232
|
*
|
|
256
233
|
* ```ts
|
|
257
234
|
* helpers: ({ state }) => ({
|