@gigzen/populace 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -267,9 +267,13 @@ export ANTHROPIC_API_KEY=sk-ant-...
267
267
  populace explain
268
268
  ```
269
269
 
270
- Rules run first and the model only ever sees what they could not name so most
271
- runs never call it at all, and the ones that do send a handful of short strings.
272
- Explanations that came from the model are labelled as such.
270
+ Rules run first and the model only ever sees what they could not name, so most
271
+ runs never call it at all. What is sent, exactly: the method name, the error
272
+ message truncated to 500 characters, how many times it happened, and two
273
+ latency numbers. No URL, no credential, no persona, nothing about your
274
+ configuration. Note that the error message is your application's own text, so
275
+ it can carry a table or column name — read one before you enable this if that
276
+ matters to you. Explanations that came from the model are labelled as such.
273
277
 
274
278
  **What is sent, exactly:** the method name, the error text, how many times it
275
279
  happened, and that method's p50/p95. Nothing else — no target URL, no keys, no
@@ -0,0 +1,63 @@
1
+ // Buzz against the local Supabase stack, at a scale the hosted project refuses.
2
+ //
3
+ // Why this file exists rather than reusing examples/buzzbuzz:
4
+ //
5
+ // The hosted test project applies Supabase's default auth rate limit of 30 sign
6
+ // ups per five minutes per IP address. Measured on 2026-08-24: a 250-driver run
7
+ // signs in 35 people and every remaining identity fails with "Request rate limit
8
+ // reached". That limit is correct for real users, who each arrive from their own
9
+ // address. A load test is the one case where every request shares a single IP,
10
+ // so it is the one case the limit cannot accommodate.
11
+ //
12
+ // The local stack raises it in supabase/config.toml, which a hosted project
13
+ // cannot do from a file. So large runs belong here.
14
+ //
15
+ // Everything is self-contained: no environment variables. Studio passes a config
16
+ // path and nothing else, so a config that reads process.env works from a shell
17
+ // and silently targets `undefined` when launched from Explorer.
18
+ //
19
+ // Start the backend first, from i-want-to-make-one-app:
20
+ // npx supabase start
21
+ //
22
+ // Then either pick this file in Populace Studio, or:
23
+ // node src/cli.mjs run --config examples/buzzbuzz-local/populace.config.mjs
24
+
25
+ export default {
26
+ app: "Buzz",
27
+ adapter: "../../adapters/buzzbuzz.mjs",
28
+ environment: "test",
29
+
30
+ // The local stack's fixed development address and publishable key. These are
31
+ // the same on every machine that runs `supabase start` — they are not secrets
32
+ // and nothing outside this computer can reach them.
33
+ target: {
34
+ url: "http://127.0.0.1:54321",
35
+ key: "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH",
36
+ },
37
+
38
+ // Kept identical to examples/buzzbuzz. A local run is no reason to relax the
39
+ // guard: if one of these ever appears in `target.url` above, refuse to start.
40
+ neverRunAgainst: [
41
+ "https://rqzuuvlougzhynckvqzd.supabase.co",
42
+ "https://ypdaetbeexyepswyhbui.supabase.co",
43
+ ],
44
+
45
+ population: {
46
+ agents: 250,
47
+ // The same twenty-five cities as the 200-driver run of 23 August, so head
48
+ // count is the only variable that changed between the two.
49
+ cities: [
50
+ "manila", "mumbai", "delhi", "jakarta", "saopaulo",
51
+ "mexicocity", "bogota", "lima", "lagos", "nairobi",
52
+ "accra", "cairo", "johannesburg", "istanbul", "dubai",
53
+ "riyadh", "moscow", "kyiv", "warsaw", "madrid",
54
+ "almaty", "tashkent", "baku", "casablanca", "amman",
55
+ ],
56
+ minutes: 20,
57
+ tickSeconds: 2,
58
+ engagement: 4,
59
+ },
60
+
61
+ identity: { phonePrefix: "0900" },
62
+ report: { path: "populace-report.json" },
63
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gigzen/populace",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "A simulated population that uses your app through its real API, so you can test what needs more than one person.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,9 @@
15
15
  "src",
16
16
  "adapters",
17
17
  "examples",
18
+ "!**/populace-report.json",
19
+ "!**/populace-report.html",
20
+ "!examples/buzzbuzz/run-test.ps1",
18
21
  "populace.config.example.mjs",
19
22
  "README.md",
20
23
  "LICENSE",
package/src/cli.mjs CHANGED
@@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
12
12
  import { ConfigError, loadAdapter, loadConfig } from "./config.mjs";
13
13
  import { createMetrics, instrument } from "./instrument.mjs";
14
14
  import { buildReport, renderReport, writeReport } from "./report.mjs";
15
+ import { createProgress } from "./progress.mjs";
15
16
  import { canSignInOnly } from "./contract.mjs";
16
17
  import { isTransportError } from "./net.mjs";
17
18
  import { diagnose } from "./diagnose.mjs";
@@ -55,6 +56,7 @@ function overridesFromFlags() {
55
56
  if (flag("cities")) o.cities = String(flag("cities")).split(",").map((s) => s.trim()).filter(Boolean);
56
57
  if (flag("engagement")) o.engagement = Number(flag("engagement"));
57
58
  if (flag("report")) o.reportPath = flag("report");
59
+ if (flag("stagger")) o.signupStaggerMs = Number(flag("stagger"));
58
60
  return o;
59
61
  }
60
62
 
@@ -187,6 +189,28 @@ async function doctor() {
187
189
 
188
190
  const d = diagnose({ config, adapter: raw, reachable });
189
191
 
192
+ // --json for anything that has to describe a config without printing it -
193
+ // the desktop app shows this on the Run screen, so you can see what you are
194
+ // about to point a population at before you start one.
195
+ if (has("json")) {
196
+ console.log(JSON.stringify({
197
+ app: config.app || null,
198
+ environment: config.environment,
199
+ adapter: config.adapter,
200
+ target: config.target?.url || null,
201
+ coverage: d.coverage,
202
+ guarded: d.guarded,
203
+ cleanup: d.cleanup,
204
+ reachable,
205
+ reachError: reachable === false ? reachError : null,
206
+ ready: d.ready,
207
+ blockers: d.blockers,
208
+ population: config.population,
209
+ }));
210
+ if (!d.ready) process.exitCode = 1;
211
+ return;
212
+ }
213
+
190
214
  console.log(`
