@caelo-cms/provisioning 0.10.49 → 0.10.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/caddy/Caddyfile.production +4 -0
  2. package/caddy/Caddyfile.staging +4 -0
  3. package/dist/agent-db.js +4 -0
  4. package/dist/caddy-redirects.d.ts +15 -0
  5. package/dist/caddy-redirects.d.ts.map +1 -0
  6. package/dist/caddy-redirects.js +65 -0
  7. package/dist/caddy-redirects.js.map +1 -0
  8. package/dist/caddy.d.ts +22 -2
  9. package/dist/caddy.d.ts.map +1 -1
  10. package/dist/caddy.js +66 -17
  11. package/dist/caddy.js.map +1 -1
  12. package/dist/cli-routing.d.ts.map +1 -1
  13. package/dist/cli-routing.js +7 -0
  14. package/dist/cli-routing.js.map +1 -1
  15. package/dist/cli.js +248 -254
  16. package/dist/cli.js.map +1 -1
  17. package/dist/compose.d.ts +54 -2
  18. package/dist/compose.d.ts.map +1 -1
  19. package/dist/compose.js +86 -32
  20. package/dist/compose.js.map +1 -1
  21. package/dist/gcp-names.d.ts +12 -0
  22. package/dist/gcp-names.d.ts.map +1 -1
  23. package/dist/gcp-names.js +21 -0
  24. package/dist/gcp-names.js.map +1 -1
  25. package/dist/gcp-url-map.d.ts +27 -0
  26. package/dist/gcp-url-map.d.ts.map +1 -0
  27. package/dist/gcp-url-map.js +62 -0
  28. package/dist/gcp-url-map.js.map +1 -0
  29. package/dist/index.d.ts +0 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +0 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/infra-contract.d.ts +20 -1
  34. package/dist/infra-contract.d.ts.map +1 -1
  35. package/dist/infra-contract.js +39 -1
  36. package/dist/infra-contract.js.map +1 -1
  37. package/dist/public-role-isolation.d.ts +19 -0
  38. package/dist/public-role-isolation.d.ts.map +1 -0
  39. package/dist/public-role-isolation.js +69 -0
  40. package/dist/public-role-isolation.js.map +1 -0
  41. package/dist/release-contract.d.ts.map +1 -1
  42. package/dist/release-contract.js +3 -0
  43. package/dist/release-contract.js.map +1 -1
  44. package/dist/release-manifest.d.ts.map +1 -1
  45. package/dist/release-manifest.js +2 -1
  46. package/dist/release-manifest.js.map +1 -1
  47. package/dist/self-hosted-backup.d.ts +86 -0
  48. package/dist/self-hosted-backup.d.ts.map +1 -0
  49. package/dist/self-hosted-backup.js +350 -0
  50. package/dist/self-hosted-backup.js.map +1 -0
  51. package/dist/self-hosted-release.d.ts +12 -3
  52. package/dist/self-hosted-release.d.ts.map +1 -1
  53. package/dist/self-hosted-release.js +6 -4
  54. package/dist/self-hosted-release.js.map +1 -1
  55. package/dist/self-hosted-start.d.ts +50 -0
  56. package/dist/self-hosted-start.d.ts.map +1 -0
  57. package/dist/self-hosted-start.js +59 -0
  58. package/dist/self-hosted-start.js.map +1 -0
  59. package/dist/self-hosted-upgrade.d.ts +30 -0
  60. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  61. package/dist/self-hosted-upgrade.js +29 -7
  62. package/dist/self-hosted-upgrade.js.map +1 -1
  63. package/dist/tar-entries.d.ts +14 -0
  64. package/dist/tar-entries.d.ts.map +1 -0
  65. package/dist/tar-entries.js +104 -0
  66. package/dist/tar-entries.js.map +1 -0
  67. package/dist/upgrade/converge.d.ts +2 -2
  68. package/dist/upgrade/converge.d.ts.map +1 -1
  69. package/dist/upgrade/converge.js +44 -2
  70. package/dist/upgrade/converge.js.map +1 -1
  71. package/dist/upgrader-assess.d.ts.map +1 -1
  72. package/dist/upgrader-assess.js +2 -1
  73. package/dist/upgrader-assess.js.map +1 -1
  74. package/dist/url-map-redirects-access.d.ts +15 -0
  75. package/dist/url-map-redirects-access.d.ts.map +1 -0
  76. package/dist/url-map-redirects-access.js +81 -0
  77. package/dist/url-map-redirects-access.js.map +1 -0
  78. package/dist/wizard.d.ts.map +1 -1
  79. package/dist/wizard.js +3 -1
  80. package/dist/wizard.js.map +1 -1
  81. package/dist/wizards/gcp.d.ts.map +1 -1
  82. package/dist/wizards/gcp.js +45 -0
  83. package/dist/wizards/gcp.js.map +1 -1
  84. package/package.json +2 -2
  85. package/src/admin-memory.test.ts +2 -2
  86. package/src/caddy-redirects.docker.test.ts +255 -0
  87. package/src/caddy-redirects.test.ts +115 -0
  88. package/src/caddy-redirects.ts +68 -0
  89. package/src/caddy-validate.test.ts +119 -0
  90. package/src/caddy.test.ts +108 -0
  91. package/src/caddy.ts +89 -20
  92. package/src/cli-routing.test.ts +12 -0
  93. package/src/cli-routing.ts +7 -0
  94. package/src/cli.ts +299 -287
  95. package/src/compose.ts +106 -34
  96. package/src/gcp-names.ts +23 -0
  97. package/src/gcp-url-map.test.ts +150 -0
  98. package/src/gcp-url-map.ts +81 -0
  99. package/src/index.test.ts +18 -146
  100. package/src/index.ts +0 -9
  101. package/src/infra-contract.test.ts +39 -0
  102. package/src/infra-contract.ts +42 -1
  103. package/src/public-role-isolation.test.ts +89 -0
  104. package/src/public-role-isolation.ts +83 -0
  105. package/src/release-contract.ts +3 -0
  106. package/src/release-manifest.test.ts +12 -0
  107. package/src/release-manifest.ts +2 -1
  108. package/src/self-hosted-backup.test.ts +344 -0
  109. package/src/self-hosted-backup.ts +480 -0
  110. package/src/self-hosted-compose.test.ts +88 -4
  111. package/src/self-hosted-release.ts +22 -7
  112. package/src/self-hosted-start.test.ts +68 -0
  113. package/src/self-hosted-start.ts +123 -0
  114. package/src/self-hosted-upgrade.ts +35 -8
  115. package/src/stack-contract.test.ts +2 -2
  116. package/src/tar-entries.test.ts +52 -0
  117. package/src/tar-entries.ts +116 -0
  118. package/src/upgrade/converge.ts +51 -4
  119. package/src/upgrade/upgrade.test.ts +78 -1
  120. package/src/upgrader-assess.test.ts +22 -0
  121. package/src/upgrader-assess.ts +2 -1
  122. package/src/url-map-redirects-access.test.ts +88 -0
  123. package/src/url-map-redirects-access.ts +89 -0
  124. package/src/wizard.ts +3 -1
  125. package/src/wizards/gcp.ts +48 -0
  126. package/stacks/aws/index.ts +34 -2
  127. package/stacks/azure/index.ts +13 -0
  128. package/stacks/gcp/index.ts +24 -17
  129. package/stacks/gcp-firebase/index.ts +7 -0
  130. package/stacks/self-hosted/Pulumi.yaml +2 -2
  131. package/stacks/self-hosted/README.md +3 -3
  132. package/stacks/self-hosted/index.ts +45 -22
  133. package/dist/redirects-emit.d.ts +0 -52
  134. package/dist/redirects-emit.d.ts.map +0 -1
  135. package/dist/redirects-emit.js +0 -111
  136. package/dist/redirects-emit.js.map +0 -1
  137. package/src/redirects-emit.ts +0 -175
