@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/smoke.mjs ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * `populace smoke` — prove an adapter works before spending five minutes on it.
3
+ *
4
+ * Written for the person who has just implemented the contract against an API
5
+ * we have never seen. A full run takes minutes and reports on THEIR app; this
6
+ * takes seconds and reports on THEIR ADAPTER, which is a different question and
7
+ * the one they actually have at that moment.
8
+ *
9
+ * It creates ONE user, calls each implemented method once, checks what came
10
+ * back against what the contract promises, and deletes the user again. The
11
+ * checks are deliberately about shape and behaviour, not about the customer's
12
+ * business rules — an adapter that returns undefined where an id was promised
13
+ * is broken no matter whose app is behind it.
14
+ *
15
+ * Nothing here is a substitute for `run`. A smoke test that passes means the
16
+ * wiring is right, not that the app is correct.
17
+ */
18
+
19
+ import { CONTRACT, isStub } from "./contract.mjs";
20
+
21
+ /** One persona, kept identical between smoke runs so it is easy to clean up. */
22
+ export function smokePersona(prefix = "0900", n = 1) {
23
+ return {
24
+ name: n === 1 ? "Populace Smoke" : `Populace Smoke ${n}`,
25
+ phone: `${prefix}00000${n}`,
26
+ password: "SimDriver!2026",
27
+ city: { name: "Manila", lat: 14.5995, lng: 120.9842 },
28
+ platform: "grab",
29
+ rate: 10,
30
+ vehicle: "motorcycle",
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Checks that run against whatever the adapter returned.
36
+ *
37
+ * Each returns a string when something is wrong, or null when it is fine. They
38
+ * are phrased as instructions rather than complaints: someone reading this has
39
+ * a half-written adapter and wants to know what to change.
40
+ */
41
+ const EXPECTATIONS = {
42
+ createUser: (value) =>
43
+ !value || typeof value !== "object"
44
+ ? "createUser must return the user object your other methods will receive. Return whatever you need — an id, a client, a token — but return something."
45
+ : value.id === undefined
46
+ ? "createUser returned an object with no `id`. Populace uses it to tell your simulated people apart in the report."
47
+ : null,
48
+
49
+ post: (value) =>
50
+ value === undefined
51
+ ? "post returned undefined. Return the new post's id, or `like` and `comment` will have nothing to act on."
52
+ : null,
53
+
54
+ recentPostsByOthers: (value) =>
55
+ !Array.isArray(value)
56
+ ? "recentPostsByOthers must return an array (empty is fine). It is the call that catches a feed leaking other people's rows."
57
+ : null,
58
+
59
+ openConversation: (value) =>
60
+ value === undefined
61
+ ? "openConversation returned undefined. Return the conversation id so sendMessage can use it."
62
+ : null,
63
+
64
+ listGroups: (value) =>
65
+ !Array.isArray(value)
66
+ ? "listGroups must return an array (empty is fine)."
67
+ : null,
68
+
69
+ inbox: (value) =>
70
+ !Array.isArray(value)
71
+ ? "inbox must return an array (empty is fine)."
72
+ : null,
73
+ };
74
+
75
+ /**
76
+ * Exercise every implemented method once.
77
+ *
78
+ * `call` is injected so this stays pure enough to test: the CLI passes a real
79
+ * instrumented adapter, the self-test passes a fake.
80
+ */
81
+ export async function smoke({ adapter, persona = smokePersona(), onStep = () => {} }) {
82
+ const results = [];
83
+ const record = (method, status, detail = "") => {
84
+ results.push({ method, status, detail });
85
+ onStep({ method, status, detail });
86
+ };
87
+
88
+ const implemented = (name) =>
89
+ typeof adapter[name] === "function" && !isStub(adapter[name]);
90
+
91
+ // Nothing can be attempted without an identity, so this failure is fatal
92
+ // rather than one line in a list.
93
+ if (!implemented("createUser")) {
94
+ record("createUser", "fail", "not implemented — it is the one method every adapter must have");
95
+ return { results, user: null, fatal: true };
96
+ }
97
+
98
+ /**
99
+ * The engine calls `createUser({ name, phone, persona, index })` — a wrapper,
100
+ * documented in adapters/contract.md. smoke used to pass the flat persona
101
+ * instead, which is a DIFFERENT object with different keys.
102
+ *
103
+ * That made smoke lie in the most damaging direction available to it. An
104
+ * adapter written against the shape smoke passed sailed through 13/13 and
105
+ * then failed on every signup of a real run; one written against the
106
+ * documented shape failed smoke while being correct. Either way the tool
107
+ * whose entire job is "tell me my adapter is wired right" was the thing
108
+ * that was wrong.
109
+ *
110
+ * Caught by pointing smoke and a run at the same new backend and getting
111
+ * opposite answers.
112
+ */
113
+ const asEngineCallsIt = (p, index = 0) => ({
114
+ name: p.name,
115
+ phone: p.phone,
116
+ persona: p,
117
+ index,
118
+ });
119
+
120
+ let user;
121
+ try {
122
+ user = await adapter.createUser(asEngineCallsIt(persona));
123
+ const problem = EXPECTATIONS.createUser(user);
124
+ record("createUser", problem ? "fail" : "ok", problem ?? "");
125
+ if (problem) return { results, user: null, fatal: true };
126
+ } catch (error) {
127
+ record("createUser", "fail", error.message);
128
+ return { results, user: null, fatal: true };
129
+ }
130
+
131
+ // A conversation needs two people. Opening one with yourself is not a
132
+ // weaker version of the test — most backends refuse it outright, and Buzz
133
+ // Buzz's start_direct_thread raises "Invalid direct thread" on me = p_other,
134
+ // which is correct behaviour being reported as an adapter fault.
135
+ //
136
+ // So a counterpart is created when, and only when, the adapter implements
137
+ // conversations. Any adapter without them pays nothing for this.
138
+ let partner = null;
139
+ if (implemented("openConversation")) {
140
+ try {
141
+ partner = await adapter.createUser(
142
+ asEngineCallsIt(smokePersona(persona.phone.slice(0, 4), 2), 1),
143
+ );
144
+ } catch (error) {
145
+ record("openConversation", "skip",
146
+ `needs a second account and one could not be created: ${error.message}`);
147
+ }
148
+ }
149
+
150
+ // Everything the persona might need, in an order where later calls can use
151
+ // what earlier ones returned.
152
+ let postId;
153
+ let conversationId;
154
+
155
+ for (const entry of CONTRACT) {
156
+ const name = entry.method;
157
+ if (name === "createUser" || name === "deleteUser") continue; // handled separately
158
+ if (!implemented(name)) {
159
+ record(name, "skip", entry.exercises);
160
+ continue;
161
+ }
162
+
163
+ try {
164
+ let value;
165
+ switch (name) {
166
+ case "setProfile": value = await adapter.setProfile(user, persona); break;
167
+ case "refreshSession": value = await adapter.refreshSession(user); break;
168
+ case "reportLocation":
169
+ value = await adapter.reportLocation(user, {
170
+ lat: persona.city.lat, lng: persona.city.lng,
171
+ distanceKm: 1.2, earnings: 12, platform: persona.platform,
172
+ });
173
+ break;
174
+ case "post": value = await adapter.post(user, "Populace smoke test"); postId = value; break;
175
+ case "recentPostsByOthers": value = await adapter.recentPostsByOthers(user, 5); break;
176
+ case "like":
177
+ if (postId === undefined) { record(name, "skip", "no post id — `post` did not return one"); continue; }
178
+ value = await adapter.like(user, postId);
179
+ break;
180
+ case "comment":
181
+ if (postId === undefined) { record(name, "skip", "no post id — `post` did not return one"); continue; }
182
+ value = await adapter.comment(user, postId, "smoke");
183
+ break;
184
+ case "openConversation":
185
+ if (!partner) { record(name, "skip", "no second account"); continue; }
186
+ // partner.id, not partner. The contract's second argument is an id,
187
+ // and passing the whole user object put a live Supabase client into
188
+ // an RPC body — supabase-js serialises that body, so the adapter died
189
+ // with "Converting circular structure to JSON" and the report blamed
190
+ // the adapter for a fault in this file.
191
+ value = await adapter.openConversation(user, partner.id);
192
+ conversationId = value;
193
+ break;
194
+ case "sendMessage":
195
+ if (conversationId === undefined) { record(name, "skip", "no conversation id"); continue; }
196
+ value = await adapter.sendMessage(user, conversationId, "smoke");
197
+ break;
198
+ case "inbox": value = await adapter.inbox(user); break;
199
+ case "listGroups": value = await adapter.listGroups(user); break;
200
+ case "joinGroup": {
201
+ const groups = implemented("listGroups") ? await adapter.listGroups(user) : [];
202
+ const first = Array.isArray(groups) && groups[0];
203
+ if (!first) { record(name, "skip", "no group to join"); continue; }
204
+ value = await adapter.joinGroup(user, first.id ?? first);
205
+ break;
206
+ }
207
+ default: record(name, "skip", "not exercised by smoke"); continue;
208
+ }
209
+
210
+ const problem = EXPECTATIONS[name]?.(value);
211
+ record(name, problem ? "fail" : "ok", problem ?? "");
212
+ } catch (error) {
213
+ record(name, "fail", error.message);
214
+ }
215
+ }
216
+
217
+ // Always last, and always attempted — a smoke test that leaves an account
218
+ // behind in someone else's project is a bad first impression.
219
+ if (implemented("deleteUser")) {
220
+ try {
221
+ await adapter.deleteUser(user);
222
+ // The counterpart is removed on the same pass. Leaving it behind would
223
+ // be worse than never creating it.
224
+ if (partner) await adapter.deleteUser(partner);
225
+ record("deleteUser", "ok");
226
+ } catch (error) {
227
+ record("deleteUser", "fail", error.message);
228
+ }
229
+ } else {
230
+ record("deleteUser", "skip",
231
+ "not implemented — the smoke account stays behind. Run `populace clean` to remove it.");
232
+ }
233
+
234
+ return { results, user, fatal: false };
235
+ }
236
+
237
+ /** Render for a terminal. Failures first: they are why someone ran this. */
238
+ export function renderSmoke(results) {
239
+ const L = [];
240
+ const failed = results.filter((r) => r.status === "fail");
241
+ const skipped = results.filter((r) => r.status === "skip");
242
+ const ok = results.filter((r) => r.status === "ok");
243
+
244
+ L.push("");
245
+ if (failed.length) {
246
+ L.push(` ✖ ${failed.length} method(s) need attention:`);
247
+ L.push("");
248
+ for (const r of failed) {
249
+ L.push(` ${r.method}`);
250
+ L.push(` ${r.detail}`);
251
+ L.push("");
252
+ }
253
+ } else {
254
+ L.push(` ✔ ${ok.length} method(s) answered correctly.`);
255
+ L.push("");
256
+ }
257
+
258
+ if (ok.length && failed.length) L.push(` Working: ${ok.map((r) => r.method).join(", ")}`);
259
+ if (skipped.length) {
260
+ L.push("");
261
+ L.push(` Not implemented — these will be skipped in a run, not tested:`);
262
+ for (const r of skipped) L.push(` · ${r.method.padEnd(21)} ${r.detail}`);
263
+ }
264
+
265
+ L.push("");
266
+ L.push(
267
+ failed.length
268
+ ? " Fix the above, then run `populace smoke` again. A full run is only worth"
269
+ : " Wiring looks right. Next: populace run --agents 5 --minutes 3",
270
+ );
271
+ if (failed.length) L.push(" starting once these answer.");
272
+ L.push("");
273
+ return L.join("\n");
274
+ }
@@ -0,0 +1,24 @@
1
+ // One source of truth for the version.
2
+ //
3
+ // It was written in package.json and again in report.mjs. Two copies of a
4
+ // number that must agree is a bug waiting for a release: the package says one
5
+ // thing, every report a customer keeps says another, and the mismatch surfaces
6
+ // months later when someone tries to reproduce a run.
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ function read() {
15
+ try {
16
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
17
+ return { version: pkg.version, name: pkg.name };
18
+ } catch {
19
+ // Never let a packaging accident take down a run that is otherwise fine.
20
+ return { version: "unknown", name: "@gigzen/populace" };
21
+ }
22
+ }
23
+
24
+ export const { version: VERSION, name: PACKAGE_NAME } = read();