pi-remote-control 1.0.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/README.md +46 -0
- package/docs/adr/0001-package-extension-as-control-shim.md +19 -0
- package/docs/adr/0002-use-sqlite-for-daemon-state.md +19 -0
- package/docs/adr/0003-use-lock-file-as-process-state.md +19 -0
- package/docs/adr/0004-allow-loopback-pair-code-without-token.md +19 -0
- package/docs/adr/0005-defer-os-service-installation.md +19 -0
- package/docs/adr/0006-use-tui-activated-remote-control-sessions.md +24 -0
- package/docs/adr/0007-require-tui-originated-pairing.md +19 -0
- package/docs/adr/0008-use-qr-pairing-links.md +21 -0
- package/docs/adr/0009-rename-package-to-remote-control.md +19 -0
- package/docs/adr/0010-clean-stale-lock-on-status.md +19 -0
- package/docs/adr/0011-use-loopback-tui-control.md +19 -0
- package/docs/adr/0012-use-paginated-session-transcript-loading.md +37 -0
- package/docs/adr/0013-require-manual-reactivation-after-tui-entry.md +31 -0
- package/docs/adr/0014-read-transcripts-from-session-files.md +33 -0
- package/docs/adr/0015-normalize-transcript-messages-and-stream-events.md +35 -0
- package/docs/adr/0016-expose-turn-lifecycle-events.md +31 -0
- package/docs/adr/0017-bound-initial-websocket-session-state.md +31 -0
- package/docs/adr/0018-reregister-active-tui-session-on-heartbeat-miss.md +33 -0
- package/docs/adr/0019-display-only-pairing-qr-and-expiry.md +25 -0
- package/docs/adr/0020-expose-session-status-snapshots.md +31 -0
- package/docs/adr/0021-support-remote-compact-action.md +31 -0
- package/docs/adr/0022-rename-session-status-to-runtime-status.md +27 -0
- package/docs/adr/0023-return-remote-compact-results.md +29 -0
- package/docs/architecture.md +96 -0
- package/docs/data-model.md +284 -0
- package/docs/interfaces.md +470 -0
- package/package.json +37 -0
- package/scripts/http-smoke-test.sh +100 -0
- package/src/active-session-registry.ts +205 -0
- package/src/auth/pairing.ts +30 -0
- package/src/auth/tokens.ts +30 -0
- package/src/cli-runner.cjs +15 -0
- package/src/cli.ts +254 -0
- package/src/config.ts +26 -0
- package/src/extension/index.ts +422 -0
- package/src/index.ts +16 -0
- package/src/lock.ts +26 -0
- package/src/pairing-link.ts +15 -0
- package/src/paths.ts +21 -0
- package/src/persistence/daemon-store.ts +56 -0
- package/src/persistence/schema.ts +21 -0
- package/src/qr.ts +23 -0
- package/src/runtime-status.ts +116 -0
- package/src/server/http.ts +529 -0
- package/src/session-index.ts +9 -0
- package/src/session-transcript.ts +34 -0
- package/src/transcript-message.ts +76 -0
- package/src/transcript-pagination.ts +68 -0
- package/src/transcript-preview.ts +102 -0
- package/src/transcript-stream.ts +89 -0
- package/src/types.ts +116 -0
- package/tests/active-session-registry.test.ts +170 -0
- package/tests/auth.test.ts +18 -0
- package/tests/cli.test.ts +361 -0
- package/tests/config.test.ts +35 -0
- package/tests/daemon-store.test.ts +54 -0
- package/tests/extension.test.ts +617 -0
- package/tests/lock.test.ts +36 -0
- package/tests/pairing-link.test.ts +26 -0
- package/tests/pairing.test.ts +26 -0
- package/tests/paths.test.ts +29 -0
- package/tests/qr.test.ts +25 -0
- package/tests/schema.test.ts +18 -0
- package/tests/server-http.test.ts +932 -0
- package/tests/session-index.test.ts +10 -0
- package/tests/session-transcript.test.ts +75 -0
- package/tests/transcript-pagination.test.ts +54 -0
- package/tests/transcript-preview.test.ts +64 -0
- package/tests/transcript-stream.test.ts +103 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import remoteControlExtension, { collectRuntimeStatus, enrichTuiEventForDaemon, handleRemoteCommand } from "../src/extension/index.js";
|
|
6
|
+
|
|
7
|
+
type Registered = {
|
|
8
|
+
name: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
handler: (args: string, ctx: ExtensionTestContext) => Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ExtensionTestContext = {
|
|
14
|
+
cwd: string;
|
|
15
|
+
sessionManager: {
|
|
16
|
+
getSessionId(): string;
|
|
17
|
+
getSessionFile(): string | undefined;
|
|
18
|
+
getSessionName(): string | undefined;
|
|
19
|
+
getEntries(): unknown[];
|
|
20
|
+
};
|
|
21
|
+
model?: unknown;
|
|
22
|
+
getContextUsage?(): unknown;
|
|
23
|
+
isIdle(): boolean;
|
|
24
|
+
ui: {
|
|
25
|
+
theme: { fg(color: string, text: string): string };
|
|
26
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
27
|
+
setStatus(key: string, text: string | undefined): void;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type ExecCall = { command: string; args: string[] };
|
|
32
|
+
|
|
33
|
+
function createFakePi(execCalls: ExecCall[] = []) {
|
|
34
|
+
const commands: Registered[] = [];
|
|
35
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionTestContext) => void | Promise<unknown>>();
|
|
36
|
+
return {
|
|
37
|
+
commands,
|
|
38
|
+
handlers,
|
|
39
|
+
pi: {
|
|
40
|
+
registerCommand(name: string, options: Omit<Registered, "name">) {
|
|
41
|
+
commands.push({ name, ...options });
|
|
42
|
+
},
|
|
43
|
+
on(name: string, handler: (event: unknown, ctx: ExtensionTestContext) => void | Promise<unknown>) {
|
|
44
|
+
handlers.set(name, handler);
|
|
45
|
+
},
|
|
46
|
+
sendUserMessage: vi.fn(),
|
|
47
|
+
exec: async (command: string, args: string[]) => {
|
|
48
|
+
execCalls.push({ command, args });
|
|
49
|
+
return { stdout: "pi-remote-control is running (pid 1234)\n", stderr: "", code: 0, killed: false };
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createContext() {
|
|
56
|
+
const notifications: Array<{ message: string; type?: "info" | "warning" | "error" }> = [];
|
|
57
|
+
const statuses: Array<{ key: string; text: string | undefined }> = [];
|
|
58
|
+
return {
|
|
59
|
+
statuses,
|
|
60
|
+
notifications,
|
|
61
|
+
ctx: {
|
|
62
|
+
cwd: "/repo/example",
|
|
63
|
+
sessionManager: {
|
|
64
|
+
getSessionId: () => "pi_1",
|
|
65
|
+
getSessionFile: () => "/tmp/session.jsonl",
|
|
66
|
+
getSessionName: () => "Fix bug",
|
|
67
|
+
getEntries: () => [{}, {}],
|
|
68
|
+
},
|
|
69
|
+
model: { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", contextWindow: 200000, maxTokens: 8192, reasoning: true },
|
|
70
|
+
getContextUsage: () => ({ tokens: 65000, contextWindow: 200000, percent: 32.5 }),
|
|
71
|
+
isIdle: () => true,
|
|
72
|
+
ui: {
|
|
73
|
+
theme: { fg: (color: string, text: string) => (color === "success" ? `\u001b[32m${text}\u001b[39m` : text) },
|
|
74
|
+
notify(message: string, type?: "info" | "warning" | "error") {
|
|
75
|
+
notifications.push({ message, type });
|
|
76
|
+
},
|
|
77
|
+
setStatus(key: string, text: string | undefined) {
|
|
78
|
+
statuses.push({ key, text });
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
vi.stubEnv("PI_REMOTE_CONTROL_LOCAL_URL", "http://127.0.0.1:17373");
|
|
87
|
+
vi.stubEnv("PI_REMOTE_CONTROL_DEV_TOKEN", "test-token");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
vi.useRealTimers();
|
|
92
|
+
vi.unstubAllGlobals();
|
|
93
|
+
vi.unstubAllEnvs();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("remote control extension", () => {
|
|
97
|
+
it("collects runtime status from the live Pi context", () => {
|
|
98
|
+
const ctx = {
|
|
99
|
+
model: { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", contextWindow: 200000, maxTokens: 8192, reasoning: true },
|
|
100
|
+
getContextUsage: () => ({ tokens: 65000, contextWindow: 200000, percent: 32.5 }),
|
|
101
|
+
sessionManager: {
|
|
102
|
+
entries: [
|
|
103
|
+
{ type: "message", message: { role: "user", content: "hello" } },
|
|
104
|
+
{ type: "message", message: { role: "assistant" }, usage: { input: 12, output: 3, cacheRead: 50, cacheWrite: 10, cost: { input: 0.036, output: 0.045, cacheRead: 0.015, cacheWrite: 0.0375, total: 0.1335 } } },
|
|
105
|
+
],
|
|
106
|
+
getEntries() {
|
|
107
|
+
return this.entries;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const pi = { getThinkingLevel: () => "medium" };
|
|
112
|
+
|
|
113
|
+
expect(collectRuntimeStatus(pi, ctx, () => new Date("2026-05-09T09:47:00.000Z"))).toEqual({
|
|
114
|
+
model: { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", contextWindow: 200000, maxTokens: 8192, reasoning: true },
|
|
115
|
+
thinkingLevel: "medium",
|
|
116
|
+
usage: { input: 12, output: 3, cacheRead: 50, cacheWrite: 10, cost: { input: 0.036, output: 0.045, cacheRead: 0.015, cacheWrite: 0.0375, total: 0.1335 } },
|
|
117
|
+
context: { tokens: 65000, contextWindow: 200000, percent: 32.5 },
|
|
118
|
+
updatedAt: "2026-05-09T09:47:00.000Z",
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("registers remote-control commands", () => {
|
|
123
|
+
const { pi, commands } = createFakePi();
|
|
124
|
+
|
|
125
|
+
remoteControlExtension(pi as never);
|
|
126
|
+
|
|
127
|
+
expect(commands.map((command) => command.name)).toEqual(["remote-control", "remote-control-pair"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("starts daemon if needed and registers the current TUI session", async () => {
|
|
131
|
+
const commands: Registered[] = [];
|
|
132
|
+
const execCalls: ExecCall[] = [];
|
|
133
|
+
const fetchCalls: unknown[] = [];
|
|
134
|
+
const { ctx, notifications } = createContext();
|
|
135
|
+
const pi = {
|
|
136
|
+
registerCommand(name: string, options: Omit<Registered, "name">) {
|
|
137
|
+
commands.push({ name, ...options });
|
|
138
|
+
},
|
|
139
|
+
on: vi.fn(),
|
|
140
|
+
sendUserMessage: vi.fn(),
|
|
141
|
+
exec: async (command: string, args: string[]) => {
|
|
142
|
+
execCalls.push({ command, args });
|
|
143
|
+
if (args.includes("status")) return { stdout: "pi-remote-control is stopped\n", stderr: "", code: 1, killed: false };
|
|
144
|
+
return { stdout: "", stderr: "", code: 0, killed: false };
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
const healthOutcomes = [new TypeError("fetch failed"), new Response(JSON.stringify({ status: "ok" }), { status: 200 })];
|
|
148
|
+
vi.stubGlobal(
|
|
149
|
+
"fetch",
|
|
150
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
151
|
+
if (url.endsWith("/v1/health")) {
|
|
152
|
+
fetchCalls.push({ url, init: undefined });
|
|
153
|
+
const outcome = healthOutcomes.shift();
|
|
154
|
+
if (outcome instanceof Error) throw outcome;
|
|
155
|
+
return outcome!;
|
|
156
|
+
}
|
|
157
|
+
fetchCalls.push({ url, init: { method: init?.method, body: JSON.parse(String(init?.body)) } });
|
|
158
|
+
return new Response(JSON.stringify({ session: { id: "sess_pi_1" } }), { status: 200 });
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
remoteControlExtension(pi as never);
|
|
162
|
+
|
|
163
|
+
await commands.find((command) => command.name === "remote-control")!.handler("", ctx);
|
|
164
|
+
|
|
165
|
+
expect(execCalls[0]).toEqual({ command: process.execPath, args: [expect.stringContaining("src/cli-runner.cjs"), "status"] });
|
|
166
|
+
expect(execCalls[1]?.command).toBe("sh");
|
|
167
|
+
expect(execCalls[1]?.args[1]).toContain("nohup");
|
|
168
|
+
expect(fetchCalls).toEqual([
|
|
169
|
+
{ url: "http://127.0.0.1:17373/v1/health", init: undefined },
|
|
170
|
+
{ url: "http://127.0.0.1:17373/v1/health", init: undefined },
|
|
171
|
+
{
|
|
172
|
+
url: "http://127.0.0.1:17373/v1/tui/sessions",
|
|
173
|
+
init: {
|
|
174
|
+
method: "POST",
|
|
175
|
+
body: expect.objectContaining({
|
|
176
|
+
id: "sess_pi_1",
|
|
177
|
+
piSessionId: "pi_1",
|
|
178
|
+
sessionFile: "/tmp/session.jsonl",
|
|
179
|
+
runtimeStatus: expect.objectContaining({ model: expect.objectContaining({ id: "claude-sonnet-4-5" }), context: expect.objectContaining({ percent: 32.5 }) }),
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
]);
|
|
184
|
+
expect(notifications.at(-1)).toEqual({ message: "Remote control enabled for this session", type: "info" });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("reports startup readiness failure without throwing", async () => {
|
|
188
|
+
const commands: Registered[] = [];
|
|
189
|
+
const { ctx, notifications } = createContext();
|
|
190
|
+
const pi = {
|
|
191
|
+
registerCommand(name: string, options: Omit<Registered, "name">) {
|
|
192
|
+
commands.push({ name, ...options });
|
|
193
|
+
},
|
|
194
|
+
on: vi.fn(),
|
|
195
|
+
sendUserMessage: vi.fn(),
|
|
196
|
+
exec: async (command: string, args: string[]) => {
|
|
197
|
+
if (args.includes("status")) return { stdout: "pi-remote-control is stopped\n", stderr: "", code: 1, killed: false };
|
|
198
|
+
return { stdout: "", stderr: "", code: 0, killed: false };
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
vi.stubEnv("PI_REMOTE_CONTROL_READY_ATTEMPTS", "1");
|
|
202
|
+
vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("fetch failed"); }));
|
|
203
|
+
remoteControlExtension(pi as never);
|
|
204
|
+
|
|
205
|
+
await expect(commands.find((command) => command.name === "remote-control")!.handler("", ctx)).resolves.toBeUndefined();
|
|
206
|
+
|
|
207
|
+
expect(notifications.at(-1)).toEqual({
|
|
208
|
+
message: "pi-remote-control did not become ready; see /tmp/pi-remote-control.log",
|
|
209
|
+
type: "error",
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("reports registration network failures without throwing", async () => {
|
|
214
|
+
const { pi, commands } = createFakePi();
|
|
215
|
+
const { ctx, notifications } = createContext();
|
|
216
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
217
|
+
if (url.endsWith("/v1/health")) return new Response(JSON.stringify({ status: "ok" }), { status: 200 });
|
|
218
|
+
throw new TypeError("fetch failed");
|
|
219
|
+
}));
|
|
220
|
+
remoteControlExtension(pi as never);
|
|
221
|
+
|
|
222
|
+
await expect(commands.find((command) => command.name === "remote-control")!.handler("", ctx)).resolves.toBeUndefined();
|
|
223
|
+
|
|
224
|
+
expect(notifications.at(-1)).toEqual({ message: "Remote control enable failed: fetch failed", type: "error" });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("uses localhost with the configured port for TUI control when no local override is set", async () => {
|
|
228
|
+
const root = await mkdtemp(join(tmpdir(), "pi-remote-control-extension-"));
|
|
229
|
+
try {
|
|
230
|
+
await writeFile(join(root, "config.json"), JSON.stringify({ bindAddress: "100.86.12.34:17373" }));
|
|
231
|
+
vi.unstubAllEnvs();
|
|
232
|
+
vi.stubEnv("PI_REMOTE_CONTROL_DIR", root);
|
|
233
|
+
vi.stubEnv("PI_REMOTE_CONTROL_URL", "https://macbook.tailnet.ts.net:17373");
|
|
234
|
+
const { pi, commands } = createFakePi();
|
|
235
|
+
const { ctx } = createContext();
|
|
236
|
+
const urls: string[] = [];
|
|
237
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
238
|
+
urls.push(url);
|
|
239
|
+
return new Response(JSON.stringify({ session: { id: "sess_pi_1" } }), { status: 200 });
|
|
240
|
+
}));
|
|
241
|
+
remoteControlExtension(pi as never);
|
|
242
|
+
|
|
243
|
+
await commands.find((command) => command.name === "remote-control")!.handler("", ctx);
|
|
244
|
+
|
|
245
|
+
expect(urls.at(-1)).toBe("http://127.0.0.1:17373/v1/tui/sessions");
|
|
246
|
+
} finally {
|
|
247
|
+
await rm(root, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("updates TUI status when toggling remote control", async () => {
|
|
252
|
+
const { pi, commands } = createFakePi();
|
|
253
|
+
const { ctx, statuses } = createContext();
|
|
254
|
+
vi.stubGlobal(
|
|
255
|
+
"fetch",
|
|
256
|
+
vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })),
|
|
257
|
+
);
|
|
258
|
+
remoteControlExtension(pi as never);
|
|
259
|
+
const command = commands.find((registered) => registered.name === "remote-control")!;
|
|
260
|
+
|
|
261
|
+
await command.handler("", ctx);
|
|
262
|
+
await command.handler("", ctx);
|
|
263
|
+
|
|
264
|
+
expect(statuses).toEqual([
|
|
265
|
+
{ key: "remote-control", text: "\u001b[32mRemote Control Active\u001b[39m" },
|
|
266
|
+
{ key: "remote-control", text: undefined },
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("re-registers a locally active TUI session when daemon heartbeat state is missing", async () => {
|
|
271
|
+
vi.useFakeTimers();
|
|
272
|
+
const { pi, commands } = createFakePi();
|
|
273
|
+
const { ctx, statuses } = createContext();
|
|
274
|
+
const fetchCalls: Array<{ url: string; method: string; body?: unknown }> = [];
|
|
275
|
+
vi.stubGlobal(
|
|
276
|
+
"fetch",
|
|
277
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
278
|
+
fetchCalls.push({ url, method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
|
279
|
+
if (url.endsWith("/commands")) return new Response(JSON.stringify({ error: "session_not_found" }), { status: 404 });
|
|
280
|
+
return new Response(JSON.stringify({ session: { id: "sess_pi_1" } }), { status: 200 });
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
remoteControlExtension(pi as never);
|
|
284
|
+
|
|
285
|
+
await commands.find((command) => command.name === "remote-control")!.handler("", ctx);
|
|
286
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
287
|
+
|
|
288
|
+
expect(fetchCalls).toEqual([
|
|
289
|
+
{
|
|
290
|
+
url: "http://127.0.0.1:17373/v1/tui/sessions",
|
|
291
|
+
method: "POST",
|
|
292
|
+
body: expect.objectContaining({ id: "sess_pi_1", piSessionId: "pi_1", sessionFile: "/tmp/session.jsonl" }),
|
|
293
|
+
},
|
|
294
|
+
{ url: "http://127.0.0.1:17373/v1/tui/sessions/sess_pi_1/commands", method: "GET", body: undefined },
|
|
295
|
+
{
|
|
296
|
+
url: "http://127.0.0.1:17373/v1/tui/sessions",
|
|
297
|
+
method: "POST",
|
|
298
|
+
body: expect.objectContaining({ id: "sess_pi_1", piSessionId: "pi_1", sessionFile: "/tmp/session.jsonl" }),
|
|
299
|
+
},
|
|
300
|
+
]);
|
|
301
|
+
expect(statuses).toEqual([{ key: "remote-control", text: "\u001b[32mRemote Control Active\u001b[39m" }]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("clears local active state when heartbeat re-registration fails", async () => {
|
|
305
|
+
vi.useFakeTimers();
|
|
306
|
+
const { pi, commands } = createFakePi();
|
|
307
|
+
const { ctx, statuses, notifications } = createContext();
|
|
308
|
+
const fetchCalls: Array<{ url: string; method: string }> = [];
|
|
309
|
+
let registrationAttempts = 0;
|
|
310
|
+
vi.stubGlobal(
|
|
311
|
+
"fetch",
|
|
312
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
313
|
+
fetchCalls.push({ url, method: init?.method ?? "GET" });
|
|
314
|
+
if (url.endsWith("/commands")) return new Response(JSON.stringify({ error: "session_not_found" }), { status: 404 });
|
|
315
|
+
registrationAttempts += 1;
|
|
316
|
+
return new Response(JSON.stringify({ session: { id: "sess_pi_1" } }), { status: registrationAttempts === 1 ? 200 : 503 });
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
remoteControlExtension(pi as never);
|
|
320
|
+
|
|
321
|
+
await commands.find((command) => command.name === "remote-control")!.handler("", ctx);
|
|
322
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
323
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
324
|
+
|
|
325
|
+
expect(statuses).toEqual([
|
|
326
|
+
{ key: "remote-control", text: "\u001b[32mRemote Control Active\u001b[39m" },
|
|
327
|
+
{ key: "remote-control", text: undefined },
|
|
328
|
+
]);
|
|
329
|
+
expect(notifications.at(-1)).toEqual({ message: "Remote control disconnected; run /remote-control to re-enable", type: "warning" });
|
|
330
|
+
expect(fetchCalls.filter((call) => call.url.endsWith("/commands"))).toHaveLength(1);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("clears active status and unregisters on session shutdown", async () => {
|
|
334
|
+
const { pi, commands, handlers } = createFakePi();
|
|
335
|
+
const { ctx, statuses } = createContext();
|
|
336
|
+
const fetchCalls: unknown[] = [];
|
|
337
|
+
vi.stubGlobal(
|
|
338
|
+
"fetch",
|
|
339
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
340
|
+
fetchCalls.push({ url, init: { method: init?.method } });
|
|
341
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
remoteControlExtension(pi as never);
|
|
345
|
+
await commands.find((command) => command.name === "remote-control")!.handler("", ctx);
|
|
346
|
+
|
|
347
|
+
await handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, ctx);
|
|
348
|
+
|
|
349
|
+
expect(fetchCalls.at(-1)).toEqual({
|
|
350
|
+
url: "http://127.0.0.1:17373/v1/tui/sessions/sess_pi_1",
|
|
351
|
+
init: { method: "DELETE" },
|
|
352
|
+
});
|
|
353
|
+
expect(statuses.at(-1)).toEqual({ key: "remote-control", text: undefined });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("awaits proactive deactivation before session shutdown completes", async () => {
|
|
357
|
+
const { pi, commands, handlers } = createFakePi();
|
|
358
|
+
const { ctx } = createContext();
|
|
359
|
+
let resolveDelete: (() => void) | undefined;
|
|
360
|
+
const fetchCalls: string[] = [];
|
|
361
|
+
vi.stubGlobal(
|
|
362
|
+
"fetch",
|
|
363
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
364
|
+
fetchCalls.push(`${init?.method ?? "GET"} ${url}`);
|
|
365
|
+
if (init?.method === "DELETE") await new Promise<void>((resolve) => { resolveDelete = resolve; });
|
|
366
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
remoteControlExtension(pi as never);
|
|
370
|
+
await commands.find((command) => command.name === "remote-control")!.handler("", ctx);
|
|
371
|
+
|
|
372
|
+
let completed = false;
|
|
373
|
+
const shutdownPromise = Promise.resolve(handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, ctx)).then(() => { completed = true; });
|
|
374
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
375
|
+
|
|
376
|
+
expect(fetchCalls.at(-1)).toBe("DELETE http://127.0.0.1:17373/v1/tui/sessions/sess_pi_1");
|
|
377
|
+
expect(completed).toBe(false);
|
|
378
|
+
resolveDelete?.();
|
|
379
|
+
await shutdownPromise;
|
|
380
|
+
expect(completed).toBe(true);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("deactivates local remote-control state on session start", async () => {
|
|
384
|
+
const { pi, commands, handlers } = createFakePi();
|
|
385
|
+
const { ctx, statuses } = createContext();
|
|
386
|
+
const fetchCalls: Array<{ url: string; init: { method?: string; body?: unknown } }> = [];
|
|
387
|
+
vi.stubGlobal(
|
|
388
|
+
"fetch",
|
|
389
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
390
|
+
fetchCalls.push({ url, init: { method: init?.method, body: init?.body ? JSON.parse(String(init.body)) : undefined } });
|
|
391
|
+
return new Response(JSON.stringify({ session: { id: "sess_pi_1" } }), { status: 200 });
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
remoteControlExtension(pi as never);
|
|
395
|
+
await commands.find((command) => command.name === "remote-control")!.handler("", ctx);
|
|
396
|
+
|
|
397
|
+
handlers.get("session_start")?.({ type: "session_start", reason: "resume" }, ctx);
|
|
398
|
+
handlers.get("message_start")?.({ type: "message_start", message: { id: "msg_1", role: "user" } }, ctx);
|
|
399
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
400
|
+
|
|
401
|
+
expect(statuses.at(-1)).toEqual({ key: "remote-control", text: undefined });
|
|
402
|
+
expect(fetchCalls).toHaveLength(1);
|
|
403
|
+
expect(fetchCalls[0]?.url).toBe("http://127.0.0.1:17373/v1/tui/sessions");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("does not automatically re-enable remote control on resumed sessions", async () => {
|
|
407
|
+
const { pi, handlers } = createFakePi();
|
|
408
|
+
const { ctx, statuses } = createContext();
|
|
409
|
+
const fetch = vi.fn(async () => new Response(JSON.stringify({ session: { id: "sess_pi_1" } }), { status: 200 }));
|
|
410
|
+
vi.stubGlobal("fetch", fetch);
|
|
411
|
+
remoteControlExtension(pi as never);
|
|
412
|
+
|
|
413
|
+
handlers.get("session_start")?.({ type: "session_start", reason: "resume" }, ctx);
|
|
414
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
415
|
+
|
|
416
|
+
expect(statuses).toEqual([{ key: "remote-control", text: undefined }]);
|
|
417
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("toggles off an already registered TUI session", async () => {
|
|
421
|
+
const { pi, commands } = createFakePi();
|
|
422
|
+
const { ctx, notifications } = createContext();
|
|
423
|
+
const fetchCalls: unknown[] = [];
|
|
424
|
+
vi.stubGlobal(
|
|
425
|
+
"fetch",
|
|
426
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
427
|
+
fetchCalls.push({ url, init: { method: init.method } });
|
|
428
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
remoteControlExtension(pi as never);
|
|
432
|
+
const command = commands.find((registered) => registered.name === "remote-control")!;
|
|
433
|
+
|
|
434
|
+
await command.handler("", ctx);
|
|
435
|
+
await command.handler("", ctx);
|
|
436
|
+
|
|
437
|
+
expect(fetchCalls.at(-1)).toEqual({ url: "http://127.0.0.1:17373/v1/tui/sessions/sess_pi_1", init: { method: "DELETE" } });
|
|
438
|
+
expect(notifications.at(-1)).toEqual({ message: "Remote control disabled for this session", type: "info" });
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("enriches message events with stable session entry ids before daemon normalization", () => {
|
|
442
|
+
const event = { type: "message_update", message: { role: "assistant", timestamp: 1778284801000, content: [{ type: "text", text: "hello" }] }, assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "hello" } };
|
|
443
|
+
const ctx = {
|
|
444
|
+
sessionManager: {
|
|
445
|
+
getEntries: () => [
|
|
446
|
+
{ type: "message", id: "msg_1", timestamp: "2026-05-09T00:00:01.000Z", message: { role: "assistant", timestamp: 1778284801000, content: [{ type: "text", text: "hello" }] } },
|
|
447
|
+
],
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
expect(enrichTuiEventForDaemon(event, ctx as never)).toEqual({
|
|
452
|
+
...event,
|
|
453
|
+
id: "msg_1",
|
|
454
|
+
timestamp: "2026-05-09T00:00:01.000Z",
|
|
455
|
+
message: { ...event.message, id: "msg_1" },
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("forwards turn lifecycle events while remote control is active", async () => {
|
|
460
|
+
const { pi, commands, handlers } = createFakePi();
|
|
461
|
+
const { ctx } = createContext();
|
|
462
|
+
const fetchCalls: unknown[] = [];
|
|
463
|
+
vi.stubGlobal(
|
|
464
|
+
"fetch",
|
|
465
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
466
|
+
fetchCalls.push({ url, init: { method: init.method, body: init.body ? JSON.parse(String(init.body)) : undefined } });
|
|
467
|
+
return new Response(JSON.stringify({ session: { id: "sess_pi_1" } }), { status: 200 });
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
remoteControlExtension(pi as never);
|
|
471
|
+
await commands.find((command) => command.name === "remote-control")!.handler("", ctx);
|
|
472
|
+
|
|
473
|
+
handlers.get("turn_start")?.({ type: "turn_start", turnIndex: 0, timestamp: 1778284801000 }, ctx);
|
|
474
|
+
handlers.get("turn_end")?.({ type: "turn_end", turnIndex: 0, message: { role: "assistant", content: [] }, toolResults: [] }, ctx);
|
|
475
|
+
await vi.waitFor(() => expect(fetchCalls).toHaveLength(3));
|
|
476
|
+
|
|
477
|
+
expect(fetchCalls.slice(1)).toEqual([
|
|
478
|
+
{
|
|
479
|
+
url: "http://127.0.0.1:17373/v1/tui/sessions/sess_pi_1/events",
|
|
480
|
+
init: { method: "POST", body: { type: "turn_start", turnIndex: 0, timestamp: 1778284801000 } },
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
url: "http://127.0.0.1:17373/v1/tui/sessions/sess_pi_1/events",
|
|
484
|
+
init: { method: "POST", body: { type: "turn_end", turnIndex: 0, message: { role: "assistant", content: [] }, toolResults: [] } },
|
|
485
|
+
},
|
|
486
|
+
]);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("forwards runtime status changes while remote control is active", async () => {
|
|
490
|
+
const { pi, commands, handlers } = createFakePi();
|
|
491
|
+
const entries = [{ type: "message", message: { role: "assistant" }, usage: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } } }];
|
|
492
|
+
const { ctx } = createContext();
|
|
493
|
+
ctx.sessionManager.getEntries = () => entries;
|
|
494
|
+
const fetchCalls: unknown[] = [];
|
|
495
|
+
vi.stubGlobal(
|
|
496
|
+
"fetch",
|
|
497
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
498
|
+
fetchCalls.push({ url, init: { method: init.method, body: init.body ? JSON.parse(String(init.body)) : undefined } });
|
|
499
|
+
return new Response(JSON.stringify({ session: { id: "sess_pi_1" } }), { status: 200 });
|
|
500
|
+
}),
|
|
501
|
+
);
|
|
502
|
+
remoteControlExtension(pi as never);
|
|
503
|
+
await commands.find((command) => command.name === "remote-control")!.handler("", ctx);
|
|
504
|
+
|
|
505
|
+
entries.push({ type: "message", message: { role: "assistant" }, usage: { input: 2, output: 3, cacheRead: 0, cacheWrite: 0, cost: { total: 0.02 } } });
|
|
506
|
+
handlers.get("message_end")?.({ type: "message_end", message: { id: "msg_1", role: "assistant" } }, ctx);
|
|
507
|
+
await vi.waitFor(() => expect(fetchCalls).toContainEqual(expect.objectContaining({
|
|
508
|
+
url: "http://127.0.0.1:17373/v1/tui/sessions/sess_pi_1/events",
|
|
509
|
+
init: expect.objectContaining({ method: "POST", body: expect.objectContaining({ type: "runtime_status", status: expect.objectContaining({ usage: expect.objectContaining({ input: 3, output: 3 }) }) }) }),
|
|
510
|
+
})));
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("forwards TUI events while remote control is active", async () => {
|
|
514
|
+
const { pi, commands, handlers } = createFakePi();
|
|
515
|
+
const { ctx } = createContext();
|
|
516
|
+
const fetchCalls: unknown[] = [];
|
|
517
|
+
vi.stubGlobal(
|
|
518
|
+
"fetch",
|
|
519
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
520
|
+
fetchCalls.push({ url, init: { method: init.method, body: init.body ? JSON.parse(String(init.body)) : undefined } });
|
|
521
|
+
return new Response(JSON.stringify({ session: { id: "sess_pi_1" } }), { status: 200 });
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
remoteControlExtension(pi as never);
|
|
525
|
+
await commands.find((command) => command.name === "remote-control")!.handler("", ctx);
|
|
526
|
+
|
|
527
|
+
handlers.get("message_start")?.({ type: "message_start", message: { id: "msg_1", role: "user" } }, ctx);
|
|
528
|
+
await vi.waitFor(() => expect(fetchCalls.at(-1)).toMatchObject({ url: "http://127.0.0.1:17373/v1/tui/sessions/sess_pi_1/events" }));
|
|
529
|
+
|
|
530
|
+
expect(fetchCalls.at(-1)).toEqual({
|
|
531
|
+
url: "http://127.0.0.1:17373/v1/tui/sessions/sess_pi_1/events",
|
|
532
|
+
init: { method: "POST", body: { type: "message_start", message: { id: "msg_1", role: "user" } } },
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("applies queued remote prompt and abort commands to the TUI runtime", () => {
|
|
537
|
+
const sendUserMessage = vi.fn();
|
|
538
|
+
const abort = vi.fn();
|
|
539
|
+
const compact = vi.fn();
|
|
540
|
+
handleRemoteCommand({ sendUserMessage } as never, { abort, compact, isIdle: () => true } as never, {
|
|
541
|
+
type: "remote_prompt",
|
|
542
|
+
requestId: "req_1",
|
|
543
|
+
text: "hello",
|
|
544
|
+
streamingBehavior: "followUp",
|
|
545
|
+
});
|
|
546
|
+
handleRemoteCommand({ sendUserMessage } as never, { abort, compact, isIdle: () => true } as never, { type: "remote_abort", requestId: "req_2" });
|
|
547
|
+
|
|
548
|
+
expect(sendUserMessage).toHaveBeenCalledWith("hello", { deliverAs: "followUp" });
|
|
549
|
+
expect(abort).toHaveBeenCalledOnce();
|
|
550
|
+
expect(compact).not.toHaveBeenCalled();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("defaults remote prompts without streamingBehavior to followUp while the TUI is busy", () => {
|
|
554
|
+
const sendUserMessage = vi.fn();
|
|
555
|
+
|
|
556
|
+
handleRemoteCommand({ sendUserMessage } as never, { abort: vi.fn(), compact: vi.fn(), isIdle: () => false } as never, {
|
|
557
|
+
type: "remote_prompt",
|
|
558
|
+
requestId: "req_1",
|
|
559
|
+
text: "hello while busy",
|
|
560
|
+
streamingBehavior: null,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
expect(sendUserMessage).toHaveBeenCalledWith("hello while busy", { deliverAs: "followUp" });
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("posts remote compact success and failure results from TUI callbacks", async () => {
|
|
567
|
+
const fetchCalls: unknown[] = [];
|
|
568
|
+
vi.stubGlobal(
|
|
569
|
+
"fetch",
|
|
570
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
571
|
+
fetchCalls.push({ url, init: { method: init?.method, body: init?.body ? JSON.parse(String(init.body)) : undefined } });
|
|
572
|
+
return new Response(JSON.stringify({ accepted: true }), { status: 200 });
|
|
573
|
+
}),
|
|
574
|
+
);
|
|
575
|
+
const compact = vi.fn();
|
|
576
|
+
|
|
577
|
+
handleRemoteCommand({ sendUserMessage: vi.fn() } as never, { abort: vi.fn(), compact } as never, { type: "remote_compact", requestId: "req_3" }, "sess_pi_1");
|
|
578
|
+
const options = compact.mock.calls[0]?.[0] as {
|
|
579
|
+
onComplete(result: unknown): void;
|
|
580
|
+
onError(error: Error): void;
|
|
581
|
+
};
|
|
582
|
+
options.onComplete({ summary: "Summary", firstKeptEntryId: "entry_1", tokensBefore: 12345 });
|
|
583
|
+
options.onError(new Error("No compaction needed"));
|
|
584
|
+
|
|
585
|
+
await vi.waitFor(() => expect(fetchCalls).toHaveLength(2));
|
|
586
|
+
expect(fetchCalls).toEqual([
|
|
587
|
+
{
|
|
588
|
+
url: "http://127.0.0.1:17373/v1/tui/sessions/sess_pi_1/events",
|
|
589
|
+
init: { method: "POST", body: { type: "remote_compact_result", requestId: "req_3", ok: true, summary: "Summary", firstKeptEntryId: "entry_1", tokensBefore: 12345 } },
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
url: "http://127.0.0.1:17373/v1/tui/sessions/sess_pi_1/events",
|
|
593
|
+
init: { method: "POST", body: { type: "remote_compact_result", requestId: "req_3", ok: false, message: "No compaction needed" } },
|
|
594
|
+
},
|
|
595
|
+
]);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("runs local pairing command from remote-control-pair", async () => {
|
|
599
|
+
const execCalls: ExecCall[] = [];
|
|
600
|
+
const { pi, commands } = createFakePi(execCalls);
|
|
601
|
+
const { ctx, notifications } = createContext();
|
|
602
|
+
pi.exec = async (command: string, args: string[]) => {
|
|
603
|
+
execCalls.push({ command, args });
|
|
604
|
+
if (args.includes("pair")) return { stdout: "Pairing link: pi-remote://pair?...\n", stderr: "", code: 0, killed: false };
|
|
605
|
+
return { stdout: "pi-remote-control is running (pid 1234)\n", stderr: "", code: 0, killed: false };
|
|
606
|
+
};
|
|
607
|
+
remoteControlExtension(pi as never);
|
|
608
|
+
|
|
609
|
+
await commands.find((command) => command.name === "remote-control-pair")!.handler("", ctx);
|
|
610
|
+
|
|
611
|
+
expect(execCalls).toEqual([
|
|
612
|
+
{ command: process.execPath, args: [expect.stringContaining("src/cli-runner.cjs"), "status"] },
|
|
613
|
+
{ command: process.execPath, args: [expect.stringContaining("src/cli-runner.cjs"), "pair"] },
|
|
614
|
+
]);
|
|
615
|
+
expect(notifications).toEqual([{ message: "Pairing link: pi-remote://pair?...", type: "info" }]);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { acquireDaemonLock } from "../src/lock.js";
|
|
6
|
+
|
|
7
|
+
describe("daemon lock", () => {
|
|
8
|
+
it("creates and releases daemon.lock", async () => {
|
|
9
|
+
const root = await mkdtemp(join(tmpdir(), "pi-remote-control-lock-"));
|
|
10
|
+
try {
|
|
11
|
+
const lock = await acquireDaemonLock(root, 1234);
|
|
12
|
+
|
|
13
|
+
expect(lock?.path).toBe(join(root, "daemon.lock"));
|
|
14
|
+
await expect(readFile(join(root, "daemon.lock"), "utf8")).resolves.toBe("1234\n");
|
|
15
|
+
|
|
16
|
+
await lock!.release();
|
|
17
|
+
await expect(readFile(join(root, "daemon.lock"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
|
18
|
+
} finally {
|
|
19
|
+
await rm(root, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns undefined when daemon.lock already exists", async () => {
|
|
24
|
+
const root = await mkdtemp(join(tmpdir(), "pi-remote-control-lock-"));
|
|
25
|
+
try {
|
|
26
|
+
const first = await acquireDaemonLock(root, 1234);
|
|
27
|
+
const second = await acquireDaemonLock(root, 5678);
|
|
28
|
+
|
|
29
|
+
expect(second).toBeUndefined();
|
|
30
|
+
await expect(readFile(join(root, "daemon.lock"), "utf8")).resolves.toBe("1234\n");
|
|
31
|
+
await first!.release();
|
|
32
|
+
} finally {
|
|
33
|
+
await rm(root, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildPairingLink } from "../src/pairing-link.js";
|
|
3
|
+
|
|
4
|
+
describe("pairing links", () => {
|
|
5
|
+
it("encodes advertised daemon URL, pair code, and expiry", () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildPairingLink({
|
|
8
|
+
advertisedBaseUrl: "https://macbook.tailnet.ts.net:17373",
|
|
9
|
+
pairCode: "123456",
|
|
10
|
+
expiresAt: "2026-05-09T09:52:00.000Z",
|
|
11
|
+
}),
|
|
12
|
+
).toBe(
|
|
13
|
+
"pi-remote://pair?baseUrl=https%3A%2F%2Fmacbook.tailnet.ts.net%3A17373&code=123456&expiresAt=2026-05-09T09%3A52%3A00.000Z",
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("requires an advertised daemon URL", () => {
|
|
18
|
+
expect(() =>
|
|
19
|
+
buildPairingLink({
|
|
20
|
+
advertisedBaseUrl: undefined,
|
|
21
|
+
pairCode: "123456",
|
|
22
|
+
expiresAt: "2026-05-09T09:52:00.000Z",
|
|
23
|
+
}),
|
|
24
|
+
).toThrow("advertisedBaseUrl is required for QR pairing");
|
|
25
|
+
});
|
|
26
|
+
});
|