@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
@@ -0,0 +1,166 @@
1
+ /**
2
+ * An adapter for a plain REST API — the second one Populace has ever had, and
3
+ * the reason the portability claim is more than a hope.
4
+ *
5
+ * Read this next to adapters/buzzbuzz.mjs. They share no library, no id type,
6
+ * no error convention and no auth mechanism, and the engine cannot tell them
7
+ * apart. That is the whole argument.
8
+ *
9
+ * If your API looks like this one — HTTP, JSON, a bearer token — you can copy
10
+ * this file and change the URLs.
11
+ */
12
+
13
+ export function createAdapter(target) {
14
+ const base = target.url.replace(/\/$/, "");
15
+
16
+ /**
17
+ * The contract hands an adapter a name, a phone and a persona — never a
18
+ * password. Choosing one is the adapter's job, exactly as the Buzz Buzz
19
+ * reference adapter does, because only you know what your API will accept.
20
+ *
21
+ * A constant is right here: identities are deterministic so that a re-run
22
+ * signs back in rather than piling up new accounts, and that only works if
23
+ * the credential is stable too.
24
+ */
25
+ const PASSWORD = "PopulaceDemo!2026";
26
+
27
+ /**
28
+ * One place where HTTP becomes either a value or a thrown Error.
29
+ *
30
+ * Populace groups failures by shape, so the message must describe the fault
31
+ * and not the request: "409 on /likes" tells you which endpoint is unhappy,
32
+ * whereas including the post id would make every occurrence unique and turn
33
+ * one bug into fifty lines in the report.
34
+ */
35
+ async function call(method, path, { token, body } = {}) {
36
+ const res = await fetch(`${base}${path}`, {
37
+ method,
38
+ headers: {
39
+ ...(body ? { "content-type": "application/json" } : {}),
40
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
41
+ },
42
+ body: body ? JSON.stringify(body) : undefined,
43
+ signal: AbortSignal.timeout(15000),
44
+ });
45
+
46
+ if (res.status === 204) return undefined;
47
+
48
+ const text = await res.text();
49
+ let payload;
50
+ try { payload = text ? JSON.parse(text) : undefined; } catch { payload = undefined; }
51
+
52
+ if (!res.ok) {
53
+ const detail = payload?.error || text.slice(0, 120) || "no body";
54
+ // Never swallow this. An adapter that returns on failure makes the report
55
+ // blame a later method for an earlier fault — the exact defect Populace's
56
+ // own first run found in its own reference adapter.
57
+ throw new Error(`${res.status} on ${method} ${path}: ${detail}`);
58
+ }
59
+ return payload;
60
+ }
61
+
62
+ return {
63
+ name: "rest-api-demo",
64
+
65
+ async healthCheck() {
66
+ const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(10000) });
67
+ if (!res.ok) throw new Error(`health check returned ${res.status}`);
68
+ },
69
+
70
+ // ── identity ────────────────────────────────────────────────────────
71
+ // Signature per adapters/contract.md: { name, phone, persona, index }.
72
+ async createUser({ name, phone }) {
73
+ const out = await call("POST", "/auth/signup", {
74
+ body: { phone, password: PASSWORD, name },
75
+ });
76
+ // The handle is whatever later calls need. Here that is an integer id and
77
+ // a token; for Buzz Buzz it was a UUID and a client object. The engine
78
+ // only ever reads `.id`.
79
+ return { id: out.id, token: out.token };
80
+ },
81
+
82
+ // Lets `populace clean` check for leftovers without creating accounts to
83
+ // find out whether they exist.
84
+ async signIn({ phone }) {
85
+ try {
86
+ const out = await call("POST", "/auth/signin", {
87
+ body: { phone, password: PASSWORD },
88
+ });
89
+ return { id: out.id, token: out.token };
90
+ } catch (error) {
91
+ if (/^401/.test(error.message)) return null; // genuinely not there
92
+ throw error; // anything else is real
93
+ }
94
+ },
95
+
96
+ // The server issues 15-minute tokens. Without this a run longer than that
97
+ // collapses at once, the report blames the API, and cleanup cannot even
98
+ // delete its own accounts.
99
+ async refreshSession(user) {
100
+ const out = await call("POST", "/auth/refresh", { token: user.token });
101
+ user.token = out.token;
102
+ return user;
103
+ },
104
+
105
+ async setProfile(user, persona) {
106
+ await call("PATCH", "/me", {
107
+ token: user.token,
108
+ body: { name: persona.name, city: persona.city?.name },
109
+ });
110
+ },
111
+
112
+ async deleteUser(user) {
113
+ await call("DELETE", "/me", { token: user.token });
114
+ },
115
+
116
+ // ── movement ────────────────────────────────────────────────────────
117
+ async reportLocation(user, { lat, lng, distanceKm }) {
118
+ await call("POST", "/locations", {
119
+ token: user.token,
120
+ body: { lat, lng, km: distanceKm },
121
+ });
122
+ },
123
+
124
+ // ── social ──────────────────────────────────────────────────────────
125
+ async post(user, body) {
126
+ const out = await call("POST", "/posts", { token: user.token, body: { body } });
127
+ return out.id; // the engine needs this for like/comment
128
+ },
129
+
130
+ // Returns bare integer ids, not row objects. Perfectly legal under the
131
+ // contract, and the shape that used to break the engine.
132
+ async recentPostsByOthers(user) {
133
+ return await call("GET", "/posts/others", { token: user.token });
134
+ },
135
+
136
+ async like(user, postId) {
137
+ await call("POST", "/likes", { token: user.token, body: { postId } });
138
+ },
139
+
140
+ async comment(user, postId, body) {
141
+ await call("POST", "/comments", { token: user.token, body: { postId, body } });
142
+ },
143
+
144
+ // ── messaging ───────────────────────────────────────────────────────
145
+ async openConversation(user, otherUserId) {
146
+ const out = await call("POST", "/threads", {
147
+ token: user.token,
148
+ body: { otherUserId },
149
+ });
150
+ return out.id;
151
+ },
152
+
153
+ async sendMessage(user, threadId, body) {
154
+ await call("POST", "/messages", { token: user.token, body: { threadId, body } });
155
+ },
156
+
157
+ // ── groups ──────────────────────────────────────────────────────────
158
+ async listGroups(user) {
159
+ return await call("GET", "/groups", { token: user.token });
160
+ },
161
+
162
+ async joinGroup(user, groupId) {
163
+ await call("POST", "/groups/join", { token: user.token, body: { groupId } });
164
+ },
165
+ };
166
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Populace against a plain REST API.
3
+ *
4
+ * Two terminals:
5
+ *
6
+ * node examples/rest-api/server.mjs
7
+ * node src/cli.mjs run --config examples/rest-api/populace.config.mjs
8
+ *
9
+ * Nothing to sign up for and nothing to configure — the server is in this
10
+ * repository, holds everything in memory, and forgets it all when you stop it.
11
+ */
12
+
13
+ export default {
14
+ app: "REST API demo",
15
+ adapter: "./adapter.mjs",
16
+ environment: "test",
17
+
18
+ target: {
19
+ url: process.env.REST_DEMO_URL || "http://127.0.0.1:8787",
20
+ },
21
+
22
+ // The demo server binds to loopback and has no real authentication, so it
23
+ // cannot be a production host. The guard is here anyway: an empty denylist
24
+ // warns loudly, and "the config I copied had nothing in it" is the likeliest
25
+ // way someone ends up pointing this at something real.
26
+ neverRunAgainst: [
27
+ "https://api.example.com",
28
+ "https://production",
29
+ ],
30
+
31
+ population: {
32
+ agents: 6,
33
+ cities: ["manila", "mumbai"],
34
+ minutes: 2,
35
+ tickSeconds: 3,
36
+ },
37
+
38
+ identity: { phonePrefix: "0700" },
39
+ report: { path: "populace-report.json" },
40
+ };
@@ -0,0 +1,247 @@
1
+ /**
2
+ * A small REST API, deliberately unlike the Supabase one Populace grew up on.
3
+ *
4
+ * The point of this file is not the server. It is the claim it lets us test:
5
+ * that the engine is app-agnostic. Populace had only ever been pointed at one
6
+ * real backend, and that backend was ours — a testing tool that has only tested
7
+ * its author's own app has not been shown to be portable.
8
+ *
9
+ * So every architectural choice here disagrees with Buzz Buzz on purpose:
10
+ *
11
+ * Buzz Buzz / Supabase this server
12
+ * ──────────────────── ───────────────────────────────
13
+ * supabase-js client plain fetch over HTTP
14
+ * UUID string ids INTEGER ids
15
+ * {data, error} tuples HTTP status codes + {error: "..."}
16
+ * session object on the client Bearer token in an Authorization header
17
+ * list endpoints return rows list endpoints return BARE ID ARRAYS
18
+ * RLS refuses in the database the handler refuses in application code
19
+ *
20
+ * The bare id arrays matter most: that shape is what caught the engine reading
21
+ * `target.id` off a string. If this file agreed with Buzz Buzz it would prove
22
+ * nothing.
23
+ *
24
+ * Zero dependencies, in-memory, and it binds to 127.0.0.1 only — it is a test
25
+ * fixture, not something to deploy.
26
+ *
27
+ * node examples/rest-api/server.mjs # starts on :8787
28
+ */
29
+
30
+ import http from "node:http";
31
+ import crypto from "node:crypto";
32
+
33
+ const PORT = Number(process.env.PORT || 8787);
34
+
35
+ /* ── storage ───────────────────────────────────────────────────────────── */
36
+ const db = {
37
+ users: new Map(), // id -> { id, phone, password, name, city, deleted }
38
+ tokens: new Map(), // token -> { userId, expiresAt }
39
+ posts: [], // { id, authorId, body }
40
+ likes: new Set(), // `${postId}:${userId}`
41
+ comments: [], // { id, postId, authorId, body }
42
+ threads: [], // { id, a, b }
43
+ messages: [], // { id, threadId, senderId, body }
44
+ groups: [{ id: 1, name: "Night shift" }, { id: 2, name: "Airport runs" }],
45
+ members: new Set(), // `${groupId}:${userId}`
46
+ locations: [], // { userId, lat, lng, km }
47
+ };
48
+
49
+ let nextId = 1;
50
+ const newId = () => nextId++; // INTEGER ids, not UUIDs
51
+
52
+ /* Short-lived on purpose: a run longer than this must call the refresh
53
+ endpoint, which is how adapters that forget refreshSession get caught. */
54
+ const TOKEN_TTL_MS = 15 * 60 * 1000;
55
+
56
+ function issueToken(userId) {
57
+ const token = crypto.randomBytes(24).toString("hex");
58
+ db.tokens.set(token, { userId, expiresAt: Date.now() + TOKEN_TTL_MS });
59
+ return token;
60
+ }
61
+
62
+ function authenticate(req) {
63
+ const header = req.headers.authorization || "";
64
+ const token = header.startsWith("Bearer ") ? header.slice(7) : null;
65
+ if (!token) return { error: 401, message: "missing bearer token" };
66
+ const entry = db.tokens.get(token);
67
+ if (!entry) return { error: 401, message: "unknown token" };
68
+ if (entry.expiresAt < Date.now()) return { error: 401, message: "token expired" };
69
+ const user = db.users.get(entry.userId);
70
+ if (!user || user.deleted) return { error: 401, message: "account no longer exists" };
71
+ return { user, token };
72
+ }
73
+
74
+ /* ── helpers ───────────────────────────────────────────────────────────── */
75
+ const send = (res, status, body) => {
76
+ const payload = body === undefined ? "" : JSON.stringify(body);
77
+ res.writeHead(status, {
78
+ "content-type": "application/json",
79
+ "content-length": Buffer.byteLength(payload),
80
+ });
81
+ res.end(payload);
82
+ };
83
+
84
+ const readBody = (req) =>
85
+ new Promise((resolve, reject) => {
86
+ let raw = "";
87
+ req.on("data", (c) => {
88
+ raw += c;
89
+ if (raw.length > 1e6) reject(new Error("body too large"));
90
+ });
91
+ req.on("end", () => {
92
+ if (!raw) return resolve({});
93
+ try { resolve(JSON.parse(raw)); } catch { reject(new Error("invalid JSON")); }
94
+ });
95
+ req.on("error", reject);
96
+ });
97
+
98
+ /* ── routes ────────────────────────────────────────────────────────────── */
99
+ const routes = {
100
+ "GET /health": async () => [200, { ok: true }],
101
+
102
+ "POST /auth/signup": async (_req, body) => {
103
+ if (!body.phone || !body.password) return [400, { error: "phone and password required" }];
104
+ // Deterministic identities mean re-runs re-use accounts rather than piling
105
+ // up new ones, so signup on an existing phone is a sign-in.
106
+ const existing = [...db.users.values()].find((u) => u.phone === body.phone && !u.deleted);
107
+ if (existing) {
108
+ if (existing.password !== body.password) return [401, { error: "wrong password" }];
109
+ return [200, { id: existing.id, token: issueToken(existing.id) }];
110
+ }
111
+ const id = newId();
112
+ db.users.set(id, { id, phone: body.phone, password: body.password, name: body.name || "", deleted: false });
113
+ return [201, { id, token: issueToken(id) }];
114
+ },
115
+
116
+ "POST /auth/signin": async (_req, body) => {
117
+ const user = [...db.users.values()].find((u) => u.phone === body.phone && !u.deleted);
118
+ if (!user || user.password !== body.password) return [401, { error: "invalid credentials" }];
119
+ return [200, { id: user.id, token: issueToken(user.id) }];
120
+ },
121
+
122
+ "POST /auth/refresh": async (req) => {
123
+ const header = req.headers.authorization || "";
124
+ const token = header.startsWith("Bearer ") ? header.slice(7) : null;
125
+ const entry = token && db.tokens.get(token);
126
+ // An EXPIRED token can still be refreshed; an unknown one cannot.
127
+ if (!entry) return [401, { error: "unknown token" }];
128
+ db.tokens.delete(token);
129
+ return [200, { token: issueToken(entry.userId) }];
130
+ },
131
+
132
+ "PATCH /me": async (req, body, auth) => {
133
+ auth.user.name = body.name ?? auth.user.name;
134
+ auth.user.city = body.city ?? auth.user.city;
135
+ return [200, { id: auth.user.id }];
136
+ },
137
+
138
+ "DELETE /me": async (_req, _body, auth) => {
139
+ auth.user.deleted = true;
140
+ for (const [t, e] of db.tokens) if (e.userId === auth.user.id) db.tokens.delete(t);
141
+ return [204, undefined];
142
+ },
143
+
144
+ "POST /locations": async (_req, body, auth) => {
145
+ if (typeof body.lat !== "number" || typeof body.lng !== "number") {
146
+ return [400, { error: "lat and lng must be numbers" }];
147
+ }
148
+ db.locations.push({ userId: auth.user.id, lat: body.lat, lng: body.lng, km: body.km ?? 0 });
149
+ return [201, { ok: true }];
150
+ },
151
+
152
+ "POST /posts": async (_req, body, auth) => {
153
+ if (!body.body) return [400, { error: "body required" }];
154
+ const id = newId();
155
+ db.posts.push({ id, authorId: auth.user.id, body: body.body });
156
+ return [201, { id }];
157
+ },
158
+
159
+ // Bare ids, not objects. This is the shape that caught the engine.
160
+ "GET /posts/others": async (_req, _body, auth) =>
161
+ [200, db.posts.filter((p) => p.authorId !== auth.user.id).slice(-10).map((p) => p.id)],
162
+
163
+ "POST /likes": async (_req, body, auth) => {
164
+ const post = db.posts.find((p) => p.id === body.postId);
165
+ if (!post) return [404, { error: "no such post" }];
166
+ const key = `${body.postId}:${auth.user.id}`;
167
+ // Liking twice is a no-op, not an error. The Supabase version got this
168
+ // wrong via upsert, and any port of that mistake would show up here.
169
+ db.likes.add(key);
170
+ return [201, { ok: true }];
171
+ },
172
+
173
+ "POST /comments": async (_req, body, auth) => {
174
+ const post = db.posts.find((p) => p.id === body.postId);
175
+ if (!post) return [404, { error: "no such post" }];
176
+ const id = newId();
177
+ db.comments.push({ id, postId: body.postId, authorId: auth.user.id, body: body.body });
178
+ return [201, { id }];
179
+ },
180
+
181
+ "POST /threads": async (_req, body, auth) => {
182
+ const other = Number(body.otherUserId);
183
+ if (!Number.isInteger(other)) return [400, { error: "otherUserId must be an integer" }];
184
+ if (other === auth.user.id) return [400, { error: "cannot open a thread with yourself" }];
185
+ if (!db.users.has(other)) return [404, { error: "no such user" }];
186
+ const found = db.threads.find(
187
+ (t) => (t.a === auth.user.id && t.b === other) || (t.a === other && t.b === auth.user.id),
188
+ );
189
+ if (found) return [200, { id: found.id }];
190
+ const id = newId();
191
+ db.threads.push({ id, a: auth.user.id, b: other });
192
+ return [201, { id }];
193
+ },
194
+
195
+ "POST /messages": async (_req, body, auth) => {
196
+ const thread = db.threads.find((t) => t.id === Number(body.threadId));
197
+ if (!thread) return [404, { error: "no such thread" }];
198
+ // Refused in application code rather than by a database policy — a
199
+ // different enforcement point from Buzz Buzz, deliberately.
200
+ if (thread.a !== auth.user.id && thread.b !== auth.user.id) {
201
+ return [403, { error: "not a participant in this thread" }];
202
+ }
203
+ const id = newId();
204
+ db.messages.push({ id, threadId: thread.id, senderId: auth.user.id, body: body.body });
205
+ return [201, { id }];
206
+ },
207
+
208
+ "GET /groups": async () => [200, db.groups.map((g) => g.id)], // bare ids again
209
+
210
+ "POST /groups/join": async (_req, body, auth) => {
211
+ const group = db.groups.find((g) => g.id === Number(body.groupId));
212
+ if (!group) return [404, { error: "no such group" }];
213
+ db.members.add(`${group.id}:${auth.user.id}`); // idempotent, like /likes
214
+ return [201, { ok: true }];
215
+ },
216
+ };
217
+
218
+ const PUBLIC = new Set(["GET /health", "POST /auth/signup", "POST /auth/signin", "POST /auth/refresh"]);
219
+
220
+ const server = http.createServer(async (req, res) => {
221
+ const url = new URL(req.url, `http://${req.headers.host}`);
222
+ const key = `${req.method} ${url.pathname}`;
223
+ const handler = routes[key];
224
+ if (!handler) return send(res, 404, { error: `no route for ${key}` });
225
+
226
+ try {
227
+ const body = ["POST", "PATCH", "PUT"].includes(req.method) ? await readBody(req) : {};
228
+ let auth = null;
229
+ if (!PUBLIC.has(key)) {
230
+ auth = authenticate(req);
231
+ if (auth.error) return send(res, auth.error, { error: auth.message });
232
+ }
233
+ const [status, payload] = await handler(req, body, auth);
234
+ send(res, status, payload);
235
+ } catch (error) {
236
+ send(res, 400, { error: error.message });
237
+ }
238
+ });
239
+
240
+ // 127.0.0.1, never 0.0.0.0: this has no real authentication and must not be
241
+ // reachable from another machine.
242
+ server.listen(PORT, "127.0.0.1", () => {
243
+ console.log(` demo REST API on http://127.0.0.1:${PORT}`);
244
+ console.log(` integer ids · bearer tokens · bare id arrays · ${TOKEN_TTL_MS / 60000}min token TTL`);
245
+ });
246
+
247
+ export { server, db };
@@ -0,0 +1,119 @@
1
+ // A visible before/after for the session-expiry defect.
2
+ //
3
+ // node examples/token-expiry/expiry-demo.mjs
4
+ //
5
+ // Both runs use the SAME population against the SAME pretend app — an app whose
6
+ // access tokens expire after 3 seconds. The only difference is whether the
7
+ // adapter implements refreshSession().
8
+ //
9
+ // This is the failure worth demonstrating because of how it LIES: the agents
10
+ // keep moving, the table keeps updating, the run looks healthy — while every
11
+ // single call is rejected. A customer would read that as their own API falling
12
+ // over under load.
13
+
14
+ import { World } from "../../src/engine/world.mjs";
15
+ import { buildPersonas } from "../../src/engine/personas.mjs";
16
+ import { createMetrics, instrument, summarise } from "../../src/instrument.mjs";
17
+
18
+ const TOKEN_TTL_MS = 3000;
19
+
20
+ function pretendApp({ supportsRefresh }) {
21
+ const sessions = new Map();
22
+ const seen = { posts: 0, locations: 0 };
23
+
24
+ const assertLive = (user) => {
25
+ const expiresAt = sessions.get(user.id);
26
+ if (!expiresAt || Date.now() > expiresAt) {
27
+ throw new Error(`JWT expired (token issued for user ${user.id})`);
28
+ }
29
+ };
30
+
31
+ const adapter = {
32
+ name: supportsRefresh ? "with-refresh" : "no-refresh",
33
+ async createUser({ name, phone }) {
34
+ const id = `u_${phone}`;
35
+ sessions.set(id, Date.now() + TOKEN_TTL_MS);
36
+ return { id, name };
37
+ },
38
+ async setProfile(user) {
39
+ assertLive(user);
40
+ },
41
+ async reportLocation(user) {
42
+ assertLive(user);
43
+ seen.locations += 1;
44
+ },
45
+ async post(user) {
46
+ assertLive(user);
47
+ seen.posts += 1;
48
+ return `p${seen.posts}`;
49
+ },
50
+ async recentPostsByOthers(user) {
51
+ assertLive(user);
52
+ return [];
53
+ },
54
+ async deleteUser(user) {
55
+ assertLive(user);
56
+ sessions.delete(user.id);
57
+ },
58
+ seen,
59
+ };
60
+
61
+ if (supportsRefresh) {
62
+ adapter.refreshSession = async (user) => {
63
+ if (!sessions.has(user.id)) throw new Error("no session to refresh");
64
+ sessions.set(user.id, Date.now() + TOKEN_TTL_MS);
65
+ };
66
+ }
67
+ return adapter;
68
+ }
69
+
70
+ async function go({ supportsRefresh }) {
71
+ const app = pretendApp({ supportsRefresh });
72
+ const metrics = createMetrics();
73
+ const world = new World({
74
+ adapter: instrument(app, metrics),
75
+ personas: buildPersonas(4, ["manila", "mumbai"]),
76
+ // Refresh well inside the 3s token, the same way the real default sits
77
+ // well inside a 1-hour one.
78
+ options: { refreshEveryMs: 1200 },
79
+ });
80
+
81
+ await world.populate({ staggerMs: 0 });
82
+ await world.run({ minutes: 0.25, tickSeconds: 1, realtime: true });
83
+ metrics.endedAt = Date.now();
84
+
85
+ const api = summarise(metrics);
86
+ const teardown = await world.teardown();
87
+ return { api, world, app, teardown };
88
+ }
89
+
90
+ const pct = (n) => `${(n * 100).toFixed(0)}%`;
91
+ const row = (label, a, b) => console.log(` ${label.padEnd(30)}${String(a).padStart(18)}${String(b).padStart(18)}`);
92
+
93
+ console.log(`\n Pretend app: access tokens expire after ${TOKEN_TTL_MS / 1000}s. Run lasts ~15s.\n`);
94
+
95
+ process.stdout.write(" running WITHOUT refreshSession… ");
96
+ const before = await go({ supportsRefresh: false });
97
+ console.log("done");
98
+ process.stdout.write(" running WITH refreshSession… ");
99
+ const after = await go({ supportsRefresh: true });
100
+ console.log("done\n");
101
+
102
+ console.log(" " + "─".repeat(64));
103
+ row("", "no refreshSession", "with refresh");
104
+ console.log(" " + "─".repeat(64));
105
+ row("API calls attempted", before.api.calls, after.api.calls);
106
+ row("API calls FAILED", before.api.failures, after.api.failures);
107
+ row("failure rate", pct(before.api.failureRate), pct(after.api.failureRate));
108
+ row("location writes accepted", before.app.seen.locations, after.app.seen.locations);
109
+ row("posts accepted", before.app.seen.posts, after.app.seen.posts);
110
+ row("accounts cleaned up", before.teardown.removed, after.teardown.removed);
111
+ row("accounts LEFT BEHIND", before.teardown.failed.length, after.teardown.failed.length);
112
+ console.log(" " + "─".repeat(64));
113
+
114
+ const topBefore = before.api.methods.find((m) => m.failures)?.errors[0];
115
+ if (topBefore) console.log(`\n What the broken run reported: "${topBefore.message}"`);
116
+ console.log(
117
+ `\n Both runs kept ${before.world.agents.length} agents "alive" on screen the whole time.\n` +
118
+ ` Only the numbers show that one of them stopped testing anything after ${TOKEN_TTL_MS / 1000}s.\n`,
119
+ );
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@gigzen/populace",
3
+ "version": "0.1.0",
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.config.example.mjs",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "demo": "node src/cli.mjs demo",
24
+ "doctor": "node src/cli.mjs doctor",
25
+ "test": "node src/selftest.mjs",
26
+ "prepublishOnly": "node src/selftest.mjs"
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "keywords": [
32
+ "simulation",
33
+ "testing",
34
+ "load-testing",
35
+ "multi-user",
36
+ "synthetic-users",
37
+ "qa"
38
+ ],
39
+ "license": "AGPL-3.0-only",
40
+ "dependencies": {},
41
+ "devDependencies": {
42
+ "@supabase/supabase-js": "^2.45.0"
43
+ },
44
+ "author": "Sankur Kundu <sankur.kundu.tw@gmail.com>",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/Shakhtar-Sankur/populace.git"
48
+ },
49
+ "homepage": "https://github.com/Shakhtar-Sankur/populace#readme",
50
+ "bugs": {
51
+ "url": "https://github.com/Shakhtar-Sankur/populace/issues"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ }
56
+ }