@specific.dev/spectest 0.49.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 +52 -2
- package/dist/annotate.js +46 -0
- package/dist/daemon.js +153 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -5
- package/dist/recorder.d.ts +69 -1
- package/dist/recorder.js +16 -3
- package/package.json +1 -1
- package/src/annotate.ts +104 -1
- package/src/daemon.ts +172 -16
- package/src/index.ts +8 -5
- package/src/recorder.test.ts +57 -0
- package/src/recorder.ts +70 -4
package/dist/annotate.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type EmailEventMessage, type EmailEventSummary } from "./recorder.js";
|
|
1
|
+
import { type EmailEventMessage, type EmailEventSummary, type StepBlock } from "./recorder.js";
|
|
2
2
|
/** Phantom brand — `Annotated<T>` is nominally distinct from `T`, so the
|
|
3
3
|
* box can't be read as the value by accident inside the fake, and the
|
|
4
4
|
* helper types can recognise it and hand the *test* back a plain `T`. */
|
|
@@ -25,6 +25,9 @@ export interface AnnotationOptions {
|
|
|
25
25
|
/** Draw the value as an email — one message, or a list of them for a
|
|
26
26
|
* mailbox listing. See {@link EmailAnnotation}. */
|
|
27
27
|
email: EmailAnnotation | readonly EmailAnnotation[];
|
|
28
|
+
/** Draw the value as a chat transcript — bubbles by role, with the
|
|
29
|
+
* messages that arrived in this call marked. See {@link ChatAnnotation}. */
|
|
30
|
+
chat: ChatAnnotation | readonly ChatMessageAnnotation[];
|
|
28
31
|
}
|
|
29
32
|
/** The kinds {@link annotate} accepts. */
|
|
30
33
|
export type AnnotationKind = keyof AnnotationOptions;
|
|
@@ -34,7 +37,8 @@ export type AnnotationKind = keyof AnnotationOptions;
|
|
|
34
37
|
* which needn't be the annotation kind's name, since an annotation names
|
|
35
38
|
* what the value *is* and the event names how it's drawn.
|
|
36
39
|
*/
|
|
37
|
-
export type RenderAnnotation =
|
|
40
|
+
export type RenderAnnotation = EmailRenderAnnotation | ChatRenderAnnotation;
|
|
41
|
+
type EmailRenderAnnotation = {
|
|
38
42
|
kind: "email";
|
|
39
43
|
/** Single-message ops. */
|
|
40
44
|
message?: EmailEventMessage;
|
|
@@ -43,6 +47,14 @@ export type RenderAnnotation = {
|
|
|
43
47
|
/** Total matches (a listing is capped on the event, never in the value). */
|
|
44
48
|
count?: number;
|
|
45
49
|
};
|
|
50
|
+
/** A chat lowers to a step that describes itself in presentation terms — a
|
|
51
|
+
* title plus blocks — rather than to a kind of its own: `blocks.rs` owns
|
|
52
|
+
* the drawing vocabulary, and a transcript is one of its block types. */
|
|
53
|
+
type ChatRenderAnnotation = {
|
|
54
|
+
kind: "chat";
|
|
55
|
+
title?: string;
|
|
56
|
+
blocks: StepBlock[];
|
|
57
|
+
};
|
|
46
58
|
/**
|
|
47
59
|
* The email fields to render. Every one is optional — pass what the fake
|
|
48
60
|
* has. Addresses take a single string or a list.
|
|
@@ -64,6 +76,44 @@ export interface EmailAnnotation {
|
|
|
64
76
|
* "Plain-text version" disclosure when there is. */
|
|
65
77
|
text?: string;
|
|
66
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* A conversation to draw as chat bubbles.
|
|
81
|
+
*
|
|
82
|
+
* Two sides and their text, which is all a transcript needs to read as one.
|
|
83
|
+
* It is drawn from the end user's point of view — their own messages on the
|
|
84
|
+
* right, the other party's on the left.
|
|
85
|
+
* Pass the messages alone (`annotate(v, "chat", messages)`), or this object
|
|
86
|
+
* when the thread has an identity worth showing. With no options at all the
|
|
87
|
+
* value itself is read as the message list.
|
|
88
|
+
*/
|
|
89
|
+
export interface ChatAnnotation {
|
|
90
|
+
messages: readonly ChatMessageAnnotation[];
|
|
91
|
+
/**
|
|
92
|
+
* The thread's identity — a phone number, a channel, whoever is on the
|
|
93
|
+
* other end — shown as a header over the bubbles. Optional: a fake with
|
|
94
|
+
* one conversation has nothing useful to put here, and the transcript
|
|
95
|
+
* reads fine without it.
|
|
96
|
+
*/
|
|
97
|
+
title?: string;
|
|
98
|
+
}
|
|
99
|
+
/** One turn of a {@link ChatAnnotation}. */
|
|
100
|
+
export interface ChatMessageAnnotation {
|
|
101
|
+
/**
|
|
102
|
+
* Who sent it. A transcript is drawn the way the **end user** would see
|
|
103
|
+
* it on their own screen, so `self` is the end user's own messages — the
|
|
104
|
+
* person using the app under test — and sits on the right; `other` is
|
|
105
|
+
* whoever they are talking to and sits on the left.
|
|
106
|
+
*/
|
|
107
|
+
side: "self" | "other";
|
|
108
|
+
text: string;
|
|
109
|
+
/**
|
|
110
|
+
* This message arrived in *this* call: it slides in when the step opens,
|
|
111
|
+
* and again whenever it is reopened. Only the fake knows which turns are new —
|
|
112
|
+
* the dashboard sees one call, not the conversation's history — so it is
|
|
113
|
+
* the fake that marks them.
|
|
114
|
+
*/
|
|
115
|
+
new?: boolean;
|
|
116
|
+
}
|
|
67
117
|
/**
|
|
68
118
|
* Say what a fake helper's return value *is*, so the timeline can draw it
|
|
69
119
|
* as that on top of the JSON it always shows. The call stays an ordinary
|
package/dist/annotate.js
CHANGED
|
@@ -82,6 +82,8 @@ function lower(kind, options, value) {
|
|
|
82
82
|
switch (kind) {
|
|
83
83
|
case "email":
|
|
84
84
|
return lowerEmail(options, value);
|
|
85
|
+
case "chat":
|
|
86
|
+
return lowerChat(options, value);
|
|
85
87
|
default:
|
|
86
88
|
// Unreachable while `kind` is a key of AnnotationOptions; a kind added
|
|
87
89
|
// to that interface without a case here lands on this line.
|
|
@@ -129,6 +131,50 @@ function fields(v) {
|
|
|
129
131
|
? v
|
|
130
132
|
: {};
|
|
131
133
|
}
|
|
134
|
+
const NO_MESSAGES = 'annotate(value, "chat"): nothing to render — no messages found on the ' +
|
|
135
|
+
"value. Pass them explicitly, e.g. " +
|
|
136
|
+
'annotate(value, "chat", value.turns.map((t) => ({ side: t.fromCustomer ? "self" : "other", text: t.body }))).';
|
|
137
|
+
function lowerChat(options, value) {
|
|
138
|
+
const source = deepUnwrap(options ?? value);
|
|
139
|
+
let list;
|
|
140
|
+
let title;
|
|
141
|
+
if (Array.isArray(source)) {
|
|
142
|
+
list = source;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const bag = fields(source);
|
|
146
|
+
list = bag.messages;
|
|
147
|
+
title = text(bag.title) || undefined;
|
|
148
|
+
}
|
|
149
|
+
// An empty conversation is a real state (nothing said yet) and renders as
|
|
150
|
+
// one; a value with no `messages` at all is the author's mistake.
|
|
151
|
+
if (!Array.isArray(list))
|
|
152
|
+
throw new Error(NO_MESSAGES);
|
|
153
|
+
const messages = list.map((m) => chatMessageOf(fields(m)));
|
|
154
|
+
// The title travels twice on purpose: as the step's own summary, and as
|
|
155
|
+
// the transcript's header — the annotated view renders the blocks alone,
|
|
156
|
+
// so a title carried only on the step would never be seen.
|
|
157
|
+
return {
|
|
158
|
+
kind: "chat",
|
|
159
|
+
title,
|
|
160
|
+
blocks: [{ type: "chat", messages, ...(title ? { label: title } : {}) }],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function chatMessageOf(src) {
|
|
164
|
+
const msg = {};
|
|
165
|
+
// Anything that isn't the end user's own message is drawn as the other
|
|
166
|
+
// party's, rather than a turn being dropped for a typo.
|
|
167
|
+
if (text(src.side) === "self")
|
|
168
|
+
msg.side = "self";
|
|
169
|
+
else
|
|
170
|
+
msg.side = "other";
|
|
171
|
+
const body = text(src.text);
|
|
172
|
+
if (body)
|
|
173
|
+
msg.text = truncateUtf8(body).value;
|
|
174
|
+
if (src.new === true)
|
|
175
|
+
msg.new = true;
|
|
176
|
+
return msg;
|
|
177
|
+
}
|
|
132
178
|
function messageOf(src) {
|
|
133
179
|
const msg = {};
|
|
134
180
|
const from = text(src.from);
|
package/dist/daemon.js
CHANGED
|
@@ -45,7 +45,7 @@ import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToke
|
|
|
45
45
|
import { conflict, notFound, requireString, } from "./harness/methods.js";
|
|
46
46
|
import { openTerminal } from "./terminal.js";
|
|
47
47
|
import { readAnnotation } from "./annotate.js";
|
|
48
|
-
import { recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
|
|
48
|
+
import { pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
|
|
49
49
|
import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
|
|
50
50
|
import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
|
|
51
51
|
import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
|
|
@@ -829,13 +829,126 @@ async function prepareServiceImage(svc, opts) {
|
|
|
829
829
|
}
|
|
830
830
|
return buildServiceImage(svc.name, image, tag);
|
|
831
831
|
}
|
|
832
|
+
/**
|
|
833
|
+
* What the folded CA step prints when it could not write the trust store
|
|
834
|
+
* at all — the one outcome that still needs {@link ensureCaTrustedImage},
|
|
835
|
+
* which can take root for the write.
|
|
836
|
+
*
|
|
837
|
+
* The step assembles this prefix from a shell variable so that the marker
|
|
838
|
+
* appears ONLY in the step's output. BuildKit echoes each instruction into
|
|
839
|
+
* the same log verbatim, so a marker written literally in the RUN would
|
|
840
|
+
* match on every build whether or not the step ever printed it.
|
|
841
|
+
*/
|
|
842
|
+
const CA_FOLD_UNWRITABLE = "[spectest-ca] trust store not writable";
|
|
843
|
+
/**
|
|
844
|
+
* The CA-trust steps as a suffix appended to a dockerfile service's OWN
|
|
845
|
+
* Dockerfile, so one build produces the finished image instead of a build
|
|
846
|
+
* plus a derivative rebuild ({@link ensureCaTrustedImage}) per service.
|
|
847
|
+
* Returns null when there is no CA to layer, or when the PEM can't be
|
|
848
|
+
* quoted — the caller then falls back to the derivative build.
|
|
849
|
+
*
|
|
850
|
+
* The PEM is written INLINE rather than `COPY`d: the build context is
|
|
851
|
+
* `/workspace` under a per-service ignore file that the project itself
|
|
852
|
+
* contributes to (a `**` line with re-includes is the common idiom), and
|
|
853
|
+
* a context path we don't control is a context path that can be excluded.
|
|
854
|
+
* printf needs nothing but a shell.
|
|
855
|
+
*
|
|
856
|
+
* Two rules make this safe to bolt onto user code. It must never fail the
|
|
857
|
+
* build — every branch ends in an echo, so the RUN exits 0 whatever the
|
|
858
|
+
* image lacks — and it must never change the image, beyond the trust
|
|
859
|
+
* store: notably no `USER root`, since we cannot know statically what
|
|
860
|
+
* user to hand back. An image that declares a non-root user therefore
|
|
861
|
+
* fails to write and is finished by the derivative build, which inspects
|
|
862
|
+
* the built image and can escalate properly.
|
|
863
|
+
*/
|
|
864
|
+
async function caTrustSuffix() {
|
|
865
|
+
if (!existsSync(CA_PATH))
|
|
866
|
+
return null;
|
|
867
|
+
const pem = (await fs.readFile(CA_PATH, "utf8")).trim();
|
|
868
|
+
// A quote in the PEM would break out of the shell quoting below. PEM is
|
|
869
|
+
// base64 and dashes, so this is a guard, not a case we expect.
|
|
870
|
+
if (!pem || pem.includes("'"))
|
|
871
|
+
return null;
|
|
872
|
+
const args = pem
|
|
873
|
+
.split("\n")
|
|
874
|
+
.map((l) => `'${l.trimEnd()}'`)
|
|
875
|
+
.join(" ");
|
|
876
|
+
const dst = "/usr/local/share/ca-certificates/spectest-ca.crt";
|
|
877
|
+
return `
|
|
878
|
+
# spectest: trust the environment's root CA. Appended by the harness —
|
|
879
|
+
# not part of the project's Dockerfile.
|
|
880
|
+
RUN P='[spectest-ca]'; \\
|
|
881
|
+
mkdir -p /usr/local/share/ca-certificates 2>/dev/null; \\
|
|
882
|
+
if printf '%s\\n' ${args} > ${dst} 2>/dev/null; then \\
|
|
883
|
+
if command -v update-ca-certificates >/dev/null 2>&1 && update-ca-certificates >/dev/null 2>&1; then \\
|
|
884
|
+
echo "$P trusted via update-ca-certificates"; \\
|
|
885
|
+
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 \\
|
|
886
|
+
echo "$P trusted via update-ca-trust"; \\
|
|
887
|
+
else \\
|
|
888
|
+
echo "$P no system CA trust tool in image; env-var trust only"; \\
|
|
889
|
+
fi; \\
|
|
890
|
+
else \\
|
|
891
|
+
echo "$P trust store not writable by this image's user"; \\
|
|
892
|
+
fi
|
|
893
|
+
`;
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Build a dockerfile service's image.
|
|
897
|
+
*
|
|
898
|
+
* The CA-trust layer is folded into THIS build when it can be (see
|
|
899
|
+
* {@link caTrustSuffix}), so a service costs one image build and one
|
|
900
|
+
* export rather than two. The derivative build stays as the fallback for
|
|
901
|
+
* everything the folded form can't serve: an image with no shell to run
|
|
902
|
+
* the step (distroless, scratch — the appended RUN can't execute, so the
|
|
903
|
+
* build fails and we rebuild the project's Dockerfile untouched), and an
|
|
904
|
+
* image that declares a non-root user (the step runs as that user and
|
|
905
|
+
* can't write the trust store).
|
|
906
|
+
*/
|
|
832
907
|
async function buildServiceImage(name, image, tag) {
|
|
908
|
+
const suffix = await caTrustSuffix();
|
|
909
|
+
let attempt = await runServiceBuild(name, image, tag, suffix);
|
|
910
|
+
if (!attempt.ok && suffix) {
|
|
911
|
+
// Our step must not be able to break a project's build.
|
|
912
|
+
// eslint-disable-next-line no-console
|
|
913
|
+
console.warn(`[ca-trust] folded CA step could not run in ${name}'s image; rebuilding without it`);
|
|
914
|
+
attempt = await runServiceBuild(name, image, tag, null);
|
|
915
|
+
}
|
|
916
|
+
if (!attempt.ok) {
|
|
917
|
+
progressService(name, { status: "failed" });
|
|
918
|
+
throw new Error(`docker build for ${name} failed:\n${attempt.log}`);
|
|
919
|
+
}
|
|
920
|
+
// The derivative build is still needed for the one thing the folded step
|
|
921
|
+
// cannot do: write the trust store of an image that does not run as
|
|
922
|
+
// root. That is decided on the IMAGE, not on the build log — a CACHED
|
|
923
|
+
// layer prints nothing, so a log-only check would quietly stop
|
|
924
|
+
// re-applying the moment BuildKit had the layer. The log covers the
|
|
925
|
+
// rarer case of a root image whose /etc is read-only.
|
|
926
|
+
//
|
|
927
|
+
// An image with no trust tool at all needs nothing further: the
|
|
928
|
+
// derivative build would reach the same dead end, and `runContainer`'s
|
|
929
|
+
// env vars are the fallback either way.
|
|
930
|
+
const needsDerivative = !attempt.folded ||
|
|
931
|
+
attempt.log.includes(CA_FOLD_UNWRITABLE) ||
|
|
932
|
+
!(await imageRunsAsRoot(tag));
|
|
933
|
+
if (needsDerivative)
|
|
934
|
+
await ensureCaTrustedImage(name, tag);
|
|
935
|
+
return { tag, buildSteps: attempt.buildSteps };
|
|
936
|
+
}
|
|
937
|
+
/** Whether `tag`'s declared `USER` is root (or unset, which means root). */
|
|
938
|
+
async function imageRunsAsRoot(tag) {
|
|
939
|
+
const user = (await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)).stdout.trim();
|
|
940
|
+
return user === "" || user === "root" || user === "0";
|
|
941
|
+
}
|
|
942
|
+
async function runServiceBuild(name, image, tag, caSuffix) {
|
|
833
943
|
let buildSteps;
|
|
834
944
|
{
|
|
945
|
+
const content = caSuffix
|
|
946
|
+
? `${image.content.replace(/\n*$/, "\n")}${caSuffix}`
|
|
947
|
+
: image.content;
|
|
835
948
|
const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
|
|
836
949
|
await fs.mkdir(dfDir, { recursive: true });
|
|
837
950
|
const dfPath = path.join(dfDir, "Dockerfile");
|
|
838
|
-
await fs.writeFile(dfPath,
|
|
951
|
+
await fs.writeFile(dfPath, content);
|
|
839
952
|
// Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
|
|
840
953
|
// (next to the Dockerfile) in preference to the context root's
|
|
841
954
|
// `.dockerignore`, so this build sees the defaults, the project's own
|
|
@@ -897,9 +1010,11 @@ async function buildServiceImage(name, image, tag) {
|
|
|
897
1010
|
});
|
|
898
1011
|
}
|
|
899
1012
|
});
|
|
1013
|
+
const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
|
|
900
1014
|
if (build.code !== 0) {
|
|
901
|
-
|
|
902
|
-
|
|
1015
|
+
// The caller decides whether this is fatal: a failure with the CA
|
|
1016
|
+
// step appended is retried without it before anyone hears about it.
|
|
1017
|
+
return { ok: false, folded: caSuffix !== null, log };
|
|
903
1018
|
}
|
|
904
1019
|
if (useBuildKit) {
|
|
905
1020
|
// Keep only the slowest dozen steps ≥1s — enough to profile, small
|
|
@@ -908,14 +1023,8 @@ async function buildServiceImage(name, image, tag) {
|
|
|
908
1023
|
.filter((s) => s.secs >= 1)
|
|
909
1024
|
.slice(0, 12);
|
|
910
1025
|
}
|
|
1026
|
+
return { ok: true, folded: caSuffix !== null, log, buildSteps };
|
|
911
1027
|
}
|
|
912
|
-
// Layer the spectest CA into the image's system trust store so apps
|
|
913
|
-
// that read the system bundle (Go, Java, CLIs that don't honour the
|
|
914
|
-
// SSL_CERT_FILE env vars) accept HTTPS to fakes. Best-effort: images
|
|
915
|
-
// without `update-ca-certificates` / `update-ca-trust` (distroless,
|
|
916
|
-
// scratch) fall through to the env-var path that `runContainer` sets.
|
|
917
|
-
await ensureCaTrustedImage(name, tag);
|
|
918
|
-
return { tag, buildSteps };
|
|
919
1028
|
}
|
|
920
1029
|
/**
|
|
921
1030
|
* Build a derivative image on top of `tag` that copies the spectest
|
|
@@ -2294,20 +2403,39 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
|
|
|
2294
2403
|
error: errMessage(err),
|
|
2295
2404
|
}, resv);
|
|
2296
2405
|
};
|
|
2406
|
+
// A helper call is ONE step. Whatever the fake does inside it — a fetch to
|
|
2407
|
+
// deliver a webhook, a call to its own API — is the fake's plumbing, not
|
|
2408
|
+
// something the test did, and it would otherwise land on the timeline as
|
|
2409
|
+
// an `http` step the test never made. The built-in `email()` helpers
|
|
2410
|
+
// already pause around their internal polls by hand for exactly this
|
|
2411
|
+
// reason (`components/email.ts`); doing it here gives every user-authored
|
|
2412
|
+
// fake the same contract without having to know about it.
|
|
2413
|
+
//
|
|
2414
|
+
// The pause spans the helper's `await`s, so genuinely concurrent test work
|
|
2415
|
+
// (`Promise.all([helper(), ctx.fetch(…)])`) loses its events too. That is
|
|
2416
|
+
// the same trade `email()` has always made, and sequential test code — all
|
|
2417
|
+
// of it, in practice — is unaffected.
|
|
2418
|
+
pauseRecording();
|
|
2297
2419
|
let result;
|
|
2298
2420
|
try {
|
|
2299
2421
|
result = fn.apply(thisArg, args);
|
|
2300
2422
|
}
|
|
2301
2423
|
catch (err) {
|
|
2424
|
+
resumeRecording();
|
|
2302
2425
|
recordError(err);
|
|
2303
2426
|
throw err;
|
|
2304
2427
|
}
|
|
2305
2428
|
if (result instanceof Promise) {
|
|
2306
|
-
return result.then(
|
|
2429
|
+
return result.then((value) => {
|
|
2430
|
+
resumeRecording();
|
|
2431
|
+
return recordResult(value);
|
|
2432
|
+
}, (err) => {
|
|
2433
|
+
resumeRecording();
|
|
2307
2434
|
recordError(err);
|
|
2308
2435
|
throw err;
|
|
2309
2436
|
});
|
|
2310
2437
|
}
|
|
2438
|
+
resumeRecording();
|
|
2311
2439
|
return recordResult(result);
|
|
2312
2440
|
}
|
|
2313
2441
|
/** Record a fake-helper call's render annotation as a child of the call's
|
|
@@ -2337,6 +2465,19 @@ function recordAnnotationChild(fakeName, member, annotation, durationMs, parentS
|
|
|
2337
2465
|
durationMs,
|
|
2338
2466
|
});
|
|
2339
2467
|
break;
|
|
2468
|
+
case "chat":
|
|
2469
|
+
// A chat has no event kind of its own: it describes itself in
|
|
2470
|
+
// presentation terms (title + blocks), which is the seam a new step
|
|
2471
|
+
// type is supposed to use. `kind` stays an opaque grouping label.
|
|
2472
|
+
recordStep({
|
|
2473
|
+
kind: "chat",
|
|
2474
|
+
parentSeq,
|
|
2475
|
+
annotation: true,
|
|
2476
|
+
title: annotation.title ?? `${fakeName}.${member}`,
|
|
2477
|
+
blocks: annotation.blocks,
|
|
2478
|
+
durationMs,
|
|
2479
|
+
});
|
|
2480
|
+
break;
|
|
2340
2481
|
}
|
|
2341
2482
|
}
|
|
2342
2483
|
function errMessage(err) {
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedResponse, Pr
|
|
|
3
3
|
import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
4
4
|
export { field } from "./inspect.js";
|
|
5
5
|
export { annotate } from "./annotate.js";
|
|
6
|
-
export type { Annotated, AnnotationKind, AnnotationOptions, EmailAnnotation, } from "./annotate.js";
|
|
6
|
+
export type { Annotated, AnnotationKind, AnnotationOptions, ChatAnnotation, ChatMessageAnnotation, EmailAnnotation, } from "./annotate.js";
|
|
7
7
|
import type { Annotated } from "./annotate.js";
|
|
8
8
|
export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
|
|
9
9
|
export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
|
package/dist/index.js
CHANGED
|
@@ -17,11 +17,12 @@ import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
|
|
|
17
17
|
// `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
|
|
18
18
|
export { field } from "./inspect.js";
|
|
19
19
|
// Render annotations for fake helpers: a helper returns
|
|
20
|
-
// `annotate(value, "email", { … })`
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// the
|
|
20
|
+
// `annotate(value, "email", { … })` or `annotate(value, "chat", { … })`
|
|
21
|
+
// when its value is one of those, and the fake step it records gains a
|
|
22
|
+
// nested event drawing it — an email mail-client style, a conversation as
|
|
23
|
+
// bubbles — which the step's panel leads with, tabbing to the raw JSON
|
|
24
|
+
// value. The kind picks the options' type, and the test still gets the raw
|
|
25
|
+
// value, with the raw value's type — see `annotate.ts`.
|
|
25
26
|
export { annotate } from "./annotate.js";
|
|
26
27
|
// Instrumented client primitives — drop-in replacements for Bun's native
|
|
27
28
|
// clients that record each operation on the test event log and return their
|
package/dist/recorder.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | EnvEvent | EmailEvent;
|
|
1
|
+
export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | EnvEvent | EmailEvent | StepEvent;
|
|
2
2
|
interface BaseEvent {
|
|
3
3
|
/** Order of *start* within the test. Reserved when an op begins (see
|
|
4
4
|
* `reserveEvent`), so an op whose nested children finish — and record —
|
|
@@ -557,6 +557,74 @@ export declare function recordTerminal(ev: Omit<TerminalEvent, "seq" | "tOffsetM
|
|
|
557
557
|
export declare function recordTerminalStep(ev: Omit<TerminalStepEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
558
558
|
export declare function recordFake(ev: Omit<FakeEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
559
559
|
export declare function recordEnv(ev: Omit<EnvEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
560
|
+
/**
|
|
561
|
+
* One block of step detail, in the dashboard's presentation vocabulary
|
|
562
|
+
* (`crates/control-plane/src/web/blocks.rs`, which owns the closed set).
|
|
563
|
+
* A server that predates a block type skips it rather than failing, so a
|
|
564
|
+
* newer SDK's step degrades to the blocks the server knows.
|
|
565
|
+
*/
|
|
566
|
+
export type StepBlock = {
|
|
567
|
+
type: "text";
|
|
568
|
+
text: string;
|
|
569
|
+
} | {
|
|
570
|
+
type: "code";
|
|
571
|
+
code: string;
|
|
572
|
+
lang?: string;
|
|
573
|
+
label?: string;
|
|
574
|
+
} | {
|
|
575
|
+
type: "json";
|
|
576
|
+
value: unknown;
|
|
577
|
+
label?: string;
|
|
578
|
+
} | {
|
|
579
|
+
type: "kv";
|
|
580
|
+
rows: {
|
|
581
|
+
label: string;
|
|
582
|
+
value?: string;
|
|
583
|
+
error?: boolean;
|
|
584
|
+
}[];
|
|
585
|
+
} | {
|
|
586
|
+
type: "table";
|
|
587
|
+
columns: string[];
|
|
588
|
+
rows: unknown[][];
|
|
589
|
+
} | {
|
|
590
|
+
type: "htmlSandbox";
|
|
591
|
+
html: string;
|
|
592
|
+
label?: string;
|
|
593
|
+
} | {
|
|
594
|
+
type: "chat";
|
|
595
|
+
messages: ChatBlockMessage[];
|
|
596
|
+
label?: string;
|
|
597
|
+
};
|
|
598
|
+
/** One message of a `chat` block. */
|
|
599
|
+
export interface ChatBlockMessage {
|
|
600
|
+
/** Who sent it. The transcript is drawn as the end user would see it, so
|
|
601
|
+
* `self` (the end user's own messages) sits on the right and `other` on
|
|
602
|
+
* the left. */
|
|
603
|
+
side?: "self" | "other";
|
|
604
|
+
text?: string;
|
|
605
|
+
/** Arrived in *this* step: drawn with an entrance animation. Only the
|
|
606
|
+
* producer knows this, so only the producer sets it. */
|
|
607
|
+
new?: boolean;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* A step that describes itself in presentation terms — a title plus an
|
|
611
|
+
* ordered list of blocks — instead of as one of the recorder's hard-coded
|
|
612
|
+
* kinds. This is the seam that lets a component ship a new kind of step
|
|
613
|
+
* with no server change: `kind` stays an opaque grouping/diff-alignment
|
|
614
|
+
* label and never decides how the step draws.
|
|
615
|
+
*/
|
|
616
|
+
export interface StepEvent extends BaseEvent {
|
|
617
|
+
/** Opaque. Groups the step and aligns it across runs; never matched on
|
|
618
|
+
* to pick a renderer. */
|
|
619
|
+
kind: string;
|
|
620
|
+
/** The step's one-line summary in the timeline. */
|
|
621
|
+
title: string;
|
|
622
|
+
blocks?: StepBlock[];
|
|
623
|
+
status?: "passed" | "failed" | "error";
|
|
624
|
+
durationMs?: number;
|
|
625
|
+
error?: string;
|
|
626
|
+
}
|
|
627
|
+
export declare function recordStep(ev: Omit<StepEvent, "seq" | "tOffsetMs">, reservation?: EventReservation): number | undefined;
|
|
560
628
|
export declare function recordEmail(ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
561
629
|
/**
|
|
562
630
|
* Recorded once per `ctx.poll(...)` call. Stands in for the suppressed
|
package/dist/recorder.js
CHANGED
|
@@ -49,14 +49,24 @@ class Recorder {
|
|
|
49
49
|
this.events.length = toLen;
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
-
/** Stamp `parentSeq` onto every event at index >= `startIdx
|
|
53
|
-
* by `ctx.poll` to group the
|
|
54
|
-
* resulting wait event.
|
|
52
|
+
/** Stamp `parentSeq` onto every event at index >= `startIdx` that isn't
|
|
53
|
+
* already grouped under something else. Used by `ctx.poll` to group the
|
|
54
|
+
* kept iteration's events under the resulting wait event.
|
|
55
|
+
*
|
|
56
|
+
* An event that already carries a `parentSeq` keeps it: it belongs to a
|
|
57
|
+
* step *inside* this one, and re-parenting it here would flatten the
|
|
58
|
+
* nesting the UI renders from. The case that made this matter is a fake
|
|
59
|
+
* helper called in a poll predicate — its `annotate(...)` child is the
|
|
60
|
+
* fake call's value, and stamping the wait's seq over it made the WAIT
|
|
61
|
+
* render as an email/chat while the fake call showed raw JSON. Its
|
|
62
|
+
* ancestor is still the wait, one level up through the fake event. */
|
|
55
63
|
markChildren(startIdx, parentSeq) {
|
|
56
64
|
for (let i = startIdx; i < this.events.length; i++) {
|
|
57
65
|
const ev = this.events[i];
|
|
58
66
|
if (ev.seq === parentSeq)
|
|
59
67
|
continue;
|
|
68
|
+
if (ev.parentSeq !== undefined)
|
|
69
|
+
continue;
|
|
60
70
|
ev.parentSeq = parentSeq;
|
|
61
71
|
}
|
|
62
72
|
}
|
|
@@ -178,6 +188,9 @@ export function recordFake(ev, reservation) {
|
|
|
178
188
|
export function recordEnv(ev, reservation) {
|
|
179
189
|
return active() ? current.push({ kind: "env", ...ev }, reservation) : undefined;
|
|
180
190
|
}
|
|
191
|
+
export function recordStep(ev, reservation) {
|
|
192
|
+
return active() ? current.push(ev, reservation) : undefined;
|
|
193
|
+
}
|
|
181
194
|
export function recordEmail(ev, reservation) {
|
|
182
195
|
return active() ? current.push({ kind: "email", ...ev }, reservation) : undefined;
|
|
183
196
|
}
|
package/package.json
CHANGED
package/src/annotate.ts
CHANGED
|
@@ -33,8 +33,10 @@
|
|
|
33
33
|
import { deepUnwrap } from "./inspect.js";
|
|
34
34
|
import {
|
|
35
35
|
truncateUtf8,
|
|
36
|
+
type ChatBlockMessage,
|
|
36
37
|
type EmailEventMessage,
|
|
37
38
|
type EmailEventSummary,
|
|
39
|
+
type StepBlock,
|
|
38
40
|
} from "./recorder.js";
|
|
39
41
|
|
|
40
42
|
/**
|
|
@@ -74,6 +76,9 @@ export interface AnnotationOptions {
|
|
|
74
76
|
/** Draw the value as an email — one message, or a list of them for a
|
|
75
77
|
* mailbox listing. See {@link EmailAnnotation}. */
|
|
76
78
|
email: EmailAnnotation | readonly EmailAnnotation[];
|
|
79
|
+
/** Draw the value as a chat transcript — bubbles by role, with the
|
|
80
|
+
* messages that arrived in this call marked. See {@link ChatAnnotation}. */
|
|
81
|
+
chat: ChatAnnotation | readonly ChatMessageAnnotation[];
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
/** The kinds {@link annotate} accepts. */
|
|
@@ -85,7 +90,9 @@ export type AnnotationKind = keyof AnnotationOptions;
|
|
|
85
90
|
* which needn't be the annotation kind's name, since an annotation names
|
|
86
91
|
* what the value *is* and the event names how it's drawn.
|
|
87
92
|
*/
|
|
88
|
-
export type RenderAnnotation =
|
|
93
|
+
export type RenderAnnotation = EmailRenderAnnotation | ChatRenderAnnotation;
|
|
94
|
+
|
|
95
|
+
type EmailRenderAnnotation = {
|
|
89
96
|
kind: "email";
|
|
90
97
|
/** Single-message ops. */
|
|
91
98
|
message?: EmailEventMessage;
|
|
@@ -95,6 +102,15 @@ export type RenderAnnotation = {
|
|
|
95
102
|
count?: number;
|
|
96
103
|
};
|
|
97
104
|
|
|
105
|
+
/** A chat lowers to a step that describes itself in presentation terms — a
|
|
106
|
+
* title plus blocks — rather than to a kind of its own: `blocks.rs` owns
|
|
107
|
+
* the drawing vocabulary, and a transcript is one of its block types. */
|
|
108
|
+
type ChatRenderAnnotation = {
|
|
109
|
+
kind: "chat";
|
|
110
|
+
title?: string;
|
|
111
|
+
blocks: StepBlock[];
|
|
112
|
+
};
|
|
113
|
+
|
|
98
114
|
/**
|
|
99
115
|
* The email fields to render. Every one is optional — pass what the fake
|
|
100
116
|
* has. Addresses take a single string or a list.
|
|
@@ -117,6 +133,46 @@ export interface EmailAnnotation {
|
|
|
117
133
|
text?: string;
|
|
118
134
|
}
|
|
119
135
|
|
|
136
|
+
/**
|
|
137
|
+
* A conversation to draw as chat bubbles.
|
|
138
|
+
*
|
|
139
|
+
* Two sides and their text, which is all a transcript needs to read as one.
|
|
140
|
+
* It is drawn from the end user's point of view — their own messages on the
|
|
141
|
+
* right, the other party's on the left.
|
|
142
|
+
* Pass the messages alone (`annotate(v, "chat", messages)`), or this object
|
|
143
|
+
* when the thread has an identity worth showing. With no options at all the
|
|
144
|
+
* value itself is read as the message list.
|
|
145
|
+
*/
|
|
146
|
+
export interface ChatAnnotation {
|
|
147
|
+
messages: readonly ChatMessageAnnotation[];
|
|
148
|
+
/**
|
|
149
|
+
* The thread's identity — a phone number, a channel, whoever is on the
|
|
150
|
+
* other end — shown as a header over the bubbles. Optional: a fake with
|
|
151
|
+
* one conversation has nothing useful to put here, and the transcript
|
|
152
|
+
* reads fine without it.
|
|
153
|
+
*/
|
|
154
|
+
title?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One turn of a {@link ChatAnnotation}. */
|
|
158
|
+
export interface ChatMessageAnnotation {
|
|
159
|
+
/**
|
|
160
|
+
* Who sent it. A transcript is drawn the way the **end user** would see
|
|
161
|
+
* it on their own screen, so `self` is the end user's own messages — the
|
|
162
|
+
* person using the app under test — and sits on the right; `other` is
|
|
163
|
+
* whoever they are talking to and sits on the left.
|
|
164
|
+
*/
|
|
165
|
+
side: "self" | "other";
|
|
166
|
+
text: string;
|
|
167
|
+
/**
|
|
168
|
+
* This message arrived in *this* call: it slides in when the step opens,
|
|
169
|
+
* and again whenever it is reopened. Only the fake knows which turns are new —
|
|
170
|
+
* the dashboard sees one call, not the conversation's history — so it is
|
|
171
|
+
* the fake that marks them.
|
|
172
|
+
*/
|
|
173
|
+
new?: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
120
176
|
/** Listing rows carried on the event. The returned value is never capped. */
|
|
121
177
|
const LIST_CAP = 50;
|
|
122
178
|
|
|
@@ -173,6 +229,8 @@ function lower<K extends AnnotationKind>(
|
|
|
173
229
|
switch (kind) {
|
|
174
230
|
case "email":
|
|
175
231
|
return lowerEmail(options as AnnotationOptions["email"] | undefined, value);
|
|
232
|
+
case "chat":
|
|
233
|
+
return lowerChat(options as AnnotationOptions["chat"] | undefined, value);
|
|
176
234
|
default:
|
|
177
235
|
// Unreachable while `kind` is a key of AnnotationOptions; a kind added
|
|
178
236
|
// to that interface without a case here lands on this line.
|
|
@@ -230,6 +288,51 @@ function fields(v: unknown): Record<string, unknown> {
|
|
|
230
288
|
: {};
|
|
231
289
|
}
|
|
232
290
|
|
|
291
|
+
const NO_MESSAGES =
|
|
292
|
+
'annotate(value, "chat"): nothing to render — no messages found on the ' +
|
|
293
|
+
"value. Pass them explicitly, e.g. " +
|
|
294
|
+
'annotate(value, "chat", value.turns.map((t) => ({ side: t.fromCustomer ? "self" : "other", text: t.body }))).';
|
|
295
|
+
|
|
296
|
+
function lowerChat(
|
|
297
|
+
options: AnnotationOptions["chat"] | undefined,
|
|
298
|
+
value: unknown,
|
|
299
|
+
): RenderAnnotation {
|
|
300
|
+
const source: unknown = deepUnwrap(options ?? value);
|
|
301
|
+
let list: unknown;
|
|
302
|
+
let title: string | undefined;
|
|
303
|
+
if (Array.isArray(source)) {
|
|
304
|
+
list = source;
|
|
305
|
+
} else {
|
|
306
|
+
const bag = fields(source);
|
|
307
|
+
list = bag.messages;
|
|
308
|
+
title = text(bag.title) || undefined;
|
|
309
|
+
}
|
|
310
|
+
// An empty conversation is a real state (nothing said yet) and renders as
|
|
311
|
+
// one; a value with no `messages` at all is the author's mistake.
|
|
312
|
+
if (!Array.isArray(list)) throw new Error(NO_MESSAGES);
|
|
313
|
+
const messages = list.map((m) => chatMessageOf(fields(m)));
|
|
314
|
+
// The title travels twice on purpose: as the step's own summary, and as
|
|
315
|
+
// the transcript's header — the annotated view renders the blocks alone,
|
|
316
|
+
// so a title carried only on the step would never be seen.
|
|
317
|
+
return {
|
|
318
|
+
kind: "chat",
|
|
319
|
+
title,
|
|
320
|
+
blocks: [{ type: "chat", messages, ...(title ? { label: title } : {}) }],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function chatMessageOf(src: Record<string, unknown>): ChatBlockMessage {
|
|
325
|
+
const msg: ChatBlockMessage = {};
|
|
326
|
+
// Anything that isn't the end user's own message is drawn as the other
|
|
327
|
+
// party's, rather than a turn being dropped for a typo.
|
|
328
|
+
if (text(src.side) === "self") msg.side = "self";
|
|
329
|
+
else msg.side = "other";
|
|
330
|
+
const body = text(src.text);
|
|
331
|
+
if (body) msg.text = truncateUtf8(body).value;
|
|
332
|
+
if (src.new === true) msg.new = true;
|
|
333
|
+
return msg;
|
|
334
|
+
}
|
|
335
|
+
|
|
233
336
|
function messageOf(src: Record<string, unknown>): EmailEventMessage {
|
|
234
337
|
const msg: EmailEventMessage = {};
|
|
235
338
|
const from = text(src.from);
|
package/src/daemon.ts
CHANGED
|
@@ -114,17 +114,20 @@ import type { Mobile, MobileApp } from "./mobile.js";
|
|
|
114
114
|
import { openTerminal } from "./terminal.js";
|
|
115
115
|
import { readAnnotation, type RenderAnnotation } from "./annotate.js";
|
|
116
116
|
import {
|
|
117
|
+
pauseRecording,
|
|
117
118
|
recordEmail,
|
|
118
119
|
recordEnv,
|
|
119
120
|
recordExec,
|
|
120
121
|
recordFake,
|
|
121
122
|
recordHttp,
|
|
123
|
+
recordStep,
|
|
122
124
|
recordTerminal,
|
|
123
125
|
recordWait,
|
|
124
126
|
reserveEvent,
|
|
125
127
|
recorderEventCount,
|
|
126
128
|
recorderMarkChildren,
|
|
127
129
|
recorderTruncate,
|
|
130
|
+
resumeRecording,
|
|
128
131
|
startRecording,
|
|
129
132
|
stopRecording,
|
|
130
133
|
truncateUtf8,
|
|
@@ -1113,17 +1116,141 @@ async function prepareServiceImage(
|
|
|
1113
1116
|
return buildServiceImage(svc.name, image, tag);
|
|
1114
1117
|
}
|
|
1115
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
|
+
*/
|
|
1116
1194
|
async function buildServiceImage(
|
|
1117
1195
|
name: string,
|
|
1118
1196
|
image: { content: string; exclude?: readonly string[] },
|
|
1119
1197
|
tag: string,
|
|
1120
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[] }> {
|
|
1121
1245
|
let buildSteps: BuildStep[] | undefined;
|
|
1122
1246
|
{
|
|
1247
|
+
const content = caSuffix
|
|
1248
|
+
? `${image.content.replace(/\n*$/, "\n")}${caSuffix}`
|
|
1249
|
+
: image.content;
|
|
1123
1250
|
const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
|
|
1124
1251
|
await fs.mkdir(dfDir, { recursive: true });
|
|
1125
1252
|
const dfPath = path.join(dfDir, "Dockerfile");
|
|
1126
|
-
await fs.writeFile(dfPath,
|
|
1253
|
+
await fs.writeFile(dfPath, content);
|
|
1127
1254
|
// Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
|
|
1128
1255
|
// (next to the Dockerfile) in preference to the context root's
|
|
1129
1256
|
// `.dockerignore`, so this build sees the defaults, the project's own
|
|
@@ -1183,11 +1310,11 @@ async function buildServiceImage(
|
|
|
1183
1310
|
});
|
|
1184
1311
|
}
|
|
1185
1312
|
});
|
|
1313
|
+
const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
|
|
1186
1314
|
if (build.code !== 0) {
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
);
|
|
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 };
|
|
1191
1318
|
}
|
|
1192
1319
|
if (useBuildKit) {
|
|
1193
1320
|
// Keep only the slowest dozen steps ≥1s — enough to profile, small
|
|
@@ -1196,14 +1323,8 @@ async function buildServiceImage(
|
|
|
1196
1323
|
.filter((s) => s.secs >= 1)
|
|
1197
1324
|
.slice(0, 12);
|
|
1198
1325
|
}
|
|
1326
|
+
return { ok: true, folded: caSuffix !== null, log, buildSteps };
|
|
1199
1327
|
}
|
|
1200
|
-
// Layer the spectest CA into the image's system trust store so apps
|
|
1201
|
-
// that read the system bundle (Go, Java, CLIs that don't honour the
|
|
1202
|
-
// SSL_CERT_FILE env vars) accept HTTPS to fakes. Best-effort: images
|
|
1203
|
-
// without `update-ca-certificates` / `update-ca-trust` (distroless,
|
|
1204
|
-
// scratch) fall through to the env-var path that `runContainer` sets.
|
|
1205
|
-
await ensureCaTrustedImage(name, tag);
|
|
1206
|
-
return { tag, buildSteps };
|
|
1207
1328
|
}
|
|
1208
1329
|
|
|
1209
1330
|
/**
|
|
@@ -2762,19 +2883,41 @@ function invokeFakeHelper(
|
|
|
2762
2883
|
}, resv);
|
|
2763
2884
|
};
|
|
2764
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();
|
|
2765
2899
|
let result: unknown;
|
|
2766
2900
|
try {
|
|
2767
2901
|
result = fn.apply(thisArg, args);
|
|
2768
2902
|
} catch (err) {
|
|
2903
|
+
resumeRecording();
|
|
2769
2904
|
recordError(err);
|
|
2770
2905
|
throw err;
|
|
2771
2906
|
}
|
|
2772
2907
|
if (result instanceof Promise) {
|
|
2773
|
-
return result.then(
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
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
|
+
);
|
|
2777
2919
|
}
|
|
2920
|
+
resumeRecording();
|
|
2778
2921
|
return recordResult(result);
|
|
2779
2922
|
}
|
|
2780
2923
|
|
|
@@ -2811,6 +2954,19 @@ function recordAnnotationChild(
|
|
|
2811
2954
|
durationMs,
|
|
2812
2955
|
});
|
|
2813
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;
|
|
2814
2970
|
}
|
|
2815
2971
|
}
|
|
2816
2972
|
|
package/src/index.ts
CHANGED
|
@@ -35,16 +35,19 @@ import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
|
35
35
|
export { field } from "./inspect.js";
|
|
36
36
|
|
|
37
37
|
// Render annotations for fake helpers: a helper returns
|
|
38
|
-
// `annotate(value, "email", { … })`
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// the
|
|
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`.
|
|
43
44
|
export { annotate } from "./annotate.js";
|
|
44
45
|
export type {
|
|
45
46
|
Annotated,
|
|
46
47
|
AnnotationKind,
|
|
47
48
|
AnnotationOptions,
|
|
49
|
+
ChatAnnotation,
|
|
50
|
+
ChatMessageAnnotation,
|
|
48
51
|
EmailAnnotation,
|
|
49
52
|
} from "./annotate.js";
|
|
50
53
|
import type { Annotated } from "./annotate.js";
|
|
@@ -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
|
|
@@ -637,13 +641,22 @@ class Recorder {
|
|
|
637
641
|
}
|
|
638
642
|
}
|
|
639
643
|
|
|
640
|
-
/** Stamp `parentSeq` onto every event at index >= `startIdx
|
|
641
|
-
* by `ctx.poll` to group the
|
|
642
|
-
* 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. */
|
|
643
655
|
markChildren(startIdx: number, parentSeq: number): void {
|
|
644
656
|
for (let i = startIdx; i < this.events.length; i++) {
|
|
645
657
|
const ev = this.events[i]!;
|
|
646
658
|
if (ev.seq === parentSeq) continue;
|
|
659
|
+
if (ev.parentSeq !== undefined) continue;
|
|
647
660
|
ev.parentSeq = parentSeq;
|
|
648
661
|
}
|
|
649
662
|
}
|
|
@@ -819,6 +832,59 @@ export function recordEnv(
|
|
|
819
832
|
return active() ? current!.push({ kind: "env", ...ev }, reservation) : undefined;
|
|
820
833
|
}
|
|
821
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
|
+
|
|
822
888
|
export function recordEmail(
|
|
823
889
|
ev: Omit<EmailEvent, "seq" | "tOffsetMs" | "kind">,
|
|
824
890
|
reservation?: EventReservation,
|