@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.
package/README.md CHANGED
@@ -3,6 +3,11 @@
3
3
  **A simulated population that uses your app through its real API — so you can
4
4
  test what needs more than one person.**
5
5
 
6
+ ```bash
7
+ npm install -g @gigzen/populace
8
+ populace demo
9
+ ```
10
+
6
11
  **Site:** https://shakhtar-sankur.github.io/populace/  · 
7
12
  **Test report:** [the full engineering record](https://shakhtar-sankur.github.io/gigzen/test-report.html)
8
13
   ·  A [Gigzen](https://shakhtar-sankur.github.io/gigzen/) product
@@ -22,13 +27,13 @@ applying**. Then it hands you a report on what broke.
22
27
 
23
28
  ## It has done this to a real, finished app
24
29
 
25
- ![Populace report for Buzz Buzz, 9 August 2026 — no failures across 400 API calls](docs/buzzbuzz-run-2026-08-09.svg)
30
+ ![Populace report for Buzz, 21 August 2026 — no failures across 932,455 API calls from 200 simulated drivers across 20 cities in 11 countries](docs/buzz-run-2026-08-21.svg)
26
31
 
27
32
  That is the *second* run. The first one is the interesting one.
28
33
 
29
34
  ### What happened, in plain English
30
35
 
31
- On **9 August 2026** we pointed Populace at **Buzz Buzz** — a gig-worker platform
36
+ On **9 August 2026** we pointed Populace at **Buzz** — a gig-worker platform
32
37
  on Android with a live Postgres backend, 17 tables and 48 row-level-security
33
38
  policies. It was finished. It was signed. It had been through a full manual test
34
39
  of every screen by the person who wrote it, and it had passed.
@@ -97,15 +102,20 @@ within seconds to six users signing up at once.
97
102
 
98
103
  ### After the fixes
99
104
 
100
- All five were fixed and the run repeated: the report at the top of this page.
101
- **No failures across 400 API calls.** Six accounts created, six accounts deleted,
102
- nothing left behind.
105
+ All five were fixed, and the runs since have grown by three orders of magnitude.
106
+ The report at the top of this page is the largest that was **clean end to end**:
107
+ **no failures across 932,455 API calls**, 200 concurrent drivers over 20 cities in
108
+ 11 countries, 200 accounts created and 200 removed.
103
109
 
104
- **What this does not claim.** Six users for three minutes is a **correctness run,
105
- not a load test**, and it ran against that project while it was still empty. The
106
- latencies above are what six concurrent users saw and nothing more. Populace has
107
- been pointed at exactly one real backend so far, and that backend was ours — the
108
- next one should be someone else's.
110
+ A later run put **300 drivers through 1,401,435 calls with zero API failures** — but
111
+ one call never reached the server, a socket exhausted on the test machine, so
112
+ Populace marked it *inconclusive* rather than clean. That distinction is the point:
113
+ a verdict that is never withheld is worth nothing when it is given.
114
+
115
+ **What this does not claim.** These runs are against a **local backend over
116
+ loopback**, so no network is in the latencies — the same calls cost about 175 ms
117
+ against a hosted project. Three hundred drivers is where throughput stops scaling,
118
+ not where the app breaks; that is still unfound.
109
119
 
110
120
  ---
111
121
 
@@ -170,6 +180,213 @@ your API for what were really expired tokens — and the run cannot even delete
170
180
  its own accounts, stranding simulated users in your environment. See it happen:
171
181
  `node examples/token-expiry/expiry-demo.mjs`
172
182
 
183
+
184
+ ### And to an app we did not write
185
+
186
+ Pointed at a local [Gitea](https://gitea.io) instance — a git forge, not a social
187
+ app, and nobody here has touched its source:
188
+
189
+ ```
190
+ POPULACE REPORT — Gitea
191
+ ✔ No failures across 1106 API calls.
192
+
193
+ recentPostsByOthers 382 0 364ms 879ms
194
+ like 382 0 186ms 520ms
195
+ post 173 0 575ms 1.2s
196
+ comment 139 0 405ms 771ms
197
+ createUser 10 0 116ms 183ms
198
+ setProfile 10 0 59ms 77ms
199
+ deleteUser 10 0 336ms 410ms
200
+
201
+ NOT TESTED — adapter implements 8/13
202
+ ✔ Cleanup complete — 10 accounts removed.
203
+ ```
204
+
205
+ Gitea has no location tracking, no direct messages and no group joins, so five
206
+ methods have no equivalent. They are **absent from the adapter rather than
207
+ stubbed**, and the report says which and what each would have covered — because
208
+ a stub returning a fake success turns "we did not test this" into "this works".
209
+ Posting is opening an issue; the feed is listing other people's; liking is a
210
+ reaction.
211
+
212
+ Ten accounts created through Gitea's own signup, driven under Gitea's own
213
+ permissions with per-user tokens, and ten removed — checked inside Gitea
214
+ afterwards, not taken from this tool's own report.
215
+
216
+ **It found five defects on the way in, all of them in Populace.** Gitea's OpenAPI
217
+ description has 482 operations against the 18 in the fixture the adapter
218
+ generator was built on, and at that scale it mismatched four methods whose
219
+ correct endpoint was right there in the spec: it summed path-word hits instead of
220
+ taking the best, penalised path depth too weakly, matched `"registration"` inside
221
+ a CI-runner token endpoint, and — the good one — matched `"dm"` inside
222
+ `"admin"`. All fixed; the confidence markers are what stopped the wrong guesses
223
+ from looking finished.
224
+
225
+ ## It tells you what to do about it
226
+
227
+ A report that says `duplicate key value violates unique constraint` has told you
228
+ what happened and nothing about what to do. Every run now ends with the part
229
+ that matters:
230
+
231
+ ```
232
+ WHAT TO DO
233
+ 12 failures in the application.
234
+
235
+ [YOUR APP] like × 12
236
+ The database refused the write on permissions, not on data.
237
+ A row-level-security policy rejected this call for the signed-in role.
238
+ The request was well formed; the policy did not allow it. This is
239
+ invisible to whoever wrote the policy, because their own account
240
+ usually satisfies it.
241
+ Fix:
242
+ Check the policy for this table against the role the app
243
+ authenticates as, and confirm there is a policy for this specific
244
+ command — a table with only a SELECT policy refuses every INSERT.
245
+ ```
246
+
247
+ **The first judgement is the one that matters: was this your application at
248
+ all?** A socket that never opened and a 500 look equally red in a terminal and
249
+ mean completely different things. Every failure is attributed to your app, the
250
+ platform, or the test client, and the summary line says so before any detail:
251
+
252
+ ```
253
+ No application failures — 21 never reached the server.
254
+ ```
255
+
256
+ A failure Populace cannot recognise is reported as unrecognised. It is never
257
+ counted as "not your app", because that would turn *we could not tell* into an
258
+ all-clear.
259
+
260
+ ### When no rule fits
261
+
262
+ Rules cover the shapes a simulated population usually provokes. For anything
263
+ else, `populace explain` can ask a model:
264
+
265
+ ```bash
266
+ export ANTHROPIC_API_KEY=sk-ant-...
267
+ populace explain
268
+ ```
269
+
270
+ Rules run first and the model only ever sees what they could not name — so most
271
+ runs never call it at all, and the ones that do send a handful of short strings.
272
+ Explanations that came from the model are labelled as such.
273
+
274
+ **What is sent, exactly:** the method name, the error text, how many times it
275
+ happened, and that method's p50/p95. Nothing else — no target URL, no keys, no
276
+ tokens, no request bodies, no simulated users' content. Run with `--verbose` to
277
+ print it before it leaves.
278
+
279
+ ```
280
+ Sending to the model — this is everything, nothing else leaves this machine:
281
+ [ { "method": "post", "error": "quorum lost on shard 3 …",
282
+ "occurrences": 9, "p50ms": 40, "p95ms": 120 } ]
283
+ ```
284
+
285
+ The model layer can never break a run. No key, a rejected key, a rate limit, a
286
+ refusal, a malformed reply — each prints one line and falls back to the rule
287
+ output. `populace explain` is a separate command for the same reason: a run
288
+ should not wait on somebody else's network, and a report is complete without it.
289
+
290
+ ## Start from your API description
291
+
292
+ Writing the adapter is the slow part: thirteen methods against your endpoints.
293
+ If your API has an OpenAPI description, Populace can fill most of it in:
294
+
295
+ ```bash
296
+ populace init --from-openapi openapi.json
297
+ ```
298
+
299
+ It reads every operation, matches them against the thirteen contract methods by
300
+ path, verb and description, and writes the adapter with each confidence marked:
301
+
302
+ ```
303
+ Read 18 operations from openapi.json and matched 13 of 13.
304
+
305
+ ✔ createUser POST /auth/signup
306
+ ✔ like POST /posts/{postId}/likes
307
+ ? joinGroup POST /groups/{id}/members
308
+ low confidence — check this one
309
+ ✖ reportLocation left as the template default
310
+ ```
311
+
312
+ Path parameters become real template literals — `` `/posts/${postId}/likes` ``,
313
+ not the literal string — so the adapter runs rather than requesting a URL with
314
+ braces in it.
315
+
316
+ **This is a guess and the generated file says so.** Request bodies, field names
317
+ and response shapes are still the template's defaults. It removes the half hour
318
+ of looking endpoints up; it does not finish the job. Run `populace smoke` next —
319
+ it calls every method once and names the first one that is wrong.
320
+
321
+ A verb match alone is never enough to claim a method: a spec containing only
322
+ `/health` matches nothing, rather than guessing that a lone `GET` is your feed.
323
+
324
+ JSON only. A YAML parser would be Populace's first runtime dependency, and
325
+ `npx js-yaml openapi.yaml > openapi.json` covers it.
326
+
327
+ ## Run it in CI
328
+
329
+ Populace is a GitHub Action, so a population can run against your staging
330
+ environment on every pull request:
331
+
332
+ ```yaml
333
+ - uses: Shakhtar-Sankur/populace@v1
334
+ with:
335
+ agents: 30
336
+ minutes: 10
337
+ env:
338
+ MY_API_URL: ${{ secrets.STAGING_URL }}
339
+ MY_API_KEY: ${{ secrets.STAGING_KEY }}
340
+ ```
341
+
342
+ Your credentials stay in your repository's secrets and are read by your own
343
+ adapter. **Populace never sees them and nothing is sent anywhere** — the run
344
+ happens on your runner, against your server.
345
+
346
+ The job fails when the run is not clean, and the per-method table is written to
347
+ the job summary, so the result is visible in the pull request without
348
+ downloading an artifact.
349
+
350
+ | input | default | |
351
+ |---|---|---|
352
+ | `config` | `populace.config.mjs` | path to your config |
353
+ | `agents` `minutes` `tick` `engagement` `cities` | from the config | overrides |
354
+ | `report` | `populace-report.json` | the HTML lands beside it |
355
+ | `working-directory` | `.` | |
356
+ | `fail-on-problems` | `true` | set `false` to record without breaking the build |
357
+
358
+ Outputs `verdict`, `calls`, `api-failures`, `transport-failures`,
359
+ `report-json` and `report-html` for later steps:
360
+
361
+ ```yaml
362
+ - uses: actions/upload-artifact@v4
363
+ if: always()
364
+ with:
365
+ name: populace-report
366
+ path: ${{ steps.populace.outputs.report-html }}
367
+ ```
368
+
369
+ **`api-failures` is the number about your app.** `transport-failures` counts
370
+ calls that never reached your API at all — a flaky runner, not your code — and
371
+ Populace reports a run with any of those as *inconclusive* rather than clean,
372
+ because it cannot vouch for a call it could not make.
373
+
374
+ ## Staying current
375
+
376
+ ```bash
377
+ populace update
378
+ ```
379
+
380
+ Checks the npm registry and tells you if there is a newer release. A run mentions
381
+ it at most once a day, **after** the report rather than before it, and says
382
+ nothing at all if the registry cannot be reached — a version check is never worth
383
+ an error message at the end of a good run.
384
+
385
+ It is a plain anonymous GET to the public registry: no identifiers, no telemetry,
386
+ nothing about your app or your runs. Off entirely with `POPULACE_NO_UPDATE_CHECK=1`,
387
+ and off by default under `CI`, because a build server should not make an outbound
388
+ call nobody asked for.
389
+
173
390
  ## The safety guard
174
391
 
175
392
  Populace creates real accounts and writes real rows. Pointed at production it
package/action.yml ADDED
@@ -0,0 +1,145 @@
1
+ name: Populace
2
+ description: Bring a population of simulated users to life against your app's own API, and report what broke.
3
+ author: Gigzen
4
+
5
+ branding:
6
+ icon: users
7
+ color: green
8
+
9
+ # Why a composite action rather than a Docker or JavaScript one.
10
+ #
11
+ # Docker would add a build and a pull to every job, for a tool whose entire
12
+ # claim is that it has nothing to install. A JavaScript action would need
13
+ # `@actions/core` bundled in, which is a runtime dependency in a package that
14
+ # advertises zero. Composite keeps the promise: this runs the same
15
+ # `node src/cli.mjs` a person runs locally, and nothing else.
16
+
17
+ inputs:
18
+ config:
19
+ description: Path to populace.config.mjs, relative to working-directory.
20
+ required: false
21
+ default: populace.config.mjs
22
+ agents:
23
+ description: How many simulated users. Overrides the config.
24
+ required: false
25
+ minutes:
26
+ description: How long the population runs for. Overrides the config.
27
+ required: false
28
+ tick:
29
+ description: Simulated seconds per step. Lower means each person decides more often.
30
+ required: false
31
+ engagement:
32
+ description: How busy people are. 1 is normal, 5 is relentless.
33
+ required: false
34
+ cities:
35
+ description: Comma-separated city keys. Overrides the config.
36
+ required: false
37
+ report:
38
+ description: Where to write the JSON report. The HTML lands beside it.
39
+ required: false
40
+ default: populace-report.json
41
+ working-directory:
42
+ description: Directory to run in. Defaults to the repository root.
43
+ required: false
44
+ default: "."
45
+ fail-on-problems:
46
+ description: >-
47
+ Fail the job when the run is not clean. Set to false to record the result
48
+ without breaking the build - useful while you are still wiring an adapter up.
49
+ required: false
50
+ default: "true"
51
+ summary:
52
+ description: Write the per-method table to the job summary page.
53
+ required: false
54
+ default: "true"
55
+
56
+ outputs:
57
+ verdict:
58
+ description: clean, problems-found, or inconclusive.
59
+ value: ${{ steps.report.outputs.verdict }}
60
+ calls:
61
+ description: Total API calls made.
62
+ value: ${{ steps.report.outputs.calls }}
63
+ api-failures:
64
+ description: Calls your API answered with an error. This is the number about your app.
65
+ value: ${{ steps.report.outputs.api-failures }}
66
+ transport-failures:
67
+ description: Calls that never reached your API. These say nothing about your app.
68
+ value: ${{ steps.report.outputs.transport-failures }}
69
+ report-json:
70
+ description: Path to the JSON report.
71
+ value: ${{ steps.report.outputs.report-json }}
72
+ report-html:
73
+ description: Path to the self-contained HTML report.
74
+ value: ${{ steps.report.outputs.report-html }}
75
+
76
+ runs:
77
+ using: composite
78
+ steps:
79
+ - name: Run the population
80
+ id: run
81
+ shell: bash
82
+ working-directory: ${{ inputs.working-directory }}
83
+ env:
84
+ POPULACE_CONFIG: ${{ inputs.config }}
85
+ POPULACE_AGENTS: ${{ inputs.agents }}
86
+ POPULACE_MINUTES: ${{ inputs.minutes }}
87
+ POPULACE_TICK: ${{ inputs.tick }}
88
+ POPULACE_ENGAGEMENT: ${{ inputs.engagement }}
89
+ POPULACE_CITIES: ${{ inputs.cities }}
90
+ POPULACE_REPORT: ${{ inputs.report }}
91
+ run: |
92
+ # Inputs arrive through the environment rather than being interpolated
93
+ # into this script. A city list or a path is attacker-controllable in a
94
+ # fork PR, and ${{ }} pasted straight into bash is a shell injection.
95
+ args=(run --config "$POPULACE_CONFIG" --report "$POPULACE_REPORT")
96
+ [ -n "$POPULACE_AGENTS" ] && args+=(--agents "$POPULACE_AGENTS")
97
+ [ -n "$POPULACE_MINUTES" ] && args+=(--minutes "$POPULACE_MINUTES")
98
+ [ -n "$POPULACE_TICK" ] && args+=(--tick "$POPULACE_TICK")
99
+ [ -n "$POPULACE_ENGAGEMENT" ] && args+=(--engagement "$POPULACE_ENGAGEMENT")
100
+ [ -n "$POPULACE_CITIES" ] && args+=(--cities "$POPULACE_CITIES")
101
+
102
+ set +e
103
+ node "$GITHUB_ACTION_PATH/src/cli.mjs" "${args[@]}"
104
+ code=$?
105
+ set -e
106
+ echo "exit-code=$code" >> "$GITHUB_OUTPUT"
107
+ echo "Populace exited $code"
108
+
109
+ - name: Read the report
110
+ id: report
111
+ if: always()
112
+ shell: bash
113
+ working-directory: ${{ inputs.working-directory }}
114
+ env:
115
+ POPULACE_REPORT: ${{ inputs.report }}
116
+ POPULACE_SUMMARY: ${{ inputs.summary }}
117
+ run: node "$GITHUB_ACTION_PATH/src/github-summary.mjs"
118
+
119
+ - name: Decide the job
120
+ if: always()
121
+ shell: bash
122
+ env:
123
+ EXIT_CODE: ${{ steps.run.outputs.exit-code }}
124
+ VERDICT: ${{ steps.report.outputs.verdict }}
125
+ API_FAILURES: ${{ steps.report.outputs.api-failures }}
126
+ FAIL_ON_PROBLEMS: ${{ inputs.fail-on-problems }}
127
+ run: |
128
+ if [ "$EXIT_CODE" = "0" ]; then
129
+ echo "Clean run."
130
+ exit 0
131
+ fi
132
+ if [ "$FAIL_ON_PROBLEMS" != "true" ]; then
133
+ echo "::warning::Populace reported '$VERDICT' ($API_FAILURES API failures), but fail-on-problems is false."
134
+ exit 0
135
+ fi
136
+ # An inconclusive run is called out separately: it means Populace could
137
+ # not make every call, which is usually the runner's network rather than
138
+ # the app under test. Saying so here saves someone hunting a bug that is
139
+ # not in their code.
140
+ if [ "$VERDICT" = "inconclusive" ]; then
141
+ echo "::error::Populace could not complete the run: $API_FAILURES API failures, but some calls never reached your API. This is usually the network between the runner and your server, not your app. See the job summary."
142
+ else
143
+ echo "::error::Populace found problems: $API_FAILURES API failures. See the job summary."
144
+ fi
145
+ exit 1
@@ -1,4 +1,4 @@
1
- // Adapter: Buzz Buzz — a gig-driver tracking app on Supabase.
1
+ // Adapter: Buzz — a gig-driver tracking app on Supabase.
2
2
  //
3
3
  // Populace's first customer, and the reference implementation. Read this
4
4
  // alongside contract.md to see what a complete adapter looks like: it is ~140
@@ -19,6 +19,22 @@
19
19
  */
20
20
 
21
21
  export function createAdapter(target, config) {
22
+ /**
23
+ * Fail with a sentence, not a TypeError.
24
+ *
25
+ * A fresh scaffold has `url: process.env.MY_APP_TEST_URL`, which is
26
+ * undefined until you set it — so the very first `populace doctor` a new
27
+ * user runs went straight into `target.url.replace(...)` and printed
28
+ * "Cannot read properties of undefined (reading 'replace')". Technically a
29
+ * correct refusal, and a terrible first five minutes.
30
+ */
31
+ if (!target?.url) {
32
+ throw new Error(
33
+ "No target URL. Set `target.url` in populace.config.mjs, or export the " +
34
+ "environment variable it reads. It must point at your TEST environment.",
35
+ );
36
+ }
37
+
22
38
  const base = target.url.replace(/\/$/, "");
23
39
 
24
40
  /**