@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,115 @@
1
+ // Who the simulated people are.
2
+ //
3
+ // A persona is a city, a platform, a rate, and behaviour weights that decide
4
+ // how often someone posts, chats, likes and takes breaks. The weights are the
5
+ // whole trick: a quiet courier who never posts sitting next to a rider who
6
+ // comments on everything is what makes a population read as people instead of
7
+ // a loop. Identical bots find identical bugs.
8
+ //
9
+ // This is the built-in "mobile workers" pack. Supply your own via
10
+ // `personas` in populace.config.mjs if your users are something else.
11
+
12
+ export const CITIES = {
13
+ manila: { name: "Manila", lat: 14.5995, lng: 120.9842, currency: "PHP", rate: 10 },
14
+ mumbai: { name: "Mumbai", lat: 19.076, lng: 72.8777, currency: "INR", rate: 10 },
15
+ delhi: { name: "Delhi", lat: 28.6139, lng: 77.209, currency: "INR", rate: 10 },
16
+ jakarta: { name: "Jakarta", lat: -6.2088, lng: 106.8456, currency: "IDR", rate: 3000 },
17
+ bangkok: { name: "Bangkok", lat: 13.7563, lng: 100.5018, currency: "THB", rate: 6 },
18
+ };
19
+
20
+ // Chatter is per-city so a feed reads like the right place.
21
+ const CHATTER = {
22
+ manila: [
23
+ "Heavy traffic sa EDSA southbound, mag-alternate route kayo",
24
+ "Surge ngayon sa BGC, sulit ang pila",
25
+ "Ingat sa Commonwealth, may flood sa gilid",
26
+ "Sino nandito sa Ortigas? Ang dami booking",
27
+ "Break muna, 6 hours na akong byahe",
28
+ ],
29
+ mumbai: [
30
+ "Andheri me bahut traffic hai, Link Road se jao",
31
+ "Bandra side surge chal raha hai abhi",
32
+ "Sion flyover pe jam, 25 min lag rahe hain",
33
+ "Koi Powai me hai? Bookings kaafi aa rahe hain",
34
+ "Chai break, subah se 80 km ho gaye",
35
+ ],
36
+ delhi: [
37
+ "Ring Road pe heavy jam, Outer se nikal jao",
38
+ "Connaught Place me surge hai abhi",
39
+ "Gurgaon toll pe lambi line lagi hai",
40
+ "Metro strike ki wajah se bookings badh gaye",
41
+ "Paani pi lo bhai, aaj bahut garmi hai",
42
+ ],
43
+ jakarta: [
44
+ "Macet parah di Sudirman, lewat jalan tikus aja",
45
+ "Lagi surge di Kuningan, lumayan",
46
+ "Hati-hati banjir di Kemang",
47
+ "Ada yang di Senayan? Orderan rame",
48
+ "Istirahat dulu, udah 7 jam narik",
49
+ ],
50
+ bangkok: [
51
+ "รถติดมากแถวสุขุมวิท ใช้ทางด่วนดีกว่า",
52
+ "ตอนนี้ราคาขึ้นแถวสีลม",
53
+ "ระวังน้ำท่วมแถวลาดพร้าว",
54
+ "ใครอยู่แถวอโศกบ้าง งานเยอะมาก",
55
+ "พักก่อน วิ่งมา 6 ชั่วโมงแล้ว",
56
+ ],
57
+ };
58
+
59
+ const REPLIES = [
60
+ "Thanks for the heads up 🙏",
61
+ "Same here, confirmed",
62
+ "Good to know, avoiding that route",
63
+ "How long is the wait?",
64
+ "Salamat / Thanks bhai",
65
+ "On my way there now",
66
+ ];
67
+
68
+ const NAMES = {
69
+ manila: ["Jhun Ramirez", "Maricel Santos", "Dante Cruz", "Liza Bautista", "Noel Aquino"],
70
+ mumbai: ["Ramesh Kadam", "Priya Sharma", "Imran Shaikh", "Sunita Patil", "Arjun Nair"],
71
+ delhi: ["Vikas Yadav", "Neha Gupta", "Rahul Verma", "Pooja Singh", "Manoj Kumar"],
72
+ jakarta: ["Budi Santoso", "Siti Rahayu", "Agus Wijaya", "Dewi Lestari", "Eko Prasetyo"],
73
+ bangkok: ["Somchai Prasert", "Ratana Wong", "Niran Suk", "Malee Chai", "Kittipong S."],
74
+ };
75
+
76
+ const PLATFORMS = {
77
+ manila: ["grab", "angkas", "joyride", "foodpanda", "lalamove"],
78
+ mumbai: ["uber", "ola", "swiggy", "zomato", "rapido"],
79
+ delhi: ["uber", "ola", "blinkit", "zepto", "amazon"],
80
+ jakarta: ["gojek", "grab", "shopeefood", "maxim", "indrive"],
81
+ bangkok: ["grab", "foodpanda", "lalamove", "shopeefood", "bolt"],
82
+ };
83
+
84
+ const pick = (arr, i) => arr[i % arr.length];
85
+
86
+ /** Build `count` personas spread across the given cities. */
87
+ export function buildPersonas(count, cityKeys = Object.keys(CITIES)) {
88
+ const keys = cityKeys.filter((c) => CITIES[c]);
89
+ if (!keys.length) throw new Error(`Unknown cities. Available: ${Object.keys(CITIES).join(", ")}`);
90
+
91
+ const personas = [];
92
+ for (let i = 0; i < count; i++) {
93
+ const cityKey = keys[i % keys.length];
94
+ const city = CITIES[cityKey];
95
+ const n = Math.floor(i / keys.length);
96
+ personas.push({
97
+ id: `sim${String(i + 1).padStart(3, "0")}`,
98
+ cityKey,
99
+ city,
100
+ name: pick(NAMES[cityKey], n),
101
+ platform: pick(PLATFORMS[cityKey], n),
102
+ rate: city.rate,
103
+ // Behaviour weights — the reason the population feels human.
104
+ postiness: 0.05 + (i % 5) * 0.05, // 0.05 – 0.25 chance per tick
105
+ chattiness: 0.05 + (i % 4) * 0.06,
106
+ likeliness: 0.15 + (i % 3) * 0.2,
107
+ breakiness: 0.03 + (i % 6) * 0.02,
108
+ speedKmh: 18 + (i % 5) * 6, // 18–42 km/h city driving
109
+ });
110
+ }
111
+ return personas;
112
+ }
113
+
114
+ export const chatterFor = (cityKey, n) => pick(CHATTER[cityKey] ?? CHATTER.manila, n);
115
+ export const replyLine = (n) => pick(REPLIES, n);
@@ -0,0 +1,120 @@
1
+ // Orchestrates a run: brings a population into existence, lets it live for a
2
+ // while, then reports what happened. Knows nothing about the terminal — the CLI
3
+ // passes callbacks in, so the same engine can drive a UI or a CI job later.
4
+
5
+ import { Agent } from "./agent.mjs";
6
+ import { buildPersonas } from "./personas.mjs";
7
+
8
+ export class World {
9
+ constructor({ adapter, personas, options = {}, on = {} }) {
10
+ this.adapter = adapter;
11
+ this.personas = personas;
12
+ this.options = options;
13
+ this.on = on;
14
+ this.agents = [];
15
+ this.signupFailures = [];
16
+ this.stopping = false;
17
+ }
18
+
19
+ static fromConfig(config, adapter, on) {
20
+ const { agents = 6, cities } = config.population;
21
+ const personas =
22
+ typeof config.personas === "function"
23
+ ? config.personas(agents, cities)
24
+ : Array.isArray(config.personas)
25
+ ? config.personas
26
+ : buildPersonas(agents, cities);
27
+ const options = {
28
+ ...(config.identity || {}),
29
+ refreshEveryMs: (config.session?.refreshEveryMinutes ?? 30) * 60_000,
30
+ };
31
+ return new World({ adapter, personas, options, on });
32
+ }
33
+
34
+ /**
35
+ * Sign everyone in, staggered. Auth endpoints are commonly rate-limited, and
36
+ * a burst of simultaneous sign-ups produces a wall of 429s that looks like a
37
+ * bug in the customer's app when it is really a bug in this harness.
38
+ */
39
+ async populate({ staggerMs = 400 } = {}) {
40
+ 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);
49
+ }
50
+ if (staggerMs) await sleep(staggerMs);
51
+ }
52
+ return this.agents.length;
53
+ }
54
+
55
+ async run({ minutes = 10, tickSeconds = 5, realtime = true } = {}) {
56
+ const totalTicks = Math.max(1, Math.round((minutes * 60) / tickSeconds));
57
+ for (let tick = 1; tick <= totalTicks && !this.stopping; tick++) {
58
+ // Everyone acts concurrently, the way real users do. Sequential agents
59
+ // would never surface a race condition, which is half the reason to run
60
+ // a simulation at all.
61
+ await Promise.all(this.agents.map((a) => a.tick(tickSeconds, this)));
62
+ this.on.tick?.(tick, totalTicks, this);
63
+ if (realtime) await sleep(tickSeconds * 1000);
64
+ }
65
+ return this.totals();
66
+ }
67
+
68
+ /** Failures that never reached the adapter: bugs in this engine. */
69
+ engineErrors() {
70
+ return this.agents.flatMap((a) => a.engineErrors ?? []);
71
+ }
72
+
73
+ totals() {
74
+ return this.agents.reduce(
75
+ (acc, a) => {
76
+ acc.km += a.distanceKm;
77
+ for (const key of Object.keys(a.stats)) acc[key] = (acc[key] || 0) + a.stats[key];
78
+ return acc;
79
+ },
80
+ { km: 0, posts: 0, likes: 0, comments: 0, messages: 0, groups: 0, errors: 0 },
81
+ );
82
+ }
83
+
84
+ stop() {
85
+ this.stopping = true;
86
+ }
87
+
88
+ /** Remove every account this run created, through the app's own delete path. */
89
+ /**
90
+ * Three outcomes, never two. An account that was deleted, one there was
91
+ * nothing to delete for, and one whose deletion failed are different facts,
92
+ * and a cleanup line that merges the first two tells the customer their
93
+ * environment is clear when it may not be.
94
+ *
95
+ * `notDeleted` rather than `skipped`: the report already uses `skipped` as a
96
+ * boolean for --keep, and an empty array is truthy, so reusing the name would
97
+ * have made every clean run claim it had left its agents in place.
98
+ */
99
+ async teardown() {
100
+ const results = { removed: 0, notDeleted: [], failed: [] };
101
+ for (const agent of this.agents) {
102
+ try {
103
+ const outcome = await agent.selfDestruct();
104
+ if (outcome && outcome.deleted) {
105
+ results.removed += 1;
106
+ } else {
107
+ results.notDeleted.push({
108
+ name: agent.persona.name,
109
+ why: (outcome && outcome.why) || "nothing to delete",
110
+ });
111
+ }
112
+ } catch (error) {
113
+ results.failed.push({ name: agent.persona.name, error: String(error.message || error) });
114
+ }
115
+ }
116
+ return results;
117
+ }
118
+ }
119
+
120
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -0,0 +1,218 @@
1
+ // Renders a run report as a single self-contained HTML file.
2
+ //
3
+ // The terminal output is for the person who started the run. This is for
4
+ // everyone else — the colleague who wasn't watching, the client who paid for
5
+ // the run, the ticket it gets attached to. It has to survive being emailed.
6
+ //
7
+ // No external anything: one file, inline CSS, no fonts or scripts fetched.
8
+
9
+ const esc = (s) =>
10
+ String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11
+
12
+ const pct = (n) => `${(n * 100).toFixed(n < 0.01 && n > 0 ? 2 : 1)}%`;
13
+ const ms = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}s` : `${Math.round(n)}ms`);
14
+ const dur = (n) => (n >= 60000 ? `${Math.round(n / 60000)}m ${Math.round((n % 60000) / 1000)}s` : `${(n / 1000).toFixed(1)}s`);
15
+
16
+ export function renderHtmlReport(report) {
17
+ const r = report;
18
+ const clean = r.verdict.status === "clean";
19
+ const engineBroke = (r.engineErrors?.length ?? 0) > 0;
20
+
21
+ // Latency bars are scaled to the slowest method, so the outlier is obvious
22
+ // at a glance rather than requiring the reader to compare numbers.
23
+ const slowest = Math.max(1, ...r.api.methods.map((m) => m.latencyMs.p95));
24
+
25
+ const methodRows = r.api.methods
26
+ .map((m) => {
27
+ const bad = m.failures > 0;
28
+ const width = Math.max(2, (m.latencyMs.p95 / slowest) * 100);
29
+ const errs = m.errors
30
+ .slice(0, 3)
31
+ .map((e) => `<div class="err"><span class="errn">${e.count}×</span>${esc(e.message)}</div>`)
32
+ .join("");
33
+ return `
34
+ <tr class="${bad ? "row-bad" : ""}">
35
+ <td class="mono name">${bad ? '<span class="x">✖</span>' : ""}${esc(m.method)}</td>
36
+ <td class="num">${m.calls}</td>
37
+ <td class="num ${bad ? "fail" : "zero"}">${m.failures}</td>
38
+ <td class="num">${ms(m.latencyMs.p50)}</td>
39
+ <td class="num">${ms(m.latencyMs.p95)}</td>
40
+ <td class="barcell"><div class="bar" style="width:${width.toFixed(1)}%"></div></td>
41
+ </tr>
42
+ ${errs ? `<tr class="errrow"><td colspan="6">${errs}</td></tr>` : ""}`;
43
+ })
44
+ .join("");
45
+
46
+ const notTested = (r.coverage.notTested || [])
47
+ .map(
48
+ (c) =>
49
+ `<li><span class="mono">${esc(c.method)}</span><span class="would">would have tested ${esc(c.wouldHaveTested)}</span></li>`,
50
+ )
51
+ .join("");
52
+
53
+ const problems = r.verdict.problems.map((p) => `<li>${esc(p)}</li>`).join("");
54
+
55
+ const engineBlock = engineBroke
56
+ ? `
57
+ <section class="panel danger">
58
+ <h2>Populace itself failed ${r.engineErrors.length} time(s)</h2>
59
+ <p>These failures never reached your app. They are bugs in the simulation, which
60
+ means <strong>this run did not test everything it claims to have tested</strong>.
61
+ Treat the results below as incomplete.</p>
62
+ <pre class="trace">${r.engineErrors.map(esc).join("\n\n")}</pre>
63
+ </section>`
64
+ : "";
65
+
66
+ const cleanupBlock = r.cleanup?.skipped
67
+ ? `<div class="chip warn">Cleanup skipped — ${r.population.signedIn} accounts still live</div>`
68
+ : r.cleanup?.failed?.length
69
+ ? `<div class="chip bad">Cleanup incomplete — ${r.cleanup.failed.length} accounts could not be deleted</div>`
70
+ : r.cleanup?.notDeleted?.length
71
+ ? `<div class="chip warn">Cleanup partial — ${r.cleanup.removed} removed, ${r.cleanup.notDeleted.length} had nothing to delete</div>`
72
+ : `<div class="chip ok">Cleanup complete — ${r.cleanup?.removed ?? 0} accounts removed</div>`;
73
+
74
+ return `<!doctype html>
75
+ <html lang="en"><head><meta charset="utf-8">
76
+ <meta name="viewport" content="width=device-width, initial-scale=1">
77
+ <title>Populace report — ${esc(r.run.app)}</title>
78
+ <style>
79
+ :root{
80
+ --paper:#F4F5F7;--card:#fff;--ink:#12181D;--body:#3A454E;--muted:#6B7883;
81
+ --rule:#DDE2E7;--accent:#A85A0B;--ok:#2C6E4C;--bad:#A63127;--warn:#9A6A08;
82
+ --bar:#C9D4DC;--barbad:#E0A99F;
83
+ --mono:ui-monospace,"SF Mono","Cascadia Mono",Menlo,Consolas,monospace;
84
+ --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
85
+ }
86
+ @media (prefers-color-scheme:dark){:root{
87
+ --paper:#0E1418;--card:#161E24;--ink:#E8EEF3;--body:#B0BDC7;--muted:#7B8994;
88
+ --rule:#26323A;--accent:#E0A45C;--ok:#5FAB80;--bad:#DE7568;--warn:#D2A24E;
89
+ --bar:#2E3A43;--barbad:#5C332C;}}
90
+ :root[data-theme="dark"]{--paper:#0E1418;--card:#161E24;--ink:#E8EEF3;--body:#B0BDC7;
91
+ --muted:#7B8994;--rule:#26323A;--accent:#E0A45C;--ok:#5FAB80;--bad:#DE7568;
92
+ --warn:#D2A24E;--bar:#2E3A43;--barbad:#5C332C;}
93
+ :root[data-theme="light"]{--paper:#F4F5F7;--card:#fff;--ink:#12181D;--body:#3A454E;
94
+ --muted:#6B7883;--rule:#DDE2E7;--accent:#A85A0B;--ok:#2C6E4C;--bad:#A63127;
95
+ --warn:#9A6A08;--bar:#C9D4DC;--barbad:#E0A99F;}
96
+ *{box-sizing:border-box}
97
+ body{margin:0;background:var(--paper);color:var(--body);font-family:var(--sans);
98
+ font-size:15.5px;line-height:1.6;-webkit-font-smoothing:antialiased}
99
+ .wrap{max-width:900px;margin:0 auto;padding:0 22px 80px}
100
+ h1,h2,h3{color:var(--ink);margin:0;text-wrap:balance}
101
+ h1{font-family:var(--mono);font-size:clamp(22px,4vw,30px);letter-spacing:-.02em}
102
+ h2{font-size:17px;font-family:var(--mono);letter-spacing:-.01em}
103
+ .mono{font-family:var(--mono)}
104
+ header{padding:44px 0 24px;border-bottom:2px solid var(--ink);display:flex;
105
+ flex-direction:column;gap:10px}
106
+ .sub{font-family:var(--mono);font-size:12.5px;color:var(--muted)}
107
+ .verdict{margin-top:26px;padding:20px 22px;border-radius:6px;display:flex;
108
+ flex-direction:column;gap:8px;border-left:4px solid}
109
+ .verdict.ok{background:color-mix(in srgb,var(--ok) 8%,transparent);border-color:var(--ok)}
110
+ .verdict.bad{background:color-mix(in srgb,var(--bad) 8%,transparent);border-color:var(--bad)}
111
+ .verdict h2{color:var(--ink)}
112
+ .verdict ul{margin:4px 0 0;padding-left:20px}
113
+ .stats{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));margin-top:26px}
114
+ .stat{background:var(--card);border:1px solid var(--rule);border-radius:6px;padding:15px 16px}
115
+ .stat .v{font-family:var(--mono);font-size:23px;color:var(--ink);font-weight:600;
116
+ letter-spacing:-.02em;font-variant-numeric:tabular-nums;display:block}
117
+ .stat .l{font-family:var(--mono);font-size:10px;letter-spacing:.12em;
118
+ text-transform:uppercase;color:var(--muted)}
119
+ section{margin-top:42px;display:flex;flex-direction:column;gap:14px}
120
+ .panel{background:var(--card);border:1px solid var(--rule);border-radius:6px;padding:20px 22px}
121
+ .panel.danger{border-color:var(--bad);border-left-width:4px}
122
+ .panel.danger h2{color:var(--bad)}
123
+ .tablewrap{overflow-x:auto;border:1px solid var(--rule);border-radius:6px;background:var(--card)}
124
+ table{border-collapse:collapse;width:100%;min-width:600px}
125
+ th,td{padding:10px 14px;text-align:left;border-bottom:1px solid var(--rule)}
126
+ th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;
127
+ color:var(--muted);font-weight:600}
128
+ td.num{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:13.5px}
129
+ td.name{font-size:13.5px;color:var(--ink);white-space:nowrap}
130
+ td.zero{color:var(--muted)}
131
+ td.fail{color:var(--bad);font-weight:600}
132
+ .x{color:var(--bad);margin-right:6px}
133
+ .barcell{width:120px}
134
+ .bar{height:7px;background:var(--bar);border-radius:4px}
135
+ .row-bad .bar{background:var(--barbad)}
136
+ tr.errrow td{padding-top:0;border-bottom:1px solid var(--rule)}
137
+ .err{font-family:var(--mono);font-size:12px;color:var(--bad);padding:3px 0 3px 26px}
138
+ .errn{color:var(--muted);margin-right:8px}
139
+ .trace{font-family:var(--mono);font-size:11.5px;white-space:pre-wrap;overflow-x:auto;
140
+ background:var(--paper);padding:14px;border-radius:5px;margin:0;color:var(--body)}
141
+ ul.cov{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:9px}
142
+ ul.cov li{display:flex;gap:14px;flex-wrap:wrap;align-items:baseline;font-size:14px}
143
+ ul.cov .mono{color:var(--ink);min-width:170px;font-size:13px}
144
+ .would{color:var(--muted);font-size:13.5px}
145
+ .chip{display:inline-block;font-family:var(--mono);font-size:12px;padding:7px 13px;
146
+ border-radius:5px;border:1px solid}
147
+ .chip.ok{color:var(--ok);border-color:var(--ok);background:color-mix(in srgb,var(--ok) 8%,transparent)}
148
+ .chip.bad{color:var(--bad);border-color:var(--bad);background:color-mix(in srgb,var(--bad) 8%,transparent)}
149
+ .chip.warn{color:var(--warn);border-color:var(--warn);background:color-mix(in srgb,var(--warn) 8%,transparent)}
150
+ footer{margin-top:52px;padding-top:20px;border-top:1px solid var(--rule);
151
+ font-size:13px;color:var(--muted);display:flex;flex-direction:column;gap:6px}
152
+ </style></head><body>
153
+ <div class="wrap">
154
+
155
+ <header>
156
+ <h1>${esc(r.run.app)}</h1>
157
+ <span class="sub">${esc(r.run.environment)} environment · ${r.population.signedIn} simulated people ·
158
+ ${dur(r.run.durationMs)} · ${esc(new Date(r.run.startedAt).toUTCString())}</span>
159
+ </header>
160
+
161
+ <div class="verdict ${clean ? "ok" : "bad"}">
162
+ <h2>${clean ? `No failures across ${r.api.calls} API calls` : "Problems found"}</h2>
163
+ ${clean ? "" : `<ul>${problems}</ul>`}
164
+ </div>
165
+
166
+ ${engineBlock}
167
+
168
+ <div class="stats">
169
+ <div class="stat"><span class="v">${r.api.calls}</span><span class="l">API calls</span></div>
170
+ <div class="stat"><span class="v" style="color:${r.api.failures ? "var(--bad)" : "var(--ok)"}">${r.api.failures}</span><span class="l">failed</span></div>
171
+ <div class="stat"><span class="v">${pct(r.api.failureRate)}</span><span class="l">failure rate</span></div>
172
+ <div class="stat"><span class="v">${r.population.signedIn}</span><span class="l">concurrent users</span></div>
173
+ <div class="stat"><span class="v">${esc(r.coverage.label)}</span><span class="l">coverage</span></div>
174
+ </div>
175
+
176
+ <section>
177
+ <h2>Your API under ${r.population.signedIn} concurrent users</h2>
178
+ <div class="tablewrap">
179
+ <table>
180
+ <thead><tr><th>Method</th><th style="text-align:right">Calls</th>
181
+ <th style="text-align:right">Fails</th><th style="text-align:right">p50</th>
182
+ <th style="text-align:right">p95</th><th>p95 relative</th></tr></thead>
183
+ <tbody>${methodRows}</tbody>
184
+ </table>
185
+ </div>
186
+ </section>
187
+
188
+ <section>
189
+ <h2>What the population did</h2>
190
+ <div class="panel">
191
+ ${r.activity.distanceKm} km travelled · ${r.activity.posts} posts ·
192
+ ${r.activity.likes} likes · ${r.activity.comments} comments ·
193
+ ${r.activity.messages} messages · ${r.activity.groupJoins} group joins
194
+ </div>
195
+ </section>
196
+
197
+ ${
198
+ notTested
199
+ ? `<section>
200
+ <h2>Not tested — adapter implements ${esc(r.coverage.label)}</h2>
201
+ <div class="panel"><ul class="cov">${notTested}</ul></div>
202
+ </section>`
203
+ : ""
204
+ }
205
+
206
+ <section>
207
+ <h2>Cleanup</h2>
208
+ <div>${cleanupBlock}</div>
209
+ </section>
210
+
211
+ <footer>
212
+ <span>Generated by Populace ${esc(r.populace.version)} · adapter <span class="mono">${esc(r.run.adapter)}</span></span>
213
+ <span>Simulated people are generated from patterns. This report shows whether your app
214
+ <strong>works</strong> — not whether anyone wants it.</span>
215
+ </footer>
216
+
217
+ </div></body></html>`;
218
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,38 @@
1
+ // Programmatic entry point, for driving Populace from a CI job or a UI rather
2
+ // than the terminal.
3
+ //
4
+ // import { simulate } from "populace";
5
+ // const report = await simulate({ configPath: "./populace.config.mjs" });
6
+ // if (report.verdict.status !== "clean") process.exit(1);
7
+
8
+ import { loadAdapter, loadConfig } from "./config.mjs";
9
+ import { createMetrics, instrument } from "./instrument.mjs";
10
+ import { buildReport } from "./report.mjs";
11
+ import { World } from "./engine/world.mjs";
12
+
13
+ export { loadConfig, loadAdapter, ConfigError } from "./config.mjs";
14
+ export { CONTRACT, coverageOf } from "./contract.mjs";
15
+ export { buildReport, renderReport, writeReport } from "./report.mjs";
16
+ export { createMetrics, instrument, summarise } from "./instrument.mjs";
17
+ export { World } from "./engine/world.mjs";
18
+ export { Agent } from "./engine/agent.mjs";
19
+ export { buildPersonas, CITIES } from "./engine/personas.mjs";
20
+
21
+ export async function simulate({ configPath, overrides = {}, on = {}, cleanup = true } = {}) {
22
+ const config = await loadConfig({ configPath, overrides });
23
+ const raw = await loadAdapter(config);
24
+ const metrics = createMetrics();
25
+ const adapter = instrument(raw, metrics, { timeoutMs: config.timeoutMs, retries: config.retries, giveUpAfter: config.giveUpAfter });
26
+ const startedAt = Date.now();
27
+
28
+ const world = World.fromConfig(config, adapter, on);
29
+ await world.populate();
30
+ await world.run(config.population);
31
+
32
+ // Same fresh budget for the programmatic path — see cli.mjs.
33
+ if (cleanup) metrics.breaker?.reset();
34
+ const teardown = cleanup ? await world.teardown() : null;
35
+ metrics.endedAt = Date.now();
36
+
37
+ return buildReport({ config, adapter: raw, world, metrics, teardown, startedAt });
38
+ }