191
215
  Config ${path.basename(config._file)}`);
192
216
  console.log(` App ${config.app || raw.name || "(unnamed)"}`);
@@ -231,13 +255,41 @@ async function run() {
231
255
  const startedAt = Date.now();
232
256
 
233
257
  const { agents, cities, minutes, tickSeconds } = config.population;
258
+
259
+ // Anything watching this run rather than reading it - the desktop app today,
260
+ // a dashboard tomorrow - gets the same numbers the terminal table shows,
261
+ // every tick, as JSON. Off unless asked, so ordinary output is untouched.
262
+ const progress = createProgress({ enabled: flag("progress") === "json" });
263
+ progress.start(config);
264
+
234
265
  console.log(`\n Bringing ${agents} people to life across ${cities.join(", ")}…\n`);
235
266
 
267
+ // Sign-ups are counted here rather than in the engine, so the engine keeps
268
+ // knowing nothing about who is watching.
269
+ let joinedSoFar = 0;
236
270
  const world = World.fromConfig(config, adapter, {
237
- joined: (a) => console.log(` ✓ ${a.persona.name} (${a.persona.city.name}, ${a.persona.platform})`),
238
- joinFailed: (p, e) => console.log(` ${p.name}: ${e.message}`),
271
+ joined: (a) => {
272
+ console.log(` ${a.persona.name} (${a.persona.city.name}, ${a.persona.platform})`);
273
+ progress.joining({
274
+ done: ++joinedSoFar, total: agents, ok: true,
275
+ name: a.persona.name, city: a.persona.city.name,
276
+ });
277
+ },
278
+ joinFailed: (p, e) => {
279
+ console.log(` ✖ ${p.name}: ${e.message}`);
280
+ progress.joining({
281
+ done: ++joinedSoFar, total: agents, ok: false,
282
+ name: p.name, city: p.city?.name,
283
+ });
284
+ },
285
+ // Said out loud, because waiting silently is indistinguishable from hanging.
286
+ // Retrying a throttled sign-up can add seconds per person, and a run that
287
+ // pauses without explanation is a run somebody kills.
288
+ joinThrottled: (p, attempt) =>
289
+ console.log(` … ${p.name}: rate limited, waiting (attempt ${attempt})`),
239
290
  tick: (n, total, w) => {
240
291
  render(config, n, total, w);
292
+ progress.tick(n, total, w, metrics);
241
293
  // Stop as soon as the target is judged gone. Grinding out the remaining
242
294
  // ticks against a dead host wastes the operator's time and adds nothing
243
295
  // to the report.
@@ -280,6 +332,7 @@ async function run() {
280
332
 
281
333
  metrics.endedAt = Date.now();
282
334
  const report = buildReport({ config, adapter: raw, world, metrics, teardown, startedAt });
335
+ progress.done(report);
283
336
  const files = writeReport(report, config);
284
337
  console.log(renderReport(report));
285
338
  console.log(` Report: ${displayPath(files.json)}`);
@@ -617,6 +670,19 @@ async function explainCmd() {
617
670
  }
618
671
 
619
672
  const explained = explainReport(report);
673
+
674
+ // --json for anything rendering this itself rather than reading a terminal.
675
+ // Same explanations, same order; only the presentation differs, so a window
676
+ // cannot show a cause the command line would not.
677
+ if (has("json")) {
678
+ console.log(JSON.stringify({
679
+ report: reportPath,
680
+ verdict: explained.length ? verdictLine(explained) : null,
681
+ explained,
682
+ }));
683
+ return;
684
+ }
685
+
620
686
  if (!explained.length) {
621
687
  console.log(`
