@raketa-cloud/admin 1.8.18 → 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.
Files changed (54) hide show
  1. package/dist/application.css +288 -11
  2. package/package.json +8 -6
  3. package/src/handlers/resources/ReportsAPIHandler.js +27 -0
  4. package/src/handlers/resources/ResourcesHandler.js +16 -0
  5. package/src/helpers/reports/cwv.js +217 -0
  6. package/src/helpers/reports/googleAuth.js +149 -0
  7. package/src/helpers/reports/quick-insights.js +294 -0
  8. package/src/helpers/reports/reportsUtils.js +181 -0
  9. package/src/index.js +8 -0
  10. package/src/localCache.js +112 -0
  11. package/src/report/analytics.js +107 -0
  12. package/src/report/reports.js +390 -0
  13. package/src/routeAssets.js +74 -0
  14. package/src/routeResourceModule.js +24 -0
  15. package/src/routeStandard.js +7 -1
  16. package/src/styles/application.css +1 -1
  17. package/src/tests/handlers/RedirectsHandler.test/notes.test.js +56 -0
  18. package/src/tests/handlers/_shared/SettingsHandler.test/global_variables.test.js +54 -0
  19. package/src/tests/handlers/categories/update_defaults.test.js +35 -0
  20. package/src/tests/handlers/content_tasks/ContentTasksHandler.test/editors.test.js +29 -0
  21. package/src/tests/handlers/resource_revisions/PublishesHandler.test/schedule.test.js +137 -0
  22. package/src/tests/handlers/resource_revisions/TagHandler.test/default_locale_tags.test.js +162 -0
  23. package/src/tests/handlers/resource_revisions/WidgetHandler.test/defaults.test.js +61 -0
  24. package/src/tests/handlers/resources/DeletesHandler.test/no_redirect.test.js +55 -0
  25. package/src/tests/handlers/resources/ReportsAPIHandler.test/show.test.js +156 -0
  26. package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +8 -0
  27. package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +94 -0
  28. package/src/tests/handlers/resources/TranslationsHandler.test/edge_cases.test.js +211 -0
  29. package/src/tests/helpers/CSVExportHelper.branches.test.js +57 -0
  30. package/src/tests/helpers/FormBuilder.branches.test.js +151 -0
  31. package/src/tests/helpers/HTMLBuilder.test.js +51 -0
  32. package/src/tests/helpers/historyUtils.branches.test.js +36 -0
  33. package/src/tests/helpers/reports/cwv.test.js +239 -0
  34. package/src/tests/helpers/reports/quick-insights.test.js +593 -0
  35. package/src/tests/helpers/reports/reportsUtils.test.js +425 -0
  36. package/src/tests/helpers/utils.test.js +37 -0
  37. package/src/tests/helpers/view_helpers.test.js +87 -0
  38. package/src/tests/index.test.js +74 -0
  39. package/src/tests/localCache.test.js +236 -0
  40. package/src/tests/report/analytics.test.js +230 -0
  41. package/src/tests/report/reports.test.js +831 -0
  42. package/src/tests/routeAssets.test.js +120 -0
  43. package/src/tests/routeResourceModule.test.js +99 -2
  44. package/src/tests/routeStandard.test.js +3 -3
  45. package/src/tests/support/fakeDom.js +164 -0
  46. package/src/views/resources/_shared/_core_web_vitals.ejs +27 -0
  47. package/src/views/resources/_shared/_quick_insights.ejs +64 -0
  48. package/src/views/resources/_shared/_scheduled_revisions.ejs +88 -0
  49. package/src/views/resources/_shared/_title.ejs +16 -12
  50. package/src/views/resources/_shared/_toolbar.ejs +11 -21
  51. package/src/views/resources/_shared/_traffic_chart.ejs +27 -0
  52. package/src/views/resources/_shared/_translations_strip.ejs +13 -25
  53. package/src/views/resources/_shared/_vital_meter.ejs +54 -0
  54. package/src/views/resources/show.ejs +88 -98
