From ab1dfddcc8d040e7be2b12b8bab812be69b5c401 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 20 Jun 2026 06:43:50 -0300 Subject: [PATCH] =?UTF-8?q?chore(release):=20finalize=20v3.8.30=20?= =?UTF-8?q?=E2=80=94=20complete=20CHANGELOG=20+=20CodeQL=20freeWebSearch?= =?UTF-8?q?=20hardening=20+=20docs=20reconcile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: complete the [3.8.30] section to 1:1 coverage of all 70 commits since v3.8.29 (Features/Changed/Fixed/Tests/Maintenance/Security/Dependencies). - security: cherry-pick the DDG-lite scraper sanitization hardening from #4356 (closes 4 HIGH CodeQL alerts: js/double-escaping, js/incomplete-multi-character- sanitization, js/incomplete-url-substring-sanitization x2). - docs: ANTHROPIC_AUTH_TOKEN (not the bare AUTH_TOKEN shorthand) in the Claude Code guide; allowlist GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL (Gemini CLI vars) in the fabricated-docs checker — fixes the docs-sync-strict gate. --- CHANGELOG.md | 59 +++++++++++++++++++- docs/guides/CLAUDE-CODE-CONFIGURATION.md | 2 +- open-sse/services/freeWebSearch.ts | 39 +++++++++---- scripts/check/check-fabricated-docs.mjs | 2 + tests/unit/free-web-search.test.ts | 33 +++++++++++ tests/unit/search-handler-duckduckgo.test.ts | 13 ++++- 6 files changed, 132 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba645bbfdd..76af7585c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,7 @@ --- -## [3.8.30] — TBD - -_In development — bullets added per PR; finalized at release._ +## [3.8.30] — 2026-06-20 ### ✨ New Features @@ -15,6 +13,13 @@ _In development — bullets added per PR; finalized at release._ - **feat(providers): add OpenAdapter, dit.ai and TokenRouter as OpenAI-compatible providers** — three community-requested OpenAI-compatible aggregators now register as standard named OpenAI-style providers with live `/v1/models` discovery (the zenmux pattern), falling back to a seeded catalog when the upstream list is unavailable: **OpenAdapter** (`https://api.openadapter.in/v1`, free tier, 70+ open-source models — [#4239](https://github.com/diegosouzapw/OmniRoute/issues/4239)), **dit.ai** (`https://api.dit.ai/v1`, dynamic-pricing router/gateway — [#4155](https://github.com/diegosouzapw/OmniRoute/issues/4155)), and **TokenRouter** (`https://api.tokenrouter.com/v1`, free MiniMax model — [#3841](https://github.com/diegosouzapw/OmniRoute/issues/3841), thanks @FerLuisxd). No custom executor/translator — default OpenAI passthrough. - **feat(api): `x-omniroute-no-memory` request header — per-request opt-out of memory/skills injection** — clients that manage their own context (e.g. their own RAG/memory) can send `x-omniroute-no-memory: true` (mirrors the existing `x-omniroute-no-cache` convention) to skip the gateway injecting up to `memorySettings.maxTokens` (~2k) tokens of memory **and** skills context into that chat request — avoiding the token/cost inflation it otherwise adds on every call. Absent the header, behavior is unchanged. (PRD-2026-06-19-no-memory-header) - **feat(dashboard): MITM tool card lists the exact hosts-file entries to add manually** — the CLI-tools MITM card's "How it works" section now lists the full set of `127.0.0.1 ` lines for the selected tool (sourced from the canonical MITM target registry) instead of a single example domain. Users on locked-down machines — where the automatic, sudo-gated hosts-file edit isn't available — can now copy every required entry by hand. (thanks @mrcyclo) +- **feat(cli): `omniroute launch-codex` + `setup-codex` — run/configure the Codex CLI against OmniRoute** — a launcher and setup command that point the Codex CLI at an OmniRoute endpoint (remote-mode aware). ([#4270](https://github.com/diegosouzapw/OmniRoute/pull/4270)) +- **feat(cli): Claude Code launcher + setup — remote mode + profiles** — `omniroute launch`/`setup` for Claude Code with remote-mode support and named connection profiles. ([#4274](https://github.com/diegosouzapw/OmniRoute/pull/4274)) +- **feat(cli): OpenCode setup — OpenAI-compatible provider + remote-aware plugin** — `setup-opencode` registers OmniRoute as an OpenAI-compatible provider for OpenCode and installs a remote-aware plugin. ([#4277](https://github.com/diegosouzapw/OmniRoute/pull/4277)) +- **feat(cli): one-command setup for popular AI coding tools** — new `setup-*` commands that configure each tool to talk to OmniRoute: **Cline** ([#4280](https://github.com/diegosouzapw/OmniRoute/pull/4280)), **Kilo Code** ([#4284](https://github.com/diegosouzapw/OmniRoute/pull/4284)), **Continue** ([#4289](https://github.com/diegosouzapw/OmniRoute/pull/4289)), **Cursor** ([#4291](https://github.com/diegosouzapw/OmniRoute/pull/4291)), **Roo Code** ([#4292](https://github.com/diegosouzapw/OmniRoute/pull/4292)), **Crush** ([#4298](https://github.com/diegosouzapw/OmniRoute/pull/4298)), **Goose** ([#4300](https://github.com/diegosouzapw/OmniRoute/pull/4300)), **Qwen Code** ([#4301](https://github.com/diegosouzapw/OmniRoute/pull/4301)), **Aider** ([#4302](https://github.com/diegosouzapw/OmniRoute/pull/4302)) and the **Gemini CLI** (native `/v1beta`) ([#4303](https://github.com/diegosouzapw/OmniRoute/pull/4303)). +- **feat(providers): provider model sweep — live discovery, refreshed catalogs, dead-provider cleanup** — a broad sweep that enables live `/v1/models` discovery for more OpenAI-style providers (the zenmux pattern), refreshes the seeded catalogs with current models, and marks dead providers `deprecated`. ([#4324](https://github.com/diegosouzapw/OmniRoute/pull/4324)) +- **feat(mitm): translate Antigravity cloudcode end-to-end (Gap B)** — the MITM decrypt path now translates Antigravity `cloudcode` traffic end-to-end. ([#4299](https://github.com/diegosouzapw/OmniRoute/pull/4299)) +- **feat(keys): per-key USD usage quota controls** — an API key can now carry a USD spend quota that caps its usage once the threshold is reached. ([#4327](https://github.com/diegosouzapw/OmniRoute/pull/4327) — thanks @Witroch4) ### 🔧 Changed @@ -39,6 +44,54 @@ _In development — bullets added per PR; finalized at release._ - **fix(oauth): GitHub Copilot token refresh now sends the public client_id** — the `github` provider config never carried a `clientId`, so GitHub OAuth `refresh_token` exchanges either omitted `client_id` or sent the literal string `undefined` (and a bogus `client_secret=undefined`), which GitHub rejects — leaving a Copilot connection stuck once its short-lived token expired and the long-lived refresh path was needed. The provider now resolves its public device-flow `client_id` from the embedded public credential and omits `client_secret` entirely (GitHub's Copilot app is a public client with no secret). (thanks @baslr) - **fix(translator): a tool property named `pattern` survives Gemini/Antigravity schema sanitization** — the Gemini schema sanitizer strips JSON-Schema constraint keywords Gemini rejects (`pattern`, `minLength`, …) at every nesting level, but it also deleted any tool **property** literally *named* one of those keywords. glob/grep tools declare a property called `pattern`, so on `ag/*` (Antigravity) backends that argument (and its `required` entry) was silently dropped, breaking the tools. Keyword stripping is now position-aware: it only removes constraint keywords at the schema-node level and never against the user-defined names inside a `properties` map. A genuine string-level `pattern` *constraint* is still stripped. (thanks @youthanh) - **fix(translator): MCP `namespace` tools flatten to individual functions on the Responses→Chat path** — when a Codex CLI client routes a Responses-API request to a non-Codex backend (e.g. `kr/claude-opus-4.7`), each MCP server is declared as a `namespace` tool (`{ type:"namespace", name, tools:[…] }`). The Responses→Chat translator had no `namespace` branch, so the whole group collapsed into a single empty-schema function named `mcp____` and every MCP call returned `unsupported call: mcp____`, breaking all MCP-based workflows (context7, codegraph, custom MCPs) for that combination. The translator now expands a namespace into one Chat function per sub-tool (preserving each sub-tool's name and parameters); an empty namespace yields no tools instead of a broken placeholder. The native Codex passthrough path was already correct. (thanks @V13t4nh) +- **fix(cli): the active remote-context credential wins over an ambient `OMNIROUTE_API_KEY`** — when a remote context is selected, its scoped access token now takes precedence over an `OMNIROUTE_API_KEY` present in the environment, so the connected remote is targeted as expected. ([#4364](https://github.com/diegosouzapw/OmniRoute/pull/4364)) +- **fix(cli): wire the `contexts` command into the CLI program** — the `omniroute contexts` command (list/switch saved remote contexts) was implemented but never registered, so it was unreachable; it is now wired into the CLI program. ([#4369](https://github.com/diegosouzapw/OmniRoute/pull/4369)) +- **fix(mitm): mask bare `Bearer ` header values in the Traffic Inspector** — the inspector now redacts bare `Authorization: Bearer …` values so tokens don't leak into captured traffic. ([#4358](https://github.com/diegosouzapw/OmniRoute/pull/4358)) +- **fix(pricing): price the `gpt-5.x-pro` OpenAI models + align the opencode-go discovery test** — adds pricing for the gpt-5.x-pro models so cost telemetry reports a real cost instead of zero. ([#4355](https://github.com/diegosouzapw/OmniRoute/pull/4355)) +- **fix(sse): release the reader and cancel the stream on abort/error (no more Undici pool socket leak)** — on abort or a mid-stream error the response reader is released and the stream cancelled, preventing leaked pooled sockets that degraded later requests. ([#4309](https://github.com/diegosouzapw/OmniRoute/pull/4309) — thanks @Ardem2025) +- **fix(kiro): emit an early role-only start chunk to release the stream-readiness gate** — Kiro streams now send an initial role-only chunk so the stream-readiness gate releases promptly instead of stalling. ([#4311](https://github.com/diegosouzapw/OmniRoute/pull/4311) — thanks @artickc) +- **fix(dashboard): the proxy modal stops pre-filling new scopes with an unrelated proxy** — adding a new scope assignment no longer inherits a previously-selected proxy's configuration. ([#4312](https://github.com/diegosouzapw/OmniRoute/pull/4312)) +- **fix(open-sse): inner-ai stops silently rerouting unmatched models to `models[0]`** — an unmatched model id is no longer silently served by the first available model; the lookup now returns null and the request is handled explicitly. ([#4310](https://github.com/diegosouzapw/OmniRoute/pull/4310)) +- **fix(pollinations): handle auth-required premium models (claude, gemini, midjourney)** — premium Pollinations models that require authentication are now handled correctly instead of failing. ([#4266](https://github.com/diegosouzapw/OmniRoute/pull/4266) — thanks @oyi77) +- **fix(codex): isolate the Spark quota scope** — Codex Spark usage is tracked under its own quota scope so it no longer bleeds into other Codex quotas. ([#4293](https://github.com/diegosouzapw/OmniRoute/pull/4293) — thanks @xz-dev) +- **fix(dashboard): improve the API "try it" functionality** — fixes the request path used by the dashboard's API "try it" panel. ([#4296](https://github.com/diegosouzapw/OmniRoute/pull/4296) — thanks @edrickrenan) +- **fix: polyfill `crypto.randomUUID` for non-secure contexts** — restores UUID generation when the dashboard is served over a non-secure (plain-HTTP) origin where `crypto.randomUUID` is unavailable. ([#4287](https://github.com/diegosouzapw/OmniRoute/pull/4287) — thanks @pizzav-xyz) +- **fix(proxy): allow concurrent proxy dispatcher streams** — the proxy dispatcher no longer serializes streams, so concurrent requests through a proxied connection run in parallel. ([#4288](https://github.com/diegosouzapw/OmniRoute/pull/4288) — thanks @wilsonicdev) +- **fix(build): co-locate llmlingua SLM optionals into `dist/node_modules` (postinstall)** — the optional llmlingua SLM packages are co-located into the standalone build so the compression worker can actually spawn in production. ([#4286](https://github.com/diegosouzapw/OmniRoute/pull/4286)) +- **fix(mitm): surface AgentBridge traffic in the Traffic Inspector (D4 ingest)** — AgentBridge requests now appear in the Traffic Inspector. ([#4285](https://github.com/diegosouzapw/OmniRoute/pull/4285)) +- **fix(sse): surface undici `err.cause` on dispatcher failure** — dispatcher failures now flatten the cause chain (and `AggregateError`s) into the error detail for diagnosability. ([#4281](https://github.com/diegosouzapw/OmniRoute/pull/4281)) +- **fix(cli): harden `launch`/`launch-codex` with free-claude-code patterns** — the launchers adopt the hardened launch patterns ported from free-claude-code. ([#4278](https://github.com/diegosouzapw/OmniRoute/pull/4278)) +- **fix(compression): end-to-end audit — fixes across the whole compression flow** — a sweep of the compression pipeline fixing ultra/aggressive/lossless edge cases, accessibility-anchor handling, language detection, and mode decoupling. ([#4323](https://github.com/diegosouzapw/OmniRoute/pull/4323)) + +### 🧪 Tests + +- **test: align two tests left red by merged PRs** — re-aligns the db-rules classification count (#4335) and the LMArena split-cookie metadata test (#4271) after concurrent merges. ([#4346](https://github.com/diegosouzapw/OmniRoute/pull/4346)) +- **test(ci): reconcile the release/v3.8.30 baseline + test drift** — reconciles quality baselines and drifted tests accumulated on the release branch. ([#4276](https://github.com/diegosouzapw/OmniRoute/pull/4276)) + +### 📝 Maintenance + +- **refactor(combo): `ComboContext` + extract `phaseComboSetup` (god-file split, phase 1)** — begins decomposing the combo god-file by extracting combo setup into a context object, without touching dispatch/semaphore logic. ([#4326](https://github.com/diegosouzapw/OmniRoute/pull/4326)) +- **feat(quality): cap test-file size — anti-reinflation Layer 1** — freezes the existing god-tests and caps new test files at 800 lines to stop re-inflation. ([#4273](https://github.com/diegosouzapw/OmniRoute/pull/4273)) +- **feat(quality): seed per-module mutationScore floors + a blocking aggregation ratchet (T3)** — adds per-module mutation-score floors with a blocking aggregate gate. ([#4305](https://github.com/diegosouzapw/OmniRoute/pull/4305)) +- **feat(quality): make the a11y gate real (`@axe-core/playwright` in nightly)** — wires the previously-phantom accessibility gate into the nightly run with real baselines. ([#4321](https://github.com/diegosouzapw/OmniRoute/pull/4321)) +- **feat(quality): unblock R1 — test-redundancy measurement via `disableBail`** — enables the test-redundancy measurement that was previously blocked by fail-fast. ([#4322](https://github.com/diegosouzapw/OmniRoute/pull/4322)) +- **fix(quality): the complexity gate now covers `bin/` + `electron/`, and tracked-artifacts runs in pre-commit** — extends the complexity gate's scope and moves the tracked-artifacts check into the pre-commit hook. ([#4318](https://github.com/diegosouzapw/OmniRoute/pull/4318)) +- **fix(quality): restore release/v3.8.30 green — 3 latent reds from concurrent merges** — fixes three latent test reds surfaced by concurrent merges into the release branch. ([#4335](https://github.com/diegosouzapw/OmniRoute/pull/4335)) +- **fix(combo): keep `phaseComboSetup` under the complexity ceiling** — extracts a helper so the new combo setup phase stays under the complexity gate. ([#4338](https://github.com/diegosouzapw/OmniRoute/pull/4338)) +- **ci(mutation): split over-budget batches by range/pair so every batch fits the job cap** — re-splits the mutation batches so each fits the CI job budget. ([#4272](https://github.com/diegosouzapw/OmniRoute/pull/4272)) +- **chore(ci): align the electron audit gate to the root advisory policy** — the electron-workspace audit gate now follows the same advisory policy as the root. ([#4275](https://github.com/diegosouzapw/OmniRoute/pull/4275)) +- **chore(quality): reconcile the complexity/quality baselines across concurrent-merge drift** — rolls up the cycle's baseline reconciliations driven by concurrent merges into the release branch. ([#4330](https://github.com/diegosouzapw/OmniRoute/pull/4330), [#4336](https://github.com/diegosouzapw/OmniRoute/pull/4336), [#4370](https://github.com/diegosouzapw/OmniRoute/pull/4370)) +- **docs: ban AI-generation footers in commits/PRs/CHANGELOG (Hard Rule #16)** — codifies the prohibition on AI-generation footers and bot co-author trailers. ([#4328](https://github.com/diegosouzapw/OmniRoute/pull/4328)) +- **docs(design): add the OmniRoute design system and visual identity specification** — adds the design-system / visual-identity specification document. (thanks @diegosouzapw) + +### 🔒 Security + +- **fix(sse): harden the DuckDuckGo lite scraper sanitization (CodeQL)** — closes four HIGH CodeQL alerts in the no-key web-search scraper: `decodeEntities` now resolves `&` **last** so an already-escaped entity (e.g. `&lt;`) survives as literal text instead of being double-unescaped (`js/double-escaping`); `stripTags` decodes entities first, then strips tags in a loop to a fixpoint and drops any trailing unclosed `<…`, so entity-encoded markup like `<script>` can never reach the LLM/client as a live tag (`js/incomplete-multi-character-sanitization`); and the host checks in the search tests use `new URL().hostname` equality instead of substring `.includes` (`js/incomplete-url-substring-sanitization`). ([#4356](https://github.com/diegosouzapw/OmniRoute/pull/4356)) + +### 🔧 Dependencies + +- **fix(deps): bump undici to 7.28.0 and dompurify to 3.4.11 (security)** — addresses the undici SOCKS5-TLS / cache advisories and the dompurify advisory. ([#4306](https://github.com/diegosouzapw/OmniRoute/pull/4306)) +- **chore(deps): bump actions/checkout from 4 to 7** — CI checkout-action update. ([#4297](https://github.com/diegosouzapw/OmniRoute/pull/4297)) --- diff --git a/docs/guides/CLAUDE-CODE-CONFIGURATION.md b/docs/guides/CLAUDE-CODE-CONFIGURATION.md index 4cfea16d12..76388c1de3 100644 --- a/docs/guides/CLAUDE-CODE-CONFIGURATION.md +++ b/docs/guides/CLAUDE-CODE-CONFIGURATION.md @@ -36,7 +36,7 @@ endpoint with environment variables (it has no `--base-url` flag): |----------|---------| | `ANTHROPIC_BASE_URL` | Gateway root URL (Claude Code appends `/v1/messages`). **No `/v1` suffix.** | | `ANTHROPIC_AUTH_TOKEN` | Sent as `Authorization: Bearer …` — use your OmniRoute access token / API key | -| `ANTHROPIC_API_KEY` | Alternative: sent as `x-api-key`. If both set, `AUTH_TOKEN` wins | +| `ANTHROPIC_API_KEY` | Alternative: sent as `x-api-key`. If both set, `ANTHROPIC_AUTH_TOKEN` wins | | `ANTHROPIC_MODEL` | Force a specific model (overrides the `/model` picker default) | | `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` | `1` → the native `/model` picker lists `claude*`/`anthropic*` models from `/v1/models` | | `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | Cap output tokens per response (e.g. `65536`) | diff --git a/open-sse/services/freeWebSearch.ts b/open-sse/services/freeWebSearch.ts index 3f0e890f8e..ffd4318eae 100644 --- a/open-sse/services/freeWebSearch.ts +++ b/open-sse/services/freeWebSearch.ts @@ -27,8 +27,7 @@ const DDG_USER_AGENT = // The inner-content captures are HARD-BOUNDED ({0,N}?) and the whole body is truncated // (MAX_HTML_BYTES) so adversarial/unclosed HTML can't cause catastrophic backtracking // (ReDoS) — real titles/snippets are short. See CLAUDE.md PII learnings §1. -const ANCHOR_RE = - /]*?class=['"][^'"]*result-link[^'"]*['"][^>]*)>([\s\S]{0,512}?)<\/a>/gi; +const ANCHOR_RE = /]*?class=['"][^'"]*result-link[^'"]*['"][^>]*)>([\s\S]{0,512}?)<\/a>/gi; const HREF_RE = /href=['"]([^'"]+)['"]/i; const SNIPPET_RE = /]*?class=['"][^'"]*result-snippet[^'"]*['"][^>]*>([\s\S]{0,2048}?)<\/td>/gi; @@ -36,17 +35,37 @@ const SNIPPET_RE = const MAX_HTML_BYTES = 256 * 1024; function decodeEntities(text: string): string { - return text - .replace(/ /gi, " ") - .replace(/&/gi, "&") - .replace(/</gi, "<") - .replace(/>/gi, ">") - .replace(/"/gi, '"') - .replace(/�*39;|�*27;|'/gi, "'"); + return ( + text + .replace(/ /gi, " ") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/�*39;|�*27;|'/gi, "'") + // Decode & LAST so an already-escaped entity like "&lt;" survives as the + // literal text "<" instead of being double-unescaped into "<" — CodeQL + // js/double-escaping. When unescaping, & must resolve after every other entity + // it could otherwise re-create. + .replace(/&/gi, "&") + ); } function stripTags(html: string): string { - return decodeEntities(html.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim(); + // Decode entities FIRST so entity-encoded markup (e.g. "<script>") becomes real + // markup, then remove every tag. Looping to a fixpoint plus dropping a leftover + // unclosed "<…" guarantees the result can't carry "]*>/g, ""); + } while (text !== previous); + // After every well-formed tag is gone, any remaining "<" has no closing ">" — strip the + // trailing partial tag start so "]*$/, ""); + return text.replace(/\s+/g, " ").trim(); } /** diff --git a/scripts/check/check-fabricated-docs.mjs b/scripts/check/check-fabricated-docs.mjs index 27478cf316..63a8be1638 100644 --- a/scripts/check/check-fabricated-docs.mjs +++ b/scripts/check/check-fabricated-docs.mjs @@ -106,6 +106,8 @@ const ENV_VAR_ALLOWLIST = new Set([ "NINEROUTER_API_KEY", // injected into the 9router subprocess at spawn (EMBEDDED-SERVICES.md) "CLAUDE_CODE_MAX_OUTPUT_TOKENS", // Claude Code CLI's own env var (CODEX-CLI-CONFIGURATION.md) "CODEX_HOME", // Codex CLI's own config-home env var (CODEX-CLI-CONFIGURATION.md) + "GEMINI_API_KEY", // Gemini CLI's own API-key env var, set by `omniroute setup-gemini` (REMOTE-MODE.md) + "GOOGLE_GEMINI_BASE_URL", // Gemini CLI's own base-URL env var, set by `omniroute setup-gemini` (REMOTE-MODE.md) "REDIS_PORT", // docker-compose host-port override (DOCKER_GUIDE.md) "AUTO_UPDATE_HOST_REPO_DIR", // docker-compose self-update mount (DOCKER_GUIDE.md) "LINUX_GPG_KEY", // electron AppImage signing key, CI/build only (ELECTRON_GUIDE.md) diff --git a/tests/unit/free-web-search.test.ts b/tests/unit/free-web-search.test.ts index 6b5dc44cf4..001b748451 100644 --- a/tests/unit/free-web-search.test.ts +++ b/tests/unit/free-web-search.test.ts @@ -95,3 +95,36 @@ test("parseDuckDuckGoLite tolerates a missing snippet (empty string, not crash)" assert.equal(results.length, 1); assert.equal(results[0].snippet, ""); }); + +test("parseDuckDuckGoLite does not double-unescape entities (CodeQL js/double-escaping)", () => { + // A snippet whose author literally wrote "5 < 10" reaches us HTML-escaped as + // "5 &lt; 10". Correct single-level decoding must yield the literal "5 < 10", + // NOT collapse it into a real "<" ("5 < 10") by unescaping & before <. + const html = `T + 5 &lt; 10`; + const [r] = parseDuckDuckGoLite(html); + assert.equal(r.snippet, "5 < 10"); + assert.doesNotMatch(r.snippet, /5 < 10/, "must not double-unescape &lt; into a real '<'"); +}); + +test("parseDuckDuckGoLite never emits a live