@@ -0,0 +1,344 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readdirSync,
9
+ rmSync,
10
+ statSync,
11
+ symlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { COMPOSE_APP_SERVICES } from "./compose.js";
17
+ import {
18
+ BACKUP_FORMAT,
19
+ backupSelfHosted,
20
+ checkArchive,
21
+ checkArchiveMembers,
22
+ checkFilesTar,
23
+ restoreSelfHosted,
24
+ } from "./self-hosted-backup.js";
25
+ import {
26
+ bunHostRunner,
27
+ type CommandResult,
28
+ type HostRunner,
29
+ type HostRunOptions,
30
+ } from "./self-hosted-release.js";
31
+
32
+ let caeloDir: string;
33
+ let scratch: string;
34
+
35
+ beforeEach(() => {
36
+ scratch = mkdtempSync(join(tmpdir(), "caelo-backup-test-"));
37
+ caeloDir = join(scratch, ".caelo");
38
+ mkdirSync(caeloDir);
39
+ });
40
+ afterEach(() => rmSync(scratch, { recursive: true, force: true }));
41
+
42
+ /** Like the admin container's GNU tar: no macOS AppleDouble members. */
43
+ const NO_MAC = { env: { COPYFILE_DISABLE: "1" } };
44
+
45
+ interface Call {
46
+ readonly cmd: string;
47
+ readonly opts?: HostRunOptions;
48
+ }
49
+
50
+ /** A real tar of `files` (path → content, `->target` makes a symlink), built in `scratch`. */
51
+ async function makeTar(files: Record<string, string>, out: string, gzip = false): Promise<void> {
52
+ const root = mkdtempSync(join(scratch, "tar-src-"));
53
+ for (const [path, content] of Object.entries(files)) {
54
+ const full = join(root, path);
55
+ mkdirSync(join(full, ".."), { recursive: true });
56
+ if (content.startsWith("->")) symlinkSync(content.slice(2), full);
57
+ else writeFileSync(full, content);
58
+ }
59
+ const r = await bunHostRunner.run(["tar", gzip ? "-czf" : "-cf", out, "-C", root, "."], NO_MAC);
60
+ if (r.exitCode !== 0) throw new Error(r.stderr);
61
+ }
62
+
63
+ /** A files.tar like the admin container writes (members `srv/caelo/…`, `data/media/…`). */
64
+ async function contentTar(out: string): Promise<void> {
65
+ const root = mkdtempSync(join(scratch, "content-"));
66
+ mkdirSync(join(root, "srv/caelo/output/production/current"), { recursive: true });
67
+ mkdirSync(join(root, "data/media"), { recursive: true });
68
+ writeFileSync(join(root, "srv/caelo/output/production/current/index.html"), "<h1>home</h1>");
69
+ writeFileSync(join(root, "data/media/logo.png"), "png");
70
+ const r = await bunHostRunner.run(
71
+ ["tar", "-cf", out, "-C", root, "srv/caelo", "data/media"],
72
+ NO_MAC,
73
+ );
74
+ if (r.exitCode !== 0) throw new Error(r.stderr);
75
+ }
76
+
77
+ /**
78
+ * Docker is faked (every call recorded, dumps written as stand-in bytes,
79
+ * the file archive as a real tar); host `tar` runs for real, so the archive
80
+ * round-trip and its checks are the real ones.
81
+ */
82
+ function runner(fail?: (cmd: string) => boolean): HostRunner & { calls: Call[] } {
83
+ const calls: Call[] = [];
84
+ return {
85
+ calls,
86
+ async run(argv, opts): Promise<CommandResult> {
87
+ if (argv[0] === "tar") return bunHostRunner.run(argv, opts);
88
+ const cmd = argv.join(" ").replace(`docker compose -f ${caeloDir}/docker-compose.yml `, "");
89
+ calls.push({ cmd, ...(opts ? { opts } : {}) });
90
+ if (fail?.(cmd)) return { exitCode: 1, stdout: "", stderr: "boom" };
91
+ if (opts?.stdoutPath) {
92
+ if (cmd.includes("--entrypoint tar")) await contentTar(opts.stdoutPath);
93
+ else writeFileSync(opts.stdoutPath, `stand-in for: ${cmd}`);
94
+ }
95
+ return { exitCode: 0, stdout: "", stderr: "" };
96
+ },
97
+ };
98
+ }
99
+
100
+ const AT = new Date("2026-10-14T12:00:00.000Z");
101
+ const quiet = { log: () => {}, now: () => AT };
102
+ const probe = (cmds: string[]) => cmds.map((c) => c.replace(/ -e .*/, " -e <probe>"));
103
+
104
+ describe("backupSelfHosted", () => {
105
+ it("quiesces the services, dumps, archives site + media, writes one mode-600 .tar.gz, restarts", async () => {
106
+ const r = runner();
107
+ const to = join(scratch, "b.tar.gz");
108
+ const out = await backupSelfHosted(to, "0.10.49", { caeloDir, runner: r, ...quiet });
109
+ expect(out).toEqual({ ok: true, path: to });
110
+ expect(probe(r.calls.map((c) => c.cmd))).toEqual([
111
+ "exec -T postgres pg_isready -U caelo",
112
+ "stop caelo-admin caelo-gateway",
113
+ "exec -T postgres pg_dumpall -U caelo --globals-only",
114
+ "exec -T postgres pg_dump -U caelo -Fc cms_admin",
115
+ "exec -T postgres pg_dump -U caelo -Fc cms_public",
116
+ "run --rm --no-deps -T --entrypoint tar caelo-admin -C / -cf - srv/caelo data/media",
117
+ "up -d caelo-admin caelo-gateway",
118
+ "exec -T caelo-admin bun -e <probe>",
119
+ "exec -T caelo-gateway bun -e <probe>",
120
+ ]);
121
+ expect(statSync(to).mode & 0o777).toBe(0o600);
122
+ // The scratch dir is gone; only the archive remains.
123
+ expect(readdirSync(join(caeloDir, "backups"))).toEqual([]);
124
+ expect(checkArchiveMembers(to)).toEqual({ ok: true });
125
+
126
+ const unpacked = join(scratch, "unpacked");
127
+ mkdirSync(unpacked);
128
+ await bunHostRunner.run(["tar", "-xzf", to, "-C", unpacked]);
129
+ const checked = checkArchive(unpacked);
130
+ expect(checked.ok).toBe(true);
131
+ if (checked.ok) {
132
+ expect(checked.manifest).toEqual({
133
+ format: BACKUP_FORMAT,
134
+ caeloVersion: "0.10.49",
135
+ createdAt: AT.toISOString(),
136
+ });
137
+ }
138
+ expect(existsSync(join(unpacked, "db", "globals.sql"))).toBe(true);
139
+ });
140
+
141
+ it("starts the services again when the backup itself fails", async () => {
142
+ const r = runner((c) => c.includes("pg_dump -U caelo -Fc cms_public"));
143
+ const out = await backupSelfHosted(join(scratch, "b.tar.gz"), "0.10.49", {
144
+ caeloDir,
145
+ runner: r,
146
+ ...quiet,
147
+ });
148
+ expect(out.ok).toBe(false);
149
+ expect(r.calls.map((c) => c.cmd)).toContain("up -d caelo-admin caelo-gateway");
150
+ });
151
+
152
+ it("refuses when Postgres is not running and changes nothing", async () => {
153
+ const r = runner((c) => c.includes("pg_isready"));
154
+ const to = join(scratch, "b.tar.gz");
155
+ const out = await backupSelfHosted(to, "0.10.49", { caeloDir, runner: r, ...quiet });
156
+ expect(out.ok).toBe(false);
157
+ if (!out.ok) expect(out.error).toContain("Postgres is not running");
158
+ expect(existsSync(to)).toBe(false);
159
+ expect(r.calls).toHaveLength(1);
160
+ });
161
+ });
162
+
163
+ /** A real backup archive made by backupSelfHosted with the fake docker. */
164
+ async function makeArchive(): Promise<string> {
165
+ const to = join(scratch, "b.tar.gz");
166
+ const made = await backupSelfHosted(to, "0.10.49", { caeloDir, runner: runner(), ...quiet });
167
+ if (!made.ok) throw new Error(made.error);
168
+ return to;
169
+ }
170
+
171
+ function restoreDeps(r: HostRunner & { calls: Call[] }) {
172
+ return {
173
+ caeloDir,
174
+ runner: r,
175
+ ...quiet,
176
+ convergeRoles: async () => {
177
+ r.calls.push({ cmd: "<converge roles>" });
178
+ return { ok: true as const };
179
+ },
180
+ refreshCaddy: async () => {
181
+ r.calls.push({ cmd: "<re-render + reload Caddyfile>" });
182
+ return { ok: true as const };
183
+ },
184
+ };
185
+ }
186
+
187
+ const MIGRATE =
188
+ "run --rm --no-deps -T caelo-admin bun --bun /app/packages/migrations/src/migrate.ts";
189
+
190
+ describe("restoreSelfHosted", () => {
191
+ it("restores in order: stop, re-create + restore the DBs, migrate, files, start, health, Caddy", async () => {
192
+ const archive = await makeArchive();
193
+ const r = runner();
194
+ const out = await restoreSelfHosted(archive, restoreDeps(r));
195
+ expect(out.ok).toBe(true);
196
+ expect(probe(r.calls.map((c) => c.cmd))).toEqual([
197
+ "stop caelo-admin caelo-gateway",
198
+ "up -d --wait postgres",
199
+ "exec -T postgres psql -U caelo -d postgres -v ON_ERROR_STOP=1 -c DROP DATABASE IF EXISTS cms_admin WITH (FORCE) -c DROP DATABASE IF EXISTS cms_public WITH (FORCE)",
200
+ "<converge roles>",
201
+ "exec -T postgres pg_restore -U caelo -d cms_admin --exit-on-error --single-transaction",
202
+ "exec -T postgres pg_restore -U caelo -d cms_public --exit-on-error --single-transaction",
203
+ "<converge roles>",
204
+ `${MIGRATE} admin`,
205
+ `${MIGRATE} public`,
206
+ "run --rm --no-deps -T --entrypoint sh caelo-admin -c set -e; find /srv/caelo /data/media -mindepth 1 -delete; tar -C / -xf -",
207
+ "up -d --remove-orphans",
208
+ "exec -T caelo-admin bun -e <probe>",
209
+ "exec -T caelo-gateway bun -e <probe>",
210
+ "<re-render + reload Caddyfile>",
211
+ ]);
212
+ // pg_restore and the file restore read the archive's parts on stdin.
213
+ const stdin = r.calls.filter((c) => c.opts?.stdinPath).map((c) => c.opts?.stdinPath ?? "");
214
+ expect(stdin.map((p) => p.split("/").slice(-2).join("/"))).toEqual([
215
+ "db/cms_admin.dump",
216
+ "db/cms_public.dump",
217
+ expect.stringMatching(/files\.tar$/),
218
+ ]);
219
+ });
220
+
221
+ it("stops only services the Compose file defines — no orchestrator / runner (#707)", async () => {
222
+ const archive = await makeArchive();
223
+ const r = runner();
224
+ await restoreSelfHosted(archive, restoreDeps(r));
225
+ const stop = r.calls.find((c) => c.cmd.startsWith("stop "));
226
+ expect(stop?.cmd).toBe(`stop ${COMPOSE_APP_SERVICES.join(" ")}`);
227
+ expect(r.calls.some((c) => /orchestrator|runner/.test(c.cmd))).toBe(false);
228
+ });
229
+
230
+ it("leaves the install untouched when the archive is incomplete", async () => {
231
+ const archive = join(scratch, "partial.tar.gz");
232
+ await makeTar(
233
+ {
234
+ "manifest.json": JSON.stringify({
235
+ format: BACKUP_FORMAT,
236
+ caeloVersion: "x",
237
+ createdAt: "y",
238
+ }),
239
+ "db/cms_admin.dump": "x",
240
+ },
241
+ archive,
242
+ true,
243
+ );
244
+ const r = runner();
245
+ const out = await restoreSelfHosted(archive, restoreDeps(r));
246
+ expect(out).toEqual({
247
+ ok: false,
248
+ installChanged: false,
249
+ error:
250
+ "The archive is incomplete: db/cms_public.dump is missing or empty. Nothing on the install was changed.",
251
+ });
252
+ expect(r.calls).toEqual([]);
253
+ });
254
+
255
+ it("reports a failed pg_restore as a partial restore", async () => {
256
+ const archive = await makeArchive();
257
+ const out = await restoreSelfHosted(
258
+ archive,
259
+ restoreDeps(runner((c) => c.includes("pg_restore -U caelo -d cms_public"))),
260
+ );
261
+ expect(out.ok).toBe(false);
262
+ if (!out.ok) {
263
+ expect(out.installChanged).toBe(true);
264
+ expect(out.error).toContain("pg_restore of cms_public failed");
265
+ }
266
+ });
267
+ });
268
+
269
+ describe("archive checks run before anything is extracted (PR #755 review)", () => {
270
+ it("refuses an archive with a member a backup never writes, and extracts nothing", async () => {
271
+ const archive = join(scratch, "evil.tar.gz");
272
+ await makeTar(
273
+ {
274
+ "manifest.json": JSON.stringify({
275
+ format: BACKUP_FORMAT,
276
+ caeloVersion: "x",
277
+ createdAt: "y",
278
+ }),
279
+ "db/cms_admin.dump": "x",
280
+ "db/cms_public.dump": "x",
281
+ "files.tar": "x",
282
+ "etc/cron.d/backdoor": "* * * * * root sh",
283
+ },
284
+ archive,
285
+ true,
286
+ );
287
+ const members = checkArchiveMembers(archive);
288
+ expect(members.ok).toBe(false);
289
+ if (!members.ok) expect(members.error).toMatch(/unexpected member "\.\/etc/);
290
+ const r = runner();
291
+ const out = await restoreSelfHosted(archive, restoreDeps(r));
292
+ expect(out.ok).toBe(false);
293
+ expect(r.calls).toEqual([]);
294
+ expect(existsSync(join(caeloDir, "backups"))).toBe(true);
295
+ expect(readdirSync(join(caeloDir, "backups"))).toEqual([]);
296
+ });
297
+
298
+ it("refuses a symlink where a backup writes a file", async () => {
299
+ const archive = join(scratch, "link.tar.gz");
300
+ await makeTar({ "manifest.json": "->/etc/passwd" }, archive, true);
301
+ const r = checkArchiveMembers(archive);
302
+ expect(r.ok).toBe(false);
303
+ if (!r.ok) expect(r.error).toContain("is a symlink");
304
+ });
305
+
306
+ it("files.tar: only the site and media trees, no member below a symlink", async () => {
307
+ const ok = join(scratch, "ok.tar");
308
+ await contentTar(ok);
309
+ expect(checkFilesTar(ok)).toEqual({ ok: true });
310
+
311
+ const outside = join(scratch, "outside.tar");
312
+ await makeTar({ "app/server.js": "evil" }, outside);
313
+ const o = checkFilesTar(outside);
314
+ expect(o.ok).toBe(false);
315
+ if (!o.ok) expect(o.error).toContain("outside the site and media directories");
316
+
317
+ const viaLink = join(scratch, "via-link.tar");
318
+ const root = mkdtempSync(join(scratch, "via-"));
319
+ mkdirSync(join(root, "srv/caelo"), { recursive: true });
320
+ symlinkSync("/app", join(root, "srv/caelo/x"));
321
+ await bunHostRunner.run(["tar", "-cf", viaLink, "-C", root, "srv/caelo"], NO_MAC);
322
+ const l = checkFilesTar(viaLink);
323
+ expect(l.ok).toBe(false);
324
+ if (!l.ok) expect(l.error).toContain("links outside its directory");
325
+ });
326
+ });
327
+
328
+ describe("checkArchive", () => {
329
+ it("refuses a directory that is not a Caelo backup, and another format", () => {
330
+ const dir = join(scratch, "x");
331
+ mkdirSync(dir);
332
+ expect(checkArchive(dir)).toEqual({
333
+ ok: false,
334
+ error: "The archive has no manifest.json — it is not a Caelo backup.",
335
+ });
336
+ writeFileSync(
337
+ join(dir, "manifest.json"),
338
+ JSON.stringify({ format: 99, caeloVersion: "9.9.9", createdAt: "" }),
339
+ );
340
+ const r = checkArchive(dir);
341
+ expect(r.ok).toBe(false);
342
+ if (!r.ok) expect(r.error).toContain("format 99");
343
+ });
344
+ });