@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/selftest.mjs
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// that the failures are CAUGHT, grouped, and reflected in the verdict.
|
|
10
10
|
|
|
11
11
|
import assert from "node:assert/strict";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
12
15
|
import { execFile } from "node:child_process";
|
|
13
16
|
import { World } from "./engine/world.mjs";
|
|
14
17
|
import { Agent } from "./engine/agent.mjs";
|
|
@@ -25,6 +28,10 @@ import {
|
|
|
25
28
|
import { buildReport, renderReport } from "./report.mjs";
|
|
26
29
|
import { canSignInOnly, CONTRACT_METHODS, coverageOf, isStub } from "./contract.mjs";
|
|
27
30
|
import { diagnose } from "./diagnose.mjs";
|
|
31
|
+
import { fill, match } from "./openapi.mjs";
|
|
32
|
+
import { explain, explainReport, verdictLine } from "./explain.mjs";
|
|
33
|
+
import { explainWithAI, isConfigured } from "./ai.mjs";
|
|
34
|
+
import { checksDisabled, compare, latestVersion } from "./update.mjs";
|
|
28
35
|
|
|
29
36
|
let failed = 0;
|
|
30
37
|
const pending = [];
|
|
@@ -1355,10 +1362,326 @@ check("the report renders without throwing", () => {
|
|
|
1355
1362
|
});
|
|
1356
1363
|
}
|
|
1357
1364
|
|
|
1365
|
+
// --- 9. the OpenAPI adapter generator -------------------------------------
|
|
1366
|
+
//
|
|
1367
|
+
// The generator is a guess by design, so what has to hold is not "it is always
|
|
1368
|
+
// right" but "it never produces something broken that looks finished".
|
|
1369
|
+
|
|
1370
|
+
const SPEC = {
|
|
1371
|
+
openapi: "3.0.0",
|
|
1372
|
+
paths: {
|
|
1373
|
+
"/auth/signup": { post: { operationId: "registerUser", summary: "Register a new account" } },
|
|
1374
|
+
"/auth/login": { post: { operationId: "login", summary: "Sign in" } },
|
|
1375
|
+
"/auth/token/refresh": { post: { operationId: "refreshToken", summary: "Refresh the access token" } },
|
|
1376
|
+
"/users/me": {
|
|
1377
|
+
patch: { operationId: "updateProfile", summary: "Update profile" },
|
|
1378
|
+
delete: { operationId: "deleteAccount", summary: "Delete account" },
|
|
1379
|
+
},
|
|
1380
|
+
"/locations": { post: { operationId: "reportPosition", summary: "Report current position" } },
|
|
1381
|
+
"/posts": {
|
|
1382
|
+
get: { operationId: "listFeed", summary: "Recent posts timeline" },
|
|
1383
|
+
post: { operationId: "createPost", summary: "Create a post" },
|
|
1384
|
+
},
|
|
1385
|
+
"/posts/{postId}/likes": { post: { operationId: "likePost", summary: "Like a post" } },
|
|
1386
|
+
"/posts/{postId}/comments": { post: { operationId: "addComment", summary: "Reply to a post" } },
|
|
1387
|
+
"/conversations": { post: { operationId: "startConversation", summary: "Start a direct message thread" } },
|
|
1388
|
+
"/conversations/{id}/messages": { post: { operationId: "sendMessage", summary: "Send a message" } },
|
|
1389
|
+
"/groups": { get: { operationId: "listGroups", summary: "List groups" } },
|
|
1390
|
+
"/groups/{id}/members": { post: { operationId: "joinGroup", summary: "Join a group" } },
|
|
1391
|
+
},
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
check("the generator matches a conventional REST spec", () => {
|
|
1395
|
+
const { results } = match(SPEC);
|
|
1396
|
+
const expected = {
|
|
1397
|
+
createUser: "POST /auth/signup",
|
|
1398
|
+
refreshSession: "POST /auth/token/refresh",
|
|
1399
|
+
setProfile: "PATCH /users/me",
|
|
1400
|
+
deleteUser: "DELETE /users/me",
|
|
1401
|
+
reportLocation: "POST /locations",
|
|
1402
|
+
post: "POST /posts",
|
|
1403
|
+
recentPostsByOthers: "GET /posts",
|
|
1404
|
+
like: "POST /posts/{postId}/likes",
|
|
1405
|
+
comment: "POST /posts/{postId}/comments",
|
|
1406
|
+
openConversation: "POST /conversations",
|
|
1407
|
+
sendMessage: "POST /conversations/{id}/messages",
|
|
1408
|
+
listGroups: "GET /groups",
|
|
1409
|
+
joinGroup: "POST /groups/{id}/members",
|
|
1410
|
+
};
|
|
1411
|
+
for (const [method, want] of Object.entries(expected)) {
|
|
1412
|
+
const r = results[method];
|
|
1413
|
+
const got = r.op ? `${r.op.verb.toUpperCase()} ${r.op.path}` : "no match";
|
|
1414
|
+
if (got !== want) throw new Error(`${method}: matched ${got}, expected ${want}`);
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
check("a summary's own words never exclude the right endpoint", () => {
|
|
1419
|
+
// POST /conversations is described as "start a direct message thread". An
|
|
1420
|
+
// earlier version checked the `avoid` list against the summary as well as the
|
|
1421
|
+
// path, so the word "message" rejected the one correct answer.
|
|
1422
|
+
const { results } = match(SPEC);
|
|
1423
|
+
if (results.openConversation.op?.path !== "/conversations") {
|
|
1424
|
+
throw new Error("openConversation was excluded by a word in its own description");
|
|
1425
|
+
}
|
|
1426
|
+
// And sendMessage lives UNDER conversations, so "conversation" cannot exclude it.
|
|
1427
|
+
if (results.sendMessage.op?.path !== "/conversations/{id}/messages") {
|
|
1428
|
+
throw new Error("sendMessage was excluded by its parent resource's name");
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
check("a generated adapter never leaves a literal {param} in a call", () => {
|
|
1433
|
+
// A path written in as a literal string would make the adapter request
|
|
1434
|
+
// "/posts/{postId}/likes" verbatim: broken, and looking finished.
|
|
1435
|
+
const template = fs.readFileSync(
|
|
1436
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "adapters", "template-rest.mjs"),
|
|
1437
|
+
"utf8",
|
|
1438
|
+
);
|
|
1439
|
+
const { source } = fill(template, match(SPEC).results);
|
|
1440
|
+
const leftovers = source.match(/call\("[A-Z]+", "[^"]*\{[a-zA-Z_]+\}/g);
|
|
1441
|
+
if (leftovers) throw new Error(`unfilled path parameters: ${leftovers.join(", ")}`);
|
|
1442
|
+
if (!source.includes("`/posts/${postId}/likes`")) {
|
|
1443
|
+
throw new Error("the path parameter was not turned into a template literal");
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
check("a spec with nothing recognisable produces no false matches", () => {
|
|
1448
|
+
const { results } = match({ paths: { "/health": { get: { operationId: "health" } } } });
|
|
1449
|
+
const matched = Object.values(results).filter((r) => r.op).length;
|
|
1450
|
+
if (matched > 0) throw new Error(`${matched} method(s) matched a spec containing only /health`);
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
// --- 10. failure explanation ----------------------------------------------
|
|
1454
|
+
//
|
|
1455
|
+
// The judgement that matters is the first one: was this the application at all?
|
|
1456
|
+
// Getting that wrong in either direction is worse than saying nothing.
|
|
1457
|
+
|
|
1458
|
+
check("a transport failure is never blamed on the application", () => {
|
|
1459
|
+
for (const msg of ["TypeError: fetch failed", "socket hang up", "ECONNRESET", "UND_ERR_CONNECT_TIMEOUT"]) {
|
|
1460
|
+
const e = explain(msg);
|
|
1461
|
+
if (e.blame !== "harness") throw new Error(`"${msg}" was blamed on ${e.blame}, not the harness`);
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
check("a provider quota is not the app's fault", () => {
|
|
1466
|
+
const e = explain("Invalid login credentials (signup first failed: Request rate limit reached)");
|
|
1467
|
+
if (e.blame !== "environment") throw new Error(`rate limit blamed on ${e.blame}`);
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
check("permission and constraint failures are the app's", () => {
|
|
1471
|
+
for (const [msg, rule] of [
|
|
1472
|
+
["permission denied for table profiles", "rls-denied"],
|
|
1473
|
+
["duplicate key value violates unique constraint \"post_likes_pkey\"", "duplicate-key"],
|
|
1474
|
+
["insert violates foreign key constraint", "foreign-key"],
|
|
1475
|
+
]) {
|
|
1476
|
+
const e = explain(msg);
|
|
1477
|
+
if (e.blame !== "app") throw new Error(`"${msg}" blamed on ${e.blame}`);
|
|
1478
|
+
if (e.rule !== rule) throw new Error(`"${msg}" matched ${e.rule}, expected ${rule}`);
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
check("the upsert-under-RLS bug gets its own explanation", () => {
|
|
1483
|
+
// The specific mistake behind five of the first defects Populace ever found.
|
|
1484
|
+
// Plain "permission denied" is true but useless; this names the cause.
|
|
1485
|
+
const e = explain("permission denied for table profiles (upsert / ON CONFLICT DO UPDATE)");
|
|
1486
|
+
if (e.rule !== "rls-upsert") throw new Error(`matched ${e.rule}, expected the upsert-specific rule`);
|
|
1487
|
+
if (!/cannot upsert a column you cannot select/i.test(e.fix)) {
|
|
1488
|
+
throw new Error("the fix does not state the actual rule");
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
check("an unrecognised failure says so rather than inventing a cause", () => {
|
|
1493
|
+
const e = explain("Xyzzy plugh 42 frobnicated");
|
|
1494
|
+
if (e.rule !== "none") throw new Error(`invented rule ${e.rule}`);
|
|
1495
|
+
if (e.blame !== "unknown") throw new Error(`claimed blame "${e.blame}" for an unknown error`);
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
check("unclassified failures are never counted as 'not the app'", () => {
|
|
1499
|
+
// The dangerous rounding: reporting "no application failures" when some
|
|
1500
|
+
// failures were simply not understood turns "we cannot tell" into an
|
|
1501
|
+
// all-clear.
|
|
1502
|
+
const line = verdictLine([{ blame: "harness", count: 5 }, { blame: "unknown", count: 3 }]);
|
|
1503
|
+
if (/^No application failures/.test(line)) {
|
|
1504
|
+
throw new Error(`claimed a clean app while 3 failures were unclassified: "${line}"`);
|
|
1505
|
+
}
|
|
1506
|
+
if (!/could not be classified/.test(line)) throw new Error(`unclassified failures not mentioned: "${line}"`);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
check("explanations are ordered by how much they happened", () => {
|
|
1510
|
+
const ex = explainReport({
|
|
1511
|
+
api: { methods: [
|
|
1512
|
+
{ method: "a", errors: [{ message: "fetch failed", count: 2 }] },
|
|
1513
|
+
{ method: "b", errors: [{ message: "fetch failed", count: 90 }] },
|
|
1514
|
+
] },
|
|
1515
|
+
});
|
|
1516
|
+
if (ex[0].method !== "b") throw new Error("the 90-count failure was not listed first");
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
// --- 11. the model layer is an enhancement, never a dependency ------------
|
|
1520
|
+
//
|
|
1521
|
+
// A report is complete and useful without it. Everything here is about the
|
|
1522
|
+
// model layer being unable to make things worse.
|
|
1523
|
+
|
|
1524
|
+
check("without a key, the model layer returns nothing and says nothing", async () => {
|
|
1525
|
+
const before = process.env.ANTHROPIC_API_KEY;
|
|
1526
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
1527
|
+
try {
|
|
1528
|
+
if (isConfigured({})) throw new Error("reported configured with no key anywhere");
|
|
1529
|
+
const out = await explainWithAI([{ method: "post", message: "something odd", count: 1 }], { config: {} });
|
|
1530
|
+
if (out.length) throw new Error("returned explanations without a key");
|
|
1531
|
+
} finally {
|
|
1532
|
+
if (before !== undefined) process.env.ANTHROPIC_API_KEY = before;
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
check("nothing to explain means no request is made", async () => {
|
|
1537
|
+
// Guards against a run with zero failures still costing an API call.
|
|
1538
|
+
const out = await explainWithAI([], { config: { ai: { apiKey: "sk-ant-would-be-wrong-to-use" } } });
|
|
1539
|
+
if (out.length) throw new Error("returned explanations for an empty list");
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
check("a key in config is found as well as one in the environment", () => {
|
|
1543
|
+
const before = process.env.ANTHROPIC_API_KEY;
|
|
1544
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
1545
|
+
try {
|
|
1546
|
+
if (!isConfigured({ ai: { apiKey: "sk-ant-test" } })) {
|
|
1547
|
+
throw new Error("a key in populace.config.mjs was not found");
|
|
1548
|
+
}
|
|
1549
|
+
} finally {
|
|
1550
|
+
if (before !== undefined) process.env.ANTHROPIC_API_KEY = before;
|
|
1551
|
+
}
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
check("rule explanations are never labelled as coming from the model", () => {
|
|
1555
|
+
// The provenance has to survive, or a reader cannot tell which explanations
|
|
1556
|
+
// were deterministic and which were generated.
|
|
1557
|
+
const e = explain("permission denied for table profiles");
|
|
1558
|
+
if (e.source === "model") throw new Error("a rule explanation claimed to be from the model");
|
|
1559
|
+
if (e.rule === "model") throw new Error("a rule explanation used the model's rule name");
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
// --- 12. the update check ------------------------------------------------
|
|
1563
|
+
|
|
1564
|
+
check("version comparison orders releases correctly", () => {
|
|
1565
|
+
const cases = [
|
|
1566
|
+
["1.0.0", "1.0.0", 0], ["1.0.1", "1.0.0", 1], ["1.0.0", "1.0.1", -1],
|
|
1567
|
+
["1.10.0", "1.9.0", 1], ["2.0.0", "1.99.99", 1], ["1.0.0", "0.1.0", 1],
|
|
1568
|
+
["1.0.0-beta.1", "1.0.0", 0], // pre-release tags are ignored, not parsed
|
|
1569
|
+
];
|
|
1570
|
+
for (const [a, b, want] of cases) {
|
|
1571
|
+
const got = compare(a, b);
|
|
1572
|
+
if (got !== want) throw new Error(`compare("${a}","${b}") = ${got}, expected ${want}`);
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
check("10.0.0 is newer than 9.0.0, not older", () => {
|
|
1577
|
+
// String comparison would say "10" < "9". Numeric parsing is the whole point.
|
|
1578
|
+
if (compare("10.0.0", "9.0.0") !== 1) throw new Error("compared version parts as strings");
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
check("CI switches the update check off without being asked", async () => {
|
|
1582
|
+
// A build server should not make an outbound call nobody requested, and its
|
|
1583
|
+
// logs should not carry an upgrade nag.
|
|
1584
|
+
const beforeCI = process.env.CI;
|
|
1585
|
+
process.env.CI = "true";
|
|
1586
|
+
try {
|
|
1587
|
+
if (!checksDisabled()) throw new Error("update checks stayed on under CI");
|
|
1588
|
+
const v = await latestVersion();
|
|
1589
|
+
if (v !== null) throw new Error("a request was made under CI");
|
|
1590
|
+
} finally {
|
|
1591
|
+
if (beforeCI === undefined) delete process.env.CI; else process.env.CI = beforeCI;
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
check("POPULACE_NO_UPDATE_CHECK switches it off", () => {
|
|
1596
|
+
const before = process.env.POPULACE_NO_UPDATE_CHECK;
|
|
1597
|
+
const beforeCI = process.env.CI;
|
|
1598
|
+
delete process.env.CI;
|
|
1599
|
+
process.env.POPULACE_NO_UPDATE_CHECK = "1";
|
|
1600
|
+
try {
|
|
1601
|
+
if (!checksDisabled()) throw new Error("the opt-out was ignored");
|
|
1602
|
+
} finally {
|
|
1603
|
+
if (before === undefined) delete process.env.POPULACE_NO_UPDATE_CHECK;
|
|
1604
|
+
else process.env.POPULACE_NO_UPDATE_CHECK = before;
|
|
1605
|
+
if (beforeCI !== undefined) process.env.CI = beforeCI;
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1358
1609
|
// Async checks must settle before the total is printed. Exiting synchronously
|
|
1359
1610
|
// would report "all passed" while an async assertion was still in flight — a
|
|
1360
1611
|
// test suite lying about its own result, in a product whose entire argument is
|
|
1361
1612
|
// that a report must never claim more than it has earned.
|
|
1613
|
+
// --- sign-up pacing and throttling -----------------------------------------
|
|
1614
|
+
// A rate limit is the target pacing us; a real error is a finding. Waiting out
|
|
1615
|
+
// the first is honest and waiting out the second would hide a defect, so the two
|
|
1616
|
+
// must never be treated alike. Added 2026-08-24 after a 250-person run lost 215
|
|
1617
|
+
// people to a limit that said nothing about the app under test.
|
|
1618
|
+
{
|
|
1619
|
+
const persona = () => ({
|
|
1620
|
+
name: "Test Person",
|
|
1621
|
+
city: { name: "Manila", country: "PH", lat: 14.6, lng: 121 },
|
|
1622
|
+
platform: "grab", rhythm: {}, engagement: 1,
|
|
1623
|
+
});
|
|
1624
|
+
const stub = (fail) => ({
|
|
1625
|
+
createUser: fail,
|
|
1626
|
+
async setProfile() {}, async refreshSession() {}, async reportLocation() {},
|
|
1627
|
+
async post() {}, async recentPostsByOthers() { return []; }, async like() {},
|
|
1628
|
+
async comment() {}, async openConversation() { return { id: "c" }; },
|
|
1629
|
+
async sendMessage() {}, async listGroups() { return []; }, async joinGroup() {},
|
|
1630
|
+
async deleteUser() {},
|
|
1631
|
+
});
|
|
1632
|
+
const throttling = (times) => {
|
|
1633
|
+
let n = 0;
|
|
1634
|
+
return stub(async () => {
|
|
1635
|
+
if (n++ < times) throw new Error("Request rate limit reached");
|
|
1636
|
+
return { id: "u" + n, token: "t" };
|
|
1637
|
+
});
|
|
1638
|
+
};
|
|
1639
|
+
const world = (adapter, options, on = {}) =>
|
|
1640
|
+
new World({ adapter, personas: [persona()], options, on });
|
|
1641
|
+
const pacing = { signupRateLimitBackoffMs: 10, signupRateLimitRetries: 2, signupStaggerMs: 0 };
|
|
1642
|
+
|
|
1643
|
+
const recovered = world(throttling(2), pacing);
|
|
1644
|
+
const attempts = [];
|
|
1645
|
+
recovered.on.joinThrottled = (_p, a) => attempts.push(a);
|
|
1646
|
+
await recovered.populate();
|
|
1647
|
+
check("a sign-up refused for being too fast is retried, not counted as a failure", () => {
|
|
1648
|
+
assert.equal(recovered.agents.length, 1, "the person should end up signed in");
|
|
1649
|
+
assert.equal(recovered.signupFailures.length, 0, "a rate limit is not a finding");
|
|
1650
|
+
assert.deepEqual(attempts, [1, 2], "each wait should be announced, not silent");
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
const exhausted = world(throttling(99), pacing);
|
|
1654
|
+
await exhausted.populate();
|
|
1655
|
+
check("a rate limit that never lets up is recorded with its cause intact", () => {
|
|
1656
|
+
assert.equal(exhausted.agents.length, 0);
|
|
1657
|
+
assert.equal(exhausted.signupFailures[0].throttled, true,
|
|
1658
|
+
"the report must be able to tell throttling from a broken API");
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
let realErrorCalls = 0;
|
|
1662
|
+
const genuine = world(stub(async () => {
|
|
1663
|
+
realErrorCalls += 1;
|
|
1664
|
+
throw new Error("duplicate key violates unique constraint");
|
|
1665
|
+
}), pacing);
|
|
1666
|
+
await genuine.populate();
|
|
1667
|
+
check("a genuine sign-up error is never retried or waited out", () => {
|
|
1668
|
+
assert.equal(realErrorCalls, 1, "retrying a finding would hide it");
|
|
1669
|
+
assert.equal(genuine.signupFailures[0].throttled, false);
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
const paced = new World({
|
|
1673
|
+
adapter: throttling(0), personas: [persona(), persona(), persona()],
|
|
1674
|
+
options: { signupStaggerMs: 120 }, on: {},
|
|
1675
|
+
});
|
|
1676
|
+
const startedAt = Date.now();
|
|
1677
|
+
await paced.populate();
|
|
1678
|
+
const elapsed = Date.now() - startedAt;
|
|
1679
|
+
check("signupStaggerMs actually paces sign-ups", () => {
|
|
1680
|
+
assert.ok(elapsed >= 240,
|
|
1681
|
+
`three people at 120ms apart should take at least 240ms, took ${elapsed}ms`);
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1362
1685
|
await Promise.all(pending);
|
|
1363
1686
|
|
|
1364
1687
|
console.log(
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Is there a newer Populace than the one running?
|
|
2
|
+
//
|
|
3
|
+
// A version check phones home, and a testing tool that quietly makes network
|
|
4
|
+
// calls you did not ask for has no business asking you to trust it with your
|
|
5
|
+
// staging credentials. So this one is:
|
|
6
|
+
//
|
|
7
|
+
// · explicit `populace update` asks; a run mentions it at most once
|
|
8
|
+
// a day, after the report, never before
|
|
9
|
+
// · off with one flag POPULACE_NO_UPDATE_CHECK=1, and the CI environment
|
|
10
|
+
// variable turns it off on its own
|
|
11
|
+
// · silent on failure no registry, no network, a firewall — nothing is said,
|
|
12
|
+
// because a version check is never worth an error message
|
|
13
|
+
// · anonymous a plain GET to the public registry. No identifiers, no
|
|
14
|
+
// telemetry, nothing about your app or your runs
|
|
15
|
+
//
|
|
16
|
+
// Cached in the system temp directory for a day so twenty runs make one request.
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { PACKAGE_NAME, VERSION } from "./version.mjs";
|
|
22
|
+
|
|
23
|
+
const REGISTRY = "https://registry.npmjs.org";
|
|
24
|
+
const CACHE = path.join(os.tmpdir(), "populace-update-check.json");
|
|
25
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
/** Off in CI, and off whenever anyone says so. */
|
|
28
|
+
export function checksDisabled() {
|
|
29
|
+
return Boolean(process.env.POPULACE_NO_UPDATE_CHECK || process.env.CI);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** -1 a is older, 0 same, 1 a is newer. Plain semver; pre-release tags ignored. */
|
|
33
|
+
export function compare(a, b) {
|
|
34
|
+
const parts = (v) => String(v).split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
|
|
35
|
+
const [x, y] = [parts(a), parts(b)];
|
|
36
|
+
for (let i = 0; i < 3; i++) {
|
|
37
|
+
if ((x[i] || 0) > (y[i] || 0)) return 1;
|
|
38
|
+
if ((x[i] || 0) < (y[i] || 0)) return -1;
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readCache() {
|
|
44
|
+
try {
|
|
45
|
+
const c = JSON.parse(fs.readFileSync(CACHE, "utf8"));
|
|
46
|
+
return Date.now() - c.at < DAY ? c : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeCache(latest) {
|
|
53
|
+
try {
|
|
54
|
+
fs.writeFileSync(CACHE, JSON.stringify({ at: Date.now(), latest }));
|
|
55
|
+
} catch {
|
|
56
|
+
// A read-only temp directory is not a reason to fail anything.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The newest published version, or null.
|
|
62
|
+
*
|
|
63
|
+
* Never throws and never waits long: this runs after a report a person is
|
|
64
|
+
* already reading, and a version check that delays it has cost more than it
|
|
65
|
+
* is worth.
|
|
66
|
+
*/
|
|
67
|
+
export async function latestVersion({ timeoutMs = 3000, useCache = true } = {}) {
|
|
68
|
+
if (checksDisabled()) return null;
|
|
69
|
+
if (useCache) {
|
|
70
|
+
const cached = readCache();
|
|
71
|
+
if (cached) return cached.latest;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
76
|
+
try {
|
|
77
|
+
// No Accept header. The abbreviated-packument type
|
|
78
|
+
// (application/vnd.npm.install-v1+json) is valid on the full packument and
|
|
79
|
+
// returns 406 on /latest — which this swallowed as "could not reach the
|
|
80
|
+
// registry", hiding a bug behind a reassuring message.
|
|
81
|
+
const res = await fetch(`${REGISTRY}/${PACKAGE_NAME}/latest`, { signal: controller.signal });
|
|
82
|
+
if (!res.ok) return null;
|
|
83
|
+
const { version } = await res.json();
|
|
84
|
+
if (!version) return null;
|
|
85
|
+
writeCache(version);
|
|
86
|
+
return version;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One line for the end of a report, or null when there is nothing to say. */
|
|
95
|
+
export async function updateNotice(options) {
|
|
96
|
+
const latest = await latestVersion(options);
|
|
97
|
+
if (!latest || compare(latest, VERSION) !== 1) return null;
|
|
98
|
+
return ` A newer Populace is out: ${VERSION} → ${latest} npm i -g ${PACKAGE_NAME}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `populace update` — the explicit check, which always says something. */
|
|
102
|
+
export async function updateCommand() {
|
|
103
|
+
if (checksDisabled()) {
|
|
104
|
+
console.log(`
|
|
105
|
+
Update checks are switched off${process.env.CI ? " (CI is set)" : " (POPULACE_NO_UPDATE_CHECK is set)"}.
|
|
106
|
+
Running ${PACKAGE_NAME} ${VERSION}.
|
|
107
|
+
`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log(`\n Running ${PACKAGE_NAME} ${VERSION}. Asking the npm registry…`);
|
|
112
|
+
const latest = await latestVersion({ timeoutMs: 10000, useCache: false });
|
|
113
|
+
|
|
114
|
+
if (!latest) {
|
|
115
|
+
console.log(`
|
|
116
|
+
Could not reach the registry. That is all this means — nothing is wrong with
|
|
117
|
+
your install, and Populace never needs the network to run.
|
|
118
|
+
`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const d = compare(latest, VERSION);
|
|
123
|
+
if (d === 1) {
|
|
124
|
+
console.log(`
|
|
125
|
+
${VERSION} → ${latest} is available.
|
|
126
|
+
|
|
127
|
+
npm i -g ${PACKAGE_NAME}
|
|
128
|
+
npx ${PACKAGE_NAME}@latest run
|
|
129
|
+
|
|
130
|
+
Turn these checks off with POPULACE_NO_UPDATE_CHECK=1.
|
|
131
|
+
`);
|
|
132
|
+
} else if (d === 0) {
|
|
133
|
+
console.log(`\n Up to date.\n`);
|
|
134
|
+
} else {
|
|
135
|
+
// Running ahead of the registry: a local build, or a publish still pending.
|
|
136
|
+
console.log(`
|
|
137
|
+
You are running ${VERSION}; the registry has ${latest}. That means this is a
|
|
138
|
+
local or unpublished build, not that anything is wrong.
|
|
139
|
+
`);
|
|
140
|
+
}
|
|
141
|
+
}
|