libretto 0.6.11 → 0.6.13

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.
Files changed (130) hide show
  1. package/README.md +7 -8
  2. package/README.template.md +7 -8
  3. package/dist/cli/cli.js +0 -22
  4. package/dist/cli/commands/browser.js +18 -24
  5. package/dist/cli/commands/execution.js +254 -234
  6. package/dist/cli/commands/experiments.js +100 -0
  7. package/dist/cli/commands/setup.js +3 -310
  8. package/dist/cli/commands/shared.js +10 -0
  9. package/dist/cli/commands/snapshot.js +46 -64
  10. package/dist/cli/commands/status.js +1 -40
  11. package/dist/cli/core/browser.js +303 -124
  12. package/dist/cli/core/config.js +5 -6
  13. package/dist/cli/core/context.js +4 -0
  14. package/dist/cli/core/daemon/config.js +0 -6
  15. package/dist/cli/core/daemon/daemon.js +497 -90
  16. package/dist/cli/core/daemon/ipc.js +170 -129
  17. package/dist/cli/core/daemon/snapshot.js +48 -9
  18. package/dist/cli/core/experiments.js +39 -0
  19. package/dist/cli/core/session.js +5 -4
  20. package/dist/cli/core/skill-version.js +2 -1
  21. package/dist/cli/core/workflow-runner/runner.js +147 -0
  22. package/dist/cli/core/workflow-runtime.js +60 -0
  23. package/dist/cli/index.js +0 -2
  24. package/dist/cli/router.js +4 -3
  25. package/dist/shared/debug/pause-handler.d.ts +9 -0
  26. package/dist/shared/debug/pause-handler.js +15 -0
  27. package/dist/shared/debug/pause.d.ts +1 -2
  28. package/dist/shared/debug/pause.js +13 -36
  29. package/dist/shared/instrumentation/instrument.js +4 -4
  30. package/dist/shared/ipc/child-process-transport.d.ts +7 -0
  31. package/dist/shared/ipc/child-process-transport.js +60 -0
  32. package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
  33. package/dist/shared/ipc/child-process-transport.spec.js +68 -0
  34. package/dist/shared/ipc/ipc.d.ts +46 -0
  35. package/dist/shared/ipc/ipc.js +165 -0
  36. package/dist/shared/ipc/ipc.spec.d.ts +2 -0
  37. package/dist/shared/ipc/ipc.spec.js +114 -0
  38. package/dist/shared/ipc/socket-transport.d.ts +9 -0
  39. package/dist/shared/ipc/socket-transport.js +143 -0
  40. package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
  41. package/dist/shared/ipc/socket-transport.spec.js +117 -0
  42. package/dist/shared/package-manager.d.ts +7 -0
  43. package/dist/shared/package-manager.js +60 -0
  44. package/dist/shared/paths/paths.d.ts +1 -8
  45. package/dist/shared/paths/paths.js +1 -49
  46. package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
  47. package/dist/shared/snapshot/capture-snapshot.js +463 -0
  48. package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
  49. package/dist/shared/snapshot/diff-snapshots.js +358 -0
  50. package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
  51. package/dist/shared/snapshot/render-snapshot.js +651 -0
  52. package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
  53. package/dist/shared/snapshot/snapshot.spec.js +333 -0
  54. package/dist/shared/snapshot/types.d.ts +40 -0
  55. package/dist/shared/snapshot/types.js +0 -0
  56. package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
  57. package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
  58. package/dist/shared/state/session-state.d.ts +1 -0
  59. package/dist/shared/state/session-state.js +1 -0
  60. package/docs/experiments.md +67 -0
  61. package/docs/releasing.md +8 -6
  62. package/package.json +5 -2
  63. package/skills/libretto/SKILL.md +19 -19
  64. package/skills/libretto/references/configuration-file-reference.md +6 -12
  65. package/skills/libretto/references/pages-and-page-targeting.md +1 -1
  66. package/skills/libretto-readonly/SKILL.md +2 -9
  67. package/src/cli/AGENTS.md +7 -0
  68. package/src/cli/cli.ts +0 -23
  69. package/src/cli/commands/browser.ts +14 -18
  70. package/src/cli/commands/execution.ts +303 -271
  71. package/src/cli/commands/experiments.ts +120 -0
  72. package/src/cli/commands/setup.ts +3 -400
  73. package/src/cli/commands/shared.ts +20 -0
  74. package/src/cli/commands/snapshot.ts +54 -94
  75. package/src/cli/commands/status.ts +1 -48
  76. package/src/cli/core/browser.ts +372 -150
  77. package/src/cli/core/config.ts +4 -5
  78. package/src/cli/core/context.ts +4 -0
  79. package/src/cli/core/daemon/config.ts +35 -19
  80. package/src/cli/core/daemon/daemon.ts +645 -107
  81. package/src/cli/core/daemon/ipc.ts +319 -214
  82. package/src/cli/core/daemon/snapshot.ts +71 -15
  83. package/src/cli/core/experiments.ts +56 -0
  84. package/src/cli/core/resolve-model.ts +5 -0
  85. package/src/cli/core/session.ts +5 -4
  86. package/src/cli/core/skill-version.ts +2 -1
  87. package/src/cli/core/workflow-runner/runner.ts +237 -0
  88. package/src/cli/core/workflow-runtime.ts +86 -0
  89. package/src/cli/index.ts +0 -1
  90. package/src/cli/router.ts +4 -3
  91. package/src/shared/debug/pause-handler.ts +20 -0
  92. package/src/shared/debug/pause.ts +14 -48
  93. package/src/shared/instrumentation/instrument.ts +4 -4
  94. package/src/shared/ipc/AGENTS.md +24 -0
  95. package/src/shared/ipc/child-process-transport.spec.ts +86 -0
  96. package/src/shared/ipc/child-process-transport.ts +96 -0
  97. package/src/shared/ipc/ipc.spec.ts +161 -0
  98. package/src/shared/ipc/ipc.ts +288 -0
  99. package/src/shared/ipc/socket-transport.spec.ts +141 -0
  100. package/src/shared/ipc/socket-transport.ts +189 -0
  101. package/src/shared/package-manager.ts +76 -0
  102. package/src/shared/paths/paths.ts +0 -72
  103. package/src/shared/snapshot/capture-snapshot.ts +615 -0
  104. package/src/shared/snapshot/diff-snapshots.ts +579 -0
  105. package/src/shared/snapshot/render-snapshot.ts +962 -0
  106. package/src/shared/snapshot/snapshot.spec.ts +388 -0
  107. package/src/shared/snapshot/types.ts +43 -0
  108. package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
  109. package/src/shared/state/session-state.ts +1 -0
  110. package/dist/cli/commands/ai.js +0 -109
  111. package/dist/cli/core/ai-model.js +0 -192
  112. package/dist/cli/core/api-snapshot-analyzer.js +0 -86
  113. package/dist/cli/core/daemon/index.js +0 -16
  114. package/dist/cli/core/daemon/spawn.js +0 -90
  115. package/dist/cli/core/pause-signals.js +0 -29
  116. package/dist/cli/core/snapshot-analyzer.js +0 -666
  117. package/dist/cli/workers/run-integration-runtime.js +0 -235
  118. package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
  119. package/dist/cli/workers/run-integration-worker.js +0 -64
  120. package/scripts/summarize-evals.mjs +0 -135
  121. package/src/cli/commands/ai.ts +0 -143
  122. package/src/cli/core/ai-model.ts +0 -298
  123. package/src/cli/core/api-snapshot-analyzer.ts +0 -110
  124. package/src/cli/core/daemon/index.ts +0 -24
  125. package/src/cli/core/daemon/spawn.ts +0 -171
  126. package/src/cli/core/pause-signals.ts +0 -35
  127. package/src/cli/core/snapshot-analyzer.ts +0 -855
  128. package/src/cli/workers/run-integration-runtime.ts +0 -326
  129. package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
  130. package/src/cli/workers/run-integration-worker.ts +0 -72