@@ -0,0 +1,831 @@
1
+ import {
2
+ createDom,
3
+ el,
4
+ flushPromises,
5
+ installDom,
6
+ uninstallDom,
7
+ } from "../support/fakeDom";
8
+
9
+ const METRICS = ["views", "users", "avgDuration", "engagement"];
10
+
11
+ // The script groups thousands with the runtime's default locale, which differs
12
+ // between a bare `jest` run and CI, so derive the expectation the same way.
13
+ const THOUSANDS = (12345).toLocaleString();
14
+
15
+ function metricSlots() {
16
+ return METRICS.flatMap((metric) => [
17
+ el("span", { "data-report": "value", "data-metric": metric }),
18
+ el("span", { "data-report": "trend", "data-metric": metric }),
19
+ ]);
20
+ }
21
+
22
+ function gauge(metric, { unit = "ms", scaleMax = "4000" } = {}) {
23
+ const loader = el("span", {
24
+ "data-report": "vital-loader",
25
+ "data-loader": "",
26
+ });
27
+ const value = el("span", { "data-report": "vital-value" });
28
+ const arrow = el("span", { "data-report": "vital-arrow" });
29
+ const marker = el("div", { "data-report": "marker", class: "invisible" }, [
30
+ value,
31
+ arrow,
32
+ ]);
33
+
34
+ const element = el(
35
+ "div",
36
+ {
37
+ "data-report": "vital",
38
+ "data-metric": metric,
39
+ "data-unit": unit,
40
+ "data-scale-max": scaleMax,
41
+ },
42
+ [loader, marker],
43
+ );
44
+
45
+ return { element, loader, marker, value, arrow };
46
+ }
47
+
48
+ // Mirrors the skeleton markup rendered by _quick_insights.ejs,
49
+ // _traffic_chart.ejs and _core_web_vitals.ejs.
50
+ function buildPage() {
51
+ const range = el("span", { "data-report": "range" });
52
+ const chartLoader = el("div", {
53
+ "data-report": "chart-loader",
54
+ "data-loader": "",
55
+ });
56
+
57
+ const period = el("span", { "data-report": "cwv-period" });
58
+ const title = el("title");
59
+ const path = el("path");
60
+ const assessment = el(
61
+ "svg",
62
+ { "data-report": "cwv-assessment", class: "invisible" },
63
+ [title, path],
64
+ );
65
+
66
+ const gauges = {
67
+ lcp: gauge("lcp"),
68
+ cls: gauge("cls", { unit: "", scaleMax: "0.5" }),
69
+ inp: gauge("inp", { scaleMax: "500" }),
70
+ };
71
+
72
+ const grid = el(
73
+ "div",
74
+ { "data-report": "vitals-grid" },
75
+ Object.values(gauges).map((g) => g.element),
76
+ );
77
+
78
+ const insightsGrid = el(
79
+ "div",
80
+ { "data-report": "insights-grid" },
81
+ metricSlots(),
82
+ );
83
+ const chartCard = el("div", { "data-report": "chart-card" }, [chartLoader]);
84
+
85
+ const dom = createDom([
86
+ range,
87
+ insightsGrid,
88
+ chartCard,
89
+ period,
90
+ assessment,
91
+ grid,
92
+ ]);
93
+
94
+ return {
95
+ dom,
96
+ range,
97
+ insightsGrid,
98
+ chartCard,
99
+ chartLoader,
100
+ period,
101
+ assessment,
102
+ title,
103
+ path,
104
+ grid,
105
+ gauges,
106
+ value: (metric) =>
107
+ dom.document.querySelector(
108
+ `[data-report="value"][data-metric="${metric}"]`,
109
+ ),
110
+ trend: (metric) =>
111
+ dom.document.querySelector(
112
+ `[data-report="trend"][data-metric="${metric}"]`,
113
+ ),
114
+ };
115
+ }
116
+
117
+ const summary = (overrides = {}) => ({
118
+ views: { value: 12345, trend: 12.34 },
119
+ users: { value: 678, trend: -4.5 },
120
+ avgDuration: { seconds: 95.4, trend: 0 },
121
+ engagement: { rate: 0.6789, trend: null },
122
+ ...overrides,
123
+ });
124
+
125
+ const INSIGHTS = {
126
+ summary: summary(),
127
+ dateRange: { startDate: "2026-07-01", endDate: "2026-07-28" },
128
+ series: { dates: ["2026-07-01"], labels: ["Jul 1"], data: [5] },
129
+ publishDates: ["2026-07-01"],
130
+ error: null,
131
+ };
132
+
133
+ const WEB_VITALS = {
134
+ assessment: "good",
135
+ collectionPeriod: {
136
+ firstDate: { year: 2026, month: 7, day: 1 },
137
+ lastDate: { year: 2026, month: 7, day: 28 },
138
+ },
139
+ metrics: [
140
+ { key: "lcp", p75: 2500, rating: "good" },
141
+ { key: "cls", p75: 0.12, rating: "needs-improvement" },
142
+ { key: "inp", p75: null, rating: null },
143
+ ],
144
+ error: null,
145
+ };
146
+
147
+ function loadScript() {
148
+ jest.isolateModules(() => {
149
+ require("../../report/reports");
150
+ });
151
+ }
152
+
153
+ // The script kicks off an async fetch on import and nothing exposes the
154
+ // promise, so drain the microtask queue after loading.
155
+ async function run(page, { reports, path = "/en/report/pages/1" } = {}) {
156
+ if (path) page.dom.window.RAKETA_REPORTS = { path };
157
+ page.dom.window.gaRenderTrafficChart = jest.fn();
158
+ installDom(page.dom);
159
+
160
+ loadScript();
161
+ await flushPromises();
162
+
163
+ return reports;
164
+ }
165
+
166
+ function mockFetch(body, { ok = true, status = 200 } = {}) {
167
+ const json = jest.fn(async () => body);
168
+ globalThis.fetch = jest.fn(async () => ({ ok, status, json }));
169
+ return globalThis.fetch;
170
+ }
171
+
172
+ describe("report/reports.js", () => {
173
+ beforeEach(() => {
174
+ jest.spyOn(console, "error").mockImplementation(() => {});
175
+ });
176
+
177
+ afterEach(() => {
178
+ uninstallDom();
179
+ delete globalThis.fetch;
180
+ jest.restoreAllMocks();
181
+ });
182
+
183
+ test("does nothing without a configured path", async () => {
184
+ mockFetch({});
185
+ const page = buildPage();
186
+
187
+ await run(page, { path: null });
188
+
189
+ expect(globalThis.fetch).not.toHaveBeenCalled();
190
+ });
191
+
192
+ test("fetches the reports endpoint as JSON", async () => {
193
+ const fetchMock = mockFetch({ insights: INSIGHTS, webVitals: WEB_VITALS });
194
+ const page = buildPage();
195
+
196
+ await run(page);
197
+
198
+ expect(fetchMock).toHaveBeenCalledWith("/en/report/pages/1", {
199
+ headers: { accept: "application/json" },
200
+ credentials: "same-origin",
201
+ });
202
+ });
203
+
204
+ describe("insights", () => {
205
+ let page;
206
+
207
+ beforeEach(async () => {
208
+ mockFetch({ insights: INSIGHTS, webVitals: WEB_VITALS });
209
+ page = buildPage();
210
+ await run(page);
211
+ });
212
+
213
+ test("renders the date range", () => {
214
+ expect(page.range.textContent).toEqual("Jul 1, 2026 → Jul 28, 2026");
215
+ });
216
+
217
+ test("formats each summary value", () => {
218
+ expect(page.value("views").textContent).toEqual(THOUSANDS);
219
+ expect(page.value("users").textContent).toEqual("678");
220
+ expect(page.value("avgDuration").textContent).toEqual("1m 35s");
221
+ expect(page.value("engagement").textContent).toEqual("67.9%");
222
+ });
223
+
224
+ test("colours a positive trend green and a negative one red", () => {
225
+ expect(page.trend("views").textContent).toEqual("+12.3%");
226
+ expect(page.trend("views").classList.contains("text-emerald-600")).toBe(
227
+ true,
228
+ );
229
+
230
+ expect(page.trend("users").textContent).toEqual("-4.5%");
231
+ expect(page.trend("users").classList.contains("text-red-600")).toBe(true);
232
+ });
233
+
234
+ test("treats a flat trend as positive", () => {
235
+ expect(page.trend("avgDuration").textContent).toEqual("+0.0%");
236
+ });
237
+
238
+ test("renders a dash for a missing trend", () => {
239
+ expect(page.trend("engagement").textContent).toEqual("—");
240
+ });
241
+
242
+ // A page that had no traffic last period is growing, not unmeasured.
243
+ test("calls a metric new when the previous period was zero", async () => {
244
+ mockFetch({
245
+ insights: {
246
+ ...INSIGHTS,
247
+ summary: summary({
248
+ views: { value: 200, previous: 0, trend: null },
249
+ users: { value: 0, previous: 0, trend: null },
250
+ }),
251
+ },
252
+ webVitals: WEB_VITALS,
253
+ });
254
+ const fresh = buildPage();
255
+
256
+ await run(fresh);
257
+
258
+ expect(fresh.trend("views").textContent).toEqual("New");
259
+ expect(fresh.trend("views").classList.contains("text-emerald-600")).toBe(
260
+ true,
261
+ );
262
+ // Nothing then, nothing now — there is still no trend to show.
263
+ expect(fresh.trend("users").textContent).toEqual("—");
264
+ });
265
+
266
+ test("drops the muted placeholder class off filled values", () => {
267
+ expect(
268
+ page.trend("views").classList.contains("text-raketa-foreground/50"),
269
+ ).toBe(false);
270
+ });
271
+
272
+ test("dashes out every metric when the report failed", async () => {
273
+ mockFetch({ insights: { error: "boom" }, webVitals: WEB_VITALS });
274
+ const failed = buildPage();
275
+
276
+ await run(failed);
277
+
278
+ METRICS.forEach((metric) => {
279
+ expect(failed.value(metric).textContent).toEqual("—");
280
+ expect(failed.trend(metric).textContent).toEqual("—");
281
+ });
282
+ });
283
+
284
+ // "No traffic" and "the report never arrived" are different answers.
285
+ test("shows why the report failed instead of a date range", async () => {
286
+ mockFetch({
287
+ insights: { error: "Google Analytics is not configured." },
288
+ webVitals: WEB_VITALS,
289
+ });
290
+ const failed = buildPage();
291
+
292
+ await run(failed);
293
+
294
+ expect(failed.range.textContent).toEqual(
295
+ "Google Analytics is not configured.",
296
+ );
297
+ expect(failed.range.classList.contains("text-red-600")).toBe(true);
298
+ expect(console.error).toHaveBeenCalledWith(
299
+ "Insights report failed:",
300
+ "Google Analytics is not configured.",
301
+ );
302
+ });
303
+
304
+ test("truncates a long error and logs it in full", async () => {
305
+ const error = "GA Data API 403: " + "x".repeat(400);
306
+ mockFetch({ insights: { error }, webVitals: WEB_VITALS });
307
+ const failed = buildPage();
308
+
309
+ await run(failed);
310
+
311
+ expect(failed.range.textContent).toHaveLength(161);
312
+ expect(failed.range.textContent.startsWith("GA Data API 403: x")).toBe(
313
+ true,
314
+ );
315
+ expect(console.error).toHaveBeenCalledWith(
316
+ "Insights report failed:",
317
+ error,
318
+ );
319
+ });
320
+
321
+ test("dashes out every metric when the summary is absent", async () => {
322
+ mockFetch({ insights: { dateRange: null }, webVitals: WEB_VITALS });
323
+ const empty = buildPage();
324
+
325
+ await run(empty);
326
+
327
+ expect(empty.value("views").textContent).toEqual("—");
328
+ });
329
+
330
+ test("leaves the range untouched when the payload has no dateRange", async () => {
331
+ mockFetch({
332
+ insights: { summary: summary(), series: INSIGHTS.series },
333
+ webVitals: WEB_VITALS,
334
+ });
335
+ const noRange = buildPage();
336
+
337
+ await run(noRange);
338
+
339
+ expect(noRange.range.textContent).toEqual("");
340
+ expect(noRange.value("views").textContent).toEqual(THOUSANDS);
341
+ });
342
+ });
343
+
344
+ describe("insights without traffic", () => {
345
+ // GA answers a page with no traffic with zeros, not an error, so the script
346
+ // has to read "no data" off the numbers.
347
+ const EMPTY_INSIGHTS = {
348
+ summary: {
349
+ views: { value: 0, trend: null },
350
+ users: { value: 0, trend: null },
351
+ avgDuration: { seconds: 0, trend: null },
352
+ engagement: { rate: 0, trend: null },
353
+ },
354
+ dateRange: { startDate: "2026-07-01", endDate: "2026-07-30" },
355
+ series: {
356
+ dates: ["2026-07-01", "2026-07-02"],
357
+ labels: ["a", "b"],
358
+ data: [0, 0],
359
+ },
360
+ publishDates: [],
361
+ error: null,
362
+ };
363
+
364
+ test("notes the empty range beside the title instead of the dates", async () => {
365
+ mockFetch({ insights: EMPTY_INSIGHTS, webVitals: WEB_VITALS });
366
+ const page = buildPage();
367
+
368
+ await run(page);
369
+
370
+ expect(page.range.textContent).toEqual(
371
+ "There is not enough data for the past 30 days to generate a report.",
372
+ );
373
+ });
374
+
375
+ test("mutes the metric grid and the chart card", async () => {
376
+ mockFetch({ insights: EMPTY_INSIGHTS, webVitals: WEB_VITALS });
377
+ const page = buildPage();
378
+
379
+ await run(page);
380
+
381
+ for (const hook of [page.insightsGrid, page.chartCard]) {
382
+ expect(hook.className).toContain("grayscale");
383
+ expect(hook.className).toContain("opacity-50");
384
+ }
385
+ });
386
+
387
+ test("keeps the zeros on the cards", async () => {
388
+ mockFetch({ insights: EMPTY_INSIGHTS, webVitals: WEB_VITALS });
389
+ const page = buildPage();
390
+
391
+ await run(page);
392
+
393
+ expect(page.value("views").textContent).toEqual("0");
394
+ expect(page.trend("views").textContent).toEqual("—");
395
+ });
396
+
397
+ test("replaces the chart with a note rather than plotting zeros", async () => {
398
+ mockFetch({ insights: EMPTY_INSIGHTS, webVitals: WEB_VITALS });
399
+ const page = buildPage();
400
+
401
+ await run(page);
402
+
403
+ expect(page.dom.window.gaRenderTrafficChart).not.toHaveBeenCalled();
404
+ expect(page.chartLoader.removed).toBeFalsy();
405
+ expect(page.chartLoader.textContent).toEqual("Not enough data.");
406
+ });
407
+
408
+ test("still plots when only the series carries traffic", async () => {
409
+ mockFetch({
410
+ insights: {
411
+ ...EMPTY_INSIGHTS,
412
+ series: { ...EMPTY_INSIGHTS.series, data: [0, 3] },
413
+ },
414
+ webVitals: WEB_VITALS,
415
+ });
416
+ const page = buildPage();
417
+
418
+ await run(page);
419
+
420
+ expect(page.dom.window.gaRenderTrafficChart).toHaveBeenCalled();
421
+ expect(page.insightsGrid.className).not.toContain("grayscale");
422
+ });
423
+ });
424
+
425
+ describe("traffic chart", () => {
426
+ test("hands the series to the chart renderer and clears its loader", async () => {
427
+ mockFetch({ insights: INSIGHTS, webVitals: WEB_VITALS });
428
+ const page = buildPage();
429
+
430
+ await run(page);
431
+
432
+ expect(page.dom.window.gaRenderTrafficChart).toHaveBeenCalledWith({
433
+ series: INSIGHTS.series,
434
+ publishDates: INSIGHTS.publishDates,
435
+ });
436
+ expect(page.chartLoader.removed).toBe(true);
437
+ });
438
+
439
+ test("defaults the publish markers to empty", async () => {
440
+ mockFetch({
441
+ insights: { ...INSIGHTS, publishDates: undefined },
442
+ webVitals: WEB_VITALS,
443
+ });
444
+ const page = buildPage();
445
+
446
+ await run(page);
447
+
448
+ expect(page.dom.window.gaRenderTrafficChart).toHaveBeenCalledWith({
449
+ series: INSIGHTS.series,
450
+ publishDates: [],
451
+ });
452
+ });
453
+
454
+ test("skips rendering when the payload carries no series", async () => {
455
+ mockFetch({
456
+ insights: { summary: summary(), dateRange: INSIGHTS.dateRange },
457
+ webVitals: WEB_VITALS,
458
+ });
459
+ const page = buildPage();
460
+
461
+ await run(page);
462
+
463
+ expect(page.dom.window.gaRenderTrafficChart).not.toHaveBeenCalled();
464
+ });
465
+
466
+ test("logs when analytics.js has not loaded first", async () => {
467
+ mockFetch({ insights: INSIGHTS, webVitals: WEB_VITALS });
468
+ const page = buildPage();
469
+ page.dom.window.RAKETA_REPORTS = { path: "/reports" };
470
+ installDom(page.dom);
471
+
472
+ loadScript();
473
+ await flushPromises();
474
+
475
+ expect(console.error).toHaveBeenCalledWith(
476
+ expect.stringContaining("Traffic chart not drawn"),
477
+ );
478
+ });
479
+ });
480
+
481
+ describe("core web vitals", () => {
482
+ let page;
483
+
484
+ beforeEach(async () => {
485
+ mockFetch({ insights: INSIGHTS, webVitals: WEB_VITALS });
486
+ page = buildPage();
487
+ await run(page);
488
+ });
489
+
490
+ test("renders the collection period", () => {
491
+ expect(page.period.textContent).toEqual("Jul 1, 2026 → Jul 28, 2026");
492
+ });
493
+
494
+ test("shows the assessment icon for the reported state", () => {
495
+ expect(page.assessment.classList.contains("invisible")).toBe(false);
496
+ expect(page.assessment.classList.contains("text-emerald-500")).toBe(true);
497
+ expect(page.assessment.getAttribute("aria-label")).toEqual(
498
+ "Core Web Vitals Assessment: Passed",
499
+ );
500
+ expect(page.title.textContent).toEqual(
501
+ "Core Web Vitals Assessment: Passed",
502
+ );
503
+ expect(page.path.getAttribute("d")).toEqual(expect.any(String));
504
+ });
505
+
506
+ test("positions the marker and formats a millisecond metric", () => {
507
+ const { marker, value, arrow, loader } = page.gauges.lcp;
508
+
509
+ expect(loader.removed).toBe(true);
510
+ expect(marker.style.left).toEqual("62.50%");
511
+ expect(marker.classList.contains("invisible")).toBe(false);
512
+ expect(value.textContent).toEqual("2.50s");
513
+ expect(value.classList.contains("text-emerald-600")).toBe(true);
514
+ expect(arrow.classList.contains("text-emerald-600")).toBe(true);
515
+ });
516
+
517
+ test("formats a unitless metric to two decimals", () => {
518
+ const { marker, value } = page.gauges.cls;
519
+
520
+ expect(value.textContent).toEqual("0.12");
521
+ expect(value.classList.contains("text-amber-500")).toBe(true);
522
+ expect(marker.style.left).toEqual("24.00%");
523
+ });
524
+
525
+ test("labels an unmeasured gauge instead of guessing a position", () => {
526
+ const { loader, marker } = page.gauges.inp;
527
+
528
+ expect(loader.removed).toBe(false);
529
+ expect(loader.textContent).toEqual("No data");
530
+ expect(loader.hasAttribute("data-loader")).toBe(false);
531
+ expect(marker.classList.contains("invisible")).toBe(true);
532
+ });
533
+
534
+ test("keeps the grid at full colour once anything is measured", () => {
535
+ expect(page.grid.classList.contains("grayscale")).toBe(false);
536
+ });
537
+
538
+ test("sub-second values keep millisecond units", async () => {
539
+ mockFetch({
540
+ insights: INSIGHTS,
541
+ webVitals: {
542
+ ...WEB_VITALS,
543
+ metrics: [{ key: "lcp", p75: 812.4, rating: "poor" }],
544
+ },
545
+ });
546
+ const fast = buildPage();
547
+
548
+ await run(fast);
549
+
550
+ expect(fast.gauges.lcp.value.textContent).toEqual("812ms");
551
+ expect(fast.gauges.lcp.value.classList.contains("text-red-600")).toBe(
552
+ true,
553
+ );
554
+ });
555
+
556
+ test("clamps the marker inside the gauge", async () => {
557
+ mockFetch({
558
+ insights: INSIGHTS,
559
+ webVitals: {
560
+ ...WEB_VITALS,
561
+ metrics: [
562
+ { key: "lcp", p75: 40000, rating: "poor" },
563
+ { key: "cls", p75: 0, rating: "good" },
564
+ ],
565
+ },
566
+ });
567
+ const clamped = buildPage();
568
+
569
+ await run(clamped);
570
+
571
+ expect(clamped.gauges.lcp.marker.style.left).toEqual("99.00%");
572
+ expect(clamped.gauges.cls.marker.style.left).toEqual("1.00%");
573
+ });
574
+
575
+ test("falls back to a muted colour for an unknown rating", async () => {
576
+ mockFetch({
577
+ insights: INSIGHTS,
578
+ webVitals: {
579
+ ...WEB_VITALS,
580
+ metrics: [{ key: "lcp", p75: 2500, rating: "unrecognised" }],
581
+ },
582
+ });
583
+ const unknown = buildPage();
584
+
585
+ await run(unknown);
586
+
587
+ expect(
588
+ unknown.gauges.lcp.value.classList.contains(
589
+ "text-raketa-foreground/50",
590
+ ),
591
+ ).toBe(true);
592
+ });
593
+ });
594
+
595
+ describe("core web vitals without data", () => {
596
+ let page;
597
+
598
+ beforeEach(async () => {
599
+ mockFetch({ insights: INSIGHTS, webVitals: { error: "no crux data" } });
600
+ page = buildPage();
601
+ await run(page);
602
+ });
603
+
604
+ test("reports what went wrong rather than blaming the data", () => {
605
+ expect(page.period.textContent).toEqual("no crux data");
606
+ expect(page.period.classList.contains("text-red-600")).toBe(true);
607
+ expect(console.error).toHaveBeenCalledWith(
608
+ "Core Web Vitals report failed:",
609
+ "no crux data",
610
+ );
611
+ });
612
+
613
+ test("keeps the no-data note for a page CrUX simply has not measured", async () => {
614
+ mockFetch({
615
+ insights: INSIGHTS,
616
+ webVitals: { assessment: "unknown", metrics: [], error: null },
617
+ });
618
+ const unmeasured = buildPage();
619
+
620
+ await run(unmeasured);
621
+
622
+ expect(unmeasured.period.textContent).toEqual(
623
+ "There is not enough data for the past 28 days to generate a report.",
624
+ );
625
+ expect(unmeasured.period.classList.contains("text-red-600")).toBe(false);
626
+ });
627
+
628
+ test("desaturates the gauges", () => {
629
+ expect(page.grid.classList.contains("grayscale")).toBe(true);
630
+ expect(page.grid.classList.contains("opacity-50")).toBe(true);
631
+ });
632
+
633
+ test("falls back to the unknown assessment icon", () => {
634
+ expect(page.assessment.classList.contains("text-orange-500")).toBe(true);
635
+ expect(page.assessment.getAttribute("aria-label")).toEqual(
636
+ "Core Web Vitals Assessment: no real-user data yet",
637
+ );
638
+ });
639
+
640
+ test("marks every gauge as having no data", () => {
641
+ Object.values(page.gauges).forEach(({ loader }) => {
642
+ expect(loader.textContent).toEqual("No data");
643
+ });
644
+ });
645
+
646
+ test("renders an empty period when the collection period is malformed", async () => {
647
+ mockFetch({
648
+ insights: INSIGHTS,
649
+ webVitals: {
650
+ assessment: "poor",
651
+ collectionPeriod: { firstDate: { year: 2026, month: 7, day: 1 } },
652
+ metrics: [{ key: "lcp", p75: 2500, rating: "poor" }],
653
+ },
654
+ });
655
+ const malformed = buildPage();
656
+
657
+ await run(malformed);
658
+
659
+ expect(malformed.period.textContent).toEqual("");
660
+ expect(malformed.assessment.classList.contains("text-red-500")).toBe(
661
+ true,
662
+ );
663
+ });
664
+ });
665
+
666
+ describe("request failures", () => {
667
+ test("stops the spinners when the response is not ok", async () => {
668
+ mockFetch(null, { ok: false, status: 500 });
669
+ const page = buildPage();
670
+
671
+ await run(page);
672
+
673
+ expect(page.chartLoader.removed).toBe(true);
674
+ // clearLoaders() takes every spinner with it, the gauges included, so
675
+ // there is nothing left to relabel "No data".
676
+ expect(page.gauges.lcp.loader.removed).toBe(true);
677
+ expect(page.gauges.lcp.marker.classList.contains("invisible")).toBe(true);
678
+ // Both sections name the request as the cause, not the data.
679
+ const failure = "The reports request failed (500).";
680
+ expect(page.period.textContent).toEqual(failure);
681
+ expect(page.range.textContent).toEqual(failure);
682
+ expect(page.dom.window.gaRenderTrafficChart).not.toHaveBeenCalled();
683
+ });
684
+
685
+ test("stops the spinners when the request throws", async () => {
686
+ const error = new Error("offline");
687
+ globalThis.fetch = jest.fn(async () => {
688
+ throw error;
689
+ });
690
+ const page = buildPage();
691
+
692
+ await run(page);
693
+
694
+ expect(page.range.textContent).toEqual(
695
+ "The reports request failed: offline",
696
+ );
697
+ expect(page.chartLoader.removed).toBe(true);
698
+ });
699
+ });
700
+
701
+ describe("partial markup", () => {
702
+ // Each section renders independently, so the script has to survive a page
703
+ // that only shows some of them.
704
+ test("survives a page with none of the report slots", async () => {
705
+ mockFetch({ insights: INSIGHTS, webVitals: WEB_VITALS });
706
+ const bare = { dom: createDom([]) };
707
+
708
+ await expect(run(bare)).resolves.not.toThrow();
709
+ expect(console.error).not.toHaveBeenCalled();
710
+ });
711
+
712
+ test("survives a page whose slots are missing on a failed report", async () => {
713
+ mockFetch({ insights: { error: "boom" }, webVitals: { error: "boom" } });
714
+ const bare = { dom: createDom([]) };
715
+
716
+ await expect(run(bare)).resolves.not.toThrow();
717
+ });
718
+
719
+ test("colours a bare assessment icon that has no title or path", async () => {
720
+ mockFetch({ insights: INSIGHTS, webVitals: WEB_VITALS });
721
+ const icon = el("svg", {
722
+ "data-report": "cwv-assessment",
723
+ class: "invisible",
724
+ });
725
+
726
+ await run({ dom: createDom([icon]) });
727
+
728
+ expect(icon.classList.contains("invisible")).toBe(false);
729
+ expect(icon.getAttribute("aria-label")).toEqual(
730
+ "Core Web Vitals Assessment: Passed",
731
+ );
732
+ });
733
+
734
+ test("skips a gauge that has no marker", async () => {
735
+ mockFetch({ insights: INSIGHTS, webVitals: WEB_VITALS });
736
+ const loader = el("span", {
737
+ "data-report": "vital-loader",
738
+ "data-loader": "",
739
+ });
740
+ const markerless = el(
741
+ "div",
742
+ {
743
+ "data-report": "vital",
744
+ "data-metric": "lcp",
745
+ "data-unit": "ms",
746
+ },
747
+ [loader],
748
+ );
749
+
750
+ await run({ dom: createDom([markerless]) });
751
+
752
+ expect(loader.removed).toBe(true);
753
+ });
754
+
755
+ test("skips an unmeasured gauge that has no loader", async () => {
756
+ mockFetch({
757
+ insights: INSIGHTS,
758
+ webVitals: { ...WEB_VITALS, metrics: [] },
759
+ });
760
+ const marker = el("div", { "data-report": "marker" });
761
+ const loaderless = el(
762
+ "div",
763
+ { "data-report": "vital", "data-metric": "lcp" },
764
+ [marker],
765
+ );
766
+
767
+ await run({ dom: createDom([loaderless]) });
768
+
769
+ expect(marker.style.left).toBeUndefined();
770
+ });
771
+
772
+ test("defaults empty summary values to zero", async () => {
773
+ mockFetch({
774
+ insights: {
775
+ summary: {
776
+ views: { value: null },
777
+ users: {},
778
+ avgDuration: {},
779
+ engagement: {},
780
+ },
781
+ series: null,
782
+ },
783
+ webVitals: WEB_VITALS,
784
+ });
785
+ const page = buildPage();
786
+
787
+ await run(page);
788
+
789
+ expect(page.value("views").textContent).toEqual("0");
790
+ expect(page.value("users").textContent).toEqual("0");
791
+ expect(page.value("avgDuration").textContent).toEqual("0m 0s");
792
+ expect(page.value("engagement").textContent).toEqual("0.0%");
793
+ });
794
+
795
+ test("falls back to a full-width scale when the gauge declares none", async () => {
796
+ mockFetch({
797
+ insights: INSIGHTS,
798
+ webVitals: {
799
+ ...WEB_VITALS,
800
+ metrics: [{ key: "cls", p75: 0.4, rating: "good" }],
801
+ },
802
+ });
803
+ const marker = el("div", { "data-report": "marker" });
804
+ const scaleless = el(
805
+ "div",
806
+ { "data-report": "vital", "data-metric": "cls", "data-unit": "" },
807
+ [marker],
808
+ );
809
+
810
+ await run({ dom: createDom([scaleless]) });
811
+
812
+ expect(marker.style.left).toEqual("40.00%");
813
+ });
814
+ });
815
+
816
+ test("waits for DOMContentLoaded while the document is still loading", async () => {
817
+ mockFetch({ insights: INSIGHTS, webVitals: WEB_VITALS });
818
+ const page = buildPage();
819
+ page.dom.document.readyState = "loading";
820
+
821
+ await run(page);
822
+
823
+ expect(globalThis.fetch).not.toHaveBeenCalled();
824
+
825
+ page.dom.document.dispatchEvent("DOMContentLoaded");
826
+ await flushPromises();
827
+
828
+ expect(globalThis.fetch).toHaveBeenCalled();
829
+ expect(page.value("views").textContent).toEqual(THOUSANDS);
830
+ });
831
+ });