@gigzen/populace 0.1.0 → 1.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/README.md +231 -10
- package/action.yml +145 -0
- package/adapters/buzzbuzz.mjs +1 -1
- package/adapters/template-rest.mjs +16 -0
- package/examples/buzzbuzz/populace.config.mjs +3 -3
- package/examples/buzzbuzz-local/populace.config.mjs +63 -0
- package/examples/rest-api/README.md +2 -2
- package/examples/rest-api/adapter.mjs +5 -2
- package/examples/rest-api/server.mjs +4 -4
- package/package.json +6 -2
- package/src/ai.mjs +158 -0
- package/src/cli.mjs +192 -2
- package/src/config.mjs +18 -2
- package/src/engine/personas.mjs +654 -11
- package/src/engine/world.mjs +40 -12
- package/src/explain.mjs +247 -0
- package/src/github-summary.mjs +152 -0
- package/src/openapi.mjs +322 -0
- package/src/progress.mjs +141 -0
- package/src/report.mjs +37 -0
- package/src/selftest.mjs +323 -0
- package/src/update.mjs +141 -0
- package/examples/buzzbuzz/populace-report.html +0 -245
- package/examples/buzzbuzz/populace-report.json +0 -280
- package/examples/buzzbuzz/run-test.ps1 +0 -61
- package/examples/demo/populace-report.html +0 -230
- package/examples/demo/populace-report.json +0 -219
package/src/openapi.mjs
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Turn an OpenAPI description into a filled-in adapter.
|
|
2
|
+
//
|
|
3
|
+
// Writing the adapter is the wall. Thirteen methods against someone else's API
|
|
4
|
+
// is the half-hour where a new user either gets a run or gives up, and it is
|
|
5
|
+
// the reason a tool that works has no users outside the company that wrote it.
|
|
6
|
+
// Most serious APIs already describe themselves; this reads that description
|
|
7
|
+
// and fills in what it can.
|
|
8
|
+
//
|
|
9
|
+
// It is deliberately a GUESS, and says so. Every match carries a confidence and
|
|
10
|
+
// the evidence behind it, and anything it is unsure of is left as the template's
|
|
11
|
+
// default with a TODO. A generator that quietly guessed wrong would be worse
|
|
12
|
+
// than no generator: the run would fail and the adapter would look finished.
|
|
13
|
+
//
|
|
14
|
+
// JSON only. Populace has no runtime dependencies and a YAML parser would be
|
|
15
|
+
// the first, for a convenience that `npx js-yaml` covers in one command.
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What each contract method looks like in a REST API, as scoring signals.
|
|
21
|
+
*
|
|
22
|
+
* `verb` the HTTP method it almost certainly is
|
|
23
|
+
* `path` words that appear in the URL, best first
|
|
24
|
+
* `words` words in the operationId, summary or tags
|
|
25
|
+
* `avoid` path fragments meaning this is a different endpoint in similar clothes.
|
|
26
|
+
* Checked against the PATH ONLY. An earlier version also checked the
|
|
27
|
+
* summary, which rejected `POST /conversations` because its description
|
|
28
|
+
* read "start a direct message thread" — the correct answer, thrown out
|
|
29
|
+
* by a word in its own prose.
|
|
30
|
+
*/
|
|
31
|
+
const SIGNALS = {
|
|
32
|
+
createUser: {
|
|
33
|
+
verb: "post",
|
|
34
|
+
path: ["signup", "sign-up", "admin/users", "register/", "auth/users", "users", "accounts"],
|
|
35
|
+
words: ["signup", "register", "create user", "create account"],
|
|
36
|
+
avoid: ["login", "signin", "sign-in", "refresh", "verify", "reset"],
|
|
37
|
+
},
|
|
38
|
+
refreshSession: {
|
|
39
|
+
verb: "post",
|
|
40
|
+
path: ["refresh", "token/refresh", "auth/token", "token"],
|
|
41
|
+
words: ["refresh", "renew token", "access token"],
|
|
42
|
+
avoid: ["revoke", "signup", "register"],
|
|
43
|
+
},
|
|
44
|
+
setProfile: {
|
|
45
|
+
verb: "patch",
|
|
46
|
+
path: ["profile", "me", "users", "account"],
|
|
47
|
+
words: ["update profile", "profile", "update user", "edit"],
|
|
48
|
+
avoid: ["password", "avatar", "delete", "settings/notification"],
|
|
49
|
+
},
|
|
50
|
+
deleteUser: {
|
|
51
|
+
verb: "delete",
|
|
52
|
+
path: ["users", "account", "me", "profile"],
|
|
53
|
+
words: ["delete account", "delete user", "remove user", "close account"],
|
|
54
|
+
avoid: ["post", "comment", "message", "group"],
|
|
55
|
+
},
|
|
56
|
+
reportLocation: {
|
|
57
|
+
verb: "post",
|
|
58
|
+
path: ["location", "locations", "position", "track", "ping", "telemetry", "gps"],
|
|
59
|
+
words: ["location", "position", "track", "heartbeat", "ping"],
|
|
60
|
+
avoid: [],
|
|
61
|
+
},
|
|
62
|
+
post: {
|
|
63
|
+
verb: "post",
|
|
64
|
+
path: ["posts", "feed", "statuses", "tweets", "entries"],
|
|
65
|
+
words: ["create post", "new post", "publish", "compose"],
|
|
66
|
+
avoid: ["comment", "like", "reply", "report"],
|
|
67
|
+
},
|
|
68
|
+
recentPostsByOthers: {
|
|
69
|
+
verb: "get",
|
|
70
|
+
path: ["feed", "posts", "timeline", "statuses", "entries"],
|
|
71
|
+
words: ["feed", "timeline", "list posts", "recent"],
|
|
72
|
+
avoid: ["comment", "like", "my", "mine", "draft"],
|
|
73
|
+
},
|
|
74
|
+
like: {
|
|
75
|
+
verb: "post",
|
|
76
|
+
path: ["like", "likes", "reactions", "favourite", "favorite", "upvote"],
|
|
77
|
+
words: ["like", "react", "favourite", "favorite", "upvote"],
|
|
78
|
+
avoid: ["unlike", "dislike", "remove"],
|
|
79
|
+
},
|
|
80
|
+
comment: {
|
|
81
|
+
verb: "post",
|
|
82
|
+
path: ["comments", "replies", "comment"],
|
|
83
|
+
words: ["comment", "reply"],
|
|
84
|
+
avoid: ["delete", "list", "edit"],
|
|
85
|
+
},
|
|
86
|
+
openConversation: {
|
|
87
|
+
verb: "post",
|
|
88
|
+
// "/dm" not "dm": as a bare substring it matches "admin", which is how
|
|
89
|
+
// openConversation ended up pointing at Gitea's POST /admin/cron/{task}.
|
|
90
|
+
path: ["conversations", "threads", "chats", "/dm", "/dms", "rooms"],
|
|
91
|
+
words: ["conversation", "thread", "start chat", "direct message", "room"],
|
|
92
|
+
avoid: ["/messages", "/send", "/read", "/typing"],
|
|
93
|
+
},
|
|
94
|
+
sendMessage: {
|
|
95
|
+
verb: "post",
|
|
96
|
+
path: ["messages", "message", "send"],
|
|
97
|
+
words: ["send message", "message", "post message"],
|
|
98
|
+
avoid: ["/read", "/typing", "/receipts"],
|
|
99
|
+
},
|
|
100
|
+
listGroups: {
|
|
101
|
+
verb: "get",
|
|
102
|
+
path: ["groups", "orgs", "organizations", "communities", "channels", "teams"],
|
|
103
|
+
words: ["list groups", "groups", "organizations", "communities", "channels"],
|
|
104
|
+
avoid: ["member", "join", "leave", "create"],
|
|
105
|
+
},
|
|
106
|
+
joinGroup: {
|
|
107
|
+
verb: "post",
|
|
108
|
+
path: ["join", "members", "membership", "subscribe"],
|
|
109
|
+
words: ["join group", "join", "add member", "subscribe"],
|
|
110
|
+
avoid: ["leave", "remove", "kick", "list"],
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const CONTRACT_METHODS = Object.keys(SIGNALS);
|
|
115
|
+
|
|
116
|
+
/** Every operation in the document, flattened. */
|
|
117
|
+
function operations(doc) {
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const [path, item] of Object.entries(doc.paths || {})) {
|
|
120
|
+
if (!item || typeof item !== "object") continue;
|
|
121
|
+
for (const verb of ["get", "post", "put", "patch", "delete"]) {
|
|
122
|
+
const op = item[verb];
|
|
123
|
+
if (!op) continue;
|
|
124
|
+
out.push({
|
|
125
|
+
verb,
|
|
126
|
+
path,
|
|
127
|
+
operationId: op.operationId || "",
|
|
128
|
+
summary: op.summary || "",
|
|
129
|
+
description: (op.description || "").slice(0, 200),
|
|
130
|
+
tags: op.tags || [],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function score(op, sig) {
|
|
138
|
+
const path = op.path.toLowerCase();
|
|
139
|
+
const text = `${op.operationId} ${op.summary} ${op.tags.join(" ")}`.toLowerCase();
|
|
140
|
+
const why = [];
|
|
141
|
+
let n = 0;
|
|
142
|
+
|
|
143
|
+
for (const bad of sig.avoid) {
|
|
144
|
+
if (path.includes(bad)) return { n: -1, why: [`excluded: path contains "${bad}"`] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Path is the strongest signal, and earlier entries are better matches.
|
|
148
|
+
//
|
|
149
|
+
// Take the BEST match, never the sum. Summing rewarded long paths that happen
|
|
150
|
+
// to contain several keywords: against Gitea's real spec,
|
|
151
|
+
// /pulls/{index}/comments/{id}/replies scored both "comments" and "replies"
|
|
152
|
+
// and beat /issues/{index}/comments, which is the endpoint a person wants.
|
|
153
|
+
let bestWord = null;
|
|
154
|
+
sig.path.forEach((word, i) => {
|
|
155
|
+
if (!path.includes(word)) return;
|
|
156
|
+
const points = 10 - Math.min(i, 6);
|
|
157
|
+
if (!bestWord || points > bestWord.points) bestWord = { word, points };
|
|
158
|
+
});
|
|
159
|
+
if (bestWord) {
|
|
160
|
+
n += bestWord.points;
|
|
161
|
+
why.push(`path contains "${bestWord.word}"`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (op.verb === sig.verb) { n += 4; why.push(`${op.verb.toUpperCase()} matches`); }
|
|
165
|
+
// PUT and PATCH are used interchangeably for updates often enough to allow.
|
|
166
|
+
else if (sig.verb === "patch" && op.verb === "put") { n += 3; why.push("PUT accepted for PATCH"); }
|
|
167
|
+
else n -= 3;
|
|
168
|
+
|
|
169
|
+
for (const word of sig.words) {
|
|
170
|
+
if (text.includes(word)) { n += 3; why.push(`described as "${word}"`); }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A shallow path is much likelier to be the main resource than a deep one.
|
|
174
|
+
// At -1 per segment this was too weak to stop a five-segment sub-resource
|
|
175
|
+
// outscoring the collection it hangs off.
|
|
176
|
+
n -= 2 * Math.max(0, op.path.split("/").filter(Boolean).length - 3);
|
|
177
|
+
|
|
178
|
+
// Did anything about the URL itself suggest this method? A verb alone must
|
|
179
|
+
// never be enough: with only that, every GET in a document matched
|
|
180
|
+
// recentPostsByOthers and listGroups, so a spec containing nothing but
|
|
181
|
+
// /health produced two confident-looking matches.
|
|
182
|
+
const hadPathSignal = why.some((w) => w.startsWith("path contains"));
|
|
183
|
+
|
|
184
|
+
return { n, why, hadPathSignal };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Match each contract method to its best operation.
|
|
189
|
+
*
|
|
190
|
+
* Confidence is deliberately coarse. Anything below `low` is reported as no
|
|
191
|
+
* match at all rather than dressed up, because a wrong path that looks
|
|
192
|
+
* confident costs more to debug than an obvious blank.
|
|
193
|
+
*/
|
|
194
|
+
export function match(doc) {
|
|
195
|
+
const ops = operations(doc);
|
|
196
|
+
const results = {};
|
|
197
|
+
|
|
198
|
+
for (const [method, sig] of Object.entries(SIGNALS)) {
|
|
199
|
+
let best = null;
|
|
200
|
+
for (const op of ops) {
|
|
201
|
+
const { n, why, hadPathSignal } = score(op, sig);
|
|
202
|
+
// A path signal is mandatory, not just helpful.
|
|
203
|
+
if (!hadPathSignal || n < 7) continue;
|
|
204
|
+
if (!best || n > best.n) best = { op, n, why };
|
|
205
|
+
}
|
|
206
|
+
const confidence = !best ? "none" : best.n >= 12 ? "high" : best.n >= 9 ? "medium" : "low";
|
|
207
|
+
results[method] = best && confidence !== "none"
|
|
208
|
+
? { ...best, confidence }
|
|
209
|
+
: { op: null, n: 0, why: ["nothing in the spec looked like this"], confidence: "none" };
|
|
210
|
+
}
|
|
211
|
+
return { operationCount: ops.length, results };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Read a spec from disk, refusing YAML with a sentence rather than a stack trace. */
|
|
215
|
+
export function load(specPath) {
|
|
216
|
+
const raw = fs.readFileSync(specPath, "utf8");
|
|
217
|
+
const looksYaml = /\.ya?ml$/i.test(specPath) || /^\s*(openapi|swagger)\s*:/m.test(raw);
|
|
218
|
+
if (looksYaml && !raw.trimStart().startsWith("{")) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`${specPath} looks like YAML. Populace has no runtime dependencies, so it does not ship a\n` +
|
|
221
|
+
` YAML parser. Convert it once and point at the JSON:\n\n` +
|
|
222
|
+
` npx js-yaml ${specPath} > openapi.json\n`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
let doc;
|
|
226
|
+
try {
|
|
227
|
+
doc = JSON.parse(raw);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw new Error(`${specPath} is not valid JSON: ${error.message}`);
|
|
230
|
+
}
|
|
231
|
+
if (!doc.paths || typeof doc.paths !== "object") {
|
|
232
|
+
throw new Error(`${specPath} has no "paths" object, so it is not an OpenAPI description.`);
|
|
233
|
+
}
|
|
234
|
+
return doc;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Rewrite the REST template's call lines with the matched paths.
|
|
239
|
+
*
|
|
240
|
+
* The template is deliberately one `call(VERB, PATH, ...)` per method, so this
|
|
241
|
+
* is a targeted substitution rather than code generation. Everything the
|
|
242
|
+
* template already gets right — error handling, token plumbing, the refusal to
|
|
243
|
+
* return quietly on failure — is left exactly as it is.
|
|
244
|
+
*/
|
|
245
|
+
/**
|
|
246
|
+
* The identifier each method already has in scope, for filling path parameters.
|
|
247
|
+
*
|
|
248
|
+
* Without this, a matched path like /posts/{postId}/likes was written into the
|
|
249
|
+
* adapter as a literal string, and the adapter then requested that URL verbatim.
|
|
250
|
+
* Broken — but broken in a way that looks finished, which is the worst kind.
|
|
251
|
+
*/
|
|
252
|
+
const PATH_VAR = {
|
|
253
|
+
like: "postId",
|
|
254
|
+
comment: "postId",
|
|
255
|
+
sendMessage: "conversationId",
|
|
256
|
+
joinGroup: "groupId",
|
|
257
|
+
openConversation: "otherUserId",
|
|
258
|
+
setProfile: "user.id",
|
|
259
|
+
deleteUser: "user.id",
|
|
260
|
+
refreshSession: "user.id",
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/** `/posts/{postId}/likes` → a template literal, or the raw string if we cannot fill it. */
|
|
264
|
+
function pathExpression(rawPath, method) {
|
|
265
|
+
const params = [...rawPath.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
|
|
266
|
+
if (!params.length) return { code: JSON.stringify(rawPath), unresolved: [] };
|
|
267
|
+
|
|
268
|
+
const variable = PATH_VAR[method];
|
|
269
|
+
if (!variable) return { code: JSON.stringify(rawPath), unresolved: params };
|
|
270
|
+
|
|
271
|
+
// One parameter is the common case and safe to fill. Two or more means a
|
|
272
|
+
// nested resource whose second id this method does not have in scope, so it
|
|
273
|
+
// is left visible rather than guessed at.
|
|
274
|
+
if (params.length > 1) return { code: JSON.stringify(rawPath), unresolved: params.slice(1) };
|
|
275
|
+
|
|
276
|
+
return { code: "`" + rawPath.replace(/\{[^}]+\}/, "${" + variable + "}") + "`", unresolved: [] };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function fill(template, matches) {
|
|
280
|
+
let out = template;
|
|
281
|
+
const applied = [];
|
|
282
|
+
const needsHand = [];
|
|
283
|
+
|
|
284
|
+
for (const [method, m] of Object.entries(matches)) {
|
|
285
|
+
if (!m.op) continue;
|
|
286
|
+
const verb = m.op.verb.toUpperCase();
|
|
287
|
+
const { code, unresolved } = pathExpression(m.op.path, method);
|
|
288
|
+
if (unresolved.length) needsHand.push({ method, path: m.op.path, params: unresolved });
|
|
289
|
+
|
|
290
|
+
// Match: await call("POST", "/posts", ...) inside `async <method>(`
|
|
291
|
+
const block = new RegExp(
|
|
292
|
+
`(async ${method}\\s*\\([^)]*\\)\\s*\\{[\\s\\S]{0,400}?call\\()"[A-Z]+",\\s*"[^"]*"`,
|
|
293
|
+
);
|
|
294
|
+
if (!block.test(out)) continue;
|
|
295
|
+
out = out.replace(block, `$1"${verb}", ${code}`);
|
|
296
|
+
applied.push({ method, verb, path: m.op.path, confidence: m.confidence, unresolved });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// A header that tells the reader exactly how much to trust what follows.
|
|
300
|
+
const banner = [
|
|
301
|
+
"/**",
|
|
302
|
+
" * GENERATED from an OpenAPI description by `populace init --from-openapi`.",
|
|
303
|
+
" *",
|
|
304
|
+
" * The paths below are a best guess made by matching endpoint names against",
|
|
305
|
+
" * the thirteen contract methods. They are a starting point, not a finished",
|
|
306
|
+
" * adapter: request bodies, field names and response shapes are still the",
|
|
307
|
+
" * template's defaults and almost certainly need editing.",
|
|
308
|
+
" *",
|
|
309
|
+
" * Run `populace smoke` before anything else. It exercises each method once",
|
|
310
|
+
" * and names the first one that is wrong.",
|
|
311
|
+
...(needsHand.length
|
|
312
|
+
? [" *",
|
|
313
|
+
" * Paths still containing {braces} need filling by hand - this method has no",
|
|
314
|
+
" * variable in scope for them:",
|
|
315
|
+
...needsHand.map((h) => ` * ${h.method}: ${h.path}`)]
|
|
316
|
+
: []),
|
|
317
|
+
" */",
|
|
318
|
+
"",
|
|
319
|
+
].join("\n");
|
|
320
|
+
|
|
321
|
+
return { source: banner + out, applied, needsHand };
|
|
322
|
+
}
|
package/src/progress.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// A machine-readable view of a run, for anything watching it happen.
|
|
2
|
+
//
|
|
3
|
+
// The engine already knows everything anyone could want each tick - every
|
|
4
|
+
// person's city, platform, distance, activity and position, and the latency of
|
|
5
|
+
// every method. In a terminal it draws all of that as a table. Piped anywhere
|
|
6
|
+
// else it deliberately drops to a heartbeat every fifth of the run, because a
|
|
7
|
+
// full table written into a CI log would bury the report under thousands of
|
|
8
|
+
// screens of scrollback.
|
|
9
|
+
//
|
|
10
|
+
// That is right for CI and wrong for a window. Populace Studio spawns the CLI
|
|
11
|
+
// with its output piped, so it received the CI version: at a one-second tick
|
|
12
|
+
// over fifteen minutes, one update every three minutes. The application looked
|
|
13
|
+
// frozen while a quarter of a million calls went through it.
|
|
14
|
+
//
|
|
15
|
+
// So the data was never missing - only the transport. This is the transport.
|
|
16
|
+
//
|
|
17
|
+
// populace run --progress json
|
|
18
|
+
//
|
|
19
|
+
// Each line is `@@populace@@` followed by one JSON object. The prefix means a
|
|
20
|
+
// reader can pick these out of ordinary output without ambiguity, and nothing
|
|
21
|
+
// is emitted at all unless asked, so a human's terminal is unchanged.
|
|
22
|
+
//
|
|
23
|
+
// Percentiles are not computed every tick. summarise() sorts every recorded
|
|
24
|
+
// duration, and at a quarter of a million calls that is real work to do once a
|
|
25
|
+
// second for numbers nobody can read that fast.
|
|
26
|
+
|
|
27
|
+
export const PROGRESS_PREFIX = "@@populace@@";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Cheap per-method counters, straight off the metrics map - no sorting.
|
|
31
|
+
*
|
|
32
|
+
* The most common error message travels with them when there is one. A count
|
|
33
|
+
* of failures tells a watcher that something is wrong; only the message tells
|
|
34
|
+
* them what, and waiting for the report to find out is a long time to sit in
|
|
35
|
+
* front of a run that is already broken.
|
|
36
|
+
*/
|
|
37
|
+
function counters(metrics) {
|
|
38
|
+
return [...metrics.methods.values()].map((e) => {
|
|
39
|
+
const row = {
|
|
40
|
+
method: e.method,
|
|
41
|
+
calls: e.calls,
|
|
42
|
+
apiFailures: e.apiFailures,
|
|
43
|
+
transportFailures: e.transportFailures,
|
|
44
|
+
retries: e.retries,
|
|
45
|
+
};
|
|
46
|
+
if (e.failures && e.errors?.size) {
|
|
47
|
+
let top = null;
|
|
48
|
+
for (const [message, count] of e.errors) if (!top || count > top.count) top = { message, count };
|
|
49
|
+
if (top) row.error = { message: top.message.slice(0, 300), count: top.count };
|
|
50
|
+
}
|
|
51
|
+
return row;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** p50 and p95 per method. Costs a sort per method, so it runs rarely. */
|
|
56
|
+
function latencies(metrics) {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const e of metrics.methods.values()) {
|
|
59
|
+
if (!e.durations.length) continue;
|
|
60
|
+
const sorted = [...e.durations].sort((a, b) => a - b);
|
|
61
|
+
const at = (p) => sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];
|
|
62
|
+
out[e.method] = { p50: Math.round(at(50)), p95: Math.round(at(95)) };
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} options
|
|
69
|
+
* @param {boolean} options.enabled emit anything at all
|
|
70
|
+
* @param {number} options.everyLatency ticks between percentile refreshes
|
|
71
|
+
* @param {(line: string) => void} options.write
|
|
72
|
+
*/
|
|
73
|
+
export function createProgress({ enabled, everyLatency = 5, write = (l) => process.stdout.write(l) } = {}) {
|
|
74
|
+
if (!enabled) return { start() {}, tick() {}, done() {} };
|
|
75
|
+
|
|
76
|
+
const emit = (event) => {
|
|
77
|
+
try {
|
|
78
|
+
write(`${PROGRESS_PREFIX}${JSON.stringify(event)}\n`);
|
|
79
|
+
} catch {
|
|
80
|
+
// A watcher that has gone away is not a reason to fail a run.
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const startedAt = Date.now();
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
start(config) {
|
|
88
|
+
emit({
|
|
89
|
+
type: "start",
|
|
90
|
+
app: config.app || "populace",
|
|
91
|
+
environment: config.environment,
|
|
92
|
+
agents: config.population.agents,
|
|
93
|
+
cities: config.population.cities,
|
|
94
|
+
minutes: config.population.minutes,
|
|
95
|
+
tickSeconds: config.population.tickSeconds,
|
|
96
|
+
engagement: config.population.engagement ?? 1,
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
tick(tickNo, totalTicks, world, metrics) {
|
|
101
|
+
const t = world.totals();
|
|
102
|
+
emit({
|
|
103
|
+
type: "tick",
|
|
104
|
+
tick: tickNo,
|
|
105
|
+
totalTicks,
|
|
106
|
+
elapsedMs: Date.now() - startedAt,
|
|
107
|
+
totals: {
|
|
108
|
+
km: Number(t.km.toFixed(2)),
|
|
109
|
+
posts: t.posts || 0,
|
|
110
|
+
likes: t.likes || 0,
|
|
111
|
+
comments: t.comments || 0,
|
|
112
|
+
messages: t.messages || 0,
|
|
113
|
+
groupJoins: t.groupJoins || 0,
|
|
114
|
+
errors: t.errors || 0,
|
|
115
|
+
},
|
|
116
|
+
// Short keys: this is written once a second with a row per person, and
|
|
117
|
+
// the field names would otherwise be most of the bytes.
|
|
118
|
+
people: world.agents.map((a) => ({
|
|
119
|
+
n: a.persona.name,
|
|
120
|
+
c: a.persona.city.name,
|
|
121
|
+
y: a.persona.city.country,
|
|
122
|
+
p: a.persona.platform,
|
|
123
|
+
k: Number(a.distanceKm.toFixed(1)),
|
|
124
|
+
o: a.stats.posts || 0,
|
|
125
|
+
l: a.stats.likes || 0,
|
|
126
|
+
m: a.stats.messages || 0,
|
|
127
|
+
e: a.stats.errors || 0,
|
|
128
|
+
b: Boolean(a.onBreak),
|
|
129
|
+
la: Number(a.position.lat.toFixed(3)),
|
|
130
|
+
lo: Number(a.position.lng.toFixed(3)),
|
|
131
|
+
})),
|
|
132
|
+
methods: counters(metrics),
|
|
133
|
+
latency: tickNo % everyLatency === 0 || tickNo === totalTicks ? latencies(metrics) : undefined,
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
done(report) {
|
|
138
|
+
emit({ type: "done", verdict: report?.verdict?.status || "unknown" });
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
package/src/report.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// only shows successes is worse than no report — it manufactures confidence.
|
|
7
7
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
+
import { explainReport, verdictLine } from "./explain.mjs";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { summarise } from "./instrument.mjs";
|
|
11
12
|
import { coverageOf } from "./contract.mjs";
|
|
@@ -15,6 +16,22 @@ import { VERSION } from "./version.mjs";
|
|
|
15
16
|
const pct = (n) => `${(n * 100).toFixed(1)}%`;
|
|
16
17
|
const ms = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}s` : `${Math.round(n)}ms`);
|
|
17
18
|
|
|
19
|
+
/** Break a sentence onto lines of at most `width`, without splitting words. */
|
|
20
|
+
function wrap(text, width) {
|
|
21
|
+
const lines = [];
|
|
22
|
+
let line = "";
|
|
23
|
+
for (const word of String(text).split(/\s+/).filter(Boolean)) {
|
|
24
|
+
if (line && line.length + 1 + word.length > width) {
|
|
25
|
+
lines.push(line);
|
|
26
|
+
line = word;
|
|
27
|
+
} else {
|
|
28
|
+
line = line ? `${line} ${word}` : word;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (line) lines.push(line);
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
export function buildReport({ config, adapter, world, metrics, teardown, startedAt }) {
|
|
19
36
|
const api = summarise(metrics);
|
|
20
37
|
const coverage = coverageOf(adapter);
|
|
@@ -219,6 +236,26 @@ export function renderReport(report) {
|
|
|
219
236
|
}
|
|
220
237
|
L.push("");
|
|
221
238
|
|
|
239
|
+
// What broke, why, and the fix - the thing a report is actually for. Rules
|
|
240
|
+
// only; anything unrecognised says so rather than inventing a cause.
|
|
241
|
+
const explained = explainReport(report);
|
|
242
|
+
if (explained.length) {
|
|
243
|
+
L.push(` WHAT TO DO`);
|
|
244
|
+
L.push(` ${verdictLine(explained)}`);
|
|
245
|
+
L.push("");
|
|
246
|
+
const BLAME = { app: "YOUR APP", environment: "THE PLATFORM", harness: "THE TEST CLIENT", unknown: "UNKNOWN" };
|
|
247
|
+
for (const e of explained.slice(0, 4)) {
|
|
248
|
+
L.push(` [${BLAME[e.blame]}] ${e.method} × ${e.count}`);
|
|
249
|
+
L.push(` ${e.headline}`);
|
|
250
|
+
for (const line of wrap(e.why, 72)) L.push(` ${line}`);
|
|
251
|
+
if (e.fix) {
|
|
252
|
+
L.push(` Fix:`);
|
|
253
|
+
for (const line of wrap(e.fix, 70)) L.push(` ${line}`);
|
|
254
|
+
}
|
|
255
|
+
L.push("");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
222
259
|
if (report.coverage.notTested.length) {
|
|
223
260
|
L.push(` NOT TESTED — adapter implements ${report.coverage.label}`);
|
|
224
261
|
for (const c of report.coverage.notTested) {
|