622
688
  Nothing failed in ${displayPath(reportPath)}. Nothing to explain.
@@ -707,6 +773,8 @@ if (!commands[command]) {
707
773
  --tick <seconds> simulated seconds per step
708
774
  --cities <a,b> ${Object.keys(CITIES).join(", ")}
709
775
  --engagement <x> how busy people are; 1 = normal, 5 = relentless
776
+ --stagger <ms> gap between sign-ups; raise it for a
777
+ throttled auth endpoint (default 400)
710
778
  --report <path> where to write the report
711
779
  --keep leave accounts in place after a run
712
780
  --file <path> which report to re-open (report)
package/src/config.mjs CHANGED
@@ -36,6 +36,19 @@ const DEFAULTS = {
36
36
  // Consecutive unreachable calls before Populace declares the target down and
37
37
  // stops, instead of retrying every call for the rest of the run. 0 disables.
38
38
  giveUpAfter: 12,
39
+ // Gap between sign-ups. Auth endpoints are throttled far harder than the rest
40
+ // of an API — Supabase's default is 30 sign-ups per five minutes per address,
41
+ // and 400ms is 2.5 per second. That default is right for real users, who each
42
+ // arrive from their own address, and impossible for a simulation, where every
43
+ // request shares one. Raise this to fit a target you do not control; a hosted
44
+ // Supabase project needs about 10_000.
45
+ signupStaggerMs: 400,
46
+ // When a sign-up is refused *for being too fast*, wait and try that person
47
+ // again rather than recording them as a failure. A rate limit says nothing
48
+ // about the application under test, so counting it as a finding is a lie.
49
+ // Backoff is this value times the attempt number. 0 disables retrying.
50
+ signupRateLimitBackoffMs: 5_000,
51
+ signupRateLimitRetries: 2,
39
52
  population: { agents: 6, cities: ["manila", "mumbai"], tickSeconds: 5, minutes: 10 },
40
53
  // Comfortably inside a 1-hour token, which is the common default.
41
54
  session: { refreshEveryMinutes: 30 },
@@ -43,8 +56,8 @@ const DEFAULTS = {
43
56
  };
44
57
 
45
58
  export async function loadConfig({ configPath, cwd = process.cwd(), overrides = {} } = {}) {
46
- // reportPath is not a population setting; keep it out of that spread.
47
- const { reportPath: _reportPath, ...populationOverrides } = overrides;
59
+ // Neither of these is a population setting; keep them out of that spread.
60
+ const { reportPath: _reportPath, signupStaggerMs: _stagger, ...populationOverrides } = overrides;
48
61
  const file = path.resolve(cwd, configPath || "populace.config.mjs");
49
62
 
50
63
  if (!fs.existsSync(file)) {
@@ -63,6 +76,9 @@ export async function loadConfig({ configPath, cwd = process.cwd(), overrides =
63
76
  const config = {
64
77
  ...DEFAULTS,
65
78
  ...loaded,
79
+ // --stagger wins over the config file, so a run can be paced to fit a target
80
+ // whose rate limit is not yours to change.
81
+ ...(overrides.signupStaggerMs ? { signupStaggerMs: overrides.signupStaggerMs } : {}),
66
82
  population: {
67
83
  ...DEFAULTS.population,
68
84
  ...(loaded.population || {}),
@@ -27,6 +27,13 @@ export class World {
27
27
  const options = {
28
28
  ...(config.identity || {}),
29
29
  refreshEveryMs: (config.session?.refreshEveryMinutes ?? 30) * 60_000,
30
+ // Sign-up pacing, so populate() does not have to be told twice. These were
31
+ // hardcoded in populate() until 2026-08-24, which meant a target throttling
32
+ // harder than 2.5 sign-ups a second could not be tested at all without
33
+ // editing this package's source.
34
+ signupStaggerMs: config.signupStaggerMs,
35
+ signupRateLimitBackoffMs: config.signupRateLimitBackoffMs,
36
+ signupRateLimitRetries: config.signupRateLimitRetries,
30
37
  };
31
38
  return new World({ adapter, personas, options, on });
32
39
  }
@@ -36,16 +43,37 @@ export class World {
36
43
  * a burst of simultaneous sign-ups produces a wall of 429s that looks like a
37
44
  * bug in the customer's app when it is really a bug in this harness.
38
45
  */
39
- async populate({ staggerMs = 400 } = {}) {
46
+ async populate({
47
+ staggerMs = this.options?.signupStaggerMs ?? 400,
48
+ rateLimitBackoffMs = this.options?.signupRateLimitBackoffMs ?? 5_000,
49
+ rateLimitRetries = this.options?.signupRateLimitRetries ?? 2,
50
+ } = {}) {
40
51
  for (const [i, persona] of this.personas.entries()) {
41
- const agent = new Agent(persona, this.adapter, i, this.options);
42
- try {
43
- await agent.ensureAccount();
44
- this.agents.push(agent);
45
- this.on.joined?.(agent);
46
- } catch (error) {
47
- this.signupFailures.push({ persona: persona.name, error: String(error.message || error) });
48
- this.on.joinFailed?.(persona, error);
52
+ let attempt = 0;
53
+ for (;;) {
54
+ const agent = new Agent(persona, this.adapter, i, this.options);
55
+ try {
56
+ await agent.ensureAccount();
57
+ this.agents.push(agent);
58
+ this.on.joined?.(agent);
59
+ break;
60
+ } catch (error) {
61
+ const message = String(error.message || error);
62
+ // Being refused for going too fast is the target pacing us, not a
63
+ // defect in it. Waiting is the honest response: at a hosted default of
64
+ // 30 sign-ups per five minutes a 250-person run otherwise loses 215
65
+ // people to an error that says nothing about the app under test.
66
+ const throttled = /rate limit|429|too many requests/i.test(message);
67
+ if (throttled && rateLimitBackoffMs > 0 && attempt < rateLimitRetries) {
68
+ attempt += 1;
69
+ this.on.joinThrottled?.(persona, attempt);
70
+ await sleep(rateLimitBackoffMs * attempt);
71
+ continue;
72
+ }
73
+ this.signupFailures.push({ persona: persona.name, error: message, throttled });
74
+ this.on.joinFailed?.(persona, error);
75
+ break;
76
+ }
49
77
  }
50
78
  if (staggerMs) await sleep(staggerMs);
51
79
  }
@@ -0,0 +1,153 @@
1
+ // A machine-readable view of a run, for anything watching it happen.
2
+ //
3
+ // The engine already knows everything anyone could want each tick - every
4
+ // person's city, platform, distance, activity and position, and the latency of
5
+ // every method. In a terminal it draws all of that as a table. Piped anywhere
6
+ // else it deliberately drops to a heartbeat every fifth of the run, because a
7
+ // full table written into a CI log would bury the report under thousands of
8
+ // screens of scrollback.
9
+ //
10
+ // That is right for CI and wrong for a window. Populace Studio spawns the CLI
11
+ // with its output piped, so it received the CI version: at a one-second tick
12
+ // over fifteen minutes, one update every three minutes. The application looked
13
+ // frozen while a quarter of a million calls went through it.
14
+ //
15
+ // So the data was never missing - only the transport. This is the transport.
16
+ //
17
+ // populace run --progress json
18
+ //
19
+ // Each line is `@@populace@@` followed by one JSON object. The prefix means a
20
+ // reader can pick these out of ordinary output without ambiguity, and nothing
21
+ // is emitted at all unless asked, so a human's terminal is unchanged.
22
+ //
23
+ // Percentiles are not computed every tick. summarise() sorts every recorded
24
+ // duration, and at a quarter of a million calls that is real work to do once a
25
+ // second for numbers nobody can read that fast.
26
+
27
+ export const PROGRESS_PREFIX = "@@populace@@";
28
+
29
+ /**
30
+ * Cheap per-method counters, straight off the metrics map - no sorting.
31
+ *
32
+ * The most common error message travels with them when there is one. A count
33
+ * of failures tells a watcher that something is wrong; only the message tells
34
+ * them what, and waiting for the report to find out is a long time to sit in
35
+ * front of a run that is already broken.
36
+ */
37
+ function counters(metrics) {
38
+ return [...metrics.methods.values()].map((e) => {
39
+ const row = {
40
+ method: e.method,
41
+ calls: e.calls,
42
+ apiFailures: e.apiFailures,
43
+ transportFailures: e.transportFailures,
44
+ retries: e.retries,
45
+ };
46
+ if (e.failures && e.errors?.size) {
47
+ let top = null;
48
+ for (const [message, count] of e.errors) if (!top || count > top.count) top = { message, count };
49
+ if (top) row.error = { message: top.message.slice(0, 300), count: top.count };
50
+ }
51
+ return row;
52
+ });
53
+ }
54
+
55
+ /** p50 and p95 per method. Costs a sort per method, so it runs rarely. */
56
+ function latencies(metrics) {
57
+ const out = {};
58
+ for (const e of metrics.methods.values()) {
59
+ if (!e.durations.length) continue;
60
+ const sorted = [...e.durations].sort((a, b) => a - b);
61
+ const at = (p) => sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];
62
+ out[e.method] = { p50: Math.round(at(50)), p95: Math.round(at(95)) };
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * @param {object} options
69
+ * @param {boolean} options.enabled emit anything at all
70
+ * @param {number} options.everyLatency ticks between percentile refreshes
71
+ * @param {(line: string) => void} options.write
72
+ */
73
+ export function createProgress({ enabled, everyLatency = 5, write = (l) => process.stdout.write(l) } = {}) {
74
+ if (!enabled) return { start() {}, joining() {}, tick() {}, done() {} };
75
+
76
+ const emit = (event) => {
77
+ try {
78
+ write(`${PROGRESS_PREFIX}${JSON.stringify(event)}\n`);
79
+ } catch {
80
+ // A watcher that has gone away is not a reason to fail a run.
81
+ }
82
+ };
83
+
84
+ const startedAt = Date.now();
85
+
86
+ return {
87
+ start(config) {
88
+ emit({
89
+ type: "start",
90
+ app: config.app || "populace",
91
+ environment: config.environment,
92
+ agents: config.population.agents,
93
+ cities: config.population.cities,
94
+ minutes: config.population.minutes,
95
+ tickSeconds: config.population.tickSeconds,
96
+ engagement: config.population.engagement ?? 1,
97
+ });
98
+ },
99
+
100
+ /**
101
+ * One event per person as they sign in, before any tick exists.
102
+ *
103
+ * Signing 250 people in takes minutes, and until this existed a watcher had
104
+ * nothing to show for it: every counter read zero, the progress bar stayed
105
+ * empty and the clock counted down as though the run were already under way.
106
+ * A window that looks identical to a hung one is a window people kill.
107
+ */
108
+ joining({ done, total, name, city, ok }) {
109
+ emit({ type: "joining", done, total, name, city, ok });
110
+ },
111
+
112
+ tick(tickNo, totalTicks, world, metrics) {
113
+ const t = world.totals();
114
+ emit({
115
+ type: "tick",
116
+ tick: tickNo,
117
+ totalTicks,
118
+ elapsedMs: Date.now() - startedAt,
119
+ totals: {
120
+ km: Number(t.km.toFixed(2)),
121
+ posts: t.posts || 0,
122
+ likes: t.likes || 0,
123
+ comments: t.comments || 0,
124
+ messages: t.messages || 0,
125
+ groupJoins: t.groupJoins || 0,
126
+ errors: t.errors || 0,
127
+ },
128
+ // Short keys: this is written once a second with a row per person, and
129
+ // the field names would otherwise be most of the bytes.
130
+ people: world.agents.map((a) => ({
131
+ n: a.persona.name,
132
+ c: a.persona.city.name,
133
+ y: a.persona.city.country,
134
+ p: a.persona.platform,
135
+ k: Number(a.distanceKm.toFixed(1)),
136
+ o: a.stats.posts || 0,
137
+ l: a.stats.likes || 0,
138
+ m: a.stats.messages || 0,
139
+ e: a.stats.errors || 0,
140
+ b: Boolean(a.onBreak),
141
+ la: Number(a.position.lat.toFixed(3)),
142
+ lo: Number(a.position.lng.toFixed(3)),
143
+ })),
144
+ methods: counters(metrics),
145
+ latency: tickNo % everyLatency === 0 || tickNo === totalTicks ? latencies(metrics) : undefined,
146
+ });
147
+ },
148
+
149
+ done(report) {
150
+ emit({ type: "done", verdict: report?.verdict?.status || "unknown" });
151
+ },
152
+ };
153
+ }
package/src/selftest.mjs CHANGED
@@ -1610,6 +1610,78 @@ check("POPULACE_NO_UPDATE_CHECK switches it off", () => {
1610
1610
  // would report "all passed" while an async assertion was still in flight — a
1611
1611
  // test suite lying about its own result, in a product whose entire argument is
1612
1612
  // that a report must never claim more than it has earned.
1613
+ // --- sign-up pacing and throttling -----------------------------------------
1614
+ // A rate limit is the target pacing us; a real error is a finding. Waiting out
1615
+ // the first is honest and waiting out the second would hide a defect, so the two
1616
+ // must never be treated alike. Added 2026-08-24 after a 250-person run lost 215
1617
+ // people to a limit that said nothing about the app under test.
1618
+ {
1619
+ const persona = () => ({
1620
+ name: "Test Person",
1621
+ city: { name: "Manila", country: "PH", lat: 14.6, lng: 121 },
1622
+ platform: "grab", rhythm: {}, engagement: 1,
1623
+ });
1624
+ const stub = (fail) => ({
1625
+ createUser: fail,
1626
+ async setProfile() {}, async refreshSession() {}, async reportLocation() {},
1627
+ async post() {}, async recentPostsByOthers() { return []; }, async like() {},
1628
+ async comment() {}, async openConversation() { return { id: "c" }; },
1629
+ async sendMessage() {}, async listGroups() { return []; }, async joinGroup() {},
1630
+ async deleteUser() {},
1631
+ });
1632
+ const throttling = (times) => {
1633
+ let n = 0;
1634
+ return stub(async () => {
1635
+ if (n++ < times) throw new Error("Request rate limit reached");
1636
+ return { id: "u" + n, token: "t" };
1637
+ });
1638
+ };
1639
+ const world = (adapter, options, on = {}) =>
1640
+ new World({ adapter, personas: [persona()], options, on });
1641
+ const pacing = { signupRateLimitBackoffMs: 10, signupRateLimitRetries: 2, signupStaggerMs: 0 };
1642
+
1643
+ const recovered = world(throttling(2), pacing);
1644
+ const attempts = [];
1645
+ recovered.on.joinThrottled = (_p, a) => attempts.push(a);
1646
+ await recovered.populate();
1647
+ check("a sign-up refused for being too fast is retried, not counted as a failure", () => {
1648
+ assert.equal(recovered.agents.length, 1, "the person should end up signed in");
1649
+ assert.equal(recovered.signupFailures.length, 0, "a rate limit is not a finding");
1650
+ assert.deepEqual(attempts, [1, 2], "each wait should be announced, not silent");
1651
+ });
1652
+
1653
+ const exhausted = world(throttling(99), pacing);
1654
+ await exhausted.populate();
1655
+ check("a rate limit that never lets up is recorded with its cause intact", () => {
1656
+ assert.equal(exhausted.agents.length, 0);
1657
+ assert.equal(exhausted.signupFailures[0].throttled, true,
1658
+ "the report must be able to tell throttling from a broken API");
1659
+ });
1660
+
1661
+ let realErrorCalls = 0;
1662
+ const genuine = world(stub(async () => {
1663
+ realErrorCalls += 1;
1664
+ throw new Error("duplicate key violates unique constraint");
1665
+ }), pacing);
1666
+ await genuine.populate();
1667
+ check("a genuine sign-up error is never retried or waited out", () => {
1668
+ assert.equal(realErrorCalls, 1, "retrying a finding would hide it");
1669
+ assert.equal(genuine.signupFailures[0].throttled, false);
1670
+ });
1671
+
1672
+ const paced = new World({
1673
+ adapter: throttling(0), personas: [persona(), persona(), persona()],
1674
+ options: { signupStaggerMs: 120 }, on: {},
1675
+ });
1676
+ const startedAt = Date.now();
1677
+ await paced.populate();
1678
+ const elapsed = Date.now() - startedAt;
1679
+ check("signupStaggerMs actually paces sign-ups", () => {
1680
+ assert.ok(elapsed >= 240,
1681
+ `three people at 120ms apart should take at least 240ms, took ${elapsed}ms`);
1682
+ });
1683
+ }
1684
+
1613
1685
  await Promise.all(pending);
1614
1686
 
1615
1687
  console.log(