@specific.dev/spectest 0.131.18 → 0.131.20

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,95 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { type DnsRegistry, publishDnsRegistry, parseDnsRegistry } from "./dns-registry.js";
3
+
4
+ export type NameTarget = { service: string } | { ip: string | null };
5
+ interface Binding { target: NameTarget; owner: string }
6
+ interface Service { ip: string | null; aliases: string[] }
7
+
8
+ /** SDK-owned desired state. Updates are serialized and publish the whole view.
9
+ * Service bindings retain their target, so replacement IPs and stops also
10
+ * update ctx.dnsName aliases. State and the publication queue fork with the VM. */
11
+ export class ServiceDns {
12
+ private services = new Map<string, Service>();
13
+ private bindings = new Map<string, Binding>();
14
+ private pending: Promise<void> = Promise.resolve();
15
+ constructor(private publish: (doc: DnsRegistry) => Promise<void> = publishDnsRegistry) {}
16
+
17
+ private update(change: () => void): Promise<void> {
18
+ const next = this.pending.then(async () => {
19
+ const services = this.services;
20
+ const bindings = this.bindings;
21
+ this.services = new Map(services);
22
+ this.bindings = new Map(bindings);
23
+ let doc: DnsRegistry;
24
+ try {
25
+ change();
26
+ doc = parseDnsRegistry(JSON.stringify(this.document()));
27
+ } catch (error) {
28
+ this.services = services;
29
+ this.bindings = bindings;
30
+ throw error;
31
+ }
32
+ await this.publish(doc);
33
+ });
34
+ // Preserve desired state after failure; the caller gets an error and the
35
+ // next full publication repairs an uncertain/lost acknowledgement.
36
+ this.pending = next.catch(() => {});
37
+ return next;
38
+ }
39
+ private document(): DnsRegistry {
40
+ const hosts: DnsRegistry["hosts"] = Object.create(null);
41
+ const suffixes = new Map<string, string | null>();
42
+ const put = (name: string, ip: string | null) => {
43
+ if (name.startsWith("*.")) suffixes.set(name.slice(1), ip);
44
+ else hosts[name] = ip;
45
+ };
46
+ for (const [name, svc] of this.services)
47
+ for (const alias of [name, `${name}.internal`, ...svc.aliases]) put(alias.toLowerCase(), svc.ip);
48
+ for (const [name, { target }] of this.bindings)
49
+ put(name, "service" in target ? this.services.get(target.service)?.ip ?? null : target.ip);
50
+ return { version: 1, generation: randomUUID(), hosts, wildcards: [...suffixes].map(([suffix, ip]) => ({ suffix, ip })) };
51
+ }
52
+ address(name: string): string | null { return this.services.get(name)?.ip ?? null; }
53
+
54
+ reset(services: Array<{ name: string; aliases: string[] }>, bindings: Array<{ name: string; target: NameTarget }>): Promise<void> {
55
+ return this.update(() => {
56
+ this.services = new Map(services.map(svc => [svc.name, { aliases: svc.aliases, ip: null }]));
57
+ this.bindings = new Map(bindings.map(b => [b.name.toLowerCase(), { target: b.target, owner: "static" }]));
58
+ });
59
+ }
60
+ service(name: string, ip: string | null, aliases?: string[]): Promise<void> {
61
+ return this.update(() => {
62
+ const previous = this.services.get(name);
63
+ // Retire removed aliases as owned/unavailable as well.
64
+ for (const old of previous?.aliases ?? []) {
65
+ if (aliases && !aliases.includes(old) && !this.bindings.has(old))
66
+ this.bindings.set(old, { target: { ip: null }, owner: `retired:${name}` });
67
+ }
68
+ for (const alias of aliases ?? []) {
69
+ if (this.bindings.get(alias)?.owner === `retired:${name}`) this.bindings.delete(alias);
70
+ }
71
+ this.services.set(name, { ip, aliases: aliases ?? previous?.aliases ?? [] });
72
+ });
73
+ }
74
+ bind(name: string, target: NameTarget, owner = "explicit"): Promise<void> {
75
+ return this.update(() => { this.bindings.set(name.toLowerCase(), { target, owner }); });
76
+ }
77
+ retire(name: string, owner: string): Promise<void> {
78
+ return this.update(() => {
79
+ const binding = this.bindings.get(name.toLowerCase());
80
+ if (binding?.owner === owner) this.bindings.set(name.toLowerCase(), { target: { ip: null }, owner });
81
+ });
82
+ }
83
+ }
84
+
85
+ /** Serialize same-name start/stop operations, while different services can
86
+ * start concurrently. Prevents stale teardown deleting a replacement. */
87
+ export class ServiceOperations {
88
+ private pending = new Map<string, Promise<unknown>>();
89
+ run<T>(name: string, fn: () => Promise<T>): Promise<T> {
90
+ const next = (this.pending.get(name) ?? Promise.resolve()).catch(() => {}).then(fn);
91
+ this.pending.set(name, next);
92
+ void next.finally(() => { if (this.pending.get(name) === next) this.pending.delete(name); }).catch(() => {});
93
+ return next;
94
+ }
95
+ }
@@ -77,7 +77,7 @@ async function peak(browser: MobileBackend): Promise<number> {
77
77
  }));
