@tikoci/rosetta 0.8.8 → 0.8.10

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.
@@ -0,0 +1,201 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+ import {
4
+ compareRouterOsVersions,
5
+ computeActiveChannelHeads,
6
+ gcSchemaNodePresence,
7
+ } from "./gc-versions.ts";
8
+
9
+ function createTestDb(): Database {
10
+ const db = new Database(":memory:");
11
+ db.run("PRAGMA foreign_keys=ON;");
12
+
13
+ db.run(`CREATE TABLE ros_versions (
14
+ version TEXT NOT NULL,
15
+ arch TEXT NOT NULL DEFAULT 'x86',
16
+ channel TEXT,
17
+ extra_packages INTEGER NOT NULL DEFAULT 0,
18
+ extracted_at TEXT NOT NULL,
19
+ PRIMARY KEY (version, arch)
20
+ );`);
21
+
22
+ db.run(`CREATE TABLE schema_nodes (
23
+ id INTEGER PRIMARY KEY,
24
+ path TEXT NOT NULL,
25
+ name TEXT NOT NULL,
26
+ type TEXT NOT NULL,
27
+ UNIQUE(path, type)
28
+ );`);
29
+
30
+ db.run(`CREATE TABLE schema_node_presence (
31
+ node_id INTEGER NOT NULL REFERENCES schema_nodes(id),
32
+ version TEXT NOT NULL,
33
+ PRIMARY KEY (node_id, version)
34
+ );`);
35
+
36
+ db.run(`CREATE TABLE command_versions (
37
+ command_path TEXT NOT NULL,
38
+ ros_version TEXT NOT NULL,
39
+ PRIMARY KEY (command_path, ros_version)
40
+ );`);
41
+
42
+ for (let i = 1; i <= 3; i++) {
43
+ db.run("INSERT INTO schema_nodes (id, path, name, type) VALUES (?, ?, ?, 'cmd')", [
44
+ i,
45
+ `/node-${i}`,
46
+ `node-${i}`,
47
+ ]);
48
+ }
49
+
50
+ return db;
51
+ }
52
+
53
+ function insertVersion(db: Database, version: string, channel: string | null, arch = "x86"): void {
54
+ db.run(
55
+ `INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
56
+ VALUES (?, ?, ?, 1, '2026-04-26T00:00:00Z')`,
57
+ [version, arch, channel],
58
+ );
59
+ }
60
+
61
+ function insertPresence(db: Database, version: string, nodeIds = [1, 2]): void {
62
+ for (const nodeId of nodeIds) {
63
+ db.run("INSERT INTO schema_node_presence (node_id, version) VALUES (?, ?)", [nodeId, version]);
64
+ }
65
+ }
66
+
67
+ function insertCommandVersion(db: Database, version: string): void {
68
+ db.run("INSERT INTO command_versions (command_path, ros_version) VALUES (?, ?)", ["/ip/address/add", version]);
69
+ }
70
+
71
+ function count(db: Database, sql: string): number {
72
+ return (db.prepare(sql).get() as { c: number }).c;
73
+ }
74
+
75
+ describe("RouterOS semantic version sorting", () => {
76
+ test("sorts numeric releases and prereleases semantically", () => {
77
+ const versions = ["7.23", "7.9", "7.23rc1", "7.10", "7.23beta2", "7.23beta10", "7.22.1"];
78
+
79
+ expect(versions.sort(compareRouterOsVersions)).toEqual([
80
+ "7.9",
81
+ "7.10",
82
+ "7.22.1",
83
+ "7.23beta2",
84
+ "7.23beta10",
85
+ "7.23rc1",
86
+ "7.23",
87
+ ]);
88
+ });
89
+ });
90
+
91
+ describe("computeActiveChannelHeads", () => {
92
+ test("keeps the newest version per recognized active channel", () => {
93
+ const heads = computeActiveChannelHeads([
94
+ { version: "7.9", channel: "stable" },
95
+ { version: "7.10", channel: "stable" },
96
+ { version: "7.22", channel: "long-term" },
97
+ { version: "7.23beta2", channel: "testing" },
98
+ { version: "7.23rc1", channel: "testing" },
99
+ { version: "7.24beta1", channel: "development" },
100
+ { version: "9.99", channel: "legacy" },
101
+ { version: "10.0", channel: null },
102
+ ]);
103
+
104
+ expect(heads).toEqual({
105
+ stable: "7.10",
106
+ "long-term": "7.22",
107
+ testing: "7.23rc1",
108
+ development: "7.24beta1",
109
+ });
110
+ });
111
+ });
112
+
113
+ describe("gcSchemaNodePresence", () => {
114
+ test("dry-run reports candidates without mutating schema_node_presence", () => {
115
+ const db = createTestDb();
116
+ try {
117
+ insertVersion(db, "7.9", "stable");
118
+ insertVersion(db, "7.10", "stable");
119
+ insertPresence(db, "7.9");
120
+ insertPresence(db, "7.10");
121
+
122
+ const stats = gcSchemaNodePresence(db, { dryRun: true });
123
+
124
+ expect(stats.dry_run).toBe(true);
125
+ expect(stats.before_count).toBe(4);
126
+ expect(stats.after_count).toBe(4);
127
+ expect(stats.deleted_rows).toBe(0);
128
+ expect(stats.would_delete_rows).toBe(2);
129
+ expect(stats.kept_versions).toEqual(["7.10"]);
130
+ expect(stats.pruned_versions).toEqual(["7.9"]);
131
+ expect(count(db, "SELECT COUNT(*) AS c FROM schema_node_presence")).toBe(4);
132
+ } finally {
133
+ db.close();
134
+ }
135
+ });
136
+
137
+ test("de-duplicates ros_versions by version when arches differ", () => {
138
+ const db = createTestDb();
139
+ try {
140
+ insertVersion(db, "7.21", "stable", "x86");
141
+ insertVersion(db, "7.22", "stable", "x86");
142
+ insertVersion(db, "7.22", "stable", "arm64");
143
+ insertPresence(db, "7.21");
144
+ insertPresence(db, "7.22");
145
+
146
+ const stats = gcSchemaNodePresence(db);
147
+
148
+ expect(stats.kept_versions).toEqual(["7.22"]);
149
+ expect(stats.pruned_versions).toEqual(["7.21"]);
150
+ expect(stats.deleted_rows).toBe(2);
151
+ expect(count(db, "SELECT COUNT(*) AS c FROM schema_node_presence WHERE version = '7.22'")).toBe(2);
152
+ expect(count(db, "SELECT COUNT(*) AS c FROM schema_node_presence WHERE version = '7.21'")).toBe(0);
153
+ } finally {
154
+ db.close();
155
+ }
156
+ });
157
+
158
+ test("skips safely when no recognized channel heads can be computed", () => {
159
+ const db = createTestDb();
160
+ try {
161
+ insertVersion(db, "7.9", "legacy");
162
+ insertVersion(db, "7.10", null);
163
+ insertPresence(db, "7.9");
164
+ insertPresence(db, "7.10");
165
+
166
+ const stats = gcSchemaNodePresence(db);
167
+
168
+ expect(stats.skipped).toBe(true);
169
+ expect(stats.deleted_rows).toBe(0);
170
+ expect(stats.kept_versions).toEqual([]);
171
+ expect(stats.note).toContain("skipped");
172
+ expect(count(db, "SELECT COUNT(*) AS c FROM schema_node_presence")).toBe(4);
173
+ } finally {
174
+ db.close();
175
+ }
176
+ });
177
+
178
+ test("prunes only schema_node_presence and leaves command_versions untouched", () => {
179
+ const db = createTestDb();
180
+ try {
181
+ insertVersion(db, "7.20", "stable");
182
+ insertVersion(db, "7.22", "stable");
183
+ insertVersion(db, "7.23beta1", "development");
184
+ for (const version of ["7.20", "7.22", "7.23beta1"]) {
185
+ insertPresence(db, version);
186
+ insertCommandVersion(db, version);
187
+ }
188
+
189
+ const stats = gcSchemaNodePresence(db);
190
+
191
+ expect(stats.kept_versions).toEqual(["7.22", "7.23beta1"]);
192
+ expect(stats.pruned_versions).toEqual(["7.20"]);
193
+ expect(stats.deleted_rows).toBe(2);
194
+ expect(count(db, "SELECT COUNT(*) AS c FROM schema_node_presence")).toBe(4);
195
+ expect(count(db, "SELECT COUNT(*) AS c FROM command_versions")).toBe(3);
196
+ expect(count(db, "SELECT COUNT(*) AS c FROM command_versions WHERE ros_version = '7.20'")).toBe(1);
197
+ } finally {
198
+ db.close();
199
+ }
200
+ });
201
+ });
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * gc-versions.ts — prune schema_node_presence to active RouterOS channel heads.
5
+ *
6
+ * The GC is intentionally narrow: it deletes only schema_node_presence rows.
7
+ * command_versions, changelogs, schema_nodes, commands, and ros_versions keep
8
+ * their full extracted history.
9
+ */
10
+
11
+ import type { SQLQueryBindings } from "bun:sqlite";
12
+ import { Database } from "bun:sqlite";
13
+ import { resolveDbPath } from "./paths.ts";
14
+
15
+ export const ACTIVE_CHANNELS = ["stable", "long-term", "testing", "development"] as const;
16
+ export type ActiveChannel = (typeof ACTIVE_CHANNELS)[number];
17
+
18
+ export interface VersionChannelRow {
19
+ version: string;
20
+ channel: string | null;
21
+ }
22
+
23
+ export interface SchemaPresenceGcOptions {
24
+ dryRun?: boolean;
25
+ }
26
+
27
+ export interface SchemaPresenceGcStats {
28
+ before_count: number;
29
+ after_count: number;
30
+ deleted_rows: number;
31
+ would_delete_rows: number;
32
+ kept_versions: string[];
33
+ pruned_versions: string[];
34
+ channel_heads: Partial<Record<ActiveChannel, string>>;
35
+ dry_run: boolean;
36
+ skipped: boolean;
37
+ note?: string;
38
+ }
39
+
40
+ interface ParsedVersion {
41
+ valid: boolean;
42
+ major: number;
43
+ minor: number;
44
+ patch: number;
45
+ phase: number;
46
+ phaseNumber: number;
47
+ }
48
+
49
+ function normalizeChannel(channel: string | null): ActiveChannel | null {
50
+ if (!channel) return null;
51
+ const normalized = channel.trim().toLowerCase();
52
+ return ACTIVE_CHANNELS.includes(normalized as ActiveChannel) ? (normalized as ActiveChannel) : null;
53
+ }
54
+
55
+ function parseRouterOsVersion(version: string): ParsedVersion {
56
+ const match = version.match(/^(\d+)\.(\d+)(?:\.(\d+))?(?:(beta|rc)(\d+))?$/);
57
+ if (!match) {
58
+ return { valid: false, major: 0, minor: 0, patch: 0, phase: -3, phaseNumber: 0 };
59
+ }
60
+
61
+ const phase = match[4] === "beta" ? -2 : match[4] === "rc" ? -1 : 0;
62
+ return {
63
+ valid: true,
64
+ major: Number(match[1]),
65
+ minor: Number(match[2]),
66
+ patch: Number(match[3] ?? 0),
67
+ phase,
68
+ phaseNumber: Number(match[5] ?? 0),
69
+ };
70
+ }
71
+
72
+ /** Compare RouterOS versions semantically: 7.9 < 7.10 < 7.23beta2 < 7.23rc1 < 7.23. */
73
+ export function compareRouterOsVersions(a: string, b: string): number {
74
+ const av = parseRouterOsVersion(a);
75
+ const bv = parseRouterOsVersion(b);
76
+
77
+ if (!av.valid || !bv.valid) {
78
+ if (av.valid !== bv.valid) return av.valid ? 1 : -1;
79
+ return a.localeCompare(b, undefined, { numeric: true });
80
+ }
81
+
82
+ for (const key of ["major", "minor", "patch", "phase", "phaseNumber"] as const) {
83
+ const diff = av[key] - bv[key];
84
+ if (diff !== 0) return diff;
85
+ }
86
+
87
+ return 0;
88
+ }
89
+
90
+ export function computeActiveChannelHeads(rows: VersionChannelRow[]): Partial<Record<ActiveChannel, string>> {
91
+ const heads: Partial<Record<ActiveChannel, string>> = {};
92
+
93
+ for (const row of rows) {
94
+ const channel = normalizeChannel(row.channel);
95
+ if (!channel) continue;
96
+
97
+ const current = heads[channel];
98
+ if (!current || compareRouterOsVersions(row.version, current) > 0) {
99
+ heads[channel] = row.version;
100
+ }
101
+ }
102
+
103
+ return heads;
104
+ }
105
+
106
+ function countRows(db: Database, sql: string, params: SQLQueryBindings[] = []): number {
107
+ return (db.prepare(sql).get(...params) as { c: number }).c;
108
+ }
109
+
110
+ function distinctVersions(db: Database, sql: string): string[] {
111
+ const rows = db.prepare(sql).all() as Array<{ version: string }>;
112
+ return rows.map((row) => row.version).sort(compareRouterOsVersions);
113
+ }
114
+
115
+ function countPresenceVersions(db: Database, versions: string[]): number {
116
+ if (versions.length === 0) return 0;
117
+ const placeholders = versions.map(() => "?").join(", ");
118
+ return countRows(db, `SELECT COUNT(*) AS c FROM schema_node_presence WHERE version IN (${placeholders})`, versions);
119
+ }
120
+
121
+ export function gcSchemaNodePresence(
122
+ db: Database,
123
+ options: SchemaPresenceGcOptions = {},
124
+ ): SchemaPresenceGcStats {
125
+ const dryRun = options.dryRun ?? false;
126
+ const beforeCount = countRows(db, "SELECT COUNT(*) AS c FROM schema_node_presence");
127
+ const versionRows = db.prepare("SELECT DISTINCT version, channel FROM ros_versions").all() as VersionChannelRow[];
128
+ const channelHeads = computeActiveChannelHeads(versionRows);
129
+ const keptVersions = Array.from(new Set(Object.values(channelHeads))).sort(compareRouterOsVersions);
130
+
131
+ if (keptVersions.length === 0) {
132
+ return {
133
+ before_count: beforeCount,
134
+ after_count: beforeCount,
135
+ deleted_rows: 0,
136
+ would_delete_rows: 0,
137
+ kept_versions: [],
138
+ pruned_versions: [],
139
+ channel_heads: channelHeads,
140
+ dry_run: dryRun,
141
+ skipped: true,
142
+ note: "No recognized ros_versions channel heads found; schema_node_presence GC skipped.",
143
+ };
144
+ }
145
+
146
+ const keptSet = new Set(keptVersions);
147
+ const presenceVersions = distinctVersions(db, "SELECT DISTINCT version FROM schema_node_presence");
148
+ const prunedVersions = presenceVersions.filter((version) => !keptSet.has(version));
149
+ const wouldDeleteRows = countPresenceVersions(db, prunedVersions);
150
+
151
+ if (!dryRun && prunedVersions.length > 0) {
152
+ const placeholders = prunedVersions.map(() => "?").join(", ");
153
+ db.run(`DELETE FROM schema_node_presence WHERE version IN (${placeholders})`, prunedVersions);
154
+ }
155
+
156
+ const afterCount = countRows(db, "SELECT COUNT(*) AS c FROM schema_node_presence");
157
+ const deletedRows = dryRun ? 0 : beforeCount - afterCount;
158
+
159
+ return {
160
+ before_count: beforeCount,
161
+ after_count: afterCount,
162
+ deleted_rows: deletedRows,
163
+ would_delete_rows: wouldDeleteRows,
164
+ kept_versions: keptVersions,
165
+ pruned_versions: prunedVersions,
166
+ channel_heads: channelHeads,
167
+ dry_run: dryRun,
168
+ skipped: false,
169
+ note: dryRun ? "Dry run only; schema_node_presence was not modified." : undefined,
170
+ };
171
+ }
172
+
173
+ function getArgValue(name: string): string | undefined {
174
+ const eq = process.argv.find((arg) => arg.startsWith(`${name}=`));
175
+ if (eq) return eq.slice(name.length + 1);
176
+
177
+ const idx = process.argv.indexOf(name);
178
+ return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : undefined;
179
+ }
180
+
181
+ function usage(): string {
182
+ return [
183
+ "Usage: bun run src/gc-versions.ts [--dry-run] [--verbose] [--db <path>]",
184
+ "",
185
+ "Prunes only schema_node_presence to active RouterOS channel heads.",
186
+ "Conservative fallback: if no stable/long-term/testing/development heads exist, nothing is deleted.",
187
+ ].join("\n");
188
+ }
189
+
190
+ function printStats(stats: SchemaPresenceGcStats, verbose: boolean): void {
191
+ const action = stats.dry_run ? "would delete" : "deleted";
192
+ console.log(
193
+ `schema_node_presence GC: before=${stats.before_count} after=${stats.after_count} ${action}=${stats.dry_run ? stats.would_delete_rows : stats.deleted_rows}`,
194
+ );
195
+ console.log(`kept_versions=${stats.kept_versions.length ? stats.kept_versions.join(",") : "(none)"}`);
196
+
197
+ if (stats.note) console.log(`note=${stats.note}`);
198
+
199
+ if (verbose) {
200
+ console.log(`pruned_versions=${stats.pruned_versions.length ? stats.pruned_versions.join(",") : "(none)"}`);
201
+ console.log(`channel_heads=${JSON.stringify(stats.channel_heads)}`);
202
+ }
203
+ }
204
+
205
+ async function main(): Promise<void> {
206
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
207
+ console.log(usage());
208
+ return;
209
+ }
210
+
211
+ const dryRun = process.argv.includes("--dry-run");
212
+ const verbose = process.argv.includes("--verbose");
213
+ const dbPath = getArgValue("--db") ?? resolveDbPath(import.meta.dirname);
214
+ const db = new Database(dbPath);
215
+
216
+ try {
217
+ db.run("PRAGMA foreign_keys=ON;");
218
+ const stats = gcSchemaNodePresence(db, { dryRun });
219
+ printStats(stats, verbose);
220
+ } finally {
221
+ db.close();
222
+ }
223
+ }
224
+
225
+ if (import.meta.main) {
226
+ main().catch((error: unknown) => {
227
+ console.error(error instanceof Error ? error.message : String(error));
228
+ process.exit(1);
229
+ });
230
+ }
@@ -2,7 +2,7 @@
2
2
  * mcp-contract.test.ts — MCP tool surface contract tests (Phase 2).
