@slashgear/gdpr-cookie-scanner 1.2.1 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slashgear/gdpr-cookie-scanner",
3
- "version": "1.2.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
  }
@@ -9,6 +9,7 @@ import { analyzeButtonWording, analyzeModalText } from "./wording.js";
9
9
 
10
10
  interface ComplianceInput {
11
11
  modal: ConsentModal;
12
+ privacyPolicyUrl: string | null;
12
13
  cookiesBeforeInteraction: ScannedCookie[];
13
14
  cookiesAfterAccept: ScannedCookie[];
14
15
  cookiesAfterReject: ScannedCookie[];
@@ -125,6 +126,28 @@ export function analyzeCompliance(input: ComplianceInput): ComplianceScore {
125
126
  if (wordingResult.missingInfo.length > 0) {
126
127
  transparency -= wordingResult.missingInfo.length * 3;
127
128
  }
129
+ // No privacy policy link in the modal
130
+ if (!input.modal.privacyPolicyUrl) {
131
+ issues.push({
132
+ type: "missing-info",
133
+ severity: "warning",
134
+ description: "No privacy policy link found in the consent modal",
135
+ evidence:
136
+ "GDPR Art. 13 requires the privacy policy to be accessible from the consent interface",
137
+ });
138
+ transparency -= 5;
139
+ }
140
+ }
141
+
142
+ // No privacy policy link anywhere on the page
143
+ if (!input.privacyPolicyUrl) {
144
+ issues.push({
145
+ type: "missing-info",
146
+ severity: "warning",
147
+ description: "No privacy policy link found on the page",
148
+ evidence: "A privacy policy must be accessible from every page (GDPR Art. 13)",
149
+ });
150
+ transparency -= 3;
128
151
  }
129
152
 
130
153
  // ── D. Cookie behavior (0-25) ─────────────────────────────────
@@ -350,6 +350,9 @@ ${row("Cookie behavior", breakdown.cookieBehavior, 25)}
350
350
  `**CSS selector:** \`${modal.selector}\``,
351
351
  `**Granular controls:** ${modal.hasGranularControls ? "✅ Yes" : "❌ No"}`,
352
352
  `**Layer count:** ${modal.layerCount}`,
353
+ modal.privacyPolicyUrl
354
+ ? `**Privacy policy link:** ✅ [${modal.privacyPolicyUrl}](${modal.privacyPolicyUrl})`
355
+ : `**Privacy policy link:** ⚠️ Not found in the modal`,
353
356
  "",
354
357
  "### Detected buttons",
355
358
  "",
@@ -914,6 +917,28 @@ The **Description / Purpose** column is to be filled in by the DPO or technical
914
917
  });
915
918
  }
916
919
 
920
+ rows.push({
921
+ category: "Transparency",
922
+ rule: "Privacy policy link present in the consent modal",
923
+ reference: "[GDPR Art. 13](https://gdpr-info.eu/art-13-gdpr/)",
924
+ status: !r.modal.detected ? ko : r.modal.privacyPolicyUrl ? ok : warn,
925
+ detail: !r.modal.detected
926
+ ? "Modal not detected"
927
+ : r.modal.privacyPolicyUrl
928
+ ? `Link found: ${r.modal.privacyPolicyUrl}`
929
+ : "No privacy policy link found inside the consent modal",
930
+ });
931
+
932
+ rows.push({
933
+ category: "Transparency",
934
+ rule: "Privacy policy accessible from the main page",
935
+ reference: "[GDPR Art. 13](https://gdpr-info.eu/art-13-gdpr/)",
936
+ status: r.privacyPolicyUrl ? ok : warn,
937
+ detail: r.privacyPolicyUrl
938
+ ? `Link found: ${r.privacyPolicyUrl}`
939
+ : "No privacy policy link found on the main page",
940
+ });
941
+
917
942
  // ── D. Cookie behavior ────────────────────────────────────────
918
943
  const illegalPre = r.cookiesBeforeInteraction.filter((c) => c.requiresConsent);
