@gigzen/populace 1.3.1 → 1.3.3

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/package.json CHANGED
@@ -1,60 +1,60 @@
1
- {
2
- "name": "@gigzen/populace",
3
- "version": "1.3.1",
4
- "description": "A simulated population that uses your app through its real API, so you can test what needs more than one person.",
5
- "type": "module",
6
- "bin": {
7
- "populace": "./src/cli.mjs"
8
- },
9
- "main": "./src/index.mjs",
10
- "exports": {
11
- ".": "./src/index.mjs",
12
- "./engine": "./src/engine/index.mjs"
13
- },
14
- "files": [
15
- "src",
16
- "adapters",
17
- "examples",
18
- "!**/populace-report.json",
19
- "!**/populace-report.html",
20
- "!examples/buzzbuzz/run-test.ps1",
21
- "populace.config.example.mjs",
22
- "README.md",
23
- "LICENSE",
24
- "action.yml"
25
- ],
26
- "scripts": {
27
- "demo": "node src/cli.mjs demo",
28
- "doctor": "node src/cli.mjs doctor",
29
- "test": "node src/selftest.mjs",
30
- "prepublishOnly": "node src/selftest.mjs"
31
- },
32
- "engines": {
33
- "node": ">=18"
34
- },
35
- "keywords": [
36
- "simulation",
37
- "testing",
38
- "load-testing",
39
- "multi-user",
40
- "synthetic-users",
41
- "qa"
42
- ],
43
- "license": "AGPL-3.0-only",
44
- "dependencies": {},
45
- "devDependencies": {
46
- "@supabase/supabase-js": "^2.45.0"
47
- },
48
- "author": "Sankur Kundu <sankur.kundu.tw@gmail.com>",
49
- "repository": {
50
- "type": "git",
51
- "url": "git+https://github.com/Shakhtar-Sankur/populace.git"
52
- },
53
- "homepage": "https://github.com/Shakhtar-Sankur/populace#readme",
54
- "bugs": {
55
- "url": "https://github.com/Shakhtar-Sankur/populace/issues"
56
- },
57
- "publishConfig": {
58
- "access": "public"
59
- }
60
- }
1
+ {
2
+ "name": "@gigzen/populace",
3
+ "version": "1.3.3",
4
+ "description": "A simulated population that uses your app through its real API, so you can test what needs more than one person.",
5
+ "type": "module",
6
+ "bin": {
7
+ "populace": "./src/cli.mjs"
8
+ },
9
+ "main": "./src/index.mjs",
10
+ "exports": {
11
+ ".": "./src/index.mjs",
12
+ "./engine": "./src/engine/index.mjs"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "adapters",
17
+ "examples",
18
+ "!**/populace-report.json",
19
+ "!**/populace-report.html",
20
+ "!examples/buzzbuzz/run-test.ps1",
21
+ "populace.config.example.mjs",
22
+ "README.md",
23
+ "LICENSE",
24
+ "action.yml"
25
+ ],
26
+ "scripts": {
27
+ "demo": "node src/cli.mjs demo",
28
+ "doctor": "node src/cli.mjs doctor",
29
+ "test": "node src/selftest.mjs",
30
+ "prepublishOnly": "node src/selftest.mjs"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "keywords": [
36
+ "simulation",
37
+ "testing",
38
+ "load-testing",
39
+ "multi-user",
40
+ "synthetic-users",
41
+ "qa"
42
+ ],
43
+ "license": "AGPL-3.0-only",
44
+ "dependencies": {},
45
+ "devDependencies": {
46
+ "@supabase/supabase-js": "^2.45.0"
47
+ },
48
+ "author": "Sankur Kundu <sankur.kundu.tw@gmail.com>",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/Shakhtar-Sankur/populace.git"
52
+ },
53
+ "homepage": "https://github.com/Shakhtar-Sankur/populace#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/Shakhtar-Sankur/populace/issues"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }
package/src/config.mjs CHANGED
@@ -16,6 +16,40 @@ export class ConfigError extends Error {}
16
16
 
17
17
  const strip = (u) => String(u || "").trim().replace(/\/+$/, "").toLowerCase();
18
18
 
