@uxf/scripts 11.122.5 → 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 +1 -38
- package/package.json +12 -16
- package/src/cli-args.js +43 -0
- package/src/cli-args.test.js +61 -0
- package/src/{GitLab.js → gitlab.js} +43 -21
- package/src/gitlab.test.js +207 -0
- package/src/{GoogleChat.js → google-chat.js} +10 -12
- package/src/google-chat.test.js +95 -0
- package/src/http.js +86 -0
- package/src/http.test.js +155 -0
- package/src/sitemap.js +80 -0
- package/src/sitemap.test.js +64 -0
- package/src/{Slack.js → slack.js} +9 -8
- package/src/slack.test.js +81 -0
- package/src/uxf-i18n-namespaces-gen/dependency-tree.js +102 -0
- package/src/uxf-i18n-namespaces-gen/dependency-tree.test.js +46 -0
- package/src/uxf-i18n-namespaces-gen/index.js +62 -44
- package/src/uxf-i18n-namespaces-gen/index.test.js +32 -1
- package/src/uxf-merge-requests-notifier/cli.js +9 -12
- package/src/uxf-merge-requests-notifier/index.js +44 -10
- package/src/uxf-merge-requests-notifier/index.test.js +103 -0
- package/src/uxf-push-notifier/cli.js +14 -13
- package/src/uxf-push-notifier/index.js +32 -23
- package/src/uxf-release/index.js +3 -3
- package/src/uxf-sitemap-check/index.js +6 -2
- package/src/uxf-sitemap-check/index.test.js +2 -2
- package/src/uxf-sitemap-meta-export/index.js +3 -3
- package/bin/uxf-lunch.js +0 -8
- package/bin/uxf-unused.js +0 -9
- package/src/Logger.js +0 -12
- package/src/Sitemap.js +0 -60
- package/src/shared/load-page-imports.js +0 -60
- package/src/uxf-lunch/cli.js +0 -44
- package/src/uxf-lunch/index.js +0 -50
- package/src/uxf-unused/cli.js +0 -30
- package/src/uxf-unused/index.js +0 -59
|
@@ -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
|
+
});
|
package/src/http.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimalistická náhrada axiosu nad globálním `fetch` (Node >= 18).
|
|
3
|
+
*
|
|
4
|
+
* Existuje proto, aby notifiery běžely v CI bez `npm install` — viz
|
|
5
|
+
* `docs/decisions/package-manager.md`. Drží se chování axiosu ve třech věcech,
|
|
6
|
+
* na kterých volající stojí: vyhazuje na non-2xx, parsuje JSON odpověď
|
|
7
|
+
* a zahazuje `undefined` query parametry.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Chyba HTTP požadavku. `message` má stejný tvar jako u axiosu, protože
|
|
12
|
+
* `GitLab.findMigrationFiles` ho loguje.
|
|
13
|
+
*/
|
|
14
|
+
class HttpError extends Error {
|
|
15
|
+
/**
|
|
16
|
+
* @param {number} status
|
|
17
|
+
* @param {string} url
|
|
18
|
+
* @param {any} body
|
|
19
|
+
*/
|
|
20
|
+
constructor(status, url, body) {
|
|
21
|
+
super(`Request failed with status code ${status}`);
|
|
22
|
+
this.name = "HttpError";
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.url = url;
|
|
25
|
+
this.body = body;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Poskládá query string a zahodí `undefined`/`null` hodnoty — axios se choval stejně.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} url
|
|
33
|
+
* @param {Record<string, any> | undefined} params
|
|
34
|
+
*/
|
|
35
|
+
function withQuery(url, params) {
|
|
36
|
+
const search = new URLSearchParams();
|
|
37
|
+
|
|
38
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
39
|
+
if (value !== undefined && value !== null) {
|
|
40
|
+
search.append(key, String(value));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const query = search.toString();
|
|
45
|
+
|
|
46
|
+
if (!query) {
|
|
47
|
+
return url;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return `${url}${url.includes("?") ? "&" : "?"}${query}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} url
|
|
55
|
+
* @param {{baseUrl?: string, method?: string, headers?: Record<string, string>, params?: Record<string, any>, body?: any}} options
|
|
56
|
+
* @returns {Promise<{data: any, headers: Headers, status: number}>}
|
|
57
|
+
*/
|
|
58
|
+
async function request(url, options = {}) {
|
|
59
|
+
const { baseUrl = "", method = "GET", headers = {}, params, body } = options;
|
|
60
|
+
const target = withQuery(`${baseUrl}${url}`, params);
|
|
61
|
+
|
|
62
|
+
/** @type {RequestInit} */
|
|
63
|
+
const init = { method, headers: { ...headers } };
|
|
64
|
+
|
|
65
|
+
if (body !== undefined) {
|
|
66
|
+
init.body = JSON.stringify(body);
|
|
67
|
+
init.headers["Content-Type"] = init.headers["Content-Type"] ?? "application/json";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const response = await fetch(target, init);
|
|
71
|
+
const text = await response.text();
|
|
72
|
+
const isJson = (response.headers.get("content-type") ?? "").includes("application/json");
|
|
73
|
+
const data = isJson && text ? JSON.parse(text) : text;
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new HttpError(response.status, target, data);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { data, headers: response.headers, status: response.status };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
HttpError,
|
|
84
|
+
request,
|
|
85
|
+
withQuery,
|
|
86
|
+
};
|
package/src/http.test.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const { request, withQuery, HttpError } = require("./http");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimální náhrada `Response` — testy nesmí záviset na tom, jestli ji testovací
|
|
5
|
+
* prostředí (jsdom) vystavuje.
|
|
6
|
+
*/
|
|
7
|
+
function fakeResponse({ status = 200, body = "", contentType = "application/json" } = {}) {
|
|
8
|
+
return {
|
|
9
|
+
ok: status >= 200 && status < 300,
|
|
10
|
+
status,
|
|
11
|
+
headers: { get: (name) => (name.toLowerCase() === "content-type" ? contentType : null) },
|
|
12
|
+
text: async () => body,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stubFetch(response) {
|
|
17
|
+
const calls = [];
|
|
18
|
+
globalThis.fetch = async (url, init) => {
|
|
19
|
+
calls.push({ url, init });
|
|
20
|
+
return typeof response === "function" ? response(url, init) : response;
|
|
21
|
+
};
|
|
22
|
+
return calls;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const originalFetch = globalThis.fetch;
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
globalThis.fetch = originalFetch;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("withQuery", () => {
|
|
32
|
+
it("returns the url untouched when there are no params", () => {
|
|
33
|
+
expect(withQuery("/projects", undefined)).toBe("/projects");
|
|
34
|
+
expect(withQuery("/projects", {})).toBe("/projects");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("drops undefined and null values", () => {
|
|
38
|
+
expect(withQuery("/mr", { a: "1", b: undefined, c: null, d: "2" })).toBe("/mr?a=1&d=2");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps falsy values that are not undefined or null", () => {
|
|
42
|
+
expect(withQuery("/mr", { page: 0, simple: false, empty: "" })).toBe("/mr?page=0&simple=false&empty=");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("appends with & when the url already has a query string", () => {
|
|
46
|
+
expect(withQuery("/mr?state=open", { page: "2" })).toBe("/mr?state=open&page=2");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("encodes values", () => {
|
|
50
|
+
expect(withQuery("/tags", { search: "^release-" })).toBe("/tags?search=%5Erelease-");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("request", () => {
|
|
55
|
+
it("joins baseUrl, path and params", async () => {
|
|
56
|
+
const calls = stubFetch(fakeResponse({ body: "{}" }));
|
|
57
|
+
|
|
58
|
+
await request("/projects", { baseUrl: "https://git.test/api/v4", params: { per_page: "100" } });
|
|
59
|
+
|
|
60
|
+
expect(calls[0].url).toBe("https://git.test/api/v4/projects?per_page=100");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("defaults to GET and sends no body", async () => {
|
|
64
|
+
const calls = stubFetch(fakeResponse({ body: "{}" }));
|
|
65
|
+
|
|
66
|
+
await request("/x");
|
|
67
|
+
|
|
68
|
+
expect(calls[0].init.method).toBe("GET");
|
|
69
|
+
expect(calls[0].init.body).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("serializes the body as JSON and sets the content type", async () => {
|
|
73
|
+
const calls = stubFetch(fakeResponse({ body: "{}" }));
|
|
74
|
+
|
|
75
|
+
await request("/x", { method: "POST", body: { text: "ahoj" } });
|
|
76
|
+
|
|
77
|
+
expect(calls[0].init.body).toBe('{"text":"ahoj"}');
|
|
78
|
+
expect(calls[0].init.headers["Content-Type"]).toBe("application/json");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not override an explicitly provided content type", async () => {
|
|
82
|
+
const calls = stubFetch(fakeResponse({ body: "{}" }));
|
|
83
|
+
|
|
84
|
+
await request("/x", {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/json; charset=UTF-8" },
|
|
87
|
+
body: { a: 1 },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(calls[0].init.headers["Content-Type"]).toBe("application/json; charset=UTF-8");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("passes custom headers through", async () => {
|
|
94
|
+
const calls = stubFetch(fakeResponse({ body: "{}" }));
|
|
95
|
+
|
|
96
|
+
await request("/x", { headers: { Authorization: "Bearer t" } });
|
|
97
|
+
|
|
98
|
+
expect(calls[0].init.headers.Authorization).toBe("Bearer t");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("parses a JSON response", async () => {
|
|
102
|
+
stubFetch(fakeResponse({ body: '{"id":1}' }));
|
|
103
|
+
|
|
104
|
+
const response = await request("/x");
|
|
105
|
+
|
|
106
|
+
expect(response.data).toEqual({ id: 1 });
|
|
107
|
+
expect(response.status).toBe(200);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns raw text when the response is not JSON", async () => {
|
|
111
|
+
stubFetch(fakeResponse({ body: "<html></html>", contentType: "text/html" }));
|
|
112
|
+
|
|
113
|
+
const response = await request("/x");
|
|
114
|
+
|
|
115
|
+
expect(response.data).toBe("<html></html>");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns an empty string for an empty JSON response instead of throwing", async () => {
|
|
119
|
+
stubFetch(fakeResponse({ body: "" }));
|
|
120
|
+
|
|
121
|
+
const response = await request("/x");
|
|
122
|
+
|
|
123
|
+
expect(response.data).toBe("");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("exposes response headers so callers can paginate", async () => {
|
|
127
|
+
globalThis.fetch = async () => ({
|
|
128
|
+
ok: true,
|
|
129
|
+
status: 200,
|
|
130
|
+
headers: { get: (name) => (name === "x-next-page" ? "2" : "application/json") },
|
|
131
|
+
text: async () => "[]",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const response = await request("/x");
|
|
135
|
+
|
|
136
|
+
expect(response.headers.get("x-next-page")).toBe("2");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("throws on a non-2xx status, the way axios did", async () => {
|
|
140
|
+
stubFetch(fakeResponse({ status: 404, body: '{"message":"404 Not found"}' }));
|
|
141
|
+
|
|
142
|
+
await expect(request("/missing")).rejects.toThrow("Request failed with status code 404");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("carries status, url and parsed body on the error", async () => {
|
|
146
|
+
stubFetch(fakeResponse({ status: 500, body: '{"message":"boom"}' }));
|
|
147
|
+
|
|
148
|
+
const error = await request("/x", { baseUrl: "https://git.test" }).catch((e) => e);
|
|
149
|
+
|
|
150
|
+
expect(error).toBeInstanceOf(HttpError);
|
|
151
|
+
expect(error.status).toBe(500);
|
|
152
|
+
expect(error.url).toBe("https://git.test/x");
|
|
153
|
+
expect(error.body).toEqual({ message: "boom" });
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/sitemap.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const cheerio = require("cheerio");
|
|
2
|
+
|
|
3
|
+
const { HTTP_USERNAME, HTTP_PASSWORD } = process.env;
|
|
4
|
+
|
|
5
|
+
// got v15 je ESM-only, proto dynamický import — stejný vzorec jako v uxf-sitemap-check.
|
|
6
|
+
const got = (url, init) => import("got").then((mod) => mod.default(url, init));
|
|
7
|
+
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
9
|
+
|
|
10
|
+
const HEADERS = {
|
|
11
|
+
"User-Agent":
|
|
12
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36",
|
|
13
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
|
14
|
+
"Accept-Language": "en-US,en;q=0.9,cs-CZ;q=0.8,cs;q=0.7,de;q=0.6",
|
|
15
|
+
"Cache-Control": "no-cache",
|
|
16
|
+
Connection: "keep-alive",
|
|
17
|
+
Pragma: "no-cache",
|
|
18
|
+
"Sec-Ch-Ua": '"Google Chrome";v="111", "Not(A:Brand";v="8", "Chromium";v="111"',
|
|
19
|
+
"Sec-Ch-Ua-Arch": '"x86"',
|
|
20
|
+
"Sec-Ch-Ua-Mobile": "?0",
|
|
21
|
+
"Sec-Ch-Ua-Platform": '"Windows"',
|
|
22
|
+
"Sec-Fetch-Dest": "document",
|
|
23
|
+
"Sec-Fetch-Mode": "navigate",
|
|
24
|
+
"Sec-Fetch-Site": "cross-site",
|
|
25
|
+
"Sec-Fetch-User": "?1",
|
|
26
|
+
"Sec-Fetch-User-Agent": "?1",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stáhne stránku se stejnou konfigurací, jakou měla původní axios instance:
|
|
31
|
+
* basic auth z env, žádné následování redirectů, 20s timeout a vypnuté ověřování
|
|
32
|
+
* TLS certifikátu (kvůli staging webům se self-signed certy).
|
|
33
|
+
*
|
|
34
|
+
* `https.rejectUnauthorized` je důvod, proč tenhle modul jede na `got` a ne na
|
|
35
|
+
* globálním `fetch` — undici žádnou per-request TLS volbu nenabízí.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} url
|
|
38
|
+
* @returns {Promise<string>} tělo odpovědi
|
|
39
|
+
*/
|
|
40
|
+
async function fetchPage(url) {
|
|
41
|
+
const response = await got(url, {
|
|
42
|
+
headers: HEADERS,
|
|
43
|
+
username: HTTP_USERNAME || undefined,
|
|
44
|
+
password: HTTP_PASSWORD || undefined,
|
|
45
|
+
followRedirect: false,
|
|
46
|
+
// BEZPEČNOST: vypnuté ověřování TLS certifikátu. Zděděno z axios verze
|
|
47
|
+
// (`httpsAgent` s `rejectUnauthorized: false`), zachováno kvůli staging webům
|
|
48
|
+
// se self-signed certy. Platí to ale na VŠECHNY URL, včetně produkčních —
|
|
49
|
+
// takže crawler nepozná podvržený certifikát a je náchylný na MITM.
|
|
50
|
+
// Čistší by bylo podmínit to env proměnnou nebo přidat CA do trust storu.
|
|
51
|
+
https: { rejectUnauthorized: false },
|
|
52
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// got s `followRedirect: false` vrací 3xx jako úspěch s prázdným tělem, kdežto
|
|
56
|
+
// axios s `maxRedirects: 0` na něj vyhazoval. Bez tohohle by přesměrované URL
|
|
57
|
+
// tiše propadly do exportu jako řádek s prázdným titulkem.
|
|
58
|
+
if (response.statusCode >= 300) {
|
|
59
|
+
throw new Error(`Request failed with status code ${response.statusCode}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return response.body;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function getSitemap(xml) {
|
|
66
|
+
const $ = cheerio.load(await fetchPage(xml), { xmlMode: true });
|
|
67
|
+
|
|
68
|
+
const urls = [];
|
|
69
|
+
|
|
70
|
+
$("loc").each(function () {
|
|
71
|
+
urls.push($(this).text());
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return urls;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
getSitemap,
|
|
79
|
+
fetchPage,
|
|
80
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const mockGot = jest.fn();
|
|
2
|
+
|
|
3
|
+
jest.mock("got", () => ({ __esModule: true, default: (...args) => mockGot(...args) }));
|
|
4
|
+
// cheerio je ESM-only a jest ho neprochází (viz transformIgnorePatterns v jest.config.js),
|
|
5
|
+
// proto je mockované — stejně jako v uxf-sitemap-check/index.test.js. Parsování sitemapy
|
|
6
|
+
// v `getSitemap` tím pádem testovat nejde; testy tu pokrývají `fetchPage`, tedy to,
|
|
7
|
+
// co se převodem z axiosu na got mohlo rozejít.
|
|
8
|
+
jest.mock("cheerio", () => ({ load: jest.fn() }));
|
|
9
|
+
|
|
10
|
+
const Sitemap = require("./sitemap");
|
|
11
|
+
|
|
12
|
+
function response({ statusCode = 200, body = "" } = {}) {
|
|
13
|
+
return { statusCode, body, headers: {} };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
mockGot.mockReset();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("fetchPage", () => {
|
|
21
|
+
it("returns the response body", async () => {
|
|
22
|
+
mockGot.mockResolvedValue(response({ body: "<html>ok</html>" }));
|
|
23
|
+
|
|
24
|
+
await expect(Sitemap.fetchPage("https://x.test/a")).resolves.toBe("<html>ok</html>");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("does not follow redirects and skips TLS verification", async () => {
|
|
28
|
+
mockGot.mockResolvedValue(response({ body: "" }));
|
|
29
|
+
|
|
30
|
+
await Sitemap.fetchPage("https://x.test/a");
|
|
31
|
+
|
|
32
|
+
const [, options] = mockGot.mock.calls[0];
|
|
33
|
+
expect(options.followRedirect).toBe(false);
|
|
34
|
+
expect(options.https).toEqual({ rejectUnauthorized: false });
|
|
35
|
+
expect(options.signal).toBeInstanceOf(AbortSignal);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// got s `followRedirect: false` vrací 3xx jako úspěch s prázdným tělem, zatímco
|
|
39
|
+
// axios s `maxRedirects: 0` vyhazoval. Bez tohohle by přesměrované URL propadly
|
|
40
|
+
// do meta exportu jako řádek s prázdným titulkem.
|
|
41
|
+
it("throws on a redirect instead of returning an empty body", async () => {
|
|
42
|
+
mockGot.mockResolvedValue(response({ statusCode: 302, body: "" }));
|
|
43
|
+
|
|
44
|
+
await expect(Sitemap.fetchPage("https://x.test/redirect")).rejects.toThrow(
|
|
45
|
+
"Request failed with status code 302",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("passes the requested url through", async () => {
|
|
50
|
+
mockGot.mockResolvedValue(response());
|
|
51
|
+
|
|
52
|
+
await Sitemap.fetchPage("https://x.test/a");
|
|
53
|
+
|
|
54
|
+
expect(mockGot.mock.calls[0][0]).toBe("https://x.test/a");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("sends a browser-like user agent", async () => {
|
|
58
|
+
mockGot.mockResolvedValue(response());
|
|
59
|
+
|
|
60
|
+
await Sitemap.fetchPage("https://x.test/a");
|
|
61
|
+
|
|
62
|
+
expect(mockGot.mock.calls[0][1].headers["User-Agent"]).toContain("Mozilla/5.0");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
const { env } = require("process");
|
|
2
|
-
const {
|
|
2
|
+
const { request } = require("./http");
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
baseURL: "https://slack.com/api",
|
|
6
|
-
headers: {
|
|
7
|
-
Authorization: `Bearer ${env.SLACK_TOKEN}`,
|
|
8
|
-
},
|
|
9
|
-
});
|
|
4
|
+
const BASE_URL = "https://slack.com/api";
|
|
10
5
|
|
|
11
6
|
/**
|
|
12
7
|
* @see https://api.slack.com/methods/chat.postMessage
|
|
@@ -14,7 +9,13 @@ const axios = create({
|
|
|
14
9
|
*/
|
|
15
10
|
async function chatPostMessage(channel, data, dryRun = false) {
|
|
16
11
|
if (env.SLACK_TOKEN && !dryRun) {
|
|
17
|
-
|
|
12
|
+
// Slack vrací HTTP 200 i pro chyby API, rozlišuje je až polem `ok`.
|
|
13
|
+
const res = await request("/chat.postMessage", {
|
|
14
|
+
baseUrl: BASE_URL,
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { Authorization: `Bearer ${env.SLACK_TOKEN}` },
|
|
17
|
+
body: { ...data, channel },
|
|
18
|
+
});
|
|
18
19
|
if (res.data.ok === false) {
|
|
19
20
|
process.stdout.write("SLACK: chat.postMessage error - " + JSON.stringify(res.data));
|
|
20
21
|
return;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const Slack = require("./slack");
|
|
2
|
+
|
|
3
|
+
const originalFetch = globalThis.fetch;
|
|
4
|
+
const { SLACK_TOKEN } = process.env;
|
|
5
|
+
|
|
6
|
+
function stubFetch(payload = { ok: true }) {
|
|
7
|
+
const calls = [];
|
|
8
|
+
globalThis.fetch = async (url, init) => {
|
|
9
|
+
calls.push({ url, init });
|
|
10
|
+
return {
|
|
11
|
+
ok: true,
|
|
12
|
+
status: 200,
|
|
13
|
+
headers: { get: () => "application/json" },
|
|
14
|
+
text: async () => JSON.stringify(payload),
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
return calls;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
process.env.SLACK_TOKEN = "xoxb-test";
|
|
22
|
+
jest.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
globalThis.fetch = originalFetch;
|
|
27
|
+
process.env.SLACK_TOKEN = SLACK_TOKEN;
|
|
28
|
+
jest.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("chatPostMessage", () => {
|
|
32
|
+
it("posts to the Slack API with the bearer token and the channel merged in", async () => {
|
|
33
|
+
const calls = stubFetch();
|
|
34
|
+
|
|
35
|
+
await Slack.chatPostMessage("#releases", { text: "ahoj" });
|
|
36
|
+
|
|
37
|
+
expect(calls[0].url).toBe("https://slack.com/api/chat.postMessage");
|
|
38
|
+
expect(calls[0].init.method).toBe("POST");
|
|
39
|
+
expect(calls[0].init.headers.Authorization).toBe("Bearer xoxb-test");
|
|
40
|
+
expect(JSON.parse(calls[0].init.body)).toEqual({ text: "ahoj", channel: "#releases" });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reports a done message on success", async () => {
|
|
44
|
+
stubFetch({ ok: true });
|
|
45
|
+
|
|
46
|
+
await Slack.chatPostMessage("#releases", { text: "ahoj" });
|
|
47
|
+
|
|
48
|
+
expect(process.stdout.write).toHaveBeenCalledWith("SLACK: chat.postMessage - done\n");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Slack vrací HTTP 200 i pro chyby API, rozlišuje je až polem `ok` — proto se
|
|
52
|
+
// na to nedá spolehnout na status kód.
|
|
53
|
+
it("reports an error when Slack answers ok:false despite HTTP 200", async () => {
|
|
54
|
+
stubFetch({ ok: false, error: "channel_not_found" });
|
|
55
|
+
|
|
56
|
+
await Slack.chatPostMessage("#nope", { text: "ahoj" });
|
|
57
|
+
|
|
58
|
+
expect(process.stdout.write).toHaveBeenCalledWith(
|
|
59
|
+
'SLACK: chat.postMessage error - {"ok":false,"error":"channel_not_found"}',
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("skips when there is no token", async () => {
|
|
64
|
+
delete process.env.SLACK_TOKEN;
|
|
65
|
+
const calls = stubFetch();
|
|
66
|
+
|
|
67
|
+
await Slack.chatPostMessage("#releases", { text: "ahoj" });
|
|
68
|
+
|
|
69
|
+
expect(calls).toHaveLength(0);
|
|
70
|
+
expect(process.stdout.write).toHaveBeenCalledWith("SLACK: chat.postMessage - skipped\n");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("skips in dry run mode", async () => {
|
|
74
|
+
const calls = stubFetch();
|
|
75
|
+
|
|
76
|
+
await Slack.chatPostMessage("#releases", { text: "ahoj" }, true);
|
|
77
|
+
|
|
78
|
+
expect(calls).toHaveLength(0);
|
|
79
|
+
expect(process.stdout.write).toHaveBeenCalledWith("SLACK: chat.postMessage - skipped\n");
|
|
80
|
+
});
|
|
81
|
+
});
|