@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,65 @@
1
+ // populace.config.mjs
2
+ //
3
+ // Everything Populace needs to know about your app lives here and in your
4
+ // adapter. Nothing about your app should ever end up inside the engine.
5
+
6
+ export default {
7
+ app: "My App",
8
+
9
+ // Where the translation between "a person did something" and "your API"
10
+ // lives. See adapters/contract.md.
11
+ adapter: "./adapters/my-app.mjs",
12
+
13
+ // Populace only runs against non-production environments, and it checks.
14
+ // Lying here is possible; it is also entirely on you.
15
+ environment: "test",
16
+
17
+ // Handed to createAdapter(). Put whatever your adapter needs — URLs, keys,
18
+ // a base path. Read secrets from process.env rather than committing them.
19
+ target: {
20
+ url: process.env.MY_APP_TEST_URL,
21
+ key: process.env.MY_APP_TEST_KEY,
22
+ },
23
+
24
+ // The safety net that matters most. List every production host you own.
25
+ // If `target` ever resolves to one of these, Populace refuses to start —
26
+ // no flag overrides it.
27
+ neverRunAgainst: [
28
+ // "https://api.myapp.com",
29
+ // "https://xxxxxxxx.supabase.co",
30
+ ],
31
+
32
+ // How long any single call into your adapter may take before Populace stops
33
+ // waiting, records it as a timeout, and lets the other agents carry on.
34
+ //
35
+ // Without a deadline one unresponsive endpoint freezes the whole simulation
36
+ // and you get no report at all — the run just stops moving. With one, a slow
37
+ // endpoint shows up in the report as a timeout, which is a finding.
38
+ //
39
+ // Raise it if your API is legitimately slow; set 0 to disable it entirely if
40
+ // your adapter does long work on purpose (a batch import, say).
41
+ timeoutMs: 20_000,
42
+
43
+ population: {
44
+ agents: 8,
45
+ cities: ["manila", "mumbai"], // manila · mumbai · delhi · jakarta · bangkok
46
+ minutes: 10,
47
+ tickSeconds: 5,
48
+ },
49
+
50
+ // Simulated accounts share a phone prefix so they are always identifiable —
51
+ // and so `populace clean` can find them after a crashed run.
52
+ identity: {
53
+ phonePrefix: "0900",
54
+ },
55
+
56
+ // How often to call your adapter's refreshSession(). Keep this comfortably
57
+ // inside your access-token lifetime; the default suits a 1-hour token.
58
+ session: {
59
+ refreshEveryMinutes: 30,
60
+ },
61
+
62
+ report: {
63
+ path: "populace-report.json",
64
+ },
65
+ };
package/src/cli.mjs ADDED
@@ -0,0 +1,591 @@
1
+ #!/usr/bin/env node
2
+ // populace — command line.
3
+ //
4
+ // populace init scaffold a config and a blank adapter
5
+ // populace doctor check config, reachability and coverage WITHOUT running
6
+ // populace run bring the population to life
7
+ // populace clean delete every account a run created
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { ConfigError, loadAdapter, loadConfig } from "./config.mjs";
13
+ import { createMetrics, instrument } from "./instrument.mjs";
14
+ import { buildReport, renderReport, writeReport } from "./report.mjs";
15
+ import { canSignInOnly } from "./contract.mjs";
16
+ import { isTransportError } from "./net.mjs";
17
+ import { diagnose } from "./diagnose.mjs";
18
+ import { renderSmoke, smoke, smokePersona } from "./smoke.mjs";
19
+ import { PACKAGE_NAME, VERSION } from "./version.mjs";
20
+ import { World } from "./engine/world.mjs";
21
+ import { buildPersonas, CITIES } from "./engine/personas.mjs";
22
+ import { Agent } from "./engine/agent.mjs";
23
+
24
+ const here = path.dirname(fileURLToPath(import.meta.url));
25
+ const argv = process.argv.slice(2);
26
+ const command = argv[0] || "help";
27
+
28
+ function flag(name, fallback) {
29
+ const i = argv.indexOf(`--${name}`);
30
+ return i > -1 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : fallback;
31
+ }
32
+ const has = (name) => argv.includes(`--${name}`);
33
+
34
+ /**
35
+ * A path a human can read.
36
+ *
37
+ * Relative is right when the file sits under the current directory and absurd
38
+ * when it does not — running the bundled demo from elsewhere printed eight
39
+ * levels of "../" before the real path, which reads like a bug.
40
+ */
41
+ function displayPath(file) {
42
+ const rel = path.relative(process.cwd(), file);
43
+ return rel.startsWith("..") ? file : rel;
44
+ }
45
+
46
+ function overridesFromFlags() {
47
+ const o = {};
48
+ if (flag("agents")) o.agents = Number(flag("agents"));
49
+ if (flag("minutes")) o.minutes = Number(flag("minutes"));
50
+ if (flag("tick")) o.tickSeconds = Number(flag("tick"));
51
+ if (flag("cities")) o.cities = String(flag("cities")).split(",").map((s) => s.trim()).filter(Boolean);
52
+ if (flag("report")) o.reportPath = flag("report");
53
+ return o;
54
+ }
55
+
56
+ async function open() {
57
+ const config = await loadConfig({ configPath: flag("config"), overrides: overridesFromFlags() });
58
+ for (const w of config._warnings || []) console.log(` ⚠ ${w}`);
59
+ const raw = await loadAdapter(config);
60
+ return { config, raw };
61
+ }
62
+
63
+ // ---------------------------------------------------------------- init
64
+
65
+ async function init() {
66
+ const cwd = process.cwd();
67
+ const configFile = path.join(cwd, "populace.config.mjs");
68
+ const adapterDir = path.join(cwd, "adapters");
69
+ const adapterFile = path.join(adapterDir, "my-app.mjs");
70
+
71
+ if (fs.existsSync(configFile) && !has("force")) {
72
+ console.log(`\n populace.config.mjs already exists. Use --force to overwrite.\n`);
73
+ return;
74
+ }
75
+ /**
76
+ * Which template to start from.
77
+ *
78
+ * The bare stub throws `createUser not implemented` on every line, so the
79
+ * first thing a newcomer saw was a file that could not run. Most APIs this
80
+ * tool will ever meet are HTTP with a bearer token, and there is now a REST
81
+ * adapter that has been run for real — 430 calls, 0 failures, 13/13 methods.
82
+ * Starting from working code and editing URLs is a much shorter path than
83
+ * starting from nothing and guessing the contract.
84
+ *
85
+ * populace init → the proven REST template
86
+ * populace init --blank → the bare stub, for anything not HTTP
87
+ */
88
+ const blank = has("blank");
89
+ const templateName = blank ? "template.mjs" : "template-rest.mjs";
90
+
91
+ fs.mkdirSync(adapterDir, { recursive: true });
92
+ fs.copyFileSync(path.join(here, "..", "populace.config.example.mjs"), configFile);
93
+ if (!fs.existsSync(adapterFile) || has("force")) {
94
+ fs.copyFileSync(path.join(here, "..", "adapters", templateName), adapterFile);
95
+ }
96
+
97
+ console.log(`
98
+ Created:
99
+ populace.config.mjs ← point this at your TEST environment
100
+ adapters/my-app.mjs ← ${blank ? "an empty contract to fill in" : "a working REST adapter to edit"}
101
+ `);
102
+
103
+ if (blank) {
104
+ console.log(` Every method throws until you write it. Start with createUser and
105
+ deleteUser — those two are required.
106
+ `);
107
+ } else {
108
+ console.log(` This template is the adapter from examples/rest-api/, which has been run
109
+ against a live server. Every line you need to change is marked EDIT: the
110
+ URLs, the field names and the response shapes. The error handling, session
111
+ refresh and cleanup around them already work.
112
+
113
+ Not an HTTP API? populace init --blank --force
114
+ `);
115
+ }
116
+
117
+ console.log(` Next:
118
+ 1. Fill in \`target\` and \`neverRunAgainst\` in populace.config.mjs
119
+ 2. Change the EDIT lines in adapters/my-app.mjs
120
+ 3. populace doctor ← checks config and reachability, runs nothing
121
+ 4. populace smoke ← calls every method once, tells you what is wired wrong
122
+ `);
123
+ }
124
+
125
+ // ---------------------------------------------------------------- doctor
126
+
127
+ async function doctor() {
128
+ const { config, raw } = await open();
129
+
130
+ // Reachability is the only part that needs the network, so it happens here
131
+ // and the judgement itself is made by diagnose(), which is pure and tested.
132
+ let reachable = null;
133
+ let reachError = "";
134
+ if (typeof raw.healthCheck === "function") {
135
+ try {
136
+ await raw.healthCheck();
137
+ reachable = true;
138
+ } catch (error) {
139
+ reachable = false;
140
+ reachError = error.message;
141
+ }
142
+ }
143
+
144
+ const d = diagnose({ config, adapter: raw, reachable });
145
+
146
+ console.log(`
147
+ Config ${path.basename(config._file)}`);
148
+ console.log(` App ${config.app || raw.name || "(unnamed)"}`);
149
+ console.log(` Adapter ${config.adapter} → ${d.coverage.label} contract methods`);
150
+ console.log(` Env ${config.environment}`);
151
+ console.log(` Guarded ${d.guarded} production host(s) denied`);
152
+ console.log(
153
+ ` Cleanup ${d.cleanup === "read-only"
154
+ ? "read-only — signIn lets clean check without creating"
155
+ : "create-then-delete — no signIn, so clean writes to your auth table"}`,
156
+ );
157
+ if (reachable !== null) {
158
+ console.log(` Reaching ${reachable ? "✔ target responded" : `✖ ${reachError}`}`);
159
+ }
160
+
161
+ if (d.coverage.missing.length) {
162
+ console.log(`
163
+ Not implemented — these will be SKIPPED, not tested:`);
164
+ for (const c of d.coverage.missing) {
165
+ console.log(` · ${c.method.padEnd(21)} ${c.required ? "(REQUIRED) " : ""}${c.exercises}`);
166
+ }
167
+ }
168
+
169
+ if (!d.ready) {
170
+ console.log(`
171
+ ✖ Not ready — ${d.blockers.join("; ")}.
172
+ `);
173
+ process.exitCode = 1;
174
+ } else {
175
+ console.log(`
176
+ ✔ Ready. Start with: populace run --agents 5 --minutes 3
177
+ `);
178
+ }
179
+ }
180
+
181
+ // ---------------------------------------------------------------- run
182
+
183
+ async function run() {
184
+ const { config, raw } = await open();
185
+ const metrics = createMetrics();
186
+ const adapter = instrument(raw, metrics, { timeoutMs: config.timeoutMs, retries: config.retries, giveUpAfter: config.giveUpAfter });
187
+ const startedAt = Date.now();
188
+
189
+ const { agents, cities, minutes, tickSeconds } = config.population;
190
+ console.log(`\n Bringing ${agents} people to life across ${cities.join(", ")}…\n`);
191
+
192
+ const world = World.fromConfig(config, adapter, {
193
+ joined: (a) => console.log(` ✓ ${a.persona.name} (${a.persona.city.name}, ${a.persona.platform})`),
194
+ joinFailed: (p, e) => console.log(` ✖ ${p.name}: ${e.message}`),
195
+ tick: (n, total, w) => {
196
+ render(config, n, total, w);
197
+ // Stop as soon as the target is judged gone. Grinding out the remaining
198
+ // ticks against a dead host wastes the operator's time and adds nothing
199
+ // to the report.
200
+ if (metrics.breaker?.abandoned) {
201
+ console.log(`
202
+ ✖ Target unreachable — stopping early after ${n} of ${total} ticks.`);
203
+ w.stop();
204
+ }
205
+ },
206
+ });
207
+
208
+ process.on("SIGINT", () => {
209
+ world.stop();
210
+ console.log(`\n Stopping…\n`);
211
+ });
212
+
213
+ await world.populate();
214
+ if (!world.agents.length) {
215
+ console.error(`\n ✖ Nobody could sign in. Run \`populace doctor\`, and check your test\n environment has the same schema as production.\n`);
216
+ process.exitCode = 1;
217
+ return;
218
+ }
219
+
220
+ console.log(`\n Running ${world.agents.length} people for ${minutes} min…\n`);
221
+ await new Promise((r) => setTimeout(r, 1000));
222
+ await world.run({ minutes, tickSeconds });
223
+
224
+ // Clean up by default. Leaving invented accounts lying around in someone
225
+ // else's environment is the rudest thing this product could do.
226
+ let teardown = null;
227
+ if (!has("keep")) {
228
+ console.log(`\n Removing simulated accounts…`);
229
+ // Cleanup gets a fresh budget. If the run gave up on the target, the
230
+ // breaker is open and every deletion fails instantly — which is how an
231
+ // earlier build left five invented accounts live in a real project, the
232
+ // safety mechanism causing the exact harm the product promises to avoid.
233
+ metrics.breaker?.reset();
234
+ teardown = await world.teardown();
235
+ }
236
+
237
+ metrics.endedAt = Date.now();
238
+ const report = buildReport({ config, adapter: raw, world, metrics, teardown, startedAt });
239
+ const files = writeReport(report, config);
240
+ console.log(renderReport(report));
241
+ console.log(` Report: ${displayPath(files.json)}`);
242
+ console.log(` Shareable page: ${displayPath(files.html)}\n`);
243
+
244
+ if (report.verdict.status !== "clean") process.exitCode = 1;
245
+ }
246
+
247
+ function render(config, tickNo, totalTicks, world) {
248
+ // The live table is for a human watching a terminal. Piped to a file or a CI
249
+ // job, console.clear() is a no-op, so every tick would append another full
250
+ // copy — burying the report under thousands of lines of scrollback. Emit a
251
+ // sparse heartbeat instead.
252
+ if (!process.stdout.isTTY) {
253
+ const every = Math.max(1, Math.round(totalTicks / 5));
254
+ if (tickNo === totalTicks || tickNo % every === 0) {
255
+ const t = world.totals();
256
+ console.log(
257
+ ` tick ${tickNo}/${totalTicks} · ${world.agents.length} people · ` +
258
+ `${t.km.toFixed(1)}km · ${t.posts}p ${t.likes}l ${t.comments}c ${t.messages}m` +
259
+ (t.errors ? ` · ${t.errors} errors` : ""),
260
+ );
261
+ }
262
+ return;
263
+ }
264
+
265
+ const rule = "─".repeat(74);
266
+ const rows = world.agents.map((a) => {
267
+ const s = a.stats;
268
+ const where = a.onBreak ? "on break" : `${a.position.lat.toFixed(3)},${a.position.lng.toFixed(3)}`;
269
+ return (
270
+ ` ${a.persona.name.padEnd(17).slice(0, 17)} ` +
271
+ `${a.persona.city.name.padEnd(8)} ` +
272
+ `${a.persona.platform.padEnd(10)} ` +
273
+ `${a.distanceKm.toFixed(1).padStart(6)}km ` +
274
+ `${String(s.posts).padStart(2)}p ${String(s.likes).padStart(2)}l ` +
275
+ `${String(s.comments).padStart(2)}c ${String(s.messages).padStart(2)}m ` +
276
+ `${s.errors ? `⚠${s.errors}` : " "} ${where}`
277
+ );
278
+ });
279
+ console.clear();
280
+ console.log(`\n ${config.app || "populace"} — simulated population tick ${tickNo}/${totalTicks}`);
281
+ console.log(` ${config.environment} environment`);
282
+ console.log(rule);
283
+ console.log(rows.join("\n"));
284
+ console.log(rule);
285
+ const recent = world.agents
286
+ .flatMap((a) => a.log.slice(-1).map((l) => ` ${a.persona.name.split(" ")[0]}: ${l}`))
287
+ .slice(-8);
288
+ console.log(recent.join("\n"));
289
+ console.log(`\n Ctrl-C to stop.\n`);
290
+ }
291
+
292
+ // ---------------------------------------------------------------- clean
293
+
294
+ /**
295
+ * A failure to reach the API is not evidence that an account is gone.
296
+ *
297
+ * This distinction is the whole point of the function: cleanup that turns "I
298
+ * could not look" into "there was nothing there" hands the customer a false
299
+ * all-clear over their own database.
300
+ */
301
+
302
+ async function clean() {
303
+ const { config, raw } = await open();
304
+
305
+ // The count must cover the LARGEST run this target has seen, not whatever
306
+ // the config happens to say now.
307
+ //
308
+ // `run --agents 10` against a config declaring 8 used to leave `clean` (with
309
+ // no flag) checking only 8 identities — and then printing an all-clear. If
310
+ // that run had died before its own cleanup, two real accounts would have
311
+ // survived a command whose entire job is to guarantee they had not. A
312
+ // cleanup that under-counts is worse than one that refuses, because it is
313
+ // believed.
314
+ //
315
+ // So the last report's agent count is taken into account. An explicit
316
+ // --agents still wins, and the maximum is used otherwise.
317
+ let lastRunAgents = 0;
318
+ try {
319
+ const reportPath = config.report?.path || "populace-report.json";
320
+ const prev = JSON.parse(fs.readFileSync(reportPath, "utf8"));
321
+ lastRunAgents = Number(prev?.run?.population?.agents) || 0;
322
+ } catch {
323
+ // No previous report, or an unreadable one. Not a problem: the config
324
+ // count still applies, and this is only ever used to widen the sweep.
325
+ }
326
+
327
+ const explicit = flag("agents", null);
328
+ const count = explicit !== null && explicit !== undefined
329
+ ? Number(explicit)
330
+ : Math.max(Number(config.population.agents) || 0, lastRunAgents);
331
+ // Identities are deterministic, so a fresh process can find the accounts an
332
+ // earlier run created — including one that was killed mid-flight.
333
+ const personas = buildPersonas(count, config.population.cities);
334
+ const lookOnly = canSignInOnly(raw);
335
+
336
+ // Retry transport failures here too. `clean` is the command most likely to be
337
+ // run on a bad link — it is what you reach for after a run died — and every
338
+ // blip leaves an identity "unverified", which is deliberately sticky: it can
339
+ // never be reported as absent. Without retries a flaky network means the
340
+ // all-clear never arrives even though the accounts are long gone.
341
+ const probe = instrument(raw, createMetrics(), {
342
+ timeoutMs: config.timeoutMs,
343
+ retries: config.retries,
344
+ });
345
+
346
+ const found = []; // existed, and we deleted it
347
+ const absent = []; // definitively was not there
348
+ const unverified = []; // could not tell — never counted as absent
349
+
350
+ console.log(`
351
+ Checking ${count} simulated identities…
352
+ `);
353
+ if (!lookOnly) {
354
+ console.log(" ! This adapter has no signIn, so an identity can only be reached");
355
+ console.log(" by createUser — which SIGNS UP when it does not exist. Cleaning");
356
+ console.log(" therefore creates and immediately deletes any identity that was");
357
+ console.log(" already absent, and cannot tell you which was which.");
358
+ console.log(" Implement signIn to make cleanup read-only. See adapters/contract.md.\n");
359
+ }
360
+
361
+ for (const [i, persona] of personas.entries()) {
362
+ const agent = new Agent(persona, probe, i, config.identity || {});
363
+ try {
364
+ if (lookOnly) {
365
+ const user = await agent.findAccount();
366
+ if (!user) {
367
+ absent.push(persona.name);
368
+ console.log(` · ${persona.name} — not present`);
369
+ continue;
370
+ }
371
+ await agent.selfDestruct();
372
+ found.push(persona.name);
373
+ console.log(` ✓ ${persona.name} — found and removed`);
374
+ } else {
375
+ // Fallback: create-or-sign-in, then delete. Guarantees absence
376
+ // afterwards; proves nothing about what was there before.
377
+ await agent.ensureAccount();
378
+ await agent.selfDestruct();
379
+ found.push(persona.name);
380
+ console.log(` ✓ ${persona.name} — absent now`);
381
+ }
382
+ } catch (err) {
383
+ if (isTransportError(err)) {
384
+ unverified.push({ name: persona.name, why: String(err?.cause?.code || err?.message || err) });
385
+ console.log(` ? ${persona.name} — could not reach the API`);
386
+ } else {
387
+ // A non-transport failure on a look-only probe means the adapter said
388
+ // something definite; treat it as absent only when we were looking.
389
+ absent.push(persona.name);
390
+ }
391
+ }
392
+ }
393
+
394
+ if (lookOnly) {
395
+ console.log(`
396
+ ${found.length} found and removed, ${absent.length} were already absent.`);
397
+ } else {
398
+ console.log(`
399
+ ${found.length} identities absent now (created-then-deleted where they did not exist),`);
400
+ console.log(` ${absent.length} unreachable-but-not-present.`);
401
+ }
402
+
403
+ if (unverified.length) {
404
+ console.log(`
405
+ ✖ ${unverified.length} could NOT be verified:
406
+ `);
407
+ for (const u of unverified) console.log(` · ${u.name} — ${u.why}`);
408
+ console.log(`
409
+ These may still exist. Re-run clean once the API is reachable.
410
+ `);
411
+ process.exitCode = 1;
412
+ } else {
413
+ console.log("");
414
+ }
415
+ }
416
+
417
+ // ---------------------------------------------------------------- demo
418
+
419
+ /**
420
+ * Run against the bundled demo app.
421
+ *
422
+ * Everything else here needs a config, an adapter and a test backend before it
423
+ * shows you anything — which is a lot of trust to ask for from someone who has
424
+ * not yet seen the tool work. This runs the whole product end to end against a
425
+ * fake app that lives in this repo: no setup, no account, nothing of yours
426
+ * touched, and a real report at the end.
427
+ *
428
+ * The demo app has a deliberate bug in it. Finding that bug is the point.
429
+ */
430
+ async function demo() {
431
+ const configPath = path.join(here, "..", "examples", "demo", "populace.config.mjs");
432
+ if (!fs.existsSync(configPath)) {
433
+ throw new ConfigError(
434
+ "The bundled demo is missing. It ships in the repository — if you installed from npm, clone the repo to run it.",
435
+ );
436
+ }
437
+ console.log(`
438
+ Running the bundled demo. No setup, no backend of yours, nothing to clean up.
439
+ The demo app has a real bug in it — see whether the report finds it.
440
+ `);
441
+ argv.push("--config", configPath);
442
+ // Write the report where the person is standing, not inside the package —
443
+ // which for an npm install would bury it in node_modules.
444
+ argv.push("--report", path.join(process.cwd(), "populace-report.json"));
445
+ await run();
446
+ }
447
+
448
+ // ---------------------------------------------------------------- main
449
+
450
+ // ---------------------------------------------------------------- smoke
451
+
452
+ /**
453
+ * Prove an adapter is wired correctly, in seconds rather than minutes.
454
+ *
455
+ * `doctor` says which methods EXIST; this says whether they WORK. That is the
456
+ * gap someone falls into when writing their first adapter against an API we
457
+ * have never seen: everything is implemented, the run starts, and five minutes
458
+ * later the report is nonsense because `post` returned undefined.
459
+ */
460
+ async function smokeCmd() {
461
+ const { config, raw } = await open();
462
+ const metrics = createMetrics();
463
+ const adapter = instrument(raw, metrics, {
464
+ timeoutMs: config.timeoutMs,
465
+ retries: config.retries,
466
+ giveUpAfter: config.giveUpAfter,
467
+ });
468
+
469
+ console.log(`
470
+ Smoke-testing ${config.adapter} against ${config.environment}…
471
+ `);
472
+
473
+ const { results, fatal } = await smoke({
474
+ adapter,
475
+ persona: smokePersona(config.identity?.phonePrefix ?? "0900"),
476
+ onStep: ({ method, status }) => {
477
+ const mark = status === "ok" ? "✓" : status === "skip" ? "·" : "✖";
478
+ console.log(` ${mark} ${method}`);
479
+ },
480
+ });
481
+
482
+ console.log(renderSmoke(results));
483
+ if (fatal || results.some((r) => r.status === "fail")) process.exitCode = 1;
484
+ }
485
+
486
+ // ---------------------------------------------------------------- report
487
+
488
+ /**
489
+ * Re-open a report from an earlier run.
490
+ *
491
+ * Runs are expensive and their output scrolls away. Without this the only way
492
+ * to see a past verdict again is to read raw JSON, which is not what the
493
+ * terminal renderer exists for.
494
+ */
495
+ async function report() {
496
+ const file = path.resolve(process.cwd(), flag("file", "populace-report.json"));
497
+ if (!fs.existsSync(file)) {
498
+ console.error(`
499
+ ✖ No report at ${displayPath(file)}
500
+
501
+ Point at one with: populace report --file path/to/populace-report.json
502
+ Or make one with: populace run
503
+ `);
504
+ process.exitCode = 1;
505
+ return;
506
+ }
507
+
508
+ let saved;
509
+ try {
510
+ saved = JSON.parse(fs.readFileSync(file, "utf8"));
511
+ } catch (error) {
512
+ console.error(`
513
+ ✖ ${displayPath(file)} is not readable JSON: ${error.message}
514
+ `);
515
+ process.exitCode = 1;
516
+ return;
517
+ }
518
+ if (!saved?.api || !saved?.verdict) {
519
+ console.error(`
520
+ ✖ ${displayPath(file)} is JSON but not a Populace report.
521
+ `);
522
+ process.exitCode = 1;
523
+ return;
524
+ }
525
+
526
+ // A report written by a different version may lack fields this renderer
527
+ // expects. Say so rather than crashing on a missing property.
528
+ if (saved.populace?.version && saved.populace.version !== VERSION) {
529
+ console.log(`
530
+ ⚠ Written by Populace ${saved.populace.version}; you are running ${VERSION}.`);
531
+ }
532
+ console.log(renderReport(saved));
533
+ const html = file.replace(/\.json$/, "") + ".html";
534
+ if (fs.existsSync(html)) console.log(` Shareable page: ${displayPath(html)}
535
+ `);
536
+
537
+ // Same exit code the original run used, so this is usable in a CI gate.
538
+ if (saved.verdict.status !== "clean") process.exitCode = 1;
539
+ }
540
+
541
+ // ---------------------------------------------------------------- version
542
+
543
+ async function version() {
544
+ console.log(`${PACKAGE_NAME} ${VERSION} · node ${process.version} · ${process.platform}`);
545
+ }
546
+
547
+ const commands = { init, doctor, run, clean, demo, report, version, smoke: smokeCmd };
548
+
549
+ // `--version` and `-v` are what people actually type.
550
+ if (has("version") || argv[0] === "-v") {
551
+ await version();
552
+ process.exit(0);
553
+ }
554
+
555
+ if (!commands[command]) {
556
+ console.log(`
557
+ populace ${VERSION} — a simulated population for testing your app
558
+
559
+ populace demo see it work, against a fake app, right now
560
+ populace init scaffold a config and adapter here
561
+ populace doctor check everything WITHOUT running
562
+ populace smoke prove your adapter works, in seconds
563
+ populace run bring the population to life
564
+ populace clean delete accounts a run created
565
+ populace report re-open the report from an earlier run
566
+ populace version print version and environment
567
+
568
+ Options
569
+ --config <path> default ./populace.config.mjs
570
+ --agents <n> --minutes <n> override the config
571
+ --tick <seconds> simulated seconds per step
572
+ --cities <a,b> ${Object.keys(CITIES).join(", ")}
573
+ --report <path> where to write the report
574
+ --keep leave accounts in place after a run
575
+ --file <path> which report to re-open (report)
576
+
577
+ Resilience — see populace.config.mjs to change these
578
+ timeoutMs 20000 give up waiting on one call
579
+ retries 3 extra tries for calls that never landed
580
+ giveUpAfter 12 unreachable calls before stopping the run
581
+
582
+ Exit codes
583
+ 0 clean — nothing failed
584
+ 1 problems found, inconclusive, or refused
585
+ `);
586
+ } else {
587
+ commands[command]().catch((error) => {
588
+ console.error(error instanceof ConfigError ? `\n ✖ ${error.message}\n` : error);
589
+ process.exit(1);
590
+ });
591
+ }