@gigzen/populace 0.1.0 → 1.0.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.
@@ -17,13 +17,13 @@ export class World {
17
17
  }
18
18
 
19
19
  static fromConfig(config, adapter, on) {
20
- const { agents = 6, cities } = config.population;
20
+ const { agents = 6, cities, engagement = 1 } = config.population;
21
21
  const personas =
22
22
  typeof config.personas === "function"
23
- ? config.personas(agents, cities)
23
+ ? config.personas(agents, cities, engagement)
24
24
  : Array.isArray(config.personas)
25
25
  ? config.personas
26
- : buildPersonas(agents, cities);
26
+ : buildPersonas(agents, cities, engagement);
27
27
  const options = {
28
28
  ...(config.identity || {}),
29
29
  refreshEveryMs: (config.session?.refreshEveryMinutes ?? 30) * 60_000,
@@ -0,0 +1,247 @@
1
+ // Turn an error message into what broke, why, and the fix.
2
+ //
3
+ // A report that says `duplicate key value violates unique constraint
4
+ // "post_likes_pkey"` has told you what happened and nothing about what to do.
5
+ // The five defects Populace found on its first run were all one mistake wearing
6
+ // different clothes, and it took a person to notice. This is that person,
7
+ // written down.
8
+ //
9
+ // Rules first, model second, and deliberately in that order. Most failures a
10
+ // simulated population provokes are a small set of recognisable shapes:
11
+ // permissions, foreign keys, expired sessions, provider quotas, sockets. A rule
12
+ // is instant, free, offline and auditable — you can read why it said what it
13
+ // said. The model is for the remainder, and gets the failures a rule could not
14
+ // name rather than all of them.
15
+ //
16
+ // The most important job here is the first question, not the last: was this
17
+ // even your application? A transport failure and a 500 look equally red in a
18
+ // terminal and mean completely different things.
19
+
20
+ /**
21
+ * @typedef {object} Explanation
22
+ * @property {"app"|"environment"|"harness"|"unknown"} blame who has to fix it
23
+ * @property {string} headline one line: what happened
24
+ * @property {string} why the mechanism
25
+ * @property {string} [fix] what to change
26
+ * @property {string} rule which rule matched, so the reasoning is traceable
27
+ */
28
+
29
+ /**
30
+ * Ordered. The first match wins, so specific patterns precede general ones —
31
+ * "upsert refused by RLS" has to be tested before plain "permission denied",
32
+ * or the more useful explanation never fires.
33
+ */
34
+ const RULES = [
35
+ {
36
+ rule: "rls-upsert",
37
+ when: (t) => /42501|permission denied|row-level security/i.test(t) && /upsert|on conflict/i.test(t),
38
+ blame: "app",
39
+ headline: "An upsert was refused by a row-level-security policy.",
40
+ why:
41
+ "`INSERT … ON CONFLICT DO UPDATE` needs SELECT on every column it touches, not just " +
42
+ "INSERT. If a privacy policy restricts reads on any one of those columns, the whole " +
43
+ "statement is refused — and it fails silently for the owner, because their own row " +
44
+ "already exists.",
45
+ fix:
46
+ "Insert plainly and treat the duplicate error as success, or widen the SELECT policy to " +
47
+ "cover the columns the upsert writes. You cannot upsert a column you cannot select.",
48
+ },
49
+ {
50
+ rule: "rls-denied",
51
+ when: (t) => /42501|permission denied|row-level security|violates row-level/i.test(t),
52
+ blame: "app",
53
+ headline: "The database refused the write on permissions, not on data.",
54
+ why:
55
+ "A row-level-security policy rejected this call for the signed-in role. The request was " +
56
+ "well formed; the policy did not allow it. This is invisible to whoever wrote the policy, " +
57
+ "because their own account usually satisfies it.",
58
+ fix:
59
+ "Check the policy for this table against the role the app authenticates as, and confirm " +
60
+ "there is a policy for this specific command — a table with only a SELECT policy refuses " +
61
+ "every INSERT.",
62
+ },
63
+ {
64
+ rule: "duplicate-key",
65
+ when: (t) => /23505|duplicate key|already exists|unique constraint/i.test(t),
66
+ blame: "app",
67
+ headline: "The row already existed.",
68
+ why:
69
+ "A unique constraint rejected a second identical write. Usually this is not a bug at all: " +
70
+ "liking a post twice, or joining a group you are already in, is a normal thing for a user " +
71
+ "to do and the constraint is doing its job.",
72
+ fix:
73
+ "Decide whether this is idempotent. If it is, catch the duplicate code and return success " +
74
+ "rather than surfacing an error the user cannot act on.",
75
+ },
76
+ {
77
+ rule: "foreign-key",
78
+ when: (t) => /23503|foreign key|violates foreign key constraint/i.test(t),
79
+ blame: "app",
80
+ headline: "This referenced a row that was never created.",
81
+ why:
82
+ "A foreign key pointed at something absent. In a simulated run the usual cause is an " +
83
+ "earlier step that failed quietly: if profile creation is refused during signup, every " +
84
+ "post and group-join afterwards dies on a key pointing at the row that was never written.",
85
+ fix:
86
+ "Look at what ran before this, not at this call. The first failure in the sequence is the " +
87
+ "real one; this is its shadow.",
88
+ },
89
+ {
90
+ rule: "schema-cache",
91
+ when: (t) => /PGRST20[245]|schema cache|could not find the (table|column|function)/i.test(t),
92
+ blame: "app",
93
+ headline: "The table, column or function is not in the API's schema cache.",
94
+ why:
95
+ "PostgREST answers this both when an object genuinely does not exist and when it exists " +
96
+ "with a different signature. A function called with the wrong argument names reports " +
97
+ "exactly the same code as one that was never created.",
98
+ fix:
99
+ "Confirm the object exists, then confirm the call matches its signature. If a migration " +
100
+ "was run recently, the schema cache may simply need reloading.",
101
+ },
102
+ {
103
+ rule: "auth-expired",
104
+ when: (t) => /jwt expired|token (is )?expired|invalid token|401/i.test(t),
105
+ blame: "app",
106
+ headline: "The session was rejected as expired or invalid.",
107
+ why:
108
+ "Access tokens are short-lived. A run longer than the token lifetime must refresh, or " +
109
+ "every call after expiry fails — and the failures start abruptly partway through rather " +
110
+ "than at the beginning, which makes them look like a load problem.",
111
+ fix:
112
+ "Implement `refreshSession` in the adapter and check the session lifetime against the run " +
113
+ "length. Note when the failures began: at the token lifetime is the tell.",
114
+ },
115
+ {
116
+ rule: "rate-limit",
117
+ when: (t) => /rate limit|429|too many requests|quota/i.test(t),
118
+ blame: "environment",
119
+ headline: "A provider rate limit refused the call. This is not your code.",
120
+ why:
121
+ "The platform hosting the API applied its own throttle. Signup endpoints are the usual " +
122
+ "one — most hosted auth services cap new accounts per hour, which caps how large a " +
123
+ "simulated population can be regardless of what the app can handle.",
124
+ fix:
125
+ "Run a smaller population, spread signups further apart, or use a project without the " +
126
+ "quota. It does not tell you anything about the app's capacity.",
127
+ },
128
+ {
129
+ rule: "transport",
130
+ when: (t) => /fetch failed|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|UND_ERR|socket hang up|network error/i.test(t),
131
+ blame: "harness",
132
+ headline: "The request never reached the server, so nothing about the app was tested.",
133
+ why:
134
+ "This failed at the socket layer on the machine running the test: no HTTP response, so no " +
135
+ "status code. Common causes are the client exhausting ephemeral ports at high request " +
136
+ "rates, a connection pool saturating, or a genuinely unreliable link.",
137
+ fix:
138
+ "Check the failure count against the request rate. A handful across a long run is noise; " +
139
+ "a count that scales with the work means the client is the bottleneck, not the server.",
140
+ },
141
+ {
142
+ rule: "unreachable",
143
+ when: (t) => /became unreachable|consecutive calls failed at the network level/i.test(t),
144
+ blame: "harness",
145
+ headline: "The target stopped answering and the run gave up rather than hammering it.",
146
+ why:
147
+ "Enough consecutive calls failed at the network level that Populace stopped retrying. " +
148
+ "That is the circuit breaker working: continuing would produce thousands of identical " +
149
+ "failures and tell you nothing more than the first twelve did.",
150
+ fix:
151
+ "Confirm the target was up for the whole run. If it was, the link between this machine " +
152
+ "and it is the suspect, not the app.",
153
+ },
154
+ {
155
+ rule: "timeout",
156
+ when: (t) => /timed out|ETIMEDOUT|timeout/i.test(t),
157
+ blame: "unknown",
158
+ headline: "The call was abandoned before the server answered.",
159
+ why:
160
+ "Either the endpoint is genuinely slow under this load, or the request never arrived and " +
161
+ "the wait expired. The distinction matters and the timeout alone cannot settle it.",
162
+ fix:
163
+ "Compare this method's p95 with its timeout. If p95 is close to the limit the endpoint is " +
164
+ "slow; if p95 is comfortable and a few calls still time out, suspect the connection.",
165
+ },
166
+ {
167
+ rule: "server-error",
168
+ when: (t) => /\b5\d\d\b|internal server error|502|503|504/i.test(t),
169
+ blame: "app",
170
+ headline: "The server accepted the request and then failed to handle it.",
171
+ why:
172
+ "A 5xx means the request arrived and the application broke while processing it. Under a " +
173
+ "simulated population this is often a case the code never sees with one user: a race on " +
174
+ "the same row, a connection pool exhausted, or an unhandled null in concurrent access.",
175
+ fix:
176
+ "The server's own logs for this window will name it. Check whether the failures cluster " +
177
+ "in time — a burst points at contention, a steady trickle at a specific input.",
178
+ },
179
+ ];
180
+
181
+ /** Explain one error message. */
182
+ export function explain(message, context = {}) {
183
+ const text = String(message ?? "");
184
+ for (const r of RULES) {
185
+ if (r.when(text)) {
186
+ return { blame: r.blame, headline: r.headline, why: r.why, fix: r.fix, rule: r.rule };
187
+ }
188
+ }
189
+ return {
190
+ blame: "unknown",
191
+ headline: "No rule recognised this failure.",
192
+ why: `Populace has no pattern for "${text.slice(0, 120)}".`,
193
+ fix: context.method
194
+ ? `Check what \`${context.method}\` sends against what the API expects.`
195
+ : undefined,
196
+ rule: "none",
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Explain every distinct failure in a report.
202
+ *
203
+ * Ordered by how many calls each affected, because the one that happened a
204
+ * thousand times is worth reading before the one that happened once.
205
+ */
206
+ export function explainReport(report) {
207
+ const out = [];
208
+ for (const m of report?.api?.methods || []) {
209
+ for (const e of m.errors || []) {
210
+ const message = typeof e === "string" ? e : e.message;
211
+ const count = typeof e === "string" ? 1 : e.count ?? 1;
212
+ out.push({ method: m.method, message, count, ...explain(message, { method: m.method }) });
213
+ }
214
+ }
215
+ return out.sort((a, b) => b.count - a.count);
216
+ }
217
+
218
+ /**
219
+ * One-line summary of who has to act, for the top of a report.
220
+ *
221
+ * The distinction this draws is the whole point: a run can be red with nothing
222
+ * wrong in the application, and saying so plainly is more useful than any
223
+ * amount of per-error detail.
224
+ */
225
+ export function verdictLine(explanations) {
226
+ const n = (b) => explanations.filter((e) => e.blame === b).reduce((a, e) => a + e.count, 0);
227
+ const app = n("app"), env = n("environment"), harness = n("harness"), unknown = n("unknown");
228
+ if (!explanations.length) return "Nothing failed.";
229
+
230
+ const notApp = [];
231
+ if (harness) notApp.push(`${harness} never reached the server`);
232
+ if (env) notApp.push(`${env} refused by the platform`);
233
+
234
+ // An unclassified failure must never be counted as "not the app". Doing that
235
+ // turns "we could not tell" into an all-clear, which is the exact overreach
236
+ // this module exists to prevent.
237
+ if (app === 0) {
238
+ if (unknown === 0) return `No application failures — ${notApp.join(", ")}.`;
239
+ return `No confirmed application failures` +
240
+ (notApp.length ? ` (${notApp.join(", ")})` : "") +
241
+ `, but ${unknown} could not be classified.`;
242
+ }
243
+
244
+ return `${app} failure${app === 1 ? "" : "s"} in the application` +
245
+ (notApp.length ? `, plus ${notApp.join(" and ")}` : "") +
246
+ (unknown ? `, and ${unknown} unclassified` : "") + ".";
247
+ }
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // Turns a run's JSON report into a GitHub Actions job summary and step outputs.
3
+ //
4
+ // Exists so a result is visible in the pull request itself. The HTML report is
5
+ // better, but reaching it means downloading an artifact and opening it, which
6
+ // in practice means nobody looks. A table in the summary is read.
7
+ //
8
+ // Reads: POPULACE_REPORT path to the JSON report
9
+ // POPULACE_SUMMARY "false" to skip writing the summary
10
+ // GITHUB_OUTPUT / GITHUB_STEP_SUMMARY supplied by the runner
11
+ //
12
+ // Never throws on a missing or unreadable report. This runs with `if: always()`
13
+ // after a run that may have died, and a crash here would replace the real
14
+ // failure with a confusing one.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { explainReport, verdictLine } from "./explain.mjs";
19
+
20
+ const reportPath = process.env.POPULACE_REPORT || "populace-report.json";
21
+ const wantSummary = process.env.POPULACE_SUMMARY !== "false";
22
+
23
+ const out = (k, v) => {
24
+ if (!process.env.GITHUB_OUTPUT) return;
25
+ // Multi-line-safe, and a value containing "=" cannot corrupt the file.
26
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, `${k}<<__POPULACE__\n${v}\n__POPULACE__\n`);
27
+ };
28
+ const summary = (md) => {
29
+ if (!wantSummary || !process.env.GITHUB_STEP_SUMMARY) return;
30
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md + "\n");
31
+ };
32
+
33
+ let report;
34
+ try {
35
+ report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
36
+ } catch (error) {
37
+ const why = error.code === "ENOENT" ? "no report was written" : error.message;
38
+ console.log(`No usable report at ${reportPath}: ${why}`);
39
+ out("verdict", "no-report");
40
+ out("calls", "0");
41
+ out("api-failures", "0");
42
+ out("transport-failures", "0");
43
+ out("report-json", "");
44
+ out("report-html", "");
45
+ summary(`### Populace\n\nNo report at \`${reportPath}\` — ${why}. The run did not get far enough to write one.`);
46
+ process.exit(0);
47
+ }
48
+
49
+ const api = report.api || {};
50
+ const verdict = report.verdict?.status || "unknown";
51
+ const apiFailures = api.apiFailures ?? 0;
52
+ const transport = api.transportFailures ?? 0;
53
+ const htmlPath = reportPath.replace(/\.json$/, ".html");
54
+
55
+ out("verdict", verdict);
56
+ out("calls", String(api.calls ?? 0));
57
+ out("api-failures", String(apiFailures));
58
+ out("transport-failures", String(transport));
59
+ out("report-json", reportPath);
60
+ out("report-html", fs.existsSync(htmlPath) ? htmlPath : "");
61
+
62
+ const n = (v) => Number(v ?? 0).toLocaleString("en-US");
63
+ const ms = (v) => (v == null ? "—" : v >= 1000 ? `${(v / 1000).toFixed(1)} s` : `${v} ms`);
64
+
65
+ const BADGE = {
66
+ clean: "✅ **Clean**",
67
+ "problems-found": "❌ **Problems found**",
68
+ inconclusive: "⚠️ **Inconclusive**",
69
+ };
70
+
71
+ const pop = report.run?.population || {};
72
+ const lines = [];
73
+
74
+ lines.push(`## Populace — ${report.run?.app || "run"}`);
75
+ lines.push("");
76
+ lines.push(`${BADGE[verdict] || `**${verdict}**`} · ${n(api.calls)} API calls · **${n(apiFailures)} API failures**`);
77
+ lines.push("");
78
+
79
+ // The distinction that matters most, stated before the table rather than after.
80
+ if (verdict === "inconclusive") {
81
+ lines.push(
82
+ `> ${n(transport)} call${transport === 1 ? "" : "s"} never reached your API, so ` +
83
+ `${transport === 1 ? "it was" : "they were"} not tested. That is the network between the ` +
84
+ `runner and your server, not your code — but Populace will not call a run clean when it ` +
85
+ `could not make every call.`,
86
+ );
87
+ lines.push("");
88
+ }
89
+
90
+ lines.push(
91
+ `${pop.agents ?? "?"} simulated users · ${pop.minutes ?? "?"} min · ` +
92
+ `${(pop.cities || []).length} cities · coverage ${report.coverage?.label || "?"} · ` +
93
+ `${report.cleanup?.removed ?? 0}/${report.population?.signedIn ?? 0} accounts removed`,
94
+ );
95
+ lines.push("");
96
+
97
+ const methods = api.methods || [];
98
+ if (methods.length) {
99
+ lines.push("| Method | Calls | API fails | Network | p50 | p95 |");
100
+ lines.push("|---|---:|---:|---:|---:|---:|");
101
+ for (const m of methods) {
102
+ const bad = (m.apiFailures ?? 0) > 0;
103
+ lines.push(
104
+ `| ${bad ? "**" : ""}\`${m.method}\`${bad ? "**" : ""} | ${n(m.calls)} | ` +
105
+ `${m.apiFailures ?? 0} | ${m.transportFailures ?? 0} | ` +
106
+ `${ms(m.latencyMs?.p50)} | ${ms(m.latencyMs?.p95)} |`,
107
+ );
108
+ }
109
+ lines.push("");
110
+ }
111
+
112
+ // What broke, why and the fix - not just the raw message. A reviewer reading a
113
+ // pull request should not have to know what 23503 means.
114
+ const explained = explainReport(report);
115
+ if (explained.length) {
116
+ const BLAME = { app: "your app", environment: "the platform", harness: "the test client", unknown: "unclassified" };
117
+ lines.push(`**${verdictLine(explained)}**`);
118
+ lines.push("");
119
+ for (const e of explained.slice(0, 5)) {
120
+ lines.push(`<details><summary><b>${e.method}</b> × ${e.count} — ${e.headline} <em>(${BLAME[e.blame]})</em></summary>`);
121
+ lines.push("");
122
+ lines.push(e.why);
123
+ if (e.fix) { lines.push(""); lines.push(`**Fix.** ${e.fix}`); }
124
+ lines.push("");
125
+ lines.push(`<sub><code>${e.message}</code></sub>`);
126
+ lines.push("</details>");
127
+ }
128
+ lines.push("");
129
+ }
130
+
131
+ // What the population did, and the only removal claim that is ours to make.
132
+ //
133
+ // This used to say the activity itself was "all removed afterwards". Populace
134
+ // removes accounts; what happens to the rows they wrote is the application's
135
+ // decision. Gitea proved the difference - every simulated account was purged,
136
+ // and all 337 issues stayed in the repository reattributed to a ghost user. A
137
+ // tool whose whole argument is that it says only what it verified cannot make
138
+ // a cleanup claim it never checked.
139
+ const a = report.activity;
140
+ if (a) {
141
+ const removed = report.cleanup?.removed ?? 0;
142
+ const made = report.population?.signedIn ?? 0;
143
+ lines.push(
144
+ `<sub>${n(a.posts)} posts · ${n(a.likes)} likes · ${n(a.comments)} comments · ` +
145
+ `${n(a.messages)} messages · ${Number(a.distanceKm ?? 0).toFixed(1)} km — from ` +
146
+ `${n(removed)} of ${n(made)} accounts since removed. Whether your app keeps what ` +
147
+ `a deleted account wrote is your app's behaviour, not something Populace changes.</sub>`,
148
+ );
149
+ }
150
+
151
+ summary(lines.join("\n"));
152
+ console.log(`${verdict} — ${n(api.calls)} calls, ${apiFailures} API failures, ${transport} transport`);