@specific.dev/spectest 0.47.0 → 0.49.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 +101 -0
- package/dist/annotate.js +196 -0
- package/dist/browser.d.ts +1 -0
- package/dist/daemon.js +129 -41
- package/dist/index.d.ts +20 -2
- package/dist/index.js +7 -0
- package/dist/locator.d.ts +32 -0
- package/dist/locator.js +28 -0
- package/dist/project-files.d.ts +18 -0
- package/dist/project-files.js +97 -0
- package/dist/recorder.d.ts +28 -9
- package/package.json +1 -1
- package/src/annotate.ts +292 -0
- package/src/browser.ts +1 -0
- package/src/daemon.ts +159 -48
- package/src/index.ts +35 -2
- package/src/locator.ts +70 -0
- package/src/project-files.ts +103 -0
- package/src/recorder.ts +28 -9
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type EmailEventMessage, type EmailEventSummary } 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
|
+
}
|
|
29
|
+
/** The kinds {@link annotate} accepts. */
|
|
30
|
+
export type AnnotationKind = keyof AnnotationOptions;
|
|
31
|
+
/**
|
|
32
|
+
* What an annotation lowers to: the fields of the child event the daemon
|
|
33
|
+
* records under the call's own `fake` one. Tagged by that event's `kind` —
|
|
34
|
+
* which needn't be the annotation kind's name, since an annotation names
|
|
35
|
+
* what the value *is* and the event names how it's drawn.
|
|
36
|
+
*/
|
|
37
|
+
export type RenderAnnotation = {
|
|
38
|
+
kind: "email";
|
|
39
|
+
/** Single-message ops. */
|
|
40
|
+
message?: EmailEventMessage;
|
|
41
|
+
/** Listing ops. */
|
|
42
|
+
messages?: EmailEventSummary[];
|
|
43
|
+
/** Total matches (a listing is capped on the event, never in the value). */
|
|
44
|
+
count?: number;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The email fields to render. Every one is optional — pass what the fake
|
|
48
|
+
* has. Addresses take a single string or a list.
|
|
49
|
+
*
|
|
50
|
+
* The event can also carry a date, per-row previews and attachment
|
|
51
|
+
* metadata, which the built-in `email()` component fills from a really
|
|
52
|
+
* captured message; a fake's mail was never sent, so those are deliberately
|
|
53
|
+
* not offered here. A listing row's preview is derived from the body.
|
|
54
|
+
*/
|
|
55
|
+
export interface EmailAnnotation {
|
|
56
|
+
from?: string;
|
|
57
|
+
to?: string | readonly string[];
|
|
58
|
+
cc?: string | readonly string[];
|
|
59
|
+
bcc?: string | readonly string[];
|
|
60
|
+
subject?: string;
|
|
61
|
+
/** HTML body. Rendered in a fully sandboxed iframe (scripts off). */
|
|
62
|
+
html?: string;
|
|
63
|
+
/** Plain-text body. Shown on its own when there's no HTML, folded into a
|
|
64
|
+
* "Plain-text version" disclosure when there is. */
|
|
65
|
+
text?: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Say what a fake helper's return value *is*, so the timeline can draw it
|
|
69
|
+
* as that on top of the JSON it always shows. The call stays an ordinary
|
|
70
|
+
* fake step; the rendered view nests inside it. The value is returned to
|
|
71
|
+
* the test unchanged (and unchanged in type).
|
|
72
|
+
*
|
|
73
|
+
* ```ts
|
|
74
|
+
* helpers: ({ state }) => ({
|
|
75
|
+
* lastReceipt() {
|
|
76
|
+
* const r = state.receipts.at(-1);
|
|
77
|
+
* return r && annotate(r, "email", {
|
|
78
|
+
* from: "receipts@stripe.test",
|
|
79
|
+
* to: r.customer,
|
|
80
|
+
* subject: `Receipt for ${r.description}`,
|
|
81
|
+
* html: r.body,
|
|
82
|
+
* });
|
|
83
|
+
* },
|
|
84
|
+
* })
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* `kind` picks what the options are: `"email"` takes an
|
|
88
|
+
* {@link EmailAnnotation} (or a list of them, which renders a mailbox
|
|
89
|
+
* listing instead of one message). Every field is optional, and with no
|
|
90
|
+
* options at all they're read off the value itself — enough when it already
|
|
91
|
+
* carries `from`/`to`/`subject`/`html`/`text`. Throws if there is nothing to
|
|
92
|
+
* render, rather than recording an empty step.
|
|
93
|
+
*/
|
|
94
|
+
export declare function annotate<T, K extends AnnotationKind>(value: T, kind: K, options?: AnnotationOptions[K]): Annotated<T>;
|
|
95
|
+
/** Open an annotation box. Returns `undefined` for any ordinary value, so
|
|
96
|
+
* call sites can treat annotation as the exception it is. */
|
|
97
|
+
export declare function readAnnotation(value: unknown): {
|
|
98
|
+
annotation: RenderAnnotation;
|
|
99
|
+
value: unknown;
|
|
100
|
+
} | undefined;
|
|
101
|
+
export {};
|
package/dist/annotate.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
default:
|
|
86
|
+
// Unreachable while `kind` is a key of AnnotationOptions; a kind added
|
|
87
|
+
// to that interface without a case here lands on this line.
|
|
88
|
+
throw new Error(`annotate(): unknown annotation kind ${String(kind)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function lowerEmail(options, value) {
|
|
92
|
+
// `deepUnwrap` only where fields are *read* — a value off another
|
|
93
|
+
// instrumented call is a provenance carrier, and `String(carrier)` would
|
|
94
|
+
// otherwise be its coerced shape rather than the address it holds. The
|
|
95
|
+
// value handed back to the test is never touched.
|
|
96
|
+
const source = deepUnwrap(options ?? value);
|
|
97
|
+
if (Array.isArray(source)) {
|
|
98
|
+
const messages = source.slice(0, LIST_CAP).map((m) => summaryOf(fields(m)));
|
|
99
|
+
if (source.length > 0 && messages.every((m) => isEmpty(m))) {
|
|
100
|
+
throw new Error(NO_FIELDS);
|
|
101
|
+
}
|
|
102
|
+
return { kind: "email", messages, count: source.length };
|
|
103
|
+
}
|
|
104
|
+
const message = messageOf(fields(source));
|
|
105
|
+
if (isEmpty(message))
|
|
106
|
+
throw new Error(NO_FIELDS);
|
|
107
|
+
return { kind: "email", message };
|
|
108
|
+
}
|
|
109
|
+
/** Open an annotation box. Returns `undefined` for any ordinary value, so
|
|
110
|
+
* call sites can treat annotation as the exception it is. */
|
|
111
|
+
export function readAnnotation(value) {
|
|
112
|
+
if (typeof value !== "object" || value === null)
|
|
113
|
+
return undefined;
|
|
114
|
+
const annotation = value[ANNOTATION];
|
|
115
|
+
if (!annotation)
|
|
116
|
+
return undefined;
|
|
117
|
+
return { annotation, value: value.value };
|
|
118
|
+
}
|
|
119
|
+
function box(value, annotation) {
|
|
120
|
+
return { [ANNOTATION]: annotation, value };
|
|
121
|
+
}
|
|
122
|
+
function isEmpty(o) {
|
|
123
|
+
return Object.keys(o).length === 0;
|
|
124
|
+
}
|
|
125
|
+
/** The value as a field bag — anything that isn't an object contributes
|
|
126
|
+
* nothing, and falls through to the `NO_FIELDS` error. */
|
|
127
|
+
function fields(v) {
|
|
128
|
+
return typeof v === "object" && v !== null
|
|
129
|
+
? v
|
|
130
|
+
: {};
|
|
131
|
+
}
|
|
132
|
+
function messageOf(src) {
|
|
133
|
+
const msg = {};
|
|
134
|
+
const from = text(src.from);
|
|
135
|
+
if (from)
|
|
136
|
+
msg.from = from;
|
|
137
|
+
for (const key of ["to", "cc", "bcc"]) {
|
|
138
|
+
const addrs = addresses(src[key]);
|
|
139
|
+
if (addrs.length > 0)
|
|
140
|
+
msg[key] = addrs;
|
|
141
|
+
}
|
|
142
|
+
const subject = text(src.subject);
|
|
143
|
+
if (subject)
|
|
144
|
+
msg.subject = subject;
|
|
145
|
+
const html = text(src.html);
|
|
146
|
+
if (html) {
|
|
147
|
+
const t = truncateUtf8(html);
|
|
148
|
+
msg.html = t.value;
|
|
149
|
+
if (t.truncated)
|
|
150
|
+
msg.htmlTruncated = true;
|
|
151
|
+
}
|
|
152
|
+
const body = text(src.text);
|
|
153
|
+
if (body) {
|
|
154
|
+
const t = truncateUtf8(body);
|
|
155
|
+
msg.text = t.value;
|
|
156
|
+
if (t.truncated)
|
|
157
|
+
msg.textTruncated = true;
|
|
158
|
+
}
|
|
159
|
+
return msg;
|
|
160
|
+
}
|
|
161
|
+
/** A listing row is a whole message plus its preview line — the dashboard
|
|
162
|
+
* expands the row you click into the full view, so the body has to ride
|
|
163
|
+
* along rather than being summarised away. */
|
|
164
|
+
function summaryOf(src) {
|
|
165
|
+
const row = messageOf(src);
|
|
166
|
+
const snippet = preview(text(src.text), text(src.html));
|
|
167
|
+
if (snippet)
|
|
168
|
+
row.snippet = snippet;
|
|
169
|
+
return row;
|
|
170
|
+
}
|
|
171
|
+
/** A listing row's preview: the plain-text body if there is one, else the
|
|
172
|
+
* HTML with its tags stripped — enough to tell two messages apart. */
|
|
173
|
+
function preview(body, html) {
|
|
174
|
+
const raw = body || html.replace(/<[^>]*>/g, " ");
|
|
175
|
+
const collapsed = raw.replace(/\s+/g, " ").trim();
|
|
176
|
+
return collapsed.length > SNIPPET_CHARS
|
|
177
|
+
? `${collapsed.slice(0, SNIPPET_CHARS)}…`
|
|
178
|
+
: collapsed;
|
|
179
|
+
}
|
|
180
|
+
/** Scalars render as themselves; anything else (an object, a nested array)
|
|
181
|
+
* is not an address or a subject line and is dropped rather than shown as
|
|
182
|
+
* `[object Object]`. */
|
|
183
|
+
function text(v) {
|
|
184
|
+
if (typeof v === "string")
|
|
185
|
+
return v;
|
|
186
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
187
|
+
return String(v);
|
|
188
|
+
return "";
|
|
189
|
+
}
|
|
190
|
+
function addresses(v) {
|
|
191
|
+
if (typeof v === "string")
|
|
192
|
+
return v ? [v] : [];
|
|
193
|
+
if (!Array.isArray(v))
|
|
194
|
+
return [];
|
|
195
|
+
return v.map((a) => text(a)).filter((a) => a.length > 0);
|
|
196
|
+
}
|
package/dist/browser.d.ts
CHANGED
package/dist/daemon.js
CHANGED
|
@@ -34,6 +34,7 @@ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
|
34
34
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
35
35
|
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
36
36
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
37
|
+
import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
|
|
37
38
|
import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
|
|
38
39
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
39
40
|
import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
|
|
@@ -43,7 +44,8 @@ import { runContainerArgs } from "./harness/container-run.js";
|
|
|
43
44
|
import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
|
|
44
45
|
import { conflict, notFound, requireString, } from "./harness/methods.js";
|
|
45
46
|
import { openTerminal } from "./terminal.js";
|
|
46
|
-
import {
|
|
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";
|
|
47
49
|
import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
|
|
48
50
|
import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
|
|
49
51
|
import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
|
|
@@ -52,7 +54,6 @@ function namedServices(cfg) {
|
|
|
52
54
|
}
|
|
53
55
|
const DEFAULT_TEST_TIMEOUT_MS = 60_000;
|
|
54
56
|
const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
|
|
55
|
-
const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
56
57
|
// Stable hostname every service container resolves to the host (the
|
|
57
58
|
// `spectest-br0` gateway) — so apps that build or pull images at runtime
|
|
58
59
|
// can point a builder at `spectest-host:5000` (the zot Docker Hub mirror)
|
|
@@ -77,7 +78,9 @@ function hostCacheGateway() {
|
|
|
77
78
|
}
|
|
78
79
|
return _hostCacheGateway;
|
|
79
80
|
}
|
|
80
|
-
|
|
81
|
+
// WORKSPACE (/workspace) and APP_DIR (/opt/spectest/app) both live in
|
|
82
|
+
// project-files.ts, next to the rule that decides which copy of a project
|
|
83
|
+
// file is the current one.
|
|
81
84
|
// The bun the base snapshot installs (base.rs::BASE_SETUP_SH). The daemon
|
|
82
85
|
// runs under it, and eval's dependency install shells out to it.
|
|
83
86
|
const BUN_BIN = "/usr/local/bin/bun";
|
|
@@ -1028,7 +1031,7 @@ async function probeTcp(host, port) {
|
|
|
1028
1031
|
return new Promise((resolve) => {
|
|
1029
1032
|
const sock = net.createConnection({ host, port });
|
|
1030
1033
|
let settled = false;
|
|
1031
|
-
const finish = (
|
|
1034
|
+
const finish = (ok, detail) => {
|
|
1032
1035
|
if (settled)
|
|
1033
1036
|
return;
|
|
1034
1037
|
settled = true;
|
|
@@ -1038,26 +1041,30 @@ async function probeTcp(host, port) {
|
|
|
1038
1041
|
catch {
|
|
1039
1042
|
/* ignore */
|
|
1040
1043
|
}
|
|
1041
|
-
resolve(
|
|
1044
|
+
resolve({ ok, detail });
|
|
1042
1045
|
};
|
|
1043
1046
|
sock.setTimeout(2000);
|
|
1044
|
-
sock.once("connect", () => finish(true));
|
|
1045
|
-
sock.once("error", () => finish(false));
|
|
1046
|
-
sock.once("timeout", () => finish(false));
|
|
1047
|
+
sock.once("connect", () => finish(true, `connected to ${host}:${port}`));
|
|
1048
|
+
sock.once("error", (err) => finish(false, `connect to ${host}:${port} failed: ${err.message}`));
|
|
1049
|
+
sock.once("timeout", () => finish(false, `connect to ${host}:${port} got no reply within 2000ms`));
|
|
1047
1050
|
});
|
|
1048
1051
|
}
|
|
1049
1052
|
async function probeHttp(host, port, urlPath, headers, expectStatus) {
|
|
1053
|
+
const url = `http://${host}:${port}${urlPath}`;
|
|
1050
1054
|
const ctrl = new AbortController();
|
|
1051
1055
|
const to = setTimeout(() => ctrl.abort(), 5000);
|
|
1052
1056
|
try {
|
|
1053
|
-
const res = await fetch(
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
});
|
|
1057
|
-
return expectStatus !== undefined ? res.status === expectStatus : res.ok;
|
|
1057
|
+
const res = await fetch(url, { signal: ctrl.signal, headers });
|
|
1058
|
+
const ok = expectStatus !== undefined ? res.status === expectStatus : res.ok;
|
|
1059
|
+
const want = expectStatus !== undefined ? String(expectStatus) : "2xx";
|
|
1060
|
+
return { ok, detail: `GET ${url} → ${res.status} (wanted ${want})` };
|
|
1058
1061
|
}
|
|
1059
|
-
catch {
|
|
1060
|
-
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
const e = err;
|
|
1064
|
+
const detail = e?.name === "AbortError"
|
|
1065
|
+
? `GET ${url} got no reply within 5000ms`
|
|
1066
|
+
: `GET ${url} failed: ${e?.message ?? String(err)}`;
|
|
1067
|
+
return { ok: false, detail };
|
|
1061
1068
|
}
|
|
1062
1069
|
finally {
|
|
1063
1070
|
clearTimeout(to);
|
|
@@ -1065,28 +1072,64 @@ async function probeHttp(host, port, urlPath, headers, expectStatus) {
|
|
|
1065
1072
|
}
|
|
1066
1073
|
async function probeExec(name, command) {
|
|
1067
1074
|
const r = await docker(["exec", name, "sh", "-c", command], 10_000);
|
|
1068
|
-
|
|
1075
|
+
if (r.code === 0)
|
|
1076
|
+
return { ok: true, detail: `\`${command}\` exited 0` };
|
|
1077
|
+
// 124 is shx's own kill (see `shx`): the exec never answered, which says
|
|
1078
|
+
// the docker daemon is wedged or starved rather than anything about the
|
|
1079
|
+
// command's verdict.
|
|
1080
|
+
const why = r.code === 124
|
|
1081
|
+
? `was killed after 10000ms with no reply`
|
|
1082
|
+
: `exited ${r.code}`;
|
|
1083
|
+
const output = firstLine(r.stderr) || firstLine(r.stdout);
|
|
1084
|
+
return {
|
|
1085
|
+
ok: false,
|
|
1086
|
+
detail: `\`${command}\` ${why}${output ? `: ${output}` : ""}`,
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
/** First non-empty line, trimmed and capped — probe output goes into an
|
|
1090
|
+
* error message, not a log file. */
|
|
1091
|
+
function firstLine(s) {
|
|
1092
|
+
const line = s.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
1093
|
+
return line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
|
1069
1094
|
}
|
|
1070
1095
|
async function waitForReady(svc) {
|
|
1071
1096
|
const check = svc.readyCheck;
|
|
1072
1097
|
if (!check)
|
|
1073
1098
|
return;
|
|
1074
1099
|
const timeoutSecs = check.timeoutSecs ?? 60;
|
|
1100
|
+
let last;
|
|
1075
1101
|
const probe = async () => {
|
|
1076
1102
|
if (check.type === "tcp")
|
|
1077
|
-
|
|
1078
|
-
if (check.type === "http") {
|
|
1079
|
-
|
|
1103
|
+
last = await probeTcp(svc.name, check.port);
|
|
1104
|
+
else if (check.type === "http") {
|
|
1105
|
+
last = await probeHttp(svc.name, check.port, check.path ?? "/", check.headers, check.expectStatus);
|
|
1080
1106
|
}
|
|
1081
|
-
|
|
1107
|
+
else
|
|
1108
|
+
last = await probeExec(svc.name, check.command);
|
|
1109
|
+
return last.ok;
|
|
1082
1110
|
};
|
|
1083
1111
|
// Scheduling (the ramp, and not sleeping past the deadline) lives in
|
|
1084
1112
|
// `harness/ready-poll.ts`; this supplies the probe and the diagnosis.
|
|
1085
|
-
const { ready } = await pollUntilReady(probe, {
|
|
1113
|
+
const { ready, attempts, elapsedMs } = await pollUntilReady(probe, {
|
|
1114
|
+
kind: check.type,
|
|
1115
|
+
timeoutSecs,
|
|
1116
|
+
});
|
|
1086
1117
|
if (ready)
|
|
1087
1118
|
return;
|
|
1119
|
+
// The attempt count is half the diagnosis: a probe with a 10s timeout of
|
|
1120
|
+
// its own can only run a handful of times in 60s, so "6 attempts" says the
|
|
1121
|
+
// probes were hanging where "80 attempts" says they ran and kept saying no.
|
|
1122
|
+
let msg = `service ${svc.name} not ready within ${timeoutSecs}s ` +
|
|
1123
|
+
`(${attempts} ${check.type} probe${attempts === 1 ? "" : "s"} over ${Math.round(elapsedMs / 100) / 10}s).`;
|
|
1124
|
+
if (last)
|
|
1125
|
+
msg += `\nLast probe: ${last.detail}`;
|
|
1088
1126
|
const logs = await docker(["logs", "--tail=200", svc.name], 30_000);
|
|
1089
|
-
|
|
1127
|
+
const output = `${logs.stdout}\n${logs.stderr}`.trim();
|
|
1128
|
+
// A service that logs nothing (`sleep infinity`, a quiet daemon) used to
|
|
1129
|
+
// get an empty "Recent container logs:" heading, which reads as if the
|
|
1130
|
+
// logs were the evidence and there simply weren't any.
|
|
1131
|
+
msg += output ? `\nRecent container logs:\n${output}` : `\n(the container logged nothing)`;
|
|
1132
|
+
throw new Error(msg);
|
|
1090
1133
|
}
|
|
1091
1134
|
/** Validate the `dependsOn` graph and return the name→service map used to
|
|
1092
1135
|
* walk it. Rules live in `harness/service-graph.ts`. */
|
|
@@ -2220,14 +2263,27 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
|
|
|
2220
2263
|
const args = callArgs.map((a) => deepUnwrap(a));
|
|
2221
2264
|
const safeArgs = args.map((a) => safeSerialize(a));
|
|
2222
2265
|
const recordResult = (value) => {
|
|
2266
|
+
// A helper may box its return in a render annotation
|
|
2267
|
+
// (`annotate(v, "email", …)`). The box never reaches the test, and it
|
|
2268
|
+
// never replaces the step either: the call is recorded as the ordinary
|
|
2269
|
+
// `fake` event it is, and the annotation rides *under* it as a child
|
|
2270
|
+
// event (`parentSeq`), which the dashboard folds into the fake step's
|
|
2271
|
+
// detail panel. So the timeline reads the same as any other helper
|
|
2272
|
+
// call, with the richer view one click in.
|
|
2273
|
+
const annotated = readAnnotation(value);
|
|
2274
|
+
const raw = annotated ? annotated.value : value;
|
|
2275
|
+
const durationMs = Date.now() - t;
|
|
2223
2276
|
const seq = recordFake({
|
|
2224
2277
|
fake: fakeName,
|
|
2225
2278
|
member,
|
|
2226
2279
|
args: safeArgs,
|
|
2227
|
-
result: safeSerialize(
|
|
2228
|
-
durationMs
|
|
2280
|
+
result: safeSerialize(raw),
|
|
2281
|
+
durationMs,
|
|
2229
2282
|
}, resv);
|
|
2230
|
-
|
|
2283
|
+
if (annotated && seq !== undefined) {
|
|
2284
|
+
recordAnnotationChild(fakeName, member, annotated.annotation, durationMs, seq);
|
|
2285
|
+
}
|
|
2286
|
+
return wrap(raw, seq);
|
|
2231
2287
|
};
|
|
2232
2288
|
const recordError = (err) => {
|
|
2233
2289
|
recordFake({
|
|
@@ -2254,6 +2310,35 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
|
|
|
2254
2310
|
}
|
|
2255
2311
|
return recordResult(result);
|
|
2256
2312
|
}
|
|
2313
|
+
/** Record a fake-helper call's render annotation as a child of the call's
|
|
2314
|
+
* own `fake` event — the same `parentSeq` grouping `ctx.poll` uses for the
|
|
2315
|
+
* iteration it kept, so the annotated view folds into the fake step's
|
|
2316
|
+
* detail panel instead of taking a timeline row of its own.
|
|
2317
|
+
*
|
|
2318
|
+
* The child is the event kind that already knows how to draw this: an
|
|
2319
|
+
* `email` annotation records the very event the built-in `email()`
|
|
2320
|
+
* component's mailbox helpers record, so it renders with no new code. It
|
|
2321
|
+
* carries the fake's name and member as its service/op, and the parent's
|
|
2322
|
+
* duration, since it describes that same call.
|
|
2323
|
+
*
|
|
2324
|
+
* Only the success path is annotated: a helper that threw returned no value
|
|
2325
|
+
* to annotate, so it's a plain `fake` error event. */
|
|
2326
|
+
function recordAnnotationChild(fakeName, member, annotation, durationMs, parentSeq) {
|
|
2327
|
+
switch (annotation.kind) {
|
|
2328
|
+
case "email":
|
|
2329
|
+
recordEmail({
|
|
2330
|
+
parentSeq,
|
|
2331
|
+
annotation: true,
|
|
2332
|
+
service: fakeName,
|
|
2333
|
+
op: member,
|
|
2334
|
+
message: annotation.message,
|
|
2335
|
+
messages: annotation.messages,
|
|
2336
|
+
count: annotation.count,
|
|
2337
|
+
durationMs,
|
|
2338
|
+
});
|
|
2339
|
+
break;
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2257
2342
|
function errMessage(err) {
|
|
2258
2343
|
return err?.message ?? String(err);
|
|
2259
2344
|
}
|
|
@@ -2926,7 +3011,8 @@ async function spectestContext(scope = {}) {
|
|
|
2926
3011
|
const execTimeoutMs = scope.service ? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS : undefined;
|
|
2927
3012
|
return {
|
|
2928
3013
|
projectRoot: WORKSPACE,
|
|
2929
|
-
readProjectFile: (p) => fs.readFile(
|
|
3014
|
+
readProjectFile: (p) => fs.readFile(resolveProjectPath(p), "utf8"),
|
|
3015
|
+
readProjectFileBytes: async (p) => new Uint8Array(await fs.readFile(resolveProjectPath(p))),
|
|
2930
3016
|
exec: (service, command, opts) => componentExec(service, command, opts, execTimeoutMs),
|
|
2931
3017
|
// Read `globalThis.fetch` at call time: the wrapper is installed for
|
|
2932
3018
|
// the duration of a test / eval / project setup, so a context built
|
|
@@ -3342,22 +3428,31 @@ async function pollCall(description, fn, opts) {
|
|
|
3342
3428
|
let value;
|
|
3343
3429
|
let success = false;
|
|
3344
3430
|
let predicateError;
|
|
3345
|
-
// Record all iterations normally
|
|
3346
|
-
//
|
|
3347
|
-
//
|
|
3348
|
-
//
|
|
3431
|
+
// Record all iterations normally, but keep only the newest one: a new
|
|
3432
|
+
// attempt drops the events the previous attempt emitted, so the timeline
|
|
3433
|
+
// never fills with polling noise. Whichever attempt is last when the loop
|
|
3434
|
+
// ends — the winning one, or the final failed one — stays, and gets marked
|
|
3435
|
+
// as a child of the wait event so the UI can render it nested.
|
|
3436
|
+
//
|
|
3437
|
+
// The failed attempt is kept deliberately. A poll that times out reports
|
|
3438
|
+
// only "timed out after 120000ms (60 attempts)", which says nothing about
|
|
3439
|
+
// WHY: an ingress answering an instant 404 for two minutes and a backend
|
|
3440
|
+
// that never returns look identical in that message. The last attempt's
|
|
3441
|
+
// events carry the status, the body and the duration, which is the whole
|
|
3442
|
+
// difference between "the route was never programmed" and "the app hung".
|
|
3349
3443
|
const beforePollIdx = recorderEventCount();
|
|
3350
3444
|
let lastIterStartIdx = beforePollIdx;
|
|
3351
|
-
let keptIterStartIdx = beforePollIdx;
|
|
3352
3445
|
while (Date.now() - start < timeoutMs) {
|
|
3353
3446
|
attempts += 1;
|
|
3447
|
+
// Drop the *previous* attempt's events, not this one's — its events are
|
|
3448
|
+
// the ones worth keeping until a newer attempt replaces them.
|
|
3449
|
+
recorderTruncate(lastIterStartIdx);
|
|
3354
3450
|
lastIterStartIdx = recorderEventCount();
|
|
3355
3451
|
try {
|
|
3356
3452
|
const v = await fn();
|
|
3357
3453
|
if (v !== null && v !== undefined && v !== false) {
|
|
3358
3454
|
value = v;
|
|
3359
3455
|
success = true;
|
|
3360
|
-
keptIterStartIdx = lastIterStartIdx;
|
|
3361
3456
|
break;
|
|
3362
3457
|
}
|
|
3363
3458
|
}
|
|
@@ -3365,17 +3460,10 @@ async function pollCall(description, fn, opts) {
|
|
|
3365
3460
|
predicateError = err;
|
|
3366
3461
|
break;
|
|
3367
3462
|
}
|
|
3368
|
-
// Failed iteration — drop the events it emitted.
|
|
3369
|
-
recorderTruncate(lastIterStartIdx);
|
|
3370
3463
|
if (Date.now() - start + intervalMs > timeoutMs)
|
|
3371
3464
|
break;
|
|
3372
3465
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
3373
3466
|
}
|
|
3374
|
-
if (!success) {
|
|
3375
|
-
// Timeout or predicate error: drop every attempt's events. The
|
|
3376
|
-
// wait event we emit below is the only trace.
|
|
3377
|
-
recorderTruncate(beforePollIdx);
|
|
3378
|
-
}
|
|
3379
3467
|
const errMsg = predicateError !== undefined
|
|
3380
3468
|
? (predicateError?.message ?? String(predicateError))
|
|
3381
3469
|
: success
|
|
@@ -3388,11 +3476,11 @@ async function pollCall(description, fn, opts) {
|
|
|
3388
3476
|
passed: success,
|
|
3389
3477
|
...(errMsg !== undefined ? { error: errMsg } : {}),
|
|
3390
3478
|
}, resv);
|
|
3391
|
-
if (
|
|
3479
|
+
if (seq !== undefined) {
|
|
3392
3480
|
// Group the kept iteration's events under the wait so the UI can
|
|
3393
3481
|
// render them inside the wait card. The wait event itself is the
|
|
3394
3482
|
// very last entry; markChildren skips it via the seq match.
|
|
3395
|
-
recorderMarkChildren(
|
|
3483
|
+
recorderMarkChildren(lastIterStartIdx, seq);
|
|
3396
3484
|
}
|
|
3397
3485
|
if (predicateError !== undefined)
|
|
3398
3486
|
throw predicateError;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,12 +2,15 @@ import { strict as nodeAssert } from "node:assert";
|
|
|
2
2
|
export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedResponse, Provenanced, SpectestFetch, Unwrap, } from "./inspect.js";
|
|
3
3
|
import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
4
4
|
export { field } from "./inspect.js";
|
|
5
|
+
export { annotate } from "./annotate.js";
|
|
6
|
+
export type { Annotated, AnnotationKind, AnnotationOptions, EmailAnnotation, } from "./annotate.js";
|
|
7
|
+
import type { Annotated } from "./annotate.js";
|
|
5
8
|
export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
|
|
6
9
|
export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
|
|
7
10
|
export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
|
|
8
11
|
export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
|
|
9
12
|
import type { Browser, BrowserOptions } from "./browser.js";
|
|
10
|
-
export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, } from "./locator.js";
|
|
13
|
+
export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, FilePayload, InputFiles, } from "./locator.js";
|
|
11
14
|
import type { Locator } from "./locator.js";
|
|
12
15
|
export type { UrlPattern } from "./url-match.js";
|
|
13
16
|
import type { UrlPattern } from "./url-match.js";
|
|
@@ -215,6 +218,10 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
215
218
|
/** Read a project file as UTF-8. Relative paths resolve against
|
|
216
219
|
* {@link projectRoot}; absolute paths are read as-is. */
|
|
217
220
|
readProjectFile(path: string): Promise<string>;
|
|
221
|
+
/** Read a project file as bytes — a fixture to post, hash, or compare
|
|
222
|
+
* against what the app under test received. Same path rules as
|
|
223
|
+
* {@link readProjectFile}. */
|
|
224
|
+
readProjectFileBytes(path: string): Promise<Uint8Array>;
|
|
218
225
|
/**
|
|
219
226
|
* Run a command inside a service container. Pass an **array** for exact
|
|
220
227
|
* argv with no shell (`["psql", "-f", "-"]`), or a **string** to run via
|
|
@@ -1143,6 +1150,10 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1143
1150
|
* Every call is tracked in the test timeline: it records a `fake` step
|
|
1144
1151
|
* and the return value is tagged so a later `expect(...)` on it nests
|
|
1145
1152
|
* under that step in the UI (same provenance as `fetch`/db results).
|
|
1153
|
+
* The step renders the return value as JSON; wrap it in {@link annotate}
|
|
1154
|
+
* to add a richer view (an email, today) that the step's panel leads with,
|
|
1155
|
+
* the JSON one tab away — the test still receives the raw value, unchanged
|
|
1156
|
+
* and unchanged in type.
|
|
1146
1157
|
*
|
|
1147
1158
|
* Receives the fake's `state` plus a {@link FakeContext} `ctx`, so a
|
|
1148
1159
|
* helper can provision/teardown runtime services just like the handler.
|
|
@@ -1184,8 +1195,15 @@ export type FakesMap = Record<string, FakeDefinition<any, any>>;
|
|
|
1184
1195
|
* `void` side-effect helpers) pass through untouched. Mirrors `Tagged<T>` in
|
|
1185
1196
|
* `components/k3s.ts`, extended to cover synchronous returns. */
|
|
1186
1197
|
type WrappedHelpers<H> = {
|
|
1187
|
-
[K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Wrapped<R
|
|
1198
|
+
[K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Wrapped<Unannotated<R>>> : H[K] extends (...args: infer A) => infer R ? (...args: A) => [R] extends [void] ? void : Wrapped<Unannotated<R>> : H[K];
|
|
1188
1199
|
};
|
|
1200
|
+
/** A helper's return type as the *test* sees it. {@link annotate} boxes the
|
|
1201
|
+
* value in an {@link Annotated} to pick how the step renders; the daemon
|
|
1202
|
+
* opens that box at the helper boundary, so the annotation never reaches
|
|
1203
|
+
* the caller — in the types either. Distributes over a union, so a helper
|
|
1204
|
+
* that annotates only when it has something (`return m && annotate(m, "email")`)
|
|
1205
|
+
* still reads as `T | undefined`. */
|
|
1206
|
+
type Unannotated<R> = R extends Annotated<infer U> ? U : R;
|
|
1189
1207
|
/** Awaited return type of a fake's `helpers` factory (with each result
|
|
1190
1208
|
* inspect-wrapped, see {@link WrappedHelpers}), or `{ state: S }` (the
|
|
1191
1209
|
* default) when the user didn't ship one. */
|