@slashgear/gdpr-cookie-scanner 1.3.0 → 1.4.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.
@@ -36,3 +36,9 @@ jobs:
36
36
 
37
37
  - name: Build
38
38
  run: pnpm build
39
+
40
+ - name: Install Playwright browsers
41
+ run: pnpm exec playwright install chromium --with-deps
42
+
43
+ - name: Test
44
+ run: pnpm test
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @slashgear/gdpr-cookie-scanner
2
2
 
3
+ ## 1.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5af8678: test: add Vitest unit and E2E test suite
8
+
9
+ Introduces a comprehensive test suite using Vitest:
10
+
11
+ - **Unit tests** (76 tests) covering the three pure-logic modules:
12
+ `cookie-classifier`, `network-classifier`, `wording` analyzer, and
13
+ `compliance` analyzer — verifying scoring rules, dark pattern detection,
14
+ and cookie/tracker classification across French and English content.
15
+
16
+ - **E2E tests** (14 tests) that spin up a local HTTP server serving three
17
+ HTML fixtures (compliant banner, non-compliant banner, no modal) and run
18
+ the full Playwright scanner against them, asserting detected modals,
19
+ cookie behavior, and compliance grades.
20
+
21
+ - **CI integration**: the GitHub Actions workflow now installs Playwright's
22
+ Chromium browser and runs `pnpm test` after every build, blocking merges
23
+ on test failures.
24
+
3
25
  ## 1.3.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -69,58 +69,12 @@ gdpr-scan list-trackers
69
69
 
70
70
  ## How it works
71
71
 
72
- The scanner runs **4 phases** using a real Chromium browser (Playwright):
73
-
74
- 1. **Initial load** — The page is loaded without any interaction. All cookies and network requests are captured (`before-interaction`).
75
- 2. **Modal analysis** — The consent banner is detected (CSS selectors of known CMPs + DOM heuristics). Buttons are extracted with their visual properties (size, color, contrast ratio).
76
- 3. **Reject test** — The "Reject" button is clicked. Cookies and requests are captured (`after-reject`).
77
- 4. **Accept test** — A new browser session (clean state) loads the page and clicks "Accept". Cookies and requests are captured (`after-accept`).
78
-
79
- ## Architecture
80
-
81
72
  ```mermaid
82
- flowchart TD
83
- CLI["⌨️ gdpr-scan CLI\n─────────────────\nURL · options"]
84
-
85
- CLI --> B
86
-
87
- subgraph B["🌐 Chromium browser — 4 sequential phases"]
88
- direction TB
89
- P1["Phase 1 — Load page\nCapture cookies & network requests\n(before-interaction)"]
90
- P2["Phase 2 — Detect consent modal\nCSS selectors · DOM heuristics\nExtract buttons, checkboxes, screenshots"]
91
- P3["Phase 3 — Click Reject (same session)\nCapture state (after-reject)"]
92
- P4["Phase 4 — Fresh session · Click Accept\nCapture state (after-accept)"]
93
- P1 --> P2 --> P3 --> P4
94
- end
95
-
96
- B --> C
97
-
98
- subgraph C["🔎 Classifiers"]
99
- direction LR
100
- CK["Cookie classifier\n─────────────\nPattern matching → category\n(analytics, ads, strictly-necessary…)"]
101
- NK["Network classifier\n─────────────\nTracker DB lookup\nPixel pattern matching"]
102
- end
103
-
104
- C --> A
105
-
106
- subgraph A["⚖️ Analyzers"]
107
- direction LR
108
- SC["Compliance scorer\n─────────────\n4 dimensions × 25 pts\n→ score 0–100, grade A–F"]
109
- DP["Dark pattern detector\n─────────────\nPre-ticked boxes · asymmetry\nMissing reject · misleading wording"]
110
- end
111
-
112
- A --> R
113
-
114
- subgraph R["📄 Report generator"]
115
- direction LR
116
- MD1["gdpr-report-*.md\nMain compliance report"]
117
- MD2["gdpr-checklist-*.md\nPer-rule checklist\nwith legal references"]
118
- MD3["gdpr-cookies-*.md\nDeduplicated cookie\ninventory"]
119
- PDF["gdpr-report-*.pdf\nMerged PDF with TOC\n& embedded screenshots"]
120
- end
73
+ flowchart LR
74
+ URL([URL]) --> Chromium[Chromium] --> Classify[Classify] --> Score[Score] --> Report[Report]
121
75
  ```
122
76
 
