@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 +7 -3
- package/examples/buzzbuzz-local/populace.config.mjs +63 -0
- package/package.json +4 -1
- package/src/cli.mjs +70 -2
- package/src/config.mjs +18 -2
- package/src/engine/world.mjs +37 -9
- package/src/progress.mjs +153 -0
- package/src/selftest.mjs +72 -0
- package/examples/buzzbuzz/populace-report.html +0 -254
- package/examples/buzzbuzz/populace-report.json +0 -298
- package/examples/buzzbuzz/run-test.ps1 +0 -61
- package/examples/demo/populace-report.html +0 -230
- package/examples/demo/populace-report.json +0 -219
- package/examples/rest-api/populace-report.html +0 -245
- package/examples/rest-api/populace-report.json +0 -280
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
|
|
271
|
-
runs never call it at all
|
|
272
|
-
|
|
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.
|
|
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) =>
|
|
238
|
-
|
|
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
|
-
//
|
|
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 || {}),
|
package/src/engine/world.mjs
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
}
|
package/src/progress.mjs
ADDED
|
@@ -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(
|