@gigzen/populace 0.1.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.
Files changed (38) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +258 -0
  3. package/adapters/buzzbuzz.mjs +247 -0
  4. package/adapters/contract.md +164 -0
  5. package/adapters/template-rest.mjs +192 -0
  6. package/adapters/template.mjs +80 -0
  7. package/examples/buzzbuzz/populace-report.html +245 -0
  8. package/examples/buzzbuzz/populace-report.json +280 -0
  9. package/examples/buzzbuzz/populace.config.mjs +51 -0
  10. package/examples/buzzbuzz/run-test.ps1 +61 -0
  11. package/examples/demo/adapters/demo.mjs +90 -0
  12. package/examples/demo/populace-report.html +230 -0
  13. package/examples/demo/populace-report.json +219 -0
  14. package/examples/demo/populace.config.mjs +22 -0
  15. package/examples/rest-api/README.md +85 -0
  16. package/examples/rest-api/adapter.mjs +166 -0
  17. package/examples/rest-api/populace.config.mjs +40 -0
  18. package/examples/rest-api/server.mjs +247 -0
  19. package/examples/token-expiry/expiry-demo.mjs +119 -0
  20. package/package.json +56 -0
  21. package/populace.config.example.mjs +65 -0
  22. package/src/cli.mjs +591 -0
  23. package/src/config.mjs +186 -0
  24. package/src/contract.mjs +130 -0
  25. package/src/diagnose.mjs +40 -0
  26. package/src/engine/agent.mjs +264 -0
  27. package/src/engine/geo.mjs +59 -0
  28. package/src/engine/index.mjs +4 -0
  29. package/src/engine/personas.mjs +115 -0
  30. package/src/engine/world.mjs +120 -0
  31. package/src/html-report.mjs +218 -0
  32. package/src/index.mjs +38 -0
  33. package/src/instrument.mjs +299 -0
  34. package/src/net.mjs +175 -0
  35. package/src/report.mjs +251 -0
  36. package/src/selftest.mjs +1369 -0
  37. package/src/smoke.mjs +274 -0
  38. package/src/version.mjs +24 -0