@@ -1,32 +1,17 @@
1
- import { existsSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
- import { getSessionDir } from "../../cli/core/context.js";
4
- import {
5
- getPauseSignalPaths,
6
- removeSignalIfExists,
7
- } from "../../cli/core/pause-signals.js";
8
- import { listRunningSessions } from "../../cli/core/session.js";
1
+ import { getActivePauseHandler } from "./pause-handler.js";
2
+ import { librettoCommand } from "../package-manager.js";
9
3
 
10
4
  function throwMissingSessionError(): never {
11
- const runningSessions = listRunningSessions();
12
- const lines = ["pause(session) requires a non-empty session ID."];
13
-
14
- if (runningSessions.length > 0) {
15
- lines.push("", "Running sessions:");
16
- for (const s of runningSessions) {
17
- lines.push(` ${s.session}`);
18
- }
19
- }
20
-
21
- throw new Error(lines.join("\n"));
5
+ throw new Error(
6
+ `pause(session) requires a non-empty session ID. Pass ctx.session from inside your workflow: await pause(ctx.session). To list running sessions, run: ${librettoCommand("status")}.`,
7
+ );
22
8
  }
23
9
 
24
10
  /**
25
11
  * Standalone pause function.
26
12
  *
27
13
  * - In production (`NODE_ENV === "production"`), returns immediately (no-op).
28
- * - Otherwise, writes a `.paused` signal file and polls for a `.resume` signal,
29
- * using the same file-based mechanism as the CLI runner.
14
+ * - Otherwise, delegates to the active Libretto workflow runtime pause handler.
30
15
  *
31
16
  * Import directly: `import { pause } from "libretto";`
32
17
  */
@@ -39,34 +24,15 @@ export async function pause(session: string): Promise<void> {
39
24
  throwMissingSessionError();
40
25
  }
41
26
 
42
- const signalPaths = getPauseSignalPaths(session);
43
- const { pausedSignalPath, resumeSignalPath } = signalPaths;
44
-
45
- await mkdir(getSessionDir(session), { recursive: true });
46
- await removeSignalIfExists(resumeSignalPath);
47
-
48
- const details = {
49
- sessionName: session,
50
- pausedAt: new Date().toISOString(),
51
- url: "unknown",
52
- };
53
-
54
- // Try to read the current page URL from the process (best-effort).
55
- // The standalone pause doesn't have access to the page object,
56
- // so we just record what we can.
57
- await writeFile(pausedSignalPath, JSON.stringify(details, null, 2), "utf8");
58
-
59
- console.log(`[pause] Paused (session: ${session})`);
60
- console.log("[pause] Waiting for resume signal...");
61
-
62
- const RESUME_POLL_INTERVAL_MS = 250;
63
- while (!existsSync(resumeSignalPath)) {
64
- await new Promise((resolve) =>
65
- setTimeout(resolve, RESUME_POLL_INTERVAL_MS),
27
+ const handler = getActivePauseHandler();
28
+ if (!handler) {
29
+ throw new Error(
30
+ `pause(session) can only suspend an active Libretto workflow. Run the workflow with ${librettoCommand("run <integrationFile>")} and call pause(ctx.session) from inside the workflow.`,
66
31
  );
67
32
  }
68
33
 
69
- await removeSignalIfExists(resumeSignalPath);
70
- await removeSignalIfExists(pausedSignalPath);
71
- console.log("[pause] Resume signal received. Continuing workflow...");
34
+ await handler({
35
+ session,
36
+ pausedAt: new Date().toISOString(),
37
+ });
72
38
  }
@@ -131,12 +131,12 @@ function wrapLocatorActions(
131
131
  try {
132
132
  const result = await orig(...args);
133
133
  if (opts.visualize) {
134
- enqueue(page, () => visualizeAfterAction(page));
134
+ void enqueue(page, () => visualizeAfterAction(page));
135
135
  }
136
136
  return result;
137
137
  } catch (err: any) {
138
138
  if (opts.visualize) {
139
- enqueue(page, () => visualizeAfterAction(page));
139
+ void enqueue(page, () => visualizeAfterAction(page));
140
140
  }
141
141
  // Enrich timeout errors for pointer actions
142
142
  if (POINTER_ACTIONS.has(method) && isTimeoutError(err)) {
@@ -323,12 +323,12 @@ export async function installInstrumentation(
323
323
  try {
324
324
  const result = await orig(...args);
325
325
  if (visualize) {
326
- enqueue(page, () => visualizeAfterAction(page));
326
+ void enqueue(page, () => visualizeAfterAction(page));
327
327
  }
328
328
  return result;
329
329
  } catch (err: any) {
330
330
  if (visualize) {
331
- enqueue(page, () => visualizeAfterAction(page));
331
+ void enqueue(page, () => visualizeAfterAction(page));
332
332
  }
333
333
  if (
334
334
  POINTER_ACTIONS.has(method) &&
@@ -0,0 +1,24 @@
1
+ # Shared IPC
2
+
3
+ This directory contains the generic typed IPC peer used across socket and child-process boundaries. Keep it transport-agnostic and avoid daemon-, workflow-, or CLI-specific behavior here.
4
+
5
+ ## IpcPeer model
6
+
7
+ `createIpcPeer<Remote, Local>(transport, handlers)` creates one bidirectional peer:
8
+
9
+ - `Remote` is the API this side can call through `peer.call.*`.
10
+ - `Local` is the API this side exposes through `handlers`.
11
+ - Both peers on a connection use opposite generic ordering.
12
+ - Calls are request/response RPCs over `IpcProtocolMessage`; concurrent calls are supported.
13
+
14
+ ## Transports
15
+
16
+ Transports implement `IpcTransport<IpcProtocolMessage>` and are responsible only for delivery, framing, close notification, and cleanup. Use `socket-transport.ts` for Unix socket connections and `child-process-transport.ts` for Node child-process IPC.
17
+
18
+ When adding a transport, filter incoming messages to valid IPC protocol messages before passing them to `createIpcPeer`, and wire close/error events through `onClose` so pending calls reject when the connection dies.
19
+
20
+ ## Error behavior
21
+
22
+ `createIpcPeer` serializes handler throws into rejected call promises. Callers should usually let those rejections propagate instead of wrapping them in subsystem-specific error adapters.
23
+
24
+ Use explicit result shapes only when failure is part of the method contract. For example, daemon `exec` and `readonlyExec` return user-code failures as `{ ok: false, message, output }` so CLI callers can preserve captured stdout and stderr.
@@ -0,0 +1,86 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { fileURLToPath } from "node:url";
4
+ import { expect, test } from "vitest";
5
+ import { createIpcPeer } from "./ipc.js";
6
+ import { createChildProcessIpcTransport } from "./child-process-transport.js";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ type ParentApi = {
11
+ greet(name: string): string;
12
+ };
13
+
14
+ type ChildApi = {
15
+ double(value: number): number;
16
+ askParent(name: string): Promise<string>;
17
+ };
18
+
19
+ test("throws a clear error without a child-process IPC channel", async () => {
20
+ const modulePath = fileURLToPath(
21
+ new URL("./child-process-transport.ts", import.meta.url),
22
+ );
23
+
24
+ await expect(
25
+ execFileAsync(
26
+ process.execPath,
27
+ [
28
+ "--import",
29
+ "tsx",
30
+ "-e",
31
+ `import { createParentProcessIpcTransport } from ${JSON.stringify(modulePath)}; createParentProcessIpcTransport();`,
32
+ ],
33
+ { cwd: process.cwd() },
34
+ ),
35
+ ).rejects.toMatchObject({
36
+ stderr: expect.stringContaining("process.send is not available"),
37
+ });
38
+ });
39
+
40
+ test("lets parent and child process call each other", async () => {
41
+ const child = spawn(
42
+ process.execPath,
43
+ ["--import", "tsx", "--input-type=module", "-e", getChildFixtureSource()],
44
+ { stdio: ["ignore", "ignore", "ignore", "ipc"] },
45
+ );
46
+ child.send({ type: "not-an-ipc-protocol-message" });
47
+
48
+ const peer = createIpcPeer<ChildApi, ParentApi>(
49
+ createChildProcessIpcTransport(child),
50
+ {
51
+ greet(name) {
52
+ return `hello ${name}`;
53
+ },
54
+ },
55
+ );
56
+
57
+ await expect(peer.call.double(21)).resolves.toBe(42);
58
+ await expect(peer.call.askParent("Ada")).resolves.toBe("hello Ada");
59
+
60
+ peer.destroy();
61
+ await new Promise<void>((resolve) => child.once("exit", () => resolve()));
62
+ });
63
+
64
+ function getChildFixtureSource(): string {
65
+ const ipcModule = new URL("./ipc.ts", import.meta.url).href;
66
+ const transportModule = new URL(
67
+ "./child-process-transport.ts",
68
+ import.meta.url,
69
+ ).href;
70
+
71
+ return `
72
+ import { createIpcPeer } from ${JSON.stringify(ipcModule)};
73
+ import { createParentProcessIpcTransport } from ${JSON.stringify(transportModule)};
74
+
75
+ const peer = createIpcPeer(createParentProcessIpcTransport(), {
76
+ double(value) {
77
+ return value * 2;
78
+ },
79
+ async askParent(name) {
80
+ return peer.call.greet(name);
81
+ },
82
+ });
83
+
84
+ process.on("disconnect", () => peer.destroy());
85
+ `;
86
+ }
@@ -0,0 +1,96 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import type { IpcProtocolMessage, IpcTransport } from "./ipc.js";
3
+
4
+ type ProcessIpcTarget = {
5
+ send?: (message: IpcProtocolMessage) => boolean;
6
+ on(event: "message", listener: (message: unknown) => void): ProcessIpcTarget;
7
+ off(event: "message", listener: (message: unknown) => void): ProcessIpcTarget;
8
+ on(event: "disconnect", listener: () => void): ProcessIpcTarget;
9
+ off(event: "disconnect", listener: () => void): ProcessIpcTarget;
10
+ };
11
+
12
+ export function createChildProcessIpcTransport(
13
+ child: ChildProcess,
14
+ ): IpcTransport<IpcProtocolMessage> {
15
+ return createProcessIpcTransport(child, () => {
16
+ if (child.connected) child.disconnect();
17
+ });
18
+ }
19
+
20
+ export function createParentProcessIpcTransport(): IpcTransport<IpcProtocolMessage> {
21
+ if (!process.send) {
22
+ throw new Error(
23
+ "Cannot create child-process IPC transport: process.send is not available. Start the process with an IPC channel.",
24
+ );
25
+ }
26
+
27
+ return createProcessIpcTransport(process, () => {
28
+ if (process.connected) process.disconnect?.();
29
+ });
30
+ }
31
+
32
+ function createProcessIpcTransport(
33
+ target: ProcessIpcTarget,
34
+ close: () => void,
35
+ ): IpcTransport<IpcProtocolMessage> {
36
+ return {
37
+ send(message) {
38
+ if (!target.send) {
39
+ throw new Error(
40
+ "Cannot send IPC message: process IPC channel is closed.",
41
+ );
42
+ }
43
+
44
+ target.send(message);
45
+ },
46
+ listen(callback) {
47
+ const onMessage = (message: unknown) => {
48
+ if (isIpcProtocolMessage(message)) callback(message);
49
+ };
50
+
51
+ target.on("message", onMessage);
52
+ return () => target.off("message", onMessage);
53
+ },
54
+ onClose(callback) {
55
+ const onDisconnect = () => callback();
56
+ target.on("disconnect", onDisconnect);
57
+ return () => target.off("disconnect", onDisconnect);
58
+ },
59
+ close,
60
+ };
61
+ }
62
+
63
+ function isIpcProtocolMessage(message: unknown): message is IpcProtocolMessage {
64
+ if (!isRecord(message)) return false;
65
+
66
+ if (message.type === "ipc-request") {
67
+ return (
68
+ typeof message.id === "string" &&
69
+ typeof message.method === "string" &&
70
+ Array.isArray(message.args)
71
+ );
72
+ }
73
+
74
+ if (message.type === "ipc-response") {
75
+ return (
76
+ typeof message.id === "string" &&
77
+ typeof message.method === "string" &&
78
+ (message.error === undefined || isSerializedError(message.error))
79
+ );
80
+ }
81
+
82
+ return false;
83
+ }
84
+
85
+ function isSerializedError(value: unknown): boolean {
86
+ return (
87
+ isRecord(value) &&
88
+ typeof value.name === "string" &&
89
+ typeof value.message === "string" &&
90
+ (value.stack === undefined || typeof value.stack === "string")
91
+ );
92
+ }
93
+
94
+ function isRecord(value: unknown): value is Record<string, unknown> {
95
+ return typeof value === "object" && value !== null;
96
+ }
@@ -0,0 +1,161 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { expect, test as base } from "vitest";
3
+ import {
4
+ createIpcPeer,
5
+ type IpcPeer,
6
+ type IpcProtocolMessage,
7
+ } from "./ipc.js";
8
+
9
+ type ApiA = {
10
+ greet(name: string): string;
11
+ };
12
+
13
+ type ApiB = {
14
+ add(left: number, right: number): Promise<number>;
15
+ fail(): Promise<void>;
16
+ failWithCause(): Promise<void>;
17
+ failWithCode(): Promise<void>;
18
+ failWithNonError(): Promise<void>;
19
+ wait(): Promise<string>;
20
+ };
21
+
22
+ type Peers = {
23
+ a: IpcPeer<ApiB>;
24
+ b: IpcPeer<ApiA>;
25
+ };
26
+
27
+ type Fixtures = Peers & {
28
+ peers: Peers;
29
+ };
30
+
31
+ const test = base.extend<Fixtures>({
32
+ peers: async ({}, use) => {
33
+ const channel = new EventEmitter<{
34
+ a: [IpcProtocolMessage];
35
+ b: [IpcProtocolMessage];
36
+ }>();
37
+ const a = createIpcPeer<ApiB, ApiA>({
38
+ send(message) {
39
+ channel.emit("b", message);
40
+ },
41
+ listen(callback) {
42
+ channel.on("a", callback);
43
+ return () => channel.off("a", callback);
44
+ },
45
+ }, {
46
+ greet(name) {
47
+ return `hello ${name}`;
48
+ },
49
+ });
50
+ const b = createIpcPeer<ApiA, ApiB>({
51
+ send(message) {
52
+ channel.emit("a", message);
53
+ },
54
+ listen(callback) {
55
+ channel.on("b", callback);
56
+ return () => channel.off("b", callback);
57
+ },
58
+ }, {
59
+ async add(left, right) {
60
+ return left + right;
61
+ },
62
+ async fail() {
63
+ throw new Error("expected failure");
64
+ },
65
+ async failWithCause() {
66
+ throw new Error("outer failure", {
67
+ cause: new Error("inner failure", {
68
+ cause: new TypeError("root failure"),
69
+ }),
70
+ });
71
+ },
72
+ async failWithCode() {
73
+ const error = new Error("coded failure") as Error & { code: string };
74
+ error.code = "ERR_EXPECTED";
75
+ throw error;
76
+ },
77
+ async failWithNonError() {
78
+ throw "plain failure";
79
+ },
80
+ async wait() {
81
+ return new Promise(() => {});
82
+ },
83
+ });
84
+
85
+ await use({ a, b });
86
+
87
+ a.destroy();
88
+ b.destroy();
89
+ },
90
+ a: async ({ peers }, use) => {
91
+ await use(peers.a);
92
+ },
93
+ b: async ({ peers }, use) => {
94
+ await use(peers.b);
95
+ },
96
+ });
97
+
98
+ test("calls handlers on the remote peer", async ({ a, b }) => {
99
+ await expect(a.call.add(2, 3)).resolves.toBe(5);
100
+ await expect(b.call.greet("Ada")).resolves.toBe("hello Ada");
101
+ });
102
+
103
+ test("rejects with the remote handler error", async ({ a }) => {
104
+ await expect(a.call.fail()).rejects.toThrow("fail > expected failure");
105
+ });
106
+
107
+ test("rejects with the remote handler error cause chain", async ({ a }) => {
108
+ const error = await getRejectedError(a.call.failWithCause());
109
+
110
+ expect(error.message).toBe("failWithCause > outer failure");
111
+ expect(error.stack).toContain("outer failure");
112
+
113
+ const cause = getErrorCause(error);
114
+ expect(cause.message).toBe("inner failure");
115
+
116
+ const rootCause = getErrorCause(cause);
117
+ expect(rootCause.name).toBe("TypeError");
118
+ expect(rootCause.message).toBe("root failure");
119
+ });
120
+
121
+ test("rejects with the remote handler error code", async ({ a }) => {
122
+ const error = await getRejectedError(a.call.failWithCode());
123
+
124
+ expect(error.message).toBe("failWithCode > coded failure");
125
+ expect(error.code).toBe("ERR_EXPECTED");
126
+ expect(error.stack).toContain("coded failure");
127
+ });
128
+
129
+ test("rejects with a serialized non-error thrown value", async ({ a }) => {
130
+ const error = await getRejectedError(a.call.failWithNonError());
131
+
132
+ expect(error.name).toBe("NonError");
133
+ expect(error.message).toBe("failWithNonError > plain failure");
134
+ expect(error.stack).toContain("failWithNonError");
135
+ });
136
+
137
+ test("rejects pending calls when destroyed", async ({ a }) => {
138
+ const result = a.call.wait();
139
+
140
+ a.destroy();
141
+
142
+ await expect(result).rejects.toThrow("IPC peer destroyed");
143
+ });
144
+
145
+ async function getRejectedError(
146
+ promise: Promise<unknown>,
147
+ ): Promise<Error & { code?: unknown }> {
148
+ try {
149
+ await promise;
150
+ } catch (error) {
151
+ expect(error).toBeInstanceOf(Error);
152
+ return error as Error & { code?: unknown };
153
+ }
154
+
155
+ throw new Error("Expected promise to reject");
156
+ }
157
+
158
+ function getErrorCause(error: Error): Error {
159
+ expect(error.cause).toBeInstanceOf(Error);
160
+ return error.cause as Error;
161
+ }