@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.
- package/LICENSE +661 -0
- package/README.md +258 -0
- package/adapters/buzzbuzz.mjs +247 -0
- package/adapters/contract.md +164 -0
- package/adapters/template-rest.mjs +192 -0
- package/adapters/template.mjs +80 -0
- package/examples/buzzbuzz/populace-report.html +245 -0
- package/examples/buzzbuzz/populace-report.json +280 -0
- package/examples/buzzbuzz/populace.config.mjs +51 -0
- package/examples/buzzbuzz/run-test.ps1 +61 -0
- package/examples/demo/adapters/demo.mjs +90 -0
- package/examples/demo/populace-report.html +230 -0
- package/examples/demo/populace-report.json +219 -0
- package/examples/demo/populace.config.mjs +22 -0
- package/examples/rest-api/README.md +85 -0
- package/examples/rest-api/adapter.mjs +166 -0
- package/examples/rest-api/populace.config.mjs +40 -0
- package/examples/rest-api/server.mjs +247 -0
- package/examples/token-expiry/expiry-demo.mjs +119 -0
- package/package.json +56 -0
- package/populace.config.example.mjs +65 -0
- package/src/cli.mjs +591 -0
- package/src/config.mjs +186 -0
- package/src/contract.mjs +130 -0
- package/src/diagnose.mjs +40 -0
- package/src/engine/agent.mjs +264 -0
- package/src/engine/geo.mjs +59 -0
- package/src/engine/index.mjs +4 -0
- package/src/engine/personas.mjs +115 -0
- package/src/engine/world.mjs +120 -0
- package/src/html-report.mjs +218 -0
- package/src/index.mjs +38 -0
- package/src/instrument.mjs +299 -0
- package/src/net.mjs +175 -0
- package/src/report.mjs +251 -0
- package/src/selftest.mjs +1369 -0
- package/src/smoke.mjs +274 -0
- 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
|
+
}
|
package/src/version.mjs
ADDED
|
@@ -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();
|