mandrel-platform 1.4.0 → 1.4.2
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/package.json
CHANGED
|
@@ -43,9 +43,12 @@
|
|
|
43
43
|
* • --token Better Stack API token. Defaults to
|
|
44
44
|
* $BETTERSTACK_API_TOKEN. Missing token → skip-with-
|
|
45
45
|
* notice, exit 0.
|
|
46
|
-
* • --alert-email
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* • --alert-email DEPRECATED and ignored (defaults to $UPTIME_ALERT_EMAIL).
|
|
47
|
+
* Better Stack's monitor `email` field is a boolean
|
|
48
|
+
* ("Send e-mail alerts."), not a recipient — recipients
|
|
49
|
+
* resolve from the team roster and the escalation policy.
|
|
50
|
+
* Accepted only so an existing caller does not break; use
|
|
51
|
+
* a monitor entry's `policyId` to control who is alerted.
|
|
49
52
|
*
|
|
50
53
|
* Exit codes:
|
|
51
54
|
* 0 — plan computed / applied successfully, OR skip-with-notice (no token).
|
|
@@ -71,12 +74,24 @@ import { resolve } from "node:path";
|
|
|
71
74
|
* {
|
|
72
75
|
* "url": "https://api.example.com/health", // required, http(s) URL
|
|
73
76
|
* "name": "api", // optional, defaults to url's host
|
|
74
|
-
* "
|
|
77
|
+
* "emailAlerts": true, // optional, boolean, default true
|
|
78
|
+
* "policyId": "12345", // optional, Better Stack escalation policy
|
|
75
79
|
* "checkFrequency": 30 // optional, seconds, default 30
|
|
76
80
|
* }
|
|
77
81
|
*
|
|
82
|
+
* `emailAlerts` maps to Better Stack's `email` field, which the Monitors API
|
|
83
|
+
* types as a **boolean** ("Send e-mail alerts.") — it is a switch, not a
|
|
84
|
+
* recipient. Recipients resolve from the Better Stack team roster and the
|
|
85
|
+
* escalation policy named by `policyId` (`policy_id`), never from a
|
|
86
|
+
* per-monitor address. The retired `alertEmail` field is still accepted and
|
|
87
|
+
* type-checked so an existing config keeps parsing, but it is inert: it is
|
|
88
|
+
* reported in `deprecations[]` and never reaches the API payload.
|
|
89
|
+
*
|
|
78
90
|
* @param {unknown} raw Parsed JSON.
|
|
79
|
-
* @returns {{
|
|
91
|
+
* @returns {{
|
|
92
|
+
* monitors: Array<{url:string, name:string, emailAlerts:boolean, policyId:string|null, checkFrequency:number}>,
|
|
93
|
+
* deprecations: string[]
|
|
94
|
+
* }}
|
|
80
95
|
* @throws {Error} with a message naming the offending index/field on invalid input.
|
|
81
96
|
*/
|
|
82
97
|
export function parseMonitorConfig(raw) {
|
|
@@ -86,9 +101,18 @@ export function parseMonitorConfig(raw) {
|
|
|
86
101
|
"monitor config must be a JSON array of monitor entries, or an object with a `monitors` array."
|
|
87
102
|
);
|
|
88
103
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
104
|
+
const deprecations = [];
|
|
105
|
+
const monitors = list.map((entry, i) => {
|
|
106
|
+
if (entry && typeof entry === "object" && entry.alertEmail !== undefined) {
|
|
107
|
+
deprecations.push(
|
|
108
|
+
`monitor config entry [${i}] sets "alertEmail" — that field is ignored. ` +
|
|
109
|
+
`Better Stack types the monitor's \`email\` field as a boolean (an on/off switch), not a recipient. ` +
|
|
110
|
+
`Use "emailAlerts" to toggle e-mail alerts and "policyId" to choose the escalation policy that decides who is alerted.`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return validateMonitorEntry(entry, i);
|
|
114
|
+
});
|
|
115
|
+
return { monitors, deprecations };
|
|
92
116
|
}
|
|
93
117
|
|
|
94
118
|
function validateMonitorEntry(entry, index) {
|
|
@@ -110,13 +134,20 @@ function validateMonitorEntry(entry, index) {
|
|
|
110
134
|
if (entry.alertEmail !== undefined && typeof entry.alertEmail !== "string") {
|
|
111
135
|
throw new Error(`monitor config entry [${index}] "alertEmail" must be a string when present.`);
|
|
112
136
|
}
|
|
137
|
+
if (entry.emailAlerts !== undefined && typeof entry.emailAlerts !== "boolean") {
|
|
138
|
+
throw new Error(`monitor config entry [${index}] "emailAlerts" must be a boolean when present.`);
|
|
139
|
+
}
|
|
140
|
+
if (entry.policyId !== undefined && typeof entry.policyId !== "string") {
|
|
141
|
+
throw new Error(`monitor config entry [${index}] "policyId" must be a string when present.`);
|
|
142
|
+
}
|
|
113
143
|
if (entry.checkFrequency !== undefined && !(Number.isInteger(entry.checkFrequency) && entry.checkFrequency > 0)) {
|
|
114
144
|
throw new Error(`monitor config entry [${index}] "checkFrequency" must be a positive integer (seconds) when present.`);
|
|
115
145
|
}
|
|
116
146
|
return {
|
|
117
147
|
url: entry.url,
|
|
118
148
|
name: entry.name ?? host,
|
|
119
|
-
|
|
149
|
+
emailAlerts: entry.emailAlerts ?? true,
|
|
150
|
+
policyId: entry.policyId ?? null,
|
|
120
151
|
checkFrequency: entry.checkFrequency ?? DEFAULT_CHECK_FREQUENCY_SECONDS,
|
|
121
152
|
};
|
|
122
153
|
}
|
|
@@ -163,7 +194,13 @@ function normalizeUrl(url) {
|
|
|
163
194
|
function monitorNeedsUpdate(live, desired) {
|
|
164
195
|
if (live.name !== undefined && live.name !== desired.name) return true;
|
|
165
196
|
if (live.checkFrequency !== undefined && live.checkFrequency !== desired.checkFrequency) return true;
|
|
166
|
-
|
|
197
|
+
// Both sides are booleans: the live read-back maps Better Stack's boolean
|
|
198
|
+
// `email` attribute, and `emailAlerts` normalizes to a boolean at parse.
|
|
199
|
+
// Comparing a boolean against a configured address (the pre-#403 shape)
|
|
200
|
+
// read as drift on every beat and issued a redundant PATCH per monitor
|
|
201
|
+
// per apply.
|
|
202
|
+
if (live.emailAlerts !== undefined && live.emailAlerts !== desired.emailAlerts) return true;
|
|
203
|
+
if (live.policyId !== undefined && desired.policyId !== null && live.policyId !== desired.policyId) return true;
|
|
167
204
|
return false;
|
|
168
205
|
}
|
|
169
206
|
|
|
@@ -209,7 +246,8 @@ export function createBetterStackClient({ token, fetchImpl = fetch, apiBase = BE
|
|
|
209
246
|
url: m.attributes?.url ?? "",
|
|
210
247
|
name: m.attributes?.pronounceable_name,
|
|
211
248
|
checkFrequency: m.attributes?.check_frequency,
|
|
212
|
-
|
|
249
|
+
emailAlerts: m.attributes?.email,
|
|
250
|
+
policyId: m.attributes?.policy_id,
|
|
213
251
|
}));
|
|
214
252
|
},
|
|
215
253
|
async createMonitor(entry) {
|
|
@@ -231,7 +269,11 @@ function toBetterStackPayload(entry) {
|
|
|
231
269
|
url: entry.url,
|
|
232
270
|
pronounceable_name: entry.name,
|
|
233
271
|
check_frequency: entry.checkFrequency,
|
|
234
|
-
|
|
272
|
+
// `email` is a boolean in Better Stack's Monitors API ("Send e-mail
|
|
273
|
+
// alerts.") — always send the switch, never an address. `policy_id` is
|
|
274
|
+
// the field that actually determines who is alerted.
|
|
275
|
+
email: entry.emailAlerts,
|
|
276
|
+
...(entry.policyId ? { policy_id: entry.policyId } : {}),
|
|
235
277
|
};
|
|
236
278
|
}
|
|
237
279
|
|
|
@@ -245,14 +287,10 @@ function toBetterStackPayload(entry) {
|
|
|
245
287
|
* @param {{monitors: Array}} opts.config
|
|
246
288
|
* @param {ReturnType<typeof createBetterStackClient>} opts.client
|
|
247
289
|
* @param {boolean} opts.dryRun
|
|
248
|
-
* @param {string|null} [opts.defaultAlertEmail]
|
|
249
290
|
* @returns {Promise<{created: string[], updated: string[], unchanged: string[], dryRun: boolean}>}
|
|
250
291
|
*/
|
|
251
|
-
export async function applyMonitorConfig({ config, client, dryRun
|
|
252
|
-
const desired = config.monitors
|
|
253
|
-
...m,
|
|
254
|
-
alertEmail: m.alertEmail ?? defaultAlertEmail,
|
|
255
|
-
}));
|
|
292
|
+
export async function applyMonitorConfig({ config, client, dryRun }) {
|
|
293
|
+
const desired = config.monitors;
|
|
256
294
|
const live = await client.listMonitors();
|
|
257
295
|
const { toCreate, toUpdate, unchanged } = diffMonitors(desired, live);
|
|
258
296
|
|
|
@@ -322,10 +360,33 @@ async function main() {
|
|
|
322
360
|
process.exit(1);
|
|
323
361
|
}
|
|
324
362
|
|
|
363
|
+
// Announced before the token check on purpose: "secret provisioned, token
|
|
364
|
+
// absent" is exactly the state that produced a permanently-green apply a
|
|
365
|
+
// consumer believed was routing alerts, so that run is the one that most
|
|
366
|
+
// needs to hear the address is inert (refs #403).
|
|
367
|
+
if (opts.alertEmail) {
|
|
368
|
+
process.stdout.write(
|
|
369
|
+
"::warning title=UPTIME_ALERT_EMAIL is ignored::Better Stack resolves alert recipients from the team roster " +
|
|
370
|
+
"and escalation policy, not from a per-monitor address. Set a monitor's `policyId` instead, and drop this secret from your caller.\n"
|
|
371
|
+
);
|
|
372
|
+
process.stderr.write(
|
|
373
|
+
"[apply-uptime-monitors] DEPRECATED: --alert-email / $UPTIME_ALERT_EMAIL is ignored — Better Stack's monitor " +
|
|
374
|
+
"`email` field is a boolean switch, not a recipient. Use a monitor entry's `policyId` (escalation policy) to " +
|
|
375
|
+
"control who is alerted, and `emailAlerts` to toggle e-mail alerts.\n"
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
325
379
|
// Graceful degradation: no token → skip-with-notice, exit 0. Preserves the
|
|
326
380
|
// pre-existing per-consumer behaviour when Better Stack secrets are not
|
|
327
381
|
// yet provisioned (acceptance criterion — see docs/reusable-workflows.md).
|
|
328
382
|
if (!opts.token) {
|
|
383
|
+
// Annotation-level, not stdout-only: a consumer can otherwise sit on a
|
|
384
|
+
// permanently-green `uptime-apply` for weeks with zero live monitors and
|
|
385
|
+
// never notice the apply is inert (refs #403).
|
|
386
|
+
process.stdout.write(
|
|
387
|
+
"::warning title=Uptime monitors not applied::BETTERSTACK_API_TOKEN is not provisioned — " +
|
|
388
|
+
"this uptime-apply run created and updated nothing. Provision the secret to activate uptime monitoring.\n"
|
|
389
|
+
);
|
|
329
390
|
process.stdout.write(
|
|
330
391
|
"⏭️ apply-uptime-monitors: BETTERSTACK_API_TOKEN not provided — skipping uptime-monitor apply (Better Stack not provisioned for this consumer yet).\n"
|
|
331
392
|
);
|
|
@@ -341,6 +402,10 @@ async function main() {
|
|
|
341
402
|
process.exit(1);
|
|
342
403
|
}
|
|
343
404
|
|
|
405
|
+
for (const notice of config.deprecations) {
|
|
406
|
+
process.stderr.write(`[apply-uptime-monitors] DEPRECATED: ${notice}\n`);
|
|
407
|
+
}
|
|
408
|
+
|
|
344
409
|
// Test-only escape hatch: point the CLI at a local/offline server instead
|
|
345
410
|
// of the live Better Stack API. Never set in a production caller — see
|
|
346
411
|
// createBetterStackClient's apiBase docblock.
|
|
@@ -355,7 +420,6 @@ async function main() {
|
|
|
355
420
|
config,
|
|
356
421
|
client,
|
|
357
422
|
dryRun: opts.dryRun,
|
|
358
|
-
defaultAlertEmail: opts.alertEmail,
|
|
359
423
|
});
|
|
360
424
|
const verb = result.dryRun ? "would create" : "created";
|
|
361
425
|
const verbUpdate = result.dryRun ? "would update" : "updated";
|
|
@@ -42,16 +42,19 @@ test("parseMonitorConfig accepts a bare array of monitor entries", () => {
|
|
|
42
42
|
assert.equal(monitors.length, 1);
|
|
43
43
|
assert.equal(monitors[0].url, "https://api.example.com/health");
|
|
44
44
|
assert.equal(monitors[0].name, "api.example.com");
|
|
45
|
-
assert.equal(monitors[0].
|
|
45
|
+
assert.equal(monitors[0].emailAlerts, true, "e-mail alerts default to on");
|
|
46
|
+
assert.equal(monitors[0].policyId, null);
|
|
46
47
|
assert.equal(monitors[0].checkFrequency, DEFAULT_CHECK_FREQUENCY_SECONDS);
|
|
47
48
|
});
|
|
48
49
|
|
|
49
50
|
test("parseMonitorConfig accepts a wrapped { monitors: [...] } object", () => {
|
|
50
51
|
const { monitors } = parseMonitorConfig({
|
|
51
|
-
monitors: [
|
|
52
|
+
monitors: [
|
|
53
|
+
{ url: "https://x.example.com", name: "x", emailAlerts: false, policyId: "pol-1", checkFrequency: 60 },
|
|
54
|
+
],
|
|
52
55
|
});
|
|
53
56
|
assert.deepEqual(monitors, [
|
|
54
|
-
{ url: "https://x.example.com", name: "x",
|
|
57
|
+
{ url: "https://x.example.com", name: "x", emailAlerts: false, policyId: "pol-1", checkFrequency: 60 },
|
|
55
58
|
]);
|
|
56
59
|
});
|
|
57
60
|
|
|
@@ -83,12 +86,42 @@ test("parseMonitorConfig rejects non-string name/alertEmail", () => {
|
|
|
83
86
|
);
|
|
84
87
|
});
|
|
85
88
|
|
|
89
|
+
test("parseMonitorConfig rejects a non-boolean emailAlerts, naming the index", () => {
|
|
90
|
+
assert.throws(
|
|
91
|
+
() => parseMonitorConfig([{ url: "https://a.example.com" }, { url: "https://x.example.com", emailAlerts: "yes" }]),
|
|
92
|
+
/entry \[1\] "emailAlerts" must be a boolean/
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("parseMonitorConfig rejects a non-string policyId, naming the index", () => {
|
|
97
|
+
assert.throws(
|
|
98
|
+
() => parseMonitorConfig([{ url: "https://a.example.com" }, { url: "https://x.example.com", policyId: 12345 }]),
|
|
99
|
+
/entry \[1\] "policyId" must be a string/
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("parseMonitorConfig reports a retired alertEmail as a deprecation and drops it", () => {
|
|
104
|
+
const { monitors, deprecations } = parseMonitorConfig([
|
|
105
|
+
{ url: "https://x.example.com", alertEmail: "oncall@example.com" },
|
|
106
|
+
]);
|
|
107
|
+
assert.equal(deprecations.length, 1);
|
|
108
|
+
assert.match(deprecations[0], /entry \[0\] sets "alertEmail" — that field is ignored/);
|
|
109
|
+
assert.match(deprecations[0], /policyId/);
|
|
110
|
+
assert.equal(monitors[0].alertEmail, undefined, "the retired field never reaches the normalized entry");
|
|
111
|
+
assert.equal(monitors[0].emailAlerts, true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("parseMonitorConfig reports no deprecations for a clean config", () => {
|
|
115
|
+
const { deprecations } = parseMonitorConfig([{ url: "https://x.example.com", emailAlerts: false }]);
|
|
116
|
+
assert.deepEqual(deprecations, []);
|
|
117
|
+
});
|
|
118
|
+
|
|
86
119
|
// ---------------------------------------------------------------------------
|
|
87
120
|
// diffMonitors
|
|
88
121
|
// ---------------------------------------------------------------------------
|
|
89
122
|
|
|
90
123
|
test("diffMonitors classifies a brand-new url as toCreate", () => {
|
|
91
|
-
const desired = [{ url: "https://new.example.com", name: "new",
|
|
124
|
+
const desired = [{ url: "https://new.example.com", name: "new", emailAlerts: true, policyId: null, checkFrequency: 30 }];
|
|
92
125
|
const { toCreate, toUpdate, unchanged } = diffMonitors(desired, []);
|
|
93
126
|
assert.equal(toCreate.length, 1);
|
|
94
127
|
assert.equal(toUpdate.length, 0);
|
|
@@ -96,7 +129,7 @@ test("diffMonitors classifies a brand-new url as toCreate", () => {
|
|
|
96
129
|
});
|
|
97
130
|
|
|
98
131
|
test("diffMonitors classifies a matching, identical url as unchanged", () => {
|
|
99
|
-
const desired = [{ url: "https://x.example.com", name: "x",
|
|
132
|
+
const desired = [{ url: "https://x.example.com", name: "x", emailAlerts: true, policyId: null, checkFrequency: 30 }];
|
|
100
133
|
const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
|
|
101
134
|
const { toCreate, toUpdate, unchanged } = diffMonitors(desired, live);
|
|
102
135
|
assert.equal(toCreate.length, 0);
|
|
@@ -105,7 +138,7 @@ test("diffMonitors classifies a matching, identical url as unchanged", () => {
|
|
|
105
138
|
});
|
|
106
139
|
|
|
107
140
|
test("diffMonitors classifies a url with a drifted name/frequency as toUpdate", () => {
|
|
108
|
-
const desired = [{ url: "https://x.example.com", name: "renamed",
|
|
141
|
+
const desired = [{ url: "https://x.example.com", name: "renamed", emailAlerts: true, policyId: null, checkFrequency: 60 }];
|
|
109
142
|
const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
|
|
110
143
|
const { toUpdate } = diffMonitors(desired, live);
|
|
111
144
|
assert.equal(toUpdate.length, 1);
|
|
@@ -113,7 +146,7 @@ test("diffMonitors classifies a url with a drifted name/frequency as toUpdate",
|
|
|
113
146
|
});
|
|
114
147
|
|
|
115
148
|
test("diffMonitors url matching is trailing-slash and case insensitive", () => {
|
|
116
|
-
const desired = [{ url: "https://X.example.com/", name: "x",
|
|
149
|
+
const desired = [{ url: "https://X.example.com/", name: "x", emailAlerts: true, policyId: null, checkFrequency: 30 }];
|
|
117
150
|
const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
|
|
118
151
|
const { toCreate, unchanged } = diffMonitors(desired, live);
|
|
119
152
|
assert.equal(toCreate.length, 0);
|
|
@@ -121,7 +154,7 @@ test("diffMonitors url matching is trailing-slash and case insensitive", () => {
|
|
|
121
154
|
});
|
|
122
155
|
|
|
123
156
|
test("diffMonitors never proposes deleting a live monitor absent from desired (additive apply only)", () => {
|
|
124
|
-
const desired = [{ url: "https://kept.example.com", name: "kept",
|
|
157
|
+
const desired = [{ url: "https://kept.example.com", name: "kept", emailAlerts: true, policyId: null, checkFrequency: 30 }];
|
|
125
158
|
const live = [
|
|
126
159
|
{ id: "1", url: "https://kept.example.com", name: "kept", checkFrequency: 30 },
|
|
127
160
|
{ id: "2", url: "https://hand-added.example.com", name: "manual" },
|
|
@@ -162,7 +195,16 @@ test("createBetterStackClient.listMonitors normalizes the Better Stack payload s
|
|
|
162
195
|
data: [
|
|
163
196
|
{
|
|
164
197
|
id: "42",
|
|
165
|
-
|
|
198
|
+
// `email` is a boolean in Better Stack's API — a switch, not a
|
|
199
|
+
// recipient. Fixturing it as an address is what let the
|
|
200
|
+
// string-typed payload bug survive a green suite (refs #403).
|
|
201
|
+
attributes: {
|
|
202
|
+
url: "https://a.example.com",
|
|
203
|
+
pronounceable_name: "a",
|
|
204
|
+
check_frequency: 30,
|
|
205
|
+
email: true,
|
|
206
|
+
policy_id: "pol-1",
|
|
207
|
+
},
|
|
166
208
|
},
|
|
167
209
|
],
|
|
168
210
|
},
|
|
@@ -171,7 +213,7 @@ test("createBetterStackClient.listMonitors normalizes the Better Stack payload s
|
|
|
171
213
|
const client = createBetterStackClient({ token: "tok", fetchImpl });
|
|
172
214
|
const monitors = await client.listMonitors();
|
|
173
215
|
assert.deepEqual(monitors, [
|
|
174
|
-
{ id: "42", url: "https://a.example.com", name: "a", checkFrequency: 30,
|
|
216
|
+
{ id: "42", url: "https://a.example.com", name: "a", checkFrequency: 30, emailAlerts: true, policyId: "pol-1" },
|
|
175
217
|
]);
|
|
176
218
|
assert.equal(calls[0].url, "https://uptime.betterstack.com/api/v2/monitors");
|
|
177
219
|
});
|
|
@@ -209,7 +251,7 @@ function fakeClient({ live = [] } = {}) {
|
|
|
209
251
|
test("applyMonitorConfig dry-run computes the plan without calling create/update", async () => {
|
|
210
252
|
const client = fakeClient({ live: [] });
|
|
211
253
|
const result = await applyMonitorConfig({
|
|
212
|
-
config: { monitors: [{ url: "https://x.example.com", name: "x",
|
|
254
|
+
config: { monitors: [{ url: "https://x.example.com", name: "x", emailAlerts: true, policyId: null, checkFrequency: 30 }] },
|
|
213
255
|
client,
|
|
214
256
|
dryRun: true,
|
|
215
257
|
});
|
|
@@ -221,7 +263,7 @@ test("applyMonitorConfig dry-run computes the plan without calling create/update
|
|
|
221
263
|
test("applyMonitorConfig --apply issues create for new monitors", async () => {
|
|
222
264
|
const client = fakeClient({ live: [] });
|
|
223
265
|
const result = await applyMonitorConfig({
|
|
224
|
-
config: { monitors: [{ url: "https://x.example.com", name: "x",
|
|
266
|
+
config: { monitors: [{ url: "https://x.example.com", name: "x", emailAlerts: true, policyId: null, checkFrequency: 30 }] },
|
|
225
267
|
client,
|
|
226
268
|
dryRun: false,
|
|
227
269
|
});
|
|
@@ -229,21 +271,112 @@ test("applyMonitorConfig --apply issues create for new monitors", async () => {
|
|
|
229
271
|
assert.equal(client.created.length, 1);
|
|
230
272
|
});
|
|
231
273
|
|
|
232
|
-
test("
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
274
|
+
test("a created monitor sends Better Stack's `email` as a boolean, never an address", async () => {
|
|
275
|
+
const { fetchImpl, calls } = fakeFetch({
|
|
276
|
+
"POST https://uptime.betterstack.com/api/v2/monitors": { status: 201, body: { data: { id: "9" } } },
|
|
277
|
+
});
|
|
278
|
+
const client = createBetterStackClient({ token: "tok", fetchImpl });
|
|
279
|
+
await client.createMonitor({
|
|
280
|
+
url: "https://x.example.com",
|
|
281
|
+
name: "x",
|
|
282
|
+
emailAlerts: true,
|
|
283
|
+
policyId: null,
|
|
284
|
+
checkFrequency: 30,
|
|
285
|
+
});
|
|
286
|
+
const body = JSON.parse(calls[0].body);
|
|
287
|
+
assert.equal(typeof body.email, "boolean", "`email` is a boolean switch in Better Stack's API");
|
|
288
|
+
assert.equal(body.email, true);
|
|
289
|
+
assert.ok(!("policy_id" in body), "no escalation policy is sent when the entry names none");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("emailAlerts:false switches e-mail alerts off rather than omitting the field", async () => {
|
|
293
|
+
const { fetchImpl, calls } = fakeFetch({
|
|
294
|
+
"POST https://uptime.betterstack.com/api/v2/monitors": { status: 201, body: { data: { id: "9" } } },
|
|
295
|
+
});
|
|
296
|
+
const client = createBetterStackClient({ token: "tok", fetchImpl });
|
|
297
|
+
await client.createMonitor({
|
|
298
|
+
url: "https://x.example.com",
|
|
299
|
+
name: "x",
|
|
300
|
+
emailAlerts: false,
|
|
301
|
+
policyId: null,
|
|
302
|
+
checkFrequency: 30,
|
|
303
|
+
});
|
|
304
|
+
assert.equal(JSON.parse(calls[0].body).email, false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("policyId maps to Better Stack's policy_id — the field that decides who is alerted", async () => {
|
|
308
|
+
const { fetchImpl, calls } = fakeFetch({
|
|
309
|
+
"POST https://uptime.betterstack.com/api/v2/monitors": { status: 201, body: { data: { id: "9" } } },
|
|
310
|
+
});
|
|
311
|
+
const client = createBetterStackClient({ token: "tok", fetchImpl });
|
|
312
|
+
await client.createMonitor({
|
|
313
|
+
url: "https://x.example.com",
|
|
314
|
+
name: "x",
|
|
315
|
+
emailAlerts: true,
|
|
316
|
+
policyId: "pol-7",
|
|
317
|
+
checkFrequency: 30,
|
|
318
|
+
});
|
|
319
|
+
const body = JSON.parse(calls[0].body);
|
|
320
|
+
assert.equal(body.policy_id, "pol-7");
|
|
321
|
+
assert.equal(typeof body.email, "boolean");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("no payload ever carries an address in `email`, even from a legacy alertEmail config", async () => {
|
|
325
|
+
const { fetchImpl, calls } = fakeFetch({
|
|
326
|
+
"POST https://uptime.betterstack.com/api/v2/monitors": { status: 201, body: { data: { id: "9" } } },
|
|
327
|
+
});
|
|
328
|
+
const config = parseMonitorConfig([{ url: "https://x.example.com", alertEmail: "oncall@example.com" }]);
|
|
329
|
+
const client = createBetterStackClient({ token: "tok", fetchImpl });
|
|
330
|
+
await applyMonitorConfig({ config, client: { ...client, listMonitors: async () => [] }, dryRun: false });
|
|
331
|
+
const body = JSON.parse(calls[0].body);
|
|
332
|
+
assert.equal(typeof body.email, "boolean");
|
|
333
|
+
assert.ok(
|
|
334
|
+
!JSON.stringify(body).includes("oncall@example.com"),
|
|
335
|
+
"the retired address never reaches the Better Stack payload"
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("a converged monitor reports unchanged and issues no update (no per-run PATCH churn)", async () => {
|
|
340
|
+
// Live read-back carries the boolean `email`; desired carries the boolean
|
|
341
|
+
// `emailAlerts`. Pre-#403 these were a boolean vs an address, so every
|
|
342
|
+
// monitor read as drifted on every apply.
|
|
343
|
+
const client = fakeClient({
|
|
344
|
+
live: [
|
|
345
|
+
{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30, emailAlerts: true, policyId: "pol-1" },
|
|
346
|
+
],
|
|
347
|
+
});
|
|
348
|
+
const result = await applyMonitorConfig({
|
|
349
|
+
config: {
|
|
350
|
+
monitors: [
|
|
351
|
+
{ url: "https://x.example.com", name: "x", emailAlerts: true, policyId: "pol-1", checkFrequency: 30 },
|
|
352
|
+
],
|
|
353
|
+
},
|
|
354
|
+
client,
|
|
355
|
+
dryRun: false,
|
|
356
|
+
});
|
|
357
|
+
assert.deepEqual(result.unchanged, ["https://x.example.com"]);
|
|
358
|
+
assert.equal(result.updated.length, 0);
|
|
359
|
+
assert.equal(client.updated.length, 0, "a converged config issues zero update calls");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a drifted emailAlerts switch is still detected as an update", async () => {
|
|
363
|
+
const client = fakeClient({
|
|
364
|
+
live: [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30, emailAlerts: false }],
|
|
365
|
+
});
|
|
366
|
+
const result = await applyMonitorConfig({
|
|
367
|
+
config: {
|
|
368
|
+
monitors: [{ url: "https://x.example.com", name: "x", emailAlerts: true, policyId: null, checkFrequency: 30 }],
|
|
369
|
+
},
|
|
236
370
|
client,
|
|
237
371
|
dryRun: false,
|
|
238
|
-
defaultAlertEmail: "oncall@example.com",
|
|
239
372
|
});
|
|
240
|
-
assert.
|
|
373
|
+
assert.deepEqual(result.updated, ["https://x.example.com"]);
|
|
241
374
|
});
|
|
242
375
|
|
|
243
376
|
test("applyMonitorConfig issues update for a drifted existing monitor", async () => {
|
|
244
377
|
const client = fakeClient({ live: [{ id: "1", url: "https://x.example.com", name: "old", checkFrequency: 30 }] });
|
|
245
378
|
const result = await applyMonitorConfig({
|
|
246
|
-
config: { monitors: [{ url: "https://x.example.com", name: "new",
|
|
379
|
+
config: { monitors: [{ url: "https://x.example.com", name: "new", emailAlerts: true, policyId: null, checkFrequency: 30 }] },
|
|
247
380
|
client,
|
|
248
381
|
dryRun: false,
|
|
249
382
|
});
|
|
@@ -269,6 +402,50 @@ test("CLI skip-with-notice: no BETTERSTACK_API_TOKEN exits 0 with a notice, no c
|
|
|
269
402
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
270
403
|
});
|
|
271
404
|
|
|
405
|
+
test("CLI skip-with-notice raises a ::warning:: annotation so an inert apply is visible in the checks UI", () => {
|
|
406
|
+
tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-"));
|
|
407
|
+
const configPath = join(tmpDir, "monitors.json");
|
|
408
|
+
writeFileSync(configPath, JSON.stringify([{ url: "https://x.example.com" }]));
|
|
409
|
+
const out = execFileSync("node", [CLI, "--config", configPath], {
|
|
410
|
+
encoding: "utf8",
|
|
411
|
+
env: { ...process.env, BETTERSTACK_API_TOKEN: "" },
|
|
412
|
+
});
|
|
413
|
+
assert.match(out, /^::warning title=Uptime monitors not applied::/m);
|
|
414
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("CLI announces UPTIME_ALERT_EMAIL as ignored instead of silently no-oping, and still exits 0", async () => {
|
|
418
|
+
tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-"));
|
|
419
|
+
const configPath = join(tmpDir, "monitors.json");
|
|
420
|
+
writeFileSync(configPath, JSON.stringify([{ url: "https://x.example.com" }]));
|
|
421
|
+
// "Secret provisioned, token absent" is the exact state that produced a
|
|
422
|
+
// permanently-green apply a consumer believed was routing alerts — so this
|
|
423
|
+
// run must still say the address is inert, and must still exit 0.
|
|
424
|
+
const { stdout, stderr } = await execFileAsync(
|
|
425
|
+
"node",
|
|
426
|
+
[CLI, "--config", configPath, "--alert-email", "oncall@example.com"],
|
|
427
|
+
{ env: { ...process.env, BETTERSTACK_API_TOKEN: "" } }
|
|
428
|
+
);
|
|
429
|
+
assert.match(stderr, /DEPRECATED: --alert-email \/ \$UPTIME_ALERT_EMAIL is ignored/);
|
|
430
|
+
assert.match(stderr, /policyId/, "the notice names the replacement, not just the removal");
|
|
431
|
+
assert.match(stdout, /^::warning title=UPTIME_ALERT_EMAIL is ignored::/m);
|
|
432
|
+
assert.match(stdout, /skipping uptime-monitor apply/);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("CLI reports a config's retired alertEmail on stderr and never sends it", async () => {
|
|
436
|
+
const configPath = join(tmpDir, "legacy.json");
|
|
437
|
+
writeFileSync(configPath, JSON.stringify([{ url: "https://x.example.com", alertEmail: "oncall@example.com" }]));
|
|
438
|
+
const { stdout, stderr } = await execFileAsync(
|
|
439
|
+
"node",
|
|
440
|
+
[CLI, "--config", configPath, "--dry-run", "--alert-email", "oncall@example.com"],
|
|
441
|
+
{ env: { ...process.env, BETTERSTACK_API_TOKEN: "tok", BETTERSTACK_API_BASE_OVERRIDE: "http://127.0.0.1:9" } }
|
|
442
|
+
).catch((err) => err);
|
|
443
|
+
assert.match(stderr, /DEPRECATED: monitor config entry \[0\] sets "alertEmail"/);
|
|
444
|
+
assert.match(stderr, /DEPRECATED: --alert-email \/ \$UPTIME_ALERT_EMAIL is ignored/);
|
|
445
|
+
assert.match(stdout, /::warning title=UPTIME_ALERT_EMAIL is ignored::/);
|
|
446
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
447
|
+
});
|
|
448
|
+
|
|
272
449
|
test("CLI exits non-zero on an invalid config file even with a token set", () => {
|
|
273
450
|
tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-"));
|
|
274
451
|
const configPath = join(tmpDir, "monitors.json");
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-playwright-browser-install.test.mjs — regression guard for the e2e
|
|
4
|
+
* tier's Playwright browser install (Story #396).
|
|
5
|
+
*
|
|
6
|
+
* The bug this pins: the install step was gated on
|
|
7
|
+
* `steps.playwright-cache.outputs.cache-hit != 'true'`. `actions/cache` sets
|
|
8
|
+
* `cache-hit: true` on an EXACT key match and says nothing about what the
|
|
9
|
+
* restored tree actually contains, so the gate treats "an entry exists under
|
|
10
|
+
* this key" as proof the binaries are present. A cache saved partially — or
|
|
11
|
+
* saved before a Playwright patch added a browser variant under the same
|
|
12
|
+
* version key — therefore skips the only step that would repair it, and every
|
|
13
|
+
* scenario dies in milliseconds at `browserType.launch`.
|
|
14
|
+
*
|
|
15
|
+
* It could not self-heal in either direction: the hit kept skipping the
|
|
16
|
+
* repair, and Actions cache entries are IMMUTABLE under a key (`actions/cache`
|
|
17
|
+
* skips its post-job save on an exact hit), so the bad entry was never
|
|
18
|
+
* overwritten. Hence the two halves of the fix this file guards:
|
|
19
|
+
*
|
|
20
|
+
* 1. The install runs unconditionally, so a bad restore costs a re-download
|
|
21
|
+
* rather than the run. This is not a new cost — the pre-fix hit path
|
|
22
|
+
* already ran `playwright install-deps`, so the same OS-dependency step
|
|
23
|
+
* ran on BOTH branches; collapsing them adds only a browser-manifest
|
|
24
|
+
* verify.
|
|
25
|
+
* 2. A caller-settable salt is folded into the cache key, so an operator can
|
|
26
|
+
* stop paying that repair on every run by moving to a fresh key — without
|
|
27
|
+
* hand-deleting caches through the GitHub API.
|
|
28
|
+
*
|
|
29
|
+
* Asserting the key by string-matching its spelling would pin the text rather
|
|
30
|
+
* than the contract. The property that actually matters is RELATIONAL: the
|
|
31
|
+
* default salt must leave the key byte-for-byte identical to the pre-fix one
|
|
32
|
+
* (or every consumer's warm cache is silently invalidated by the upgrade), and
|
|
33
|
+
* distinct salts must produce distinct keys (or the escape hatch does not
|
|
34
|
+
* escape). So this extracts the real key template and resolves it under
|
|
35
|
+
* several salt values, the same read-then-execute approach as
|
|
36
|
+
* check-toolchain-cache-default.test.mjs.
|
|
37
|
+
*
|
|
38
|
+
* Run: node --test scripts/check-playwright-browser-install.test.mjs
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import assert from "node:assert/strict";
|
|
42
|
+
import { test } from "node:test";
|
|
43
|
+
import { spawnSync } from "node:child_process";
|
|
44
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
45
|
+
import { tmpdir } from "node:os";
|
|
46
|
+
import { join } from "node:path";
|
|
47
|
+
|
|
48
|
+
const QUALITY = ".github/workflows/pr-quality.yml";
|
|
49
|
+
const SALT_INPUT = "playwright-cache-salt";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The fixed literal the resolve step falls back to (Story #400).
|
|
53
|
+
*
|
|
54
|
+
* It must stay a CONSTANT. A host- or run-derived fallback (`$GITHUB_SHA`, a
|
|
55
|
+
* date) satisfies "the step no longer fails" while minting a new cache key on
|
|
56
|
+
* every run — permanently defeating the ~460 MiB cache the step exists to
|
|
57
|
+
* label, which is a worse outcome than the abort it replaced.
|
|
58
|
+
*/
|
|
59
|
+
const SENTINEL = "unresolved";
|
|
60
|
+
|
|
61
|
+
/** The exact key template the tier carried before Story #396. */
|
|
62
|
+
const PRE_FIX_KEY = "playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}";
|
|
63
|
+
const PRE_FIX_RESTORE_KEY = "playwright-${{ runner.os }}-";
|
|
64
|
+
|
|
65
|
+
const text = readFileSync(QUALITY, "utf8");
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The workflow with whole-line `#` comments removed.
|
|
69
|
+
*
|
|
70
|
+
* The guard is about what the workflow DOES, not what it says: the tier
|
|
71
|
+
* carries a comment naming the very expression this file forbids, so that a
|
|
72
|
+
* future reader is told not to reintroduce it. Scanning raw text would let
|
|
73
|
+
* that warning fail the check it exists to support. Only leading-`#` lines are
|
|
74
|
+
* dropped — never a mid-line `#`, which could sit inside a quoted value.
|
|
75
|
+
*/
|
|
76
|
+
const code = text
|
|
77
|
+
.split("\n")
|
|
78
|
+
.filter((l) => !l.trimStart().startsWith("#"))
|
|
79
|
+
.join("\n");
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Extraction
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The `steps:` block of the named job, sliced by indentation.
|
|
87
|
+
*
|
|
88
|
+
* Scans lines rather than building a `new RegExp` around the job name: a
|
|
89
|
+
* dynamically-constructed regex is a SAST finding (ReDoS surface) and buys
|
|
90
|
+
* nothing here, since the block boundary is just indentation.
|
|
91
|
+
*/
|
|
92
|
+
function jobBlock(name, source = code) {
|
|
93
|
+
const lines = source.split("\n");
|
|
94
|
+
const start = lines.indexOf(` ${name}:`);
|
|
95
|
+
assert.notEqual(start, -1, `${QUALITY}: job \`${name}\` not found`);
|
|
96
|
+
const out = [];
|
|
97
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
98
|
+
if (lines[i].trim() === "") {
|
|
99
|
+
out.push(lines[i]);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// Dedent to the job-name level or beyond → the block ended.
|
|
103
|
+
if (lines[i].match(/^(\s*)/)[1].length <= 2) break;
|
|
104
|
+
out.push(lines[i]);
|
|
105
|
+
}
|
|
106
|
+
return out.join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The `- name: <step>` … block for one step of a job, sliced by indentation.
|
|
111
|
+
*
|
|
112
|
+
* `source` defaults to the comment-stripped text — right for asserting what the
|
|
113
|
+
* workflow DOES. Pass the raw `text` when the block is going to be EXECUTED, so
|
|
114
|
+
* the guard runs the same script the runner does rather than a stripped
|
|
115
|
+
* paraphrase of it.
|
|
116
|
+
*/
|
|
117
|
+
function stepBlock(job, stepName, source = code) {
|
|
118
|
+
const lines = jobBlock(job, source).split("\n");
|
|
119
|
+
const start = lines.findIndex((l) => l.trim() === `- name: ${stepName}`);
|
|
120
|
+
assert.notEqual(start, -1, `${QUALITY}: step \`${stepName}\` not found in job \`${job}\``);
|
|
121
|
+
const indent = lines[start].match(/^(\s*)/)[1].length;
|
|
122
|
+
const out = [lines[start]];
|
|
123
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
124
|
+
if (lines[i].trim() === "") continue;
|
|
125
|
+
const width = lines[i].match(/^(\s*)/)[1].length;
|
|
126
|
+
// A sibling list item (or a dedent) at the same indent ends this step.
|
|
127
|
+
if (width <= indent) break;
|
|
128
|
+
out.push(lines[i]);
|
|
129
|
+
}
|
|
130
|
+
return out.join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The literal `default:` of the named workflow_call input. */
|
|
134
|
+
function inputDefault(name) {
|
|
135
|
+
const lines = code.split("\n");
|
|
136
|
+
const start = lines.indexOf(` ${name}:`);
|
|
137
|
+
assert.notEqual(start, -1, `${QUALITY}: workflow_call input \`${name}\` not found`);
|
|
138
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
139
|
+
if (lines[i].trim() === "") continue;
|
|
140
|
+
if (lines[i].match(/^(\s*)/)[1].length <= 6) break;
|
|
141
|
+
const d = lines[i].match(/^\s*default:\s*(.+)$/);
|
|
142
|
+
if (d) return d[1].trim();
|
|
143
|
+
}
|
|
144
|
+
return assert.fail(`${QUALITY}: input \`${name}\` has no default`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The `key:` / `restore-keys:` templates of the cache step. */
|
|
148
|
+
function cacheKeys() {
|
|
149
|
+
const block = stepBlock("e2e", "Cache Playwright browsers");
|
|
150
|
+
const key = block.match(/^\s*key:\s*(.+)$/m);
|
|
151
|
+
assert.ok(key, `${QUALITY}: the cache step has no \`key:\``);
|
|
152
|
+
const restore = block.match(/^\s*restore-keys:\s*\|\s*\n\s*(.+)$/m);
|
|
153
|
+
assert.ok(restore, `${QUALITY}: the cache step has no \`restore-keys:\``);
|
|
154
|
+
return { key: key[1].trim(), restoreKey: restore[1].trim() };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolve a key template for one salt value, leaving every other `${{ … }}`
|
|
159
|
+
* placeholder untouched so the result is directly comparable to the pre-fix
|
|
160
|
+
* literal. Split/join rather than a constructed regex, for the SAST reason
|
|
161
|
+
* above.
|
|
162
|
+
*/
|
|
163
|
+
function resolveSalt(template, salt) {
|
|
164
|
+
return template
|
|
165
|
+
.split(`\${{ inputs.${SALT_INPUT} }}`)
|
|
166
|
+
.join(salt)
|
|
167
|
+
.split(`\${{ inputs['${SALT_INPUT}'] }}`)
|
|
168
|
+
.join(salt);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The dedented body of a step's `run: |` block, taken from the RAW workflow
|
|
173
|
+
* text so the guard executes what the runner executes.
|
|
174
|
+
*
|
|
175
|
+
* This is only sound while the block holds no `${{ }}` expression — the runner
|
|
176
|
+
* substitutes those before bash ever sees them, and there is no substituting
|
|
177
|
+
* them here. A dedicated test below pins that precondition rather than leaving
|
|
178
|
+
* it as a silent assumption.
|
|
179
|
+
*/
|
|
180
|
+
function runScript(job, stepName) {
|
|
181
|
+
const lines = stepBlock(job, stepName, text).split("\n");
|
|
182
|
+
const start = lines.findIndex((l) => l.trim() === "run: |");
|
|
183
|
+
assert.notEqual(start, -1, `${QUALITY}: step \`${stepName}\` has no \`run: |\` block`);
|
|
184
|
+
const body = lines.slice(start + 1);
|
|
185
|
+
assert.ok(body.length > 0, `${QUALITY}: step \`${stepName}\` has an empty \`run:\` block`);
|
|
186
|
+
const indent = body[0].match(/^(\s*)/)[1].length;
|
|
187
|
+
return body.map((l) => l.slice(indent)).join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Run a script under the runner's own shell invocation, in a throwaway cwd.
|
|
192
|
+
*
|
|
193
|
+
* GitHub executes `shell: bash` as `bash --noprofile --norc -eo pipefail
|
|
194
|
+
* {0}` — the `-e` is the whole reason the pre-fix step could kill the tier, so
|
|
195
|
+
* the guard reproduces the flags exactly. `cwd` is passed to `spawnSync` and
|
|
196
|
+
* `process.chdir` is never called: this file's later tests read
|
|
197
|
+
* `docs/reusable-workflows.md` by a RELATIVE path, and a leaked cwd would fail
|
|
198
|
+
* them for a reason that has nothing to do with the change under test.
|
|
199
|
+
*/
|
|
200
|
+
function runInDir(script, cwd) {
|
|
201
|
+
const outPath = join(cwd, "github-output");
|
|
202
|
+
writeFileSync(outPath, "");
|
|
203
|
+
const scriptPath = join(cwd, "step.sh");
|
|
204
|
+
writeFileSync(scriptPath, script);
|
|
205
|
+
const res = spawnSync("bash", ["--noprofile", "--norc", "-eo", "pipefail", scriptPath], {
|
|
206
|
+
cwd,
|
|
207
|
+
env: { ...process.env, GITHUB_OUTPUT: outPath },
|
|
208
|
+
encoding: "utf8",
|
|
209
|
+
});
|
|
210
|
+
const outputs = new Map();
|
|
211
|
+
for (const line of readFileSync(outPath, "utf8").split("\n")) {
|
|
212
|
+
const eq = line.indexOf("=");
|
|
213
|
+
if (eq > 0) outputs.set(line.slice(0, eq), line.slice(eq + 1));
|
|
214
|
+
}
|
|
215
|
+
return { status: res.status, stderr: res.stderr, outputs };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A temp directory, removed however the callback exits. */
|
|
219
|
+
function withTempDir(fn) {
|
|
220
|
+
const dir = mkdtempSync(join(tmpdir(), "pw-version-"));
|
|
221
|
+
try {
|
|
222
|
+
return fn(dir);
|
|
223
|
+
} finally {
|
|
224
|
+
rmSync(dir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Plant a resolvable `@playwright/test` under `dir`.
|
|
230
|
+
*
|
|
231
|
+
* The `exports` map matters: the real package restricts subpath access and
|
|
232
|
+
* lists `"./package.json"` explicitly. Without it here, a resolution strategy
|
|
233
|
+
* that is ILLEGAL against the real package would still pass this guard.
|
|
234
|
+
*/
|
|
235
|
+
function plantPlaywright(dir, version) {
|
|
236
|
+
const pkgDir = join(dir, "node_modules", "@playwright", "test");
|
|
237
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
238
|
+
writeFileSync(
|
|
239
|
+
join(pkgDir, "package.json"),
|
|
240
|
+
JSON.stringify({
|
|
241
|
+
name: "@playwright/test",
|
|
242
|
+
version,
|
|
243
|
+
exports: { "./package.json": "./package.json" },
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// The contract
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
test("no step gates on the Playwright cache-hit output", () => {
|
|
253
|
+
// AC-1, the defect itself. `cache-hit` is true whenever an entry EXISTS
|
|
254
|
+
// under the key — it is not a statement about the entry's contents, so no
|
|
255
|
+
// step may treat it as one.
|
|
256
|
+
assert.doesNotMatch(
|
|
257
|
+
code,
|
|
258
|
+
/steps\.playwright-cache\.outputs\.cache-hit/,
|
|
259
|
+
"a cache-hit gate is back: a partial cache would again be fatal rather than repaired",
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("the browser install runs unconditionally with --with-deps", () => {
|
|
264
|
+
// AC-1. Unconditional is the whole fix — an `if:` of ANY shape here
|
|
265
|
+
// reintroduces a path where a bad restore is never repaired.
|
|
266
|
+
const block = stepBlock("e2e", "Install Playwright browsers");
|
|
267
|
+
assert.match(block, /run:\s*pnpm exec playwright install --with-deps/);
|
|
268
|
+
assert.doesNotMatch(
|
|
269
|
+
block,
|
|
270
|
+
/^\s*if:/m,
|
|
271
|
+
"the install step must carry no condition — see this file's header",
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("the cache-hit-only OS-dependency step is gone", () => {
|
|
276
|
+
// AC-1. Its only reason to exist was the hit branch; leaving it behind
|
|
277
|
+
// would run `install-deps` twice on every run.
|
|
278
|
+
assert.doesNotMatch(
|
|
279
|
+
jobBlock("e2e"),
|
|
280
|
+
/- name: Install browser OS dependencies/,
|
|
281
|
+
"the split OS-dependency step is redundant once the install is unconditional",
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test(`the ${SALT_INPUT} input exists with a literal default`, () => {
|
|
286
|
+
// AC-2. A `workflow_call` default may not hold an expression: GitHub
|
|
287
|
+
// resolves defaults during interface validation, before any context exists,
|
|
288
|
+
// and check-workflow-portability.mjs Rule 2 rejects it outright.
|
|
289
|
+
const value = inputDefault(SALT_INPUT);
|
|
290
|
+
assert.doesNotMatch(value, /\$\{\{/, "a workflow_call default may not hold an expression");
|
|
291
|
+
assert.equal(value, "''");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("the cache key interpolates the salt input", () => {
|
|
295
|
+
// AC-2. Declared-but-unread is the failure mode that makes the escape hatch
|
|
296
|
+
// silently inert.
|
|
297
|
+
const { key } = cacheKeys();
|
|
298
|
+
assert.match(key, /inputs\./, "the key does not read any input");
|
|
299
|
+
assert.notEqual(
|
|
300
|
+
resolveSalt(key, "probe"),
|
|
301
|
+
key,
|
|
302
|
+
`the key does not interpolate \`inputs.${SALT_INPUT}\``,
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("the default salt leaves the cache key byte-for-byte unchanged", () => {
|
|
307
|
+
// AC-3 — the compatibility contract. If this drifts, every consumer's warm
|
|
308
|
+
// ~460 MiB cache is silently orphaned the moment they adopt the release,
|
|
309
|
+
// which is a worse outage than the bug being fixed.
|
|
310
|
+
const { key, restoreKey } = cacheKeys();
|
|
311
|
+
assert.equal(resolveSalt(key, ""), PRE_FIX_KEY);
|
|
312
|
+
assert.equal(resolveSalt(restoreKey, ""), PRE_FIX_RESTORE_KEY);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("distinct salts produce distinct cache keys", () => {
|
|
316
|
+
// AC-4 — the escape hatch actually escapes. A salt that collapses into the
|
|
317
|
+
// same key (interpolated into a comment, or into a segment the key does not
|
|
318
|
+
// use) would leave the operator back at deleting caches by hand.
|
|
319
|
+
const { key } = cacheKeys();
|
|
320
|
+
const base = resolveSalt(key, "");
|
|
321
|
+
const bumped = resolveSalt(key, "-v2");
|
|
322
|
+
const bumpedAgain = resolveSalt(key, "-v3");
|
|
323
|
+
assert.notEqual(bumped, base, "a non-empty salt must not resolve to the default key");
|
|
324
|
+
assert.notEqual(bumpedAgain, bumped, "two different salts must not collide");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("the salt does not disturb the restore-keys prefix", () => {
|
|
328
|
+
// A deliberate asymmetry, not an oversight: the prefix fallback must keep
|
|
329
|
+
// matching older entries so a salt bump still gets a WARM start. The
|
|
330
|
+
// unconditional install then fills whatever the old entry was missing, and
|
|
331
|
+
// because a prefix (non-exact) restore leaves `cache-hit` false, the
|
|
332
|
+
// post-job save writes a complete tree under the NEW key. That is what
|
|
333
|
+
// completes the escape — one run, no manual cache deletion.
|
|
334
|
+
const { restoreKey } = cacheKeys();
|
|
335
|
+
assert.equal(
|
|
336
|
+
resolveSalt(restoreKey, "-v2"),
|
|
337
|
+
PRE_FIX_RESTORE_KEY,
|
|
338
|
+
"restore-keys must stay salt-free so a bumped key still warm-starts",
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("the documented input row states the default and the escape semantics", () => {
|
|
343
|
+
// AC-6. The row is the consumer-facing contract for a knob whose entire
|
|
344
|
+
// purpose is manual operator use — undocumented, it may as well not exist.
|
|
345
|
+
const docs = readFileSync("docs/reusable-workflows.md", "utf8");
|
|
346
|
+
const rows = docs
|
|
347
|
+
.split("\n")
|
|
348
|
+
.filter((l) => l.startsWith(`| \`${SALT_INPUT}\``) && /\|\s*string\s*\|/.test(l));
|
|
349
|
+
assert.equal(rows.length, 1, "expected exactly one documented input row");
|
|
350
|
+
assert.match(rows[0], /`''`/, "row does not state the empty-string default");
|
|
351
|
+
assert.match(rows[0], /cache/i, "row does not explain what the salt affects");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Version resolution (Story #400)
|
|
356
|
+
//
|
|
357
|
+
// The pre-fix step ran `node -e "…require('./node_modules/@playwright/test/…')"`
|
|
358
|
+
// as a bare `VAR=$(…)` assignment. Under `bash -eo pipefail` that propagates
|
|
359
|
+
// the substitution's exit status, so on a consumer whose ROOT node_modules
|
|
360
|
+
// lacks the package — pnpm's isolated layout only symlinks a root DIRECT
|
|
361
|
+
// dependency, so a workspace-owned Playwright has no such path — `set -e`
|
|
362
|
+
// killed the step and took the whole e2e tier with it. A step that exists only
|
|
363
|
+
// to LABEL a cache key must never be able to do that.
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
test("version resolution uses a bare specifier, not a hardcoded root path", () => {
|
|
367
|
+
const block = stepBlock("e2e", "Resolve Playwright version");
|
|
368
|
+
assert.doesNotMatch(
|
|
369
|
+
block,
|
|
370
|
+
/\.\/node_modules\/@playwright\/test/,
|
|
371
|
+
"a hardcoded root path is back: a workspace-owned Playwright would not resolve",
|
|
372
|
+
);
|
|
373
|
+
assert.match(
|
|
374
|
+
block,
|
|
375
|
+
/require\((['"])@playwright\/test\/package\.json\1\)/,
|
|
376
|
+
"resolution must go through the bare specifier, which walks node_modules",
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("the resolve step's run block holds no workflow expression", () => {
|
|
381
|
+
// The precondition for executing this step in the tests below: the runner
|
|
382
|
+
// substitutes `${{ }}` before bash sees it, and nothing substitutes it here.
|
|
383
|
+
// Threading an input into the block would leave the guard asserting against
|
|
384
|
+
// a string that never runs.
|
|
385
|
+
assert.doesNotMatch(
|
|
386
|
+
runScript("e2e", "Resolve Playwright version"),
|
|
387
|
+
/\$\{\{/,
|
|
388
|
+
"keep the run block expression-free so the guard executes the runner's text",
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("the sentinel is assigned as a fixed literal", () => {
|
|
393
|
+
// Invariant 2 (see SENTINEL above). Assert the SHAPE of the assignment, not
|
|
394
|
+
// merely that the step stopped failing — `PW_VERSION=$(date +%F)` would pass
|
|
395
|
+
// a stability check across two runs in the same second and still rekey the
|
|
396
|
+
// cache on every push.
|
|
397
|
+
const script = runScript("e2e", "Resolve Playwright version");
|
|
398
|
+
const assignments = script
|
|
399
|
+
.split("\n")
|
|
400
|
+
.map((l) => l.trim())
|
|
401
|
+
.filter((l) => !l.startsWith("#") && l.includes(`=${SENTINEL}`));
|
|
402
|
+
assert.equal(assignments.length, 1, `expected exactly one \`=${SENTINEL}\` assignment`);
|
|
403
|
+
const [assignment] = assignments;
|
|
404
|
+
assert.match(assignment, /^[A-Za-z_][A-Za-z0-9_]*=unresolved$/, "the sentinel must be a literal");
|
|
405
|
+
assert.ok(!assignment.includes("$("), "the sentinel must not be command-substituted");
|
|
406
|
+
assert.ok(!assignment.includes("${"), "the sentinel must not be parameter-expanded");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("an unresolvable @playwright/test yields the sentinel instead of failing the tier", () => {
|
|
410
|
+
// The defect itself. A temp dir has no `node_modules` anywhere up its tree,
|
|
411
|
+
// which is exactly the consumer shape that lost the tier.
|
|
412
|
+
const script = runScript("e2e", "Resolve Playwright version");
|
|
413
|
+
withTempDir((dir) => {
|
|
414
|
+
const { status, outputs, stderr } = runInDir(script, dir);
|
|
415
|
+
assert.equal(status, 0, `the step must not fail the tier; stderr:\n${stderr}`);
|
|
416
|
+
assert.equal(outputs.get("version"), SENTINEL);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("the sentinel is stable across runs, so the cache key does not churn", () => {
|
|
421
|
+
// Invariant 2 again, from the outside: a value that varies run to run mints a
|
|
422
|
+
// fresh key every time and permanently defeats the cache.
|
|
423
|
+
const script = runScript("e2e", "Resolve Playwright version");
|
|
424
|
+
const read = () => withTempDir((dir) => runInDir(script, dir).outputs.get("version"));
|
|
425
|
+
const first = read();
|
|
426
|
+
// Pin the value, not just its stability: two runs that both emit NOTHING are
|
|
427
|
+
// trivially equal, which would let a step that never writes the output pass.
|
|
428
|
+
assert.equal(first, SENTINEL);
|
|
429
|
+
assert.equal(read(), first);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("a resolvable @playwright/test produces the pre-fix cache key exactly", () => {
|
|
433
|
+
// The compatibility contract, end to end: what the step actually emits, fed
|
|
434
|
+
// through the real key template at the default salt, must equal the key
|
|
435
|
+
// consumers' warm caches already sit under.
|
|
436
|
+
const script = runScript("e2e", "Resolve Playwright version");
|
|
437
|
+
const version = "1.61.1";
|
|
438
|
+
const resolved = withTempDir((dir) => {
|
|
439
|
+
plantPlaywright(dir, version);
|
|
440
|
+
const { status, outputs, stderr } = runInDir(script, dir);
|
|
441
|
+
assert.equal(status, 0, `stderr:\n${stderr}`);
|
|
442
|
+
return outputs.get("version");
|
|
443
|
+
});
|
|
444
|
+
assert.equal(resolved, version, "the step must report the resolved package's version");
|
|
445
|
+
|
|
446
|
+
const { key } = cacheKeys();
|
|
447
|
+
const withVersion = resolveSalt(key, "")
|
|
448
|
+
.split("${{ steps.pw-version.outputs.version }}")
|
|
449
|
+
.join(resolved);
|
|
450
|
+
assert.equal(withVersion, `playwright-\${{ runner.os }}-${version}`);
|
|
451
|
+
});
|
|
@@ -46,9 +46,15 @@ jobs:
|
|
|
46
46
|
# the checked-in config; a workflow_dispatch preview run can pass
|
|
47
47
|
# apply:'false' instead to dry-run without writing.
|
|
48
48
|
apply: ${{ github.event_name == 'push' && 'true' || 'false' }}
|
|
49
|
-
# Frozen secret surface
|
|
50
|
-
#
|
|
51
|
-
#
|
|
49
|
+
# Frozen secret surface. BETTERSTACK_API_TOKEN is optional on the shared
|
|
50
|
+
# side — absent, the apply takes the documented graceful-degradation
|
|
51
|
+
# (skip-with-notice) path and raises a warning annotation.
|
|
52
|
+
#
|
|
53
|
+
# UPTIME_ALERT_EMAIL is deprecated and deliberately not passed here:
|
|
54
|
+
# Better Stack's monitor `email` field is a boolean switch, not a
|
|
55
|
+
# recipient, so the address never routed alerts. Set a monitor entry's
|
|
56
|
+
# `policyId` (escalation policy) to control who is alerted. The shared
|
|
57
|
+
# workflow still declares the secret, so an existing caller that passes
|
|
58
|
+
# it keeps compiling.
|
|
52
59
|
secrets:
|
|
53
60
|
BETTERSTACK_API_TOKEN: ${{ secrets.BETTERSTACK_API_TOKEN }}
|
|
54
|
-
UPTIME_ALERT_EMAIL: ${{ secrets.UPTIME_ALERT_EMAIL }}
|