Skip to content

Commit 41dd4e3

Browse files
committed
feat: automated tests (78 passing), CI, render concurrency limit
Tests (vitest): - 3 test files: adapters, validators, utils - All 5 adapters: loadCredentials, buildProxyUrl, priority order - All 4 validators: injection prevention, NaN bypass, range checks - Utils: htmlToMarkdown (script/style/noscript stripping), truncation CI (GitHub Actions): - Runs on push/PR to main - Matrix: Node 18, 20, 22 - Build + test gate Render concurrency limit: - Max 3 concurrent browser sessions (prevents runaway Browser API costs) - Returns clear error when limit hit - Override with PROXY_VEIL_MAX_RENDERS env var (1-20) prepublishOnly now runs tests before npm publish.
1 parent 6df1a78 commit 41dd4e3

17 files changed

Lines changed: 1014 additions & 6 deletions

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version: [18, 20, 22]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: ${{ matrix.node-version }}
20+
cache: npm
21+
- run: npm ci
22+
- run: npm run build
23+
- run: npm test

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
[![npm downloads](https://img.shields.io/npm/dw/proxy-veil?label=downloads&color=blue)](https://npmjs.com/package/proxy-veil)
77
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
88
[![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
9+
[![CI](https://github.com/Goldentrii/proxy-veil/actions/workflows/ci.yml/badge.svg)](https://github.com/Goldentrii/proxy-veil/actions)
910
[![Smithery](https://smithery.ai/badge/proxy-veil)](https://smithery.ai/server/proxy-veil)
1011

1112
Works with **Claude Code**, **Cursor**, **Windsurf**, **Cline**, **Continue**, and any MCP-compatible AI agent. Powered by **[Novada](https://www.novada.com)**.

build/__tests__/adapters.test.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {};

build/__tests__/adapters.test.js

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { describe, it, expect } from "vitest";
2+
import { NovadaAdapter } from "../adapters/novada.js";
3+
import { BrightDataAdapter } from "../adapters/brightdata.js";
4+
import { SmartproxyAdapter } from "../adapters/smartproxy.js";
5+
import { OxylabsAdapter } from "../adapters/oxylabs.js";
6+
import { GenericHttpAdapter } from "../adapters/generic.js";
7+
import { resolveAdapter, listAdapters } from "../adapters/index.js";
8+
// ─── Novada ───────────────────────────────────────────────
9+
describe("NovadaAdapter", () => {
10+
it("returns null when credentials missing", () => {
11+
expect(NovadaAdapter.loadCredentials({})).toBeNull();
12+
expect(NovadaAdapter.loadCredentials({ NOVADA_PROXY_USER: "u" })).toBeNull();
13+
expect(NovadaAdapter.loadCredentials({ NOVADA_PROXY_PASS: "p" })).toBeNull();
14+
});
15+
it("loads credentials with defaults", () => {
16+
const creds = NovadaAdapter.loadCredentials({
17+
NOVADA_PROXY_USER: "user1",
18+
NOVADA_PROXY_PASS: "pass1",
19+
});
20+
expect(creds).toEqual({
21+
user: "user1",
22+
pass: "pass1",
23+
host: "super.novada.pro",
24+
port: "7777",
25+
});
26+
});
27+
it("respects custom host and port", () => {
28+
const creds = NovadaAdapter.loadCredentials({
29+
NOVADA_PROXY_USER: "u",
30+
NOVADA_PROXY_PASS: "p",
31+
NOVADA_PROXY_HOST: "custom.host",
32+
NOVADA_PROXY_PORT: "9999",
33+
});
34+
expect(creds.host).toBe("custom.host");
35+
expect(creds.port).toBe("9999");
36+
});
37+
it("falls back to default port for invalid values", () => {
38+
for (const bad of ["0", "-1", "99999", "abc", ""]) {
39+
const creds = NovadaAdapter.loadCredentials({
40+
NOVADA_PROXY_USER: "u",
41+
NOVADA_PROXY_PASS: "p",
42+
NOVADA_PROXY_PORT: bad,
43+
});
44+
expect(creds.port).toBe("7777");
45+
}
46+
});
47+
it("builds proxy URL with region/city/session", () => {
48+
const creds = { user: "user1", pass: "p@ss", host: "h.com", port: "7777" };
49+
const url = NovadaAdapter.buildProxyUrl(creds, {
50+
country: "US",
51+
city: "NewYork",
52+
session_id: "abc123",
53+
});
54+
expect(url).toContain("user1-zone-res-region-us-city-newyork-session-abc123");
55+
expect(url).toContain(encodeURIComponent("p@ss"));
56+
expect(url).toContain("h.com:7777");
57+
});
58+
it("builds proxy URL without targeting params", () => {
59+
const creds = { user: "user1", pass: "pass1", host: "h.com", port: "7777" };
60+
const url = NovadaAdapter.buildProxyUrl(creds, {});
61+
expect(url).toBe("http://user1-zone-res:pass1@h.com:7777");
62+
});
63+
});
64+
// ─── BrightData ───────────────────────────────────────────
65+
describe("BrightDataAdapter", () => {
66+
it("returns null when credentials missing", () => {
67+
expect(BrightDataAdapter.loadCredentials({})).toBeNull();
68+
});
69+
it("loads credentials with defaults", () => {
70+
const creds = BrightDataAdapter.loadCredentials({
71+
BRIGHTDATA_USER: "brd-customer-abc",
72+
BRIGHTDATA_PASS: "pass",
73+
});
74+
expect(creds.host).toBe("zproxy.lum-superproxy.io");
75+
expect(creds.port).toBe("22225");
76+
});
77+
it("uses -country- for country and -sid- for session", () => {
78+
const creds = { user: "brd-abc", pass: "p", host: "h", port: "22225" };
79+
const url = BrightDataAdapter.buildProxyUrl(creds, {
80+
country: "US",
81+
session_id: "s1",
82+
});
83+
expect(url).toContain("brd-abc-country-us");
84+
expect(url).toContain("-sid-s1");
85+
expect(url).not.toContain("-session-");
86+
});
87+
});
88+
// ─── Smartproxy ───────────────────────────────────────────
89+
describe("SmartproxyAdapter", () => {
90+
it("returns null when credentials missing", () => {
91+
expect(SmartproxyAdapter.loadCredentials({})).toBeNull();
92+
});
93+
it("loads defaults correctly", () => {
94+
const creds = SmartproxyAdapter.loadCredentials({
95+
SMARTPROXY_USER: "u",
96+
SMARTPROXY_PASS: "p",
97+
});
98+
expect(creds.host).toBe("gate.smartproxy.com");
99+
expect(creds.port).toBe("10001");
100+
});
101+
it("uppercases country code", () => {
102+
const creds = { user: "u", pass: "p", host: "h", port: "10001" };
103+
const url = SmartproxyAdapter.buildProxyUrl(creds, { country: "us" });
104+
expect(url).toContain("-country-US");
105+
});
106+
});
107+
// ─── Oxylabs ──────────────────────────────────────────────
108+
describe("OxylabsAdapter", () => {
109+
it("returns null when credentials missing", () => {
110+
expect(OxylabsAdapter.loadCredentials({})).toBeNull();
111+
});
112+
it("loads defaults correctly", () => {
113+
const creds = OxylabsAdapter.loadCredentials({
114+
OXYLABS_USER: "u",
115+
OXYLABS_PASS: "p",
116+
});
117+
expect(creds.host).toBe("pr.oxylabs.io");
118+
expect(creds.port).toBe("7777");
119+
});
120+
it("uses -cc- for country (uppercase) and -sessid- for session", () => {
121+
const creds = { user: "u", pass: "p", host: "h", port: "7777" };
122+
const url = OxylabsAdapter.buildProxyUrl(creds, {
123+
country: "de",
124+
session_id: "sess1",
125+
});
126+
expect(url).toContain("-cc-DE");
127+
expect(url).toContain("-sessid-sess1");
128+
});
129+
});
130+
// ─── Generic ──────────────────────────────────────────────
131+
describe("GenericHttpAdapter", () => {
132+
it("returns null when PROXY_URL not set", () => {
133+
expect(GenericHttpAdapter.loadCredentials({})).toBeNull();
134+
});
135+
it("returns null for non-http schemes", () => {
136+
expect(GenericHttpAdapter.loadCredentials({ PROXY_URL: "ftp://host" })).toBeNull();
137+
expect(GenericHttpAdapter.loadCredentials({ PROXY_URL: "socks5://host" })).toBeNull();
138+
});
139+
it("returns null for malformed URL", () => {
140+
expect(GenericHttpAdapter.loadCredentials({ PROXY_URL: "not-a-url" })).toBeNull();
141+
});
142+
it("parses a valid PROXY_URL", () => {
143+
const creds = GenericHttpAdapter.loadCredentials({
144+
PROXY_URL: "http://user:pass@host.com:8080",
145+
});
146+
expect(creds.user).toBe("user");
147+
expect(creds.pass).toBe("pass");
148+
expect(creds.proxyUrl).toBe("http://user:pass@host.com:8080");
149+
});
150+
it("returns the URL as-is from buildProxyUrl, ignoring params", () => {
151+
const creds = { proxyUrl: "http://u:p@h:1", user: "u", pass: "p" };
152+
const url = GenericHttpAdapter.buildProxyUrl(creds, { country: "US", city: "LA" });
153+
expect(url).toBe("http://u:p@h:1");
154+
});
155+
it("has proxyUrl in sensitiveFields", () => {
156+
expect(GenericHttpAdapter.sensitiveFields).toContain("proxyUrl");
157+
});
158+
});
159+
// ─── Registry / resolveAdapter ────────────────────────────
160+
describe("resolveAdapter", () => {
161+
it("returns Novada when both Novada and Generic are set", () => {
162+
const result = resolveAdapter({
163+
NOVADA_PROXY_USER: "u",
164+
NOVADA_PROXY_PASS: "p",
165+
PROXY_URL: "http://u:p@h:1",
166+
});
167+
expect(result.adapter.name).toBe("novada");
168+
});
169+
it("returns BrightData over Generic when both set", () => {
170+
const result = resolveAdapter({
171+
BRIGHTDATA_USER: "u",
172+
BRIGHTDATA_PASS: "p",
173+
PROXY_URL: "http://u:p@h:1",
174+
});
175+
expect(result.adapter.name).toBe("brightdata");
176+
});
177+
it("returns Generic as last resort", () => {
178+
const result = resolveAdapter({
179+
PROXY_URL: "http://u:p@h:1",
180+
});
181+
expect(result.adapter.name).toBe("generic");
182+
});
183+
it("returns null when nothing is configured", () => {
184+
expect(resolveAdapter({})).toBeNull();
185+
});
186+
it("Novada is always first in adapter list", () => {
187+
const adapters = listAdapters();
188+
expect(adapters[0].name).toBe("novada");
189+
});
190+
it("Generic is always last in adapter list", () => {
191+
const adapters = listAdapters();
192+
expect(adapters[adapters.length - 1].name).toBe("generic");
193+
});
194+
});

build/__tests__/utils.test.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {};

build/__tests__/utils.test.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, it, expect } from "vitest";
2+
import { htmlToMarkdown, htmlToText, unicodeSafeTruncate } from "../utils.js";
3+
describe("unicodeSafeTruncate", () => {
4+
it("returns short strings unchanged", () => {
5+
expect(unicodeSafeTruncate("hello", 10)).toBe("hello");
6+
});
7+
it("truncates to maxChars", () => {
8+
expect(unicodeSafeTruncate("abcdef", 3)).toBe("abc");
9+
});
10+
it("handles emoji (multi-byte) correctly", () => {
11+
const emoji = "Hello 🌍🌎🌏";
12+
const truncated = unicodeSafeTruncate(emoji, 8);
13+
expect(truncated).toBe("Hello 🌍🌎");
14+
});
15+
});
16+
describe("htmlToMarkdown", () => {
17+
it("strips script tags", () => {
18+
expect(htmlToMarkdown("<p>Hello</p><script>alert(1)</script>")).not.toContain("alert");
19+
});
20+
it("strips style tags", () => {
21+
expect(htmlToMarkdown("<style>.x{color:red}</style><p>Hello</p>")).not.toContain("color");
22+
});
23+
it("strips noscript tags", () => {
24+
expect(htmlToMarkdown("<noscript>JS disabled</noscript><p>Hello</p>")).not.toContain("JS disabled");
25+
});
26+
it("converts headings", () => {
27+
const md = htmlToMarkdown("<h1>Title</h1><h2>Sub</h2>");
28+
expect(md).toContain("# Title");
29+
expect(md).toContain("## Sub");
30+
});
31+
it("converts links", () => {
32+
const md = htmlToMarkdown('<a href="https://example.com">Link</a>');
33+
expect(md).toContain("[Link](https://example.com)");
34+
});
35+
it("converts list items", () => {
36+
const md = htmlToMarkdown("<ul><li>One</li><li>Two</li></ul>");
37+
expect(md).toContain("- One");
38+
expect(md).toContain("- Two");
39+
});
40+
it("decodes HTML entities", () => {
41+
const md = htmlToMarkdown("<p>&amp; &lt; &gt; &quot;</p>");
42+
expect(md).toContain("& < > \"");
43+
});
44+
it("collapses excessive newlines", () => {
45+
const md = htmlToMarkdown("<p>A</p><p></p><p></p><p>B</p>");
46+
expect(md).not.toContain("\n\n\n");
47+
});
48+
});
49+
describe("htmlToText", () => {
50+
it("strips links but keeps text", () => {
51+
const text = htmlToText('<a href="http://x.com">Click</a>');
52+
expect(text).toContain("Click");
53+
expect(text).not.toContain("http://x.com");
54+
});
55+
it("strips heading markers", () => {
56+
const text = htmlToText("<h1>Title</h1>");
57+
expect(text).toContain("Title");
58+
expect(text).not.toContain("#");
59+
});
60+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {};

0 commit comments

Comments
 (0)