From f008ae3c0675d7504411818906fcaa5b04335d98 Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Fri, 14 Aug 2026 08:21:38 +0000 Subject: [PATCH 1/3] refactor(media): extract free-plan probe into useFreePlan hook The media studio detected the free plan with a direct fetch inside a component useEffect, tripping the strict frontend-compliance data-effect guard for the media scope. Move the probe into a useFreePlan() hook (outside the component tree, alongside the other media hooks) so the studio components stay free of direct data-effects. Behaviour is unchanged: it still probes the cloud-only /api/dashboard/usage/free-limits route (base returns 404 -> not free -> no gating; the API enforces limits server-side regardless). Co-Authored-By: Claude Opus 4.8 --- .../media/components/media-studio-panel.tsx | 12 +++----- src/features/media/use-free-plan.ts | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 src/features/media/use-free-plan.ts diff --git a/src/features/media/components/media-studio-panel.tsx b/src/features/media/components/media-studio-panel.tsx index 9bda880e..86f3afab 100644 --- a/src/features/media/components/media-studio-panel.tsx +++ b/src/features/media/components/media-studio-panel.tsx @@ -29,6 +29,7 @@ import { ParameterForm } from "./parameter-form" import { MediaPreviewDialog, type PreviewableAsset } from "./media-preview-dialog" import { useMediaStudioStore, type StoreJob } from "@/features/media/store" import { getCapability, referenceRoleLabel } from "@/features/media/model-capabilities" +import { useFreePlan } from "@/features/media/use-free-plan" import { GeneratingOverlay, MODALITY_META, @@ -66,14 +67,9 @@ export function MediaStudioPanel({ modality, models }: Props) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) // Cloud: media generation is a paid feature. Detect free plan to gate the - // generate action + show an upsell (the API also enforces this). - const [isFreePlan, setIsFreePlan] = useState(false) - useEffect(() => { - fetch("/api/dashboard/usage/free-limits", { credentials: "same-origin" }) - .then((r) => (r.ok ? r.json() : null)) - .then((d) => setIsFreePlan(!!d?.isFree)) - .catch(() => {}) - }, []) + // generate action + show an upsell (the API also enforces this). The probe + // lives in a hook so this component stays free of direct data-effects. + const isFreePlan = useFreePlan() const { jobs, diff --git a/src/features/media/use-free-plan.ts b/src/features/media/use-free-plan.ts new file mode 100644 index 00000000..27142a0d --- /dev/null +++ b/src/features/media/use-free-plan.ts @@ -0,0 +1,28 @@ +"use client" + +import { useEffect, useState } from "react" + +/** + * Detects whether the current org is on a free plan, used to gate the paid + * media-generation action and show an upsell. + * + * This is an intentional base/cloud seam: `/api/dashboard/usage/free-limits` + * exists only in the cloud deployment (paid feature). In the open-source base + * the route 404s, the probe resolves to `false`, and no gating applies — the + * API still enforces limits server-side regardless. + * + * Kept in a hook (outside the media component tree) so the studio components + * stay free of direct data-effects per the frontend-compliance policy. + */ +export function useFreePlan(): boolean { + const [isFreePlan, setIsFreePlan] = useState(false) + + useEffect(() => { + fetch("/api/dashboard/usage/free-limits", { credentials: "same-origin" }) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => setIsFreePlan(!!d?.isFree)) + .catch(() => {}) + }, []) + + return isFreePlan +} From b4c667fc8707ad38556734ea818453c8fddc6c50 Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Fri, 14 Aug 2026 08:21:38 +0000 Subject: [PATCH 2/3] test(rag): fix env pollution in getDefaultReranker cohere tests The cohere-provider tests deleted KB_RERANK_MODEL before a dynamic import of @/lib/rag/rerankers, but loading that module re-seeds the env default, so the delete was undone and the "cohere default model" assertion saw openai/gpt-4.1-nano instead of rerank-v4.0-pro. Import the module statically at the top (its load-time side effects run once, before any test) and manage env at test runtime, since getDefaultReranker reads env lazily. Restore each KB_RERANK_* key individually instead of reassigning process.env wholesale, which replaces Node's special env object and breaks cross-test isolation. Co-Authored-By: Claude Opus 4.8 --- .../rag/rerankers/cohere-reranker.test.ts | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/tests/unit/rag/rerankers/cohere-reranker.test.ts b/tests/unit/rag/rerankers/cohere-reranker.test.ts index d32781b3..e44009b0 100644 --- a/tests/unit/rag/rerankers/cohere-reranker.test.ts +++ b/tests/unit/rag/rerankers/cohere-reranker.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { CohereReranker } from "@/lib/rag/rerankers/cohere-reranker"; +// Static import so the module's load-time side effects (which seed some +// KB_RERANK_* env defaults) run ONCE, before any test manipulates env. +// getDefaultReranker reads env lazily at call time, so per-test env changes win. +import { + getDefaultReranker, + CohereReranker as C, + LlmReranker, +} from "@/lib/rag/rerankers"; describe("CohereReranker", () => { const originalFetch = global.fetch; @@ -133,39 +141,55 @@ describe("CohereReranker", () => { }); describe("getDefaultReranker (cohere provider)", () => { - const originalEnv = { ...process.env }; + // Save/restore each KB_RERANK_* key individually. Reassigning + // `process.env = {...}` replaces Node's special env object with a plain one, + // which breaks later reads and cross-test isolation — restore per key instead. + const RERANK_KEYS = [ + "KB_RERANK_ENABLED", + "KB_RERANK_PROVIDER", + "KB_RERANK_MODEL", + "KB_RERANK_API_KEY", + "COHERE_API_KEY", + ]; + const saved: Record = {}; + + beforeEach(() => { + for (const k of RERANK_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + afterEach(() => { - process.env = { ...originalEnv }; + for (const k of RERANK_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } }); - it("returns CohereReranker when KB_RERANK_PROVIDER=cohere", async () => { + it("returns CohereReranker when KB_RERANK_PROVIDER=cohere", () => { process.env.KB_RERANK_ENABLED = "true"; process.env.KB_RERANK_PROVIDER = "cohere"; process.env.KB_RERANK_API_KEY = "cohere-test-key"; - delete process.env.KB_RERANK_MODEL; + // KB_RERANK_MODEL cleared by beforeEach → cohere default applies. - const { getDefaultReranker, CohereReranker: C } = await import("@/lib/rag/rerankers"); const r = getDefaultReranker(); expect(r).toBeInstanceOf(C); expect(r?.name).toBe("rerank-v4.0-pro"); }); - it("falls back to COHERE_API_KEY when KB_RERANK_API_KEY is unset", async () => { + it("falls back to COHERE_API_KEY when KB_RERANK_API_KEY is unset", () => { process.env.KB_RERANK_ENABLED = "true"; process.env.KB_RERANK_PROVIDER = "cohere"; - delete process.env.KB_RERANK_API_KEY; process.env.COHERE_API_KEY = "fallback-key"; - const { getDefaultReranker } = await import("@/lib/rag/rerankers"); const r = getDefaultReranker(); expect(r).not.toBeNull(); }); - it("still returns LlmReranker when KB_RERANK_PROVIDER is absent", async () => { + it("still returns LlmReranker when KB_RERANK_PROVIDER is absent", () => { process.env.KB_RERANK_ENABLED = "true"; - delete process.env.KB_RERANK_PROVIDER; process.env.KB_RERANK_MODEL = "openai/gpt-4.1-nano"; - const { getDefaultReranker, LlmReranker } = await import("@/lib/rag/rerankers"); expect(getDefaultReranker()).toBeInstanceOf(LlmReranker); }); }); From 454de7ef7e581d495fb2b6b7e0a3d991d34d919b Mon Sep 17 00:00:00 2001 From: HV NQRust Date: Fri, 14 Aug 2026 08:21:38 +0000 Subject: [PATCH 3/3] test(tools): align aesthetic-directive tests with auto-default behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _validate-artifact auto-defaults a missing @aesthetic directive to a warning (not a hard failure) unless ARTIFACT_REACT_AESTHETIC_REQUIRED is set — a deliberate fix for an infinite retry loop. The tests still expected a hard failure. Enable the flag in the aesthetic-directive suite (whose cases assert strict enforcement) and rewrite the flag-unset case to assert the warning-by-default path. Co-Authored-By: Claude Opus 4.8 --- tests/unit/tools/validate-artifact.test.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/unit/tools/validate-artifact.test.ts b/tests/unit/tools/validate-artifact.test.ts index 432304bb..70764b71 100644 --- a/tests/unit/tools/validate-artifact.test.ts +++ b/tests/unit/tools/validate-artifact.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, vi } from "vitest" +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest" import { validateArtifactContent } from "@/lib/tools/builtin/_validate-artifact" vi.mock("@/lib/unsplash/client", () => ({ @@ -1714,6 +1714,19 @@ describe("validateArtifactContent — application/react — aesthetic directive" } export default App` + // @aesthetic is auto-defaulted (a warning, not a hard error) by default — see + // the retry-loop fix in _validate-artifact.ts. The "hard-errors" cases below + // assert strict-mode enforcement, so enable the flag for this block. + const ORIG_AESTHETIC_REQUIRED = process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED + beforeEach(() => { + process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED = "true" + }) + afterEach(() => { + if (ORIG_AESTHETIC_REQUIRED === undefined) + delete process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED + else process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED = ORIG_AESTHETIC_REQUIRED + }) + it("accepts a valid @aesthetic directive", async () => { const code = `// @aesthetic: editorial\n${MINIMAL_BODY}` const r = await validateArtifactContent("application/react", code) @@ -1889,10 +1902,11 @@ describe("validateArtifactContent — application/react — rollback flag", () = else process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED = orig }) - it("hard-errors on missing directive by default (flag unset)", async () => { + it("passes with a warning on missing directive by default (flag unset)", async () => { delete process.env.ARTIFACT_REACT_AESTHETIC_REQUIRED const r = await validateArtifactContent("application/react", BODY_WITHOUT_DIRECTIVE) - expect(r.ok).toBe(false) + expect(r.ok).toBe(true) + expect(r.warnings.join("\n")).toMatch(/@aesthetic.*missing/i) }) it("hard-errors on missing directive when flag='true' (explicit)", async () => {