@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.
- package/LICENSE +661 -0
- package/README.md +258 -0
- package/adapters/buzzbuzz.mjs +247 -0
- package/adapters/contract.md +164 -0
- package/adapters/template-rest.mjs +192 -0
- package/adapters/template.mjs +80 -0
- package/examples/buzzbuzz/populace-report.html +245 -0
- package/examples/buzzbuzz/populace-report.json +280 -0
- package/examples/buzzbuzz/populace.config.mjs +51 -0
- package/examples/buzzbuzz/run-test.ps1 +61 -0
- package/examples/demo/adapters/demo.mjs +90 -0
- package/examples/demo/populace-report.html +230 -0
- package/examples/demo/populace-report.json +219 -0
- package/examples/demo/populace.config.mjs +22 -0
- package/examples/rest-api/README.md +85 -0
- package/examples/rest-api/adapter.mjs +166 -0
- package/examples/rest-api/populace.config.mjs +40 -0
- package/examples/rest-api/server.mjs +247 -0
- package/examples/token-expiry/expiry-demo.mjs +119 -0
- package/package.json +56 -0
- package/populace.config.example.mjs +65 -0
- package/src/cli.mjs +591 -0
- package/src/config.mjs +186 -0
- package/src/contract.mjs +130 -0
- package/src/diagnose.mjs +40 -0
- package/src/engine/agent.mjs +264 -0
- package/src/engine/geo.mjs +59 -0
- package/src/engine/index.mjs +4 -0
- package/src/engine/personas.mjs +115 -0
- package/src/engine/world.mjs +120 -0
- package/src/html-report.mjs +218 -0
- package/src/index.mjs +38 -0
- package/src/instrument.mjs +299 -0
- package/src/net.mjs +175 -0
- package/src/report.mjs +251 -0
- package/src/selftest.mjs +1369 -0
- package/src/smoke.mjs +274 -0
- 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
|
+
}
|