@specific.dev/spectest 0.131.19 → 0.131.21

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
+ }
@@ -5,7 +5,10 @@ test("shadow image namespaces isolate experiments and hash untrusted service nam
5
5
  const a = shadowImage(config, "../../other,compression=gzip");
6
6
  expect(a).toMatch(/^registry.example.com\/shadow\/experiment-1\/[a-f0-9]{32}:result$/);
7
7
  expect(shadowImage({ ...config, experiment: "experiment-2" }, "api")).not.toBe(shadowImage(config, "api"));
8
- expect(shadowOutput(a)).toContain("compression=zstd");
8
+ expect(shadowOutput(a).split(",")).toEqual([
9
+ "type=image", `name=${a}`, "push=true", "oci-mediatypes=true",
10
+ "compression=zstd", "compression-level=1", "force-compression=true",
11
+ ]);
9
12
  });
10
13
  test("configuration rejects exporter and endpoint injection", () => {
11
14
  expect(validateShadowBuilder(config)).toEqual(config);
@@ -24,8 +24,10 @@ export function shadowImage(c: ShadowBuilder, service: string): string {
24
24
  const id = createHash("sha256").update(service).digest("hex").slice(0, 32);
25
25
  return `${c.registry}/shadow/${c.experiment}/${id}:result`;
26
26
  }
27
+ // Convert inherited gzip layers too; the persistent builder caches the conversion.
28
+ // Level 1 favors export latency over a small additional size reduction.
27
29
  export function shadowOutput(reference: string): string {
28
- return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd`;
30
+ return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd,compression-level=1,force-compression=true`;
29
31
  }
30
32
  export function imageDigest(metadata: unknown): string {
31
33
  const digest = (metadata as Record<string, unknown>)?.["containerimage.digest"];
@@ -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
+ });