mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(v3.8.24): plugins menu + proxy IP-family selector + CodeQL/test cleanup (#3800)
Surfaces the Plugins page (marketplace, #3656) in the sidebar; adds the proxy IP-family selector (auto/ipv4/ipv6) completing #3777's UI; clears the remaining CodeQL URL-substring alerts; covers #3799 with tests and fixes the costs-section count.
This commit is contained in:
committed by
GitHub
parent
c8dee8c5f7
commit
24c785a3ba
@@ -13,15 +13,18 @@
|
||||
- **feat(flags): expose the emergency budget fallback in the Feature Flags page** — `OMNIROUTE_EMERGENCY_FALLBACK` is now a runtime boolean (enabled by default, applied without restart) resolved through the feature-flag stack, so a DB override can toggle it while still honoring the raw env fallback. Follow-up to [#3741](https://github.com/diegosouzapw/OmniRoute/pull/3741) by @zoispag. ([#3752](https://github.com/diegosouzapw/OmniRoute/pull/3752) — thanks @rdself)
|
||||
- **feat(reasoning): preserve `xhigh` reasoning effort by default** — `xhigh` now passes through unless a model explicitly sets `supportsXHighEffort: false`, with the existing `max` normalization kept separate. ([#3756](https://github.com/diegosouzapw/OmniRoute/pull/3756) — thanks @rdself)
|
||||
- **feat(codex): inject OmniRoute memory into Codex Responses WebSocket requests** — retrieved memory is injected into the Responses WebSocket prepare request via the `instructions` field, with the retrieval query derived from the latest user input (skipping tool/reasoning payloads) and duplicate-safe injection. ([#3749](https://github.com/diegosouzapw/OmniRoute/pull/3749) — thanks @kkkayye)
|
||||
- **feat(dashboard): free provider rankings page** — a new dashboard page (with sidebar entry) that ranks free providers (no-auth / OAuth / API-key) by their models' Arena ELO / intelligence scores, joining the provider registry with the `model_intelligence` table via fuzzy model-name matching. Pure computation over existing data — no external calls. ([#3799](https://github.com/diegosouzapw/OmniRoute/pull/3799) — thanks @pizzav-xyz)
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **security(proxy): IPv6-only egress enforcement + closing IP-leak paths (L1/L2/L3)** — de-brackets IPv6 literals at the SOCKS host and the proxy-health tcpCheck (so `socks5://[2001:db8::1]` and any v6-literal proxy connect instead of dying with `ENOTFOUND`), adds a per-proxy `family` policy (`auto`/`ipv4`/`ipv6`), and enforces it end-to-end across SOCKS5/HTTP/HTTPS × global/provider/key × literal/hostname. 16 commits, TDD+BDD, 73 tests. ([#3777](https://github.com/diegosouzapw/OmniRoute/pull/3777))
|
||||
- **security(marketplace): harden the custom-URL SSRF guard** against three bypasses found by automated security review — IPv6/AAAA records (only `dns.resolve4` was checked, so a private AAAA record or an IPv6 literal slipped through), redirect-following (a public URL could 30x to an internal one), and DNS-rebinding TOCTOU. The guard now resolves A+AAAA via the canonical `isPrivateHost`, routes the fetch through `safeOutboundFetch` (public-only, blocks redirects to private hosts), and re-validates on fetch. Reachable only with management auth + a custom `pluginMarketplaceUrl`. Follow-up to [#3656](https://github.com/diegosouzapw/OmniRoute/pull/3656). ([#3774](https://github.com/diegosouzapw/OmniRoute/pull/3774))
|
||||
- **security: resolve all open CodeQL + Dependabot alerts** — CodeQL `js/insufficient-password-hash` (the semantic-cache `apiKeyId` is now a plaintext key prefix, `${apiKeyId}.${digest}`, instead of being folded into the SHA-256 digest, clearing the false positive while preserving per-key cache isolation) and a URL-substring check tightened to an exact host match; Dependabot `esbuild < 0.28.1` pinned via override in both workspaces. ([#3778](https://github.com/diegosouzapw/OmniRoute/pull/3778))
|
||||
- **security: resolve all open CodeQL + Dependabot alerts** — CodeQL `js/insufficient-password-hash` (the semantic-cache `apiKeyId` is now a plaintext key prefix, `${apiKeyId}.${digest}`, instead of being folded into the SHA-256 digest, clearing the false positive while preserving per-key cache isolation) and a URL-substring check tightened to an exact host match; Dependabot `esbuild < 0.28.1` pinned via override in both workspaces. ([#3778](https://github.com/diegosouzapw/OmniRoute/pull/3778)) The remaining `js/incomplete-url-substring-sanitization` instances in the api-key proxy-context test were also cleared by asserting on the parsed URL host/port instead of a substring `includes`. (thanks @diegosouzapw)
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **fix(dashboard): surface the Plugins page (plugin manager + marketplace) in the sidebar** — the plugins page (`/dashboard/plugins`), which hosts the custom plugin marketplace shipped in [#3656](https://github.com/diegosouzapw/OmniRoute/pull/3656), had no menu entry and was reachable only by typing the URL. It now appears under **Agentic Features**. (thanks @diegosouzapw)
|
||||
- **fix(proxy): add the IP-family selector (auto / IPv4-only / IPv6-only) to the proxy form** — the per-proxy `family` egress policy from [#3777](https://github.com/diegosouzapw/OmniRoute/pull/3777) was backend-only (the dashboard had no control, so every proxy stayed on `auto`). The proxy registry form now exposes the selector and the create/update schema accepts it, so IPv6-only egress can be enabled from the UI. (thanks @diegosouzapw)
|
||||
- **fix(combo): deep audit of the combo + quota-shared routing system** — repairs 5 dead/broken rules (streaming-USD cost recording, quota-pool usage provider resolution, provider-diversity wiring, `maxComboDepth` threading, and scoring clamp/NaN-safety incl. `connectionDensity`) and revives the dead `tierAffinity`/`specificityMatch` scoring factors — root cause was a `require()` that throws under ESM, so both factors silently collapsed to `0.5`; now a static import. Validates every auto-router strategy (cost / latency / sla-aware / lkgp / `selectWithStrategy` + aliases) and the predictive-TTFT decision, adds E2E coverage (3-hop priority failover, per-target timeout failover, real `strategy:auto` dispatch), and introduces opt-in complexity-aware routing (2026) layered over the existing specificity detector. Per-target credential+proxy isolation verified clean (`AsyncLocalStorage`). 4 TDD waves, 10 new/updated test files. ([#3779](https://github.com/diegosouzapw/OmniRoute/pull/3779) — thanks @diegosouzapw)
|
||||
- fix(anthropic): normalize sampling params under extended thinking — Claude models with extended thinking (e.g. Opus 4.8 via the Claude Code provider) returned **HTTP 400** when a request carried non-default `temperature`/`top_p` (`temperature may only be set to 1 …`, `top_p must be ≥ 0.95 or unset …`). Tools like VS Code Copilot's "Ollama" BYOK send `temperature: 0.7` + `top_p: 0.9`, so every thinking-enabled Claude request failed; the proxy now drops/normalizes these params at the chokepoint so the request succeeds. ([#3780](https://github.com/diegosouzapw/OmniRoute/pull/3780) — thanks @zhiru)
|
||||
- fix(sse): pass Claude passthrough `thinking` blocks through unchanged — the Anthropic-native Claude OAuth passthrough rewrote every assistant `thinking` block to `redacted_thinking`, which the Messages API rejects (submitted thinking blocks are validated against the original response), so every multi-turn request with extended thinking failed with `400 … thinking blocks … cannot be modified` (very visible on long Claude Code tool-loops). The blocks are now passed through verbatim; the signature is validated server-side and stays valid on replay (including across an OAuth token switch), so the redaction was unnecessary. ([#3775](https://github.com/diegosouzapw/OmniRoute/pull/3775) — thanks @havockdev)
|
||||
|
||||
@@ -15,6 +15,7 @@ type ProxyItem = {
|
||||
region?: string | null;
|
||||
notes?: string | null;
|
||||
status?: string;
|
||||
family?: string;
|
||||
};
|
||||
|
||||
type UsageInfo = {
|
||||
@@ -66,6 +67,7 @@ const EMPTY_FORM = {
|
||||
region: "",
|
||||
notes: "",
|
||||
status: "active",
|
||||
family: "auto",
|
||||
};
|
||||
|
||||
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
|
||||
@@ -280,6 +282,7 @@ export default function ProxyRegistryManager() {
|
||||
region: item.region || "",
|
||||
notes: item.notes || "",
|
||||
status: item.status || "active",
|
||||
family: item.family || "auto",
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
@@ -366,6 +369,7 @@ export default function ProxyRegistryManager() {
|
||||
region: (form.region || "").trim() || null,
|
||||
notes: (form.notes || "").trim() || null,
|
||||
status: form.status,
|
||||
family: form.family || "auto",
|
||||
};
|
||||
if (!editingId || normalizedUsername.length > 0) {
|
||||
payload.username = normalizedUsername;
|
||||
@@ -766,6 +770,19 @@ export default function ProxyRegistryManager() {
|
||||
<option value="socks5">SOCKS5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelFamily")}</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.family}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, family: e.target.value }))}
|
||||
>
|
||||
<option value="auto">{t("familyAuto")}</option>
|
||||
<option value="ipv4">{t("familyIpv4")}</option>
|
||||
<option value="ipv6">{t("familyIpv6")}</option>
|
||||
</select>
|
||||
<p className="text-[11px] text-text-muted mt-1">{t("familyHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelHost")}</label>
|
||||
<input
|
||||
|
||||
@@ -960,6 +960,7 @@
|
||||
"aiFeaturesSection": "AI Features",
|
||||
"mcp": "MCP Server",
|
||||
"a2a": "A2A Server",
|
||||
"plugins": "Plugins",
|
||||
"apiEndpoints": "API Endpoints",
|
||||
"batchFiles": "Files",
|
||||
"analyticsEvals": "Evals",
|
||||
@@ -1055,6 +1056,7 @@
|
||||
"agentSkillsSubtitle": "A2A skill registry",
|
||||
"mcpSubtitle": "MCP server controls",
|
||||
"a2aSubtitle": "A2A protocol server",
|
||||
"pluginsSubtitle": "Plugin marketplace & installs",
|
||||
"mediaSubtitle": "Cached media files",
|
||||
"batchFilesSubtitle": "Batch input/output files",
|
||||
"settingsSubtitle": "All settings",
|
||||
@@ -7595,6 +7597,11 @@
|
||||
"modalEditTitle": "Edit Proxy",
|
||||
"labelName": "Name",
|
||||
"labelType": "Type",
|
||||
"labelFamily": "IP family",
|
||||
"familyAuto": "Auto (dual-stack)",
|
||||
"familyIpv4": "IPv4 only",
|
||||
"familyIpv6": "IPv6 only",
|
||||
"familyHint": "Egress address family. Auto keeps dual-stack; IPv4/IPv6 pin this proxy to that family (prevents a v4 leak under an IPv6-only proxy).",
|
||||
"labelHost": "Host",
|
||||
"labelPort": "Port",
|
||||
"labelUsername": "Username",
|
||||
|
||||
@@ -64,6 +64,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"agent-skills",
|
||||
"mcp",
|
||||
"a2a",
|
||||
"plugins",
|
||||
// Gamification
|
||||
"leaderboard",
|
||||
"profile",
|
||||
@@ -587,6 +588,13 @@ const AGENTIC_FEATURES_ITEMS: readonly SidebarSectionChild[] = [
|
||||
subtitleKey: "a2aSubtitle",
|
||||
icon: "device_hub",
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
href: "/dashboard/plugins",
|
||||
i18nKey: "plugins",
|
||||
subtitleKey: "pluginsSubtitle",
|
||||
icon: "extension",
|
||||
},
|
||||
];
|
||||
|
||||
const GAMIFICATION_GROUP: SidebarItemGroup = {
|
||||
|
||||
@@ -1576,6 +1576,9 @@ const proxyRegistryFieldsSchema = z
|
||||
notes: z.string().trim().max(1000).nullable().optional(),
|
||||
status: z.enum(["active", "inactive"]).optional().default("active"),
|
||||
source: z.enum(["manual", "oneproxy", "dashboard-custom", "vercel-relay"]).optional(),
|
||||
// Address-family egress policy (#3777): "auto" keeps the prior dual-stack behavior;
|
||||
// "ipv4"/"ipv6" pin the connection to that family (no v4 leak under an IPv6-only proxy).
|
||||
family: z.enum(["auto", "ipv4", "ipv6"]).optional().default("auto"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
75
tests/unit/free-provider-rankings.test.ts
Normal file
75
tests/unit/free-provider-rankings.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Regression coverage for the free-provider-rankings feature (#3799), which shipped
|
||||
// without tests. The matching helpers are pure; computeFreeProviderRankings needs a DB
|
||||
// (model_intelligence) so we point DATA_DIR at a throwaway SQLite file.
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-free-rankings-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { stripVersionSuffix, findMatchingIntelligence, computeFreeProviderRankings } =
|
||||
await import("../../src/lib/freeProviderRankings.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test("stripVersionSuffix strips only the trailing version numbers", () => {
|
||||
assert.equal(stripVersionSuffix("kimi-k2.6"), "kimi-k2");
|
||||
assert.equal(stripVersionSuffix("gpt-5.5"), "gpt-5");
|
||||
assert.equal(stripVersionSuffix("claude-opus-4.8.1"), "claude-opus-4");
|
||||
assert.equal(stripVersionSuffix("gpt-5"), "gpt-5"); // no version suffix → unchanged
|
||||
assert.equal(stripVersionSuffix("llama-3-70b"), "llama-3-70b"); // digits not a trailing .N
|
||||
});
|
||||
|
||||
test("findMatchingIntelligence prefers exact, then version-stripped, then prefix", () => {
|
||||
const intelMap = new Map([
|
||||
["gpt-5", [{ score: 90, eloRaw: 1400, confidence: "high", category: "default" }]],
|
||||
["kimi-k2", [{ score: 80, eloRaw: 1300, confidence: "mid", category: "default" }]],
|
||||
]);
|
||||
|
||||
// Strategy 1: exact match.
|
||||
assert.equal(findMatchingIntelligence("gpt-5", intelMap)?.score, 90);
|
||||
// Strategy 2: "kimi-k2.6" → stripped "kimi-k2".
|
||||
assert.equal(findMatchingIntelligence("kimi-k2.6", intelMap)?.score, 80);
|
||||
// Strategy 3: prefix — "gpt-5-turbo" starts with "gpt-5-".
|
||||
assert.equal(findMatchingIntelligence("gpt-5-turbo", intelMap)?.score, 90);
|
||||
// No match anywhere.
|
||||
assert.equal(findMatchingIntelligence("mistral-large", intelMap), null);
|
||||
});
|
||||
|
||||
test("findMatchingIntelligence picks the highest-scoring entry on a tie key", () => {
|
||||
const intelMap = new Map([
|
||||
[
|
||||
"gpt-5",
|
||||
[
|
||||
{ score: 70, eloRaw: 1200, confidence: "low", category: "default" },
|
||||
{ score: 95, eloRaw: 1450, confidence: "high", category: "default" },
|
||||
],
|
||||
],
|
||||
]);
|
||||
assert.equal(findMatchingIntelligence("gpt-5", intelMap)?.score, 95);
|
||||
});
|
||||
|
||||
test("computeFreeProviderRankings returns an array and respects the limit", () => {
|
||||
const all = computeFreeProviderRankings();
|
||||
assert.ok(Array.isArray(all), "expected an array of rankings");
|
||||
|
||||
const limited = computeFreeProviderRankings(undefined, 2);
|
||||
assert.ok(limited.length <= 2, `expected at most 2 rankings, got ${limited.length}`);
|
||||
|
||||
for (const ranking of limited) {
|
||||
assert.equal(typeof ranking.id, "string");
|
||||
assert.ok(["noauth", "oauth", "apikey"].includes(ranking.category));
|
||||
}
|
||||
});
|
||||
@@ -12,7 +12,12 @@ describe("API-key usage egresses through proxy context", () => {
|
||||
// Deterministic, no network dependency: this is the core mechanism the L3 fix uses
|
||||
// when wrapping getUsageForProvider in runWithProxyContext(apiKeyProxy?.proxy ?? null).
|
||||
const url = proxyConfigToUrl({ type: "http", host: "p.example.com", port: 8080 });
|
||||
assert.ok(url && url.includes("p.example.com:8080"), `expected proxy url, got ${url}`);
|
||||
assert.ok(url, `expected proxy url, got ${url}`);
|
||||
// Parse and compare host/port exactly (substring matching on a URL is unsafe — CodeQL
|
||||
// js/incomplete-url-substring-sanitization — and a weaker assertion than equality).
|
||||
const parsed = new URL(url);
|
||||
assert.equal(parsed.hostname, "p.example.com");
|
||||
assert.equal(parsed.port, "8080");
|
||||
});
|
||||
|
||||
it("a null proxy config (no connection proxy) resolves to no proxy", () => {
|
||||
@@ -28,7 +33,8 @@ describe("API-key usage egresses through proxy context", () => {
|
||||
await runWithProxyContext({ type: "http", host: "p.example.com", port: 8080 }, async () => {
|
||||
const r = resolveProxyForRequest("https://api.example.com");
|
||||
assert.equal(r.source, "context");
|
||||
assert.ok(r.proxyUrl && r.proxyUrl.includes("p.example.com"));
|
||||
assert.ok(r.proxyUrl, "expected a proxy url from context");
|
||||
assert.equal(new URL(r.proxyUrl).hostname, "p.example.com");
|
||||
});
|
||||
} catch (err) {
|
||||
// Expected when the proxy host is unreachable; the mechanism is still proven by the
|
||||
|
||||
@@ -14,6 +14,7 @@ const proxiesDb = await import("../../src/lib/db/proxies.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const proxiesRoute = await import("../../src/app/api/settings/proxies/route.ts");
|
||||
const { createProxyRegistrySchema } = await import("../../src/shared/validation/schemas.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
@@ -479,3 +480,59 @@ test("createProxy persists type:vercel and source:vercel-relay to DB (schema gap
|
||||
assert.equal(created?.type, "vercel");
|
||||
assert.equal(created?.source, "vercel-relay");
|
||||
});
|
||||
|
||||
test("proxy registry schema accepts the IP family policy and defaults it to auto (#3777)", () => {
|
||||
// The dashboard proxy form (ProxyRegistryManager) now sends `family`. The schema is
|
||||
// .strict(), so an undeclared field would 400 the whole request — this guards the wiring.
|
||||
const explicit = createProxyRegistrySchema.parse({
|
||||
name: "v6-only proxy",
|
||||
type: "socks5",
|
||||
host: "proxy.example.com",
|
||||
port: 1080,
|
||||
family: "ipv6",
|
||||
});
|
||||
assert.equal(explicit.family, "ipv6");
|
||||
|
||||
const defaulted = createProxyRegistrySchema.parse({
|
||||
name: "default family proxy",
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
});
|
||||
assert.equal(defaulted.family, "auto");
|
||||
|
||||
assert.throws(() =>
|
||||
createProxyRegistrySchema.parse({
|
||||
name: "bad family",
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
family: "ipv7",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("createProxy persists the IP family and reads it back (#3777)", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const created = await proxiesDb.createProxy({
|
||||
name: "Family RoundTrip",
|
||||
type: "socks5",
|
||||
host: "v6.example.com",
|
||||
port: 1080,
|
||||
family: "ipv6",
|
||||
});
|
||||
assert.equal(created?.family, "ipv6");
|
||||
|
||||
const fetched = await proxiesDb.getProxyById(created.id, { includeSecrets: false });
|
||||
assert.equal(fetched?.family, "ipv6");
|
||||
|
||||
// Omitting family defaults to "auto" (prior dual-stack behavior, no regression).
|
||||
const legacy = await proxiesDb.createProxy({
|
||||
name: "Family Default",
|
||||
type: "http",
|
||||
host: "dual.example.com",
|
||||
port: 8080,
|
||||
});
|
||||
assert.equal(legacy?.family, "auto");
|
||||
});
|
||||
|
||||
@@ -12,15 +12,21 @@ test("costs section exists in SIDEBAR_SECTIONS", () => {
|
||||
assert.ok(section, "costs section must exist");
|
||||
});
|
||||
|
||||
test("costs section has exactly 4 items in the correct order", () => {
|
||||
test("costs section has exactly 5 items in the correct order", () => {
|
||||
const section = findSection("costs");
|
||||
assert.ok(section, "costs section must exist");
|
||||
|
||||
const items = sidebarVisibility.getSectionItems(section);
|
||||
assert.equal(items.length, 4, "costs section must have 4 items");
|
||||
assert.equal(items.length, 5, "costs section must have 5 items");
|
||||
|
||||
const itemIds = items.map((i) => i.id);
|
||||
assert.deepEqual(itemIds, ["costs", "costs-pricing", "costs-budget", "costs-free-tiers"]);
|
||||
assert.deepEqual(itemIds, [
|
||||
"costs",
|
||||
"costs-pricing",
|
||||
"costs-budget",
|
||||
"costs-free-tiers",
|
||||
"free-provider-rankings",
|
||||
]);
|
||||
});
|
||||
|
||||
test("costs section items have correct hrefs", () => {
|
||||
@@ -35,6 +41,7 @@ test("costs section items have correct hrefs", () => {
|
||||
{ id: "costs-pricing", href: "/dashboard/costs/pricing" },
|
||||
{ id: "costs-budget", href: "/dashboard/costs/budget" },
|
||||
{ id: "costs-free-tiers", href: "/dashboard/free-tiers" },
|
||||
{ id: "free-provider-rankings", href: "/dashboard/free-provider-rankings" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -58,7 +65,7 @@ test("costs item was removed from analytics section", () => {
|
||||
assert.equal(
|
||||
analyticsItemIds.includes("costs" as sidebarVisibility.HideableSidebarItemId),
|
||||
false,
|
||||
"costs item must not be in analytics section",
|
||||
"costs item must not be in analytics section"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -74,11 +81,11 @@ test("costs section is positioned between analytics and monitoring", () => {
|
||||
|
||||
assert.ok(
|
||||
analyticsIdx < costsIdx,
|
||||
`analytics (${analyticsIdx}) must come before costs (${costsIdx})`,
|
||||
`analytics (${analyticsIdx}) must come before costs (${costsIdx})`
|
||||
);
|
||||
assert.ok(
|
||||
costsIdx < monitoringIdx,
|
||||
`costs (${costsIdx}) must come before monitoring (${monitoringIdx})`,
|
||||
`costs (${costsIdx}) must come before monitoring (${monitoringIdx})`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -113,6 +113,22 @@ test("help sidebar exposes changelog after docs and issues", () => {
|
||||
assert.equal(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS.includes("changelog"), true);
|
||||
});
|
||||
|
||||
test("plugins (marketplace) has a discoverable sidebar entry (#3656 follow-up)", async () => {
|
||||
const items = sectionItems("agentic-features");
|
||||
const plugins = items.find((item) => item.id === "plugins");
|
||||
assert.ok(plugins, "expected a plugins item in the agentic-features section");
|
||||
assert.equal(plugins.href, "/dashboard/plugins");
|
||||
assert.equal(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS.includes("plugins"), true);
|
||||
|
||||
// It must be a real page (plugin manager + marketplace tab), not a legacy redirect stub.
|
||||
const pluginsPage = await readFile(
|
||||
join(repoRoot, "src/app/(dashboard)/dashboard/plugins/page.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
assert.doesNotMatch(pluginsPage, /^\s*redirect\(/m);
|
||||
assert.match(pluginsPage, /marketplace/i);
|
||||
});
|
||||
|
||||
test("legacy dashboard routes redirect to their consolidated surfaces", async () => {
|
||||
const autoComboPage = await readFile(
|
||||
join(repoRoot, "src/app/(dashboard)/dashboard/auto-combo/page.tsx"),
|
||||
|
||||
Reference in New Issue
Block a user