3
3
  *
4
4
  * Guards against silent breaking changes to the MCP tool registry:
5
- * - Block A: Frozen 13-tool list + workflow-arrow (→) convention in descriptions
5
+ * - Block A: Frozen 14-tool list + workflow-arrow (→) convention in descriptions
6
6
  * - Block B: Token-budget guardrails for 10 canonical queries (rough chars/4)
7
7
  * - Block C: Shape snapshots — fingerprint the *contract* (keys, counts,
8
8
  * classifier output), NOT the corpus. Deliberately omits page IDs
@@ -54,6 +54,7 @@ describe("Frozen tool registry", () => {
54
54
  "routeros_search",
55
55
  "routeros_get_page",
56
56
  "routeros_lookup_property",
57
+ "routeros_explain_command",
57
58
  "routeros_command_tree",
58
59
  "routeros_stats",
59
60
  "routeros_search_changelogs",
@@ -66,23 +67,19 @@ describe("Frozen tool registry", () => {
66
67
  "routeros_current_versions",
67
68
  ];
68
69
 
69
- test("exactly 13 tools registered", () => {
70
+ test("exactly 14 tools registered", () => {
70
71
  const mcpSrc = readFileSync(path.join(ROOT, "src/mcp.ts"), "utf-8");
71
72
  // Extract tool names from server.registerTool("<name>", patterns
72
73
  const toolMatches = mcpSrc.matchAll(/server\.registerTool\(\s*["']([^"']+)["']/g);
73
74
  const foundTools = Array.from(toolMatches, (m) => m[1]);
74
75
 
75
- expect(foundTools.length).toBe(13);
76
+ expect(foundTools.length).toBe(14);
76
77
  expect(foundTools.sort()).toEqual(EXPECTED_TOOLS.sort());
77
78
  });
78
79
 
79
80
  test("all tools have workflow arrow (→) in description", () => {
80
81
  const mcpSrc = readFileSync(path.join(ROOT, "src/mcp.ts"), "utf-8");
81
82
 
82
- // Terminal/informational tools that don't have natural follow-ups
83
- // (identified during Phase 2 implementation as missing workflow arrows)
84
- const KNOWN_EXCEPTIONS = ["routeros_stats", "routeros_current_versions"];
85
-
86
83
  // Extract each complete registerTool block (tool name to closing paren before next registerTool)
87
84
  // Split on registerTool calls, then extract name + description from each block
88
85
  const toolBlocks = mcpSrc.split(/(?=server\.registerTool\()/);
@@ -105,14 +102,14 @@ describe("Frozen tool registry", () => {
105
102
  if (!altMatch) continue;
106
103
 
107
104
  const description = altMatch[1];
108
- if (!description.includes("→") && !KNOWN_EXCEPTIONS.includes(toolName)) {
105
+ if (!description.includes("→")) {
109
106
  toolsWithoutArrow.push(toolName);
110
107
  }
111
108
  continue;
112
109
  }
113
110
 
114
111
  const description = descMatch[1];
115
- if (!description.includes("→") && !KNOWN_EXCEPTIONS.includes(toolName)) {
112
+ if (!description.includes("→")) {
116
113
  toolsWithoutArrow.push(toolName);
117
114
  }
118
115
  }
@@ -127,7 +124,7 @@ describe("Frozen tool registry", () => {
127
124
  const totalFound = Array.from(
128
125
  mcpSrc.matchAll(/server\.registerTool\(\s*["']([^"']+)["']/g),
129
126
  ).length;
130
- expect(totalFound).toBe(13);
127
+ expect(totalFound).toBe(14);
131
128
  });
132
129
  });
133
130
 
@@ -322,7 +322,7 @@ describe("HTTP transport: session lifecycle", () => {
322
322
  expect(serverInfo.name).toBe("rosetta");
323
323
  });
324
324
 
325
- test("tools/list returns all 13 tools after initialization", async () => {
325
+ test("tools/list returns all 14 tools after initialization", async () => {
326
326
  const { sessionId } = await mcpInitialize(server.url);
327
327
 
328
328
  // Send initialized notification first (required by protocol)
@@ -333,12 +333,13 @@ describe("HTTP transport: session lifecycle", () => {
333
333
 
334
334
  const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
335
335
  const tools = result.tools as Array<{ name: string }>;
336
- expect(tools.length).toBe(13);
336
+ expect(tools.length).toBe(14);
337
337
 
338
338
  const toolNames = tools.map((t) => t.name).sort();
339
339
  expect(toolNames).toContain("routeros_search");
340
340
  expect(toolNames).toContain("routeros_get_page");
341
341
  expect(toolNames).toContain("routeros_lookup_property");
342
+ expect(toolNames).toContain("routeros_explain_command");
342
343
  expect(toolNames).toContain("routeros_command_tree");
343
344
  expect(toolNames).toContain("routeros_search_changelogs");
344
345
  expect(toolNames).toContain("routeros_command_version_check");
@@ -555,8 +556,8 @@ describe("HTTP transport: multi-session", () => {
555
556
  const tools1 = ((msgs1[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
556
557
  const tools2 = ((msgs2[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
557
558
 
558
- expect(tools1.length).toBe(13);
559
- expect(tools2.length).toBe(13);
559
+ expect(tools1.length).toBe(14);
560
+ expect(tools2.length).toBe(14);
560
561
  });
561
562
 
562
563
  test("deleting one session does not affect another", async () => {
@@ -578,7 +579,7 @@ describe("HTTP transport: multi-session", () => {
578
579
  // Client2 still works
579
580
  const msgs = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
580
581
  const tools = ((msgs[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
581
- expect(tools.length).toBe(13);
582
+ expect(tools.length).toBe(14);
582
583
 
583
584
  // Client1 is gone
584
585
  const resp = await fetch(server.url, {