@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
package/src/report.mjs ADDED
@@ -0,0 +1,251 @@
1
+ // The run report — the thing a customer keeps after the terminal is closed.
2
+ //
3
+ // A live table is a demo. A report is a product: it says what broke, how often,
4
+ // how slow it got, what was never tested at all, and whether the accounts were
5
+ // cleaned up. It has to be honest about the last two, because a report that
6
+ // only shows successes is worse than no report — it manufactures confidence.
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { summarise } from "./instrument.mjs";
11
+ import { coverageOf } from "./contract.mjs";
12
+ import { renderHtmlReport } from "./html-report.mjs";
13
+ import { VERSION } from "./version.mjs";
14
+
15
+ const pct = (n) => `${(n * 100).toFixed(1)}%`;
16
+ const ms = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}s` : `${Math.round(n)}ms`);
17
+
18
+ export function buildReport({ config, adapter, world, metrics, teardown, startedAt }) {
19
+ const api = summarise(metrics);
20
+ const coverage = coverageOf(adapter);
21
+ const totals = world.totals();
22
+
23
+ const engineErrors = world.engineErrors ? world.engineErrors() : [];
24
+ const verdict = decideVerdict({ api, world, teardown, engineErrors });
25
+
26
+ return {
27
+ populace: { version: VERSION, generatedAt: new Date().toISOString() },
28
+ run: {
29
+ app: config.app || adapter.name || "unknown",
30
+ adapter: config.adapter,
31
+ environment: config.environment,
32
+ startedAt: new Date(startedAt).toISOString(),
33
+ durationMs: Date.now() - startedAt,
34
+ population: config.population,
35
+ },
36
+ verdict,
37
+ population: {
38
+ requested: world.personas.length,
39
+ signedIn: world.agents.length,
40
+ signupFailures: world.signupFailures,
41
+ },
42
+ engineErrors: world.engineErrors ? world.engineErrors() : [],
43
+ activity: {
44
+ distanceKm: Number(totals.km.toFixed(1)),
45
+ posts: totals.posts,
46
+ likes: totals.likes,
47
+ comments: totals.comments,
48
+ messages: totals.messages,
49
+ groupJoins: totals.groups,
50
+ },
51
+ api,
52
+ coverage: {
53
+ label: coverage.label,
54
+ implemented: coverage.implemented.map((c) => c.method),
55
+ notTested: coverage.missing.map((c) => ({ method: c.method, wouldHaveTested: c.exercises })),
56
+ },
57
+ cleanup: teardown ?? { skipped: true, note: "Agents were left in place. Run `populace clean`." },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Deliberately conservative. Anything unproven is called unproven, never
63
+ * "passed" — including a run where nothing failed because nothing ran.
64
+ */
65
+ function decideVerdict({ api, world, teardown, engineErrors = [] }) {
66
+ const problems = [];
67
+
68
+ // Populace's own failures come first. If the tool is broken, nothing it says
69
+ // about the customer's API can be trusted, and a clean verdict would be a lie.
70
+ if (engineErrors.length) {
71
+ problems.push(
72
+ `${engineErrors.length} failure(s) inside Populace itself — this run did not test what it claims to have tested.`,
73
+ );
74
+ }
75
+
76
+ if (!world.agents.length) {
77
+ problems.push("No agent could sign in — nothing was tested.");
78
+ }
79
+ if (world.signupFailures.length) {
80
+ problems.push(`${world.signupFailures.length} of ${world.personas.length} accounts could not sign in.`);
81
+ }
82
+ // ANY failure is a finding. There is deliberately no tolerance threshold:
83
+ // a percentage band means a real bug can hide under it on a short run and
84
+ // the report will call the run clean while its own table shows the failure.
85
+ // For a correctness tool that is the worst possible defect — it exits 0 and
86
+ // CI waves the bug through.
87
+ //
88
+ // But the two kinds of failure are stated separately, because they belong to
89
+ // different people. Blaming a customer's API for a dropped socket is how a
90
+ // testing tool loses its credibility — and once a team decides the reports
91
+ // cry wolf, they stop reading the real findings too.
92
+ if (api.apiFailures > 0) {
93
+ problems.push(
94
+ `${api.apiFailures} of ${api.calls} API calls failed (${pct(api.apiFailureRate)}).`,
95
+ );
96
+ }
97
+ if (api.transportFailures > 0) {
98
+ problems.push(
99
+ `${api.transportFailures} of ${api.calls} calls never reached the API after ` +
100
+ `${api.retries} retries — the network between Populace and your server, not your code. ` +
101
+ `These were NOT tested.`,
102
+ );
103
+ }
104
+ // Stated separately and first among network problems: a run that was cut
105
+ // short covers less than it appears to, and every number below it is drawn
106
+ // from a shorter sample than the one that was asked for.
107
+ if (api.network?.gaveUp) {
108
+ problems.push(
109
+ `Populace stopped early — the target stopped responding entirely ` +
110
+ `(${api.network.gaveUpAfter} consecutive unreachable calls). This run is incomplete.`,
111
+ );
112
+ }
113
+ if (teardown?.failed?.length) {
114
+ problems.push(`${teardown.failed.length} simulated accounts could NOT be deleted and are still live.`);
115
+ }
116
+ if (api.calls === 0) {
117
+ problems.push("No API calls were made.");
118
+ }
119
+
120
+ const failing = api.methods.filter((m) => m.failures > 0);
121
+
122
+ // Three outcomes, not two. A run that found no bugs but could not reach the
123
+ // server half the time has not proved anything, and calling it "clean" would
124
+ // be the single most damaging lie this tool could tell. It is also not
125
+ // "problems-found" in the customer's code — so it gets its own name.
126
+ let status = "clean";
127
+ if (api.apiFailures > 0 || engineErrors.length || !world.agents.length || teardown?.failed?.length) {
128
+ status = "problems-found";
129
+ } else if (problems.length) {
130
+ status = "inconclusive";
131
+ }
132
+
133
+ return {
134
+ status,
135
+ problems,
136
+ // Kept explicit so a CI job can branch on "is this my bug or my network?"
137
+ // without re-deriving it from the prose.
138
+ apiFailures: api.apiFailures,
139
+ transportFailures: api.transportFailures,
140
+ failingMethods: failing.map((m) => ({
141
+ method: m.method,
142
+ failureRate: Number(m.failureRate.toFixed(3)),
143
+ apiFailures: m.apiFailures,
144
+ transportFailures: m.transportFailures,
145
+ topError: m.errors[0]?.message ?? null,
146
+ })),
147
+ };
148
+ }
149
+
150
+ export function writeReport(report, config) {
151
+ const file = path.resolve(config._dir || process.cwd(), config.report?.path || "populace-report.json");
152
+ fs.writeFileSync(file, JSON.stringify(report, null, 2));
153
+
154
+ // The HTML twin is what actually gets shared — emailed to a colleague,
155
+ // attached to a ticket, sent to whoever paid for the run. The JSON is for
156
+ // machines; this is for people who weren't watching the terminal.
157
+ const html = file.replace(/\.json$/, "") + ".html";
158
+ fs.writeFileSync(html, renderHtmlReport(report));
159
+ return { json: file, html };
160
+ }
161
+
162
+ export function renderReport(report) {
163
+ const L = [];
164
+ const rule = "─".repeat(72);
165
+
166
+ L.push("");
167
+ L.push(` POPULACE REPORT — ${report.run.app}`);
168
+ L.push(` ${report.run.environment} · ${report.population.signedIn} people · ${ms(report.run.durationMs)}`);
169
+ L.push(rule);
170
+
171
+ // Verdict first. Someone reading this in CI should not have to scroll.
172
+ // The "no failures" claim is derived from the COUNT, never from the verdict
173
+ // alone — so this line can never contradict the table printed below it.
174
+ if (report.verdict.status === "clean" && report.api.failures === 0) {
175
+ L.push(` ✔ No failures across ${report.api.calls} API calls.`);
176
+ } else if (report.verdict.status === "inconclusive") {
177
+ // Deliberately not "✖". Nothing is known to be wrong with their code — the
178
+ // run simply could not prove otherwise, and saying so plainly is the point.
179
+ L.push(` ⚠ Inconclusive — your API did not fail, but the run could not complete:`);
180
+ for (const p of report.verdict.problems) L.push(` · ${p}`);
181
+ } else {
182
+ L.push(` ✖ Problems found:`);
183
+ for (const p of report.verdict.problems) L.push(` · ${p}`);
184
+ }
185
+
186
+ // Connection quality, whenever it was anything other than perfect. A reader
187
+ // comparing two runs needs to know whether the latency below was measured
188
+ // over a good link or a bad one.
189
+ const net = report.api.network;
190
+ if (net && !net.healthy) {
191
+ L.push("");
192
+ L.push(
193
+ ` CONNECTION — ${net.retries} retried attempt(s), ` +
194
+ `${pct(net.retryRate)} of all attempts; ${net.transportFailures} never got through.`,
195
+ );
196
+ L.push(` Latency below is measured on successful attempts only, so it is not inflated by these.`);
197
+ }
198
+ L.push(rule);
199
+
200
+ L.push(` ACTIVITY`);
201
+ const a = report.activity;
202
+ L.push(
203
+ ` ${a.distanceKm} km driven · ${a.posts} posts · ${a.likes} likes · ` +
204
+ `${a.comments} comments · ${a.messages} messages · ${a.groupJoins} joins`,
205
+ );
206
+ L.push("");
207
+
208
+ L.push(` YOUR API UNDER ${report.population.signedIn} CONCURRENT USERS`);
209
+ L.push(` ${"method".padEnd(22)}${"calls".padStart(7)}${"fails".padStart(7)}${"p50".padStart(9)}${"p95".padStart(9)}`);
210
+ for (const m of report.api.methods) {
211
+ const flag = m.failures ? " ✖" : " ";
212
+ L.push(
213
+ ` ${flag}${m.method.padEnd(21)}${String(m.calls).padStart(7)}${String(m.failures).padStart(7)}` +
214
+ `${ms(m.latencyMs.p50).padStart(9)}${ms(m.latencyMs.p95).padStart(9)}`,
215
+ );
216
+ for (const e of m.errors.slice(0, 3)) {
217
+ L.push(` ↳ ${e.count}× ${e.message}`);
218
+ }
219
+ }
220
+ L.push("");
221
+
222
+ if (report.coverage.notTested.length) {
223
+ L.push(` NOT TESTED — adapter implements ${report.coverage.label}`);
224
+ for (const c of report.coverage.notTested) {
225
+ L.push(` · ${c.method.padEnd(21)} would have tested ${c.wouldHaveTested}`);
226
+ }
227
+ L.push("");
228
+ }
229
+
230
+ if (report.cleanup?.skipped) {
231
+ L.push(` ⚠ CLEANUP SKIPPED — ${report.population.signedIn} simulated accounts are still live.`);
232
+ L.push(` Remove them with: populace clean`);
233
+ } else if (report.cleanup?.failed?.length) {
234
+ L.push(` ⚠ CLEANUP INCOMPLETE — ${report.cleanup.failed.length} accounts could not be deleted:`);
235
+ for (const f of report.cleanup.failed.slice(0, 5)) L.push(` · ${f.name}: ${f.error}`);
236
+ } else if (report.cleanup?.notDeleted?.length) {
237
+ // Not a failure, and not a success either. Saying "complete" here would be
238
+ // claiming to have removed accounts that were never touched.
239
+ L.push(
240
+ ` ⚠ Cleanup partial — ${report.cleanup.removed} removed, ` +
241
+ `${report.cleanup.notDeleted.length} had nothing to delete:`,
242
+ );
243
+ for (const n of report.cleanup.notDeleted.slice(0, 5)) L.push(` · ${n.name}: ${n.why}`);
244
+ } else {
245
+ L.push(` ✔ Cleanup complete — ${report.cleanup.removed} accounts removed.`);
246
+ }
247
+
248
+ L.push(rule);
249
+ L.push("");
250
+ return L.join("\n");
251
+ }