@uxf/scripts 11.123.0 → 11.124.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 CHANGED
@@ -19,7 +19,7 @@ Most binaries are already available in the UXF Docker images, so CI jobs can cal
19
19
  yarn add -D @uxf/scripts
20
20
  ```
21
21
 
22
- Requires **Node.js >= 24** (`engines.node`). No peer dependencies. Runtime dependencies (`axios`, `cheerio`, `dayjs`, `fast-glob`, `got`, `madge`, `robots-txt-parser`, `semver`, `yaml`, `yargs`) are bundled.
22
+ Requires **Node.js >= 24** (`engines.node`). No peer dependencies. Runtime dependencies (`axios`, `cheerio`, `dayjs`, `fast-glob`, `got`, `rev-dep`, `robots-txt-parser`, `semver`, `yaml`, `yargs`) are bundled.
23
23
 
24
24
  ## Commands
25
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxf/scripts",
3
- "version": "11.123.0",
3
+ "version": "11.124.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -30,12 +30,11 @@
30
30
  "node": ">=24"
31
31
  },
32
32
  "dependencies": {
33
- "axios": "^1.19.0",
34
33
  "cheerio": "1.2.0",
35
34
  "dayjs": "^1.11.21",
36
35
  "fast-glob": "3.3.3",
37
36
  "got": "15.1.0",
38
- "madge": "8.0.0",
37
+ "rev-dep": "3.0.0",
39
38
  "robots-txt-parser": "2.0.3",
40
39
  "semver": "^7.8.5",
41
40
  "yaml": "2.9.0",
@@ -46,7 +45,7 @@
46
45
  "@types/react": "18.3.31",
47
46
  "@types/react-dom": "18.3.7",
48
47
  "@types/semver": "^7.7.1",
49
- "@uxf/core": "11.123.0",
50
- "@uxf/ui": "11.123.0"
48
+ "@uxf/core": "11.124.0",
49
+ "@uxf/ui": "11.124.0"
51
50
  }
52
51
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Drobná náhrada yargsu pro notifiery, které musí běžet v CI bez `npm install`
3
+ * — viz `docs/decisions/package-manager.md`. Ostatní skripty v balíčku yargs
4
+ * používají dál; tenhle helper řeší jen `--help` a jednu hodnotovou option.
5
+ */
6
+
7
+ /**
8
+ * @param {string[]} args
9
+ * @param {string[]} names např. `["-h", "--help"]`
10
+ */
11
+ function hasFlag(args, names) {
12
+ return args.some((arg) => names.includes(arg));
13
+ }
14
+
15
+ /**
16
+ * Přečte hodnotu option ve tvaru `--name value`, `--name=value`, `-n value` i `-n=value`.
17
+ *
18
+ * @param {string[]} args
19
+ * @param {string[]} names např. `["-g", "--google-chat-webhook-url"]`
20
+ * @returns {string | undefined}
21
+ */
22
+ function readOption(args, names) {
23
+ for (let index = 0; index < args.length; index++) {
24
+ const arg = args[index];
25
+
26
+ if (names.includes(arg)) {
27
+ return args[index + 1];
28
+ }
29
+
30
+ const matched = names.find((name) => arg.startsWith(`${name}=`));
31
+
32
+ if (matched) {
33
+ return arg.slice(matched.length + 1);
34
+ }
35
+ }
36
+
37
+ return undefined;
38
+ }
39
+
40
+ module.exports = {
41
+ hasFlag,
42
+ readOption,
43
+ };
@@ -0,0 +1,61 @@
1
+ const { hasFlag, readOption } = require("./cli-args");
2
+
3
+ describe("hasFlag", () => {
4
+ it("finds the long form", () => {
5
+ expect(hasFlag(["--help"], ["-h", "--help"])).toBe(true);
6
+ });
7
+
8
+ it("finds the short form", () => {
9
+ expect(hasFlag(["-h"], ["-h", "--help"])).toBe(true);
10
+ });
11
+
12
+ it("finds the flag among other arguments", () => {
13
+ expect(hasFlag(["-g", "https://x", "--help"], ["-h", "--help"])).toBe(true);
14
+ });
15
+
16
+ it("returns false when absent", () => {
17
+ expect(hasFlag(["-g", "https://x"], ["-h", "--help"])).toBe(false);
18
+ expect(hasFlag([], ["-h", "--help"])).toBe(false);
19
+ });
20
+
21
+ it("does not match a flag that is only a prefix", () => {
22
+ expect(hasFlag(["--help-me"], ["-h", "--help"])).toBe(false);
23
+ });
24
+ });
25
+
26
+ describe("readOption", () => {
27
+ const NAMES = ["-g", "--google-chat-webhook-url"];
28
+
29
+ it("reads `--name value`", () => {
30
+ expect(readOption(["--google-chat-webhook-url", "https://x"], NAMES)).toBe("https://x");
31
+ });
32
+
33
+ it("reads `--name=value` — the form used in .gitlab-ci.yml", () => {
34
+ expect(readOption(["--google-chat-webhook-url=https://x"], NAMES)).toBe("https://x");
35
+ });
36
+
37
+ it("reads `-n value`", () => {
38
+ expect(readOption(["-g", "https://x"], NAMES)).toBe("https://x");
39
+ });
40
+
41
+ it("reads `-n=value`", () => {
42
+ expect(readOption(["-g=https://x"], NAMES)).toBe("https://x");
43
+ });
44
+
45
+ it("keeps `=` inside the value", () => {
46
+ expect(readOption(["--google-chat-webhook-url=https://x?key=abc"], NAMES)).toBe("https://x?key=abc");
47
+ });
48
+
49
+ it("finds the option among other arguments", () => {
50
+ expect(readOption(["--verbose", "-g", "https://x", "--other"], NAMES)).toBe("https://x");
51
+ });
52
+
53
+ it("returns undefined when the option is missing", () => {
54
+ expect(readOption(["--verbose"], NAMES)).toBeUndefined();
55
+ expect(readOption([], NAMES)).toBeUndefined();
56
+ });
57
+
58
+ it("returns undefined when the option is last and has no value", () => {
59
+ expect(readOption(["-g"], NAMES)).toBeUndefined();
60
+ });
61
+ });
@@ -1,24 +1,41 @@
1
1
  const { env } = require("process");
2
- const { create } = require("axios");
3
- const dayjs = require("dayjs");
2
+ const { request } = require("./http");
4
3
 
5
- const axios = create({
6
- baseURL: `${env.CI_SERVER_URL}/api/v4`,
7
- headers: {
8
- Authorization: `Bearer ${env.GITLAB_TOKEN}`,
9
- },
10
- });
4
+ /**
5
+ * @param {string} url
6
+ * @param {{method?: string, headers?: Record<string, string>, params?: Record<string, any>, body?: any}} options
7
+ */
8
+ function gitlabRequest(url, options = {}) {
9
+ return request(url, {
10
+ ...options,
11
+ baseUrl: `${env.CI_SERVER_URL}/api/v4`,
12
+ headers: { Authorization: `Bearer ${env.GITLAB_TOKEN}`, ...options.headers },
13
+ });
14
+ }
15
+
16
+ /** Lokální čas, stejně jako `dayjs().format(...)`, které tohle nahradilo. */
17
+ function formatTagTimestamp(date = new Date()) {
18
+ const pad = (value) => String(value).padStart(2, "0");
19
+
20
+ return [
21
+ date.getFullYear(),
22
+ pad(date.getMonth() + 1),
23
+ pad(date.getDate()),
24
+ pad(date.getHours()),
25
+ pad(date.getMinutes()),
26
+ ].join("-");
27
+ }
11
28
 
12
29
  /**
13
30
  *
14
31
  * @param {string} url
15
- * @param {axios.AxiosRequestConfig<any>} config
32
+ * @param {{params?: Record<string, any>}} config
16
33
  */
17
34
  async function getAll(url, config) {
18
35
  let nextPage = "1";
19
36
  const data = [];
20
37
  do {
21
- const response = await axios.get(url, {
38
+ const response = await gitlabRequest(url, {
22
39
  ...config,
23
40
  params: {
24
41
  ...config.params,
@@ -26,7 +43,7 @@ async function getAll(url, config) {
26
43
  page: nextPage,
27
44
  },
28
45
  });
29
- nextPage = response.headers["x-next-page"];
46
+ nextPage = response.headers.get("x-next-page");
30
47
  data.push(...response.data);
31
48
  } while (nextPage);
32
49
 
@@ -38,15 +55,15 @@ async function loadCommits(from, ref_name = "master") {
38
55
 
39
56
  let nextPage = "1";
40
57
  do {
41
- const response = await axios.get(`/projects/${env.CI_PROJECT_ID}/repository/commits`, {
58
+ const response = await gitlabRequest(`/projects/${env.CI_PROJECT_ID}/repository/commits`, {
42
59
  params: {
43
60
  ref_name,
44
- since: from ? dayjs(from).toISOString() : undefined,
61
+ since: from ? new Date(from).toISOString() : undefined,
45
62
  per_page: "100",
46
63
  page: nextPage,
47
64
  },
48
65
  });
49
- nextPage = response.headers["x-next-page"];
66
+ nextPage = response.headers.get("x-next-page");
50
67
  commits.push(...response.data);
51
68
  } while (nextPage);
52
69
 
@@ -61,7 +78,7 @@ async function findMigrationFiles(commits) {
61
78
  await Promise.all(
62
79
  commits.map(async (commit) => {
63
80
  try {
64
- const { data: diffs } = await axios.get(
81
+ const { data: diffs } = await gitlabRequest(
65
82
  `/projects/${env.CI_PROJECT_ID}/repository/commits/${commit.id}/diff`,
66
83
  );
67
84
  diffs.forEach(({ new_path }) => {
@@ -81,7 +98,7 @@ async function findMigrationFiles(commits) {
81
98
  }
82
99
 
83
100
  async function getLastTag(tagPrefix = "release-", ref_name = "master") {
84
- const { data: commits } = await axios.get(`/projects/${env.CI_PROJECT_ID}/repository/commits`, {
101
+ const { data: commits } = await gitlabRequest(`/projects/${env.CI_PROJECT_ID}/repository/commits`, {
85
102
  params: {
86
103
  ref_name,
87
104
  per_page: "100",
@@ -90,9 +107,11 @@ async function getLastTag(tagPrefix = "release-", ref_name = "master") {
90
107
 
91
108
  const commitIds = commits.map((commit) => commit.id);
92
109
 
93
- const { data: tags } = await axios.get(`/projects/${env.CI_PROJECT_ID}/repository/tags`, {
110
+ // Pozor: `per_page` tu záměrně chybí. V axios verzi bylo omylem mimo `params`,
111
+ // takže se na GitLab nikdy neposlalo a platí default (20 tagů). Doplnit ho by
112
+ // změnilo, který tag se najde, což je zásah do release flow — řešit zvlášť.
113
+ const { data: tags } = await gitlabRequest(`/projects/${env.CI_PROJECT_ID}/repository/tags`, {
94
114
  params: { search: `^${tagPrefix}` },
95
- per_page: "100",
96
115
  });
97
116
 
98
117
  const tag = tags.find((tag) => commitIds.includes(tag.commit.id)) ?? null;
@@ -107,20 +126,23 @@ async function getLastTag(tagPrefix = "release-", ref_name = "master") {
107
126
  }
108
127
 
109
128
  async function createRelease(description, tagPrefix = "release-", dryRun = false) {
110
- const tag = tagPrefix + dayjs().format("YYYY-MM-DD-HH-mm");
129
+ const tag = tagPrefix + formatTagTimestamp();
111
130
 
112
131
  if (dryRun) {
113
132
  console.log(`\n🎉🎉🎉 Release "${tag}" published (skipped in dry run)\n\n${description}`);
114
133
  return;
115
134
  }
116
135
 
117
- await axios.post(`/projects/${env.CI_PROJECT_ID}/releases`, { description, tag_name: tag, ref: "master" });
136
+ await gitlabRequest(`/projects/${env.CI_PROJECT_ID}/releases`, {
137
+ method: "POST",
138
+ body: { description, tag_name: tag, ref: "master" },
139
+ });
118
140
 
119
141
  console.log(`\n🎉🎉🎉 Release "${tag}" published\n\n${description}`);
120
142
  }
121
143
 
122
144
  function getSingleMergeRequestChanges(projectId, mr_iid) {
123
- return axios.get(`/projects/${projectId}/merge_requests/${mr_iid}/changes`).then((r) => r.data);
145
+ return gitlabRequest(`/projects/${projectId}/merge_requests/${mr_iid}/changes`).then((r) => r.data);
124
146
  }
125
147
 
126
148
  /**
@@ -0,0 +1,207 @@
1
+ const GitLab = require("./gitlab");
2
+
3
+ const originalFetch = globalThis.fetch;
4
+ const { CI_SERVER_URL, GITLAB_TOKEN, CI_PROJECT_ID } = process.env;
5
+
6
+ /**
7
+ * @param {Array<{body: any, nextPage?: string, status?: number}>} pages
8
+ * odpovědi v pořadí, v jakém je fetch vrátí
9
+ */
10
+ function stubFetch(pages) {
11
+ const calls = [];
12
+ let index = 0;
13
+
14
+ globalThis.fetch = async (url, init) => {
15
+ calls.push({ url, init });
16
+ const page = pages[Math.min(index++, pages.length - 1)];
17
+ const status = page.status ?? 200;
18
+
19
+ return {
20
+ ok: status >= 200 && status < 300,
21
+ status,
22
+ headers: {
23
+ get: (name) => {
24
+ if (name === "x-next-page") {
25
+ return page.nextPage ?? "";
26
+ }
27
+ return name.toLowerCase() === "content-type" ? "application/json" : null;
28
+ },
29
+ },
30
+ text: async () => JSON.stringify(page.body),
31
+ };
32
+ };
33
+
34
+ return calls;
35
+ }
36
+
37
+ beforeEach(() => {
38
+ process.env.CI_SERVER_URL = "https://git.test";
39
+ process.env.GITLAB_TOKEN = "test-token";
40
+ process.env.CI_PROJECT_ID = "42";
41
+ jest.spyOn(console, "log").mockImplementation(() => {});
42
+ });
43
+
44
+ afterEach(() => {
45
+ globalThis.fetch = originalFetch;
46
+ process.env.CI_SERVER_URL = CI_SERVER_URL;
47
+ process.env.GITLAB_TOKEN = GITLAB_TOKEN;
48
+ process.env.CI_PROJECT_ID = CI_PROJECT_ID;
49
+ jest.restoreAllMocks();
50
+ });
51
+
52
+ describe("authentication and base url", () => {
53
+ it("prefixes the API path and sends the bearer token", async () => {
54
+ const calls = stubFetch([{ body: [] }]);
55
+
56
+ await GitLab.getAllProjects();
57
+
58
+ expect(calls[0].url).toContain("https://git.test/api/v4/projects");
59
+ expect(calls[0].init.headers.Authorization).toBe("Bearer test-token");
60
+ });
61
+ });
62
+
63
+ describe("getAll pagination", () => {
64
+ it("follows x-next-page until it is empty and concatenates the pages", async () => {
65
+ const calls = stubFetch([
66
+ { body: [{ id: 1 }], nextPage: "2" },
67
+ { body: [{ id: 2 }], nextPage: "3" },
68
+ { body: [{ id: 3 }], nextPage: "" },
69
+ ]);
70
+
71
+ const projects = await GitLab.getAllProjects();
72
+
73
+ expect(projects).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
74
+ expect(calls).toHaveLength(3);
75
+ expect(calls[0].url).toContain("page=1");
76
+ expect(calls[1].url).toContain("page=2");
77
+ expect(calls[2].url).toContain("page=3");
78
+ });
79
+
80
+ it("stops after a single page when there is no next page", async () => {
81
+ const calls = stubFetch([{ body: [{ id: 1 }] }]);
82
+
83
+ await GitLab.getAllProjects();
84
+
85
+ expect(calls).toHaveLength(1);
86
+ });
87
+
88
+ it("always asks for 100 items per page", async () => {
89
+ const calls = stubFetch([{ body: [] }]);
90
+
91
+ await GitLab.getAllProjects();
92
+
93
+ expect(calls[0].url).toContain("per_page=100");
94
+ });
95
+ });
96
+
97
+ describe("getAllMergeRequests", () => {
98
+ it("excludes work in progress by default", async () => {
99
+ const calls = stubFetch([{ body: [] }]);
100
+
101
+ await GitLab.getAllMergeRequests(false);
102
+
103
+ expect(calls[0].url).toContain("wip=no");
104
+ });
105
+
106
+ it("omits the wip filter when work in progress is included", async () => {
107
+ const calls = stubFetch([{ body: [] }]);
108
+
109
+ await GitLab.getAllMergeRequests(true);
110
+
111
+ expect(calls[0].url).not.toContain("wip=");
112
+ });
113
+ });
114
+
115
+ describe("loadCommits", () => {
116
+ it("converts the `from` timestamp to ISO for the `since` parameter", async () => {
117
+ const calls = stubFetch([{ body: [] }]);
118
+
119
+ await GitLab.loadCommits("2026-01-02T03:04:05.000Z");
120
+
121
+ expect(decodeURIComponent(calls[0].url)).toContain("since=2026-01-02T03:04:05.000Z");
122
+ });
123
+
124
+ it("omits `since` when there is no starting point", async () => {
125
+ const calls = stubFetch([{ body: [] }]);
126
+
127
+ await GitLab.loadCommits(null);
128
+
129
+ expect(calls[0].url).not.toContain("since=");
130
+ });
131
+
132
+ it("drops the boundary commit so the last release is not counted twice", async () => {
133
+ const from = "2026-01-02T03:04:05.000Z";
134
+ stubFetch([
135
+ {
136
+ body: [
137
+ { id: "a", created_at: from },
138
+ { id: "b", created_at: "2026-01-03T00:00:00.000Z" },
139
+ ],
140
+ },
141
+ ]);
142
+
143
+ const commits = await GitLab.loadCommits(from);
144
+
145
+ expect(commits.map((c) => c.id)).toEqual(["b"]);
146
+ });
147
+
148
+ it("uses the requested ref", async () => {
149
+ const calls = stubFetch([{ body: [] }]);
150
+
151
+ await GitLab.loadCommits(null, "develop");
152
+
153
+ expect(calls[0].url).toContain("ref_name=develop");
154
+ });
155
+ });
156
+
157
+ describe("getSingleMergeRequestChanges", () => {
158
+ it("returns the response body", async () => {
159
+ stubFetch([{ body: { changes_count: "7" } }]);
160
+
161
+ await expect(GitLab.getSingleMergeRequestChanges(1, 2)).resolves.toEqual({ changes_count: "7" });
162
+ });
163
+ });
164
+
165
+ describe("createRelease", () => {
166
+ it("builds a tag from the prefix and a local timestamp", async () => {
167
+ const calls = stubFetch([{ body: {} }]);
168
+
169
+ await GitLab.createRelease("poznámky", "release-");
170
+
171
+ expect(calls[0].init.method).toBe("POST");
172
+ expect(JSON.parse(calls[0].init.body)).toEqual({
173
+ description: "poznámky",
174
+ tag_name: expect.stringMatching(/^release-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}$/),
175
+ ref: "master",
176
+ });
177
+ });
178
+
179
+ it("sends nothing in dry run mode", async () => {
180
+ const calls = stubFetch([{ body: {} }]);
181
+
182
+ await GitLab.createRelease("poznámky", "release-", true);
183
+
184
+ expect(calls).toHaveLength(0);
185
+ });
186
+ });
187
+
188
+ describe("findMigrationFiles", () => {
189
+ it("collects api migration files and ignores everything else", async () => {
190
+ stubFetch([
191
+ {
192
+ body: [{ new_path: "api/migrations/Version20260101120000.php" }, { new_path: "web/src/app.tsx" }],
193
+ },
194
+ ]);
195
+
196
+ await expect(GitLab.findMigrationFiles([{ id: "abc" }])).resolves.toEqual([
197
+ "api/migrations/Version20260101120000.php",
198
+ ]);
199
+ });
200
+
201
+ it("survives a failing commit diff instead of rejecting the whole run", async () => {
202
+ jest.spyOn(console, "error").mockImplementation(() => {});
203
+ stubFetch([{ body: { message: "nope" }, status: 500 }]);
204
+
205
+ await expect(GitLab.findMigrationFiles([{ id: "abc" }])).resolves.toEqual([]);
206
+ });
207
+ });
@@ -1,7 +1,5 @@
1
1
  const { env } = require("process");
2
- const { create } = require("axios");
3
-
4
- const axios = create({});
2
+ const { request } = require("./http");
5
3
 
6
4
  /**
7
5
  * @see https://developers.google.com/chat/api/reference/rest/v1/cards-v1
@@ -16,7 +14,7 @@ async function chatPostMessage(data, config = {}) {
16
14
 
17
15
  if (webhookUrl && !config.dryRun) {
18
16
  try {
19
- await axios.post(webhookUrl, { ...data });
17
+ await request(webhookUrl, { method: "POST", body: { ...data } });
20
18
  } catch (error) {
21
19
  process.stderr.write("GOOGLE CHAT: chat.postMessage - error");
22
20
  console.error(error);
@@ -30,16 +28,16 @@ async function chatPostMessage(data, config = {}) {
30
28
  *
31
29
  * @param cardsV2
32
30
  * @param {{webhookUrl?: string}} config
33
- * @returns {Promise<axios.AxiosResponse<any>>}
31
+ * @returns {Promise<any>}
34
32
  */
35
33
  async function messageCardsV2(cardsV2, config = {}) {
36
- return axios
37
- .post(
38
- config.webhookUrl ?? env.GOOGLE_WEBHOOK_URL,
39
- { cardsV2 },
40
- { headers: { "Content-Type": "application/json; charset=UTF-8" } },
41
- )
42
- .then((r) => r.data);
34
+ const response = await request(config.webhookUrl ?? env.GOOGLE_WEBHOOK_URL, {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json; charset=UTF-8" },
37
+ body: { cardsV2 },
38
+ });
39
+
40
+ return response.data;
43
41
  }
44
42
 
45
43
  module.exports = {
@@ -0,0 +1,95 @@
1
+ const GoogleChat = require("./google-chat");
2
+
3
+ const originalFetch = globalThis.fetch;
4
+ const { GOOGLE_WEBHOOK_URL } = process.env;
5
+
6
+ function stubFetch({ status = 200, payload = {} } = {}) {
7
+ const calls = [];
8
+ globalThis.fetch = async (url, init) => {
9
+ calls.push({ url, init });
10
+ return {
11
+ ok: status >= 200 && status < 300,
12
+ status,
13
+ headers: { get: () => "application/json" },
14
+ text: async () => JSON.stringify(payload),
15
+ };
16
+ };
17
+ return calls;
18
+ }
19
+
20
+ beforeEach(() => {
21
+ process.env.GOOGLE_WEBHOOK_URL = "https://chat.test/hook";
22
+ jest.spyOn(process.stdout, "write").mockImplementation(() => true);
23
+ jest.spyOn(process.stderr, "write").mockImplementation(() => true);
24
+ jest.spyOn(console, "error").mockImplementation(() => {});
25
+ });
26
+
27
+ afterEach(() => {
28
+ globalThis.fetch = originalFetch;
29
+ process.env.GOOGLE_WEBHOOK_URL = GOOGLE_WEBHOOK_URL;
30
+ jest.restoreAllMocks();
31
+ });
32
+
33
+ describe("chatPostMessage", () => {
34
+ it("posts the payload to the webhook from the environment", async () => {
35
+ const calls = stubFetch();
36
+
37
+ await GoogleChat.chatPostMessage({ text: "ahoj" });
38
+
39
+ expect(calls[0].url).toBe("https://chat.test/hook");
40
+ expect(calls[0].init.method).toBe("POST");
41
+ expect(JSON.parse(calls[0].init.body)).toEqual({ text: "ahoj" });
42
+ });
43
+
44
+ it("prefers an explicitly configured webhook url", async () => {
45
+ const calls = stubFetch();
46
+
47
+ await GoogleChat.chatPostMessage({ text: "ahoj" }, { webhookUrl: "https://other.test/hook" });
48
+
49
+ expect(calls[0].url).toBe("https://other.test/hook");
50
+ });
51
+
52
+ it("skips in dry run mode", async () => {
53
+ const calls = stubFetch();
54
+
55
+ await GoogleChat.chatPostMessage({ text: "ahoj" }, { dryRun: true });
56
+
57
+ expect(calls).toHaveLength(0);
58
+ expect(process.stdout.write).toHaveBeenCalledWith("GOOGLE CHAT: chat.postMessage - skipped");
59
+ });
60
+
61
+ it("skips when no webhook url is configured", async () => {
62
+ delete process.env.GOOGLE_WEBHOOK_URL;
63
+ const calls = stubFetch();
64
+
65
+ await GoogleChat.chatPostMessage({ text: "ahoj" });
66
+
67
+ expect(calls).toHaveLength(0);
68
+ });
69
+
70
+ // Notifiery posílají desítky zpráv za sebou; jedna neúspěšná nesmí shodit celý běh.
71
+ it("swallows a failed request instead of rejecting", async () => {
72
+ stubFetch({ status: 500 });
73
+
74
+ await expect(GoogleChat.chatPostMessage({ text: "ahoj" })).resolves.toBeUndefined();
75
+ expect(process.stderr.write).toHaveBeenCalledWith("GOOGLE CHAT: chat.postMessage - error");
76
+ });
77
+ });
78
+
79
+ describe("messageCardsV2", () => {
80
+ it("wraps the cards and sends the charset content type", async () => {
81
+ const calls = stubFetch();
82
+
83
+ await GoogleChat.messageCardsV2([{ cardId: "mr-1" }]);
84
+
85
+ expect(JSON.parse(calls[0].init.body)).toEqual({ cardsV2: [{ cardId: "mr-1" }] });
86
+ expect(calls[0].init.headers["Content-Type"]).toBe("application/json; charset=UTF-8");
87
+ });
88
+
89
+ // Na rozdíl od chatPostMessage tady chyba probublá — volající ji má řešit.
90
+ it("rejects on a failed request", async () => {
91
+ stubFetch({ status: 500 });
92
+
93
+ await expect(GoogleChat.messageCardsV2([])).rejects.toThrow("Request failed with status code 500");
94
+ });
95
+ });