@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,192 @@
1
+ /**
2
+ * Adapter template — REST API with a bearer token.
3
+ *
4
+ * This is not a sketch. It is the adapter from examples/rest-api/, which has
5
+ * been run against a live server: 430 API calls, 0 failures, 13/13 methods.
6
+ * Every URL and field name below is marked EDIT — change those and the wiring
7
+ * around them already works.
8
+ *
9
+ * Start it up and see for yourself before you change anything:
10
+ *
11
+ * node examples/rest-api/server.mjs
12
+ * populace smoke --config examples/rest-api/populace.config.mjs
13
+ *
14
+ * Two rules, both in adapters/contract.md:
15
+ * 1. Never point this at production.
16
+ * 2. Go through your real API. No admin keys, no direct database writes — a
17
+ * simulation that bypasses your permission rules proves nothing about
18
+ * whether they work.
19
+ */
20
+
21
+ export function createAdapter(target, config) {
22
+ const base = target.url.replace(/\/$/, "");
23
+
24
+ /**
25
+ * The contract hands you a name, a phone and a persona — never a password.
26
+ * Choosing one is your job, because only you know what your API accepts.
27
+ *
28
+ * It must be STABLE. Identities are deterministic so that a re-run signs
29
+ * back into the same accounts instead of piling up new ones, and that only
30
+ * works if the credential is stable too.
31
+ */
32
+ const PASSWORD = "PopulaceSim!2026"; // EDIT if your rules differ
33
+
34
+ /**
35
+ * One place where HTTP becomes either a value or a thrown Error.
36
+ *
37
+ * Two things here matter more than they look:
38
+ *
39
+ * NEVER RETURN QUIETLY ON FAILURE. An adapter that swallows an error makes
40
+ * the report blame a later method for an earlier fault. Populace's own first
41
+ * run found exactly that bug in its own reference adapter: the report said
42
+ * "post failed 14 times" when the truth was "the profile row was never
43
+ * created".
44
+ *
45
+ * DESCRIBE THE FAULT, NOT THE REQUEST. Failures are grouped by shape, so
46
+ * "409 on POST /likes" collapses into one line. Put the post id in the
47
+ * message and one bug becomes fifty.
48
+ */
49
+ async function call(method, path, { token, body } = {}) {
50
+ const res = await fetch(`${base}${path}`, {
51
+ method,
52
+ headers: {
53
+ ...(body ? { "content-type": "application/json" } : {}),
54
+ ...(token ? { authorization: `Bearer ${token}` } : {}), // EDIT: header scheme
55
+ },
56
+ body: body ? JSON.stringify(body) : undefined,
57
+ signal: AbortSignal.timeout(15000),
58
+ });
59
+
60
+ if (res.status === 204) return undefined;
61
+
62
+ const text = await res.text();
63
+ let payload;
64
+ try { payload = text ? JSON.parse(text) : undefined; } catch { payload = undefined; }
65
+
66
+ if (!res.ok) {
67
+ const detail = payload?.error || text.slice(0, 120) || "no body";
68
+ throw new Error(`${res.status} on ${method} ${path}: ${detail}`);
69
+ }
70
+ return payload;
71
+ }
72
+
73
+ return {
74
+ name: "my-app", // EDIT
75
+
76
+ // doctor calls this, so an unreachable target is caught before you spend
77
+ // a run finding out.
78
+ async healthCheck() {
79
+ const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(10000) }); // EDIT
80
+ if (!res.ok) throw new Error(`health check returned ${res.status}`);
81
+ },
82
+
83
+ // ── identity ────────────────────────────────────────────────────────
84
+ // REQUIRED. The argument is exactly this shape; see adapters/contract.md.
85
+ // Return a HANDLE — you get it back as `user` on every later call, so put
86
+ // the token, session or client on it. The engine only ever reads `.id`.
87
+ async createUser({ name, phone, persona, index }) {
88
+ const out = await call("POST", "/auth/signup", { // EDIT
89
+ body: { phone, password: PASSWORD, name }, // EDIT: field names
90
+ });
91
+ return { id: out.id, token: out.token }; // EDIT: response shape
92
+ },
93
+
94
+ // Optional but worth it: lets `populace clean` check for leftover accounts
95
+ // without CREATING them to find out whether they exist. Return null when
96
+ // the account genuinely is not there, and throw for anything else — the
97
+ // difference is what stops "I could not look" being reported as "nothing
98
+ // was there".
99
+ async signIn({ phone }) {
100
+ try {
101
+ const out = await call("POST", "/auth/signin", { // EDIT
102
+ body: { phone, password: PASSWORD },
103
+ });
104
+ return { id: out.id, token: out.token };
105
+ } catch (error) {
106
+ if (/^401/.test(error.message)) return null;
107
+ throw error;
108
+ }
109
+ },
110
+
111
+ // IMPLEMENT THIS IF YOUR TOKENS EXPIRE. Populace calls it periodically.
112
+ // Without it, any run longer than your token lifetime collapses all at
113
+ // once, the report blames your API for what were really expired tokens,
114
+ // and cleanup cannot delete its own accounts — leaving simulated people
115
+ // in your environment.
116
+ async refreshSession(user) {
117
+ const out = await call("POST", "/auth/refresh", { token: user.token }); // EDIT
118
+ user.token = out.token;
119
+ return user;
120
+ },
121
+
122
+ async setProfile(user, persona) {
123
+ await call("PATCH", "/me", { // EDIT
124
+ token: user.token,
125
+ body: { name: persona.name, city: persona.city?.name },
126
+ });
127
+ },
128
+
129
+ // REQUIRED. The path almost nobody exercises, and the one regulators ask
130
+ // about.
131
+ async deleteUser(user) {
132
+ await call("DELETE", "/me", { token: user.token }); // EDIT
133
+ },
134
+
135
+ // ── movement ────────────────────────────────────────────────────────
136
+ // Usually the heaviest sustained write load an app takes. Delete if your
137
+ // product has no location.
138
+ async reportLocation(user, { lat, lng, distanceKm, earnings, platform }) {
139
+ await call("POST", "/locations", { // EDIT
140
+ token: user.token,
141
+ body: { lat, lng, km: distanceKm },
142
+ });
143
+ },
144
+
145
+ // ── social ──────────────────────────────────────────────────────────
146
+ // Must return the new post's id, or `like` and `comment` have nothing to
147
+ // act on.
148
+ async post(user, text) {
149
+ const out = await call("POST", "/posts", { token: user.token, body: { body: text } }); // EDIT
150
+ return out.id;
151
+ },
152
+
153
+ // Return an array. Objects with an `id`, or bare ids — both are accepted.
154
+ // This is the call that catches a feed leaking other people's rows.
155
+ async recentPostsByOthers(user, limit) {
156
+ return await call("GET", "/posts/others", { token: user.token }); // EDIT
157
+ },
158
+
159
+ async like(user, postId) {
160
+ await call("POST", "/likes", { token: user.token, body: { postId } }); // EDIT
161
+ },
162
+
163
+ async comment(user, postId, text) {
164
+ await call("POST", "/comments", { token: user.token, body: { postId, body: text } }); // EDIT
165
+ },
166
+
167
+ // ── messaging ───────────────────────────────────────────────────────
168
+ async openConversation(user, otherUserId) {
169
+ const out = await call("POST", "/threads", { // EDIT
170
+ token: user.token,
171
+ body: { otherUserId },
172
+ });
173
+ return out.id; // conversation id
174
+ },
175
+
176
+ async sendMessage(user, conversationId, text) {
177
+ await call("POST", "/messages", { // EDIT
178
+ token: user.token,
179
+ body: { threadId: conversationId, body: text },
180
+ });
181
+ },
182
+
183
+ // ── groups ──────────────────────────────────────────────────────────
184
+ async listGroups(user) {
185
+ return await call("GET", "/groups", { token: user.token }); // EDIT
186
+ },
187
+
188
+ async joinGroup(user, groupId) {
189
+ await call("POST", "/groups/join", { token: user.token, body: { groupId } }); // EDIT
190
+ },
191
+ };
192
+ }
@@ -0,0 +1,80 @@
1
+ // Your adapter.
2
+ //
3
+ // Each method answers one question: "when a simulated person does this, what
4
+ // happens in MY app?" Delete the ones that don't apply — anything you leave out
5
+ // is skipped, and `populace doctor` will tell you exactly what that costs you
6
+ // in coverage.
7
+ //
8
+ // Two rules, both in adapters/contract.md:
9
+ // 1. Never point this at production.
10
+ // 2. Go through your real API. No admin keys, no direct database writes.
11
+ // A simulation that bypasses your permission rules proves nothing about
12
+ // whether they work.
13
+
14
+ export function createAdapter(target, config) {
15
+ return {
16
+ name: "my-app",
17
+
18
+ // Optional, but `populace doctor` uses it to check the target is up before
19
+ // you spend a run finding out that it isn't.
20
+ async healthCheck() {
21
+ const res = await fetch(`${target.url}/health`, { signal: AbortSignal.timeout(10000) });
22
+ if (!res.ok) throw new Error(`health check returned ${res.status}`);
23
+ },
24
+
25
+ // --- identity (createUser is REQUIRED) --------------------------------
26
+ // Create or re-use an account. Return a handle — you'll get it back on
27
+ // every later call, so put the session/token/client on it.
28
+ // Re-runs should REUSE accounts, not pile up new ones.
29
+ async createUser({ name, phone, persona, index }) {
30
+ // const session = await signUpOrSignIn(...)
31
+ // return { id: session.userId, session };
32
+ throw new Error("createUser not implemented");
33
+ },
34
+
35
+ async setProfile(user, persona) {},
36
+
37
+ // If your API uses expiring access tokens, IMPLEMENT THIS. Populace calls
38
+ // it every 30 minutes (see session.refreshEveryMinutes). Without it, a run
39
+ // longer than your token lifetime fails everywhere at once and the report
40
+ // blames your API — and cleanup can't run either, leaving simulated
41
+ // accounts stranded in your environment.
42
+ async refreshSession(user) {},
43
+
44
+ // --- the world --------------------------------------------------------
45
+ // Called every tick with a new position and running totals. Skip it
46
+ // entirely if your app has no location.
47
+ async reportLocation(user, { lat, lng, distanceKm, earnings, platform }) {},
48
+
49
+ // --- social -----------------------------------------------------------
50
+ // Empty bodies below are treated as NOT implemented, and `populace doctor`
51
+ // will list them as untested rather than pretending they passed. Fill in
52
+ // the ones your app has; delete the ones it doesn't.
53
+ async post(user, text) {
54
+ // return postId
55
+ },
56
+ async recentPostsByOthers(user, limit) {
57
+ // return [{ id, userId }]
58
+ },
59
+ async like(user, postId) {},
60
+ async comment(user, postId, text) {},
61
+
62
+ async openConversation(user, otherUserId) {
63
+ // return conversationId
64
+ },
65
+ async sendMessage(user, conversationId, text) {},
66
+
67
+ async listGroups(user) {
68
+ // return [{ id }]
69
+ },
70
+ async joinGroup(user, groupId) {},
71
+
72
+ // --- cleanup (REQUIRED) -----------------------------------------------
73
+ // Must fully remove the account. Prefer your app's OWN delete-account path
74
+ // so the simulation exercises it too — it is the route almost nobody tests
75
+ // and the one regulators ask about.
76
+ async deleteUser(user) {
77
+ throw new Error("deleteUser not implemented");
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,245 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8">
3
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4
+ <title>Populace report — Buzz Buzz</title>
5
+ <style>
6
+ :root{
7
+ --paper:#F4F5F7;--card:#fff;--ink:#12181D;--body:#3A454E;--muted:#6B7883;
8
+ --rule:#DDE2E7;--accent:#A85A0B;--ok:#2C6E4C;--bad:#A63127;--warn:#9A6A08;
9
+ --bar:#C9D4DC;--barbad:#E0A99F;
10
+ --mono:ui-monospace,"SF Mono","Cascadia Mono",Menlo,Consolas,monospace;
11
+ --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
12
+ }
13
+ @media (prefers-color-scheme:dark){:root{
14
+ --paper:#0E1418;--card:#161E24;--ink:#E8EEF3;--body:#B0BDC7;--muted:#7B8994;
15
+ --rule:#26323A;--accent:#E0A45C;--ok:#5FAB80;--bad:#DE7568;--warn:#D2A24E;
16
+ --bar:#2E3A43;--barbad:#5C332C;}}
17
+ :root[data-theme="dark"]{--paper:#0E1418;--card:#161E24;--ink:#E8EEF3;--body:#B0BDC7;
18
+ --muted:#7B8994;--rule:#26323A;--accent:#E0A45C;--ok:#5FAB80;--bad:#DE7568;
19
+ --warn:#D2A24E;--bar:#2E3A43;--barbad:#5C332C;}
20
+ :root[data-theme="light"]{--paper:#F4F5F7;--card:#fff;--ink:#12181D;--body:#3A454E;
21
+ --muted:#6B7883;--rule:#DDE2E7;--accent:#A85A0B;--ok:#2C6E4C;--bad:#A63127;
22
+ --warn:#9A6A08;--bar:#C9D4DC;--barbad:#E0A99F;}
23
+ *{box-sizing:border-box}
24
+ body{margin:0;background:var(--paper);color:var(--body);font-family:var(--sans);
25
+ font-size:15.5px;line-height:1.6;-webkit-font-smoothing:antialiased}
26
+ .wrap{max-width:900px;margin:0 auto;padding:0 22px 80px}
27
+ h1,h2,h3{color:var(--ink);margin:0;text-wrap:balance}
28
+ h1{font-family:var(--mono);font-size:clamp(22px,4vw,30px);letter-spacing:-.02em}
29
+ h2{font-size:17px;font-family:var(--mono);letter-spacing:-.01em}
30
+ .mono{font-family:var(--mono)}
31
+ header{padding:44px 0 24px;border-bottom:2px solid var(--ink);display:flex;
32
+ flex-direction:column;gap:10px}
33
+ .sub{font-family:var(--mono);font-size:12.5px;color:var(--muted)}
34
+ .verdict{margin-top:26px;padding:20px 22px;border-radius:6px;display:flex;
35
+ flex-direction:column;gap:8px;border-left:4px solid}
36
+ .verdict.ok{background:color-mix(in srgb,var(--ok) 8%,transparent);border-color:var(--ok)}
37
+ .verdict.bad{background:color-mix(in srgb,var(--bad) 8%,transparent);border-color:var(--bad)}
38
+ .verdict h2{color:var(--ink)}
39
+ .verdict ul{margin:4px 0 0;padding-left:20px}
40
+ .stats{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));margin-top:26px}
41
+ .stat{background:var(--card);border:1px solid var(--rule);border-radius:6px;padding:15px 16px}
42
+ .stat .v{font-family:var(--mono);font-size:23px;color:var(--ink);font-weight:600;
43
+ letter-spacing:-.02em;font-variant-numeric:tabular-nums;display:block}
44
+ .stat .l{font-family:var(--mono);font-size:10px;letter-spacing:.12em;
45
+ text-transform:uppercase;color:var(--muted)}
46
+ section{margin-top:42px;display:flex;flex-direction:column;gap:14px}
47
+ .panel{background:var(--card);border:1px solid var(--rule);border-radius:6px;padding:20px 22px}
48
+ .panel.danger{border-color:var(--bad);border-left-width:4px}
49
+ .panel.danger h2{color:var(--bad)}
50
+ .tablewrap{overflow-x:auto;border:1px solid var(--rule);border-radius:6px;background:var(--card)}
51
+ table{border-collapse:collapse;width:100%;min-width:600px}
52
+ th,td{padding:10px 14px;text-align:left;border-bottom:1px solid var(--rule)}
53
+ th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;
54
+ color:var(--muted);font-weight:600}
55
+ td.num{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:13.5px}
56
+ td.name{font-size:13.5px;color:var(--ink);white-space:nowrap}
57
+ td.zero{color:var(--muted)}
58
+ td.fail{color:var(--bad);font-weight:600}
59
+ .x{color:var(--bad);margin-right:6px}
60
+ .barcell{width:120px}
61
+ .bar{height:7px;background:var(--bar);border-radius:4px}
62
+ .row-bad .bar{background:var(--barbad)}
63
+ tr.errrow td{padding-top:0;border-bottom:1px solid var(--rule)}
64
+ .err{font-family:var(--mono);font-size:12px;color:var(--bad);padding:3px 0 3px 26px}
65
+ .errn{color:var(--muted);margin-right:8px}
66
+ .trace{font-family:var(--mono);font-size:11.5px;white-space:pre-wrap;overflow-x:auto;
67
+ background:var(--paper);padding:14px;border-radius:5px;margin:0;color:var(--body)}
68
+ ul.cov{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:9px}
69
+ ul.cov li{display:flex;gap:14px;flex-wrap:wrap;align-items:baseline;font-size:14px}
70
+ ul.cov .mono{color:var(--ink);min-width:170px;font-size:13px}
71
+ .would{color:var(--muted);font-size:13.5px}
72
+ .chip{display:inline-block;font-family:var(--mono);font-size:12px;padding:7px 13px;
73
+ border-radius:5px;border:1px solid}
74
+ .chip.ok{color:var(--ok);border-color:var(--ok);background:color-mix(in srgb,var(--ok) 8%,transparent)}
75
+ .chip.bad{color:var(--bad);border-color:var(--bad);background:color-mix(in srgb,var(--bad) 8%,transparent)}
76
+ .chip.warn{color:var(--warn);border-color:var(--warn);background:color-mix(in srgb,var(--warn) 8%,transparent)}
77
+ footer{margin-top:52px;padding-top:20px;border-top:1px solid var(--rule);
78
+ font-size:13px;color:var(--muted);display:flex;flex-direction:column;gap:6px}
79
+ </style></head><body>
80
+ <div class="wrap">
81
+
82
+ <header>
83
+ <h1>Buzz Buzz</h1>
84
+ <span class="sub">test environment · 10 simulated people ·
85
+ 14m 38s · Sat, 15 Aug 2026 11:18:42 GMT</span>
86
+ </header>
87
+
88
+ <div class="verdict ok">
89
+ <h2>No failures across 2030 API calls</h2>
90
+
91
+ </div>
92
+
93
+
94
+
95
+ <div class="stats">
96
+ <div class="stat"><span class="v">2030</span><span class="l">API calls</span></div>
97
+ <div class="stat"><span class="v" style="color:var(--ok)">0</span><span class="l">failed</span></div>
98
+ <div class="stat"><span class="v">0.0%</span><span class="l">failure rate</span></div>
99
+ <div class="stat"><span class="v">10</span><span class="l">concurrent users</span></div>
100
+ <div class="stat"><span class="v">13/13</span><span class="l">coverage</span></div>
101
+ </div>
102
+
103
+ <section>
104
+ <h2>Your API under 10 concurrent users</h2>
105
+ <div class="tablewrap">
106
+ <table>
107
+ <thead><tr><th>Method</th><th style="text-align:right">Calls</th>
108
+ <th style="text-align:right">Fails</th><th style="text-align:right">p50</th>
109
+ <th style="text-align:right">p95</th><th>p95 relative</th></tr></thead>
110
+ <tbody>
111
+ <tr class="">
112
+ <td class="mono name">reportLocation</td>
113
+ <td class="num">892</td>
114
+ <td class="num zero">0</td>
115
+ <td class="num">711ms</td>
116
+ <td class="num">1.0s</td>
117
+ <td class="barcell"><div class="bar" style="width:100.0%"></div></td>
118
+ </tr>
119
+
120
+ <tr class="">
121
+ <td class="mono name">recentPostsByOthers</td>
122
+ <td class="num">298</td>
123
+ <td class="num zero">0</td>
124
+ <td class="num">241ms</td>
125
+ <td class="num">349ms</td>
126
+ <td class="barcell"><div class="bar" style="width:34.8%"></div></td>
127
+ </tr>
128
+
129
+ <tr class="">
130
+ <td class="mono name">like</td>
131
+ <td class="num">296</td>
132
+ <td class="num zero">0</td>
133
+ <td class="num">238ms</td>
134
+ <td class="num">319ms</td>
135
+ <td class="barcell"><div class="bar" style="width:31.8%"></div></td>
136
+ </tr>
137
+
138
+ <tr class="">
139
+ <td class="mono name">post</td>
140
+ <td class="num">139</td>
141
+ <td class="num zero">0</td>
142
+ <td class="num">240ms</td>
143
+ <td class="num">313ms</td>
144
+ <td class="barcell"><div class="bar" style="width:31.2%"></div></td>
145
+ </tr>
146
+
147
+ <tr class="">
148
+ <td class="mono name">comment</td>
149
+ <td class="num">123</td>
150
+ <td class="num zero">0</td>
151
+ <td class="num">243ms</td>
152
+ <td class="num">326ms</td>
153
+ <td class="barcell"><div class="bar" style="width:32.5%"></div></td>
154
+ </tr>
155
+
156
+ <tr class="">
157
+ <td class="mono name">openConversation</td>
158
+ <td class="num">110</td>
159
+ <td class="num zero">0</td>
160
+ <td class="num">245ms</td>
161
+ <td class="num">336ms</td>
162
+ <td class="barcell"><div class="bar" style="width:33.5%"></div></td>
163
+ </tr>
164
+
165
+ <tr class="">
166
+ <td class="mono name">sendMessage</td>
167
+ <td class="num">110</td>
168
+ <td class="num zero">0</td>
169
+ <td class="num">244ms</td>
170
+ <td class="num">351ms</td>
171
+ <td class="barcell"><div class="bar" style="width:35.0%"></div></td>
172
+ </tr>
173
+
174
+ <tr class="">
175
+ <td class="mono name">listGroups</td>
176
+ <td class="num">16</td>
177
+ <td class="num zero">0</td>
178
+ <td class="num">250ms</td>
179
+ <td class="num">312ms</td>
180
+ <td class="barcell"><div class="bar" style="width:31.1%"></div></td>
181
+ </tr>
182
+
183
+ <tr class="">
184
+ <td class="mono name">joinGroup</td>
185
+ <td class="num">16</td>
186
+ <td class="num zero">0</td>
187
+ <td class="num">235ms</td>
188
+ <td class="num">311ms</td>
189
+ <td class="barcell"><div class="bar" style="width:31.0%"></div></td>
190
+ </tr>
191
+
192
+ <tr class="">
193
+ <td class="mono name">createUser</td>
194
+ <td class="num">10</td>
195
+ <td class="num zero">0</td>
196
+ <td class="num">601ms</td>
197
+ <td class="num">834ms</td>
198
+ <td class="barcell"><div class="bar" style="width:83.2%"></div></td>
199
+ </tr>
200
+
201
+ <tr class="">
202
+ <td class="mono name">setProfile</td>
203
+ <td class="num">10</td>
204
+ <td class="num zero">0</td>
205
+ <td class="num">249ms</td>
206
+ <td class="num">392ms</td>
207
+ <td class="barcell"><div class="bar" style="width:39.1%"></div></td>
208
+ </tr>
209
+
210
+ <tr class="">
211
+ <td class="mono name">deleteUser</td>
212
+ <td class="num">10</td>
213
+ <td class="num zero">0</td>
214
+ <td class="num">320ms</td>
215
+ <td class="num">508ms</td>
216
+ <td class="barcell"><div class="bar" style="width:50.6%"></div></td>
217
+ </tr>
218
+ </tbody>
219
+ </table>
220
+ </div>
221
+ </section>
222
+
223
+ <section>
224
+ <h2>What the population did</h2>
225
+ <div class="panel">
226
+ 36 km travelled · 139 posts ·
227
+ 296 likes · 123 comments ·
228
+ 110 messages · 16 group joins
229
+ </div>
230
+ </section>
231
+
232
+
233
+
234
+ <section>
235
+ <h2>Cleanup</h2>
236
+ <div><div class="chip ok">Cleanup complete — 10 accounts removed</div></div>
237
+ </section>
238
+
239
+ <footer>
240
+ <span>Generated by Populace 0.1.0 · adapter <span class="mono">../../adapters/buzzbuzz.mjs</span></span>
241
+ <span>Simulated people are generated from patterns. This report shows whether your app
242
+ <strong>works</strong> — not whether anyone wants it.</span>
243
+ </footer>
244
+
245
+ </div></body></html>