123
- The tool runs a **real Chromium browser** (via Playwright) through 4 isolated phases to capture the site's behaviour before any interaction, on modal detection, after rejection, and after acceptance. Raw data is then classified (cookies by name pattern, network requests against a tracker database), scored across 4 compliance dimensions, and rendered into 3 Markdown files plus a self-contained PDF.
77
+ A real Chromium browser loads the page, interacts with the consent modal (reject then accept in a fresh session), and captures cookies and network requests at each step. Results are classified, scored across 4 compliance dimensions, and rendered into Markdown and PDF reports.
124
78
 
125
79
  ## Generated report
126
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slashgear/gdpr-cookie-scanner",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "CLI tool to scan websites for GDPR cookie consent compliance",
5
5
  "keywords": [
6
6
  "compliance",
@@ -43,7 +43,8 @@
43
43
  "@types/node": "^22.0.0",
44
44
  "oxfmt": "^0.33.0",
45
45
  "oxlint": "^1.48.0",
46
- "typescript": "^5.5.0"
46
+ "typescript": "^5.5.0",
47
+ "vitest": "^4.0.18"
47
48
  },
48
49
  "engines": {
49
50
  "node": ">=20.0.0"
@@ -57,6 +58,8 @@
57
58
  "format": "oxfmt .",
58
59
  "format:check": "oxfmt --check .",
59
60
  "typecheck": "tsc --noEmit",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest",
60
63
  "changeset": "changeset"
61
64
  }
62
65
  }
