@raketa-cloud/admin 1.8.17 → 1.8.19
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/dist/application.css +288 -11
- package/package.json +8 -6
- package/src/handlers/resources/ReportsAPIHandler.js +27 -0
- package/src/handlers/resources/ResourcesHandler.js +16 -0
- package/src/helpers/content_tasks_helper.js +1 -1
- package/src/helpers/reports/cwv.js +217 -0
- package/src/helpers/reports/googleAuth.js +149 -0
- package/src/helpers/reports/quick-insights.js +294 -0
- package/src/helpers/reports/reportsUtils.js +181 -0
- package/src/index.js +8 -0
- package/src/localCache.js +112 -0
- package/src/report/analytics.js +107 -0
- package/src/report/reports.js +390 -0
- package/src/routeAssets.js +74 -0
- package/src/routeResourceModule.js +24 -0
- package/src/routeStandard.js +7 -1
- package/src/styles/application.css +1 -1
- package/src/tests/handlers/RedirectsHandler.test/notes.test.js +56 -0
- package/src/tests/handlers/_shared/SettingsHandler.test/global_variables.test.js +54 -0
- package/src/tests/handlers/categories/update_defaults.test.js +35 -0
- package/src/tests/handlers/content_tasks/ContentTasksHandler.test/editors.test.js +29 -0
- package/src/tests/handlers/resource_revisions/PublishesHandler.test/schedule.test.js +137 -0
- package/src/tests/handlers/resource_revisions/TagHandler.test/default_locale_tags.test.js +162 -0
- package/src/tests/handlers/resource_revisions/WidgetHandler.test/defaults.test.js +61 -0
- package/src/tests/handlers/resources/DeletesHandler.test/no_redirect.test.js +55 -0
- package/src/tests/handlers/resources/ReportsAPIHandler.test/show.test.js +156 -0
- package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +8 -0
- package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +94 -0
- package/src/tests/handlers/resources/TranslationsHandler.test/edge_cases.test.js +211 -0
- package/src/tests/helpers/CSVExportHelper.branches.test.js +57 -0
- package/src/tests/helpers/FormBuilder.branches.test.js +151 -0
- package/src/tests/helpers/HTMLBuilder.test.js +51 -0
- package/src/tests/helpers/content_tasks_helper.test.js +1 -1
- package/src/tests/helpers/historyUtils.branches.test.js +36 -0
- package/src/tests/helpers/reports/cwv.test.js +239 -0
- package/src/tests/helpers/reports/quick-insights.test.js +593 -0
- package/src/tests/helpers/reports/reportsUtils.test.js +425 -0
- package/src/tests/helpers/utils.test.js +37 -0
- package/src/tests/helpers/view_helpers.test.js +87 -0
- package/src/tests/index.test.js +74 -0
- package/src/tests/localCache.test.js +236 -0
- package/src/tests/report/analytics.test.js +230 -0
- package/src/tests/report/reports.test.js +831 -0
- package/src/tests/routeAssets.test.js +120 -0
- package/src/tests/routeResourceModule.test.js +99 -2
- package/src/tests/routeStandard.test.js +3 -3
- package/src/tests/support/fakeDom.js +164 -0
- package/src/views/content_tasks/index.ejs +2 -2
- package/src/views/resources/_shared/_core_web_vitals.ejs +27 -0
- package/src/views/resources/_shared/_quick_insights.ejs +64 -0
- package/src/views/resources/_shared/_scheduled_revisions.ejs +88 -0
- package/src/views/resources/_shared/_title.ejs +16 -12
- package/src/views/resources/_shared/_toolbar.ejs +11 -21
- package/src/views/resources/_shared/_traffic_chart.ejs +27 -0
- package/src/views/resources/_shared/_translations_strip.ejs +13 -25
- package/src/views/resources/_shared/_vital_meter.ejs +54 -0
- package/src/views/resources/index.ejs +1 -1
- package/src/views/resources/show.ejs +88 -98
- package/src/views/resources/translations/create_form.ejs +14 -5
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cruxApiKeyConfigured,
|
|
3
|
+
pageWebVitals,
|
|
4
|
+
} from "../../../helpers/reports/cwv";
|
|
5
|
+
import localCache from "../../../localCache";
|
|
6
|
+
|
|
7
|
+
const GA = { apiKey: "crux-key" };
|
|
8
|
+
|
|
9
|
+
const record = {
|
|
10
|
+
metrics: {
|
|
11
|
+
largest_contentful_paint: {
|
|
12
|
+
percentiles: { p75: 1200 },
|
|
13
|
+
histogram: [{ density: 0.9 }, { density: 0.07 }, { density: 0.03 }],
|
|
14
|
+
},
|
|
15
|
+
interaction_to_next_paint: {
|
|
16
|
+
percentiles: { p75: 90 },
|
|
17
|
+
histogram: [{ density: 0.95 }, { density: 0.03 }, { density: 0.02 }],
|
|
18
|
+
},
|
|
19
|
+
cumulative_layout_shift: {
|
|
20
|
+
percentiles: { p75: "0.05" },
|
|
21
|
+
histogram: [{ density: 0.97 }, { density: 0.02 }, { density: 0.01 }],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
collectionPeriod: {
|
|
25
|
+
firstDate: { year: 2026, month: 7, day: 5 },
|
|
26
|
+
lastDate: { year: 2026, month: 8, day: 1 },
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Each entry answers one queryRecord call, in order: URL-level, then origin.
|
|
31
|
+
function mockCrux(responses) {
|
|
32
|
+
const queue = [...responses];
|
|
33
|
+
global.fetch = jest.fn().mockImplementation(async () => {
|
|
34
|
+
const next = queue.shift() ?? { status: 404 };
|
|
35
|
+
return {
|
|
36
|
+
ok: next.status === 200,
|
|
37
|
+
status: next.status,
|
|
38
|
+
json: async () => next.body ?? {},
|
|
39
|
+
text: async () => JSON.stringify(next.body ?? {}),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("pageWebVitals", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
localCache.clear();
|
|
47
|
+
jest.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
delete global.fetch;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("cruxApiKeyConfigured rejects a missing or placeholder key", () => {
|
|
55
|
+
expect(cruxApiKeyConfigured(GA)).toBe(true);
|
|
56
|
+
expect(cruxApiKeyConfigured({ apiKey: "YOUR-GOOGLE-API-KEY" })).toBe(false);
|
|
57
|
+
expect(cruxApiKeyConfigured({})).toBe(false);
|
|
58
|
+
expect(cruxApiKeyConfigured()).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("queries the CrUX endpoint with the API key", async () => {
|
|
62
|
+
mockCrux([{ status: 200, body: { record } }]);
|
|
63
|
+
|
|
64
|
+
await pageWebVitals("https://example.com/", GA);
|
|
65
|
+
|
|
66
|
+
expect(global.fetch.mock.calls[0][0]).toBe(
|
|
67
|
+
`https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${GA.apiKey}`,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("asks for mobile field data by default", async () => {
|
|
72
|
+
mockCrux([{ status: 200, body: { record } }]);
|
|
73
|
+
|
|
74
|
+
await pageWebVitals("https://example.com/", GA);
|
|
75
|
+
|
|
76
|
+
const body = JSON.parse(global.fetch.mock.calls[0][1].body);
|
|
77
|
+
expect(body.formFactor).toBe("PHONE");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("takes a form factor override, and null for all devices", async () => {
|
|
81
|
+
mockCrux([{ status: 200, body: { record } }]);
|
|
82
|
+
await pageWebVitals("https://example.com/", GA, {
|
|
83
|
+
formFactor: "DESKTOP",
|
|
84
|
+
});
|
|
85
|
+
expect(JSON.parse(global.fetch.mock.calls[0][1].body).formFactor).toBe(
|
|
86
|
+
"DESKTOP",
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
localCache.clear();
|
|
90
|
+
mockCrux([{ status: 200, body: { record } }]);
|
|
91
|
+
await pageWebVitals("https://example.com/", GA, { formFactor: null });
|
|
92
|
+
// Omitted entirely — CrUX reads a missing formFactor as all devices.
|
|
93
|
+
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).not.toHaveProperty(
|
|
94
|
+
"formFactor",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("caches per form factor", async () => {
|
|
99
|
+
// One URL-level hit per segment.
|
|
100
|
+
mockCrux([
|
|
101
|
+
{ status: 200, body: { record } },
|
|
102
|
+
{ status: 200, body: { record } },
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
await pageWebVitals("https://example.com/", GA);
|
|
106
|
+
await pageWebVitals("https://example.com/", GA, { formFactor: "DESKTOP" });
|
|
107
|
+
|
|
108
|
+
// Two requests: the segments must not share a cache entry.
|
|
109
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
110
|
+
|
|
111
|
+
// A repeat of either segment is served from cache.
|
|
112
|
+
await pageWebVitals("https://example.com/", GA);
|
|
113
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("caches by cacheKey rather than by url when one is given", async () => {
|
|
117
|
+
mockCrux([
|
|
118
|
+
{ status: 200, body: { record } },
|
|
119
|
+
{ status: 200, body: { record } },
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
// Same resource, url rewritten between calls — still one request.
|
|
123
|
+
await pageWebVitals("https://example.com/old", GA, {
|
|
124
|
+
cacheKey: "page:42",
|
|
125
|
+
});
|
|
126
|
+
await pageWebVitals("https://example.com/new", GA, {
|
|
127
|
+
cacheKey: "page:42",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
131
|
+
|
|
132
|
+
// Two resources sharing a url keep their own entries.
|
|
133
|
+
await pageWebVitals("https://example.com/old", GA, {
|
|
134
|
+
cacheKey: "page:43",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("shapes URL-level field data", async () => {
|
|
141
|
+
mockCrux([{ status: 200, body: { record } }]);
|
|
142
|
+
|
|
143
|
+
const result = await pageWebVitals("https://example.com/pricing", GA);
|
|
144
|
+
|
|
145
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(result.scope).toBe("url");
|
|
147
|
+
expect(result.mock).toBeUndefined();
|
|
148
|
+
expect(result.assessment).toBe("good");
|
|
149
|
+
expect(result.metrics.map((m) => m.key)).toEqual(["lcp", "inp", "cls"]);
|
|
150
|
+
expect(result.metrics[0]).toMatchObject({
|
|
151
|
+
p75: 1200,
|
|
152
|
+
rating: "good",
|
|
153
|
+
thresholds: { good: 2500, poor: 4000 },
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("assessment takes the worst of the three core metrics", async () => {
|
|
158
|
+
const withCore = (lcp, inp, cls) => ({
|
|
159
|
+
...record,
|
|
160
|
+
metrics: {
|
|
161
|
+
largest_contentful_paint: { percentiles: { p75: lcp }, histogram: [] },
|
|
162
|
+
interaction_to_next_paint: { percentiles: { p75: inp }, histogram: [] },
|
|
163
|
+
cumulative_layout_shift: { percentiles: { p75: cls }, histogram: [] },
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// All good; one needs-improvement (LCP 3s); one poor (CLS 0.4) even though
|
|
168
|
+
// the others are fine.
|
|
169
|
+
const cases = [
|
|
170
|
+
[withCore(1200, 90, "0.05"), "good"],
|
|
171
|
+
[withCore(3000, 90, "0.05"), "needs-improvement"],
|
|
172
|
+
[withCore(1200, 90, "0.4"), "poor"],
|
|
173
|
+
[withCore(3000, 600, "0.4"), "poor"],
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
for (const [body, expected] of cases) {
|
|
177
|
+
localCache.clear();
|
|
178
|
+
mockCrux([{ status: 200, body: { record: body } }]);
|
|
179
|
+
|
|
180
|
+
const result = await pageWebVitals("https://example.com/pricing", GA);
|
|
181
|
+
expect(result.assessment).toBe(expected);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("falls back to origin-level data when the URL has none", async () => {
|
|
186
|
+
mockCrux([{ status: 404 }, { status: 200, body: { record } }]);
|
|
187
|
+
|
|
188
|
+
const result = await pageWebVitals("https://example.com/pricing", GA);
|
|
189
|
+
|
|
190
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
191
|
+
expect(result.scope).toBe("origin");
|
|
192
|
+
expect(result.mock).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("returns an unmeasured report when CrUX has no data at all", async () => {
|
|
196
|
+
mockCrux([{ status: 404 }, { status: 404 }]);
|
|
197
|
+
|
|
198
|
+
const result = await pageWebVitals("https://example.com/pricing", GA);
|
|
199
|
+
|
|
200
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
201
|
+
expect(result.noData).toBe(true);
|
|
202
|
+
expect(result.scope).toBe("none");
|
|
203
|
+
expect(result.assessment).toBeNull();
|
|
204
|
+
expect(result.collectionPeriod).toBeNull();
|
|
205
|
+
|
|
206
|
+
// Full card set so the gauges still render their bands, but with no
|
|
207
|
+
// measurements at all.
|
|
208
|
+
expect(result.metrics.map((m) => m.key)).toEqual([
|
|
209
|
+
"lcp",
|
|
210
|
+
"inp",
|
|
211
|
+
"cls",
|
|
212
|
+
"fcp",
|
|
213
|
+
"ttfb",
|
|
214
|
+
]);
|
|
215
|
+
expect(result.metrics.every((m) => m.p75 === 0 && m.rating === null)).toBe(
|
|
216
|
+
true,
|
|
217
|
+
);
|
|
218
|
+
expect(result.metrics[0].thresholds).toEqual({ good: 2500, poor: 4000 });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("non-404 CrUX errors are logged, not returned", async () => {
|
|
222
|
+
mockCrux([{ status: 400, body: { error: { message: "bad request" } } }]);
|
|
223
|
+
|
|
224
|
+
// `query` throws "CrUX API 400: ...", but localCache.fetch catches it,
|
|
225
|
+
// console.errors it and resolves to null (see localCache.js). The caller
|
|
226
|
+
// therefore cannot tell an API failure from a cache miss — only the server
|
|
227
|
+
// log has the real reason.
|
|
228
|
+
await expect(pageWebVitals("https://example.com/", GA)).resolves.toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("requires an API key and a page URL", async () => {
|
|
232
|
+
await expect(pageWebVitals("https://example.com/", {})).rejects.toThrow(
|
|
233
|
+
/CrUX API key is not set/,
|
|
234
|
+
);
|
|
235
|
+
await expect(pageWebVitals(undefined, GA)).rejects.toThrow(
|
|
236
|
+
/No public URL for this resource/,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|