@slashgear/gdpr-cookie-scanner 3.0.0 → 3.2.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/.github/workflows/update-trackers.yml +95 -0
- package/CHANGELOG.md +79 -0
- package/README.md +158 -9
- package/dist/analyzers/compliance.d.ts.map +1 -1
- package/dist/analyzers/compliance.js +33 -0
- package/dist/analyzers/compliance.js.map +1 -1
- package/dist/classifiers/cookie-classifier.d.ts.map +1 -1
- package/dist/classifiers/cookie-classifier.js +2 -1
- package/dist/classifiers/cookie-classifier.js.map +1 -1
- package/dist/classifiers/tracker-list.js +1 -1
- package/dist/classifiers/tracker-list.js.map +1 -1
- package/dist/cli.js +21 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/report/generator.d.ts.map +1 -1
- package/dist/report/generator.js +12 -8
- package/dist/report/generator.js.map +1 -1
- package/dist/scanner/index.js +4 -4
- package/dist/scanner/index.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -2
- package/scripts/update-trackers.ts +273 -0
- package/src/analyzers/compliance.ts +34 -0
- package/src/classifiers/cookie-classifier.ts +2 -1
- package/src/classifiers/tracker-list.ts +1 -1
- package/src/cli.ts +37 -1
- package/src/index.ts +87 -0
- package/src/report/generator.ts +13 -8
- package/src/scanner/index.ts +4 -4
- package/src/types.ts +1 -1
- package/tests/analyzers/compliance.test.ts +489 -0
- package/tests/analyzers/wording.test.ts +160 -0
- package/tests/classifiers/cookie-classifier.test.ts +270 -0
- package/tests/classifiers/network-classifier.test.ts +140 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { analyzeCompliance } from "../../src/analyzers/compliance.js";
|
|
3
|
+
import type {
|
|
4
|
+
ConsentModal,
|
|
5
|
+
ConsentButton,
|
|
6
|
+
ScannedCookie,
|
|
7
|
+
NetworkRequest,
|
|
8
|
+
} from "../../src/types.js";
|
|
9
|
+
|
|
10
|
+
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function makeModal(overrides: Partial<ConsentModal> = {}): ConsentModal {
|
|
13
|
+
return {
|
|
14
|
+
detected: true,
|
|
15
|
+
selector: "#cookie-banner",
|
|
16
|
+
text: "We use cookies for analytics purposes. Third-party vendors process data. Cookies are kept 13 months. You can withdraw consent at any time.",
|
|
17
|
+
buttons: [makeButton("accept", "Accept all", 1), makeButton("reject", "Reject all", 1)],
|
|
18
|
+
checkboxes: [],
|
|
19
|
+
hasGranularControls: true,
|
|
20
|
+
layerCount: 1,
|
|
21
|
+
screenshotPath: null,
|
|
22
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeButton(
|
|
28
|
+
type: ConsentButton["type"],
|
|
29
|
+
text: string,
|
|
30
|
+
clickDepth: number,
|
|
31
|
+
overrides: Partial<ConsentButton> = {},
|
|
32
|
+
): ConsentButton {
|
|
33
|
+
return {
|
|
34
|
+
type,
|
|
35
|
+
text,
|
|
36
|
+
selector: `#btn-${type}`,
|
|
37
|
+
isVisible: true,
|
|
38
|
+
boundingBox: { x: 0, y: 0, width: 120, height: 40 },
|
|
39
|
+
fontSize: 14,
|
|
40
|
+
backgroundColor: "rgb(0,0,0)",
|
|
41
|
+
textColor: "rgb(255,255,255)",
|
|
42
|
+
contrastRatio: 21,
|
|
43
|
+
clickDepth,
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeCookie(
|
|
49
|
+
name: string,
|
|
50
|
+
requiresConsent: boolean,
|
|
51
|
+
capturedAt: ScannedCookie["capturedAt"] = "before-interaction",
|
|
52
|
+
): ScannedCookie {
|
|
53
|
+
return {
|
|
54
|
+
name,
|
|
55
|
+
domain: "example.com",
|
|
56
|
+
path: "/",
|
|
57
|
+
value: "abc",
|
|
58
|
+
expires: null,
|
|
59
|
+
httpOnly: false,
|
|
60
|
+
secure: true,
|
|
61
|
+
sameSite: "Lax",
|
|
62
|
+
category: requiresConsent ? "analytics" : "strictly-necessary",
|
|
63
|
+
requiresConsent,
|
|
64
|
+
capturedAt,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeTracker(
|
|
69
|
+
capturedAt: NetworkRequest["capturedAt"] = "before-interaction",
|
|
70
|
+
): NetworkRequest {
|
|
71
|
+
return {
|
|
72
|
+
url: "https://google-analytics.com/collect",
|
|
73
|
+
method: "GET",
|
|
74
|
+
resourceType: "xhr",
|
|
75
|
+
initiator: null,
|
|
76
|
+
isThirdParty: true,
|
|
77
|
+
trackerCategory: "analytics",
|
|
78
|
+
trackerName: "Google Analytics",
|
|
79
|
+
requiresConsent: true,
|
|
80
|
+
capturedAt,
|
|
81
|
+
responseStatus: 200,
|
|
82
|
+
contentType: null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const EMPTY_INPUT = {
|
|
87
|
+
modal: makeModal({
|
|
88
|
+
detected: false,
|
|
89
|
+
selector: null,
|
|
90
|
+
text: "",
|
|
91
|
+
buttons: [],
|
|
92
|
+
checkboxes: [],
|
|
93
|
+
hasGranularControls: false,
|
|
94
|
+
privacyPolicyUrl: null,
|
|
95
|
+
}),
|
|
96
|
+
privacyPolicyUrl: null,
|
|
97
|
+
cookiesBeforeInteraction: [],
|
|
98
|
+
cookiesAfterAccept: [],
|
|
99
|
+
cookiesAfterReject: [],
|
|
100
|
+
networkBeforeInteraction: [],
|
|
101
|
+
networkAfterAccept: [],
|
|
102
|
+
networkAfterReject: [],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ── Grade thresholds ──────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
describe("grade thresholds", () => {
|
|
108
|
+
it("returns grade A (≥90) when site has no tracking at all", () => {
|
|
109
|
+
const result = analyzeCompliance(EMPTY_INPUT);
|
|
110
|
+
expect(result.grade).toBe("A");
|
|
111
|
+
expect(result.total).toBe(100);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns grade A when modal is compliant and cookies are clean", () => {
|
|
115
|
+
const result = analyzeCompliance({
|
|
116
|
+
modal: makeModal(),
|
|
117
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
118
|
+
cookiesBeforeInteraction: [makeCookie("session", false, "before-interaction")],
|
|
119
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
120
|
+
cookiesAfterReject: [],
|
|
121
|
+
networkBeforeInteraction: [],
|
|
122
|
+
networkAfterAccept: [],
|
|
123
|
+
networkAfterReject: [],
|
|
124
|
+
});
|
|
125
|
+
expect(result.grade).toBe("A");
|
|
126
|
+
expect(result.total).toBeGreaterThanOrEqual(90);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns grade F (<35) when there is no modal and non-essential cookies are set", () => {
|
|
130
|
+
const result = analyzeCompliance({
|
|
131
|
+
...EMPTY_INPUT,
|
|
132
|
+
cookiesBeforeInteraction: [makeCookie("_ga", true, "before-interaction")],
|
|
133
|
+
});
|
|
134
|
+
expect(result.grade).toBe("F");
|
|
135
|
+
expect(result.total).toBeLessThan(35);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── A. Consent validity ───────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe("consentValidity dimension", () => {
|
|
142
|
+
it("scores 0 when no modal and consent is required", () => {
|
|
143
|
+
const result = analyzeCompliance({
|
|
144
|
+
...EMPTY_INPUT,
|
|
145
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
146
|
+
});
|
|
147
|
+
expect(result.breakdown.consentValidity).toBe(0);
|
|
148
|
+
expect(result.issues.some((i) => i.type === "no-reject-button")).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("deducts 10 for pre-ticked checkboxes", () => {
|
|
152
|
+
const modal = makeModal({
|
|
153
|
+
checkboxes: [
|
|
154
|
+
{
|
|
155
|
+
name: "analytics",
|
|
156
|
+
label: "Analytics",
|
|
157
|
+
isCheckedByDefault: true,
|
|
158
|
+
category: "analytics",
|
|
159
|
+
selector: "#cb-analytics",
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
const result = analyzeCompliance({
|
|
164
|
+
modal,
|
|
165
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
166
|
+
cookiesBeforeInteraction: [],
|
|
167
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
168
|
+
cookiesAfterReject: [],
|
|
169
|
+
networkBeforeInteraction: [],
|
|
170
|
+
networkAfterAccept: [],
|
|
171
|
+
networkAfterReject: [],
|
|
172
|
+
});
|
|
173
|
+
expect(result.issues.some((i) => i.type === "pre-ticked")).toBe(true);
|
|
174
|
+
expect(result.breakdown.consentValidity).toBeLessThanOrEqual(15);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("deducts 5 per missing required info item", () => {
|
|
178
|
+
// Modal text with no required info keywords → 4 missing items → -5 for purposes, -5 for third-parties
|
|
179
|
+
const modal = makeModal({ text: "We use cookies." });
|
|
180
|
+
const result = analyzeCompliance({
|
|
181
|
+
modal,
|
|
182
|
+
privacyPolicyUrl: null,
|
|
183
|
+
cookiesBeforeInteraction: [],
|
|
184
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
185
|
+
cookiesAfterReject: [],
|
|
186
|
+
networkBeforeInteraction: [],
|
|
187
|
+
networkAfterAccept: [],
|
|
188
|
+
networkAfterReject: [],
|
|
189
|
+
});
|
|
190
|
+
expect(result.breakdown.consentValidity).toBeLessThan(20);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── B. Easy refusal ───────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
describe("easyRefusal dimension", () => {
|
|
197
|
+
it("scores 0 when no modal and consent is required", () => {
|
|
198
|
+
const result = analyzeCompliance({
|
|
199
|
+
...EMPTY_INPUT,
|
|
200
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
201
|
+
});
|
|
202
|
+
expect(result.breakdown.easyRefusal).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("deducts 15 when no reject button is present at first layer", () => {
|
|
206
|
+
const modal = makeModal({
|
|
207
|
+
buttons: [makeButton("accept", "Accept all", 1)],
|
|
208
|
+
});
|
|
209
|
+
const result = analyzeCompliance({
|
|
210
|
+
modal,
|
|
211
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
212
|
+
cookiesBeforeInteraction: [],
|
|
213
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
214
|
+
cookiesAfterReject: [],
|
|
215
|
+
networkBeforeInteraction: [],
|
|
216
|
+
networkAfterAccept: [],
|
|
217
|
+
networkAfterReject: [],
|
|
218
|
+
});
|
|
219
|
+
expect(result.breakdown.easyRefusal).toBeLessThanOrEqual(10);
|
|
220
|
+
expect(
|
|
221
|
+
result.issues.some((i) => i.type === "buried-reject" || i.type === "no-reject-button"),
|
|
222
|
+
).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("deducts 15 for click asymmetry (reject needs more clicks than accept)", () => {
|
|
226
|
+
const modal = makeModal({
|
|
227
|
+
buttons: [
|
|
228
|
+
makeButton("accept", "Accept all", 1),
|
|
229
|
+
makeButton("reject", "Reject all", 2), // 2 clicks vs 1
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
const result = analyzeCompliance({
|
|
233
|
+
modal,
|
|
234
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
235
|
+
cookiesBeforeInteraction: [],
|
|
236
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
237
|
+
cookiesAfterReject: [],
|
|
238
|
+
networkBeforeInteraction: [],
|
|
239
|
+
networkAfterAccept: [],
|
|
240
|
+
networkAfterReject: [],
|
|
241
|
+
});
|
|
242
|
+
expect(result.issues.some((i) => i.type === "click-asymmetry")).toBe(true);
|
|
243
|
+
expect(result.breakdown.easyRefusal).toBeLessThanOrEqual(10);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("deducts 5 when accept button area is 3× larger than reject", () => {
|
|
247
|
+
const modal = makeModal({
|
|
248
|
+
buttons: [
|
|
249
|
+
makeButton("accept", "Accept all", 1, {
|
|
250
|
+
boundingBox: { x: 0, y: 0, width: 360, height: 40 },
|
|
251
|
+
}), // 14 400 px²
|
|
252
|
+
makeButton("reject", "Reject all", 1, {
|
|
253
|
+
boundingBox: { x: 0, y: 0, width: 60, height: 20 },
|
|
254
|
+
}), // 1 200 px²
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
const result = analyzeCompliance({
|
|
258
|
+
modal,
|
|
259
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
260
|
+
cookiesBeforeInteraction: [],
|
|
261
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
262
|
+
cookiesAfterReject: [],
|
|
263
|
+
networkBeforeInteraction: [],
|
|
264
|
+
networkAfterAccept: [],
|
|
265
|
+
networkAfterReject: [],
|
|
266
|
+
});
|
|
267
|
+
expect(result.issues.some((i) => i.type === "asymmetric-prominence")).toBe(true);
|
|
268
|
+
expect(result.breakdown.easyRefusal).toBeLessThanOrEqual(20);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("deducts 5 for font-size nudging (accept font 1.3× larger)", () => {
|
|
272
|
+
const modal = makeModal({
|
|
273
|
+
buttons: [
|
|
274
|
+
makeButton("accept", "Accept all", 1, { fontSize: 20 }),
|
|
275
|
+
makeButton("reject", "Reject all", 1, { fontSize: 12 }),
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
const result = analyzeCompliance({
|
|
279
|
+
modal,
|
|
280
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
281
|
+
cookiesBeforeInteraction: [],
|
|
282
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
283
|
+
cookiesAfterReject: [],
|
|
284
|
+
networkBeforeInteraction: [],
|
|
285
|
+
networkAfterAccept: [],
|
|
286
|
+
networkAfterReject: [],
|
|
287
|
+
});
|
|
288
|
+
expect(result.issues.some((i) => i.type === "nudging")).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ── C. Transparency ───────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
describe("transparency dimension", () => {
|
|
295
|
+
it("deducts 10 when there are no granular controls", () => {
|
|
296
|
+
const modal = makeModal({ hasGranularControls: false, checkboxes: [] });
|
|
297
|
+
const before = analyzeCompliance({
|
|
298
|
+
modal: makeModal({ hasGranularControls: true }),
|
|
299
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
300
|
+
cookiesBeforeInteraction: [],
|
|
301
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
302
|
+
cookiesAfterReject: [],
|
|
303
|
+
networkBeforeInteraction: [],
|
|
304
|
+
networkAfterAccept: [],
|
|
305
|
+
networkAfterReject: [],
|
|
306
|
+
});
|
|
307
|
+
const after = analyzeCompliance({
|
|
308
|
+
modal,
|
|
309
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
310
|
+
cookiesBeforeInteraction: [],
|
|
311
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
312
|
+
cookiesAfterReject: [],
|
|
313
|
+
networkBeforeInteraction: [],
|
|
314
|
+
networkAfterAccept: [],
|
|
315
|
+
networkAfterReject: [],
|
|
316
|
+
});
|
|
317
|
+
expect(after.breakdown.transparency).toBeLessThan(before.breakdown.transparency);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("deducts 5 when no privacy policy link in modal", () => {
|
|
321
|
+
const modal = makeModal({ privacyPolicyUrl: null });
|
|
322
|
+
const result = analyzeCompliance({
|
|
323
|
+
modal,
|
|
324
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
325
|
+
cookiesBeforeInteraction: [],
|
|
326
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
327
|
+
cookiesAfterReject: [],
|
|
328
|
+
networkBeforeInteraction: [],
|
|
329
|
+
networkAfterAccept: [],
|
|
330
|
+
networkAfterReject: [],
|
|
331
|
+
});
|
|
332
|
+
expect(
|
|
333
|
+
result.issues.some((i) => i.type === "missing-info" && i.description.includes("modal")),
|
|
334
|
+
).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("deducts 3 when no privacy policy link found anywhere on the page", () => {
|
|
338
|
+
const result = analyzeCompliance({
|
|
339
|
+
modal: makeModal(),
|
|
340
|
+
privacyPolicyUrl: null,
|
|
341
|
+
cookiesBeforeInteraction: [],
|
|
342
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
343
|
+
cookiesAfterReject: [],
|
|
344
|
+
networkBeforeInteraction: [],
|
|
345
|
+
networkAfterAccept: [],
|
|
346
|
+
networkAfterReject: [],
|
|
347
|
+
});
|
|
348
|
+
expect(
|
|
349
|
+
result.issues.some((i) => i.type === "missing-info" && i.description.includes("on the page")),
|
|
350
|
+
).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ── D. Cookie behavior ────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
describe("cookieBehavior dimension", () => {
|
|
357
|
+
it("deducts for cookies set before any interaction", () => {
|
|
358
|
+
const result = analyzeCompliance({
|
|
359
|
+
modal: makeModal(),
|
|
360
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
361
|
+
cookiesBeforeInteraction: [makeCookie("_ga", true, "before-interaction")],
|
|
362
|
+
cookiesAfterAccept: [],
|
|
363
|
+
cookiesAfterReject: [],
|
|
364
|
+
networkBeforeInteraction: [],
|
|
365
|
+
networkAfterAccept: [],
|
|
366
|
+
networkAfterReject: [],
|
|
367
|
+
});
|
|
368
|
+
expect(
|
|
369
|
+
result.issues.some(
|
|
370
|
+
(i) => i.type === "auto-consent" && i.description.includes("before any interaction"),
|
|
371
|
+
),
|
|
372
|
+
).toBe(true);
|
|
373
|
+
expect(result.breakdown.cookieBehavior).toBeLessThan(25);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("caps cookie-before-consent deduction at 20", () => {
|
|
377
|
+
const manyCookies = Array.from({ length: 10 }, (_, i) =>
|
|
378
|
+
makeCookie(`_ga_${i}`, true, "before-interaction"),
|
|
379
|
+
);
|
|
380
|
+
const result = analyzeCompliance({
|
|
381
|
+
modal: makeModal(),
|
|
382
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
383
|
+
cookiesBeforeInteraction: manyCookies,
|
|
384
|
+
cookiesAfterAccept: [],
|
|
385
|
+
cookiesAfterReject: [],
|
|
386
|
+
networkBeforeInteraction: [],
|
|
387
|
+
networkAfterAccept: [],
|
|
388
|
+
networkAfterReject: [],
|
|
389
|
+
});
|
|
390
|
+
expect(result.breakdown.cookieBehavior).toBeGreaterThanOrEqual(5); // 25 - min(20, 10*4) = 5
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("deducts for non-essential cookies persisting after rejection", () => {
|
|
394
|
+
const result = analyzeCompliance({
|
|
395
|
+
modal: makeModal(),
|
|
396
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
397
|
+
cookiesBeforeInteraction: [],
|
|
398
|
+
cookiesAfterAccept: [],
|
|
399
|
+
cookiesAfterReject: [makeCookie("_ga", true, "after-reject")],
|
|
400
|
+
networkBeforeInteraction: [],
|
|
401
|
+
networkAfterAccept: [],
|
|
402
|
+
networkAfterReject: [],
|
|
403
|
+
});
|
|
404
|
+
expect(
|
|
405
|
+
result.issues.some(
|
|
406
|
+
(i) => i.type === "auto-consent" && i.description.includes("persist after rejection"),
|
|
407
|
+
),
|
|
408
|
+
).toBe(true);
|
|
409
|
+
expect(result.breakdown.cookieBehavior).toBeLessThan(25);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("deducts for tracker requests fired before consent", () => {
|
|
413
|
+
const result = analyzeCompliance({
|
|
414
|
+
modal: makeModal(),
|
|
415
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
416
|
+
cookiesBeforeInteraction: [],
|
|
417
|
+
cookiesAfterAccept: [],
|
|
418
|
+
cookiesAfterReject: [],
|
|
419
|
+
networkBeforeInteraction: [makeTracker("before-interaction")],
|
|
420
|
+
networkAfterAccept: [],
|
|
421
|
+
networkAfterReject: [],
|
|
422
|
+
});
|
|
423
|
+
expect(
|
|
424
|
+
result.issues.some((i) => i.type === "auto-consent" && i.description.includes("tracker")),
|
|
425
|
+
).toBe(true);
|
|
426
|
+
expect(result.breakdown.cookieBehavior).toBeLessThan(25);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("does NOT deduct for strictly-necessary cookies set before interaction", () => {
|
|
430
|
+
const result = analyzeCompliance({
|
|
431
|
+
modal: makeModal(),
|
|
432
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
433
|
+
cookiesBeforeInteraction: [makeCookie("PHPSESSID", false, "before-interaction")],
|
|
434
|
+
cookiesAfterAccept: [],
|
|
435
|
+
cookiesAfterReject: [],
|
|
436
|
+
networkBeforeInteraction: [],
|
|
437
|
+
networkAfterAccept: [],
|
|
438
|
+
networkAfterReject: [],
|
|
439
|
+
});
|
|
440
|
+
expect(result.breakdown.cookieBehavior).toBe(25);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ── Score clamping ────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
describe("score clamping", () => {
|
|
447
|
+
it("never produces a negative score dimension", () => {
|
|
448
|
+
const result = analyzeCompliance({
|
|
449
|
+
modal: makeModal({
|
|
450
|
+
detected: false,
|
|
451
|
+
selector: null,
|
|
452
|
+
text: "",
|
|
453
|
+
buttons: [],
|
|
454
|
+
checkboxes: [],
|
|
455
|
+
hasGranularControls: false,
|
|
456
|
+
privacyPolicyUrl: null,
|
|
457
|
+
}),
|
|
458
|
+
privacyPolicyUrl: null,
|
|
459
|
+
cookiesBeforeInteraction: Array.from({ length: 10 }, (_, i) => makeCookie(`_ga_${i}`, true)),
|
|
460
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
461
|
+
cookiesAfterReject: Array.from({ length: 10 }, (_, i) =>
|
|
462
|
+
makeCookie(`_ga_${i}`, true, "after-reject"),
|
|
463
|
+
),
|
|
464
|
+
networkBeforeInteraction: Array.from({ length: 10 }, () => makeTracker()),
|
|
465
|
+
networkAfterAccept: [],
|
|
466
|
+
networkAfterReject: [],
|
|
467
|
+
});
|
|
468
|
+
expect(result.breakdown.consentValidity).toBeGreaterThanOrEqual(0);
|
|
469
|
+
expect(result.breakdown.easyRefusal).toBeGreaterThanOrEqual(0);
|
|
470
|
+
expect(result.breakdown.transparency).toBeGreaterThanOrEqual(0);
|
|
471
|
+
expect(result.breakdown.cookieBehavior).toBeGreaterThanOrEqual(0);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("never produces a dimension score above 25", () => {
|
|
475
|
+
const result = analyzeCompliance({
|
|
476
|
+
modal: makeModal(),
|
|
477
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
478
|
+
cookiesBeforeInteraction: [],
|
|
479
|
+
cookiesAfterAccept: [],
|
|
480
|
+
cookiesAfterReject: [],
|
|
481
|
+
networkBeforeInteraction: [],
|
|
482
|
+
networkAfterAccept: [],
|
|
483
|
+
networkAfterReject: [],
|
|
484
|
+
});
|
|
485
|
+
for (const score of Object.values(result.breakdown)) {
|
|
486
|
+
expect(score).toBeLessThanOrEqual(25);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { analyzeButtonWording, analyzeModalText } from "../../src/analyzers/wording.js";
|
|
3
|
+
import type { ConsentButton } from "../../src/types.js";
|
|
4
|
+
|
|
5
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function makeButton(
|
|
8
|
+
type: ConsentButton["type"],
|
|
9
|
+
text: string,
|
|
10
|
+
overrides: Partial<ConsentButton> = {},
|
|
11
|
+
): ConsentButton {
|
|
12
|
+
return {
|
|
13
|
+
type,
|
|
14
|
+
text,
|
|
15
|
+
selector: `button:has-text("${text}")`,
|
|
16
|
+
isVisible: true,
|
|
17
|
+
boundingBox: { x: 0, y: 0, width: 120, height: 40 },
|
|
18
|
+
fontSize: 14,
|
|
19
|
+
backgroundColor: "rgb(0,0,0)",
|
|
20
|
+
textColor: "rgb(255,255,255)",
|
|
21
|
+
contrastRatio: 21,
|
|
22
|
+
clickDepth: 1,
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── analyzeButtonWording ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe("analyzeButtonWording", () => {
|
|
30
|
+
describe("clear labels — no issues", () => {
|
|
31
|
+
it("raises no issue for explicit Accept / Reject buttons", () => {
|
|
32
|
+
const buttons = [makeButton("accept", "Accept all"), makeButton("reject", "Reject all")];
|
|
33
|
+
const result = analyzeButtonWording(buttons);
|
|
34
|
+
expect(result.issues).toHaveLength(0);
|
|
35
|
+
expect(result.hasPositiveActionForAccept).toBe(true);
|
|
36
|
+
expect(result.hasExplicitRejectOption).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("raises no issue when a preferences button replaces reject", () => {
|
|
40
|
+
const buttons = [
|
|
41
|
+
makeButton("accept", "Accept all"),
|
|
42
|
+
makeButton("preferences", "Manage preferences"),
|
|
43
|
+
];
|
|
44
|
+
const result = analyzeButtonWording(buttons);
|
|
45
|
+
const noRejectIssue = result.issues.find((i) => i.type === "no-reject-button");
|
|
46
|
+
expect(noRejectIssue).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("misleading-wording on accept button", () => {
|
|
51
|
+
it.each(["ok", "OK", "Got it", "Continue", "J'ai compris", "D'accord", "Proceed"])(
|
|
52
|
+
'flags "%s" as misleading accept label',
|
|
53
|
+
(text) => {
|
|
54
|
+
const buttons = [makeButton("accept", text), makeButton("reject", "Refuse")];
|
|
55
|
+
const result = analyzeButtonWording(buttons);
|
|
56
|
+
const issue = result.issues.find((i) => i.type === "misleading-wording");
|
|
57
|
+
expect(issue).toBeDefined();
|
|
58
|
+
expect(issue?.severity).toBe("warning");
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
it("does NOT flag explicit Accept all label", () => {
|
|
63
|
+
const buttons = [makeButton("accept", "Accept all cookies")];
|
|
64
|
+
const result = analyzeButtonWording(buttons);
|
|
65
|
+
const issue = result.issues.find((i) => i.type === "misleading-wording");
|
|
66
|
+
expect(issue).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("no-reject-button", () => {
|
|
71
|
+
it("raises critical issue when no reject or preferences button is present", () => {
|
|
72
|
+
const buttons = [makeButton("accept", "Accept all")];
|
|
73
|
+
const result = analyzeButtonWording(buttons);
|
|
74
|
+
const issue = result.issues.find((i) => i.type === "no-reject-button");
|
|
75
|
+
expect(issue).toBeDefined();
|
|
76
|
+
expect(issue?.severity).toBe("critical");
|
|
77
|
+
expect(result.hasExplicitRejectOption).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("raises critical issue when button list is empty", () => {
|
|
81
|
+
const result = analyzeButtonWording([]);
|
|
82
|
+
const issue = result.issues.find((i) => i.type === "no-reject-button");
|
|
83
|
+
expect(issue).toBeDefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("fake reject — close/dismiss button disguised as reject", () => {
|
|
88
|
+
it.each(["×", "✕", "close", "Fermer", "dismiss", "skip"])(
|
|
89
|
+
'flags "%s" reject button as misleading (fake reject)',
|
|
90
|
+
(text) => {
|
|
91
|
+
const buttons = [makeButton("accept", "Accept all"), makeButton("reject", text)];
|
|
92
|
+
const result = analyzeButtonWording(buttons);
|
|
93
|
+
const issue = result.issues.find(
|
|
94
|
+
(i) => i.type === "misleading-wording" && i.severity === "critical",
|
|
95
|
+
);
|
|
96
|
+
expect(issue).toBeDefined();
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── analyzeModalText ─────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe("analyzeModalText", () => {
|
|
105
|
+
const FULL_TEXT = `
|
|
106
|
+
We use cookies to improve your experience. The purposes include analytics and advertising.
|
|
107
|
+
Third-party partners and vendors process data on our behalf.
|
|
108
|
+
Cookies are retained for a period of 13 months. You can withdraw your consent at any time.
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
it("finds no missing info when all required elements are present", () => {
|
|
112
|
+
const result = analyzeModalText(FULL_TEXT);
|
|
113
|
+
expect(result.missingInfo).toHaveLength(0);
|
|
114
|
+
expect(result.issues).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("reports missing purposes when no purpose keyword is found", () => {
|
|
118
|
+
const text = "We use cookies. Third-party partners. 13 months. Withdraw consent.";
|
|
119
|
+
const result = analyzeModalText(text);
|
|
120
|
+
expect(result.missingInfo).toContain("purposes");
|
|
121
|
+
expect(result.issues.some((i) => i.type === "missing-info")).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("reports missing third-parties when no partner keyword is found", () => {
|
|
125
|
+
const text = "We use cookies for analytics purposes. 13 months. Withdraw consent.";
|
|
126
|
+
const result = analyzeModalText(text);
|
|
127
|
+
expect(result.missingInfo).toContain("third-parties");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("reports missing duration when no retention keyword is found", () => {
|
|
131
|
+
// Avoid words containing "an" (e.g. "analytics") — the /an(s)?/ pattern would false-positive
|
|
132
|
+
const text = "We use cookies for security purposes. Third-party partners. Withdraw consent.";
|
|
133
|
+
const result = analyzeModalText(text);
|
|
134
|
+
expect(result.missingInfo).toContain("duration");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("reports missing withdrawal when no withdraw keyword is found", () => {
|
|
138
|
+
const text = "We use cookies for analytics purposes. Third-party partners. 13 months.";
|
|
139
|
+
const result = analyzeModalText(text);
|
|
140
|
+
expect(result.missingInfo).toContain("withdrawal");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("reports all four missing items on an empty modal text", () => {
|
|
144
|
+
const result = analyzeModalText("");
|
|
145
|
+
expect(result.missingInfo).toEqual(
|
|
146
|
+
expect.arrayContaining(["purposes", "third-parties", "duration", "withdrawal"]),
|
|
147
|
+
);
|
|
148
|
+
expect(result.issues).toHaveLength(4);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("accepts French keywords for purposes (finalité)", () => {
|
|
152
|
+
const text =
|
|
153
|
+
"Finalité : améliorer votre expérience. Partenaires. 13 mois. Retrait du consentement.";
|
|
154
|
+
const result = analyzeModalText(text);
|
|
155
|
+
expect(result.missingInfo).not.toContain("purposes");
|
|
156
|
+
expect(result.missingInfo).not.toContain("third-parties");
|
|
157
|
+
expect(result.missingInfo).not.toContain("duration");
|
|
158
|
+
expect(result.missingInfo).not.toContain("withdrawal");
|
|
159
|
+
});
|
|
160
|
+
});
|