@@ -0,0 +1,80 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Compliant Test Site</title>
6
+ </head>
7
+ <body>
8
+ <main>
9
+ <h1>Welcome to our website</h1>
10
+ <p>This is a test page with a GDPR-compliant cookie banner.</p>
11
+ </main>
12
+
13
+ <footer>
14
+ <a href="/privacy-policy">Privacy Policy</a>
15
+ <a href="/terms">Terms of Service</a>
16
+ </footer>
17
+
18
+ <!-- GDPR-compliant cookie banner -->
19
+ <div
20
+ id="cookie-banner"
21
+ style="
22
+ position: fixed;
23
+ bottom: 0;
24
+ left: 0;
25
+ right: 0;
26
+ background: #1a1a2e;
27
+ color: #fff;
28
+ padding: 20px;
29
+ z-index: 9999;
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 16px;
33
+ "
34
+ >
35
+ <p style="flex: 1; margin: 0">
36
+ We use cookies for analytics purposes with <strong>third-party vendors</strong>. Cookies
37
+ expire after <strong>13 months</strong>. You may <strong>withdraw your consent</strong> at
38
+ any time. See our <a href="/privacy-policy" style="color: #90e0ef">Privacy Policy</a>.
39
+ </p>
40
+ <button
41
+ id="reject-btn"
42
+ style="
43
+ padding: 10px 20px;
44
+ background: transparent;
45
+ color: #fff;
46
+ border: 1px solid #fff;
47
+ cursor: pointer;
48
+ font-size: 14px;
49
+ "
50
+ >
51
+ Reject all
52
+ </button>
53
+ <button
54
+ id="accept-btn"
55
+ style="
56
+ padding: 10px 20px;
57
+ background: #4caf50;
58
+ color: #fff;
59
+ border: none;
60
+ cursor: pointer;
61
+ font-size: 14px;
62
+ "
63
+ >
64
+ Accept all
65
+ </button>
66
+ </div>
67
+
68
+ <script>
69
+ document.getElementById("accept-btn").addEventListener("click", function () {
70
+ document.getElementById("cookie-banner").style.display = "none";
71
+ document.cookie = "consent=accepted; path=/; max-age=31536000";
72
+ });
73
+
74
+ document.getElementById("reject-btn").addEventListener("click", function () {
75
+ document.getElementById("cookie-banner").style.display = "none";
76
+ document.cookie = "consent=rejected; path=/; max-age=31536000";
77
+ });
78
+ </script>
79
+ </body>
80
+ </html>
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>No Modal Test Site</title>
6
+ </head>
7
+ <body>
8
+ <main>
9
+ <h1>Welcome</h1>
10
+ <p>This page has no cookie consent modal at all.</p>
11
+ </main>
12
+
13
+ <footer>
14
+ <a href="/privacy-policy">Privacy Policy</a>
15
+ </footer>
16
+ </body>
17
+ </html>
@@ -0,0 +1,54 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Non-Compliant Test Site</title>
6
+ </head>
7
+ <body>
8
+ <main>
9
+ <h1>Welcome</h1>
10
+ <p>This page has no reject button and sets analytics cookies immediately.</p>
11
+ </main>
12
+
13
+ <!-- Non-compliant: no reject button, no privacy policy link, cookie set before interaction -->
14
+ <div
15
+ id="cookie-banner"
16
+ style="
17
+ position: fixed;
18
+ bottom: 0;
19
+ left: 0;
20
+ right: 0;
21
+ background: #333;
22
+ color: #fff;
23
+ padding: 20px;
24
+ z-index: 9999;
25
+ "
26
+ >
27
+ <p style="margin: 0 0 10px 0">
28
+ By continuing to browse this site, you accept the use of cookies.
29
+ </p>
30
+ <button
31
+ id="accept-btn"
32
+ style="
33
+ padding: 10px 24px;
34
+ background: #e53935;
35
+ color: #fff;
36
+ border: none;
37
+ cursor: pointer;
38
+ font-size: 16px;
39
+ "
40
+ >
41
+ OK
42
+ </button>
43
+ </div>
44
+
45
+ <script>
46
+ // Set analytics cookie before any interaction (non-compliant)
47
+ document.cookie = "_ga=GA1.2.123456789.1234567890; path=/; max-age=34560000";
48
+
49
+ document.getElementById("accept-btn").addEventListener("click", function () {
50
+ document.getElementById("cookie-banner").style.display = "none";
51
+ });
52
+ </script>
53
+ </body>
54
+ </html>
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtemp, rm } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { Scanner } from "../../src/scanner/index.js";
6
+ import { startTestServer, type TestServer } from "../helpers/test-server.js";
7
+
8
+ // E2E tests spin up real Playwright browsers — allow generous timeouts
9
+ const E2E_TIMEOUT = 60_000;
10
+
11
+ describe("Scanner E2E", { timeout: E2E_TIMEOUT }, () => {
12
+ let server: TestServer;
13
+ let outputDir: string;
14
+
15
+ beforeAll(async () => {
16
+ server = await startTestServer();
17
+ outputDir = await mkdtemp(join(tmpdir(), "gdpr-test-"));
18
+ });
19
+
20
+ afterAll(async () => {
21
+ await server.close();
22
+ await rm(outputDir, { recursive: true, force: true });
23
+ });
24
+
25
+ function makeOptions(fixture: string) {
26
+ return {
27
+ url: `${server.url}/${fixture}`,
28
+ outputDir: join(outputDir, fixture.replace(".html", "")),
29
+ timeout: 30_000,
30
+ screenshots: false,
31
+ locale: "en-US",
32
+ verbose: false,
33
+ };
34
+ }
35
+
36
+ describe("compliant-site.html", () => {
37
+ it("detects the consent modal", async () => {
38
+ const scanner = new Scanner(makeOptions("compliant-site.html"));
39
+ const result = await scanner.run();
40
+ expect(result.modal.detected).toBe(true);
41
+ });
42
+
43
+ it("finds both accept and reject buttons", async () => {
44
+ const scanner = new Scanner(makeOptions("compliant-site.html"));
45
+ const result = await scanner.run();
46
+ const types = result.modal.buttons.map((b) => b.type);
47
+ expect(types).toContain("accept");
48
+ expect(types).toContain("reject");
49
+ });
50
+
51
+ it("finds the privacy policy link in the modal", async () => {
52
+ const scanner = new Scanner(makeOptions("compliant-site.html"));
53
+ const result = await scanner.run();
54
+ expect(result.modal.privacyPolicyUrl).toBeTruthy();
55
+ });
56
+
57
+ it("finds a privacy policy link on the page", async () => {
58
+ const scanner = new Scanner(makeOptions("compliant-site.html"));
59
+ const result = await scanner.run();
60
+ expect(result.privacyPolicyUrl).toBeTruthy();
61
+ });
62
+
63
+ it("assigns a passing compliance grade (A or B)", async () => {
64
+ const scanner = new Scanner(makeOptions("compliant-site.html"));
65
+ const result = await scanner.run();
66
+ expect(["A", "B"]).toContain(result.compliance.grade);
67
+ });
68
+
69
+ it("does not flag pre-consent cookies", async () => {
70
+ const scanner = new Scanner(makeOptions("compliant-site.html"));
71
+ const result = await scanner.run();
72
+ const illegalPreConsent = result.cookiesBeforeInteraction.filter((c) => c.requiresConsent);
73
+ expect(illegalPreConsent).toHaveLength(0);
74
+ });
75
+ });
76
+
77
+ describe("non-compliant-site.html", () => {
78
+ it("detects the consent modal", async () => {
79
+ const scanner = new Scanner(makeOptions("non-compliant-site.html"));
80
+ const result = await scanner.run();
81
+ expect(result.modal.detected).toBe(true);
82
+ });
83
+
84
+ it("detects _ga cookie set before any interaction", async () => {
85
+ const scanner = new Scanner(makeOptions("non-compliant-site.html"));
86
+ const result = await scanner.run();
87
+ const gaBeforeInteraction = result.cookiesBeforeInteraction.some(
88
+ (c) => c.name === "_ga" && c.requiresConsent,
89
+ );
90
+ expect(gaBeforeInteraction).toBe(true);
91
+ });
92
+
93
+ it("raises an auto-consent issue for pre-interaction cookies", async () => {
94
+ const scanner = new Scanner(makeOptions("non-compliant-site.html"));
95
+ const result = await scanner.run();
96
+ const issue = result.compliance.issues.find((i) => i.type === "auto-consent");
97
+ expect(issue).toBeDefined();
98
+ });
99
+
100
+ it("assigns a failing grade (C, D, or F)", async () => {
101
+ const scanner = new Scanner(makeOptions("non-compliant-site.html"));
102
+ const result = await scanner.run();
103
+ expect(["C", "D", "F"]).toContain(result.compliance.grade);
104
+ });
105
+ });
106
+
107
+ describe("no-modal-site.html", () => {
108
+ it("reports modal as not detected", async () => {
109
+ const scanner = new Scanner(makeOptions("no-modal-site.html"));
110
+ const result = await scanner.run();
111
+ expect(result.modal.detected).toBe(false);
112
+ });
113
+
114
+ it("still finds the page-level privacy policy link", async () => {
115
+ const scanner = new Scanner(makeOptions("no-modal-site.html"));
116
+ const result = await scanner.run();
117
+ expect(result.privacyPolicyUrl).toBeTruthy();
118
+ });
119
+
120
+ it("assigns grade F when no modal is detected", async () => {
121
+ const scanner = new Scanner(makeOptions("no-modal-site.html"));
122
+ const result = await scanner.run();
123
+ expect(result.compliance.grade).toBe("F");
124
+ });
125
+
126
+ it("raises a critical no-reject-button issue", async () => {
127
+ const scanner = new Scanner(makeOptions("no-modal-site.html"));
128
+ const result = await scanner.run();
129
+ const issue = result.compliance.issues.find(
130
+ (i) => i.type === "no-reject-button" && i.severity === "critical",
131
+ );
132
+ expect(issue).toBeDefined();
133
+ });
134
+ });
135
+ });
@@ -0,0 +1,57 @@
1
+ import { createServer } from "http";
2
+ import { readFile } from "fs/promises";
3
+ import { join, extname } from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
7
+ const FIXTURES_DIR = join(__dirname, "../e2e/fixtures");
8
+
9
+ const MIME_TYPES: Record<string, string> = {
10
+ ".html": "text/html; charset=utf-8",
11
+ ".js": "application/javascript",
12
+ ".css": "text/css",
13
+ ".json": "application/json",
14
+ };
15
+
16
+ export interface TestServer {
17
+ url: string;
18
+ close(): Promise<void>;
19
+ }
20
+
21
+ /**
22
+ * Starts a minimal HTTP server serving static HTML fixtures.
23
+ * Returns the base URL and a close() function.
24
+ */
25
+ export async function startTestServer(port = 0): Promise<TestServer> {
26
+ const server = createServer(async (req, res) => {
27
+ const urlPath = req.url === "/" ? "/index.html" : (req.url ?? "/");
28
+ // Map /privacy-policy → privacy-policy.html or return a stub
29
+ const filePath = urlPath.endsWith(".html")
30
+ ? join(FIXTURES_DIR, urlPath)
31
+ : join(FIXTURES_DIR, `${urlPath.slice(1)}.html`);
32
+
33
+ try {
34
+ const content = await readFile(filePath);
35
+ const ext = extname(filePath) || ".html";
36
+ res.writeHead(200, { "Content-Type": MIME_TYPES[ext] ?? "text/plain" });
37
+ res.end(content);
38
+ } catch {
39
+ // Return a minimal stub for unknown paths (e.g., /privacy-policy)
40
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
41
+ res.end("<!DOCTYPE html><html><body><h1>Privacy Policy</h1></body></html>");
42
+ }
43
+ });
44
+
45
+ await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
46
+
47
+ const address = server.address();
48
+ if (!address || typeof address === "string") {
49
+ throw new Error("Unexpected server address type");
50
+ }
51
+
52
+ return {
53
+ url: `http://127.0.0.1:${address.port}`,
54
+ close: () =>
55
+ new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
56
+ };
57
+ }