mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
fix(dashboard): Playground Compare tab loading + HTTP method guard (#4024)
randomUUID non-HTTPS fallback + static CompareTab import; raw HTTP TRACE->405 method guard wired into dev + standalone servers. Integrated into release/v3.8.27.
This commit is contained in:
@@ -1702,6 +1702,8 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ApiKey"
|
||||
"401":
|
||||
description: Authentication required
|
||||
post:
|
||||
tags: [API Keys]
|
||||
summary: Create API key
|
||||
@@ -1718,8 +1720,43 @@ paths:
|
||||
responses:
|
||||
"201":
|
||||
description: Created API key (includes full key value)
|
||||
"401":
|
||||
description: Authentication required
|
||||
|
||||
/api/keys/{id}:
|
||||
get:
|
||||
tags: [API Keys]
|
||||
summary: Get API key
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ResourceId"
|
||||
responses:
|
||||
"200":
|
||||
description: API key metadata
|
||||
"401":
|
||||
description: Authentication required
|
||||
"404":
|
||||
description: Key not found
|
||||
patch:
|
||||
tags: [API Keys]
|
||||
summary: Update API key
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ResourceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
responses:
|
||||
"200":
|
||||
description: API key settings updated
|
||||
"400":
|
||||
description: Invalid update request
|
||||
"401":
|
||||
description: Authentication required
|
||||
"404":
|
||||
description: Key not found
|
||||
delete:
|
||||
tags: [API Keys]
|
||||
summary: Delete API key
|
||||
@@ -1728,6 +1765,10 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: Key deleted
|
||||
"401":
|
||||
description: Authentication required
|
||||
"404":
|
||||
description: Key not found
|
||||
|
||||
/api/combos:
|
||||
get:
|
||||
@@ -3444,11 +3485,18 @@ paths:
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
responses:
|
||||
"200":
|
||||
description: JWT token returned
|
||||
"400":
|
||||
description: Invalid login request
|
||||
"401":
|
||||
description: Invalid password
|
||||
"403":
|
||||
description: Password setup required
|
||||
"429":
|
||||
description: Too many failed attempts
|
||||
|
||||
/api/auth/logout:
|
||||
post:
|
||||
|
||||
@@ -125,6 +125,11 @@ const EXTRA_MODULE_ENTRIES = [
|
||||
src: ["scripts", "dev", "peer-stamp.mjs"],
|
||||
dest: ["peer-stamp.mjs"],
|
||||
},
|
||||
{
|
||||
label: "HTTP method guard (server-ws.mjs dependency)",
|
||||
src: ["scripts", "dev", "http-method-guard.cjs"],
|
||||
dest: ["http-method-guard.cjs"],
|
||||
},
|
||||
{
|
||||
label: "responses-ws-proxy (server-ws.mjs dependency)",
|
||||
src: ["scripts", "dev", "responses-ws-proxy.mjs"],
|
||||
|
||||
@@ -35,6 +35,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
|
||||
".env.example",
|
||||
"BUILD_SHA",
|
||||
"docs/reference/openapi.yaml",
|
||||
"http-method-guard.cjs",
|
||||
"open-sse/mcp-server/server.js",
|
||||
// LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads
|
||||
// (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server.
|
||||
@@ -124,6 +125,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
|
||||
"dist/server-ws.mjs",
|
||||
"dist/responses-ws-proxy.mjs",
|
||||
"dist/peer-stamp.mjs",
|
||||
"dist/http-method-guard.cjs",
|
||||
"dist/webdav-handler.mjs",
|
||||
"bin/cli/program.mjs",
|
||||
"bin/mcp-server.mjs",
|
||||
|
||||
@@ -40,6 +40,7 @@ const ROOT = join(__dirname, "..", "..");
|
||||
const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
|
||||
const DIST_DIR = join(ROOT, "dist");
|
||||
const METHOD_GUARD_REQUIRE = 'require("./http-method-guard.cjs").installHttpMethodGuard();\n';
|
||||
|
||||
function walkFiles(dir: string, rootDir: string = dir, files: string[] = []): string[] {
|
||||
let entries: string[] = [];
|
||||
@@ -153,6 +154,20 @@ assembleStandalone({
|
||||
});
|
||||
console.log(" ✅ Standalone bundle assembled to dist/");
|
||||
|
||||
const distServer = join(DIST_DIR, "server.js");
|
||||
const methodGuardSrc = join(ROOT, "scripts", "dev", "http-method-guard.cjs");
|
||||
const methodGuardDest = join(DIST_DIR, "http-method-guard.cjs");
|
||||
if (existsSync(methodGuardSrc)) {
|
||||
cpSync(methodGuardSrc, methodGuardDest);
|
||||
}
|
||||
if (existsSync(distServer)) {
|
||||
const serverSource = readFileSync(distServer, "utf8");
|
||||
if (!serverSource.includes("installHttpMethodGuard")) {
|
||||
writeFileSync(distServer, METHOD_GUARD_REQUIRE + serverSource);
|
||||
console.log(" ✅ Patched dist/server.js with HTTP method guard.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 8: Compile + copy MITM cert utilities ─────────────
|
||||
const mitmSrc = join(ROOT, "src", "mitm");
|
||||
const mitmDest = join(DIST_DIR, "src", "mitm");
|
||||
|
||||
82
scripts/dev/http-method-guard.cjs
Normal file
82
scripts/dev/http-method-guard.cjs
Normal file
@@ -0,0 +1,82 @@
|
||||
"use strict";
|
||||
|
||||
const http = require("node:http");
|
||||
|
||||
const HIGH_RISK_METHOD_RULES = [
|
||||
[/^\/api\/auth\/login\/?$/, ["POST"]],
|
||||
[/^\/api\/auth\/logout\/?$/, ["POST"]],
|
||||
[/^\/api\/keys\/?$/, ["GET", "POST"]],
|
||||
[/^\/api\/keys\/[^/]+\/?$/, ["GET", "PATCH", "DELETE"]],
|
||||
];
|
||||
|
||||
let installed = false;
|
||||
|
||||
function getPathname(req) {
|
||||
const rawUrl = typeof req?.url === "string" && req.url ? req.url : "/";
|
||||
try {
|
||||
return new URL(rawUrl, "http://localhost").pathname;
|
||||
} catch {
|
||||
return rawUrl.split("?")[0] || "/";
|
||||
}
|
||||
}
|
||||
|
||||
function getAllowedMethods(pathname) {
|
||||
for (const [pattern, methods] of HIGH_RISK_METHOD_RULES) {
|
||||
if (pattern.test(pathname)) return methods;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAllowHeader(pathname) {
|
||||
const methods = getAllowedMethods(pathname);
|
||||
return methods ? methods.join(", ") : null;
|
||||
}
|
||||
|
||||
function maybeHandleDisallowedMethod(req, res) {
|
||||
const method = typeof req?.method === "string" ? req.method.toUpperCase() : "";
|
||||
const pathname = getPathname(req);
|
||||
const methods = getAllowedMethods(pathname);
|
||||
if (!methods || method === "OPTIONS" || methods.includes(method)) return false;
|
||||
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", methods.join(", "));
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
message: `${method || "Method"} is not allowed`,
|
||||
},
|
||||
})
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function wrapRequestListenerWithMethodGuard(listener) {
|
||||
return function methodGuardRequestHandler(req, res) {
|
||||
if (maybeHandleDisallowedMethod(req, res)) return;
|
||||
return listener.call(this, req, res);
|
||||
};
|
||||
}
|
||||
|
||||
function installHttpMethodGuard() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
const originalCreateServer = http.createServer.bind(http);
|
||||
http.createServer = function createServerWithMethodGuard(...args) {
|
||||
const lastFnIdx = args.map((arg) => typeof arg === "function").lastIndexOf(true);
|
||||
if (lastFnIdx >= 0) {
|
||||
args[lastFnIdx] = wrapRequestListenerWithMethodGuard(args[lastFnIdx]);
|
||||
}
|
||||
return originalCreateServer(...args);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAllowHeader,
|
||||
maybeHandleDisallowedMethod,
|
||||
wrapRequestListenerWithMethodGuard,
|
||||
installHttpMethodGuard,
|
||||
};
|
||||
@@ -9,9 +9,12 @@ import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mj
|
||||
import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs";
|
||||
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
|
||||
import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs";
|
||||
import methodGuard from "./http-method-guard.cjs";
|
||||
import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const { maybeHandleDisallowedMethod } = methodGuard;
|
||||
|
||||
// Pre-read DATA_DIR from local .env before bootstrap resolves paths
|
||||
if (!process.env.DATA_DIR) {
|
||||
try {
|
||||
@@ -83,6 +86,7 @@ async function start() {
|
||||
});
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (maybeHandleDisallowedMethod(req, res)) return;
|
||||
// Stamp the real TCP peer IP before Next sees the request, so the authz
|
||||
// middleware can decide LOCAL_ONLY locality without trusting the Host header.
|
||||
stampPeerIp(req);
|
||||
|
||||
@@ -3,9 +3,11 @@ import { randomUUID } from "node:crypto";
|
||||
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
|
||||
import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs";
|
||||
import { maybeHandleWebdav } from "./webdav-handler.mjs";
|
||||
import methodGuard from "./http-method-guard.cjs";
|
||||
|
||||
const originalCreateServer = http.createServer.bind(http);
|
||||
const proxiesByPort = new Map();
|
||||
const { wrapRequestListenerWithMethodGuard } = methodGuard;
|
||||
|
||||
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
|
||||
// Per-process secret proving the trusted peer-IP stamp came from this server.
|
||||
@@ -71,9 +73,9 @@ http.createServer = function createServerWithResponsesWs(...args) {
|
||||
// createServer; wrap it so the real TCP peer IP is stamped before Next runs.
|
||||
const lastFnIdx = args.map((a) => typeof a === "function").lastIndexOf(true);
|
||||
if (lastFnIdx >= 0) {
|
||||
// WebDAV intercept wraps outermost (first to run), then peer-stamp, then Next.
|
||||
args[lastFnIdx] = wrapRequestListenerWithWebdav(
|
||||
wrapRequestListenerWithPeerStamp(args[lastFnIdx])
|
||||
// Method guard runs before Next because Next 16 rejects TRACE while constructing requests.
|
||||
args[lastFnIdx] = wrapRequestListenerWithMethodGuard(
|
||||
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(args[lastFnIdx]))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,7 +91,9 @@ http.createServer = function createServerWithResponsesWs(...args) {
|
||||
if (eventName === "request" && typeof listener === "function") {
|
||||
return originalOn(
|
||||
eventName,
|
||||
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
|
||||
wrapRequestListenerWithMethodGuard(
|
||||
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
|
||||
)
|
||||
);
|
||||
}
|
||||
return originalOn(eventName, listener);
|
||||
@@ -102,7 +106,9 @@ http.createServer = function createServerWithResponsesWs(...args) {
|
||||
if (eventName === "request" && typeof listener === "function") {
|
||||
return originalAddListener(
|
||||
eventName,
|
||||
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
|
||||
wrapRequestListenerWithMethodGuard(
|
||||
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
|
||||
)
|
||||
);
|
||||
}
|
||||
return originalAddListener(eventName, listener);
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
// src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx
|
||||
|
||||
import { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type { StreamMetrics } from "@/shared/schemas/playground";
|
||||
import StudioTopBar, { type StudioTab } from "./components/StudioTopBar";
|
||||
import StudioConfigPane, { type ConfigState } from "./components/StudioConfigPane";
|
||||
import { DEFAULT_PARAMS } from "./components/ParamSliders";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { StreamMetrics } from "@/shared/schemas/playground";
|
||||
import CompareTab from "./components/tabs/CompareTab";
|
||||
|
||||
// Lazy-load tabs to reduce initial bundle size
|
||||
const ChatTab = dynamic(() => import("./components/tabs/ChatTab"), { ssr: false });
|
||||
const ApiTab = dynamic(() => import("./components/tabs/ApiTab"), { ssr: false });
|
||||
const CompareTab = dynamic(() => import("./components/tabs/CompareTab"), { ssr: false });
|
||||
const BuildTab = dynamic(() => import("./components/tabs/BuildTab"), { ssr: false });
|
||||
|
||||
const INITIAL_METRICS: StreamMetrics = {
|
||||
@@ -92,23 +92,14 @@ export function PlaygroundStudio() {
|
||||
{effectiveTab === "chat" && (
|
||||
<ChatTab configState={configState} onMetricsUpdate={handleMetricsUpdate} />
|
||||
)}
|
||||
{effectiveTab === "compare" && (
|
||||
<CompareTab configState={configState} />
|
||||
)}
|
||||
{effectiveTab === "api" && (
|
||||
<ApiTab configState={configState} />
|
||||
)}
|
||||
{effectiveTab === "build" && (
|
||||
<BuildTab configState={configState} />
|
||||
)}
|
||||
{effectiveTab === "compare" && <CompareTab configState={configState} />}
|
||||
{effectiveTab === "api" && <ApiTab configState={configState} />}
|
||||
{effectiveTab === "build" && <BuildTab configState={configState} />}
|
||||
</div>
|
||||
|
||||
{/* Config pane — always visible, collapsible */}
|
||||
{/* SLOT_PRESETS and SLOT_IMPROVE are inside StudioConfigPane */}
|
||||
<StudioConfigPane
|
||||
configState={configState}
|
||||
setConfigState={setConfigState}
|
||||
/>
|
||||
<StudioConfigPane configState={configState} setConfigState={setConfigState} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,13 @@ const INITIAL_METRICS: StreamMetrics = {
|
||||
costUsd: null,
|
||||
};
|
||||
|
||||
function createColumnId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `compare-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ColumnMetricsTracker — plain (non-React) imperative object for tracking per-column stream metrics.
|
||||
* Uses simple mutable state (no React hooks) so it can be stored in a ref and called safely
|
||||
@@ -91,7 +98,7 @@ class ColumnMetricsTracker {
|
||||
export default function CompareTab({ configState }: CompareTabProps) {
|
||||
const [columns, setColumns] = useState<ColumnState[]>(() => [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
id: createColumnId(),
|
||||
model: configState.model,
|
||||
status: "idle",
|
||||
metrics: INITIAL_METRICS,
|
||||
@@ -161,7 +168,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
function addColumn() {
|
||||
if (columns.length >= MAX_COLUMNS) return;
|
||||
const model = newModel.trim() || configState.model;
|
||||
const id = crypto.randomUUID();
|
||||
const id = createColumnId();
|
||||
setColumns((prev) => [
|
||||
...prev,
|
||||
{ id, model, status: "idle", metrics: INITIAL_METRICS, response: "" },
|
||||
@@ -266,8 +273,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const choices =
|
||||
(parsed["choices"] as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const choices = (parsed["choices"] as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const delta = choices[0]?.["delta"] as Record<string, unknown> | undefined;
|
||||
const content = delta?.["content"];
|
||||
|
||||
|
||||
96
tests/unit/dast-method-not-allowed.test.ts
Normal file
96
tests/unit/dast-method-not-allowed.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { maybeHandleDisallowedMethod } = require("../../scripts/dev/http-method-guard.cjs");
|
||||
|
||||
test("raw HTTP guard rejects high-risk unsupported methods before Next.js handles them", () => {
|
||||
const cases: Array<{
|
||||
label: string;
|
||||
method: string;
|
||||
url: string;
|
||||
allow: string;
|
||||
}> = [
|
||||
{ label: "login TRACE", method: "TRACE", url: "/api/auth/login", allow: "POST" },
|
||||
{ label: "login QUERY", method: "QUERY", url: "/api/auth/login", allow: "POST" },
|
||||
{ label: "logout QUERY", method: "QUERY", url: "/api/auth/logout", allow: "POST" },
|
||||
{ label: "keys QUERY", method: "QUERY", url: "/api/keys", allow: "GET, POST" },
|
||||
{
|
||||
label: "key detail QUERY",
|
||||
method: "QUERY",
|
||||
url: "/api/keys/0",
|
||||
allow: "GET, PATCH, DELETE",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
let body = "";
|
||||
const headers = new Map<string, string>();
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
setHeader(name: string, value: string) {
|
||||
headers.set(name.toLowerCase(), value);
|
||||
},
|
||||
end(chunk: string) {
|
||||
body += chunk;
|
||||
},
|
||||
};
|
||||
|
||||
const handled = maybeHandleDisallowedMethod(
|
||||
{ method: testCase.method, url: testCase.url },
|
||||
response
|
||||
);
|
||||
assert.equal(handled, true, testCase.label);
|
||||
assert.equal(response.statusCode, 405, testCase.label);
|
||||
assert.equal(headers.get("allow"), testCase.allow, testCase.label);
|
||||
assert.match(body, /METHOD_NOT_ALLOWED/, testCase.label);
|
||||
}
|
||||
});
|
||||
|
||||
test("raw HTTP guard allows documented methods through", () => {
|
||||
const response = {
|
||||
setHeader() {
|
||||
throw new Error("allowed methods should not write headers");
|
||||
},
|
||||
end() {
|
||||
throw new Error("allowed methods should not end the response");
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
maybeHandleDisallowedMethod({ method: "POST", url: "/api/auth/login" }, response),
|
||||
false
|
||||
);
|
||||
assert.equal(maybeHandleDisallowedMethod({ method: "GET", url: "/api/keys" }, response), false);
|
||||
assert.equal(
|
||||
maybeHandleDisallowedMethod({ method: "OPTIONS", url: "/api/keys" }, response),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
maybeHandleDisallowedMethod({ method: "QUERY", url: "/api/health/ping" }, response),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenAPI documents high-risk route auth and setup responses", () => {
|
||||
const spec = readFileSync("docs/reference/openapi.yaml", "utf8");
|
||||
const apiKeyDetailStart = spec.indexOf(" /api/keys/{id}:");
|
||||
const apiKeyDetailEnd = spec.indexOf("\n /api/combos:", apiKeyDetailStart);
|
||||
const apiKeyDetail = spec.slice(apiKeyDetailStart, apiKeyDetailEnd);
|
||||
|
||||
assert.match(apiKeyDetail, /\n get:/);
|
||||
assert.match(apiKeyDetail, /\n patch:/);
|
||||
assert.match(apiKeyDetail, /\n delete:/);
|
||||
assert.match(apiKeyDetail, /"401":\n\s+description: Authentication required/);
|
||||
assert.match(apiKeyDetail, /"404":\n\s+description: Key not found/);
|
||||
|
||||
const loginStart = spec.indexOf(" /api/auth/login:");
|
||||
const loginEnd = spec.indexOf("\n /api/auth/logout:", loginStart);
|
||||
const login = spec.slice(loginStart, loginEnd);
|
||||
assert.match(login, /"400":\n\s+description: Invalid login request/);
|
||||
assert.match(login, /"401":\n\s+description: Invalid password/);
|
||||
assert.match(login, /"403":\n\s+description: Password setup required/);
|
||||
assert.match(login, /"429":\n\s+description: Too many failed attempts/);
|
||||
});
|
||||
@@ -20,15 +20,40 @@ test("playground compare: prompt input + rAF throttle + user message in request"
|
||||
assert.ok(src.includes("requestAnimationFrame"), "throttles stream updates via rAF");
|
||||
assert.ok(/role:\s*"user"/.test(src), "request body includes a user message");
|
||||
assert.ok(src.includes("setPrompt"), "has a prompt input control");
|
||||
assert.ok(src.includes("createColumnId"), "uses a browser-compatible column id helper");
|
||||
assert.ok(!src.includes("id: crypto.randomUUID()"), "does not call randomUUID inline");
|
||||
});
|
||||
|
||||
test("playground compare: tab is not lazy-loaded behind a click-time chunk", () => {
|
||||
const src = read("src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx");
|
||||
assert.ok(
|
||||
src.includes('import CompareTab from "./components/tabs/CompareTab"'),
|
||||
"CompareTab is statically imported"
|
||||
);
|
||||
assert.ok(
|
||||
!src.includes('dynamic(() => import("./components/tabs/CompareTab")'),
|
||||
"CompareTab is not loaded with next/dynamic"
|
||||
);
|
||||
});
|
||||
|
||||
test("playground build: wizard with 3 modes reusing editors; BuildTab keeps handlers", () => {
|
||||
const wiz = read("src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx");
|
||||
assert.ok(wiz.includes('"tools"') && wiz.includes('"json"') && wiz.includes('"both"'), "three modes");
|
||||
assert.ok(wiz.includes("ToolsBuilder") && wiz.includes("StructuredOutputEditor"), "reuses both editors");
|
||||
const wiz = read(
|
||||
"src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx"
|
||||
);
|
||||
assert.ok(
|
||||
wiz.includes('"tools"') && wiz.includes('"json"') && wiz.includes('"both"'),
|
||||
"three modes"
|
||||
);
|
||||
assert.ok(
|
||||
wiz.includes("ToolsBuilder") && wiz.includes("StructuredOutputEditor"),
|
||||
"reuses both editors"
|
||||
);
|
||||
const tab = read("src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx");
|
||||
assert.ok(tab.includes("<BuildWizard"), "BuildTab mounts BuildWizard");
|
||||
assert.ok(tab.includes("runRequest") && tab.includes("sendToolResult"), "BuildTab preserves run/tool handlers");
|
||||
assert.ok(
|
||||
tab.includes("runRequest") && tab.includes("sendToolResult"),
|
||||
"BuildTab preserves run/tool handlers"
|
||||
);
|
||||
});
|
||||
|
||||
test("playground build i18n: playground.build keys present with en/pt parity", () => {
|
||||
|
||||
Reference in New Issue
Block a user