mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
6dea8ad764
Pure prettier-style rewrite (line-wrapping, single-line collapses, multi- line callback indentation). No semantic changes — confirmed via tsc clean, oxlint clean, vitest 235/235 pass in showcase/shell. Touched files: - showcase/scripts/__tests__/generate-registry-pattern.test.ts - showcase/scripts/generate-registry.ts - showcase/shell/src/lib/backend-url.ts - showcase/shell/src/lib/backend-url.test.ts - showcase/shell/src/lib/docs-redirects.test.ts - showcase/shell/src/lib/local-backends-env.test.ts - showcase/shell/src/lib/runtime-config.ts - showcase/shell/src/lib/runtime-config.test.ts - showcase/shell/src/lib/runtime-url-wiring.test.ts - showcase/shell/src/middleware.ts - showcase/shell/src/middleware.test.ts - showcase/shell/vitest.global-setup.ts
1410 lines
59 KiB
TypeScript
1410 lines
59 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Stub next/cache.unstable_noStore — vitest runs outside Next's runtime
|
|
// so the real implementation throws ("called outside a Server
|
|
// Component"). The function is a no-op for our purposes (it tells Next
|
|
// not to cache; in a unit test there is no cache to opt out of).
|
|
vi.mock("next/cache", () => ({
|
|
unstable_noStore: () => {},
|
|
}));
|
|
|
|
import { getRuntimeConfig } from "./runtime-config";
|
|
|
|
describe("server getRuntimeConfig (shell)", () => {
|
|
const ORIGINAL_ENV = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
for (const k of [
|
|
"BASE_URL",
|
|
"POSTHOG_HOST",
|
|
"POSTHOG_KEY",
|
|
"DOCS_HOST",
|
|
"SHOWCASE_BACKEND_HOST_PATTERN",
|
|
"NEXT_PUBLIC_BASE_URL",
|
|
"NEXT_PUBLIC_POSTHOG_HOST",
|
|
"NEXT_PUBLIC_POSTHOG_KEY",
|
|
"NEXT_PUBLIC_DOCS_HOST",
|
|
"NEXT_PUBLIC_SHOWCASE_BACKEND_HOST_PATTERN",
|
|
"NODE_ENV",
|
|
]) {
|
|
delete (process.env as Record<string, string | undefined>)[k];
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Full restore, not just value restore: Object.assign alone cannot
|
|
// DELETE keys a test added (e.g. DOCS_HOST set by a test but absent
|
|
// from the snapshot), which poisons other test FILES under vitest
|
|
// worker reuse. Drop added keys first, then restore values.
|
|
for (const key of Object.keys(process.env)) {
|
|
if (!(key in ORIGINAL_ENV)) {
|
|
delete (process.env as Record<string, string | undefined>)[key];
|
|
}
|
|
}
|
|
Object.assign(process.env, ORIGINAL_ENV);
|
|
});
|
|
|
|
it("returns env values when all are set (production)", () => {
|
|
// ALL five env values actually set — the test name promises it, and
|
|
// the exact-shape toEqual pins the full config (including posthogKey
|
|
// PRESENCE; a regression dropping the field from the return object
|
|
// would otherwise pass against an undefined-tolerant comparison).
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
process.env.POSTHOG_HOST = "https://eu.i.posthog.com";
|
|
process.env.POSTHOG_KEY = "phc_test_key";
|
|
process.env.DOCS_HOST = "https://docs-staging.example.com";
|
|
process.env.SHOWCASE_BACKEND_HOST_PATTERN =
|
|
"showcase-{slug}-staging.up.railway.app";
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg).toEqual({
|
|
baseUrl: "https://showcase.copilotkit.ai",
|
|
posthogHost: "https://eu.i.posthog.com",
|
|
posthogKey: "phc_test_key",
|
|
backendHostPattern: "showcase-{slug}-staging.up.railway.app",
|
|
docsHost: "https://docs-staging.example.com",
|
|
});
|
|
// toEqual treats a missing key and an undefined value as equal —
|
|
// pin posthogKey presence explicitly.
|
|
expect(cfg.posthogKey).toBe("phc_test_key");
|
|
});
|
|
|
|
it("backendHostPattern/docsHost default to prod values when unset (both envs, no FATAL log)", async () => {
|
|
for (const nodeEnv of ["production", "development"]) {
|
|
(process.env as Record<string, string>).NODE_ENV = nodeEnv;
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
// The no-FATAL assertions below are about ABSENCE — on the static
|
|
// module instance they can pass vacuously when a prior test
|
|
// already consumed the once-guard for these keys. Fresh module so
|
|
// a log would actually fire if the code were wrong.
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
// try/finally so a failing assertion can't leak the spy into
|
|
// other tests (matches every other spy in this file).
|
|
let cfg: ReturnType<typeof freshGet>;
|
|
try {
|
|
cfg = freshGet();
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
expect(cfg.backendHostPattern).toBe(
|
|
"showcase-{slug}-production.up.railway.app",
|
|
);
|
|
expect(cfg.docsHost).toBe("https://docs.showcase.copilotkit.ai");
|
|
// These have legitimate prod defaults — never FATAL-CONFIG noise.
|
|
expect(errs.some((m) => m.includes("DOCS_HOST"))).toBe(false);
|
|
expect(
|
|
errs.some((m) => m.includes("SHOWCASE_BACKEND_HOST_PATTERN")),
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("honors SHOWCASE_BACKEND_HOST_PATTERN and DOCS_HOST at request time", () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://staging.example.com";
|
|
process.env.SHOWCASE_BACKEND_HOST_PATTERN =
|
|
"showcase-{slug}-staging.up.railway.app";
|
|
process.env.DOCS_HOST = "https://docs-staging.example.com/";
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.backendHostPattern).toBe(
|
|
"showcase-{slug}-staging.up.railway.app",
|
|
);
|
|
// docsHost is a URL — trailing slash stripped like the others.
|
|
expect(cfg.docsHost).toBe("https://docs-staging.example.com");
|
|
|
|
// Live re-read on each call (no module-load freeze).
|
|
process.env.DOCS_HOST = "https://docs-staging2.example.com";
|
|
expect(getRuntimeConfig().docsHost).toBe(
|
|
"https://docs-staging2.example.com",
|
|
);
|
|
});
|
|
|
|
it("normalizes a scheme-bearing / slash-trailing SHOWCASE_BACKEND_HOST_PATTERN", () => {
|
|
// The consumer (backendUrlFromPattern) prepends `https://` and
|
|
// concatenates routes — a scheme-bearing value would yield
|
|
// `https://https://…` and a trailing slash would yield `//route`.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
process.env.SHOWCASE_BACKEND_HOST_PATTERN =
|
|
"https://showcase-{slug}-staging.up.railway.app/";
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
expect(getRuntimeConfig().backendHostPattern).toBe(
|
|
"showcase-{slug}-staging.up.railway.app",
|
|
);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("degenerate SHOWCASE_BACKEND_HOST_PATTERN falls back to the default pattern, never an empty string", async () => {
|
|
// Fix-round interaction bug: "https://" normalized to "" with NO
|
|
// fallback — server-side iframe srcs became literally "https://",
|
|
// and the injected "" failed the client reader's
|
|
// REQUIRED_CONFIG_FIELDS check, crashing EVERY client component
|
|
// with a message blaming the layout injection instead of the env
|
|
// var. The degenerate value must produce the default + one FATAL.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
for (const bad of ["https://", "/"]) {
|
|
process.env.SHOWCASE_BACKEND_HOST_PATTERN = bad;
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const errSpy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.backendHostPattern, `pattern ${JSON.stringify(bad)}`).toBe(
|
|
"showcase-{slug}-production.up.railway.app",
|
|
);
|
|
// The client reader requires a non-empty string — this is the
|
|
// exact invariant the round-3 check enforces.
|
|
expect(cfg.backendHostPattern.length).toBeGreaterThan(0);
|
|
expect(
|
|
errs.some(
|
|
(m) =>
|
|
m.includes("FATAL-CONFIG") &&
|
|
m.includes("SHOWCASE_BACKEND_HOST_PATTERN"),
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
errSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("prepends https:// when DOCS_HOST lacks a scheme (host-only misconfig)", () => {
|
|
// Middleware does `new URL(docsHost)` on every docs route. A
|
|
// host-only DOCS_HOST (the documented format of the sibling
|
|
// SHOWCASE_BACKEND_HOST_PATTERN var, an easy misconfig) must NOT
|
|
// produce an unparseable URL that 500s every docs request.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
process.env.DOCS_HOST = "docs-staging.example.com";
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.docsHost).toBe("https://docs-staging.example.com");
|
|
expect(() => new URL(cfg.docsHost)).not.toThrow();
|
|
});
|
|
|
|
it("prepends http:// (not https://) to scheme-less LOOPBACK hosts", () => {
|
|
// The documented local-dev DOCS_HOST wiring is `localhost:3005` —
|
|
// an https:// prepend turns it into a TLS-failing destination with
|
|
// zero warn (nothing serves TLS on a local dev port). Loopback
|
|
// hosts (localhost, 127.0.0.1, [::1]) get http:// instead, across
|
|
// every ensureScheme consumer (BASE_URL, DOCS_HOST, POSTHOG_HOST).
|
|
(process.env as Record<string, string>).NODE_ENV = "development";
|
|
process.env.BASE_URL = "localhost:3000";
|
|
process.env.DOCS_HOST = "localhost:3005";
|
|
process.env.POSTHOG_HOST = "127.0.0.1:8010";
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.baseUrl).toBe("http://localhost:3000");
|
|
expect(cfg.docsHost).toBe("http://localhost:3005");
|
|
expect(cfg.posthogHost).toBe("http://127.0.0.1:8010");
|
|
|
|
// IPv6 loopback too.
|
|
process.env.DOCS_HOST = "[::1]:3005";
|
|
expect(getRuntimeConfig().docsHost).toBe("http://[::1]:3005");
|
|
});
|
|
|
|
it("rejects a loopback BASE_URL in production with the sentinel + FATAL (no silent http:// prepend)", async () => {
|
|
// The loopback http:// prepend exists for frictionless DEV — in
|
|
// production it silently "fixed" BASE_URL=localhost:3000, so
|
|
// canonical hrefs, OG metadata, and the docs loop guard all ran
|
|
// against localhost with zero log. Prod loopback is always a
|
|
// misconfig: FATAL path, not the prepend.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
for (const bad of [
|
|
"localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
"[::1]:3000",
|
|
]) {
|
|
process.env.BASE_URL = bad;
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
expect(freshGet().baseUrl, `value ${JSON.stringify(bad)}`).toBe(
|
|
"https://shell-base-url-missing.invalid",
|
|
);
|
|
expect(
|
|
errs.some((m) => m.includes("BASE_URL") && m.includes("loopback")),
|
|
`value ${JSON.stringify(bad)} should log a loopback FATAL`,
|
|
).toBe(true);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("rejects a loopback DOCS_HOST in production with the default fallback + FATAL", async () => {
|
|
// Same class as the BASE_URL case: `DOCS_HOST=localhost:3005` is
|
|
// documented local-dev wiring — in production it would 308 every
|
|
// docs visitor to localhost, silently.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
for (const bad of ["localhost:3005", "http://localhost:3005"]) {
|
|
process.env.DOCS_HOST = bad;
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
expect(freshGet().docsHost, `value ${JSON.stringify(bad)}`).toBe(
|
|
"https://docs.showcase.copilotkit.ai",
|
|
);
|
|
expect(
|
|
errs.some((m) => m.includes("DOCS_HOST") && m.includes("loopback")),
|
|
`value ${JSON.stringify(bad)} should log a loopback FATAL`,
|
|
).toBe(true);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("preserves an explicit http:// scheme on DOCS_HOST", () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "development";
|
|
process.env.BASE_URL = "http://localhost:3000";
|
|
process.env.DOCS_HOST = "http://localhost:3005";
|
|
expect(getRuntimeConfig().docsHost).toBe("http://localhost:3005");
|
|
});
|
|
|
|
it("falls back to the default docs host (with one loud log) when DOCS_HOST is unparseable", async () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
// Spaces make the host unparseable even after prepending https://.
|
|
process.env.DOCS_HOST = "not a parseable host";
|
|
// Warn-once module state — fresh module instance so the assertion
|
|
// is order-independent and retry-safe.
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.docsHost).toBe("https://docs.showcase.copilotkit.ai");
|
|
expect(() => new URL(cfg.docsHost)).not.toThrow();
|
|
expect(errs.some((m) => m.includes("DOCS_HOST"))).toBe(true);
|
|
// Loud log fires ONCE per bad value, not per request.
|
|
const before = errs.length;
|
|
freshGet();
|
|
expect(errs.length).toBe(before);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("rejects a degenerate DOCS_HOST of just a scheme (https://) with the loud fallback", async () => {
|
|
// `https://` strips to `https:`, the prepend yields
|
|
// `https://https:`, and that PARSES (hostname "https") — it used to
|
|
// slip through silently and break every docs redirect.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
process.env.DOCS_HOST = "https://";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.docsHost).toBe("https://docs.showcase.copilotkit.ai");
|
|
expect(errs.some((m) => m.includes("DOCS_HOST"))).toBe(true);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("prepends https:// when BASE_URL lacks a scheme (host-only misconfig)", async () => {
|
|
// Consumers compose `new URL(path, baseUrl)` — a scheme-less value
|
|
// previously passed readUrl unvalidated and threw at every compose
|
|
// site: opaque 500s with NO log (the env var IS set, so the
|
|
// unset-fallback never fires).
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "shell.copilotkit.ai";
|
|
// The no-FATAL assertion below is an ABSENCE assertion — vacuous on
|
|
// the static module instance if a prior test consumed the BASE_URL
|
|
// once-guard. Fresh module so a log would actually fire.
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.baseUrl).toBe("https://shell.copilotkit.ai");
|
|
expect(() => new URL("/some/path", cfg.baseUrl)).not.toThrow();
|
|
// The prepend FIXES the value — no FATAL noise for it.
|
|
expect(errs.some((m) => m.includes("BASE_URL"))).toBe(false);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("falls back to the sentinel (one loud log naming the value) for a degenerate BASE_URL of just a scheme", async () => {
|
|
// `https://` is reduced to `https:` by the trailing-slash strip,
|
|
// ensureScheme yields `https://https:` which PARSES (hostname
|
|
// "https") — it must be rejected, not silently returned.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.baseUrl).toBe("https://shell-base-url-missing.invalid");
|
|
expect(() => new URL("/some/path", cfg.baseUrl)).not.toThrow();
|
|
// The log must NAME the bad value so the operator can find it.
|
|
// (readUrl's trailing-slash strip runs first, so the named value
|
|
// is the post-strip "https:" — still unambiguous.)
|
|
expect(
|
|
errs.some((m) => m.includes("BASE_URL") && m.includes('"https:')),
|
|
).toBe(true);
|
|
// Once-guarded — not per request.
|
|
const before = errs.length;
|
|
freshGet();
|
|
expect(errs.length).toBe(before);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("rejects a non-http(s) BASE_URL scheme with the sentinel (ftp:// and the dot-scheme edge)", async () => {
|
|
// `ftp://shell.x` parses fine; `example.com://oops` matches
|
|
// SCHEME_RE (dot is a valid scheme char) so ensureScheme leaves it
|
|
// and it parses with protocol "example.com:" and hostname "oops".
|
|
// Neither can serve consumers composing http(s) URLs.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
for (const bad of ["ftp://shell.example.com", "example.com://oops"]) {
|
|
process.env.BASE_URL = bad;
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.baseUrl, `value ${JSON.stringify(bad)}`).toBe(
|
|
"https://shell-base-url-missing.invalid",
|
|
);
|
|
expect(
|
|
errs.some((m) => m.includes("BASE_URL") && m.includes("scheme")),
|
|
`value ${JSON.stringify(bad)} should log a scheme rejection`,
|
|
).toBe(true);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("rejects a mailto: BASE_URL (userinfo after the https:// prepend) with the sentinel", async () => {
|
|
// `mailto:ops@example.com` lacks `://` so SCHEME_RE misses it and
|
|
// the prepend yields `https://mailto:ops@example.com` — a URL with
|
|
// userinfo "mailto:ops" and host "example.com" that previously
|
|
// sailed through validation.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "mailto:ops@example.com";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
expect(freshGet().baseUrl).toBe("https://shell-base-url-missing.invalid");
|
|
expect(errs.some((m) => m.includes("BASE_URL"))).toBe(true);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("origin-normalizes a BASE_URL carrying a path/query/fragment with one warn (subpath deploys unsupported)", async () => {
|
|
// Consumers compose `new URL(path, baseUrl)` — a path/query/fragment
|
|
// silently corrupts every composed URL (a base path is dropped by
|
|
// composition, a fragment swallows the path).
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
for (const [bad, why] of [
|
|
["https://shell.example.com/sub", "path"],
|
|
["https://shell.example.com?a=b", "query"],
|
|
["https://shell.example.com#frag", "fragment"],
|
|
] as const) {
|
|
process.env.BASE_URL = bad;
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const warns: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "warn")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void warns.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
expect(freshGet().baseUrl, `${why} should normalize to origin`).toBe(
|
|
"https://shell.example.com",
|
|
);
|
|
expect(warns.some((m) => m.includes("BASE_URL"))).toBe(true);
|
|
// Once-guarded — not per request.
|
|
const before = warns.length;
|
|
freshGet();
|
|
expect(warns.length).toBe(before);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("dev-mode SET-but-malformed BASE_URL falls back to localhost with a dev warn, not the prod sentinel", async () => {
|
|
// The module's contract is frictionless dev: a malformed value in
|
|
// development previously fell to the PROD `.invalid` sentinel with
|
|
// Railway-flavored guidance — useless on a laptop.
|
|
(process.env as Record<string, string>).NODE_ENV = "development";
|
|
process.env.BASE_URL = "https://";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const warns: string[] = [];
|
|
const errs: string[] = [];
|
|
const warnSpy = vi
|
|
.spyOn(console, "warn")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void warns.push(args.map(String).join(" ")),
|
|
);
|
|
const errSpy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.baseUrl).toBe("http://localhost:3000");
|
|
expect(warns.some((m) => m.includes("BASE_URL"))).toBe(true);
|
|
// No FATAL noise and no Railway guidance in dev.
|
|
expect(errs.some((m) => m.includes("BASE_URL"))).toBe(false);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
errSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("unset-BASE_URL FATAL names the post-strip sentinel (consistent with the returned value)", async () => {
|
|
// readUrl returns the trailing-slash-stripped fallback but printed
|
|
// the PRE-strip form — the log named a value that never appears.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
freshGet();
|
|
const fatal = errs.find((m) => m.includes("BASE_URL is unset"));
|
|
expect(fatal).toBeDefined();
|
|
expect(fatal).toContain("https://shell-base-url-missing.invalid");
|
|
expect(fatal).not.toContain("shell-base-url-missing.invalid/");
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("falls back to the sentinel (one loud log) when BASE_URL is unparseable garbage", async () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "not a base url";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.baseUrl).toBe("https://shell-base-url-missing.invalid");
|
|
expect(
|
|
errs.some(
|
|
(m) => m.includes("BASE_URL") && m.includes('"not a base url"'),
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("normalizes a DOCS_HOST carrying a path/query/fragment to its origin (one warn)", async () => {
|
|
// docs-redirects composes destination paths against this value — a
|
|
// fragment swallows the path (`https://docs.x.ai#frag` + `/docs/y`
|
|
// lands on the docs ROOT), a query/path corrupts every destination.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
for (const [bad, why] of [
|
|
["https://docs.example.com/sub", "path"],
|
|
["https://docs.example.com?a=b", "query"],
|
|
["https://docs.example.com#frag", "fragment"],
|
|
] as const) {
|
|
process.env.DOCS_HOST = bad;
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const warns: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "warn")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void warns.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.docsHost, `${why} should normalize to origin`).toBe(
|
|
"https://docs.example.com",
|
|
);
|
|
expect(warns.some((m) => m.includes("DOCS_HOST"))).toBe(true);
|
|
// Once-guarded — not per request.
|
|
const before = warns.length;
|
|
freshGet();
|
|
expect(warns.length).toBe(before);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("rejects a non-http(s) DOCS_HOST scheme (ftp://) with the loud fallback", async () => {
|
|
// `ftp://docs.example.com` parses fine — but no middleware redirect
|
|
// destination can ever use it; it must not pass validation silently.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
process.env.DOCS_HOST = "ftp://docs.example.com";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.docsHost).toBe("https://docs.showcase.copilotkit.ai");
|
|
// The reason must be the scheme — NOT the catch-all "not a
|
|
// parseable URL" mislabel (it parsed just fine).
|
|
expect(
|
|
errs.some(
|
|
(m) =>
|
|
m.includes("DOCS_HOST") &&
|
|
m.includes("scheme") &&
|
|
!m.includes("not a parseable URL"),
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("rejects a credentialed DOCS_HOST (userinfo) with the loud fallback", async () => {
|
|
// A credentialed DOCS_HOST lands userinfo in every 308 Location —
|
|
// browsers strip or silently block credentialed redirect
|
|
// destinations, so docs redirects break with zero signal. Same
|
|
// rejection validateBaseUrl has.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
for (const bad of [
|
|
"https://user:pass@docs.example.com",
|
|
// Scheme-less form: the https:// prepend manufactures the
|
|
// userinfo (`user:pass@docs…` → `https://user:pass@docs…`).
|
|
"user:pass@docs.example.com",
|
|
]) {
|
|
process.env.DOCS_HOST = bad;
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.docsHost, `value ${JSON.stringify(bad)}`).toBe(
|
|
"https://docs.showcase.copilotkit.ai",
|
|
);
|
|
expect(
|
|
errs.some((m) => m.includes("DOCS_HOST") && m.includes("userinfo")),
|
|
`value ${JSON.stringify(bad)} should log a userinfo rejection`,
|
|
).toBe(true);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("labels the degenerate-host rejection as such, not as a parse failure", async () => {
|
|
// `https://` DID parse (hostname "https") — the operator-facing log
|
|
// must not send them hunting for a syntax error that isn't there.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
process.env.DOCS_HOST = "https://";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
freshGet();
|
|
expect(
|
|
errs.some(
|
|
(m) => m.includes("DOCS_HOST") && !m.includes("not a parseable URL"),
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("dev-mode SET-but-broken DOCS_HOST falls back with a dev warn, not FATAL-CONFIG / Railway guidance", async () => {
|
|
// Same dev-vs-prod branch validateBaseUrl has: the FATAL-CONFIG
|
|
// error and Railway guidance are useless on a laptop — the dev
|
|
// contract is frictionless iteration with a warn.
|
|
(process.env as Record<string, string>).NODE_ENV = "development";
|
|
process.env.BASE_URL = "http://localhost:3000";
|
|
process.env.DOCS_HOST = "https://";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const warns: string[] = [];
|
|
const errs: string[] = [];
|
|
const warnSpy = vi
|
|
.spyOn(console, "warn")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void warns.push(args.map(String).join(" ")),
|
|
);
|
|
const errSpy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
// Same fallback VALUE as prod — only the log level/text branches.
|
|
expect(cfg.docsHost).toBe("https://docs.showcase.copilotkit.ai");
|
|
expect(warns.some((m) => m.includes("DOCS_HOST"))).toBe(true);
|
|
// No FATAL noise and no Railway guidance in dev.
|
|
expect(errs.some((m) => m.includes("DOCS_HOST"))).toBe(false);
|
|
expect(
|
|
warns.some((m) => m.includes("DOCS_HOST") && m.includes("Railway")),
|
|
).toBe(false);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
errSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("falls back to the default docs host (one loud log) when DOCS_HOST points at the shell's own host", async () => {
|
|
// The redirect table has self-referential entries (/faq → /faq etc.)
|
|
// that terminate ONLY because the destination host differs. An
|
|
// operator pointing DOCS_HOST at the shell's own host turns ~15
|
|
// paths into ERR_TOO_MANY_REDIRECTS loops.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://shell.example.com";
|
|
process.env.DOCS_HOST = "https://shell.example.com";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.docsHost).toBe("https://docs.showcase.copilotkit.ai");
|
|
expect(errs.some((m) => m.includes("DOCS_HOST"))).toBe(true);
|
|
// Once-guarded — not per request.
|
|
const before = errs.length;
|
|
freshGet();
|
|
expect(errs.length).toBe(before);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("disables docs redirects (sentinel) when the shell is deployed AT the default docs host with DOCS_HOST unset", async () => {
|
|
// The unconditional DEFAULT_DOCS_HOST fallback can carry the same
|
|
// defect it's escaping: shell deployed at the docs host with
|
|
// DOCS_HOST unset used to log a self-contradictory FATAL ("falling
|
|
// back to <the same looping value>") AND return the looping value.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://docs.showcase.copilotkit.ai";
|
|
// DOCS_HOST deliberately unset.
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet, DOCS_REDIRECTS_DISABLED_HOST } =
|
|
await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.docsHost).toBe(DOCS_REDIRECTS_DISABLED_HOST);
|
|
// The sentinel must be parseable (incidental new URL consumers)
|
|
// and distinct from the looping default.
|
|
expect(() => new URL(cfg.docsHost)).not.toThrow();
|
|
expect(cfg.docsHost).not.toBe("https://docs.showcase.copilotkit.ai");
|
|
// Branched message: names the UNSET case and says redirects are
|
|
// disabled — never the self-contradictory "falling back to" the
|
|
// very value that loops.
|
|
const fatal = errs.find((m) => m.includes("DOCS_HOST"));
|
|
expect(fatal).toBeDefined();
|
|
expect(fatal).toContain("unset");
|
|
expect(fatal).toContain("disabled");
|
|
expect(fatal).not.toContain(
|
|
"falling back to https://docs.showcase.copilotkit.ai",
|
|
);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("disables docs redirects (sentinel) when DOCS_HOST is rejected AND the default fallback also collides", async () => {
|
|
// Shell deployed at the docs host with DOCS_HOST explicitly SET to
|
|
// that same host: the configured value is rejected (self-host) and
|
|
// the default fallback has the identical defect — return the
|
|
// disabled sentinel, not the looping default.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://docs.showcase.copilotkit.ai";
|
|
process.env.DOCS_HOST = "https://docs.showcase.copilotkit.ai";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet, DOCS_REDIRECTS_DISABLED_HOST } =
|
|
await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.docsHost).toBe(DOCS_REDIRECTS_DISABLED_HOST);
|
|
const fatal = errs.find((m) => m.includes("DOCS_HOST"));
|
|
expect(fatal).toBeDefined();
|
|
expect(fatal).toContain("disabled");
|
|
expect(fatal).not.toContain(
|
|
"falling back to https://docs.showcase.copilotkit.ai",
|
|
);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("re-logs the DOCS_HOST fallback when the shell host or mode changes the outcome (guard key covers both)", async () => {
|
|
// The fallback log's MESSAGE and OUTCOME (default vs disabled
|
|
// sentinel) depend on shellHost — a live BASE_URL re-read — and its
|
|
// LEVEL depends on the mode. A once-guard keyed on the raw value
|
|
// alone swallows a changed outcome: the deploy silently flips to
|
|
// redirects-disabled with zero log.
|
|
process.env.DOCS_HOST = "https://";
|
|
(process.env as Record<string, string>).NODE_ENV = "development";
|
|
process.env.BASE_URL = "https://shell.example.com";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet, DOCS_REDIRECTS_DISABLED_HOST } =
|
|
await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const warns: string[] = [];
|
|
const errSpy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
const warnSpy = vi
|
|
.spyOn(console, "warn")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void warns.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
// Dev: warn-level fallback consumed the raw-value guard.
|
|
expect(freshGet().docsHost).toBe("https://docs.showcase.copilotkit.ai");
|
|
expect(warns.some((m) => m.includes("DOCS_HOST"))).toBe(true);
|
|
// Same raw value, PROD mode: the FATAL must still fire.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
expect(freshGet().docsHost).toBe("https://docs.showcase.copilotkit.ai");
|
|
expect(
|
|
errs.some((m) => m.includes("FATAL-CONFIG") && m.includes("DOCS_HOST")),
|
|
"prod FATAL must not be swallowed by the dev warn's guard entry",
|
|
).toBe(true);
|
|
// Same raw value, same mode, but BASE_URL moved to the default
|
|
// docs host: the OUTCOME flips to the disabled sentinel — that
|
|
// must be logged, not silently returned.
|
|
process.env.BASE_URL = "https://docs.showcase.copilotkit.ai";
|
|
const before = errs.length;
|
|
expect(freshGet().docsHost).toBe(DOCS_REDIRECTS_DISABLED_HOST);
|
|
expect(
|
|
errs.slice(before).some((m) => m.includes("disabled")),
|
|
"outcome change (fallback → disabled sentinel) must re-log",
|
|
).toBe(true);
|
|
// And the guard still holds per (mode, shellHost, value): an
|
|
// identical repeat call logs nothing new.
|
|
const after = errs.length;
|
|
freshGet();
|
|
expect(errs.length).toBe(after);
|
|
} finally {
|
|
errSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("still falls back to the default docs host when only the CONFIGURED value collides", async () => {
|
|
// The fallback re-check must not over-trigger: a shell at its own
|
|
// (non-docs) host with a colliding DOCS_HOST keeps the working
|
|
// default — redirects stay enabled.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://shell.example.com";
|
|
process.env.DOCS_HOST = "https://shell.example.com";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
try {
|
|
expect(freshGet().docsHost).toBe("https://docs.showcase.copilotkit.ai");
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("trips the self-host loop guard for a trailing-dot FQDN spelling (both compare sides)", async () => {
|
|
// `shell.example.com.` is the same authority as `shell.example.com`
|
|
// to DNS and browsers — the redirect table's self-referential paths
|
|
// loop just the same, but a literal host compare let the dotted
|
|
// spelling evade the guard.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://shell.example.com";
|
|
process.env.DOCS_HOST = "https://shell.example.com.";
|
|
vi.resetModules();
|
|
let { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
let spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
expect(freshGet().docsHost).toBe("https://docs.showcase.copilotkit.ai");
|
|
expect(
|
|
errs.some((m) => m.includes("DOCS_HOST") && m.includes("own host")),
|
|
).toBe(true);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
|
|
// Dotted spelling on the SHELL side: the fallback collision
|
|
// re-check must normalize too, or a shell deployed at
|
|
// `docs.showcase.copilotkit.ai.` hands out the looping default.
|
|
process.env.BASE_URL = "https://docs.showcase.copilotkit.ai.";
|
|
delete process.env.DOCS_HOST;
|
|
vi.resetModules();
|
|
const fresh2 = await import("./runtime-config");
|
|
freshGet = fresh2.getRuntimeConfig;
|
|
spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
try {
|
|
expect(freshGet().docsHost).toBe(fresh2.DOCS_REDIRECTS_DISABLED_HOST);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("does NOT trip the loop guard for same-hostname different-port docs (local dev)", () => {
|
|
// The loop predicate is the full authority (host:port), not the
|
|
// hostname: localhost:3000 (shell) → localhost:3005 (docs) is the
|
|
// documented dev wiring and terminates fine.
|
|
(process.env as Record<string, string>).NODE_ENV = "development";
|
|
process.env.BASE_URL = "http://localhost:3000";
|
|
process.env.DOCS_HOST = "http://localhost:3005";
|
|
expect(getRuntimeConfig().docsHost).toBe("http://localhost:3005");
|
|
});
|
|
|
|
it("returns the parsed-normalized form (host case, default port) for BASE_URL/DOCS_HOST/POSTHOG_HOST", () => {
|
|
// Validation already PARSES every value — returning the raw
|
|
// candidate string instead of the parsed form leaked un-normalized
|
|
// values (uppercase hosts, explicit default ports) to consumers,
|
|
// while the internal comparisons (e.g. the docs self-host guard)
|
|
// operate on parsed forms: the same authority could compare unequal
|
|
// to itself depending on which side was spelled canonically.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://Shell.Example.COM:443";
|
|
process.env.DOCS_HOST = "https://Docs.Example.COM:443";
|
|
process.env.POSTHOG_HOST = "https://Proxy.Example.COM:443/ingest";
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.baseUrl).toBe("https://shell.example.com");
|
|
expect(cfg.docsHost).toBe("https://docs.example.com");
|
|
// Path preserved (reverse-proxy ingest), host normalized.
|
|
expect(cfg.posthogHost).toBe("https://proxy.example.com/ingest");
|
|
});
|
|
|
|
it("strips trailing slashes", () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.example.com/";
|
|
process.env.POSTHOG_HOST = "https://eu.i.posthog.com//";
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.baseUrl).toBe("https://showcase.example.com");
|
|
expect(cfg.posthogHost).toBe("https://eu.i.posthog.com");
|
|
});
|
|
|
|
it("falls back to dev defaults when unset in non-production", () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "development";
|
|
// Spy to keep the dev-fallback warning out of the test output.
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.baseUrl).toBe("http://localhost:3000");
|
|
expect(cfg.posthogHost).toBe("https://eu.i.posthog.com");
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("falls back to sentinel and console.errors in production (BASE_URL only)", async () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
// The FATAL-CONFIG log is once-guarded module state — fresh module
|
|
// instance so the assertion survives test reordering / --retry.
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
// try/finally so a throwing call can't leak the spy into other
|
|
// tests (matches every other spy in this file).
|
|
let cfg: ReturnType<typeof freshGet>;
|
|
try {
|
|
cfg = freshGet();
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
expect(cfg.baseUrl).toBe("https://shell-base-url-missing.invalid");
|
|
// The sentinel must be a hierarchical URL: the previous
|
|
// `about:blank#...` form was opaque-path, and `new URL(path, base)`
|
|
// throws on opaque bases — the sentinel itself 500'd composing
|
|
// consumers.
|
|
expect(() => new URL("/some/path", cfg.baseUrl)).not.toThrow();
|
|
// POSTHOG_HOST falls back silently (analytics key — legitimately absent in some envs).
|
|
expect(cfg.posthogHost).toBe("https://eu.i.posthog.com");
|
|
expect(errs.some((m) => m.includes("BASE_URL"))).toBe(true);
|
|
expect(errs.some((m) => m.includes("POSTHOG_HOST"))).toBe(false);
|
|
});
|
|
|
|
it("FATAL-CONFIG for unset BASE_URL logs once per process, not per request", async () => {
|
|
// Middleware + the root layout call getRuntimeConfig() per request;
|
|
// without the once-guard an unset prod BASE_URL spams console.error
|
|
// on EVERY request. Fresh module instance so the guard state is
|
|
// deterministic regardless of test order / retries.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const errs: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void errs.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
freshGet();
|
|
expect(errs.filter((m) => m.includes("BASE_URL")).length).toBe(1);
|
|
freshGet();
|
|
freshGet();
|
|
expect(errs.filter((m) => m.includes("BASE_URL")).length).toBe(1);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("dev fallback warning for unset BASE_URL logs once per process, not per call", async () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "development";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const warns: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "warn")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void warns.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
freshGet();
|
|
expect(warns.filter((m) => m.includes("BASE_URL")).length).toBe(1);
|
|
freshGet();
|
|
expect(warns.filter((m) => m.includes("BASE_URL")).length).toBe(1);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("reads live process.env on each call (no module-load freeze)", () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://first.example.com";
|
|
process.env.POSTHOG_HOST = "https://eu.i.posthog.com";
|
|
expect(getRuntimeConfig().baseUrl).toBe("https://first.example.com");
|
|
process.env.BASE_URL = "https://second.example.com";
|
|
expect(getRuntimeConfig().baseUrl).toBe("https://second.example.com");
|
|
});
|
|
|
|
it("accepts NEXT_PUBLIC_BASE_URL as a fallback when BASE_URL is unset", () => {
|
|
// Deploy-config contract: the shell reads the bare name first,
|
|
// but tolerates the NEXT_PUBLIC_-prefixed variant so a Railway
|
|
// service that follows the shell-docs convention still wires
|
|
// through. See the readUrl fallback chain in runtime-config.ts.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.NEXT_PUBLIC_BASE_URL = "https://alt.example.com";
|
|
// BASE_URL deliberately unset.
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.baseUrl).toBe("https://alt.example.com");
|
|
});
|
|
|
|
it("BASE_URL takes precedence over NEXT_PUBLIC_BASE_URL when both set", () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://primary.example.com";
|
|
process.env.NEXT_PUBLIC_BASE_URL = "https://alt.example.com";
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.baseUrl).toBe("https://primary.example.com");
|
|
});
|
|
|
|
it("trims whitespace paste artifacts from env values", () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = " https://showcase.copilotkit.ai \n";
|
|
process.env.POSTHOG_HOST = " https://eu.i.posthog.com ";
|
|
process.env.DOCS_HOST = "\thttps://docs-staging.example.com ";
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.baseUrl).toBe("https://showcase.copilotkit.ai");
|
|
expect(cfg.posthogHost).toBe("https://eu.i.posthog.com");
|
|
expect(cfg.docsHost).toBe("https://docs-staging.example.com");
|
|
});
|
|
|
|
it("whitespace-only primary counts as unset (falls through to alternate)", () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = " ";
|
|
process.env.NEXT_PUBLIC_BASE_URL = "https://alt.example.com";
|
|
expect(getRuntimeConfig().baseUrl).toBe("https://alt.example.com");
|
|
});
|
|
|
|
it("empty-string primary does not mask a set alternate (length-aware fallback)", () => {
|
|
// A deliberately-empty BASE_URL must NOT win over a populated
|
|
// NEXT_PUBLIC_BASE_URL. The prior `??` form treated `""` as
|
|
// "set", masking the alternate; the length-aware form falls
|
|
// through to the alternate when the primary is empty.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "";
|
|
process.env.NEXT_PUBLIC_BASE_URL = "https://alt.example.com";
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.baseUrl).toBe("https://alt.example.com");
|
|
});
|
|
|
|
it("prepends https:// when POSTHOG_HOST lacks a scheme (host-only misconfig)", () => {
|
|
// Middleware capture fetches build URLs from posthogHost and swallow
|
|
// failures by design — a scheme-less value would make EVERY capture
|
|
// throw, forever and silently. Same hardening as DOCS_HOST.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
process.env.POSTHOG_HOST = "eu.i.posthog.com";
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.posthogHost).toBe("https://eu.i.posthog.com");
|
|
expect(() => new URL(cfg.posthogHost)).not.toThrow();
|
|
});
|
|
|
|
it("preserves an explicit scheme on POSTHOG_HOST", () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "development";
|
|
process.env.BASE_URL = "http://localhost:3000";
|
|
process.env.POSTHOG_HOST = "http://localhost:8010";
|
|
expect(getRuntimeConfig().posthogHost).toBe("http://localhost:8010");
|
|
});
|
|
|
|
it("accepts NEXT_PUBLIC_POSTHOG_HOST as a fallback when POSTHOG_HOST is unset", () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.NEXT_PUBLIC_POSTHOG_HOST = "https://alt-ph.example.com";
|
|
process.env.BASE_URL = "https://shell.example.com";
|
|
const cfg = getRuntimeConfig();
|
|
expect(cfg.posthogHost).toBe("https://alt-ph.example.com");
|
|
});
|
|
|
|
it("rejects a degenerate POSTHOG_HOST of just a scheme with one warn + default fallback", async () => {
|
|
// `https://` → readKey strips to `https:` → ensureScheme yields
|
|
// `https://https:` which PARSES (hostname "https") and would point
|
|
// every capture fetch at an unresolvable host — all analytics die
|
|
// via DNS, silently. Same rejection readDocsHost has.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
process.env.POSTHOG_HOST = "https://";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const warns: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "warn")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void warns.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.posthogHost).toBe("https://eu.i.posthog.com");
|
|
expect(warns.some((m) => m.includes("POSTHOG_HOST"))).toBe(true);
|
|
// Once-guarded — not per request.
|
|
const before = warns.length;
|
|
freshGet();
|
|
expect(warns.length).toBe(before);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("rejects a non-http(s) POSTHOG_HOST scheme with the default fallback", async () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
process.env.POSTHOG_HOST = "ftp://ph.example.com";
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const warns: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "warn")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void warns.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
expect(freshGet().posthogHost).toBe("https://eu.i.posthog.com");
|
|
expect(warns.some((m) => m.includes("POSTHOG_HOST"))).toBe(true);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("labels POSTHOG_HOST rejections accurately (scheme vs degenerate vs parse failure)", async () => {
|
|
// `ftp://ph.example.com` PARSED fine and no prepend happened — the
|
|
// previous catch-all warn claimed the value "is not a usable http(s)
|
|
// URL (even after prepending https://)": both clauses false. Same
|
|
// for the degenerate bare-scheme value (it parses too). Each
|
|
// rejection class must name its actual reason — the same branched
|
|
// labeling readDocsHost has.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
for (const [bad, mustInclude, parsedFine] of [
|
|
["ftp://ph.example.com", "scheme", true],
|
|
["https://", "no usable host", true],
|
|
["not a posthog host", "not a parseable URL", false],
|
|
] as const) {
|
|
process.env.POSTHOG_HOST = bad;
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const warns: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "warn")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void warns.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
expect(freshGet().posthogHost, `value ${JSON.stringify(bad)}`).toBe(
|
|
"https://eu.i.posthog.com",
|
|
);
|
|
const warn = warns.find((m) => m.includes("POSTHOG_HOST"));
|
|
expect(warn, `value ${JSON.stringify(bad)} should warn`).toBeDefined();
|
|
expect(warn, `value ${JSON.stringify(bad)}`).toContain(mustInclude);
|
|
if (parsedFine) {
|
|
// The value parsed and (for ftp://) no prepend happened — the
|
|
// parse-failure clauses must not appear.
|
|
expect(warn).not.toContain("not a parseable URL");
|
|
expect(warn).not.toContain("even after prepending");
|
|
}
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("rejects a credentialed POSTHOG_HOST (userinfo) with one warn + default fallback", async () => {
|
|
// The Fetch spec forbids credentialed request URLs — a userinfo-
|
|
// bearing POSTHOG_HOST makes every capture fetch throw a TypeError
|
|
// that middleware misattributes as a net-class failure. Same
|
|
// rejection validateBaseUrl/readDocsHost have.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
for (const bad of [
|
|
"https://user:pass@ph.example.com",
|
|
// Scheme-less form: the https:// prepend manufactures the userinfo.
|
|
"user:pass@ph.example.com",
|
|
]) {
|
|
process.env.POSTHOG_HOST = bad;
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const warns: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "warn")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void warns.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
const cfg = freshGet();
|
|
expect(cfg.posthogHost, `value ${JSON.stringify(bad)}`).toBe(
|
|
"https://eu.i.posthog.com",
|
|
);
|
|
expect(
|
|
warns.some(
|
|
(m) => m.includes("POSTHOG_HOST") && m.includes("userinfo"),
|
|
),
|
|
`value ${JSON.stringify(bad)} should warn a userinfo rejection`,
|
|
).toBe(true);
|
|
// Once-guarded — not per request.
|
|
const before = warns.length;
|
|
freshGet();
|
|
expect(warns.length).toBe(before);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("preserves a path-bearing POSTHOG_HOST (reverse-proxy ingest paths are legitimate)", () => {
|
|
// Unlike DOCS_HOST, a path here is NOT a misconfig: path-based
|
|
// PostHog reverse proxies (e.g. /ingest) are a documented pattern.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
process.env.POSTHOG_HOST = "https://proxy.example.com/ingest";
|
|
expect(getRuntimeConfig().posthogHost).toBe(
|
|
"https://proxy.example.com/ingest",
|
|
);
|
|
});
|
|
|
|
it("strips a query/fragment from POSTHOG_HOST (keeping the path) with one warn", async () => {
|
|
// Capture URLs are composed against this value — a `?x=1` or
|
|
// `#frag` corrupts every capture into a persistent root-POST with
|
|
// misattributed http-class warns. The PATH is kept: path-based
|
|
// reverse proxies (/ingest) are the documented pattern.
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
for (const [bad, expected] of [
|
|
[
|
|
"https://proxy.example.com/ingest?x=1",
|
|
"https://proxy.example.com/ingest",
|
|
],
|
|
[
|
|
"https://proxy.example.com/ingest#frag",
|
|
"https://proxy.example.com/ingest",
|
|
],
|
|
["https://eu.i.posthog.com?x=1", "https://eu.i.posthog.com"],
|
|
] as const) {
|
|
process.env.POSTHOG_HOST = bad;
|
|
vi.resetModules();
|
|
const { getRuntimeConfig: freshGet } = await import("./runtime-config");
|
|
const warns: string[] = [];
|
|
const spy = vi
|
|
.spyOn(console, "warn")
|
|
.mockImplementation(
|
|
(...args: unknown[]) => void warns.push(args.map(String).join(" ")),
|
|
);
|
|
try {
|
|
expect(freshGet().posthogHost, `value ${JSON.stringify(bad)}`).toBe(
|
|
expected,
|
|
);
|
|
expect(warns.some((m) => m.includes("POSTHOG_HOST"))).toBe(true);
|
|
// Once-guarded — not per request.
|
|
const before = warns.length;
|
|
freshGet();
|
|
expect(warns.length).toBe(before);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("exposes posthogKey with readEnvPair semantics (trim + NEXT_PUBLIC_ fallback)", () => {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://showcase.copilotkit.ai";
|
|
|
|
// Unset → undefined (legitimately absent off-prod; no log).
|
|
expect(getRuntimeConfig().posthogKey).toBeUndefined();
|
|
|
|
// Whitespace paste artifacts trimmed.
|
|
process.env.POSTHOG_KEY = " phc_primary \n";
|
|
expect(getRuntimeConfig().posthogKey).toBe("phc_primary");
|
|
|
|
// NEXT_PUBLIC_ fallback when the bare name is unset/empty.
|
|
process.env.POSTHOG_KEY = "";
|
|
process.env.NEXT_PUBLIC_POSTHOG_KEY = "phc_alt";
|
|
expect(getRuntimeConfig().posthogKey).toBe("phc_alt");
|
|
|
|
// Bare name wins when both are set.
|
|
process.env.POSTHOG_KEY = "phc_primary";
|
|
expect(getRuntimeConfig().posthogKey).toBe("phc_primary");
|
|
});
|
|
|
|
it("getRuntimeConfigForMiddleware skips noStore() (Edge runtime path)", async () => {
|
|
// Fresh module pair (SU6-B7): earlier tests in this file call
|
|
// vi.resetModules(), so the STATIC getRuntimeConfig import is bound
|
|
// to a stale module instance — whether its next/cache binding is
|
|
// the object spied on below depends on vitest mock-registry
|
|
// caching internals. Reset and import BOTH modules from the same
|
|
// fresh registry generation so the spy provably wraps the exact
|
|
// unstable_noStore these calls go through.
|
|
vi.resetModules();
|
|
const cacheMod = await import("next/cache");
|
|
const noStoreSpy = vi.spyOn(cacheMod, "unstable_noStore");
|
|
// try/finally so a failing assertion can't leak the spy into other
|
|
// tests — mockRestore after the assertions alone never runs on failure.
|
|
try {
|
|
(process.env as Record<string, string>).NODE_ENV = "production";
|
|
process.env.BASE_URL = "https://edge.example.com";
|
|
process.env.POSTHOG_HOST = "https://edge-posthog.example.com";
|
|
|
|
const { getRuntimeConfigForMiddleware, getRuntimeConfig: freshGet } =
|
|
await import("./runtime-config");
|
|
const cfg = getRuntimeConfigForMiddleware();
|
|
expect(cfg.baseUrl).toBe("https://edge.example.com");
|
|
expect(noStoreSpy).not.toHaveBeenCalled();
|
|
|
|
// And confirm the default entrypoint DOES call noStore() — via
|
|
// the SAME fresh module instance, not the stale static import.
|
|
noStoreSpy.mockClear();
|
|
freshGet();
|
|
expect(noStoreSpy).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
noStoreSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|