@specific.dev/spectest 0.79.0 → 0.79.1

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.
@@ -35,7 +35,7 @@
35
35
  import net from "node:net";
36
36
  import { METHODS } from "../daemon";
37
37
  import { codeOf } from "./methods";
38
- import { PROTOCOL_VERSION, decodeFrame, encodeFrame, fail, ok, } from "./protocol";
38
+ import { PROTOCOL_VERSION, decodeFrame, describeThrow, encodeFrame, fail, ok, } from "./protocol";
39
39
  /** Capabilities this build announces. Empty is fine — the slot is what
40
40
  * matters, so a later release has something to negotiate against. */
41
41
  const CAPABILITIES = [];
@@ -87,11 +87,11 @@ export function serve(socket, registry) {
87
87
  //
88
88
  // A refusal the method meant to make (an unknown case, a second test
89
89
  // while one is running) reports its own kind and just its message. An
90
- // unexpected throw is a bug in the method and carries the stack,
91
- // which is the only place that information exists.
90
+ // unexpected throw carries its message AND its stack — in that order,
91
+ // because a stack alone is sometimes headerless (`describeThrow`).
92
92
  const e = err;
93
93
  const code = codeOf(err);
94
- const detail = code === "handler_error" ? (e?.stack ?? e?.message ?? String(err)) : e.message;
94
+ const detail = code === "handler_error" ? describeThrow(err) : e.message;
95
95
  send(fail(req.id, detail, code));
96
96
  }
97
97
  };
@@ -86,3 +86,23 @@ export declare function ok(id: string, result?: Record<string, unknown>): Respon
86
86
  /** A failed reply to `id`. Always carries the id: a request that failed must
87
87
  * never leave its caller waiting. */
88
88
  export declare function fail(id: string, message: string, code?: string): ResponseFrame;