919
944
  rows.push({
@@ -45,6 +45,65 @@ const MODAL_SELECTORS = [
45
45
  "[role='alertdialog']",
46
46
  ];
47
47
 
48
+ const PRIVACY_POLICY_URL_PATTERNS = [
49
+ "privacy[_-]?polic",
50
+ "politique[_-]?(de[_-])?confidentialit",
51
+ "politique[_-]?(de[_-])?vie[_-]?priv",
52
+ "confidentialit",
53
+ "vie[_-]?priv",
54
+ "mentions[_-]?l.gales",
55
+ "datenschutz",
56
+ "privacidad",
57
+ "data[_-]?protection",
58
+ "data[_-]?privacy",
59
+ ];
60
+
61
+ const PRIVACY_POLICY_TEXT_PATTERNS = [
62
+ "privacy\\s+polic",
63
+ "politique\\s+(de\\s+)?confidentialit",
64
+ "politique\\s+(de\\s+)?vie\\s+priv",
65
+ "confidentialit",
66
+ "vie\\s+priv",
67
+ "mentions?\\s+l.gales?",
68
+ "datenschutz",
69
+ "privacidad",
70
+ "data\\s+protection",
71
+ ];
72
+
73
+ /**
74
+ * Find a privacy policy link within a given scope (modal selector) or the full page.
75
+ * Returns the absolute URL of the first matching link, or null.
76
+ */
77
+ export async function findPrivacyPolicyUrl(
78
+ page: Page,
79
+ scopeSelector?: string,
80
+ ): Promise<string | null> {
81
+ return page
82
+ .evaluate(
83
+ ({ scope, urlPats, textPats }) => {
84
+ const root: Element | Document = scope
85
+ ? (document.querySelector(scope) ?? document)
86
+ : document;
87
+ const links = root.querySelectorAll("a[href]");
88
+ for (const link of links) {
89
+ const href = (link as HTMLAnchorElement).href ?? "";
90
+ const text = (link.textContent ?? "").trim();
91
+ if (!href || href.startsWith("javascript:") || href === "#") continue;
92
+ const matchUrl = urlPats.some((p) => new RegExp(p, "i").test(href));
93
+ const matchText = textPats.some((p) => new RegExp(p, "i").test(text));
94
+ if (matchUrl || matchText) return href;
95
+ }
96
+ return null;
97
+ },
98
+ {
99
+ scope: scopeSelector ?? null,
100
+ urlPats: PRIVACY_POLICY_URL_PATTERNS,
101
+ textPats: PRIVACY_POLICY_TEXT_PATTERNS,
102
+ },
103
+ )
104
+ .catch(() => null);
105
+ }
106
+
48
107
  const ACCEPT_PATTERNS = [
49
108
  /\b(accept|accepter|acceptez|tout accepter|accept all|j'accepte|i accept|agree|ok\b|d'accord|continuer|continue|valider|confirmer)\b/i,
50
109
  ];
@@ -109,6 +168,7 @@ export async function detectConsentModal(page: Page, options: ScanOptions): Prom
109
168
  hasGranularControls: false,
110
169
  layerCount: 0,
111
170
  screenshotPath: null,
171
+ privacyPolicyUrl: null,
112
172
  };
113
173
  }
114
174
 
@@ -125,6 +185,9 @@ export async function detectConsentModal(page: Page, options: ScanOptions): Prom
125
185
  const hasGranularControls =
126
186
  checkboxes.length > 0 || buttons.some((b) => b.type === "preferences");
127
187
 
188
+ // Look for a privacy policy link inside the modal
189
+ const privacyPolicyUrl = await findPrivacyPolicyUrl(page, foundSelector);
190
+
128
191
  return {
129
192
  detected: true,
130
193
  selector: foundSelector,
@@ -134,6 +197,7 @@ export async function detectConsentModal(page: Page, options: ScanOptions): Prom
134
197
  hasGranularControls,
135
198
  layerCount: hasGranularControls ? 2 : 1,
136
199
  screenshotPath: null,
200
+ privacyPolicyUrl,
137
201
  };
138
202
  }
139
203
 
@@ -4,7 +4,7 @@ import type { ScanOptions, ScanResult } from "../types.js";
4
4
  import { createBrowser, clearState, closeBrowser } from "./browser.js";
5
5
  import { captureCookies } from "./cookies.js";
6
6
  import { createNetworkInterceptor } from "./network.js";
7
- import { detectConsentModal } from "./consent-modal.js";
7
+ import { detectConsentModal, findPrivacyPolicyUrl } from "./consent-modal.js";
8
8
  import { analyzeCompliance } from "../analyzers/compliance.js";
9
9
 
10
10
  type PhaseCallback = (message: string) => void;
@@ -44,6 +44,9 @@ export class Scanner {
44
44
  const networkBeforeInteraction = interceptor1.getRequests();
45
45
  interceptor1.stop();
46
46
 
47
+ // Look for a privacy policy link anywhere on the page (typically footer/nav)
48
+ const privacyPolicyUrl = await findPrivacyPolicyUrl(session1.page);
49
+
47
50
  // ────────────────────────────────────────────────────────────
48
51
  // Phase 2 — Detect and analyze the consent modal
49
52
  // ────────────────────────────────────────────────────────────
@@ -142,6 +145,7 @@ export class Scanner {
142
145
  // ────────────────────────────────────────────────────────────
143
146
  const compliance = analyzeCompliance({
144
147
  modal,
148
+ privacyPolicyUrl,
145
149
  cookiesBeforeInteraction,
146
150
  cookiesAfterAccept,
147
151
  cookiesAfterReject,
@@ -155,6 +159,7 @@ export class Scanner {
155
159
  scanDate: new Date().toISOString(),
156
160
  duration: Date.now() - startTime,
157
161
  modal,
162
+ privacyPolicyUrl,
158
163
  cookiesBeforeInteraction,
159
164
  cookiesAfterAccept,
160
165
  cookiesAfterReject,
package/src/types.ts CHANGED
@@ -74,6 +74,7 @@ export interface ConsentModal {
74
74
  hasGranularControls: boolean;
75
75
  layerCount: number; // number of clicks to reach full options
76
76
  screenshotPath: string | null;
77
+ privacyPolicyUrl: string | null; // link to the privacy policy found inside the modal
77
78
  }
78
79
 
79
80
  export interface DarkPatternIssue {
@@ -122,6 +123,7 @@ export interface ScanResult {
122
123
  scanDate: string;
123
124
  duration: number; // ms
124
125
  modal: ConsentModal;
126
+ privacyPolicyUrl: string | null; // link to the privacy policy found anywhere on the page
125
127
  cookiesBeforeInteraction: ScannedCookie[];
126
128
  cookiesAfterAccept: ScannedCookie[];
127
129
  cookiesAfterReject: ScannedCookie[];
@@ -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
+ }