@@ -0,0 +1,1369 @@
1
+ // Self-test: runs the entire product against an in-memory adapter.
2
+ //
3
+ // No network, no database, no customer. If this passes, the engine really is
4
+ // app-agnostic — because the "app" here is a plain object with no relationship
5
+ // to anything.
6
+ //
7
+ // It also injects a deliberately flaky method, because a reporting tool that
8
+ // only ever reports success is worse than no reporting tool at all. We assert
9
+ // that the failures are CAUGHT, grouped, and reflected in the verdict.
10
+
11
+ import assert from "node:assert/strict";
12
+ import { execFile } from "node:child_process";
13
+ import { World } from "./engine/world.mjs";
14
+ import { Agent } from "./engine/agent.mjs";
15
+ import { buildPersonas } from "./engine/personas.mjs";
16
+ import { CircuitBreaker, isTransportError } from "./net.mjs";
17
+ import { smoke, smokePersona } from "./smoke.mjs";
18
+ import {
19
+ createMetrics,
20
+ DEFAULT_TIMEOUT_MS,
21
+ instrument,
22
+ normaliseError,
23
+ summarise,
24
+ } from "./instrument.mjs";
25
+ import { buildReport, renderReport } from "./report.mjs";
26
+ import { canSignInOnly, CONTRACT_METHODS, coverageOf, isStub } from "./contract.mjs";
27
+ import { diagnose } from "./diagnose.mjs";
28
+
29
+ let failed = 0;
30
+ const pending = [];
31
+ const check = (name, fn) => {
32
+ try {
33
+ const out = fn();
34
+ // Some checks are async. Keep the sync path exactly as it was and let an
35
+ // async one settle before the process reports a total.
36
+ if (out && typeof out.then === "function") {
37
+ pending.push(
38
+ out.then(
39
+ () => console.log(` ✔ ${name}`),
40
+ (error) => {
41
+ failed += 1;
42
+ console.log(` ✖ ${name}\n ${error.message}`);
43
+ },
44
+ ),
45
+ );
46
+ return;
47
+ }
48
+ console.log(` ✔ ${name}`);
49
+ } catch (error) {
50
+ failed += 1;
51
+ console.log(` ✖ ${name}\n ${error.message}`);
52
+ }
53
+ };
54
+
55
+ // --- a fake app, backed by plain objects ---------------------------------
56
+ function inMemoryAdapter({ flakyLike = 0 } = {}) {
57
+ const db = { users: [], posts: [], likes: 0, comments: 0, messages: 0, joins: 0, locations: 0 };
58
+ let likeCalls = 0;
59
+ return {
60
+ db,
61
+ name: "in-memory",
62
+ async createUser({ name }) {
63
+ const u = { id: `u${db.users.length}`, name };
64
+ db.users.push(u);
65
+ return u;
66
+ },
67
+ async setProfile() {},
68
+ async reportLocation() {
69
+ db.locations += 1;
70
+ },
71
+ async post(u, t) {
72
+ const p = { id: `p${db.posts.length}`, userId: u.id, t };
73
+ db.posts.push(p);
74
+ return p.id;
75
+ },
76
+ async recentPostsByOthers(u) {
77
+ return db.posts.filter((p) => p.userId !== u.id);
78
+ },
79
+ async like() {
80
+ likeCalls += 1;
81
+ // Deterministic flakiness — every Nth call fails, so the assertion below
82
+ // is not a coin flip.
83
+ if (flakyLike && likeCalls % flakyLike === 0) {
84
+ throw new Error(`row 4821 violates policy "post_likes_insert"`);
85
+ }
86
+ db.likes += 1;
87
+ },
88
+ async comment() {
89
+ db.comments += 1;
90
+ },
91
+ async openConversation() {
92
+ return "c1";
93
+ },
94
+ async sendMessage() {
95
+ db.messages += 1;
96
+ },
97
+ async listGroups() {
98
+ return [{ id: "g1" }, { id: "g2" }];
99
+ },
100
+ async joinGroup() {
101
+ db.joins += 1;
102
+ },
103
+ async deleteUser(u) {
104
+ db.users = db.users.filter((x) => x.id !== u.id);
105
+ },
106
+ };
107
+ }
108
+
109
+ const config = {
110
+ app: "Self Test",
111
+ adapter: "./in-memory",
112
+ environment: "test",
113
+ population: { agents: 5, cities: ["manila", "mumbai"], minutes: 1, tickSeconds: 5 },
114
+ _dir: process.cwd(),
115
+ };
116
+
117
+ console.log("\n populace self-test\n");
118
+
119
+ // --- 1. error grouping ----------------------------------------------------
120
+ check("errors group by shape, not by exact text", () => {
121
+ const a = normaliseError(new Error("row 4821 violates policy 3f2a1b9c-1111-2222-3333-444455556666"));
122
+ const b = normaliseError(new Error("row 9134 violates policy 8e7d6c5b-9999-8888-7777-666655554444"));
123
+ assert.equal(a, b, "two instances of the same bug should collapse into one line");
124
+ });
125
+
126
+ // --- 2. a clean run -------------------------------------------------------
127
+ const clean = inMemoryAdapter();
128
+ const cleanMetrics = createMetrics();
129
+ const cleanWorld = new World({
130
+ adapter: instrument(clean, cleanMetrics),
131
+ personas: buildPersonas(5, ["manila", "mumbai"]),
132
+ });
133
+ await cleanWorld.populate({ staggerMs: 0 });
134
+ await cleanWorld.run({ minutes: 1, tickSeconds: 5, realtime: false });
135
+ const cleanTeardown = await cleanWorld.teardown();
136
+ cleanMetrics.endedAt = Date.now();
137
+ const cleanReport = buildReport({
138
+ config,
139
+ adapter: clean,
140
+ world: cleanWorld,
141
+ metrics: cleanMetrics,
142
+ teardown: cleanTeardown,
143
+ startedAt: Date.now() - 5000,
144
+ });
145
+
146
+ check("engine runs against an adapter that knows nothing about any real app", () => {
147
+ assert.equal(cleanWorld.agents.length, 5);
148
+ assert.ok(clean.db.locations > 0, "should have reported locations");
149
+ });
150
+
151
+ /* ── portability ────────────────────────────────────────────────────────────
152
+ The contract says these methods return "posts" and "groups". It does not say
153
+ they must be OBJECTS, and many APIs answer a list endpoint with bare ids.
154
+ The engine used to read `target.id` directly while `smoke` accepted either
155
+ form, so an adapter returning strings passed the smoke test and then failed
156
+ on the first tick of a real run — the precise "subtly wrong adapter" case
157
+ smoke exists to rule out. These pin the two together. */
158
+ check("ids are taken from objects, bare values, and common aliases", () => {
159
+ assert.equal(Agent.idOf({ id: "a" }), "a");
160
+ assert.equal(Agent.idOf("bare-string-id"), "bare-string-id");
161
+ assert.equal(Agent.idOf(42), 42);
162
+ assert.equal(Agent.idOf({ uuid: "u" }), "u");
163
+ assert.equal(Agent.idOf({ _id: "m" }), "m");
164
+ assert.equal(Agent.idOf(null), undefined);
165
+ assert.equal(Agent.idOf(undefined), undefined);
166
+ });
167
+
168
+ // A second world, driven by an adapter shaped like a plain REST API: its list
169
+ // endpoints return bare ids rather than objects. Built with the same World the
170
+ // real runs use, so this exercises the actual code path.
171
+ const bareSeen = { likes: [], joins: [] };
172
+ const bareAdapter = {
173
+ name: "bare-id-api",
174
+ async createUser(p) { return { id: `u_${p.phone}` }; },
175
+ async setProfile() {},
176
+ async reportLocation() {},
177
+ async post() { return "post_1"; },
178
+ async recentPostsByOthers() { return ["post_1", "post_2"]; },
179
+ async like(_u, id) { bareSeen.likes.push(id); },
180
+ async comment(_u, id, _b) { bareSeen.likes.push(id); },
181
+ async listGroups() { return ["group_1", "group_2"]; },
182
+ async joinGroup(_u, id) { bareSeen.joins.push(id); },
183
+ async deleteUser() {},
184
+ };
185
+ const bareWorld = new World({
186
+ adapter: bareAdapter,
187
+ personas: buildPersonas(4, ["manila", "mumbai"]),
188
+ });
189
+ await bareWorld.populate({ staggerMs: 0 });
190
+ await bareWorld.run({ minutes: 1, tickSeconds: 5, realtime: false });
191
+ await bareWorld.teardown();
192
+
193
+ check("a run survives an adapter whose lists contain bare ids", () => {
194
+ assert.equal(bareWorld.agents.length, 4);
195
+ assert.ok(bareSeen.likes.length + bareSeen.joins.length > 0,
196
+ "the bare-id adapter was never exercised, so this proves nothing");
197
+ assert.ok(!bareSeen.likes.includes(undefined),
198
+ "like received undefined instead of an id");
199
+ assert.ok(!bareSeen.joins.includes(undefined),
200
+ "joinGroup received undefined instead of an id");
201
+ });
202
+
203
+ /* smoke and the engine must call createUser with the SAME object. When they
204
+ disagreed, an adapter written against either one passed the other's test and
205
+ failed its run — the tool whose job is "your wiring is right" being the thing
206
+ that was wrong. Both shapes are captured here so drift shows up as a failed
207
+ check rather than as a stranger's confusing first run. */
208
+ {
209
+ let fromEngine = null;
210
+ let fromSmoke = null;
211
+
212
+ const spyAdapter = (sink) => ({
213
+ name: "shape-spy",
214
+ async createUser(arg) { sink(arg); return { id: "u1" }; },
215
+ async deleteUser() {},
216
+ });
217
+
218
+ const spyWorld = new World({
219
+ adapter: spyAdapter((a) => { fromEngine ??= a; }),
220
+ personas: buildPersonas(1, ["manila"]),
221
+ });
222
+ await spyWorld.populate({ staggerMs: 0 });
223
+
224
+ await smoke({ adapter: spyAdapter((a) => { fromSmoke ??= a; }) });
225
+
226
+ check("smoke calls createUser with the same shape the engine does", () => {
227
+ assert.ok(fromEngine, "the engine never called createUser");
228
+ assert.ok(fromSmoke, "smoke never called createUser");
229
+ assert.deepEqual(
230
+ Object.keys(fromEngine).sort(),
231
+ Object.keys(fromSmoke).sort(),
232
+ "smoke and the engine disagree about createUser's argument",
233
+ );
234
+ // And the documented keys are actually the ones present.
235
+ assert.deepEqual(Object.keys(fromEngine).sort(), ["index", "name", "persona", "phone"]);
236
+ });
237
+ }
238
+
239
+ check("people behave differently from one another", () => {
240
+ const distances = new Set(cleanWorld.agents.map((a) => a.distanceKm.toFixed(3)));
241
+ assert.ok(distances.size > 1, "identical agents would find identical bugs");
242
+ });
243
+
244
+ check("cleanup removes every account it created", () => {
245
+ assert.equal(clean.db.users.length, 0);
246
+ assert.equal(cleanTeardown.failed.length, 0);
247
+ });
248
+
249
+ check("a clean run reports clean", () => {
250
+ assert.equal(cleanReport.verdict.status, "clean", JSON.stringify(cleanReport.verdict.problems));
251
+ assert.ok(cleanReport.api.calls > 0);
252
+ });
253
+
254
+ check("latency is measured per method", () => {
255
+ const post = cleanReport.api.methods.find((m) => m.method === "post");
256
+ assert.ok(post, "post should appear in the report");
257
+ assert.ok(Number.isFinite(post.latencyMs.p95));
258
+ });
259
+
260
+ // --- 3. a run that breaks -------------------------------------------------
261
+ const flaky = inMemoryAdapter({ flakyLike: 3 });
262
+ const flakyMetrics = createMetrics();
263
+ const flakyWorld = new World({
264
+ adapter: instrument(flaky, flakyMetrics),
265
+ personas: buildPersonas(5, ["manila", "mumbai"]),
266
+ });
267
+ await flakyWorld.populate({ staggerMs: 0 });
268
+ await flakyWorld.run({ minutes: 1, tickSeconds: 5, realtime: false });
269
+ flakyMetrics.endedAt = Date.now();
270
+ const flakyReport = buildReport({
271
+ config,
272
+ adapter: flaky,
273
+ world: flakyWorld,
274
+ metrics: flakyMetrics,
275
+ teardown: await flakyWorld.teardown(),
276
+ startedAt: Date.now() - 5000,
277
+ });
278
+
279
+ check("a broken endpoint is caught and named", () => {
280
+ const like = flakyReport.api.methods.find((m) => m.method === "like");
281
+ assert.ok(like && like.failures > 0, "the flaky method should have recorded failures");
282
+ assert.ok(like.errors[0].message.includes("violates policy"));
283
+ assert.equal(flakyReport.verdict.status, "problems-found");
284
+ assert.ok(flakyReport.verdict.failingMethods.some((m) => m.method === "like"));
285
+ });
286
+
287
+ check("one person's broken app does not end everyone else's run", () => {
288
+ assert.equal(flakyWorld.agents.length, 5, "all agents should have survived the failures");
289
+ assert.ok(flaky.db.posts.length > 0, "unrelated actions should have continued");
290
+ });
291
+
292
+ // --- 4. partial adapters --------------------------------------------------
293
+ const partial = {
294
+ name: "read-only",
295
+ createUser: async () => ({ id: "x" }),
296
+ deleteUser: async (u) => {
297
+ await Promise.resolve(u);
298
+ },
299
+ };
300
+ check("a partial adapter is honestly reported as partial coverage", () => {
301
+ const cov = coverageOf(partial);
302
+ assert.equal(cov.implemented.length, 2);
303
+ assert.ok(cov.missing.some((m) => m.method === "sendMessage"));
304
+ });
305
+
306
+ // A method that exists but does nothing is not coverage. Without this, a
307
+ // freshly scaffolded adapter reports 12/12 and runs "clean" while testing
308
+ // nothing at all — confidence manufactured out of empty functions.
309
+ check("empty and not-implemented stubs do NOT count as coverage", () => {
310
+ assert.equal(isStub(async () => {}), true, "empty body");
311
+ assert.equal(isStub(async function () {}), true, "empty function");
312
+ assert.equal(isStub(async () => { /* return postId */ }), true, "comment-only body");
313
+ assert.equal(
314
+ isStub(async () => {
315
+ throw new Error("createUser not implemented");
316
+ }),
317
+ true,
318
+ "explicit not-implemented throw",
319
+ );
320
+ assert.equal(isStub(async () => ({ id: "x" })), false, "concise arrow returning a value");
321
+ assert.equal(
322
+ isStub(async (u) => {
323
+ await u.client.rpc("delete_own_account");
324
+ }),
325
+ false,
326
+ "real work",
327
+ );
328
+ });
329
+
330
+ check("skipped methods are listed with what they would have tested", () => {
331
+ const r = buildReport({
332
+ config,
333
+ adapter: partial,
334
+ world: cleanWorld,
335
+ metrics: cleanMetrics,
336
+ teardown: cleanTeardown,
337
+ startedAt: Date.now(),
338
+ });
339
+ const skipped = r.coverage.notTested.find((c) => c.method === "reportLocation");
340
+ assert.ok(skipped?.wouldHaveTested.length > 10, "a gap should say what it costs you");
341
+ });
342
+
343
+ // --- 4b. cleanup that does not write to the customer's database -----------
344
+ // clean reaches an account through createUser, which SIGNS UP when the identity
345
+ // is absent. On an already-clean environment that creates every simulated
346
+ // identity just to delete it again — writing to someone else's auth table to
347
+ // prove the table is empty — and makes the per-account result useless as
348
+ // evidence of what was actually stranded. `signIn` is the read-only path.
349
+
350
+ function appWithSignIn({ existing = [] } = {}) {
351
+ const db = { users: [...existing], signUps: 0, signIns: 0, deleted: [] };
352
+ return {
353
+ db,
354
+ name: "with-signin",
355
+ async createUser({ name, phone }) {
356
+ db.signUps += 1;
357
+ const found = db.users.find((u) => u.phone === phone);
358
+ if (found) return found;
359
+ const u = { id: `u${db.users.length}`, name, phone };
360
+ db.users.push(u);
361
+ return u;
362
+ },
363
+ async signIn({ phone }) {
364
+ db.signIns += 1;
365
+ return db.users.find((u) => u.phone === phone) || null;
366
+ },
367
+ async deleteUser(user) {
368
+ db.deleted.push(user.phone);
369
+ db.users = db.users.filter((u) => u.phone !== user.phone);
370
+ },
371
+ };
372
+ }
373
+
374
+ function appWithoutSignIn() {
375
+ const db = { users: [], signUps: 0, deleted: [] };
376
+ return {
377
+ db,
378
+ name: "no-signin",
379
+ async createUser({ name, phone }) {
380
+ db.signUps += 1;
381
+ const found = db.users.find((u) => u.phone === phone);
382
+ if (found) return found;
383
+ const u = { id: `u${db.users.length}`, name, phone };
384
+ db.users.push(u);
385
+ return u;
386
+ },
387
+ async deleteUser(user) {
388
+ db.deleted.push(user.phone);
389
+ db.users = db.users.filter((u) => u.phone !== user.phone);
390
+ },
391
+ };
392
+ }
393
+
394
+ const cleanupPersona = buildPersonas(1, ["manila"])[0];
395
+ const phoneOfAgent0 = new Agent(cleanupPersona, appWithoutSignIn(), 0, {}).phone;
396
+
397
+ check("signIn is a capability, not a fourteenth contract method", () => {
398
+ const withIt = coverageOf(appWithSignIn());
399
+ const withoutIt = coverageOf(appWithoutSignIn());
400
+ assert.equal(
401
+ withIt.label,
402
+ withoutIt.label,
403
+ "implementing signIn must not change the coverage denominator",
404
+ );
405
+ assert.ok(
406
+ !CONTRACT_METHODS.includes("signIn"),
407
+ "signIn must stay out of the simulation contract",
408
+ );
409
+ assert.ok(canSignInOnly(appWithSignIn()), "should be detected when present");
410
+ assert.ok(!canSignInOnly(appWithoutSignIn()), "should be absent when not implemented");
411
+ assert.ok(!canSignInOnly({ signIn: () => {} }), "an empty stub is not a capability");
412
+ });
413
+
414
+ check("with signIn, checking an absent identity creates nothing", async () => {
415
+ const app = appWithSignIn();
416
+ const agent = new Agent(cleanupPersona, app, 0, {});
417
+ const found = await agent.findAccount();
418
+ assert.equal(found, null, "should report definitively absent");
419
+ assert.equal(app.db.signUps, 0, "must not sign anybody up while looking");
420
+ assert.equal(app.db.users.length, 0, "must not leave a row behind");
421
+ });
422
+
423
+ check("with signIn, a stranded identity is found and removed", async () => {
424
+ const app = appWithSignIn({
425
+ existing: [{ id: "u0", name: "left over", phone: phoneOfAgent0 }],
426
+ });
427
+ const agent = new Agent(cleanupPersona, app, 0, {});
428
+ const found = await agent.findAccount();
429
+ assert.ok(found, "should find the account that is really there");
430
+ await agent.selfDestruct();
431
+ assert.deepEqual(app.db.deleted, [phoneOfAgent0], "should delete exactly that account");
432
+ assert.equal(app.db.signUps, 0, "still no sign-ups");
433
+ });
434
+
435
+ check("without signIn, the answer is 'cannot tell' rather than 'absent'", async () => {
436
+ const app = appWithoutSignIn();
437
+ const agent = new Agent(cleanupPersona, app, 0, {});
438
+ const answer = await agent.findAccount();
439
+ assert.equal(answer, undefined, "undefined means unknown — never conflate it with null");
440
+ assert.equal(app.db.signUps, 0, "asking must not create anything either");
441
+ });
442
+
443
+ // --- 4c. what `doctor` decides -------------------------------------------
444
+ // doctor is the one thing standing between a customer and a run that proves
445
+ // nothing. Its judgement now lives in diagnose(), so it can be tested without
446
+ // spawning a process and matching strings.
447
+
448
+ // Every method here needs a real body: an empty one is deliberately not
449
+ // counted as coverage, so `async deleteUser() {}` would read as missing and
450
+ // this fixture would block on its own required method rather than on the
451
+ // condition under test.
452
+ const fullApp = () => ({
453
+ name: "full",
454
+ async createUser() { return { id: "u" }; },
455
+ async deleteUser(u) { return u?.id ?? null; },
456
+ async signIn() { return null; },
457
+ });
458
+ const noDelete = () => ({ name: "partial", async createUser() { return { id: "u" }; } });
459
+ const cfg = { neverRunAgainst: ["https://prod.example"], _file: "x", environment: "test" };
460
+
461
+ check("doctor is ready only when nothing blocks it", () => {
462
+ const d = diagnose({ config: cfg, adapter: fullApp(), reachable: true });
463
+ assert.equal(d.ready, true);
464
+ assert.deepEqual(d.blockers, []);
465
+ assert.equal(d.guarded, 1, "should count the denied production hosts");
466
+ });
467
+
468
+ check("doctor blocks when a REQUIRED method is missing", () => {
469
+ const d = diagnose({ config: cfg, adapter: noDelete(), reachable: true });
470
+ assert.equal(d.ready, false);
471
+ assert.ok(
472
+ d.blockers.some((b) => b.includes("deleteUser")),
473
+ "must name the missing requirement, not just refuse",
474
+ );
475
+ });
476
+
477
+ check("doctor blocks when the target cannot be reached", () => {
478
+ const d = diagnose({ config: cfg, adapter: fullApp(), reachable: false });
479
+ assert.equal(d.ready, false);
480
+ assert.ok(d.blockers.some((b) => b.includes("unreachable")));
481
+ });
482
+
483
+ check("doctor reports both blockers when both apply", () => {
484
+ const d = diagnose({ config: cfg, adapter: noDelete(), reachable: false });
485
+ assert.equal(d.blockers.length, 2, "fixing one must not hide the other");
486
+ });
487
+
488
+ check("doctor does not invent a reachability verdict it never checked", () => {
489
+ const d = diagnose({ config: cfg, adapter: fullApp(), reachable: null });
490
+ assert.equal(d.ready, true, "no healthCheck is not the same as unreachable");
491
+ assert.deepEqual(d.blockers, []);
492
+ });
493
+
494
+ check("doctor names the cleanup mode", () => {
495
+ assert.equal(diagnose({ config: cfg, adapter: fullApp(), reachable: true }).cleanup, "read-only");
496
+ assert.equal(
497
+ diagnose({ config: cfg, adapter: noDelete(), reachable: true }).cleanup,
498
+ "create-then-delete",
499
+ );
500
+ });
501
+
502
+ // --- 4d. teardown must not claim removals it did not make ------------------
503
+ // selfDestruct() does nothing when there is no account and no deleteUser, and
504
+ // teardown counted both as successes — so a run could print "Cleanup complete
505
+ // — 6 accounts removed" having deleted none of them.
506
+
507
+ async function tornDown({ deletable, withUser }) {
508
+ const deleted = [];
509
+ const app = {
510
+ name: "t",
511
+ async createUser() { return { id: "u" }; },
512
+ ...(deletable ? { async deleteUser(u) { deleted.push(u.id); } } : {}),
513
+ };
514
+ const personas = buildPersonas(2, ["manila"]);
515
+ const world = new World({ adapter: app, personas, identity: {}, hooks: {} });
516
+ world.agents = personas.map((p, i) => {
517
+ const a = new Agent(p, app, i, {});
518
+ a.user = withUser ? { id: `u${i}` } : null;
519
+ return a;
520
+ });
521
+ return { result: await world.teardown(), deleted };
522
+ }
523
+
524
+ check("teardown counts only the accounts it actually deleted", async () => {
525
+ const { result, deleted } = await tornDown({ deletable: true, withUser: true });
526
+ assert.equal(result.removed, 2);
527
+ assert.equal(deleted.length, 2, "and really called deleteUser for each");
528
+ assert.equal(result.notDeleted.length, 0);
529
+ assert.equal(result.failed.length, 0);
530
+ });
531
+
532
+ check("teardown does not report a removal when there was no account", async () => {
533
+ const { result, deleted } = await tornDown({ deletable: true, withUser: false });
534
+ assert.equal(result.removed, 0, "claiming these were removed is the bug this covers");
535
+ assert.equal(deleted.length, 0, "nothing was deleted");
536
+ assert.equal(result.notDeleted.length, 2);
537
+ assert.ok(result.notDeleted[0].why.length > 0, "and says why for each");
538
+ });
539
+
540
+ check("teardown does not report a removal when the adapter cannot delete", async () => {
541
+ const { result } = await tornDown({ deletable: false, withUser: true });
542
+ assert.equal(result.removed, 0);
543
+ assert.equal(result.notDeleted.length, 2);
544
+ assert.ok(result.notDeleted[0].why.includes("deleteUser"));
545
+ });
546
+
547
+ check("a cleanup that removed nothing is never rendered as complete", async () => {
548
+ const { result } = await tornDown({ deletable: true, withUser: false });
549
+ const text = renderReport({ ...cleanReport, cleanup: result });
550
+ assert.ok(!/Cleanup complete/.test(text), "must not say complete");
551
+ assert.ok(/Cleanup partial/.test(text), "must say what actually happened");
552
+ });
553
+
554
+ // --- 4e. a call that never returns must not hang the run -------------------
555
+ // A real run froze at tick 24/60 on one dead socket and produced no report at
556
+ // all. The customer's API was not at fault and nothing was logged — the worst
557
+ // kind of failure, because it looks like nothing. Every adapter call now has a
558
+ // deadline.
559
+
560
+ const never = () => new Promise(() => {});
561
+
562
+ function timed(adapterFns, timeoutMs) {
563
+ const metrics = createMetrics();
564
+ return { metrics, app: instrument({ name: "t", ...adapterFns }, metrics, { timeoutMs }) };
565
+ }
566
+
567
+ check("a hung adapter call rejects instead of hanging forever", async () => {
568
+ const { app } = timed({ async post() { return never(); } }, 40);
569
+ const started = Date.now();
570
+ await assert.rejects(() => app.post(), /timed out after 40ms/);
571
+ assert.ok(Date.now() - started < 2000, "must give up at the deadline, not wait");
572
+ });
573
+
574
+ check("a timeout is recorded as a failure, not silently swallowed", async () => {
575
+ const { metrics, app } = timed({ async post() { return never(); } }, 40);
576
+ await app.post().catch(() => {});
577
+ const post = summarise(metrics).methods.find((m) => m.method === "post");
578
+ assert.equal(post.calls, 1);
579
+ assert.equal(post.failures, 1, "a run that gets no answer has failed, and must say so");
580
+ assert.ok(post.errors[0].message.includes("timed out"), "and the report must name why");
581
+ });
582
+
583
+ check("a timeout is attributed to the adapter, not to Populace", async () => {
584
+ // If this leaks as an untagged error the agent loop treats it as OUR bug and
585
+ // aborts the run — turning a slow customer endpoint into a crash.
586
+ const { app } = timed({ async post() { return never(); } }, 40);
587
+ const err = await app.post().catch((e) => e);
588
+ assert.equal(err.fromAdapter, true);
589
+ assert.equal(err.isTimeout, true);
590
+ });
591
+
592
+ check("one hung method does not stop the others from working", async () => {
593
+ const { app } = timed(
594
+ { async post() { return never(); }, async like() { return "ok"; } },
595
+ 40,
596
+ );
597
+ const [slow, fast] = await Promise.allSettled([app.post(), app.like()]);
598
+ assert.equal(slow.status, "rejected");
599
+ assert.equal(fast.status, "fulfilled", "the run must carry on around a dead endpoint");
600
+ assert.equal(fast.value, "ok");
601
+ });
602
+
603
+ check("calls that finish in time are untouched by the deadline", async () => {
604
+ const { metrics, app } = timed({ async post() { return "fine"; } }, 5000);
605
+ assert.equal(await app.post(), "fine");
606
+ assert.equal(summarise(metrics).methods.find((m) => m.method === "post").failures, 0);
607
+ });
608
+
609
+ check("a real rejection still reports its own message, not a timeout", async () => {
610
+ const { app } = timed({ async post() { throw new Error("row-level security"); } }, 5000);
611
+ await assert.rejects(() => app.post(), /row-level security/);
612
+ });
613
+
614
+ check("a late rejection after the deadline does not crash the process", async () => {
615
+ // Nothing awaits the original promise once we have raced past it, so an
616
+ // unhandled rejection here would take down the whole run.
617
+ let unhandled = null;
618
+ const onUnhandled = (e) => { unhandled = e; };
619
+ process.on("unhandledRejection", onUnhandled);
620
+ const { app } = timed(
621
+ { async post() { await new Promise((r) => setTimeout(r, 30)); throw new Error("late"); } },
622
+ 10,
623
+ );
624
+ await app.post().catch(() => {});
625
+ await new Promise((r) => setTimeout(r, 120));
626
+ process.off("unhandledRejection", onUnhandled);
627
+ assert.equal(unhandled, null, "a late failure must not become an unhandled rejection");
628
+ });
629
+
630
+ check("the deadline can be switched off for legitimately long work", async () => {
631
+ const { app } = timed({ async post() { await new Promise((r) => setTimeout(r, 60)); return "done"; } }, 0);
632
+ assert.equal(await app.post(), "done");
633
+ });
634
+
635
+ check("instrument applies a default deadline when none is given", async () => {
636
+ assert.ok(DEFAULT_TIMEOUT_MS > 0, "there must be a default, or hangs come straight back");
637
+ const metrics = createMetrics();
638
+ const app = instrument({ name: "t", async post() { return "ok"; } }, metrics);
639
+ assert.equal(await app.post(), "ok");
640
+ });
641
+
642
+ check("a fast call leaves no timer holding the process open", async () => {
643
+ // An uncleared timer per call would make `populace run` hang on exit for the
644
+ // full deadline after the simulation had already finished.
645
+ //
646
+ // This has to run in its OWN process. Checking getActiveResourcesInfo() here
647
+ // would see the deliberately-hung deadlines from the checks running alongside
648
+ // it and fail for a reason that has nothing to do with the code under test —
649
+ // which is exactly what the first version of this test did.
650
+ const here = new URL("./instrument.mjs", import.meta.url).href;
651
+ const script = `
652
+ import { createMetrics, instrument } from ${JSON.stringify(here)};
653
+ const app = instrument({ name: "t", async post() { return "ok"; } }, createMetrics(), {
654
+ timeoutMs: 60_000,
655
+ });
656
+ await app.post();
657
+ `;
658
+ const started = Date.now();
659
+ await new Promise((resolve, reject) => {
660
+ execFile(
661
+ process.execPath,
662
+ ["--input-type=module", "-e", script],
663
+ { timeout: 20_000 },
664
+ (err) => (err ? reject(err) : resolve()),
665
+ );
666
+ });
667
+ const elapsed = Date.now() - started;
668
+ assert.ok(elapsed < 10_000, `process took ${elapsed}ms to exit; a 60s timer was left armed`);
669
+ });
670
+
671
+ // Reuses the clean run's world and teardown; only the metrics vary per case.
672
+ const inconclusiveShape = {
673
+ config,
674
+ adapter: clean,
675
+ world: cleanWorld,
676
+ teardown: cleanTeardown,
677
+ startedAt: Date.now() - 5000,
678
+ };
679
+
680
+ // --- 4f. a bad link must not be reported as a bad API ----------------------
681
+ // The most damaging thing this tool could do is blame a customer's code for a
682
+ // dropped socket. Cry wolf once and the team stops reading the real findings.
683
+ // The mirror danger is retrying a genuine 500 until it passes, which turns
684
+ // their bug into a green tick. Both are tested here.
685
+
686
+ function flakyCall({ failFirst, error, method = "post" }) {
687
+ let n = 0;
688
+ const metrics = createMetrics();
689
+ const app = instrument(
690
+ {
691
+ name: "f",
692
+ async [method]() {
693
+ n += 1;
694
+ if (n <= failFirst) throw error();
695
+ return "ok";
696
+ },
697
+ },
698
+ metrics,
699
+ { timeoutMs: 2000, retries: 3 },
700
+ );
701
+ return { app, metrics, attempts: () => n };
702
+ }
703
+
704
+ const dropped = () => Object.assign(new TypeError("fetch failed"), {
705
+ cause: { code: "ECONNRESET" },
706
+ });
707
+
708
+ check("isTransportError knows a dead socket from a rejected request", () => {
709
+ assert.equal(isTransportError(dropped()), true);
710
+ assert.equal(isTransportError(new Error("ENOTFOUND")), true);
711
+ assert.equal(isTransportError({ isTimeout: true }), true);
712
+ // Anything the server actually answered is the application's.
713
+ assert.equal(isTransportError(new Error("permission denied for table posts")), false);
714
+ assert.equal(isTransportError(new Error("500 Internal Server Error")), false);
715
+ assert.equal(isTransportError(null), false);
716
+ });
717
+
718
+ check("a dropped connection is retried and the call still succeeds", async () => {
719
+ const { app, attempts } = flakyCall({ failFirst: 2, error: dropped });
720
+ assert.equal(await app.post(), "ok");
721
+ assert.equal(attempts(), 3, "should have retried twice then succeeded");
722
+ });
723
+
724
+ check("retries are counted in the report, never hidden", async () => {
725
+ const { app, metrics } = flakyCall({ failFirst: 2, error: dropped });
726
+ await app.post();
727
+ const s = summarise(metrics);
728
+ assert.equal(s.retries, 2, "a call that only worked on its third try must say so");
729
+ assert.equal(s.failures, 0, "but it did ultimately succeed");
730
+ assert.equal(s.network.healthy, false);
731
+ });
732
+
733
+ check("an error the API actually returned is NEVER retried", async () => {
734
+ // Retrying this would turn a real bug into a passing run — the single worst
735
+ // thing a correctness tool can do.
736
+ const { app, attempts, metrics } = flakyCall({
737
+ failFirst: 99,
738
+ error: () => new Error("permission denied for table posts"),
739
+ });
740
+ await assert.rejects(() => app.post(), /permission denied/);
741
+ assert.equal(attempts(), 1, "exactly one attempt for an application error");
742
+ assert.equal(summarise(metrics).apiFailures, 1);
743
+ assert.equal(summarise(metrics).transportFailures, 0);
744
+ });
745
+
746
+ check("a link that never recovers is reported as transport, not as their bug", async () => {
747
+ const { app, attempts, metrics } = flakyCall({ failFirst: 99, error: dropped });
748
+ await assert.rejects(() => app.post());
749
+ assert.equal(attempts(), 4, "initial attempt plus three retries");
750
+ const s = summarise(metrics);
751
+ assert.equal(s.transportFailures, 1);
752
+ assert.equal(s.apiFailures, 0, "their code was never even reached");
753
+ });
754
+
755
+ check("latency excludes the failed attempts before a success", async () => {
756
+ // Folding retry time into latency is how p50/p95 become unpublishable.
757
+ const metrics = createMetrics();
758
+ let n = 0;
759
+ const app = instrument(
760
+ {
761
+ name: "f",
762
+ async post() {
763
+ n += 1;
764
+ if (n === 1) { await new Promise((r) => setTimeout(r, 120)); throw dropped(); }
765
+ return "ok";
766
+ },
767
+ },
768
+ metrics,
769
+ { timeoutMs: 2000, retries: 3 },
770
+ );
771
+ await app.post();
772
+ const p50 = summarise(metrics).methods[0].latencyMs.p50;
773
+ assert.ok(p50 < 100, `p50 was ${p50}ms — the failed 120ms attempt leaked into it`);
774
+ });
775
+
776
+ check("a run with only network trouble is inconclusive, not clean and not their fault", () => {
777
+ const report = buildReport({
778
+ ...inconclusiveShape,
779
+ metrics: (() => {
780
+ const m = createMetrics();
781
+ const b = { method: "post", calls: 2, failures: 1, apiFailures: 0, transportFailures: 1,
782
+ retries: 3, durations: [10, 20], errors: new Map([["fetch failed", 1]]),
783
+ firstErrorAt: Date.now() };
784
+ m.methods.set("post", b);
785
+ return m;
786
+ })(),
787
+ });
788
+ assert.equal(report.verdict.status, "inconclusive", "must not claim clean");
789
+ assert.equal(report.verdict.apiFailures, 0);
790
+ const text = renderReport(report);
791
+ assert.ok(/Inconclusive/.test(text));
792
+ assert.ok(/not your code|not your code\./i.test(text) || /network between/i.test(text),
793
+ "must say plainly that this was the network");
794
+ assert.ok(/CONNECTION/.test(text), "and show the connection quality");
795
+ });
796
+
797
+ check("a real API failure is still 'problems-found' even on a bad link", () => {
798
+ const report = buildReport({
799
+ ...inconclusiveShape,
800
+ metrics: (() => {
801
+ const m = createMetrics();
802
+ m.methods.set("post", { method: "post", calls: 4, failures: 2, apiFailures: 1,
803
+ transportFailures: 1, retries: 5, durations: [10, 20, 30, 40],
804
+ errors: new Map([["permission denied", 1], ["fetch failed", 1]]), firstErrorAt: Date.now() });
805
+ return m;
806
+ })(),
807
+ });
808
+ assert.equal(report.verdict.status, "problems-found", "their bug must not be downgraded by noise");
809
+ const text = renderReport(report);
810
+ assert.ok(/Problems found/.test(text));
811
+ });
812
+
813
+ // --- 4g. giving up on a target that is simply gone -------------------------
814
+ // Retries fixed the flaky case and made the DEAD case worse: every call then
815
+ // cost the full deadline times every attempt. Observed on a real run — it
816
+ // stopped hanging and started grinding, 12 minutes without completing a tick.
817
+ // So a sustained outage has to end the run quickly instead.
818
+
819
+ const alwaysDown = () => {
820
+ const metrics = createMetrics();
821
+ let n = 0;
822
+ const app = instrument(
823
+ { name: "d", async post() { n += 1; throw dropped(); } },
824
+ metrics,
825
+ {
826
+ timeoutMs: 50,
827
+ retries: 3,
828
+ // A real cooldown is 15s, which a test cannot wait for. The behaviour
829
+ // under test is "does it eventually stop trying", not the duration.
830
+ breaker: new CircuitBreaker({ threshold: 4, cooldownMs: 1, giveUpAfterProbes: 1 }),
831
+ },
832
+ );
833
+ return { app, metrics, attempts: () => n };
834
+ };
835
+
836
+ check("the breaker opens after a run of unreachable calls", async () => {
837
+ const { app, metrics } = alwaysDown();
838
+ for (let i = 0; i < 4; i++) await app.post().catch(() => {});
839
+ assert.equal(metrics.breaker.open, true, "four consecutive failures should trip it");
840
+ // Open is not the same as given up: the target may yet come back, and a run
841
+ // that recovers must not be reported as abandoned.
842
+ assert.equal(summarise(metrics).network.gaveUp, false, "open, but still willing to retry");
843
+ });
844
+
845
+ check("an abandoned breaker short-circuits instantly and reports giving up", async () => {
846
+ // Deliberately NOT driven by repeated calls through a fake adapter. That
847
+ // version passed standalone and failed inside the suite, because the fake
848
+ // rejects instantly and whether the cooldown elapsed depended on wall-clock
849
+ // milliseconds passing between calls — a race, not a behaviour. Whether the
850
+ // breaker reaches abandonment is covered above with an injected clock; what
851
+ // matters here is what instrument() does once it has.
852
+ const breaker = new CircuitBreaker({ threshold: 1, cooldownMs: 60_000, giveUpAfterProbes: 0 });
853
+ breaker.recordTransportFailure(0);
854
+ assert.equal(breaker.allows(120_000), false, "budget of 0 means the first probe is the last");
855
+ assert.equal(breaker.abandoned, true);
856
+
857
+ let reached = 0;
858
+ const metrics = createMetrics();
859
+ const app = instrument(
860
+ { name: "d", async post() { reached += 1; return "ok"; } },
861
+ metrics,
862
+ { timeoutMs: 500, retries: 3, breaker },
863
+ );
864
+
865
+ const started = Date.now();
866
+ await assert.rejects(() => app.post(), /unreachable/i);
867
+ assert.equal(reached, 0, "the adapter must not be called at all once abandoned");
868
+ assert.ok(Date.now() - started < 50, "and it must fail instantly, not wait");
869
+ assert.equal(summarise(metrics).network.gaveUp, true);
870
+ });
871
+
872
+ check("once open, calls fail instantly instead of burning the deadline", async () => {
873
+ const { app, metrics } = alwaysDown();
874
+ while (!metrics.breaker.open) await app.post().catch(() => {});
875
+ const started = Date.now();
876
+ await app.post().catch(() => {});
877
+ assert.ok(Date.now() - started < 40, "an open breaker must not wait or retry");
878
+ });
879
+
880
+ check("a single success closes the breaker again", async () => {
881
+ // A blip in the middle of an otherwise fine run must not abort that run.
882
+ const breaker = new CircuitBreaker({ threshold: 3 });
883
+ breaker.recordTransportFailure();
884
+ breaker.recordTransportFailure();
885
+ breaker.recordSuccess();
886
+ breaker.recordTransportFailure();
887
+ breaker.recordTransportFailure();
888
+ assert.equal(breaker.open, false, "non-consecutive failures must not trip it");
889
+ assert.equal(breaker.recordTransportFailure(), true, "three in a row should");
890
+ });
891
+
892
+ check("an error the API returned proves the link is alive and resets the breaker", async () => {
893
+ // Otherwise a genuinely broken endpoint looks like a dead network and kills
894
+ // the run that was about to find the bug.
895
+ const metrics = createMetrics();
896
+ const app = instrument(
897
+ { name: "d", async post() { throw new Error("permission denied for table posts"); } },
898
+ metrics,
899
+ { timeoutMs: 50, retries: 3, giveUpAfter: 3 },
900
+ );
901
+ for (let i = 0; i < 8; i++) await app.post().catch(() => {});
902
+ assert.equal(metrics.breaker.open, false, "their bug must not be mistaken for an outage");
903
+ assert.equal(summarise(metrics).apiFailures, 8);
904
+ });
905
+
906
+ check("giveUpAfter: 0 disables the breaker", async () => {
907
+ const metrics = createMetrics();
908
+ const app = instrument(
909
+ { name: "d", async post() { throw dropped(); } },
910
+ metrics,
911
+ { timeoutMs: 30, retries: 0, giveUpAfter: 0 },
912
+ );
913
+ for (let i = 0; i < 10; i++) await app.post().catch(() => {});
914
+ assert.equal(metrics.breaker.open, false);
915
+ });
916
+
917
+ check("a run cut short says so, and is never called clean", () => {
918
+ const m = createMetrics();
919
+ m.breaker = { open: true, abandoned: true, openedAfter: 12, trips: 1 };
920
+ m.methods.set("post", { method: "post", calls: 5, failures: 5, apiFailures: 0,
921
+ transportFailures: 5, retries: 9, durations: [1, 1, 1, 1, 1],
922
+ errors: new Map([["Target became unreachable", 5]]), firstErrorAt: Date.now() });
923
+ const report = buildReport({ ...inconclusiveShape, metrics: m });
924
+ assert.notEqual(report.verdict.status, "clean");
925
+ assert.ok(
926
+ report.verdict.problems.some((p) => /stopped early/i.test(p)),
927
+ "the report must admit it covers less than it was asked to",
928
+ );
929
+ assert.ok(/incomplete/i.test(renderReport(report)));
930
+ });
931
+
932
+ check("cleanup gets a fresh budget after the run gave up", async () => {
933
+ // A real run left FIVE invented accounts live in a customer project because
934
+ // the breaker was still open at teardown, so every deleteUser failed in 0ms.
935
+ // The safety mechanism caused the exact harm the product exists to avoid.
936
+ const breaker = new CircuitBreaker({ threshold: 2 });
937
+ breaker.recordTransportFailure();
938
+ breaker.recordTransportFailure();
939
+ assert.equal(breaker.open, true, "precondition: the run gave up");
940
+
941
+ const deleted = [];
942
+ const metrics = createMetrics();
943
+ const app = instrument(
944
+ { name: "t", async createUser() { return { id: "u" }; }, async deleteUser(u) { deleted.push(u.id); } },
945
+ metrics,
946
+ { timeoutMs: 500, retries: 0, breaker },
947
+ );
948
+
949
+ metrics.breaker.reset();
950
+ assert.equal(breaker.open, false, "cleanup must not inherit the run's verdict");
951
+
952
+ const personas = buildPersonas(2, ["manila"]);
953
+ const world = new World({ adapter: app, personas, identity: {}, hooks: {} });
954
+ world.agents = personas.map((p, i) => {
955
+ const a = new Agent(p, app, i, {});
956
+ a.user = { id: `u${i}` };
957
+ return a;
958
+ });
959
+ const result = await world.teardown();
960
+ assert.equal(result.removed, 2, "both accounts must actually be removed");
961
+ assert.equal(deleted.length, 2);
962
+ });
963
+
964
+ // --- 4h. the breaker has to be able to come back ---------------------------
965
+ // Two live runs died at tick 1 of 60 on a link that was working seconds before
966
+ // and worked again seconds after. The breaker opened on the first burst and
967
+ // stayed open for the whole run.
968
+ //
969
+ // The mistake underneath was a probability one: transport failures were assumed
970
+ // independent, so twelve in a row looked like a 1-in-4000 event. Real loss is
971
+ // bursty — when a mobile link drops it drops for seconds — so twelve in a row
972
+ // is simply what any ordinary outage looks like. A breaker without a recovery
973
+ // path therefore ends every run on the first blip.
974
+
975
+ check("the breaker reopens the circuit after its cooldown", () => {
976
+ const b = new CircuitBreaker({ threshold: 2, cooldownMs: 1000 });
977
+ const t0 = 10_000;
978
+ b.recordTransportFailure(t0); b.recordTransportFailure(t0);
979
+ assert.equal(b.open, true);
980
+ assert.equal(b.allows(t0 + 100), false, "still cooling down");
981
+ assert.equal(b.allows(t0 + 1100), true, "cooldown elapsed — one probe allowed");
982
+ });
983
+
984
+ check("a probe that succeeds closes the breaker and the run continues", () => {
985
+ const b = new CircuitBreaker({ threshold: 2, cooldownMs: 1000 });
986
+ const t0 = 10_000;
987
+ b.recordTransportFailure(t0); b.recordTransportFailure(t0);
988
+ assert.equal(b.allows(t0 + 1100), true);
989
+ b.recordSuccess();
990
+ assert.equal(b.open, false);
991
+ assert.equal(b.abandoned, false);
992
+ assert.equal(b.allows(t0 + 1200), true, "back to normal, no cooldown");
993
+ });
994
+
995
+ check("it still abandons a target that is genuinely gone", () => {
996
+ const b = new CircuitBreaker({ threshold: 2, cooldownMs: 1000, giveUpAfterProbes: 3 });
997
+ let t = 10_000;
998
+ b.recordTransportFailure(t); b.recordTransportFailure(t);
999
+ // Every probe fails: four cooldowns pass, the budget runs out.
1000
+ for (let i = 0; i < 3; i++) { t += 1100; assert.equal(b.allows(t), true, `probe ${i + 1}`); }
1001
+ t += 1100;
1002
+ assert.equal(b.allows(t), false, "budget spent — stop trying");
1003
+ assert.equal(b.abandoned, true);
1004
+ });
1005
+
1006
+ check("a recovered outage is NOT reported as having given up", () => {
1007
+ // The distinction that matters to a customer: a run that rode out a blip is
1008
+ // complete, and calling it incomplete would understate a good result.
1009
+ const b = new CircuitBreaker({ threshold: 2, cooldownMs: 1000 });
1010
+ const t0 = 10_000;
1011
+ b.recordTransportFailure(t0); b.recordTransportFailure(t0);
1012
+ b.allows(t0 + 1100); b.recordSuccess();
1013
+ const m = createMetrics(); m.breaker = b;
1014
+ m.methods.set("post", { method:"post", calls:4, failures:2, apiFailures:0, transportFailures:2,
1015
+ retries:3, durations:[10,20,30,40], errors:new Map(), firstErrorAt:Date.now() });
1016
+ const s = summarise(m);
1017
+ assert.equal(s.network.gaveUp, false, "it recovered — the run was not abandoned");
1018
+ assert.equal(s.network.outages, 1, "but the outage is still reported");
1019
+ });
1020
+
1021
+ check("recovering resets the probe budget for a later, separate outage", () => {
1022
+ const b = new CircuitBreaker({ threshold: 1, cooldownMs: 100, giveUpAfterProbes: 2 });
1023
+ let t = 1000;
1024
+ b.recordTransportFailure(t);
1025
+ t += 150; b.allows(t); b.recordSuccess(); // outage one, survived
1026
+ b.recordTransportFailure(t); // outage two, later
1027
+ t += 150; assert.equal(b.allows(t), true, "full allowance again, not inherited");
1028
+ });
1029
+
1030
+ // --- 4i. an adapter must not lose the error that mattered -----------------
1031
+ // The Buzz Buzz adapter falls back to signIn when signUp fails, and threw only
1032
+ // the fallback's error. Two nights of debugging chased "Invalid login
1033
+ // credentials" when the real cause was signUp being refused and the sign-in
1034
+ // then failing merely because the account had never been created. This is a
1035
+ // generic hazard for any adapter with a fallback, so it is tested generically.
1036
+
1037
+ check("a fallback failure reports the original cause, not just the symptom", async () => {
1038
+ const metrics = createMetrics();
1039
+ const app = instrument(
1040
+ {
1041
+ name: "f",
1042
+ async createUser() {
1043
+ const first = new Error("email rate limit exceeded");
1044
+ try {
1045
+ throw first; // primary path refused
1046
+ } catch (primary) {
1047
+ const second = new Error("Invalid login credentials"); // fallback also fails
1048
+ throw new Error(`${second.message} (signup first failed: ${primary.message})`);
1049
+ }
1050
+ },
1051
+ },
1052
+ metrics,
1053
+ { timeoutMs: 500, retries: 0 },
1054
+ );
1055
+
1056
+ const err = await app.createUser().catch((e) => e);
1057
+ assert.match(err.message, /Invalid login credentials/, "the symptom is still reported");
1058
+ assert.match(err.message, /rate limit exceeded/, "and so is the cause that explains it");
1059
+
1060
+ // And the report must carry both, since that is where a customer reads it.
1061
+ const shape = summarise(metrics).methods[0].errors[0].message;
1062
+ assert.match(shape, /rate limit/, "the grouped error must not drop the cause either");
1063
+ });
1064
+
1065
+ // --- 4j. the smoke test, which is what a stranger meets first --------------
1066
+ // `doctor` says which methods EXIST; `smoke` says whether they WORK. It is the
1067
+ // only thing standing between someone's first adapter and a five-minute run
1068
+ // that reports nonsense because post() returned undefined.
1069
+
1070
+ const smokeAdapter = (over = {}) => ({
1071
+ name: "s",
1072
+ async createUser() { return { id: "u1" }; },
1073
+ async post() { return "p1"; },
1074
+ async recentPostsByOthers() { return []; },
1075
+ async like() {},
1076
+ async comment() {},
1077
+ async openConversation() { return "c1"; },
1078
+ async sendMessage() {},
1079
+ async listGroups() { return [{ id: "g1" }]; },
1080
+ async joinGroup() {},
1081
+ async deleteUser() {},
1082
+ ...over,
1083
+ });
1084
+
1085
+ check("smoke passes a correct adapter", async () => {
1086
+ const { results, fatal } = await smoke({ adapter: smokeAdapter(), persona: smokePersona() });
1087
+ assert.equal(fatal, false);
1088
+ assert.equal(results.filter((r) => r.status === "fail").length, 0,
1089
+ JSON.stringify(results.filter((r) => r.status === "fail")));
1090
+ });
1091
+
1092
+ check("smoke stops immediately when createUser is missing", async () => {
1093
+ const { results, fatal } = await smoke({
1094
+ adapter: { name: "s", async deleteUser() {} }, persona: smokePersona(),
1095
+ });
1096
+ assert.equal(fatal, true, "nothing can be attempted without an identity");
1097
+ assert.equal(results.length, 1, "and it should not pretend to have tried the rest");
1098
+ });
1099
+
1100
+ check("smoke catches createUser returning no id", async () => {
1101
+ const { results, fatal } = await smoke({
1102
+ adapter: smokeAdapter({ async createUser() { return { nope: 1 }; } }), persona: smokePersona(),
1103
+ });
1104
+ assert.equal(fatal, true);
1105
+ assert.match(results[0].detail, /no `id`/);
1106
+ });
1107
+
1108
+ check("smoke catches post returning undefined", async () => {
1109
+ // NOT `async post() {}` — an empty body is a stub, and isStub correctly calls
1110
+ // that "not implemented", so it is skipped rather than failed. The bug being
1111
+ // tested is different: a method that really runs and forgets to return.
1112
+ const { results } = await smoke({
1113
+ adapter: smokeAdapter({
1114
+ async post(user, text) {
1115
+ await Promise.resolve();
1116
+ void user; void text; // did the work, returned nothing
1117
+ },
1118
+ }),
1119
+ persona: smokePersona(),
1120
+ });
1121
+ const post = results.find((r) => r.method === "post");
1122
+ assert.equal(post.status, "fail");
1123
+ assert.match(post.detail, /return the new post/i);
1124
+ });
1125
+
1126
+ check("smoke catches a feed that is not an array", async () => {
1127
+ const { results } = await smoke({
1128
+ adapter: smokeAdapter({ async recentPostsByOthers() { return { rows: [] }; } }),
1129
+ persona: smokePersona(),
1130
+ });
1131
+ assert.equal(results.find((r) => r.method === "recentPostsByOthers").status, "fail");
1132
+ });
1133
+
1134
+ check("smoke reports a throwing method rather than dying", async () => {
1135
+ const { results } = await smoke({
1136
+ adapter: smokeAdapter({ async like() { throw new Error("column does not exist"); } }),
1137
+ persona: smokePersona(),
1138
+ });
1139
+ const like = results.find((r) => r.method === "like");
1140
+ assert.equal(like.status, "fail");
1141
+ assert.match(like.detail, /column does not exist/, "the real error must survive");
1142
+ // and the run must have continued past it
1143
+ assert.ok(results.some((r) => r.method === "deleteUser"), "later methods must still be tried");
1144
+ });
1145
+
1146
+ check("smoke skips what is not implemented instead of failing it", async () => {
1147
+ const { results } = await smoke({
1148
+ adapter: { name: "s", async createUser() { return { id: "u" }; }, async deleteUser() {} },
1149
+ persona: smokePersona(),
1150
+ });
1151
+ const skipped = results.filter((r) => r.status === "skip");
1152
+ assert.ok(skipped.length > 5, "an adapter with two methods is incomplete, not broken");
1153
+ assert.equal(results.filter((r) => r.status === "fail").length, 0);
1154
+ });
1155
+
1156
+ check("smoke always removes the account it created", async () => {
1157
+ let deleted = false;
1158
+ await smoke({
1159
+ adapter: smokeAdapter({ async deleteUser() { deleted = true; } }), persona: smokePersona(),
1160
+ });
1161
+ assert.equal(deleted, true, "leaving an account behind is a bad first impression");
1162
+ });
1163
+
1164
+ check("smoke says so when it cannot clean up after itself", async () => {
1165
+ const { results } = await smoke({
1166
+ adapter: { name: "s", async createUser() { return { id: "u" }; } }, persona: smokePersona(),
1167
+ });
1168
+ const del = results.find((r) => r.method === "deleteUser");
1169
+ assert.equal(del.status, "skip");
1170
+ assert.match(del.detail, /populace clean/, "and must say how to remove it");
1171
+ });
1172
+
1173
+ // --- 5. session expiry ----------------------------------------------------
1174
+ // Tokens expire. Without a refresh, every agent starts failing at once and the
1175
+ // run reports a catastrophe that belongs to us, not to the customer's app —
1176
+ // and cannot even delete its own accounts afterwards, stranding invented
1177
+ // people in someone else's environment.
1178
+ function expiringApp({ supportsRefresh, ttlMs = 120 }) {
1179
+ const sessions = new Map();
1180
+ const live = (user) => {
1181
+ if (!(sessions.get(user.id) > Date.now())) throw new Error("JWT expired");
1182
+ };
1183
+ const app = {
1184
+ name: "expiring",
1185
+ refreshes: 0,
1186
+ async createUser({ phone }) {
1187
+ const id = `u_${phone}`;
1188
+ sessions.set(id, Date.now() + ttlMs);
1189
+ return { id };
1190
+ },
1191
+ async reportLocation(u) {
1192
+ live(u);
1193
+ },
1194
+ async deleteUser(u) {
1195
+ live(u);
1196
+ sessions.delete(u.id);
1197
+ },
1198
+ };
1199
+ if (supportsRefresh) {
1200
+ app.refreshSession = async (u) => {
1201
+ if (!sessions.has(u.id)) throw new Error("no session");
1202
+ app.refreshes += 1;
1203
+ sessions.set(u.id, Date.now() + ttlMs);
1204
+ };
1205
+ }
1206
+ return app;
1207
+ }
1208
+
1209
+ async function runExpiring(supportsRefresh) {
1210
+ const app = expiringApp({ supportsRefresh });
1211
+ const metrics = createMetrics();
1212
+ const world = new World({
1213
+ adapter: instrument(app, metrics),
1214
+ personas: buildPersonas(3, ["manila"]),
1215
+ options: { refreshEveryMs: 40 },
1216
+ });
1217
+ await world.populate({ staggerMs: 0 });
1218
+ await world.run({ minutes: 0.02, tickSeconds: 0.06, realtime: true });
1219
+ const teardown = await world.teardown();
1220
+ metrics.endedAt = Date.now();
1221
+ return { app, api: summarise(metrics), teardown };
1222
+ }
1223
+
1224
+ const expired = await runExpiring(false);
1225
+ const refreshed = await runExpiring(true);
1226
+
1227
+ check("without refresh, an expired session poisons the whole run", () => {
1228
+ assert.ok(expired.api.failures > 0, "the unrefreshed run should have failed");
1229
+ assert.ok(
1230
+ expired.api.methods.some((m) => m.errors.some((e) => /expired/i.test(e.message))),
1231
+ "and should say why",
1232
+ );
1233
+ });
1234
+
1235
+ check("refreshSession keeps a run alive past token expiry", () => {
1236
+ assert.ok(refreshed.app.refreshes > 0, "refreshSession should have been called");
1237
+ assert.equal(refreshed.api.failures, 0, "no call should have failed");
1238
+ });
1239
+
1240
+ check("an expired run cannot even clean up after itself", () => {
1241
+ assert.ok(expired.teardown.failed.length > 0, "stranded accounts are the real damage");
1242
+ assert.equal(refreshed.teardown.failed.length, 0);
1243
+ assert.equal(refreshed.teardown.removed, 3);
1244
+ });
1245
+
1246
+ // A refresh token can itself be revoked or expire. Signing in again from
1247
+ // scratch is the last line of defence before an agent goes quietly dead.
1248
+ const revoked = expiringApp({ supportsRefresh: true });
1249
+ revoked.refreshSession = async () => {
1250
+ throw new Error("refresh token revoked");
1251
+ };
1252
+ const reauthAgent = new Agent(buildPersonas(1, ["manila"])[0], revoked, 0, { refreshEveryMs: 0 });
1253
+ await reauthAgent.ensureAccount();
1254
+ await reauthAgent.ensureFreshSession();
1255
+
1256
+ check("a dead refresh token falls back to full re-authentication", () => {
1257
+ assert.equal(reauthAgent.stats.reauths, 1, "should have signed in again");
1258
+ assert.ok(reauthAgent.log.includes("re-authenticated"));
1259
+ });
1260
+
1261
+ // --- 6. no tolerance band -------------------------------------------------
1262
+ // Found by a real user run: 1 failure in 114 calls is 0.88%, which slipped
1263
+ // under an old 1%-tolerance and was reported as "No failures" while the table
1264
+ // right below it showed the failure — and the process exited 0.
1265
+ // Built directly, because reproducing 114 calls through a live run is slow and
1266
+ // the thing under test is the verdict logic, not the engine.
1267
+ {
1268
+ const metrics = createMetrics();
1269
+ const app = instrument(
1270
+ {
1271
+ name: "rare",
1272
+ async ok() {},
1273
+ async bad() {
1274
+ throw new Error('new row violates row-level security policy "post_likes_insert"');
1275
+ },
1276
+ },
1277
+ metrics,
1278
+ );
1279
+ for (let i = 0; i < 113; i++) await app.ok();
1280
+ try {
1281
+ await app.bad();
1282
+ } catch {
1283
+ /* expected */
1284
+ }
1285
+ metrics.endedAt = Date.now();
1286
+
1287
+ const rare = buildReport({
1288
+ config,
1289
+ adapter: clean,
1290
+ world: cleanWorld,
1291
+ metrics,
1292
+ teardown: cleanTeardown,
1293
+ startedAt: Date.now() - 1000,
1294
+ });
1295
+
1296
+ check("0.88% failure rate is still a problem, not 'clean'", () => {
1297
+ assert.equal(rare.api.calls, 114);
1298
+ assert.equal(rare.api.failures, 1);
1299
+ assert.equal(rare.verdict.status, "problems-found", "a real failure must fail the run");
1300
+ assert.ok(rare.verdict.problems[0].includes("1 of 114"));
1301
+ });
1302
+
1303
+ check("the verdict line can never contradict the table beneath it", () => {
1304
+ const text = renderReport(rare);
1305
+ assert.ok(!text.includes("No failures"), "must not claim 'No failures' when one failed");
1306
+ assert.ok(text.includes("✖"), "must show the failure");
1307
+ });
1308
+ }
1309
+
1310
+ // --- 7. the report renders ------------------------------------------------
1311
+ check("the report renders without throwing", () => {
1312
+ const text = renderReport(flakyReport);
1313
+ assert.ok(text.includes("POPULACE REPORT"));
1314
+ assert.ok(text.includes("✖"), "problems should be visible at a glance");
1315
+ });
1316
+
1317
+ // --- 8. a bug in Populace is never reported as the customer's --------------
1318
+ {
1319
+ const adapter = inMemoryAdapter();
1320
+ const world = new World({
1321
+ adapter: instrument(adapter, createMetrics()),
1322
+ personas: buildPersonas(2, ["manila"]),
1323
+ options: {},
1324
+ on: {},
1325
+ });
1326
+ await world.populate({ staggerMs: 0 });
1327
+
1328
+ // Break the engine, not the adapter: this failure never reaches a wrapped
1329
+ // call, so nothing in the metrics will ever know about it.
1330
+ world.agents[0].post = () => {
1331
+ throw new TypeError("engine bug: cannot read properties of undefined");
1332
+ };
1333
+ world.agents[0].persona.postiness = 1;
1334
+ world.agents[0].persona.breakiness = 0;
1335
+ await world.agents[0].tick(5, world);
1336
+
1337
+ check("a bug inside Populace is kept, not swallowed", () => {
1338
+ const engineErrors = world.engineErrors();
1339
+ assert.equal(engineErrors.length, 1);
1340
+ assert.ok(engineErrors[0].includes("engine bug"));
1341
+ });
1342
+
1343
+ const report = buildReport({
1344
+ config: { app: "x", adapter: "y", environment: "test", population: {} },
1345
+ adapter,
1346
+ world,
1347
+ metrics: createMetrics(),
1348
+ teardown: { failed: [] },
1349
+ startedAt: Date.now() - 1000,
1350
+ });
1351
+
1352
+ check("a run that broke internally is never reported clean", () => {
1353
+ assert.equal(report.verdict.status, "problems-found");
1354
+ assert.ok(report.verdict.problems.some((p) => p.includes("inside Populace")));
1355
+ });
1356
+ }
1357
+
1358
+ // Async checks must settle before the total is printed. Exiting synchronously
1359
+ // would report "all passed" while an async assertion was still in flight — a
1360
+ // test suite lying about its own result, in a product whose entire argument is
1361
+ // that a report must never claim more than it has earned.
1362
+ await Promise.all(pending);
1363
+
1364
+ console.log(
1365
+ failed
1366
+ ? `\n ${failed} check(s) failed.\n`
1367
+ : `\n All checks passed — the engine is app-agnostic and the report is honest.\n`,
1368
+ );
1369
+ process.exit(failed ? 1 : 0);