89
+ /**
90
+ * Describe a thrown value for the wire: the **message first**, then the
91
+ * frames — never the stack on its own.
92
+ *
93
+ * `error.stack` normally opens with `Error: <message>`, which is why it
94
+ * looked like the richer of the two. It is not always: Bun drops that
95
+ * header when the rejection is observed a turn later than it settled —
96
+ * a promise that rejected while nobody was awaiting it yet, which is
97
+ * exactly what a bootstrap image build is (`daemon.ts` starts every
98
+ * build at once and each service awaits its own prep when the DAG
99
+ * reaches it). The whole diagnosis of a failed build IS the message
100
+ * there: `docker build for <svc> failed:` plus the build log. Sending
101
+ * the stack alone published four frames and nothing else — no docker,
102
+ * no BuildKit, no compiler error (reported by the Harmony project,
103
+ * 2026-09-09; the same run's log had the message intact).
104
+ *
105
+ * A stack that already carries the message is returned as it stands, so
106
+ * the ordinary case is unchanged.
107
+ */
108
+ export declare function describeThrow(err: unknown): string;
@@ -94,3 +94,37 @@ export function ok(id, result = {}) {
94
94
  export function fail(id, message, code) {
95
95
  return { kind: "response", id, ok: false, error: code ? { message, code } : { message } };
96
96
  }
97
+ /**
98
+ * Describe a thrown value for the wire: the **message first**, then the
99
+ * frames — never the stack on its own.
100
+ *
101
+ * `error.stack` normally opens with `Error: <message>`, which is why it
102
+ * looked like the richer of the two. It is not always: Bun drops that
103
+ * header when the rejection is observed a turn later than it settled —
104
+ * a promise that rejected while nobody was awaiting it yet, which is
105
+ * exactly what a bootstrap image build is (`daemon.ts` starts every
106
+ * build at once and each service awaits its own prep when the DAG
107
+ * reaches it). The whole diagnosis of a failed build IS the message
108
+ * there: `docker build for <svc> failed:` plus the build log. Sending
109
+ * the stack alone published four frames and nothing else — no docker,
110
+ * no BuildKit, no compiler error (reported by the Harmony project,
111
+ * 2026-09-09; the same run's log had the message intact).
112
+ *
113
+ * A stack that already carries the message is returned as it stands, so
114
+ * the ordinary case is unchanged.
115
+ */
116
+ export function describeThrow(err) {
117
+ const e = err;
118
+ const message = typeof e?.message === "string" ? e.message : "";
119
+ const stack = typeof e?.stack === "string" ? e.stack : "";
120
+ if (!message)
121
+ return stack || String(err);
122
+ if (stack.includes(message))
123
+ return stack;
124
+ const name = typeof e?.name === "string" && e.name.length > 0 ? e.name : "Error";
125
+ // The headerless form is `<Name>\n at …`. Drop that bare first line
126
+ // and write the header ourselves, so the result reads like the stack a
127
+ // reader expects rather than a message with a stray `Error` in it.
128
+ const frames = stack.startsWith(`${name}\n`) ? stack.slice(name.length + 1) : stack;
129
+ return frames.length > 0 ? `${name}: ${message}\n${frames}` : `${name}: ${message}`;
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.79.0",
3
+ "version": "0.79.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -40,6 +40,7 @@ import { codeOf } from "./methods";
40
40
  import {
41
41
  PROTOCOL_VERSION,
42
42
  decodeFrame,
43
+ describeThrow,
43
44
  encodeFrame,
44
45
  fail,
45
46
  ok,
@@ -115,12 +116,11 @@ export function serve(
115
116
  //
116
117
  // A refusal the method meant to make (an unknown case, a second test
117
118
  // while one is running) reports its own kind and just its message. An
118
- // unexpected throw is a bug in the method and carries the stack,
119
- // which is the only place that information exists.
119
+ // unexpected throw carries its message AND its stack — in that order,
120
+ // because a stack alone is sometimes headerless (`describeThrow`).
120
121
  const e = err as Error;
121
122
  const code = codeOf(err);
122
- const detail =
123
- code === "handler_error" ? (e?.stack ?? e?.message ?? String(err)) : e.message;
123
+ const detail = code === "handler_error" ? describeThrow(err) : e.message;
124
124
  send(fail(req.id, detail, code));
125
125
  }
126
126
  };
@@ -18,6 +18,7 @@ import {
18
18
  KNOWN_METHODS,
19
19
  PROTOCOL_VERSION,
20
20
  decodeFrame,
21
+ describeThrow,
21
22
  encodeFrame,
22
23
  fail,
23
24
  ok,
@@ -146,3 +147,44 @@ describe("replies", () => {
146
147
  expect(fail("7", "boom", "c")!.error!.code).toBe("c");
147
148
  });
148
149
  });
150
+
151
+ describe("describeThrow", () => {
152
+ test("an ordinary stack, which already opens with the message, is untouched", () => {
153
+ const err = new Error("docker build for web failed:\nCOPY: not found");
154
+ expect(describeThrow(err)).toBe(err.stack);
155
+ });
156
+
157
+ /**
158
+ * The shape this function exists for, produced rather than written by
159
+ * hand: a promise that rejects while nobody awaits it yet and is
160
+ * observed a turn later comes back with a headerless stack. That is a
161
+ * bootstrap image build (`daemon.ts` starts every build at once and a
162
+ * service awaits its own prep when the DAG reaches it), and the build
163
+ * log lives in the message it drops.
164
+ */
165
+ test("a headerless stack gets its message back", async () => {
166
+ const message = "docker build for client-app failed:\n#12 ERROR: killed";
167
+ const rejected = (async () => {
168
+ throw new Error(message);
169
+ })();
170
+ rejected.catch(() => undefined);
171
+ await new Promise((r) => setTimeout(r, 5));
172
+ let caught: unknown;
173
+ try {
174
+ await rejected;
175
+ } catch (e) {
176
+ caught = e;
177
+ }
178
+ const described = describeThrow(caught);
179
+ expect(described).toContain(message);
180
+ expect(described.startsWith(`Error: ${message}`)).toBe(true);
181
+ // The frames survive, and the bare `Error` line they hung under does not.
182
+ expect(described).toContain(" at ");
183
+ expect(described).not.toContain("\nError\n");
184
+ });
185
+
186
+ test("a non-Error keeps whatever it can say for itself", () => {
187
+ expect(describeThrow("plain string")).toBe("plain string");
188
+ expect(describeThrow({ message: "no stack here" })).toBe("Error: no stack here");
189
+ });
190
+ });
@@ -161,3 +161,36 @@ export function ok(id: string, result: Record<string, unknown> = {}): ResponseFr
161
161
  export function fail(id: string, message: string, code?: string): ResponseFrame {
162
162
  return { kind: "response", id, ok: false, error: code ? { message, code } : { message } };
163
163
  }
164
+
165
+ /**
166
+ * Describe a thrown value for the wire: the **message first**, then the
167
+ * frames — never the stack on its own.
168
+ *
169
+ * `error.stack` normally opens with `Error: <message>`, which is why it
170
+ * looked like the richer of the two. It is not always: Bun drops that
171
+ * header when the rejection is observed a turn later than it settled —
172
+ * a promise that rejected while nobody was awaiting it yet, which is
173
+ * exactly what a bootstrap image build is (`daemon.ts` starts every
174
+ * build at once and each service awaits its own prep when the DAG
175
+ * reaches it). The whole diagnosis of a failed build IS the message
176
+ * there: `docker build for <svc> failed:` plus the build log. Sending
177
+ * the stack alone published four frames and nothing else — no docker,
178
+ * no BuildKit, no compiler error (reported by the Harmony project,
179
+ * 2026-09-09; the same run's log had the message intact).
180
+ *
181
+ * A stack that already carries the message is returned as it stands, so
182
+ * the ordinary case is unchanged.
183
+ */
184
+ export function describeThrow(err: unknown): string {
185
+ const e = err as { message?: unknown; stack?: unknown; name?: unknown } | null | undefined;
186
+ const message = typeof e?.message === "string" ? e.message : "";
187
+ const stack = typeof e?.stack === "string" ? e.stack : "";
188
+ if (!message) return stack || String(err);
189
+ if (stack.includes(message)) return stack;
190
+ const name = typeof e?.name === "string" && e.name.length > 0 ? e.name : "Error";
191
+ // The headerless form is `<Name>\n at …`. Drop that bare first line
192
+ // and write the header ourselves, so the result reads like the stack a
193
+ // reader expects rather than a message with a stray `Error` in it.
194
+ const frames = stack.startsWith(`${name}\n`) ? stack.slice(name.length + 1) : stack;
195
+ return frames.length > 0 ? `${name}: ${message}\n${frames}` : `${name}: ${message}`;
196
+ }