@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { type EmailEventMessage, type EmailEventSummary, type StepBlock } from "./recorder.js";
|
|
2
|
+
/** Phantom brand — `Annotated<T>` is nominally distinct from `T`, so the
|
|
3
|
+
* box can't be read as the value by accident inside the fake, and the
|
|
4
|
+
* helper types can recognise it and hand the *test* back a plain `T`. */
|
|
5
|
+
declare const ANNOTATED: unique symbol;
|
|
6
|
+
/**
|
|
7
|
+
* A value plus a rendering hint, as returned by {@link annotate}. Return it
|
|
8
|
+
* straight from a fake helper: the daemon unwraps it, so the test receives
|
|
9
|
+
* the value itself and `ctx.fakes.<name>.<fn>()` is typed as if the
|
|
10
|
+
* annotation weren't there.
|
|
11
|
+
*/
|
|
12
|
+
export interface Annotated<T> {
|
|
13
|
+
readonly [ANNOTATED]: T;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Every annotation kind, and the options each one takes. This interface is
|
|
17
|
+
* the whole type contract of {@link annotate}: naming a kind picks the type
|
|
18
|
+
* of the options argument, so a stray field or a value that belongs to
|
|
19
|
+
* another kind is a compile error at the call site.
|
|
20
|
+
*
|
|
21
|
+
* A new kind is an entry here, a case in `lower`, and an arm in
|
|
22
|
+
* `daemon.ts::recordAnnotationChild`.
|
|
23
|
+
*/
|
|
24
|
+
export interface AnnotationOptions {
|
|
25
|
+
/** Draw the value as an email — one message, or a list of them for a
|
|
26
|
+
* mailbox listing. See {@link EmailAnnotation}. */
|
|
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[];
|
|
31
|
+
}
|
|
32
|
+
/** The kinds {@link annotate} accepts. */
|
|
33
|
+
export type AnnotationKind = keyof AnnotationOptions;
|
|
34
|
+
/**
|
|
35
|
+
* What an annotation lowers to: the fields of the child event the daemon
|
|
36
|
+
* records under the call's own `fake` one. Tagged by that event's `kind` —
|
|
37
|
+
* which needn't be the annotation kind's name, since an annotation names
|
|
38
|
+
* what the value *is* and the event names how it's drawn.
|
|
39
|
+
*/
|
|
40
|
+
export type RenderAnnotation = EmailRenderAnnotation | ChatRenderAnnotation;
|
|
41
|
+
type EmailRenderAnnotation = {
|
|
42
|
+
kind: "email";
|
|
43
|
+
/** Single-message ops. */
|
|
44
|
+
message?: EmailEventMessage;
|
|
45
|
+
/** Listing ops. */
|
|
46
|
+
messages?: EmailEventSummary[];
|
|
47
|
+
/** Total matches (a listing is capped on the event, never in the value). */
|
|
48
|
+
count?: number;
|
|
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
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* The email fields to render. Every one is optional — pass what the fake
|
|
60
|
+
* has. Addresses take a single string or a list.
|
|
61
|
+
*
|
|
62
|
+
* The event can also carry a date, per-row previews and attachment
|
|
63
|
+
* metadata, which the built-in `email()` component fills from a really
|
|
64
|
+
* captured message; a fake's mail was never sent, so those are deliberately
|
|
65
|
+
* not offered here. A listing row's preview is derived from the body.
|
|
66
|
+
*/
|
|
67
|
+
export interface EmailAnnotation {
|
|
68
|
+
from?: string;
|
|
69
|
+
to?: string | readonly string[];
|
|
70
|
+
cc?: string | readonly string[];
|
|
71
|
+
bcc?: string | readonly string[];
|
|
72
|
+
subject?: string;
|
|
73
|
+
/** HTML body. Rendered in a fully sandboxed iframe (scripts off). */
|
|
74
|
+
html?: string;
|
|
75
|
+
/** Plain-text body. Shown on its own when there's no HTML, folded into a
|
|
76
|
+
* "Plain-text version" disclosure when there is. */
|
|
77
|
+
text?: string;
|
|
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
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Say what a fake helper's return value *is*, so the timeline can draw it
|
|
119
|
+
* as that on top of the JSON it always shows. The call stays an ordinary
|
|
120
|
+
* fake step; the rendered view nests inside it. The value is returned to
|
|
121
|
+
* the test unchanged (and unchanged in type).
|
|
122
|
+
*
|
|
123
|
+
* ```ts
|
|
124
|
+
* helpers: ({ state }) => ({
|
|
125
|
+
* lastReceipt() {
|
|
126
|
+
* const r = state.receipts.at(-1);
|
|
127
|
+
* return r && annotate(r, "email", {
|
|
128
|
+
* from: "receipts@stripe.test",
|
|
129
|
+
* to: r.customer,
|
|
130
|
+
* subject: `Receipt for ${r.description}`,
|
|
131
|
+
* html: r.body,
|
|
132
|
+
* });
|
|
133
|
+
* },
|
|
134
|
+
* })
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* `kind` picks what the options are: `"email"` takes an
|
|
138
|
+
* {@link EmailAnnotation} (or a list of them, which renders a mailbox
|
|
139
|
+
* listing instead of one message). Every field is optional, and with no
|
|
140
|
+
* options at all they're read off the value itself — enough when it already
|
|
141
|
+
* carries `from`/`to`/`subject`/`html`/`text`. Throws if there is nothing to
|
|
142
|
+
* render, rather than recording an empty step.
|
|
143
|
+
*/
|
|
144
|
+
export declare function annotate<T, K extends AnnotationKind>(value: T, kind: K, options?: AnnotationOptions[K]): Annotated<T>;
|
|
145
|
+
/** Open an annotation box. Returns `undefined` for any ordinary value, so
|
|
146
|
+
* call sites can treat annotation as the exception it is. */
|
|
147
|
+
export declare function readAnnotation(value: unknown): {
|
|
148
|
+
annotation: RenderAnnotation;
|
|
149
|
+
value: unknown;
|
|
150
|
+
} | undefined;
|
|
151
|
+
export {};
|
package/dist/annotate.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Render annotations — how a fake tells the dashboard to draw a helper's
|
|
2
|
+
// return value as something richer than JSON.
|
|
3
|
+
//
|
|
4
|
+
// A fake helper call is recorded as a `fake` step and its return value is
|
|
5
|
+
// rendered as pretty JSON, which is the right default for arbitrary data.
|
|
6
|
+
// When the value *is* a domain object the timeline already draws well — an
|
|
7
|
+
// email, today — the fake says so: `return annotate(msg, "email", {…})`.
|
|
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.
|
|
32
|
+
import { deepUnwrap } from "./inspect.js";
|
|
33
|
+
import { truncateUtf8, } from "./recorder.js";
|
|
34
|
+
/**
|
|
35
|
+
* Runtime marker on the box `annotate(...)` returns. `Symbol.for` rather
|
|
36
|
+
* than a module-local symbol so a second copy of the SDK still recognises
|
|
37
|
+
* a box minted by the first — the duplicate-module-instance hazard that
|
|
38
|
+
* silently drops assertion events when the SDK lands in an app dir twice
|
|
39
|
+
* (see `sdk.rs`).
|
|
40
|
+
*/
|
|
41
|
+
const ANNOTATION = Symbol.for("spectest.render-annotation");
|
|
42
|
+
/** Listing rows carried on the event. The returned value is never capped. */
|
|
43
|
+
const LIST_CAP = 50;
|
|
44
|
+
/** How much of a body becomes a listing row's preview. */
|
|
45
|
+
const SNIPPET_CHARS = 200;
|
|
46
|
+
const NO_FIELDS = 'annotate(value, "email"): nothing to render — no email fields found on ' +
|
|
47
|
+
"the value. Pass them explicitly, e.g. " +
|
|
48
|
+
'annotate(value, "email", { to: value.recipient, subject: value.title, ' +
|
|
49
|
+
"html: value.body }).";
|
|
50
|
+
/**
|
|
51
|
+
* Say what a fake helper's return value *is*, so the timeline can draw it
|
|
52
|
+
* as that on top of the JSON it always shows. The call stays an ordinary
|
|
53
|
+
* fake step; the rendered view nests inside it. The value is returned to
|
|
54
|
+
* the test unchanged (and unchanged in type).
|
|
55
|
+
*
|
|
56
|
+
* ```ts
|
|
57
|
+
* helpers: ({ state }) => ({
|
|
58
|
+
* lastReceipt() {
|
|
59
|
+
* const r = state.receipts.at(-1);
|
|
60
|
+
* return r && annotate(r, "email", {
|
|
61
|
+
* from: "receipts@stripe.test",
|
|
62
|
+
* to: r.customer,
|
|
63
|
+
* subject: `Receipt for ${r.description}`,
|
|
64
|
+
* html: r.body,
|
|
65
|
+
* });
|
|
66
|
+
* },
|
|
67
|
+
* })
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* `kind` picks what the options are: `"email"` takes an
|
|
71
|
+
* {@link EmailAnnotation} (or a list of them, which renders a mailbox
|
|
72
|
+
* listing instead of one message). Every field is optional, and with no
|
|
73
|
+
* options at all they're read off the value itself — enough when it already
|
|
74
|
+
* carries `from`/`to`/`subject`/`html`/`text`. Throws if there is nothing to
|
|
75
|
+
* render, rather than recording an empty step.
|
|
76
|
+
*/
|
|
77
|
+
export function annotate(value, kind, options) {
|
|
78
|
+
return box(value, lower(kind, options, value));
|
|
79
|
+
}
|
|
80
|
+
/** Turn a kind + its options into the event fields to record. */
|
|
81
|
+
function lower(kind, options, value) {
|
|
82
|
+
switch (kind) {
|
|
83
|
+
case "email":
|
|
84
|
+
return lowerEmail(options, value);
|
|
85
|
+
case "chat":
|
|
86
|
+
return lowerChat(options, value);
|
|
87
|
+
default:
|
|
88
|
+
// Unreachable while `kind` is a key of AnnotationOptions; a kind added
|
|
89
|
+
// to that interface without a case here lands on this line.
|
|
90
|
+
throw new Error(`annotate(): unknown annotation kind ${String(kind)}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function lowerEmail(options, value) {
|
|
94
|
+
// `deepUnwrap` only where fields are *read* — a value off another
|
|
95
|
+
// instrumented call is a provenance carrier, and `String(carrier)` would
|
|
96
|
+
// otherwise be its coerced shape rather than the address it holds. The
|
|
97
|
+
// value handed back to the test is never touched.
|
|
98
|
+
const source = deepUnwrap(options ?? value);
|
|
99
|
+
if (Array.isArray(source)) {
|
|
100
|
+
const messages = source.slice(0, LIST_CAP).map((m) => summaryOf(fields(m)));
|
|
101
|
+
if (source.length > 0 && messages.every((m) => isEmpty(m))) {
|
|
102
|
+
throw new Error(NO_FIELDS);
|
|
103
|
+
}
|
|
104
|
+
return { kind: "email", messages, count: source.length };
|
|
105
|
+
}
|
|
106
|
+
const message = messageOf(fields(source));
|
|
107
|
+
if (isEmpty(message))
|
|
108
|
+
throw new Error(NO_FIELDS);
|
|
109
|
+
return { kind: "email", message };
|
|
110
|
+
}
|
|
111
|
+
/** Open an annotation box. Returns `undefined` for any ordinary value, so
|
|
112
|
+
* call sites can treat annotation as the exception it is. */
|
|
113
|
+
export function readAnnotation(value) {
|
|
114
|
+
if (typeof value !== "object" || value === null)
|
|
115
|
+
return undefined;
|
|
116
|
+
const annotation = value[ANNOTATION];
|
|
117
|
+
if (!annotation)
|
|
118
|
+
return undefined;
|
|
119
|
+
return { annotation, value: value.value };
|
|
120
|
+
}
|
|
121
|
+
function box(value, annotation) {
|
|
122
|
+
return { [ANNOTATION]: annotation, value };
|
|
123
|
+
}
|
|
124
|
+
function isEmpty(o) {
|
|
125
|
+
return Object.keys(o).length === 0;
|
|
126
|
+
}
|
|
127
|
+
/** The value as a field bag — anything that isn't an object contributes
|
|
128
|
+
* nothing, and falls through to the `NO_FIELDS` error. */
|
|
129
|
+
function fields(v) {
|
|
130
|
+
return typeof v === "object" && v !== null
|
|
131
|
+
? v
|
|
132
|
+
: {};
|
|
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
|
+
}
|
|
178
|
+
function messageOf(src) {
|
|
179
|
+
const msg = {};
|
|
180
|
+
const from = text(src.from);
|
|
181
|
+
if (from)
|
|
182
|
+
msg.from = from;
|
|
183
|
+
for (const key of ["to", "cc", "bcc"]) {
|
|
184
|
+
const addrs = addresses(src[key]);
|
|
185
|
+
if (addrs.length > 0)
|
|
186
|
+
msg[key] = addrs;
|
|
187
|
+
}
|
|
188
|
+
const subject = text(src.subject);
|
|
189
|
+
if (subject)
|
|
190
|
+
msg.subject = subject;
|
|
191
|
+
const html = text(src.html);
|
|
192
|
+
if (html) {
|
|
193
|
+
const t = truncateUtf8(html);
|
|
194
|
+
msg.html = t.value;
|
|
195
|
+
if (t.truncated)
|
|
196
|
+
msg.htmlTruncated = true;
|
|
197
|
+
}
|
|
198
|
+
const body = text(src.text);
|
|
199
|
+
if (body) {
|
|
200
|
+
const t = truncateUtf8(body);
|
|
201
|
+
msg.text = t.value;
|
|
202
|
+
if (t.truncated)
|
|
203
|
+
msg.textTruncated = true;
|
|
204
|
+
}
|
|
205
|
+
return msg;
|
|
206
|
+
}
|
|
207
|
+
/** A listing row is a whole message plus its preview line — the dashboard
|
|
208
|
+
* expands the row you click into the full view, so the body has to ride
|
|
209
|
+
* along rather than being summarised away. */
|
|
210
|
+
function summaryOf(src) {
|
|
211
|
+
const row = messageOf(src);
|
|
212
|
+
const snippet = preview(text(src.text), text(src.html));
|
|
213
|
+
if (snippet)
|
|
214
|
+
row.snippet = snippet;
|
|
215
|
+
return row;
|
|
216
|
+
}
|
|
217
|
+
/** A listing row's preview: the plain-text body if there is one, else the
|
|
218
|
+
* HTML with its tags stripped — enough to tell two messages apart. */
|
|
219
|
+
function preview(body, html) {
|
|
220
|
+
const raw = body || html.replace(/<[^>]*>/g, " ");
|
|
221
|
+
const collapsed = raw.replace(/\s+/g, " ").trim();
|
|
222
|
+
return collapsed.length > SNIPPET_CHARS
|
|
223
|
+
? `${collapsed.slice(0, SNIPPET_CHARS)}…`
|
|
224
|
+
: collapsed;
|
|
225
|
+
}
|
|
226
|
+
/** Scalars render as themselves; anything else (an object, a nested array)
|
|
227
|
+
* is not an address or a subject line and is dropped rather than shown as
|
|
228
|
+
* `[object Object]`. */
|
|
229
|
+
function text(v) {
|
|
230
|
+
if (typeof v === "string")
|
|
231
|
+
return v;
|
|
232
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
233
|
+
return String(v);
|
|
234
|
+
return "";
|
|
235
|
+
}
|
|
236
|
+
function addresses(v) {
|
|
237
|
+
if (typeof v === "string")
|
|
238
|
+
return v ? [v] : [];
|
|
239
|
+
if (!Array.isArray(v))
|
|
240
|
+
return [];
|
|
241
|
+
return v.map((a) => text(a)).filter((a) => a.length > 0);
|
|
242
|
+
}
|
package/dist/daemon.js
CHANGED
|
@@ -44,7 +44,8 @@ import { runContainerArgs } from "./harness/container-run.js";
|
|
|
44
44
|
import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
|
|
45
45
|
import { conflict, notFound, requireString, } from "./harness/methods.js";
|
|
46
46
|
import { openTerminal } from "./terminal.js";
|
|
47
|
-
import {
|
|
47
|
+
import { readAnnotation } from "./annotate.js";
|
|
48
|
+
import { pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
|
|
48
49
|
import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
|
|
49
50
|
import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
|
|
50
51
|
import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
|
|
@@ -828,13 +829,126 @@ async function prepareServiceImage(svc, opts) {
|
|
|
828
829
|
}
|
|
829
830
|
return buildServiceImage(svc.name, image, tag);
|
|
830
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
|
+
*/
|
|
831
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) {
|
|
832
943
|
let buildSteps;
|
|
833
944
|
{
|
|
945
|
+
const content = caSuffix
|
|
946
|
+
? `${image.content.replace(/\n*$/, "\n")}${caSuffix}`
|
|
947
|
+
: image.content;
|
|
834
948
|
const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
|
|
835
949
|
await fs.mkdir(dfDir, { recursive: true });
|
|
836
950
|
const dfPath = path.join(dfDir, "Dockerfile");
|
|
837
|
-
await fs.writeFile(dfPath,
|
|
951
|
+
await fs.writeFile(dfPath, content);
|
|
838
952
|
// Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
|
|
839
953
|
// (next to the Dockerfile) in preference to the context root's
|
|
840
954
|
// `.dockerignore`, so this build sees the defaults, the project's own
|
|
@@ -896,9 +1010,11 @@ async function buildServiceImage(name, image, tag) {
|
|
|
896
1010
|
});
|
|
897
1011
|
}
|
|
898
1012
|
});
|
|
1013
|
+
const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
|
|
899
1014
|
if (build.code !== 0) {
|
|
900
|
-
|
|
901
|
-
|
|
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 };
|
|
902
1018
|
}
|
|
903
1019
|
if (useBuildKit) {
|
|
904
1020
|
// Keep only the slowest dozen steps ≥1s — enough to profile, small
|
|
@@ -907,14 +1023,8 @@ async function buildServiceImage(name, image, tag) {
|
|
|
907
1023
|
.filter((s) => s.secs >= 1)
|
|
908
1024
|
.slice(0, 12);
|
|
909
1025
|
}
|
|
1026
|
+
return { ok: true, folded: caSuffix !== null, log, buildSteps };
|
|
910
1027
|
}
|
|
911
|
-
// Layer the spectest CA into the image's system trust store so apps
|
|
912
|
-
// that read the system bundle (Go, Java, CLIs that don't honour the
|
|
913
|
-
// SSL_CERT_FILE env vars) accept HTTPS to fakes. Best-effort: images
|
|
914
|
-
// without `update-ca-certificates` / `update-ca-trust` (distroless,
|
|
915
|
-
// scratch) fall through to the env-var path that `runContainer` sets.
|
|
916
|
-
await ensureCaTrustedImage(name, tag);
|
|
917
|
-
return { tag, buildSteps };
|
|
918
1028
|
}
|
|
919
1029
|
/**
|
|
920
1030
|
* Build a derivative image on top of `tag` that copies the spectest
|
|
@@ -2262,14 +2372,27 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
|
|
|
2262
2372
|
const args = callArgs.map((a) => deepUnwrap(a));
|
|
2263
2373
|
const safeArgs = args.map((a) => safeSerialize(a));
|
|
2264
2374
|
const recordResult = (value) => {
|
|
2375
|
+
// A helper may box its return in a render annotation
|
|
2376
|
+
// (`annotate(v, "email", …)`). The box never reaches the test, and it
|
|
2377
|
+
// never replaces the step either: the call is recorded as the ordinary
|
|
2378
|
+
// `fake` event it is, and the annotation rides *under* it as a child
|
|
2379
|
+
// event (`parentSeq`), which the dashboard folds into the fake step's
|
|
2380
|
+
// detail panel. So the timeline reads the same as any other helper
|
|
2381
|
+
// call, with the richer view one click in.
|
|
2382
|
+
const annotated = readAnnotation(value);
|
|
2383
|
+
const raw = annotated ? annotated.value : value;
|
|
2384
|
+
const durationMs = Date.now() - t;
|
|
2265
2385
|
const seq = recordFake({
|
|
2266
2386
|
fake: fakeName,
|
|
2267
2387
|
member,
|
|
2268
2388
|
args: safeArgs,
|
|
2269
|
-
result: safeSerialize(
|
|
2270
|
-
durationMs
|
|
2389
|
+
result: safeSerialize(raw),
|
|
2390
|
+
durationMs,
|
|
2271
2391
|
}, resv);
|
|
2272
|
-
|
|
2392
|
+
if (annotated && seq !== undefined) {
|
|
2393
|
+
recordAnnotationChild(fakeName, member, annotated.annotation, durationMs, seq);
|
|
2394
|
+
}
|
|
2395
|
+
return wrap(raw, seq);
|
|
2273
2396
|
};
|
|
2274
2397
|
const recordError = (err) => {
|
|
2275
2398
|
recordFake({
|
|
@@ -2280,22 +2403,83 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
|
|
|
2280
2403
|
error: errMessage(err),
|
|
2281
2404
|
}, resv);
|
|
2282
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();
|
|
2283
2419
|
let result;
|
|
2284
2420
|
try {
|
|
2285
2421
|
result = fn.apply(thisArg, args);
|
|
2286
2422
|
}
|
|
2287
2423
|
catch (err) {
|
|
2424
|
+
resumeRecording();
|
|
2288
2425
|
recordError(err);
|
|
2289
2426
|
throw err;
|
|
2290
2427
|
}
|
|
2291
2428
|
if (result instanceof Promise) {
|
|
2292
|
-
return result.then(
|
|
2429
|
+
return result.then((value) => {
|
|
2430
|
+
resumeRecording();
|
|
2431
|
+
return recordResult(value);
|
|
2432
|
+
}, (err) => {
|
|
2433
|
+
resumeRecording();
|
|
2293
2434
|
recordError(err);
|
|
2294
2435
|
throw err;
|
|
2295
2436
|
});
|
|
2296
2437
|
}
|
|
2438
|
+
resumeRecording();
|
|
2297
2439
|
return recordResult(result);
|
|
2298
2440
|
}
|
|
2441
|
+
/** Record a fake-helper call's render annotation as a child of the call's
|
|
2442
|
+
* own `fake` event — the same `parentSeq` grouping `ctx.poll` uses for the
|
|
2443
|
+
* iteration it kept, so the annotated view folds into the fake step's
|
|
2444
|
+
* detail panel instead of taking a timeline row of its own.
|
|
2445
|
+
*
|
|
2446
|
+
* The child is the event kind that already knows how to draw this: an
|
|
2447
|
+
* `email` annotation records the very event the built-in `email()`
|
|
2448
|
+
* component's mailbox helpers record, so it renders with no new code. It
|
|
2449
|
+
* carries the fake's name and member as its service/op, and the parent's
|
|
2450
|
+
* duration, since it describes that same call.
|
|
2451
|
+
*
|
|
2452
|
+
* Only the success path is annotated: a helper that threw returned no value
|
|
2453
|
+
* to annotate, so it's a plain `fake` error event. */
|
|
2454
|
+
function recordAnnotationChild(fakeName, member, annotation, durationMs, parentSeq) {
|
|
2455
|
+
switch (annotation.kind) {
|
|
2456
|
+
case "email":
|
|
2457
|
+
recordEmail({
|
|
2458
|
+
parentSeq,
|
|
2459
|
+
annotation: true,
|
|
2460
|
+
service: fakeName,
|
|
2461
|
+
op: member,
|
|
2462
|
+
message: annotation.message,
|
|
2463
|
+
messages: annotation.messages,
|
|
2464
|
+
count: annotation.count,
|
|
2465
|
+
durationMs,
|
|
2466
|
+
});
|
|
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;
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2299
2483
|
function errMessage(err) {
|
|
2300
2484
|
return err?.message ?? String(err);
|
|
2301
2485
|
}
|