19
+ /**
20
+ * The hostname inside a string, however it was written.
21
+ *
22
+ * Everything here is a variation a person actually types: a trailing slash, a
23
+ * path on the end, no scheme at all, a port, capitals, stray whitespace. All of
24
+ * them must come out as the same host, because all of them are the same
25
+ * database.
26
+ *
27
+ * Returns null for a string that is not a host. That matters more than it
28
+ * looks: the denylist is compared against every string in the target block,
29
+ * keys and schema names included, and those must not be mistaken for hosts.
30
+ */
31
+ function hostOf(value) {
32
+ const s = strip(value).replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/^[^/@]*@/, "");
33
+ const host = s.split(/[/?#]/)[0].split(":")[0];
34
+ return /^[a-z0-9.-]+\.[a-z]{2,}$/.test(host) ? host.replace(/^\.+|\.+$/g, "") : null;
35
+ }
36
+
37
+ /**
38
+ * Is this target one of the hosts the customer forbade?
39
+ *
40
+ * Host equality, or a subdomain of a forbidden host — so naming
41
+ * `example.com` also stops `api.example.com`, which is what someone listing
42
+ * their production domain means.
43
+ *
44
+ * It is deliberately NOT a substring test. A substring test refuses
45
+ * `key: "k"` because some forbidden URL happens to contain the letter k, and a
46
+ * guard that cries wolf over a single character is a guard people switch off.
47
+ */
48
+ function sameHost(target, denied) {
49
+ if (!target || !denied) return false;
50
+ return target === denied || target.endsWith(`.${denied}`) || denied.endsWith(`.${target}`);
51
+ }
52
+
19
53
  /** Pull every string out of a nested object, so we can scan a whole target block. */
20
54
  function stringsIn(value, found = []) {
21
55
  if (typeof value === "string") found.push(value);
@@ -134,14 +168,24 @@ export function guardProduction(config) {
134
168
  .split(",")
135
169
  .map((s) => s.trim())
136
170
  .filter(Boolean),
137
- ].map(strip);
171
+ ];
172
+
173
+ // A denied entry that is not a host is kept as a literal, so a customer who
174
+ // writes something unusual in there still gets an exact match rather than
175
+ // silently nothing.
176
+ const deniedHosts = denied.map(hostOf).filter(Boolean);
177
+ const deniedLiterals = denied.filter((d) => !hostOf(d)).map(strip).filter(Boolean);
138
178
 
139
179
  if (denied.length) {
140
- const targets = stringsIn(config.target).map(strip).filter(Boolean);
141
- const hit = targets.find((t) => denied.some((d) => d && (t === d || t.includes(d) || d.includes(t))));
180
+ const targets = stringsIn(config.target);
181
+ const hit = targets.find((raw) => {
182
+ const host = hostOf(raw);
183
+ if (host && deniedHosts.some((d) => sameHost(host, d))) return true;
184
+ return deniedLiterals.includes(strip(raw));
185
+ });
142
186
  if (hit) {
143
187
  refuse(
144
- `The target matches a host listed in neverRunAgainst:\n ${hit}`,
188
+ `The target matches a host listed in neverRunAgainst:\n ${hostOf(hit) || strip(hit)}`,
145
189
  `Simulated people must never be visible to real users.\n` +
146
190
  ` Point \`target\` at a separate test environment.`,
147
191
  );
@@ -50,6 +50,15 @@ export function renderHtmlReport(report) {
50
50
  )
51
51
  .join("");
52
52
 
53
+ // Implemented but never called. Kept apart from notTested because the fix is
54
+ // different: nothing to write, the run just has to reach it.
55
+ const notExercised = (r.coverage.notExercised || [])
56
+ .map(
57
+ (c) =>
58
+ `<li><span class="mono">${esc(c.method)}</span><span class="would">still untested: ${esc(c.wouldHaveTested)}</span></li>`,
59
+ )
60
+ .join("");
61
+
53
62
  const problems = r.verdict.problems.map((p) => `<li>${esc(p)}</li>`).join("");
54
63
 
55
64
  const engineBlock = engineBroke
@@ -170,7 +179,7 @@ ${engineBlock}
170
179
  <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
180
  <div class="stat"><span class="v">${pct(r.api.failureRate)}</span><span class="l">failure rate</span></div>
172
181
  <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>
182
+ <div class="stat"><span class="v">${esc(r.coverage.label)}</span><span class="l">methods exercised</span></div>
174
183
  </div>
175
184
 
176
185
  <section>
@@ -194,10 +203,19 @@ ${engineBlock}
194
203
  </div>
195
204
  </section>
196
205
 
206
+ ${
207
+ notExercised
208
+ ? `<section>
209
+ <h2>Not exercised — implemented, but this run never called it</h2>
210
+ <div class="panel"><ul class="cov">${notExercised}</ul></div>
211
+ </section>`
212
+ : ""
213
+ }
214
+
197
215
  ${
198
216
  notTested
199
217
  ? `<section>
200
- <h2>Not tested — adapter implements ${esc(r.coverage.label)}</h2>
218
+ <h2>Not tested — adapter implements ${esc(r.coverage.implementedLabel ?? r.coverage.label)}</h2>
201
219
  <div class="panel"><ul class="cov">${notTested}</ul></div>
202
220
  </section>`
203
221
  : ""
package/src/report.mjs CHANGED
@@ -66,15 +66,39 @@ export function buildReport({ config, adapter, world, metrics, teardown, started
66
66
  groupJoins: totals.groups,
67
67
  },
68
68
  api,
69
- coverage: {
70
- label: coverage.label,
71
- implemented: coverage.implemented.map((c) => c.method),
72
- notTested: coverage.missing.map((c) => ({ method: c.method, wouldHaveTested: c.exercises })),
73
- },
69
+ coverage: coverageFor(coverage, api),
74
70
  cleanup: teardown ?? { skipped: true, note: "Agents were left in place. Run `populace clean`." },
75
71
  };
76
72
  }
77
73
 
74
+ /**
75
+ * Coverage counts what RAN, not what exists.
76
+ *
77
+ * It used to be the adapter's implemented count, so a three-minute run printed
78
+ * "Coverage was 13/13" having never once called refreshSession, which fires
79
+ * every thirty minutes. That is the same overclaim `isStub` exists to stop — a
80
+ * method credited as tested because it is there — just one step later.
81
+ *
82
+ * `label` is exercised/total. `implementedLabel` keeps what the adapter
83
+ * provides, because "not tested because you never wrote it" and "not tested
84
+ * because this run never reached it" have different fixes.
85
+ */
86
+ function coverageFor(coverage, api) {
87
+ const total = coverage.implemented.length + coverage.missing.length;
88
+ const called = new Set((api.methods || []).filter((m) => m.calls > 0).map((m) => m.method));
89
+ const exercised = coverage.implemented.filter((c) => called.has(c.method));
90
+ return {
91
+ label: `${exercised.length}/${total}`,
92
+ implementedLabel: coverage.label,
93
+ implemented: coverage.implemented.map((c) => c.method),
94
+ exercised: exercised.map((c) => c.method),
95
+ notExercised: coverage.implemented
96
+ .filter((c) => !called.has(c.method))
97
+ .map((c) => ({ method: c.method, wouldHaveTested: c.exercises })),
98
+ notTested: coverage.missing.map((c) => ({ method: c.method, wouldHaveTested: c.exercises })),
99
+ };
100
+ }
101
+
78
102
  /**
79
103
  * Deliberately conservative. Anything unproven is called unproven, never
80
104
  * "passed" — including a run where nothing failed because nothing ran.
@@ -256,8 +280,16 @@ export function renderReport(report) {
256
280
  }
257
281
  }
258
282
 
283
+ if (report.coverage.notExercised?.length) {
284
+ L.push(` NOT EXERCISED — implemented, but this run never called it (coverage ${report.coverage.label})`);
285
+ for (const c of report.coverage.notExercised) {
286
+ L.push(` · ${c.method.padEnd(21)} still untested: ${c.wouldHaveTested}`);
287
+ }
288
+ L.push("");
289
+ }
290
+
259
291
  if (report.coverage.notTested.length) {
260
- L.push(` NOT TESTED — adapter implements ${report.coverage.label}`);
292
+ L.push(` NOT TESTED — adapter implements ${report.coverage.implementedLabel ?? report.coverage.label}`);
261
293
  for (const c of report.coverage.notTested) {
262
294
  L.push(` · ${c.method.padEnd(21)} would have tested ${c.wouldHaveTested}`);
263
295
  }
package/src/selftest.mjs CHANGED
@@ -25,7 +25,9 @@ import {
25
25
  normaliseError,
26
26
  summarise,
27
27
  } from "./instrument.mjs";
28
+ import { guardProduction } from "./config.mjs";
28
29
  import { buildReport, renderReport } from "./report.mjs";
30
+ import { renderHtmlReport } from "./html-report.mjs";
29
31
  import { canSignInOnly, CONTRACT_METHODS, coverageOf, isStub } from "./contract.mjs";
30
32
  import { diagnose } from "./diagnose.mjs";
31
33
  import { fill, match } from "./openapi.mjs";
@@ -347,6 +349,58 @@ check("skipped methods are listed with what they would have tested", () => {
347
349
  assert.ok(skipped?.wouldHaveTested.length > 10, "a gap should say what it costs you");
348
350
  });
349
351
 
352
+ // Coverage is what RAN. A hosted run on 12 September printed "Coverage was
353
+ // 13/13" having never called refreshSession, which fires every 30 minutes and
354
+ // the run lasted under five. Implemented is not exercised.
355
+ check("coverage counts methods that ran, not methods that exist", () => {
356
+ const ran = new Set(cleanReport.api.methods.filter((m) => m.calls > 0).map((m) => m.method));
357
+ const { coverage } = cleanReport;
358
+ for (const m of coverage.exercised) assert.ok(ran.has(m), `${m} counted as exercised with no calls`);
359
+ for (const c of coverage.notExercised) assert.ok(!ran.has(c.method), `${c.method} ran but is listed as not exercised`);
360
+ assert.equal(
361
+ coverage.exercised.length + coverage.notExercised.length,
362
+ coverage.implemented.length,
363
+ "every implemented method is either exercised or named as not",
364
+ );
365
+ assert.equal(coverage.label, `${coverage.exercised.length}/13`, "the headline label is the exercised count");
366
+ });
367
+
368
+ // A first draft of the next check guarded itself with "if the adapter does not
369
+ // implement refreshSession, return" — and the in-memory adapter does not, so it
370
+ // passed without asserting anything. It now builds the exact case it is about:
371
+ // refreshSession implemented for real, and a run far too short to reach it.
372
+ const idleAdapter = {
373
+ ...inMemoryAdapter(),
374
+ async refreshSession(user) {
375
+ await Promise.resolve();
376
+ return user;
377
+ },
378
+ };
379
+ const idleMetrics = createMetrics();
380
+ const idleWorld = new World({
381
+ adapter: instrument(idleAdapter, idleMetrics),
382
+ personas: buildPersonas(3, ["manila"]),
383
+ });
384
+ await idleWorld.populate({ staggerMs: 0 });
385
+ await idleWorld.run({ minutes: 1, tickSeconds: 5, realtime: false });
386
+ const idleTeardown = await idleWorld.teardown();
387
+ idleMetrics.endedAt = Date.now();
388
+ const idleReport = buildReport({
389
+ config, adapter: idleAdapter, world: idleWorld,
390
+ metrics: idleMetrics, teardown: idleTeardown, startedAt: Date.now() - 5000,
391
+ });
392
+
393
+ check("an implemented method that never ran is named, and not counted", () => {
394
+ const { coverage } = idleReport;
395
+ assert.ok(coverage.implemented.includes("refreshSession"), "setup: refreshSession must be implemented");
396
+ assert.ok(!coverage.exercised.includes("refreshSession"), "a one-minute run cannot reach a 30-minute refresh");
397
+ const idle = coverage.notExercised.find((c) => c.method === "refreshSession");
398
+ assert.ok(idle?.wouldHaveTested.length > 10, "a never-called method should say what stays untested");
399
+ assert.notEqual(coverage.label, coverage.implementedLabel, "exercised must not quietly equal implemented");
400
+ assert.ok(/NOT EXERCISED/.test(renderReport(idleReport)), "the terminal report must say so");
401
+ assert.ok(/Not exercised/.test(renderHtmlReport(idleReport)), "and so must the page people share");
402
+ });
403
+
350
404
  // --- 4b. cleanup that does not write to the customer's database -----------
351
405
  // clean reaches an account through createUser, which SIGNS UP when the identity
352
406
  // is absent. On an already-clean environment that creates every simulated
@@ -506,6 +560,77 @@ check("doctor names the cleanup mode", () => {
506
560
  );
507
561
  });
508
562
 
563
+ // --- 4c-bis. the denylist, which is the one thing that must never be wrong --
564
+ //
565
+ // Everything else in this file protects a report. This protects a database
566
+ // that real people's accounts are in. It had no coverage until a substring
567
+ // match was found refusing `key: "k"` — and the same test showed the far worse
568
+ // half: because the comparison included the scheme, writing the forbidden host
569
+ // as http:// instead of https:// walked straight past it.
570
+ //
571
+ // So the rule is hosts, not strings, and both directions are asserted: every
572
+ // way of spelling a forbidden host is refused, and nothing else is.
573
+
574
+ const FORBIDDEN = "prod.example.com";
575
+ const guarded = (target) => {
576
+ try {
577
+ guardProduction({
578
+ _file: "x", environment: "test",
579
+ neverRunAgainst: [`https://${FORBIDDEN}`], target,
580
+ });
581
+ return "ran";
582
+ } catch { return "refused"; }
583
+ };
584
+
585
+ for (const [how, target] of Object.entries({
586
+ "written plainly": `https://${FORBIDDEN}`,
587
+ "with no scheme": FORBIDDEN,
588
+ "over http instead of https": `http://${FORBIDDEN}`,
589
+ "as a websocket url": `wss://${FORBIDDEN}/realtime/v1`,
590
+ "as a postgres connection string": `postgresql://postgres:pw@${FORBIDDEN}:5432/postgres`,
591
+ "with credentials in front of it": `https://user:secret@${FORBIDDEN}`,
592
+ "with a port": `https://${FORBIDDEN}:443`,
593
+ "with a path, query and hash": `https://${FORBIDDEN}/rest/v1?a=1#b`,
594
+ "in capitals with a trailing slash": `HTTPS://${FORBIDDEN.toUpperCase()}/`,
595
+ "padded with whitespace": ` https://${FORBIDDEN} `,
596
+ "as a subdomain of it": `https://api.${FORBIDDEN}`,
597
+ "buried in a nested field": { url: "http://127.0.0.1:54321", replica: `https://${FORBIDDEN}` },
598
+ "hidden in an array": ["http://127.0.0.1:54321", `https://${FORBIDDEN}`],
599
+ })) {
600
+ check(`a forbidden host is refused ${how}`, () => {
601
+ assert.equal(guarded(target), "refused",
602
+ "this is the guard that keeps simulated people out of a real database");
603
+ });
604
+ }
605
+
606
+ check("a short field value is not mistaken for a forbidden host", () => {
607
+ // "e", "com" and "pro" are all substrings of prod.example.com. None is a host.
608
+ assert.equal(guarded({ url: "http://127.0.0.1:54321", key: "e", schema: "com", pool: "pro" }), "ran");
609
+ });
610
+
611
+ check("a different host on the same domain still runs", () => {
612
+ assert.equal(guarded({ url: "https://staging.example.com", key: "k" }), "ran");
613
+ });
614
+
615
+ check("naming a domain also covers its subdomains", () => {
616
+ // Someone who forbids example.com means all of it, not the apex alone.
617
+ assert.equal(guarded(`https://api.${FORBIDDEN}`), "refused");
618
+ });
619
+
620
+ check("an empty denylist warns rather than silently allowing", () => {
621
+ const c = guardProduction({ _file: "x", environment: "test", target: { url: "https://anything.example" } });
622
+ assert.ok((c._warnings || []).some((w) => /neverRunAgainst is empty/.test(w)));
623
+ });
624
+
625
+ check("a non-production environment is still required", () => {
626
+ for (const environment of ["production", "prod", undefined, "", "live"]) {
627
+ assert.throws(() => guardProduction({
628
+ _file: "x", environment, target: { url: "http://127.0.0.1:54321" },
629
+ neverRunAgainst: [`https://${FORBIDDEN}`],
630
+ }), `environment "${environment}" must not be accepted`);
631
+ }
632
+ });
633
+
509
634
  // --- 4d. teardown must not claim removals it did not make ------------------
510
635
  // selfDestruct() does nothing when there is no account and no deleteUser, and
511
636
  // teardown counted both as successes — so a run could print "Cleanup complete