78
78
  }
79
79
 
80
- describe.skipIf(!Bun.which("chromium") && !Bun.which("chromium-browser") && !Bun.which("google-chrome"))("native Chromium microphone", () => {
80
+ describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("native Chromium microphone", () => {
81
81
  let server: ReturnType<typeof Bun.serve>;
82
82
  beforeAll(() => { server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<html>microphone</html>", { headers: { "content-type": "text/html" } }) }); });
83
83
  afterAll(() => server.stop(true));
@@ -200,7 +200,7 @@ describe.skipIf(!available)("Spectest assertions backed by real Playwright/Chrom
200
200
 
201
201
  // Exercise the production session branding/recording as well as the real
202
202
  // native engine above. browser.ts intentionally uses system Chromium.
203
- test.skipIf(!executablePath)("browser backend preserves assertion provenance and failure page capture", async () => {
203
+ test.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("browser backend preserves assertion provenance and failure page capture", async () => {
204
204
  const { openBrowser } = await import("./browser.js");
205
205
  const replaySteps: unknown[] = [];
206
206
  const view = await openBrowser({
@@ -0,0 +1,267 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { createServer } from "node:http";
3
+ import { createConnection, createServer as createTcpServer } from "node:net";
4
+ import { request } from "node:http";
5
+ import { createSocket } from "node:dgram";
6
+ import { mkdtemp, rm, writeFile, readFile, mkdir } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { publishDnsRegistry } from "./harness/dns-registry";
10
+ import { ServiceDns } from "./harness/service-dns";
11
+ import * as packet from "dns-packet";
12
+
13
+ const cleanup: Array<() => Promise<void>> = [];
14
+ afterEach(async () => { for (const fn of cleanup.splice(0).reverse()) await fn(); });
15
+
16
+ async function fixture(initialRegistry?: unknown) {
17
+ const dir = await mkdtemp(join(tmpdir(), "spectest-dns-"));
18
+ if (initialRegistry) await writeFile(join(dir, "dns.json"), JSON.stringify(initialRegistry));
19
+ const paths: string[] = [];
20
+ let ip = "172.20.0.9";
21
+ let fail = false;
22
+ let failContainer = false;
23
+ const server = createServer((req, res) => {
24
+ paths.push(req.url!);
25
+ res.setHeader("Content-Type", "application/json");
26
+ if (fail) { res.writeHead(503).end("{}"); return; }
27
+ if (req.url === "/networks/spectest-net") {
28
+ res.end(JSON.stringify({ Containers: Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`c${i}`, {}])) }));
29
+ } else if (req.url?.startsWith("/containers/") && req.url !== "/containers/json") {
30
+ const id = req.url.split("/")[2];
31
+ if (id === "c2" && failContainer) { res.writeHead(503).end("{}"); return; }
32
+ res.end(JSON.stringify({ State: { Running: id !== "c0" }, NetworkSettings: { Networks: {
33
+ "spectest-net": { IPAddress: ip, Aliases: [id === "c0" ? "stopped.test" : `${id}.test`] },
34
+ } } }));
35
+ } else { res.writeHead(404).end("{}"); }
36
+ });
37
+ const socket = join(dir, "docker.sock");
38
+ await new Promise<void>(resolve => server.listen(socket, resolve));
39
+ await writeFile(join(dir, "registry.json"), JSON.stringify({ hosts: { "explicit.test": "172.20.0.7" }, wildcards: [{ suffix: ".wild.test", ip: "172.20.0.8" }] }));
40
+ let upstreamQueries = 0;
41
+ let upstreamDelay = 0;
42
+ const upstream = createSocket("udp4");
43
+ upstream.on("message", (msg, remote) => {
44
+ upstreamQueries++;
45
+ const q = packet.decode(msg);
46
+ setTimeout(() => upstream.send(packet.encode({ type: "response", id: q.id, questions: q.questions, answers: [{ name: q.questions![0].name, type: "A", ttl: 1, data: "192.0.2.1" }] }), remote.port, remote.address), upstreamDelay);
47
+ });
48
+ await new Promise<void>(resolve => upstream.bind(0, "127.0.0.1", resolve));
49
+ const tcpUpstream = createTcpServer(socket => {
50
+ let data = Buffer.alloc(0);
51
+ socket.on("error", () => socket.destroy());
52
+ socket.on("data", chunk => {
53
+ data = Buffer.concat([data, Buffer.from(chunk)]);
54
+ if (data.length < 2 || data.length < 2 + data.readUInt16BE(0)) return;
55
+ const q = packet.decode(data.subarray(2));
56
+ const answer = packet.encode({ type: "response", id: q.id, questions: q.questions,
57
+ answers: [{ name: q.questions![0].name, type: "A", ttl: 1, data: "192.0.2.2" }] });
58
+ const prefix = Buffer.alloc(2); prefix.writeUInt16BE(answer.length);
59
+ socket.end(Buffer.concat([prefix, answer]));
60
+ });
61
+ });
62
+ await new Promise<void>(resolve => tcpUpstream.listen(upstream.address().port, "127.0.0.1", resolve));
63
+ const reservation = createSocket("udp4");
64
+ await new Promise<void>(resolve => reservation.bind(0, "127.0.0.1", resolve));
65
+ const port = reservation.address().port;
66
+ await new Promise<void>(resolve => reservation.close(resolve));
67
+ const spawn = () => Bun.spawn([process.execPath, join(import.meta.dir, "resolver.ts")], {
68
+ env: { ...process.env, SPECTEST_DNS_SOCKET: join(dir, "dns.sock"), SPECTEST_DNS_REGISTRY: join(dir, "dns.json"), DOCKER_SOCKET: socket, SPECTEST_RESOLVER_ADDR: "127.0.0.1", SPECTEST_RESOLVER_PORT: `${port}`, SPECTEST_RESOLVER_NO_BRIDGE: "1", SPECTEST_FAKES_REGISTRY: join(dir, "registry.json"), SPECTEST_UPSTREAM_DNS: "127.0.0.1", SPECTEST_UPSTREAM_PORT: `${upstream.address().port}` },
69
+ stdout: "pipe", stderr: "pipe",
70
+ });
71
+ let child = spawn();
72
+ cleanup.push(async () => {
73
+ child.kill(); await child.exited;
74
+ upstream.close(); await new Promise<void>(resolve => tcpUpstream.close(() => resolve())); server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve()));
75
+ await rm(dir, { recursive: true, force: true });
76
+ });
77
+ const reader = child.stdout.getReader();
78
+ await reader.read(); reader.releaseLock();
79
+ async function query(name: string, type: packet.RecordType = "A") {
80
+ const sock = createSocket("udp4");
81
+ try {
82
+ return await new Promise<packet.Packet>((resolve, reject) => {
83
+ const timeout = setTimeout(() => reject(new Error(`DNS timeout: ${name}`)), 3000);
84
+ sock.once("message", msg => { clearTimeout(timeout); resolve(packet.decode(msg)); });
85
+ sock.once("error", err => { clearTimeout(timeout); reject(err); });
86
+ sock.send(packet.encode({ type: "query", id: 42, questions: [{ name, type }] }), port, "127.0.0.1");
87
+ });
88
+ } finally { sock.close(); }
89
+ }
90
+ async function publish(doc: unknown) {
91
+ return new Promise<{ status: number; body: string }>((resolve, reject) => {
92
+ const req = request({ socketPath: join(dir, "dns.sock"), method: "PUT", path: "/v1/registry", agent: false }, res => {
93
+ let body = ""; res.setEncoding("utf8"); res.on("data", chunk => { body += chunk; });
94
+ res.on("end", () => resolve({ status: res.statusCode!, body }));
95
+ });
96
+ req.on("error", reject); req.end(JSON.stringify(doc));
97
+ });
98
+ }
99
+ async function restart() {
100
+ child.kill(); await child.exited; child = spawn();
101
+ const reader = child.stdout.getReader(); await reader.read(); reader.releaseLock();
102
+ }
103
+ async function tcpQuery(name: string) {
104
+ const q = packet.encode({ type: "query", id: 81, questions: [{ name, type: "A" }] });
105
+ const size = Buffer.alloc(2); size.writeUInt16BE(q.length);
106
+ return new Promise<packet.Packet>((resolve, reject) => {
107
+ const socket = createConnection({ host: "127.0.0.1", port });
108
+ let data = Buffer.alloc(0);
109
+ socket.setTimeout(3000, () => socket.destroy(new Error("TCP DNS timeout")));
110
+ socket.on("error", reject);
111
+ socket.on("connect", () => { socket.write(size); socket.write(q.subarray(0, 4)); socket.write(q.subarray(4)); });
112
+ socket.on("data", chunk => {
113
+ data = Buffer.concat([data, Buffer.from(chunk)]);
114
+ if (data.length >= 2 && data.length >= data.readUInt16BE(0) + 2) {
115
+ socket.destroy(); resolve(packet.decode(data.subarray(2)));
116
+ }
117
+ });
118
+ });
119
+ }
120
+ return { query, tcpQuery, publish, restart, dir, upstreamDelay: (ms: number) => { upstreamDelay = ms; }, upstreamQueries: () => upstreamQueries, paths, change: (next: string) => { ip = next; }, fail: (next: boolean) => { fail = next; }, failContainer: (next: boolean) => { failContainer = next; } };
121
+ }
122
+
123
+ test("DNS bursts share network discovery, preserve alias/AAAA/registry/upstream behavior", async () => {
124
+ const f = await fixture();
125
+ const answers = await Promise.all(Array.from({ length: 80 }, (_, i) => f.query(`c${i % 19 + 1}.test`, i % 2 ? "AAAA" : "A")));
126
+ for (let i = 0; i < answers.length; i++) expect(answers[i].answers).toHaveLength(i % 2 ? 0 : 1);
127
+ expect(f.paths.filter(p => p === "/networks/spectest-net")).toHaveLength(1);
128
+ expect(f.paths.filter(p => p.startsWith("/containers/"))).toHaveLength(20);
129
+ expect(f.paths.some(p => p.startsWith("/containers/json"))).toBe(false);
130
+ expect((await f.query("explicit.test")).answers?.[0]).toMatchObject({ data: "172.20.0.7" });
131
+ expect((await f.query("child.wild.test")).answers?.[0]).toMatchObject({ data: "172.20.0.8" });
132
+ expect((await f.query("external.test")).answers?.[0]).toMatchObject({ data: "192.0.2.1" });
133
+ expect((await f.query("stopped.test")).answers?.[0]).toMatchObject({ data: "192.0.2.1" });
134
+ });
135
+
136
+ test("runtime alias changes expire and transient Docker failures are not cached", async () => {
137
+ const f = await fixture();
138
+ f.fail(true);
139
+ expect((await f.query("c1.test")).answers?.[0]).toMatchObject({ data: "192.0.2.1" });
140
+ f.fail(false);
141
+ expect((await f.query("c1.test")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
142
+ f.change("172.20.0.10");
143
+ const deadline = performance.now() + 2500;
144
+ for (;;) {
145
+ const answer = await f.query("c1.test");
146
+ if ((answer.answers?.[0] as packet.AAnswer)?.data === "172.20.0.10") break;
147
+ if (performance.now() > deadline) throw new Error("Changed container IP remained stale");
148
+ await Bun.sleep(25);
149
+ }
150
+ expect(f.paths.filter(p => p === "/networks/spectest-net").length).toBe(3);
151
+ });
152
+
153
+
154
+ test("one unavailable container does not hide healthy aliases or poison the next refresh", async () => {
155
+ const f = await fixture();
156
+ f.failContainer(true);
157
+ expect((await f.query("c1.test")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
158
+ f.failContainer(false);
159
+ expect((await f.query("c2.test")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
160
+ expect(f.paths.filter(p => p === "/networks/spectest-net")).toHaveLength(2);
161
+ });
162
+
163
+ const fullRegistry = (generation = "one") => ({ version: 1, generation, hosts: {
164
+ api: "172.20.0.2", "api.internal": "172.20.0.2", "api.example.com": "172.20.0.3", "stopped.example.com": null,
165
+ }, wildcards: [{ suffix: ".example.com", ip: "172.20.0.4" }, { suffix: ".deep.example.com", ip: "172.20.0.5" }] });
166
+
167
+ test("acknowledged complete registry is memory-only, authoritative for every type, and serves TCP", async () => {
168
+ const f = await fixture();
169
+ const doc = fullRegistry();
170
+ expect(await f.publish(doc)).toEqual({ status: 200, body: JSON.stringify({ version: 1, generation: "one" }) });
171
+ expect(JSON.parse(await readFile(join(f.dir, "dns.json"), "utf8"))).toEqual(doc);
172
+ // Neither a changed legacy file nor a corrupt persisted file affects live DNS.
173
+ await writeFile(join(f.dir, "registry.json"), "invalid");
174
+ await writeFile(join(f.dir, "dns.json"), "invalid");
175
+ f.fail(true);
176
+ const replies = await Promise.all(Array.from({ length: 100 }, (_, i) => f.query(i % 2 ? "api" : "api.internal")));
177
+ for (const reply of replies) expect(reply.answers?.[0]).toMatchObject({ data: "172.20.0.2" });
178
+ expect((await f.query("API.EXAMPLE.COM.")).answers?.[0]).toMatchObject({ data: "172.20.0.3" });
179
+ expect((await f.query("x.deep.example.com")).answers?.[0]).toMatchObject({ data: "172.20.0.5" });
180
+ expect((await f.tcpQuery("api")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
181
+ for (const type of ["AAAA", "TXT", "MX", "HTTPS"] as packet.RecordType[])
182
+ expect((await f.query("api.example.com", type)).answers).toHaveLength(0);
183
+ for (const name of ["stopped.example.com", "unknown.internal", "unknown"])
184
+ expect((await f.query(name)).answers).toHaveLength(0);
185
+ expect(f.upstreamQueries()).toBe(0);
186
+ expect(f.paths).toHaveLength(0);
187
+ expect((await f.query("external.test")).answers?.[0]).toMatchObject({ data: "192.0.2.1" });
188
+ expect(f.paths).toHaveLength(0);
189
+ expect((await f.tcpQuery("external.test")).answers?.[0]).toMatchObject({ data: "192.0.2.2" });
190
+ });
191
+
192
+ test("bad updates preserve last state; replacement and restart never need Docker", async () => {
193
+ const f = await fixture();
194
+ await f.publish(fullRegistry());
195
+ for (const bad of [null, { ...fullRegistry(), version: 2 }, { ...fullRegistry(), hosts: { api: "garbage" } }, { ...fullRegistry(), wildcards: [{ suffix: "example.com", ip: "1.1.1.1" }] }])
196
+ expect((await f.publish(bad)).status).toBe(400);
197
+ expect((await f.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
198
+ const next = { ...fullRegistry("two"), hosts: { api: "172.20.0.9", "api.example.com": null } };
199
+ expect((await f.publish(next)).status).toBe(200);
200
+ expect((await f.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
201
+ expect((await f.query("api.example.com")).answers).toHaveLength(0);
202
+ await f.restart();
203
+ expect((await f.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
204
+ expect((await f.query("api.example.com")).answers).toHaveLength(0);
205
+ expect(f.paths).toHaveLength(0);
206
+ });
207
+
208
+ test("simultaneous external clients sharing transaction IDs receive their own questions", async () => {
209
+ const f = await fixture();
210
+ await f.publish(fullRegistry());
211
+ const names = Array.from({ length: 60 }, (_, i) => `external${i}.test`);
212
+ const replies = await Promise.all(names.map(name => f.query(name)));
213
+ for (let i = 0; i < names.length; i++) {
214
+ expect(replies[i].id).toBe(42);
215
+ expect(replies[i].questions?.[0].name).toBe(names[i]);
216
+ }
217
+ expect(f.paths).toHaveLength(0);
218
+ });
219
+
220
+
221
+ test("SDK writer waits for platform activation and persists concurrent lifecycle changes", async () => {
222
+ const f = await fixture();
223
+ const dns = new ServiceDns(doc => publishDnsRegistry(doc, join(f.dir, "dns.sock")));
224
+ await dns.reset([{ name: "api", aliases: ["api.test"] }], []);
225
+ expect((await f.query("api.test")).answers).toHaveLength(0);
226
+ await Promise.all([dns.service("api", "172.20.0.2"), dns.service("db", "172.20.0.3"), dns.bind("setup.test", { service: "api" })]);
227
+ expect((await f.query("setup.test")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
228
+ expect((await f.query("db")).answers?.[0]).toMatchObject({ data: "172.20.0.3" });
229
+ await dns.service("api", null);
230
+ expect((await f.query("setup.test")).answers).toHaveLength(0);
231
+ await dns.service("api", "172.20.0.4");
232
+ await f.restart();
233
+ expect((await f.query("setup.test")).answers?.[0]).toMatchObject({ data: "172.20.0.4" });
234
+ expect(f.paths).toHaveLength(0);
235
+ });
236
+
237
+
238
+ test("identical external queries coalesce while outstanding", async () => {
239
+ const f = await fixture(fullRegistry());
240
+ f.upstreamDelay(50);
241
+ const replies = await Promise.all(Array.from({ length: 40 }, () => f.query("same.external.test")));
242
+ for (const reply of replies) expect(reply.answers?.[0]).toMatchObject({ data: "192.0.2.1" });
243
+ expect(f.upstreamQueries()).toBe(1);
244
+ });
245
+
246
+ test("persistence failure rejects the update without replacing the active or saved view", async () => {
247
+ const f = await fixture(fullRegistry());
248
+ await mkdir(join(f.dir, "dns.json.tmp"));
249
+ const dns = new ServiceDns(doc => publishDnsRegistry(doc, join(f.dir, "dns.sock")));
250
+ await expect(dns.service("api", "172.20.0.9")).rejects.toThrow("DNS update rejected");
251
+ expect((await f.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
252
+ expect(JSON.parse(await readFile(join(f.dir, "dns.json"), "utf8"))).toEqual(fullRegistry());
253
+ await rm(join(f.dir, "dns.json.tmp"), { recursive: true });
254
+ await dns.service("api", "172.20.0.9");
255
+ expect((await f.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
256
+ });
257
+
258
+ test("copies of persisted registry evolve independently", async () => {
259
+ const a = await fixture(fullRegistry());
260
+ const b = await fixture(fullRegistry());
261
+ await a.publish({ ...fullRegistry("child"), hosts: { api: "172.20.0.9" } });
262
+ expect((await a.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
263
+ expect((await b.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
264
+ await a.restart(); await b.restart();
265
+ expect((await a.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.9" });
266
+ expect((await b.query("api")).answers?.[0]).toMatchObject({ data: "172.20.0.2" });
267
+ });