@specific.dev/spectest 0.48.0 → 0.50.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/dist/annotate.d.ts +151 -0
- package/dist/annotate.js +242 -0
- package/dist/daemon.js +199 -15
- package/dist/index.d.ts +15 -1
- package/dist/index.js +8 -0
- package/dist/recorder.d.ts +94 -10
- package/dist/recorder.js +16 -3
- package/package.json +1 -1
- package/src/annotate.ts +395 -0
- package/src/daemon.ts +226 -19
- package/src/index.ts +32 -2
- package/src/recorder.test.ts +57 -0
- package/src/recorder.ts +95 -13
package/src/daemon.ts
CHANGED
|
@@ -112,17 +112,22 @@ import {
|
|
|
112
112
|
} from "./harness/methods.js";
|
|
113
113
|
import type { Mobile, MobileApp } from "./mobile.js";
|
|
114
114
|
import { openTerminal } from "./terminal.js";
|
|
115
|
+
import { readAnnotation, type RenderAnnotation } from "./annotate.js";
|
|
115
116
|
import {
|
|
117
|
+
pauseRecording,
|
|
118
|
+
recordEmail,
|
|
116
119
|
recordEnv,
|
|
117
120
|
recordExec,
|
|
118
121
|
recordFake,
|
|
119
122
|
recordHttp,
|
|
123
|
+
recordStep,
|
|
120
124
|
recordTerminal,
|
|
121
125
|
recordWait,
|
|
122
126
|
reserveEvent,
|
|
123
127
|
recorderEventCount,
|
|
124
128
|
recorderMarkChildren,
|
|
125
129
|
recorderTruncate,
|
|
130
|
+
resumeRecording,
|
|
126
131
|
startRecording,
|
|
127
132
|
stopRecording,
|
|
128
133
|
truncateUtf8,
|
|
@@ -1111,17 +1116,141 @@ async function prepareServiceImage(
|
|
|
1111
1116
|
return buildServiceImage(svc.name, image, tag);
|
|
1112
1117
|
}
|
|
1113
1118
|
|
|
1119
|
+
/**
|
|
1120
|
+
* What the folded CA step prints when it could not write the trust store
|
|
1121
|
+
* at all — the one outcome that still needs {@link ensureCaTrustedImage},
|
|
1122
|
+
* which can take root for the write.
|
|
1123
|
+
*
|
|
1124
|
+
* The step assembles this prefix from a shell variable so that the marker
|
|
1125
|
+
* appears ONLY in the step's output. BuildKit echoes each instruction into
|
|
1126
|
+
* the same log verbatim, so a marker written literally in the RUN would
|
|
1127
|
+
* match on every build whether or not the step ever printed it.
|
|
1128
|
+
*/
|
|
1129
|
+
const CA_FOLD_UNWRITABLE = "[spectest-ca] trust store not writable";
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* The CA-trust steps as a suffix appended to a dockerfile service's OWN
|
|
1133
|
+
* Dockerfile, so one build produces the finished image instead of a build
|
|
1134
|
+
* plus a derivative rebuild ({@link ensureCaTrustedImage}) per service.
|
|
1135
|
+
* Returns null when there is no CA to layer, or when the PEM can't be
|
|
1136
|
+
* quoted — the caller then falls back to the derivative build.
|
|
1137
|
+
*
|
|
1138
|
+
* The PEM is written INLINE rather than `COPY`d: the build context is
|
|
1139
|
+
* `/workspace` under a per-service ignore file that the project itself
|
|
1140
|
+
* contributes to (a `**` line with re-includes is the common idiom), and
|
|
1141
|
+
* a context path we don't control is a context path that can be excluded.
|
|
1142
|
+
* printf needs nothing but a shell.
|
|
1143
|
+
*
|
|
1144
|
+
* Two rules make this safe to bolt onto user code. It must never fail the
|
|
1145
|
+
* build — every branch ends in an echo, so the RUN exits 0 whatever the
|
|
1146
|
+
* image lacks — and it must never change the image, beyond the trust
|
|
1147
|
+
* store: notably no `USER root`, since we cannot know statically what
|
|
1148
|
+
* user to hand back. An image that declares a non-root user therefore
|
|
1149
|
+
* fails to write and is finished by the derivative build, which inspects
|
|
1150
|
+
* the built image and can escalate properly.
|
|
1151
|
+
*/
|
|
1152
|
+
async function caTrustSuffix(): Promise<string | null> {
|
|
1153
|
+
if (!existsSync(CA_PATH)) return null;
|
|
1154
|
+
const pem = (await fs.readFile(CA_PATH, "utf8")).trim();
|
|
1155
|
+
// A quote in the PEM would break out of the shell quoting below. PEM is
|
|
1156
|
+
// base64 and dashes, so this is a guard, not a case we expect.
|
|
1157
|
+
if (!pem || pem.includes("'")) return null;
|
|
1158
|
+
const args = pem
|
|
1159
|
+
.split("\n")
|
|
1160
|
+
.map((l) => `'${l.trimEnd()}'`)
|
|
1161
|
+
.join(" ");
|
|
1162
|
+
const dst = "/usr/local/share/ca-certificates/spectest-ca.crt";
|
|
1163
|
+
return `
|
|
1164
|
+
# spectest: trust the environment's root CA. Appended by the harness —
|
|
1165
|
+
# not part of the project's Dockerfile.
|
|
1166
|
+
RUN P='[spectest-ca]'; \\
|
|
1167
|
+
mkdir -p /usr/local/share/ca-certificates 2>/dev/null; \\
|
|
1168
|
+
if printf '%s\\n' ${args} > ${dst} 2>/dev/null; then \\
|
|
1169
|
+
if command -v update-ca-certificates >/dev/null 2>&1 && update-ca-certificates >/dev/null 2>&1; then \\
|
|
1170
|
+
echo "$P trusted via update-ca-certificates"; \\
|
|
1171
|
+
elif command -v update-ca-trust >/dev/null 2>&1 && cp ${dst} /etc/pki/ca-trust/source/anchors/spectest-ca.crt && update-ca-trust extract >/dev/null 2>&1; then \\
|
|
1172
|
+
echo "$P trusted via update-ca-trust"; \\
|
|
1173
|
+
else \\
|
|
1174
|
+
echo "$P no system CA trust tool in image; env-var trust only"; \\
|
|
1175
|
+
fi; \\
|
|
1176
|
+
else \\
|
|
1177
|
+
echo "$P trust store not writable by this image's user"; \\
|
|
1178
|
+
fi
|
|
1179
|
+
`;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Build a dockerfile service's image.
|
|
1184
|
+
*
|
|
1185
|
+
* The CA-trust layer is folded into THIS build when it can be (see
|
|
1186
|
+
* {@link caTrustSuffix}), so a service costs one image build and one
|
|
1187
|
+
* export rather than two. The derivative build stays as the fallback for
|
|
1188
|
+
* everything the folded form can't serve: an image with no shell to run
|
|
1189
|
+
* the step (distroless, scratch — the appended RUN can't execute, so the
|
|
1190
|
+
* build fails and we rebuild the project's Dockerfile untouched), and an
|
|
1191
|
+
* image that declares a non-root user (the step runs as that user and
|
|
1192
|
+
* can't write the trust store).
|
|
1193
|
+
*/
|
|
1114
1194
|
async function buildServiceImage(
|
|
1115
1195
|
name: string,
|
|
1116
1196
|
image: { content: string; exclude?: readonly string[] },
|
|
1117
1197
|
tag: string,
|
|
1118
1198
|
): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
|
|
1199
|
+
const suffix = await caTrustSuffix();
|
|
1200
|
+
let attempt = await runServiceBuild(name, image, tag, suffix);
|
|
1201
|
+
if (!attempt.ok && suffix) {
|
|
1202
|
+
// Our step must not be able to break a project's build.
|
|
1203
|
+
// eslint-disable-next-line no-console
|
|
1204
|
+
console.warn(
|
|
1205
|
+
`[ca-trust] folded CA step could not run in ${name}'s image; rebuilding without it`,
|
|
1206
|
+
);
|
|
1207
|
+
attempt = await runServiceBuild(name, image, tag, null);
|
|
1208
|
+
}
|
|
1209
|
+
if (!attempt.ok) {
|
|
1210
|
+
progressService(name, { status: "failed" });
|
|
1211
|
+
throw new Error(`docker build for ${name} failed:\n${attempt.log}`);
|
|
1212
|
+
}
|
|
1213
|
+
// The derivative build is still needed for the one thing the folded step
|
|
1214
|
+
// cannot do: write the trust store of an image that does not run as
|
|
1215
|
+
// root. That is decided on the IMAGE, not on the build log — a CACHED
|
|
1216
|
+
// layer prints nothing, so a log-only check would quietly stop
|
|
1217
|
+
// re-applying the moment BuildKit had the layer. The log covers the
|
|
1218
|
+
// rarer case of a root image whose /etc is read-only.
|
|
1219
|
+
//
|
|
1220
|
+
// An image with no trust tool at all needs nothing further: the
|
|
1221
|
+
// derivative build would reach the same dead end, and `runContainer`'s
|
|
1222
|
+
// env vars are the fallback either way.
|
|
1223
|
+
const needsDerivative =
|
|
1224
|
+
!attempt.folded ||
|
|
1225
|
+
attempt.log.includes(CA_FOLD_UNWRITABLE) ||
|
|
1226
|
+
!(await imageRunsAsRoot(tag));
|
|
1227
|
+
if (needsDerivative) await ensureCaTrustedImage(name, tag);
|
|
1228
|
+
return { tag, buildSteps: attempt.buildSteps };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** Whether `tag`'s declared `USER` is root (or unset, which means root). */
|
|
1232
|
+
async function imageRunsAsRoot(tag: string): Promise<boolean> {
|
|
1233
|
+
const user = (
|
|
1234
|
+
await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)
|
|
1235
|
+
).stdout.trim();
|
|
1236
|
+
return user === "" || user === "root" || user === "0";
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function runServiceBuild(
|
|
1240
|
+
name: string,
|
|
1241
|
+
image: { content: string; exclude?: readonly string[] },
|
|
1242
|
+
tag: string,
|
|
1243
|
+
caSuffix: string | null,
|
|
1244
|
+
): Promise<{ ok: boolean; folded: boolean; log: string; buildSteps?: BuildStep[] }> {
|
|
1119
1245
|
let buildSteps: BuildStep[] | undefined;
|
|
1120
1246
|
{
|
|
1247
|
+
const content = caSuffix
|
|
1248
|
+
? `${image.content.replace(/\n*$/, "\n")}${caSuffix}`
|
|
1249
|
+
: image.content;
|
|
1121
1250
|
const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
|
|
1122
1251
|
await fs.mkdir(dfDir, { recursive: true });
|
|
1123
1252
|
const dfPath = path.join(dfDir, "Dockerfile");
|
|
1124
|
-
await fs.writeFile(dfPath,
|
|
1253
|
+
await fs.writeFile(dfPath, content);
|
|
1125
1254
|
// Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
|
|
1126
1255
|
// (next to the Dockerfile) in preference to the context root's
|
|
1127
1256
|
// `.dockerignore`, so this build sees the defaults, the project's own
|
|
@@ -1181,11 +1310,11 @@ async function buildServiceImage(
|
|
|
1181
1310
|
});
|
|
1182
1311
|
}
|
|
1183
1312
|
});
|
|
1313
|
+
const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
|
|
1184
1314
|
if (build.code !== 0) {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
);
|
|
1315
|
+
// The caller decides whether this is fatal: a failure with the CA
|
|
1316
|
+
// step appended is retried without it before anyone hears about it.
|
|
1317
|
+
return { ok: false, folded: caSuffix !== null, log };
|
|
1189
1318
|
}
|
|
1190
1319
|
if (useBuildKit) {
|
|
1191
1320
|
// Keep only the slowest dozen steps ≥1s — enough to profile, small
|
|
@@ -1194,14 +1323,8 @@ async function buildServiceImage(
|
|
|
1194
1323
|
.filter((s) => s.secs >= 1)
|
|
1195
1324
|
.slice(0, 12);
|
|
1196
1325
|
}
|
|
1326
|
+
return { ok: true, folded: caSuffix !== null, log, buildSteps };
|
|
1197
1327
|
}
|
|
1198
|
-
// Layer the spectest CA into the image's system trust store so apps
|
|
1199
|
-
// that read the system bundle (Go, Java, CLIs that don't honour the
|
|
1200
|
-
// SSL_CERT_FILE env vars) accept HTTPS to fakes. Best-effort: images
|
|
1201
|
-
// without `update-ca-certificates` / `update-ca-trust` (distroless,
|
|
1202
|
-
// scratch) fall through to the env-var path that `runContainer` sets.
|
|
1203
|
-
await ensureCaTrustedImage(name, tag);
|
|
1204
|
-
return { tag, buildSteps };
|
|
1205
1328
|
}
|
|
1206
1329
|
|
|
1207
1330
|
/**
|
|
@@ -2728,14 +2851,27 @@ function invokeFakeHelper(
|
|
|
2728
2851
|
const args = callArgs.map((a) => deepUnwrap(a));
|
|
2729
2852
|
const safeArgs = args.map((a) => safeSerialize(a));
|
|
2730
2853
|
const recordResult = (value: unknown): unknown => {
|
|
2854
|
+
// A helper may box its return in a render annotation
|
|
2855
|
+
// (`annotate(v, "email", …)`). The box never reaches the test, and it
|
|
2856
|
+
// never replaces the step either: the call is recorded as the ordinary
|
|
2857
|
+
// `fake` event it is, and the annotation rides *under* it as a child
|
|
2858
|
+
// event (`parentSeq`), which the dashboard folds into the fake step's
|
|
2859
|
+
// detail panel. So the timeline reads the same as any other helper
|
|
2860
|
+
// call, with the richer view one click in.
|
|
2861
|
+
const annotated = readAnnotation(value);
|
|
2862
|
+
const raw = annotated ? annotated.value : value;
|
|
2863
|
+
const durationMs = Date.now() - t;
|
|
2731
2864
|
const seq = recordFake({
|
|
2732
2865
|
fake: fakeName,
|
|
2733
2866
|
member,
|
|
2734
2867
|
args: safeArgs,
|
|
2735
|
-
result: safeSerialize(
|
|
2736
|
-
durationMs
|
|
2868
|
+
result: safeSerialize(raw),
|
|
2869
|
+
durationMs,
|
|
2737
2870
|
}, resv);
|
|
2738
|
-
|
|
2871
|
+
if (annotated && seq !== undefined) {
|
|
2872
|
+
recordAnnotationChild(fakeName, member, annotated.annotation, durationMs, seq);
|
|
2873
|
+
}
|
|
2874
|
+
return wrap(raw, seq);
|
|
2739
2875
|
};
|
|
2740
2876
|
const recordError = (err: unknown): void => {
|
|
2741
2877
|
recordFake({
|
|
@@ -2747,22 +2883,93 @@ function invokeFakeHelper(
|
|
|
2747
2883
|
}, resv);
|
|
2748
2884
|
};
|
|
2749
2885
|
|
|
2886
|
+
// A helper call is ONE step. Whatever the fake does inside it — a fetch to
|
|
2887
|
+
// deliver a webhook, a call to its own API — is the fake's plumbing, not
|
|
2888
|
+
// something the test did, and it would otherwise land on the timeline as
|
|
2889
|
+
// an `http` step the test never made. The built-in `email()` helpers
|
|
2890
|
+
// already pause around their internal polls by hand for exactly this
|
|
2891
|
+
// reason (`components/email.ts`); doing it here gives every user-authored
|
|
2892
|
+
// fake the same contract without having to know about it.
|
|
2893
|
+
//
|
|
2894
|
+
// The pause spans the helper's `await`s, so genuinely concurrent test work
|
|
2895
|
+
// (`Promise.all([helper(), ctx.fetch(…)])`) loses its events too. That is
|
|
2896
|
+
// the same trade `email()` has always made, and sequential test code — all
|
|
2897
|
+
// of it, in practice — is unaffected.
|
|
2898
|
+
pauseRecording();
|
|
2750
2899
|
let result: unknown;
|
|
2751
2900
|
try {
|
|
2752
2901
|
result = fn.apply(thisArg, args);
|
|
2753
2902
|
} catch (err) {
|
|
2903
|
+
resumeRecording();
|
|
2754
2904
|
recordError(err);
|
|
2755
2905
|
throw err;
|
|
2756
2906
|
}
|
|
2757
2907
|
if (result instanceof Promise) {
|
|
2758
|
-
return result.then(
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2908
|
+
return result.then(
|
|
2909
|
+
(value) => {
|
|
2910
|
+
resumeRecording();
|
|
2911
|
+
return recordResult(value);
|
|
2912
|
+
},
|
|
2913
|
+
(err) => {
|
|
2914
|
+
resumeRecording();
|
|
2915
|
+
recordError(err);
|
|
2916
|
+
throw err;
|
|
2917
|
+
},
|
|
2918
|
+
);
|
|
2762
2919
|
}
|
|
2920
|
+
resumeRecording();
|
|
2763
2921
|
return recordResult(result);
|
|
2764
2922
|
}
|
|
2765
2923
|
|
|
2924
|
+
/** Record a fake-helper call's render annotation as a child of the call's
|
|
2925
|
+
* own `fake` event — the same `parentSeq` grouping `ctx.poll` uses for the
|
|
2926
|
+
* iteration it kept, so the annotated view folds into the fake step's
|
|
2927
|
+
* detail panel instead of taking a timeline row of its own.
|
|
2928
|
+
*
|
|
2929
|
+
* The child is the event kind that already knows how to draw this: an
|
|
2930
|
+
* `email` annotation records the very event the built-in `email()`
|
|
2931
|
+
* component's mailbox helpers record, so it renders with no new code. It
|
|
2932
|
+
* carries the fake's name and member as its service/op, and the parent's
|
|
2933
|
+
* duration, since it describes that same call.
|
|
2934
|
+
*
|
|
2935
|
+
* Only the success path is annotated: a helper that threw returned no value
|
|
2936
|
+
* to annotate, so it's a plain `fake` error event. */
|
|
2937
|
+
function recordAnnotationChild(
|
|
2938
|
+
fakeName: string,
|
|
2939
|
+
member: string,
|
|
2940
|
+
annotation: RenderAnnotation,
|
|
2941
|
+
durationMs: number,
|
|
2942
|
+
parentSeq: number,
|
|
2943
|
+
): void {
|
|
2944
|
+
switch (annotation.kind) {
|
|
2945
|
+
case "email":
|
|
2946
|
+
recordEmail({
|
|
2947
|
+
parentSeq,
|
|
2948
|
+
annotation: true,
|
|
2949
|
+
service: fakeName,
|
|
2950
|
+
op: member,
|
|
2951
|
+
message: annotation.message,
|
|
2952
|
+
messages: annotation.messages,
|
|
2953
|
+
count: annotation.count,
|
|
2954
|
+
durationMs,
|
|
2955
|
+
});
|
|
2956
|
+
break;
|
|
2957
|
+
case "chat":
|
|
2958
|
+
// A chat has no event kind of its own: it describes itself in
|
|
2959
|
+
// presentation terms (title + blocks), which is the seam a new step
|
|
2960
|
+
// type is supposed to use. `kind` stays an opaque grouping label.
|
|
2961
|
+
recordStep({
|
|
2962
|
+
kind: "chat",
|
|
2963
|
+
parentSeq,
|
|
2964
|
+
annotation: true,
|
|
2965
|
+
title: annotation.title ?? `${fakeName}.${member}`,
|
|
2966
|
+
blocks: annotation.blocks,
|
|
2967
|
+
durationMs,
|
|
2968
|
+
});
|
|
2969
|
+
break;
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2766
2973
|
function errMessage(err: unknown): string {
|
|
2767
2974
|
return (err as Error)?.message ?? String(err);
|
|
2768
2975
|
}
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,24 @@ import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
|
34
34
|
// `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
|
|
35
35
|
export { field } from "./inspect.js";
|
|
36
36
|
|
|
37
|
+
// Render annotations for fake helpers: a helper returns
|
|
38
|
+
// `annotate(value, "email", { … })` or `annotate(value, "chat", { … })`
|
|
39
|
+
// when its value is one of those, and the fake step it records gains a
|
|
40
|
+
// nested event drawing it — an email mail-client style, a conversation as
|
|
41
|
+
// bubbles — which the step's panel leads with, tabbing to the raw JSON
|
|
42
|
+
// value. The kind picks the options' type, and the test still gets the raw
|
|
43
|
+
// value, with the raw value's type — see `annotate.ts`.
|
|
44
|
+
export { annotate } from "./annotate.js";
|
|
45
|
+
export type {
|
|
46
|
+
Annotated,
|
|
47
|
+
AnnotationKind,
|
|
48
|
+
AnnotationOptions,
|
|
49
|
+
ChatAnnotation,
|
|
50
|
+
ChatMessageAnnotation,
|
|
51
|
+
EmailAnnotation,
|
|
52
|
+
} from "./annotate.js";
|
|
53
|
+
import type { Annotated } from "./annotate.js";
|
|
54
|
+
|
|
37
55
|
// Instrumented client primitives — drop-in replacements for Bun's native
|
|
38
56
|
// clients that record each operation on the test event log and return their
|
|
39
57
|
// results inspect-wrapped, so `expect(...)` on a result links back to the op
|
|
@@ -1633,6 +1651,10 @@ export interface FakeDefinition<
|
|
|
1633
1651
|
* Every call is tracked in the test timeline: it records a `fake` step
|
|
1634
1652
|
* and the return value is tagged so a later `expect(...)` on it nests
|
|
1635
1653
|
* under that step in the UI (same provenance as `fetch`/db results).
|
|
1654
|
+
* The step renders the return value as JSON; wrap it in {@link annotate}
|
|
1655
|
+
* to add a richer view (an email, today) that the step's panel leads with,
|
|
1656
|
+
* the JSON one tab away — the test still receives the raw value, unchanged
|
|
1657
|
+
* and unchanged in type.
|
|
1636
1658
|
*
|
|
1637
1659
|
* Receives the fake's `state` plus a {@link FakeContext} `ctx`, so a
|
|
1638
1660
|
* helper can provision/teardown runtime services just like the handler.
|
|
@@ -1703,12 +1725,20 @@ export type FakesMap = Record<string, FakeDefinition<any, any>>;
|
|
|
1703
1725
|
* `components/k3s.ts`, extended to cover synchronous returns. */
|
|
1704
1726
|
type WrappedHelpers<H> = {
|
|
1705
1727
|
[K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R>
|
|
1706
|
-
? (...args: A) => Promise<Wrapped<R
|
|
1728
|
+
? (...args: A) => Promise<Wrapped<Unannotated<R>>>
|
|
1707
1729
|
: H[K] extends (...args: infer A) => infer R
|
|
1708
|
-
? (...args: A) => [R] extends [void] ? void : Wrapped<R
|
|
1730
|
+
? (...args: A) => [R] extends [void] ? void : Wrapped<Unannotated<R>>
|
|
1709
1731
|
: H[K];
|
|
1710
1732
|
};
|
|
1711
1733
|
|
|
1734
|
+
/** A helper's return type as the *test* sees it. {@link annotate} boxes the
|
|
1735
|
+
* value in an {@link Annotated} to pick how the step renders; the daemon
|
|
1736
|
+
* opens that box at the helper boundary, so the annotation never reaches
|
|
1737
|
+
* the caller — in the types either. Distributes over a union, so a helper
|
|
1738
|
+
* that annotates only when it has something (`return m && annotate(m, "email")`)
|
|
1739
|
+
* still reads as `T | undefined`. */
|
|
1740
|
+
type Unannotated<R> = R extends Annotated<infer U> ? U : R;
|
|
1741
|
+
|
|
1712
1742
|
/** Awaited return type of a fake's `helpers` factory (with each result
|
|
1713
1743
|
* inspect-wrapped, see {@link WrappedHelpers}), or `{ state: S }` (the
|
|
1714
1744
|
* default) when the user didn't ship one. */
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
recordEmail,
|
|
5
|
+
recordFake,
|
|
6
|
+
recordWait,
|
|
7
|
+
recorderEventCount,
|
|
8
|
+
recorderMarkChildren,
|
|
9
|
+
reserveEvent,
|
|
10
|
+
startRecording,
|
|
11
|
+
stopRecording,
|
|
12
|
+
} from "./recorder.js";
|
|
13
|
+
|
|
14
|
+
describe("markChildren", () => {
|
|
15
|
+
test("groups a poll's kept iteration under the wait", () => {
|
|
16
|
+
startRecording();
|
|
17
|
+
const from = recorderEventCount();
|
|
18
|
+
recordFake({ fake: "resend", member: "lastEmailTo", args: [], durationMs: 1 });
|
|
19
|
+
const wait = recordWait({ description: "the mail arrives", attempts: 1, durationMs: 2, passed: true })!;
|
|
20
|
+
recorderMarkChildren(from, wait);
|
|
21
|
+
const events = stopRecording();
|
|
22
|
+
|
|
23
|
+
const fake = events.find((e) => e.kind === "fake")!;
|
|
24
|
+
expect(fake.parentSeq).toBe(wait);
|
|
25
|
+
// The wait never becomes its own child.
|
|
26
|
+
expect(events.find((e) => e.kind === "wait")!.parentSeq).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// A fake helper's `annotate(...)` child is that call's value, so it stays a
|
|
30
|
+
// child of the FAKE event even when the call happened inside a poll
|
|
31
|
+
// predicate. Overwriting it with the wait's seq made the wait itself render
|
|
32
|
+
// as the email, with the fake call showing raw JSON below it.
|
|
33
|
+
test("keeps an already-grouped event under its own parent", () => {
|
|
34
|
+
startRecording();
|
|
35
|
+
const from = recorderEventCount();
|
|
36
|
+
// The call's own slot is reserved first, exactly as invokeFakeHelper does.
|
|
37
|
+
const resv = reserveEvent();
|
|
38
|
+
const fakeSeq = recordFake(
|
|
39
|
+
{ fake: "resend", member: "lastEmailTo", args: [], durationMs: 1 },
|
|
40
|
+
resv,
|
|
41
|
+
)!;
|
|
42
|
+
recordEmail({
|
|
43
|
+
parentSeq: fakeSeq,
|
|
44
|
+
annotation: true,
|
|
45
|
+
service: "resend",
|
|
46
|
+
op: "lastEmailTo",
|
|
47
|
+
message: { subject: "Ditt formulär" },
|
|
48
|
+
durationMs: 1,
|
|
49
|
+
});
|
|
50
|
+
const wait = recordWait({ description: "the mail arrives", attempts: 1, durationMs: 2, passed: true })!;
|
|
51
|
+
recorderMarkChildren(from, wait);
|
|
52
|
+
const events = stopRecording();
|
|
53
|
+
|
|
54
|
+
expect(events.find((e) => e.kind === "fake")!.parentSeq).toBe(wait);
|
|
55
|
+
expect(events.find((e) => e.kind === "email")!.parentSeq).toBe(fakeSeq);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/recorder.ts
CHANGED
|
@@ -26,7 +26,11 @@ export type TestEvent =
|
|
|
26
26
|
| WaitEvent
|
|
27
27
|
| FakeEvent
|
|
28
28
|
| EnvEvent
|
|
29
|
-
| EmailEvent
|
|
29
|
+
| EmailEvent
|
|
30
|
+
// Open `kind`, so it goes last: narrowing the union by a literal kind
|
|
31
|
+
// still reaches the specific member above, and this one carries anything
|
|
32
|
+
// a component describes in presentation terms.
|
|
33
|
+
| StepEvent;
|
|
30
34
|
|
|
31
35
|
interface BaseEvent {
|
|
32
36
|
/** Order of *start* within the test. Reserved when an op begins (see
|
|
@@ -47,6 +51,15 @@ interface BaseEvent {
|
|
|
47
51
|
* timelines. The parent event is identified by its `seq`.
|
|
48
52
|
*/
|
|
49
53
|
parentSeq?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Set when this event is not an op of its own but *another view of its
|
|
56
|
+
* parent's value* — what a fake helper's `annotate(...)` records
|
|
57
|
+
* (`annotate.ts`). The two kinds of `parentSeq` child read differently
|
|
58
|
+
* and render differently: a `ctx.poll` iteration is work the step did,
|
|
59
|
+
* and belongs below it; an annotation is the same value drawn better,
|
|
60
|
+
* and belongs in place of it (the dashboard tabs between the two).
|
|
61
|
+
*/
|
|
62
|
+
annotation?: boolean;
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
export interface ExecEvent extends BaseEvent {
|
|
@@ -348,14 +361,17 @@ export interface EmailEventMessage {
|
|
|
348
361
|
attachments?: { filename: string; contentType: string; size: number }[];
|
|
349
362
|
}
|
|
350
363
|
|
|
351
|
-
/**
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
364
|
+
/**
|
|
365
|
+
* One row of a mailbox listing embedded on an {@link EmailEvent}. A row is a
|
|
366
|
+
* message: the dashboard lists rows as `to` + subject and expands the one you
|
|
367
|
+
* click into the same view a single-message op renders, so whatever body the
|
|
368
|
+
* producer has belongs here. Mailpit's list API returns no bodies, so
|
|
369
|
+
* `email()`'s own listings fill only the header fields plus `snippet` — a row
|
|
370
|
+
* with no body expands to its preview instead.
|
|
371
|
+
*/
|
|
372
|
+
export interface EmailEventSummary extends EmailEventMessage {
|
|
373
|
+
/** Plain-text preview of the body, shown when the row carries no body. */
|
|
357
374
|
snippet?: string;
|
|
358
|
-
date?: string;
|
|
359
375
|
}
|
|
360
376
|
|
|
361
377
|
/**
|
|
@@ -370,9 +386,13 @@ export interface EmailEventSummary {
|
|
|
370
386
|
*/
|
|
371
387
|
export interface EmailEvent extends BaseEvent {
|
|
372
388
|
kind: "email";
|
|
373
|
-
/** Service key of the mail server (`ctx.svc.<service>`)
|
|
389
|
+
/** Service key of the mail server (`ctx.svc.<service>`) — or the fake's
|
|
390
|
+
* name, when a fake helper annotated its return with `annotate(v, "email")`
|
|
391
|
+
* (`annotate.ts`) and this event hangs off that call's `fake` one as a
|
|
392
|
+
* `parentSeq` child. */
|
|
374
393
|
service: string;
|
|
375
|
-
/** Helper called, e.g. `"lastEmail"
|
|
394
|
+
/** Helper called, e.g. `"lastEmail"` — the fake's member name for an
|
|
395
|
+
* annotated fake-helper call. */
|
|
376
396
|
op: string;
|
|
377
397
|
/** Human-readable match criteria, e.g. `to alice@example.com`. */
|
|
378
398
|
query?: string;
|
|
@@ -621,13 +641,22 @@ class Recorder {
|
|
|
621
641
|
}
|
|
622
642
|
}
|
|
623
643
|
|
|
624
|
-
/** Stamp `parentSeq` onto every event at index >= `startIdx
|
|
625
|
-
* by `ctx.poll` to group the
|
|
626
|
-
* resulting wait event.
|
|
644
|
+
/** Stamp `parentSeq` onto every event at index >= `startIdx` that isn't
|
|
645
|
+
* already grouped under something else. Used by `ctx.poll` to group the
|
|
646
|
+
* kept iteration's events under the resulting wait event.
|
|
647
|
+
*
|
|
648
|
+
* An event that already carries a `parentSeq` keeps it: it belongs to a
|
|
649
|
+
* step *inside* this one, and re-parenting it here would flatten the
|
|
650
|
+
* nesting the UI renders from. The case that made this matter is a fake
|
|
651
|
+
* helper called in a poll predicate — its `annotate(...)` child is the
|
|
652
|
+
* fake call's value, and stamping the wait's seq over it made the WAIT
|
|
653
|
+
* render as an email/chat while the fake call showed raw JSON. Its
|
|
654
|
+
* ancestor is still the wait, one level up through the fake event. */
|
|
627
655
|
markChildren(startIdx: number, parentSeq: number): void {
|
|
628
656
|
for (let i = startIdx; i < this.events.length; i++) {
|
|
629
657
|
const ev = this.events[i]!;
|
|
630
658
|
if (ev.seq === parentSeq) continue;
|
|
659
|
+
if (ev.parentSeq !== undefined) continue;
|
|
631
660
|
ev.parentSeq = parentSeq;
|
|
632
661
|
}
|
|
633
662
|
}
|
|
@@ -803,6 +832,59 @@ export function recordEnv(
|
|
|
803
832
|
return active() ? current!.push({ kind: "env", ...ev }, reservation) : undefined;
|
|
804
833
|
}
|
|
805
834
|
|
|
835
|
+
/**
|
|
836
|
+
* One block of step detail, in the dashboard's presentation vocabulary
|
|
837
|
+
* (`crates/control-plane/src/web/blocks.rs`, which owns the closed set).
|
|
838
|
+
* A server that predates a block type skips it rather than failing, so a
|
|
839
|
+
* newer SDK's step degrades to the blocks the server knows.
|
|
840
|
+
*/
|
|
841
|
+
export type StepBlock =
|
|
842
|
+
| { type: "text"; text: string }
|
|
843
|
+
| { type: "code"; code: string; lang?: string; label?: string }
|
|
844
|
+
| { type: "json"; value: unknown; label?: string }
|
|
845
|
+
| { type: "kv"; rows: { label: string; value?: string; error?: boolean }[] }
|
|
846
|
+
| { type: "table"; columns: string[]; rows: unknown[][] }
|
|
847
|
+
| { type: "htmlSandbox"; html: string; label?: string }
|
|
848
|
+
| { type: "chat"; messages: ChatBlockMessage[]; label?: string };
|
|
849
|
+
|
|
850
|
+
/** One message of a `chat` block. */
|
|
851
|
+
export interface ChatBlockMessage {
|
|
852
|
+
/** Who sent it. The transcript is drawn as the end user would see it, so
|
|
853
|
+
* `self` (the end user's own messages) sits on the right and `other` on
|
|
854
|
+
* the left. */
|
|
855
|
+
side?: "self" | "other";
|
|
856
|
+
text?: string;
|
|
857
|
+
/** Arrived in *this* step: drawn with an entrance animation. Only the
|
|
858
|
+
* producer knows this, so only the producer sets it. */
|
|
859
|
+
new?: boolean;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* A step that describes itself in presentation terms — a title plus an
|
|
864
|
+
* ordered list of blocks — instead of as one of the recorder's hard-coded
|
|
865
|
+
* kinds. This is the seam that lets a component ship a new kind of step
|
|
866
|
+
* with no server change: `kind` stays an opaque grouping/diff-alignment
|
|
867
|
+
* label and never decides how the step draws.
|
|
868
|
+
*/
|
|
869
|
+
export interface StepEvent extends BaseEvent {
|
|
870
|
+
/** Opaque. Groups the step and aligns it across runs; never matched on
|
|
871
|
+
* to pick a renderer. */
|
|
872
|
+
kind: string;
|
|
873
|
+
/** The step's one-line summary in the timeline. */
|
|
874
|
+
title: string;
|
|
875
|
+
blocks?: StepBlock[];
|
|
876
|
+
status?: "passed" | "failed" | "error";
|
|
877
|
+
durationMs?: number;
|
|
878
|
+
error?: string;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export function recordStep(
|
|
882
|
+
ev: Omit<StepEvent, "seq" | "tOffsetMs">,
|
|
883
|
+
reservation?: EventReservation,
|
|
884
|
+
): number | undefined {
|
|
885
|
+
return active() ? current!.push(ev, reservation) : undefined;
|
|
886
|
+
}
|
|
887
|
+
|
|
806
888
|
export function recordEmail(
|
|
807
889
|
ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">,
|
|
808
890
|
reservation?: EventReservation,
|