diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx
index 921fbcb01f..9f021c6a04 100644
--- a/src/app/(dashboard)/dashboard/combos/page.tsx
+++ b/src/app/(dashboard)/dashboard/combos/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useCallback, useRef } from "react";
+import { useState, useEffect, useCallback } from "react";
import {
Card,
Button,
@@ -29,6 +29,110 @@ const STRATEGY_OPTIONS = [
{ value: "cost-optimized", labelKey: "costOpt", descKey: "costOptimizedDesc", icon: "savings" },
];
+const STRATEGY_GUIDANCE_FALLBACK = {
+ priority: {
+ when: "Use when you have one preferred model and only want fallback on failure.",
+ avoid: "Avoid when you need balanced load between models.",
+ example: "Example: Primary coding model with cheaper backup for outages.",
+ },
+ weighted: {
+ when: "Use when you need controlled traffic split across models.",
+ avoid: "Avoid when weights are not maintained or you need strict fairness.",
+ example: "Example: 80% stable model and 20% canary model for safe rollout.",
+ },
+ "round-robin": {
+ when: "Use when you need predictable, even request distribution.",
+ avoid: "Avoid when model latency/cost differs significantly.",
+ example: "Example: Same model across multiple accounts to spread throughput.",
+ },
+ random: {
+ when: "Use when you want a simple spread with low configuration effort.",
+ avoid: "Avoid when requests must be distributed with strict guarantees.",
+ example: "Example: Prototyping with equivalent models and no traffic policy.",
+ },
+ "least-used": {
+ when: "Use when you want adaptive balancing based on recent demand.",
+ avoid: "Avoid when your traffic is too low to benefit from usage balancing.",
+ example: "Example: Mixed workloads where one model tends to get overloaded.",
+ },
+ "cost-optimized": {
+ when: "Use when minimizing cost is the top priority.",
+ avoid: "Avoid when pricing data is missing or outdated.",
+ example: "Example: Batch or background jobs where lower cost matters most.",
+ },
+};
+
+const ADVANCED_FIELD_HELP_FALLBACK = {
+ maxRetries: "How many retries are attempted before failing the request.",
+ retryDelay: "Initial delay between retries. Higher values reduce burst pressure.",
+ timeout: "Maximum request time before aborting. Set higher for long generations.",
+ healthcheck: "Skips unhealthy models/providers from routing decisions when enabled.",
+ concurrencyPerModel: "Max simultaneous requests sent to each model in round-robin.",
+ queueTimeout: "How long a request can wait in queue before timeout in round-robin.",
+};
+
+const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";
+
+const COMBO_TEMPLATE_FALLBACK = {
+ title: "Quick templates",
+ description: "Apply a starting profile, then adjust models and config.",
+ apply: "Apply template",
+ highAvailabilityTitle: "High availability",
+ highAvailabilityDesc: "Priority routing with health checks and safe retries.",
+ costSaverTitle: "Cost saver",
+ costSaverDesc: "Cost-optimized routing for budget-first workloads.",
+ balancedTitle: "Balanced load",
+ balancedDesc: "Least-used routing to spread demand over time.",
+};
+
+const COMBO_TEMPLATES = [
+ {
+ id: "high-availability",
+ icon: "shield",
+ titleKey: "templateHighAvailability",
+ descKey: "templateHighAvailabilityDesc",
+ fallbackTitle: COMBO_TEMPLATE_FALLBACK.highAvailabilityTitle,
+ fallbackDesc: COMBO_TEMPLATE_FALLBACK.highAvailabilityDesc,
+ strategy: "priority",
+ suggestedName: "high-availability",
+ config: {
+ maxRetries: 2,
+ retryDelayMs: 1500,
+ healthCheckEnabled: true,
+ },
+ },
+ {
+ id: "cost-saver",
+ icon: "savings",
+ titleKey: "templateCostSaver",
+ descKey: "templateCostSaverDesc",
+ fallbackTitle: COMBO_TEMPLATE_FALLBACK.costSaverTitle,
+ fallbackDesc: COMBO_TEMPLATE_FALLBACK.costSaverDesc,
+ strategy: "cost-optimized",
+ suggestedName: "cost-saver",
+ config: {
+ maxRetries: 1,
+ retryDelayMs: 500,
+ healthCheckEnabled: true,
+ },
+ },
+ {
+ id: "balanced",
+ icon: "balance",
+ titleKey: "templateBalanced",
+ descKey: "templateBalancedDesc",
+ fallbackTitle: COMBO_TEMPLATE_FALLBACK.balancedTitle,
+ fallbackDesc: COMBO_TEMPLATE_FALLBACK.balancedDesc,
+ strategy: "least-used",
+ suggestedName: "balanced-load",
+ config: {
+ maxRetries: 1,
+ retryDelayMs: 1000,
+ healthCheckEnabled: true,
+ },
+ },
+];
+
function getStrategyMeta(strategy) {
return STRATEGY_OPTIONS.find((s) => s.value === strategy) || STRATEGY_OPTIONS[0];
}
@@ -50,6 +154,18 @@ function getStrategyBadgeClass(strategy) {
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
}
+function getI18nOrFallback(t, key, fallback) {
+ if (typeof t.has === "function" && t.has(key)) return t(key);
+ return fallback;
+}
+
+function getStrategyGuideText(t, strategy, field) {
+ const strategyFallback =
+ STRATEGY_GUIDANCE_FALLBACK[strategy] || STRATEGY_GUIDANCE_FALLBACK.priority;
+ const key = `strategyGuide.${strategy}.${field}`;
+ return getI18nOrFallback(t, key, strategyFallback[field]);
+}
+
// ─────────────────────────────────────────────
// Helper: normalize model entry (legacy string ↔ new object)
// ─────────────────────────────────────────────
@@ -81,6 +197,8 @@ export default function CombosPage() {
const [proxyTargetCombo, setProxyTargetCombo] = useState(null);
const [proxyConfig, setProxyConfig] = useState(null);
const [providerNodes, setProviderNodes] = useState([]);
+ const [showUsageGuide, setShowUsageGuide] = useState(true);
+ const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState("");
useEffect(() => {
fetchData();
@@ -90,6 +208,16 @@ export default function CombosPage() {
.catch(() => {});
}, []);
+ useEffect(() => {
+ try {
+ if (globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) === "1") {
+ setShowUsageGuide(false);
+ }
+ } catch {
+ // Ignore storage access errors (privacy mode / restricted environments)
+ }
+ }, []);
+
const fetchData = async () => {
try {
const [combosRes, providersRes, metricsRes, nodesRes] = await Promise.all([
@@ -129,6 +257,7 @@ export default function CombosPage() {
if (res.ok) {
await fetchData();
setShowCreateModal(false);
+ setRecentlyCreatedCombo(data.name?.trim() || "");
notify.success(t("comboCreated"));
} else {
const err = await res.json();
@@ -228,6 +357,20 @@ export default function CombosPage() {
}
};
+ const handleHideUsageGuideForever = () => {
+ setShowUsageGuide(false);
+ try {
+ globalThis.localStorage?.setItem(COMBO_USAGE_GUIDE_STORAGE_KEY, "1");
+ } catch {}
+ };
+
+ const handleShowUsageGuide = () => {
+ setShowUsageGuide(true);
+ try {
+ globalThis.localStorage?.removeItem(COMBO_USAGE_GUIDE_STORAGE_KEY);
+ } catch {}
+ };
+
if (loading) {
return (
@@ -245,12 +388,69 @@ export default function CombosPage() {
{t("title")}
{t("description")}
- setShowCreateModal(true)}>
- {t("createCombo")}
-
+
+ {!showUsageGuide && (
+
+ {getI18nOrFallback(t, "usageGuideShow", "Show guide")}
+
+ )}
+ setShowCreateModal(true)}>
+ {t("createCombo")}
+
+
-
+ {showUsageGuide && (
+ setShowUsageGuide(false)}
+ onHideForever={handleHideUsageGuideForever}
+ />
+ )}
+
+ {recentlyCreatedCombo && (
+
+
+
+
+ {getI18nOrFallback(
+ t,
+ "quickTestTitle",
+ `Combo "${recentlyCreatedCombo}" ready to validate`
+ )}
+
+
+ {recentlyCreatedCombo}
+
+
+ {getI18nOrFallback(
+ t,
+ "quickTestDescription",
+ "Run a test now to confirm fallback and latency behavior."
+ )}
+
+
+
+ {
+ handleTestCombo({ name: recentlyCreatedCombo });
+ setRecentlyCreatedCombo("");
+ }}
+ >
+ {getI18nOrFallback(t, "testNow", "Test now")}
+
+ setRecentlyCreatedCombo("")}>
+ {tc("close")}
+
+
+
+
+ )}
{/* Combos List */}
{combos.length === 0 ? (
@@ -332,21 +532,36 @@ export default function CombosPage() {
);
}
-function ComboUsageGuide() {
+function ComboUsageGuide({ onHide, onHideForever }) {
const t = useTranslations("combos");
const guideStrategies = ["priority", "cost-optimized", "least-used"];
return (
-
-
-
- tips_and_updates
-
+
+
+
+
+ tips_and_updates
+
+
+
+
{t("routingStrategy")}
+
{t("description")}
+
-
-
{t("routingStrategy")}
-
{t("description")}
+
+
+ {getI18nOrFallback(t, "usageGuideHide", "Hide")}
+
+
+ {getI18nOrFallback(t, "usageGuideDontShowAgain", "Don't show again")}
+
@@ -375,6 +590,50 @@ function ComboUsageGuide() {
);
}
+function StrategyGuidanceCard({ strategy }) {
+ const t = useTranslations("combos");
+ return (
+
+
+ {getI18nOrFallback(t, "strategyGuideTitle", "How to use this strategy")}
+
+
+
+
+ {getI18nOrFallback(t, "strategyGuideWhen", "When to use")}:
+ {" "}
+ {getStrategyGuideText(t, strategy, "when")}
+
+
+
+ {getI18nOrFallback(t, "strategyGuideAvoid", "Avoid when")}:
+ {" "}
+ {getStrategyGuideText(t, strategy, "avoid")}
+
+
+
+ {getI18nOrFallback(t, "strategyGuideExample", "Example")}:
+ {" "}
+ {getStrategyGuideText(t, strategy, "example")}
+
+
+
+ );
+}
+
+function FieldLabelWithHelp({ label, help }) {
+ return (
+
+ {label}
+
+
+ help
+
+
+
+ );
+}
+
// ─────────────────────────────────────────────
// Combo Card
// ─────────────────────────────────────────────
@@ -634,26 +893,66 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const [showModelSelect, setShowModelSelect] = useState(false);
const [saving, setSaving] = useState(false);
const [nameError, setNameError] = useState("");
+ const [pricingByProvider, setPricingByProvider] = useState({});
const [modelAliases, setModelAliases] = useState({});
const [providerNodes, setProviderNodes] = useState([]);
const [showAdvanced, setShowAdvanced] = useState(false);
const [config, setConfig] = useState(combo?.config || {});
// DnD state
+ const hasPricingForModel = useCallback(
+ (modelValue) => {
+ const parts = modelValue.split("/");
+ if (parts.length !== 2) return false;
+
+ const [providerIdentifier, modelId] = parts;
+ const matchedNode = providerNodes.find(
+ (node) => node.id === providerIdentifier || node.prefix === providerIdentifier
+ );
+
+ const providerCandidates = [providerIdentifier];
+ if (matchedNode?.apiType) providerCandidates.push(matchedNode.apiType);
+ if (matchedNode?.name) providerCandidates.push(String(matchedNode.name).toLowerCase());
+
+ return providerCandidates.some((candidate) => !!pricingByProvider?.[candidate]?.[modelId]);
+ },
+ [pricingByProvider, providerNodes]
+ );
+
const [dragIndex, setDragIndex] = useState(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
const weightTotal = models.reduce((sum, modelEntry) => sum + (modelEntry.weight || 0), 0);
+ const pricedModelCount = models.reduce(
+ (count, modelEntry) => count + (hasPricingForModel(modelEntry.model) ? 1 : 0),
+ 0
+ );
+ const pricingCoveragePercent =
+ models.length > 0 ? Math.round((pricedModelCount / models.length) * 100) : 0;
const hasNoModels = models.length === 0;
+ const hasRoundRobinSingleModel = strategy === "round-robin" && models.length === 1;
+ const hasCostOptimizedWithoutPricing =
+ strategy === "cost-optimized" && models.length > 0 && pricedModelCount === 0;
+ const hasCostOptimizedPartialPricing =
+ strategy === "cost-optimized" &&
+ models.length > 0 &&
+ pricedModelCount > 0 &&
+ pricedModelCount < models.length;
const hasInvalidWeightedTotal =
strategy === "weighted" && models.length > 0 && weightTotal !== 100;
const saveBlocked =
- !name.trim() || !!nameError || saving || hasNoModels || hasInvalidWeightedTotal;
+ !name.trim() ||
+ !!nameError ||
+ saving ||
+ hasNoModels ||
+ hasInvalidWeightedTotal ||
+ hasCostOptimizedWithoutPricing;
const fetchModalData = async () => {
try {
- const [aliasesRes, nodesRes] = await Promise.all([
+ const [aliasesRes, nodesRes, pricingRes] = await Promise.all([
fetch("/api/models/alias"),
fetch("/api/provider-nodes"),
+ fetch("/api/pricing"),
]);
if (!aliasesRes.ok || !nodesRes.ok) {
@@ -661,8 +960,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
`Failed to fetch data: aliases=${aliasesRes.status}, nodes=${nodesRes.status}`
);
}
+ const pricingData = pricingRes.ok ? await pricingRes.json() : {};
const [aliasesData, nodesData] = await Promise.all([aliasesRes.json(), nodesRes.json()]);
+ setPricingByProvider(
+ pricingData && typeof pricingData === "object" && !Array.isArray(pricingData)
+ ? pricingData
+ : {}
+ );
setModelAliases(aliasesData.aliases || {});
setProviderNodes(nodesData.nodes || []);
} catch (error) {
@@ -726,6 +1031,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
);
};
+ const applyTemplate = (template) => {
+ setStrategy(template.strategy);
+ setConfig((prev) => ({ ...prev, ...template.config }));
+ if (!name.trim()) setName(template.suggestedName);
+ };
+
// Format model display name with readable provider name
const formatModelDisplay = useCallback(
(modelValue) => {
@@ -799,7 +1110,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const handleSave = async () => {
if (!validateName(name)) return;
- if (hasNoModels || hasInvalidWeightedTotal) return;
+ if (hasNoModels || hasInvalidWeightedTotal || hasCostOptimizedWithoutPricing) return;
setSaving(true);
const saveData: any = {
@@ -842,6 +1153,48 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
{t("nameHint")}
+ {!isEdit && (
+
+
+
+ {getI18nOrFallback(t, "templatesTitle", COMBO_TEMPLATE_FALLBACK.title)}
+
+
+ {getI18nOrFallback(
+ t,
+ "templatesDescription",
+ COMBO_TEMPLATE_FALLBACK.description
+ )}
+
+
+
+ {COMBO_TEMPLATES.map((template) => (
+
applyTemplate(template)}
+ className="text-left rounded-md border border-black/10 dark:border-white/10 bg-white/70 dark:bg-white/[0.03] px-2 py-1.5 hover:border-primary/40 hover:bg-primary/5 transition-colors"
+ >
+
+
+ {template.icon}
+
+
+ {getI18nOrFallback(t, template.titleKey, template.fallbackTitle)}
+
+
+
+ {getI18nOrFallback(t, template.descKey, template.fallbackDesc)}
+
+
+ {getI18nOrFallback(t, "templateApply", COMBO_TEMPLATE_FALLBACK.apply)}
+
+
+ ))}
+
+
+ )}
+
{/* Strategy Toggle */}
@@ -875,6 +1228,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
{getStrategyDescription(t, strategy)}
+
+
+
{/* Models */}
@@ -929,6 +1285,25 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
{formatModelDisplay(entry.model)}
+ {strategy === "cost-optimized" && (
+
+ {hasPricingForModel(entry.model)
+ ? getI18nOrFallback(t, "pricingAvailableShort", "priced")
+ : getI18nOrFallback(t, "pricingMissingShort", "no-price")}
+
+ )}
+
{/* Weight input (weighted mode only) */}
{strategy === "weighted" && (
@@ -986,6 +1361,38 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
{/* Weight total indicator */}
{strategy === "weighted" && models.length > 0 &&
}
+ {strategy === "cost-optimized" && models.length > 0 && (
+
+
+
+ {getI18nOrFallback(t, "pricingCoverage", "Pricing coverage")}
+
+
+ {pricedModelCount}/{models.length} ({pricingCoveragePercent}%)
+
+
+
+
0
+ ? "bg-amber-500"
+ : "bg-red-500"
+ }`}
+ style={{ width: `${pricingCoveragePercent}%` }}
+ />
+
+
+ {getI18nOrFallback(
+ t,
+ "pricingCoverageHint",
+ "Cost-optimized works best when all combo models have pricing."
+ )}
+
+
+ )}
+
{hasNoModels && (
warning
@@ -1002,6 +1409,46 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
)}
+ {hasRoundRobinSingleModel && (
+
+ info
+
+ {getI18nOrFallback(
+ t,
+ "warningRoundRobinSingleModel",
+ "Round-robin is most useful with at least 2 models."
+ )}
+
+
+ )}
+
+ {hasCostOptimizedPartialPricing && (
+
+ warning
+
+ {typeof t.has === "function" && t.has("warningCostOptimizedPartialPricing")
+ ? t("warningCostOptimizedPartialPricing", {
+ priced: pricedModelCount,
+ total: models.length,
+ })
+ : `Only ${pricedModelCount} of ${models.length} models have pricing. Routing may be partially cost-aware.`}
+
+
+ )}
+
+ {hasCostOptimizedWithoutPricing && (
+
+ warning
+
+ {getI18nOrFallback(
+ t,
+ "warningCostOptimizedNoPricing",
+ "No pricing data found for this combo. Cost-optimized may route unexpectedly."
+ )}
+
+
+ )}
+
{/* Add Model button */}
setShowModelSelect(true)}
@@ -1027,9 +1474,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
-
- {t("maxRetries")}
-
+
-
- {t("retryDelay")}
-
+
- {t("timeout")}
+
-
-
{t("healthcheck")}
+
+
-
- {t("concurrencyPerModel")}
-
+
-
- {t("queueTimeout")}
-
+
;
+ isActive: boolean;
+};
+
+type ComboCreatePayload = {
+ name?: string;
+ strategy?: string;
+ models?: unknown[];
+ config?: Record;
+};
+
+test.describe("Combos flow", () => {
+ test("applies template, creates combo, and runs quick test CTA", async ({ page }) => {
+ const state: {
+ combos: ComboStub[];
+ nextId: number;
+ comboTestRequests: number;
+ } = {
+ combos: [],
+ nextId: 1,
+ comboTestRequests: 0,
+ };
+
+ await page.route("**/api/combos/test", async (route) => {
+ state.comboTestRequests += 1;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ resolvedBy: "openai/qa-test-model",
+ results: [{ model: "openai/qa-test-model", status: "ok", latencyMs: 42 }],
+ }),
+ });
+ });
+
+ await page.route("**/api/combos/metrics", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ metrics: {} }),
+ });
+ });
+
+ await page.route("**/api/settings/proxy", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ combos: {} }),
+ });
+ });
+
+ await page.route("**/api/providers", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ connections: [{ id: "conn-openai", provider: "openai", testStatus: "active" }],
+ }),
+ });
+ });
+
+ await page.route("**/api/provider-nodes", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ nodes: [] }),
+ });
+ });
+
+ await page.route("**/api/models/alias", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ aliases: {} }),
+ });
+ });
+
+ await page.route("**/api/provider-models", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ models: {
+ openai: [{ id: "qa-test-model", name: "QA Test Model" }],
+ },
+ }),
+ });
+ });
+
+ await page.route("**/api/pricing", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ openai: {
+ "qa-test-model": {
+ input: 0.01,
+ output: 0.02,
+ },
+ },
+ }),
+ });
+ });
+
+ await page.route("**/api/combos", async (route) => {
+ const method = route.request().method();
+ if (method === "GET") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ combos: state.combos }),
+ });
+ return;
+ }
+
+ if (method === "POST") {
+ const payloadRaw = route.request().postDataJSON();
+ const payload =
+ payloadRaw && typeof payloadRaw === "object" ? (payloadRaw as ComboCreatePayload) : {};
+ const comboId = `combo-${state.nextId++}`;
+ const createdCombo = {
+ id: comboId,
+ name: payload.name || comboId,
+ strategy: payload.strategy || "priority",
+ models: payload.models || [],
+ config: payload.config || {},
+ isActive: true,
+ };
+ state.combos.push(createdCombo);
+
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ combo: createdCombo }),
+ });
+ return;
+ }
+
+ await route.fulfill({
+ status: 405,
+ contentType: "application/json",
+ body: JSON.stringify({ error: "Method not allowed in test stub" }),
+ });
+ });
+
+ await page.goto("/dashboard/combos");
+ await page.waitForLoadState("networkidle");
+
+ const redirectedToLogin = page.url().includes("/login");
+ test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
+
+ await page
+ .getByRole("button", { name: /create combo|criar combo/i })
+ .first()
+ .click();
+
+ const comboDialog = page.getByRole("dialog").first();
+ await expect(comboDialog).toBeVisible();
+
+ await comboDialog
+ .getByRole("button", { name: /high availability|alta disponibilidade/i })
+ .click();
+ await comboDialog.getByRole("button", { name: /add model|adicionar modelo/i }).click();
+
+ const modelDialog = page.getByRole("dialog").last();
+ await expect(modelDialog.getByRole("button", { name: /qa test model/i })).toBeVisible();
+ await modelDialog.getByRole("button", { name: /qa test model/i }).click();
+
+ await comboDialog
+ .getByRole("button", { name: /create combo|criar combo/i })
+ .last()
+ .click();
+ await expect(comboDialog).toBeHidden();
+
+ const quickTestButton = page.getByRole("button", { name: /test now|testar agora/i });
+ await expect(quickTestButton).toBeVisible();
+ await quickTestButton.click();
+
+ await expect
+ .poll(() => state.comboTestRequests, {
+ message: "Expected the quick test CTA to hit /api/combos/test once",
+ })
+ .toBe(1);
+
+ const testResultsModal = page.getByRole("dialog").last();
+ await expect(testResultsModal).toContainText(/qa-test-model/i);
+ });
+});
diff --git a/tests/integration/integration-wiring.test.mjs b/tests/integration/integration-wiring.test.mjs
index 72465fbb2e..404945705c 100644
--- a/tests/integration/integration-wiring.test.mjs
+++ b/tests/integration/integration-wiring.test.mjs
@@ -285,4 +285,24 @@ describe("Page Integration — combos page empty state", () => {
it("should use notification store for UX feedback", () => {
assert.match(src, /useNotificationStore/);
});
+
+ it("should persist usage guide visibility and allow reopening", () => {
+ assert.match(src, /COMBO_USAGE_GUIDE_STORAGE_KEY/);
+ assert.match(src, /localStorage/);
+ assert.match(src, /handleShowUsageGuide/);
+ });
+
+ it("should expose quick templates and post-create quick test CTA", () => {
+ assert.match(src, /COMBO_TEMPLATES/);
+ assert.match(src, /applyTemplate/);
+ assert.match(src, /recentlyCreatedCombo/);
+ assert.match(src, /testNow/);
+ });
+
+ it("should include cost-optimized pricing coverage UX", () => {
+ assert.match(src, /hasPricingForModel/);
+ assert.match(src, /pricingCoveragePercent/);
+ assert.match(src, /pricingCoverage/);
+ assert.match(src, /warningCostOptimizedPartialPricing/);
+ });
});