@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.
- package/.github/workflows/ci.yml +6 -0
- package/CHANGELOG.md +22 -0
- package/README.md +3 -49
- package/package.json +5 -2
- package/tests/e2e/fixtures/compliant-site.html +80 -0
- package/tests/e2e/fixtures/no-modal-site.html +17 -0
- package/tests/e2e/fixtures/non-compliant-site.html +54 -0
- package/tests/e2e/scanner.test.ts +135 -0
- package/tests/helpers/test-server.ts +57 -0
- package/tests/unit/compliance.test.ts +460 -0
- package/tests/unit/cookie-classifier.test.ts +192 -0
- package/tests/unit/network-classifier.test.ts +91 -0
- package/tests/unit/wording.test.ts +162 -0
- package/vitest.config.ts +9 -0
package/.github/workflows/ci.yml
CHANGED
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
|
|
83
|
-
|
|
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
|
-
|
|
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
|
+
"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
|
+
}
|