mandrel-platform 0.17.2 → 0.18.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 +220 -14
- package/config/commitlint.base.mjs +36 -0
- package/config/repo-settings.schema.json +78 -0
- package/package.json +2 -1
- package/scripts/apply-uptime-monitors.mjs +378 -0
- package/scripts/apply-uptime-monitors.test.mjs +372 -0
- package/scripts/check-repo-settings.mjs +363 -0
- package/scripts/check-repo-settings.test.mjs +320 -0
- package/scripts/check-required-contexts.mjs +247 -129
- package/scripts/check-required-contexts.test.mjs +137 -0
- package/scripts/check-ruleset.mjs +435 -0
- package/scripts/check-ruleset.test.mjs +439 -0
- package/scripts/check-wrangler-baseline.mjs +514 -0
- package/scripts/check-wrangler-baseline.test.mjs +454 -0
- package/scripts/platform-sync.mjs +533 -5
- package/scripts/platform-sync.test.mjs +477 -0
- package/templates/workflows/deploy-staging.yml +86 -0
- package/templates/workflows/uptime-apply.yml +54 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* apply-uptime-monitors.test.mjs — node:test suite for the shared Better
|
|
4
|
+
* Stack monitor schema + apply unit (Story #180).
|
|
5
|
+
*
|
|
6
|
+
* Exercises the pure config parser/validator, the pure diff, and the full
|
|
7
|
+
* apply orchestration against an injected Better Stack client seam — no real
|
|
8
|
+
* network calls. The CLI's skip-with-notice (no token) and config-validation
|
|
9
|
+
* paths are exercised via `execFileSync` against the real script, capturing
|
|
10
|
+
* stdout/exit code.
|
|
11
|
+
*
|
|
12
|
+
* Run: node scripts/apply-uptime-monitors.test.mjs (or `node --test scripts/`)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { execFileSync, execFile } from "node:child_process";
|
|
17
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { test } from "node:test";
|
|
23
|
+
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_CHECK_FREQUENCY_SECONDS,
|
|
28
|
+
parseMonitorConfig,
|
|
29
|
+
diffMonitors,
|
|
30
|
+
createBetterStackClient,
|
|
31
|
+
applyMonitorConfig,
|
|
32
|
+
} from "./apply-uptime-monitors.mjs";
|
|
33
|
+
|
|
34
|
+
const CLI = fileURLToPath(new URL("./apply-uptime-monitors.mjs", import.meta.url));
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// parseMonitorConfig
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
test("parseMonitorConfig accepts a bare array of monitor entries", () => {
|
|
41
|
+
const { monitors } = parseMonitorConfig([{ url: "https://api.example.com/health" }]);
|
|
42
|
+
assert.equal(monitors.length, 1);
|
|
43
|
+
assert.equal(monitors[0].url, "https://api.example.com/health");
|
|
44
|
+
assert.equal(monitors[0].name, "api.example.com");
|
|
45
|
+
assert.equal(monitors[0].alertEmail, null);
|
|
46
|
+
assert.equal(monitors[0].checkFrequency, DEFAULT_CHECK_FREQUENCY_SECONDS);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("parseMonitorConfig accepts a wrapped { monitors: [...] } object", () => {
|
|
50
|
+
const { monitors } = parseMonitorConfig({
|
|
51
|
+
monitors: [{ url: "https://x.example.com", name: "x", alertEmail: "a@b.com", checkFrequency: 60 }],
|
|
52
|
+
});
|
|
53
|
+
assert.deepEqual(monitors, [
|
|
54
|
+
{ url: "https://x.example.com", name: "x", alertEmail: "a@b.com", checkFrequency: 60 },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("parseMonitorConfig rejects a non-array, non-{monitors} shape", () => {
|
|
59
|
+
assert.throws(() => parseMonitorConfig({ foo: "bar" }), /must be a JSON array/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("parseMonitorConfig rejects an entry missing a valid url", () => {
|
|
63
|
+
assert.throws(() => parseMonitorConfig([{ url: "not-a-url" }]), /valid http\(s\) "url"/);
|
|
64
|
+
assert.throws(() => parseMonitorConfig([{}]), /valid http\(s\) "url"/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("parseMonitorConfig rejects a non-positive-integer checkFrequency", () => {
|
|
68
|
+
assert.throws(
|
|
69
|
+
() => parseMonitorConfig([{ url: "https://x.example.com", checkFrequency: -5 }]),
|
|
70
|
+
/"checkFrequency" must be a positive integer/
|
|
71
|
+
);
|
|
72
|
+
assert.throws(
|
|
73
|
+
() => parseMonitorConfig([{ url: "https://x.example.com", checkFrequency: "30" }]),
|
|
74
|
+
/"checkFrequency" must be a positive integer/
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("parseMonitorConfig rejects non-string name/alertEmail", () => {
|
|
79
|
+
assert.throws(() => parseMonitorConfig([{ url: "https://x.example.com", name: 5 }]), /"name" must be a string/);
|
|
80
|
+
assert.throws(
|
|
81
|
+
() => parseMonitorConfig([{ url: "https://x.example.com", alertEmail: 5 }]),
|
|
82
|
+
/"alertEmail" must be a string/
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// diffMonitors
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
test("diffMonitors classifies a brand-new url as toCreate", () => {
|
|
91
|
+
const desired = [{ url: "https://new.example.com", name: "new", alertEmail: null, checkFrequency: 30 }];
|
|
92
|
+
const { toCreate, toUpdate, unchanged } = diffMonitors(desired, []);
|
|
93
|
+
assert.equal(toCreate.length, 1);
|
|
94
|
+
assert.equal(toUpdate.length, 0);
|
|
95
|
+
assert.equal(unchanged.length, 0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("diffMonitors classifies a matching, identical url as unchanged", () => {
|
|
99
|
+
const desired = [{ url: "https://x.example.com", name: "x", alertEmail: null, checkFrequency: 30 }];
|
|
100
|
+
const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
|
|
101
|
+
const { toCreate, toUpdate, unchanged } = diffMonitors(desired, live);
|
|
102
|
+
assert.equal(toCreate.length, 0);
|
|
103
|
+
assert.equal(toUpdate.length, 0);
|
|
104
|
+
assert.deepEqual(unchanged, ["https://x.example.com"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("diffMonitors classifies a url with a drifted name/frequency as toUpdate", () => {
|
|
108
|
+
const desired = [{ url: "https://x.example.com", name: "renamed", alertEmail: null, checkFrequency: 60 }];
|
|
109
|
+
const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
|
|
110
|
+
const { toUpdate } = diffMonitors(desired, live);
|
|
111
|
+
assert.equal(toUpdate.length, 1);
|
|
112
|
+
assert.equal(toUpdate[0].id, "1");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("diffMonitors url matching is trailing-slash and case insensitive", () => {
|
|
116
|
+
const desired = [{ url: "https://X.example.com/", name: "x", alertEmail: null, checkFrequency: 30 }];
|
|
117
|
+
const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
|
|
118
|
+
const { toCreate, unchanged } = diffMonitors(desired, live);
|
|
119
|
+
assert.equal(toCreate.length, 0);
|
|
120
|
+
assert.deepEqual(unchanged, ["https://X.example.com/"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("diffMonitors never proposes deleting a live monitor absent from desired (additive apply only)", () => {
|
|
124
|
+
const desired = [{ url: "https://kept.example.com", name: "kept", alertEmail: null, checkFrequency: 30 }];
|
|
125
|
+
const live = [
|
|
126
|
+
{ id: "1", url: "https://kept.example.com", name: "kept", checkFrequency: 30 },
|
|
127
|
+
{ id: "2", url: "https://hand-added.example.com", name: "manual" },
|
|
128
|
+
];
|
|
129
|
+
const result = diffMonitors(desired, live);
|
|
130
|
+
assert.ok(!("toDelete" in result), "diff result carries no deletion bucket at all");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// createBetterStackClient (injected fetch seam)
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function fakeFetch(responses) {
|
|
138
|
+
const calls = [];
|
|
139
|
+
return {
|
|
140
|
+
calls,
|
|
141
|
+
fetchImpl: async (url, init) => {
|
|
142
|
+
calls.push({ url, method: init?.method ?? "GET", body: init?.body });
|
|
143
|
+
const key = `${init?.method ?? "GET"} ${url}`;
|
|
144
|
+
const entry = responses[key];
|
|
145
|
+
if (!entry) throw new Error(`unexpected fetch call: ${key}`);
|
|
146
|
+
return {
|
|
147
|
+
ok: entry.status < 400,
|
|
148
|
+
status: entry.status,
|
|
149
|
+
statusText: entry.statusText ?? "",
|
|
150
|
+
json: async () => entry.body,
|
|
151
|
+
text: async () => JSON.stringify(entry.body),
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
test("createBetterStackClient.listMonitors normalizes the Better Stack payload shape", async () => {
|
|
158
|
+
const { fetchImpl, calls } = fakeFetch({
|
|
159
|
+
"GET https://uptime.betterstack.com/api/v2/monitors": {
|
|
160
|
+
status: 200,
|
|
161
|
+
body: {
|
|
162
|
+
data: [
|
|
163
|
+
{
|
|
164
|
+
id: "42",
|
|
165
|
+
attributes: { url: "https://a.example.com", pronounceable_name: "a", check_frequency: 30, email: "a@b.com" },
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
const client = createBetterStackClient({ token: "tok", fetchImpl });
|
|
172
|
+
const monitors = await client.listMonitors();
|
|
173
|
+
assert.deepEqual(monitors, [
|
|
174
|
+
{ id: "42", url: "https://a.example.com", name: "a", checkFrequency: 30, alertEmail: "a@b.com" },
|
|
175
|
+
]);
|
|
176
|
+
assert.equal(calls[0].url, "https://uptime.betterstack.com/api/v2/monitors");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("createBetterStackClient surfaces a non-ok response as a thrown error", async () => {
|
|
180
|
+
const { fetchImpl } = fakeFetch({
|
|
181
|
+
"GET https://uptime.betterstack.com/api/v2/monitors": { status: 401, statusText: "Unauthorized", body: {} },
|
|
182
|
+
});
|
|
183
|
+
const client = createBetterStackClient({ token: "bad", fetchImpl });
|
|
184
|
+
await assert.rejects(() => client.listMonitors(), /401/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// applyMonitorConfig — full orchestration against an injected client
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
function fakeClient({ live = [] } = {}) {
|
|
192
|
+
const created = [];
|
|
193
|
+
const updated = [];
|
|
194
|
+
return {
|
|
195
|
+
created,
|
|
196
|
+
updated,
|
|
197
|
+
listMonitors: async () => live,
|
|
198
|
+
createMonitor: async (entry) => {
|
|
199
|
+
created.push(entry);
|
|
200
|
+
return { id: `new-${created.length}` };
|
|
201
|
+
},
|
|
202
|
+
updateMonitor: async (id, entry) => {
|
|
203
|
+
updated.push({ id, entry });
|
|
204
|
+
return { id };
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
test("applyMonitorConfig dry-run computes the plan without calling create/update", async () => {
|
|
210
|
+
const client = fakeClient({ live: [] });
|
|
211
|
+
const result = await applyMonitorConfig({
|
|
212
|
+
config: { monitors: [{ url: "https://x.example.com", name: "x", alertEmail: null, checkFrequency: 30 }] },
|
|
213
|
+
client,
|
|
214
|
+
dryRun: true,
|
|
215
|
+
});
|
|
216
|
+
assert.deepEqual(result.created, ["https://x.example.com"]);
|
|
217
|
+
assert.equal(result.dryRun, true);
|
|
218
|
+
assert.equal(client.created.length, 0, "dry-run must not call createMonitor");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("applyMonitorConfig --apply issues create for new monitors", async () => {
|
|
222
|
+
const client = fakeClient({ live: [] });
|
|
223
|
+
const result = await applyMonitorConfig({
|
|
224
|
+
config: { monitors: [{ url: "https://x.example.com", name: "x", alertEmail: null, checkFrequency: 30 }] },
|
|
225
|
+
client,
|
|
226
|
+
dryRun: false,
|
|
227
|
+
});
|
|
228
|
+
assert.deepEqual(result.created, ["https://x.example.com"]);
|
|
229
|
+
assert.equal(client.created.length, 1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("applyMonitorConfig falls back to defaultAlertEmail when an entry sets none", async () => {
|
|
233
|
+
const client = fakeClient({ live: [] });
|
|
234
|
+
await applyMonitorConfig({
|
|
235
|
+
config: { monitors: [{ url: "https://x.example.com", name: "x", alertEmail: null, checkFrequency: 30 }] },
|
|
236
|
+
client,
|
|
237
|
+
dryRun: false,
|
|
238
|
+
defaultAlertEmail: "oncall@example.com",
|
|
239
|
+
});
|
|
240
|
+
assert.equal(client.created[0].alertEmail, "oncall@example.com");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("applyMonitorConfig issues update for a drifted existing monitor", async () => {
|
|
244
|
+
const client = fakeClient({ live: [{ id: "1", url: "https://x.example.com", name: "old", checkFrequency: 30 }] });
|
|
245
|
+
const result = await applyMonitorConfig({
|
|
246
|
+
config: { monitors: [{ url: "https://x.example.com", name: "new", alertEmail: null, checkFrequency: 30 }] },
|
|
247
|
+
client,
|
|
248
|
+
dryRun: false,
|
|
249
|
+
});
|
|
250
|
+
assert.deepEqual(result.updated, ["https://x.example.com"]);
|
|
251
|
+
assert.equal(client.updated[0].id, "1");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// CLI — skip-with-notice and config-validation paths (real process spawn)
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
let tmpDir;
|
|
259
|
+
|
|
260
|
+
test("CLI skip-with-notice: no BETTERSTACK_API_TOKEN exits 0 with a notice, no crash", () => {
|
|
261
|
+
tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-"));
|
|
262
|
+
const configPath = join(tmpDir, "monitors.json");
|
|
263
|
+
writeFileSync(configPath, JSON.stringify([{ url: "https://x.example.com" }]));
|
|
264
|
+
const out = execFileSync("node", [CLI, "--config", configPath], {
|
|
265
|
+
encoding: "utf8",
|
|
266
|
+
env: { ...process.env, BETTERSTACK_API_TOKEN: "" },
|
|
267
|
+
});
|
|
268
|
+
assert.match(out, /skipping uptime-monitor apply/);
|
|
269
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("CLI exits non-zero on an invalid config file even with a token set", () => {
|
|
273
|
+
tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-"));
|
|
274
|
+
const configPath = join(tmpDir, "monitors.json");
|
|
275
|
+
writeFileSync(configPath, JSON.stringify({ not: "a monitor list" }));
|
|
276
|
+
assert.throws(() => {
|
|
277
|
+
execFileSync("node", [CLI, "--config", configPath], {
|
|
278
|
+
encoding: "utf8",
|
|
279
|
+
env: { ...process.env, BETTERSTACK_API_TOKEN: "tok" },
|
|
280
|
+
});
|
|
281
|
+
}, /Command failed/);
|
|
282
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Consumer-caller simulation — exercises the SAME invocation shape
|
|
287
|
+
// .github/workflows/uptime-apply.yml's "Apply uptime monitors" step
|
|
288
|
+
// constructs, end to end, against a real (mocked-transport) HTTP server.
|
|
289
|
+
// This is the in-repo stand-in for "the caller path is exercised": rather
|
|
290
|
+
// than only unit-testing the pure functions in isolation, this spins up a
|
|
291
|
+
// local HTTP server that speaks the Better Stack v2 monitors contract (list
|
|
292
|
+
// + create), then invokes the CLI exactly as the reusable workflow's `run:`
|
|
293
|
+
// step does (`node apply-uptime-monitors.mjs --config <path> [--dry-run|
|
|
294
|
+
// --apply]`, token/alert-email via env vars), against a monitor-config
|
|
295
|
+
// fixture shaped like the one templates/workflows/uptime-apply.yml points a
|
|
296
|
+
// real consumer at. See docs/reusable-workflows.md ("uptime-apply.yml") for
|
|
297
|
+
// the documented caller contract this simulates.
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
import { createServer } from "node:http";
|
|
301
|
+
|
|
302
|
+
function startFakeBetterStackServer() {
|
|
303
|
+
const requests = [];
|
|
304
|
+
let monitors = [];
|
|
305
|
+
const server = createServer((req, res) => {
|
|
306
|
+
const chunks = [];
|
|
307
|
+
req.on("data", (c) => chunks.push(c));
|
|
308
|
+
req.on("end", () => {
|
|
309
|
+
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null;
|
|
310
|
+
requests.push({ method: req.method, url: req.url, body });
|
|
311
|
+
res.setHeader("Content-Type", "application/json");
|
|
312
|
+
if (req.method === "GET" && req.url === "/monitors") {
|
|
313
|
+
res.writeHead(200);
|
|
314
|
+
res.end(JSON.stringify({ data: monitors }));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (req.method === "POST" && req.url === "/monitors") {
|
|
318
|
+
const id = String(monitors.length + 1);
|
|
319
|
+
monitors = [
|
|
320
|
+
...monitors,
|
|
321
|
+
{ id, attributes: { url: body.url, pronounceable_name: body.pronounceable_name, check_frequency: body.check_frequency, email: body.email } },
|
|
322
|
+
];
|
|
323
|
+
res.writeHead(201);
|
|
324
|
+
res.end(JSON.stringify({ data: { id } }));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
res.writeHead(404);
|
|
328
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
return { server, requests, getMonitors: () => monitors };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
test("consumer-caller simulation: end-to-end CLI invocation against a live (local) Better Stack server, dry-run", async () => {
|
|
335
|
+
const { server, requests } = startFakeBetterStackServer();
|
|
336
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
337
|
+
const { port } = server.address();
|
|
338
|
+
|
|
339
|
+
tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-e2e-"));
|
|
340
|
+
const configPath = join(tmpDir, "monitors.json");
|
|
341
|
+
// Same shape as templates/workflows/uptime-apply.yml's <MONITOR_CONFIG_PATH>
|
|
342
|
+
// consumer fixture (e.g. infra/uptime/monitors.json).
|
|
343
|
+
writeFileSync(
|
|
344
|
+
configPath,
|
|
345
|
+
JSON.stringify([{ url: "https://smoke.example.com/health", name: "smoke", checkFrequency: 60 }])
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// execFileAsync (not execFileSync): the fake server above runs IN THIS
|
|
349
|
+
// SAME PROCESS. A synchronous spawn would block this process's event loop
|
|
350
|
+
// while waiting on the child, which would in turn prevent the server's own
|
|
351
|
+
// request handler (which needs that same event loop) from ever running —
|
|
352
|
+
// a classic single-process self-deadlock. The async variant yields the
|
|
353
|
+
// event loop back so the server can answer the child's HTTP request.
|
|
354
|
+
const { stdout: out } = await execFileAsync(
|
|
355
|
+
"node",
|
|
356
|
+
[CLI, "--config", configPath, "--dry-run", "--alert-email", "oncall@example.com"],
|
|
357
|
+
{
|
|
358
|
+
encoding: "utf8",
|
|
359
|
+
env: { ...process.env, BETTERSTACK_API_TOKEN: "fake-token", BETTERSTACK_API_BASE_OVERRIDE: `http://127.0.0.1:${port}` },
|
|
360
|
+
}
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
await new Promise((resolve) => server.close(resolve));
|
|
364
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
365
|
+
|
|
366
|
+
// Dry-run must have hit the real (local) list endpoint to compute the plan
|
|
367
|
+
// ... this is only meaningful once the CLI honors a base-URL override, so
|
|
368
|
+
// this assertion also locks in that seam for future local/offline runs.
|
|
369
|
+
assert.match(out, /would create/);
|
|
370
|
+
assert.ok(requests.some((r) => r.method === "GET" && r.url === "/monitors"), "dry-run calls the list endpoint");
|
|
371
|
+
assert.ok(!requests.some((r) => r.method === "POST"), "dry-run never issues a create/update call");
|
|
372
|
+
});
|