mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(quality): no-new-warnings por PR — ESLint bulk suppressions + lint-guard fork-condicional (Pacote 4) (#6218)
* feat(quality): no-new-warnings per PR via native ESLint bulk suppressions Pacote 4 do plano mestre testes+CI (aprovado 2026-07-04). O ratchet de eslintWarnings so rodava no CI pesado (release-PR) -> o drift acumulava invisivel e explodia na release (+41/+37/+88 por ciclo, rebaselinado as cegas — historico no proprio quality-baseline.json). Modelo novo (SonarSource Clean-as-You-Code + ESLint bulk suppressions nativo >=9.24): - config/quality/eslint-suppressions.json congela a divida existente por arquivo+regra: 476 arquivos / 4.273 violacoes. - npm run lint + lint-staged (pre-commit) + novo job lint-guard no quality.yml rodam suppressions-aware: violacao NOVA fica vermelha NO PR que a introduz (bulk suppressions ainda eleva estouros de baseline por arquivo a error). - 3 regras warn promovidas a error em src/** (react-hooks/exhaustive-deps, @next/next/no-img-element, import/no-anonymous-default-export) — divida existente congelada, ocorrencia nova = erro imediato. - collect-metrics mede sob o baseline congelado -> a metrica eslintWarnings vira 'divida liquida nova' (~0 em regime); baseline apertado 4279->0 no mesmo PR (exigencia do require-tighten). Aperto do ESTOQUE congelado: npx eslint . --prune-suppressions na reconciliacao da release. - Principio Zero: lint-guard usa continue-on-error para PR de FORK (report-only; a campanha /green-prs aplica o fix via co-autoria) — bloqueante so para branches internas, a origem real do drift. Validacao: negativo (any novo em tests/) exit 1; negativo (img em src/, regra promovida) exit 1; positivo escopado exit 0; baseline gerado por --suppress-all no tip (tree inteiro passa por construcao); YAML js-yaml ok. * fix(quality): clear the 6 residual warnings so lint-guard runs clean at --max-warnings 0 The committed baseline still let 6 warnings through the lint-guard gate: 5 now-unused inline eslint-disable directives (the file-level suppressions made them redundant — removed via eslint --fix, suppressions regenerated to absorb the re-exposed occurrences) and 1 anonymous default export in tests/load/k6-soak.js (outside the src/** severity-override scope — named the k6 scenario function instead). Verified on the clean tree: lint-guard exit=0; any-canary (new 'const x: any' in open-sse) exit=1 — the gate bites on NEW violations while the 4,273 frozen ones stay suppressed (476 files). * fix(ci): lint-guard continue-on-error must be boolean on non-PR events github.event.pull_request is undefined on workflow_dispatch — the bare property expression made the job fail at plan time (run 28722888456: 4 jobs green, run red, lint-guard never materialized). Guard with event_name check so the expression is always boolean: PR de fork = report-only (Principio Zero), resto = blocking.
This commit is contained in:
committed by
GitHub
parent
5a4bde1879
commit
059dbe9f13
28
.github/workflows/quality.yml
vendored
28
.github/workflows/quality.yml
vendored
@@ -146,3 +146,31 @@ jobs:
|
||||
- run: npm run test:unit:ci:shard
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/2
|
||||
|
||||
# ── Pacote 4 (plano mestre testes+CI, aprovado 2026-07-04) ─────────────────────────
|
||||
# No-new-warnings por PR via ESLint bulk suppressions nativo (>=9.24). O baseline
|
||||
# config/quality/eslint-suppressions.json congela as violações EXISTENTES por
|
||||
# arquivo+regra; qualquer warning NOVO aparece e o --max-warnings 0 falha o job — o
|
||||
# drift de +41/+88 warnings por ciclo passa a morrer no PR que o introduz, em vez de
|
||||
# ser rebaselinado às cegas na release. Aperto do baseline (na reconciliação da
|
||||
# release): npx eslint . --prune-suppressions --suppressions-location config/quality/eslint-suppressions.json
|
||||
#
|
||||
# Princípio Zero: bloqueante SÓ para branches internas (as campanhas/sessões são a
|
||||
# origem do drift). PR de FORK roda em modo report (continue-on-error → o job fica
|
||||
# verde com anotação; a campanha /green-prs aplica o fix via co-autoria — o
|
||||
# contribuidor NUNCA é bloqueado nem cobrado).
|
||||
lint-guard:
|
||||
name: No new ESLint warnings
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: ESLint (baseline congelado — warning novo = vermelho)
|
||||
run: npx eslint . --suppressions-location config/quality/eslint-suppressions.json --max-warnings 0
|
||||
|
||||
2385
config/quality/eslint-suppressions.json
Normal file
2385
config/quality/eslint-suppressions.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"_comment": "Catraca de qualidade. 'down' = nao pode aumentar; 'up' = nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (somente quando melhora). Cada valor e um numero REAL medido, nunca um chute. Cobertura entra na Fase 4 a partir de um run de cobertura mergeada no CI.",
|
||||
"metrics": {
|
||||
"eslintWarnings": {
|
||||
"value": 4279,
|
||||
"value": 0,
|
||||
"_rebaseline_2026_07_03_v3844_residual_release_green": "4270->4279 (+9). v3.8.44 residual drift on release tip 716041223 (moving target: eslint 4270->4279 as the branch advanced past the prior rebaseline). Inherited from parallel-session merges (Quality Ratchet not on PR->release fast-gates).",
|
||||
"_rebaseline_2026_07_03_v3844_ipfilter_release_green": "4256->4270 (+14). v3.8.44 cycle drift measured on release tip 32e4c906e during the #6131/#5975 release-green rebaseline. Inherited from the merge burst (Quality Ratchet does not run on PR->release fast-gates). route-edge-coverage +7 is my #5975 test comment; the rest is parallel-session drift. Tighten via --update next cycle.",
|
||||
"_rebaseline_2026_07_03_v3844_review_prs_fix_batch": "4199->4256 (+57). Inherited v3.8.44 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrue unmeasured across the cycle). 4256 = measured by `node scripts/quality/collect-metrics.mjs` on the release tip 72ee80649 during the /review-prs fix-batch round. The round's own merges (#5958 SSE-accept, #5988 deepseek-web, #6013/#5974 retry-after-json, #5975 embeddings-proxy, #5973 non-json-guard) plus the parallel-session merge burst into release/v3.8.44 account for the delta; all `any`-warn-allowed in open-sse/ + tests/. Cyclomatic is already green (2012 < baseline 2015) and needs no bump. Tighten via --require-tighten next cycle.",
|
||||
@@ -18,7 +18,8 @@
|
||||
"_rebaseline_2026_06_23_v3835_release": "v3.8.35 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR→release fast-gates, so warnings accrued across this cycle's parallel-session merges — Compression Phase 4 #4694/#4707/#4716/#4720, chatCore #3501 leaf extractions, contributor PRs #4726/#4753/#4774/#4781/#4783/#4793, etc.). 3907→3912 (+5). Verified my release-finalize working tree touches ONLY docs/*.md (THREAT_MODEL), CHANGELOG.md, baselines, and 1 string line in scripts/check/check-fabricated-docs.mjs — 0 production-code change, so all +5 is inherited contributor drift. No coverage/openapi/i18n regressions.",
|
||||
"_rebaseline_2026_06_22_v3834_release": "v3.8.34 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR→release fast-gates, so warnings accrued across this cycle's parallel-session merges — #4583-4586/#4588-4593/#4606-4621/#4644/#4647/#4696/etc.). 3900→3907 (+7). Verified my release-finalize working tree touches ONLY CHANGELOG.md (git status: 0 code changes), so all +7 is inherited contributor drift. No coverage/openapi/i18n regressions.",
|
||||
"_rebaseline_2026_06_22_v3833_release": "Cumulative cycle drift surfaced by the release PR full CI. 3867→3900 (+33).",
|
||||
"_rebaseline_2026_06_26_v3837_release": "3970->3987. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle."
|
||||
"_rebaseline_2026_06_26_v3837_release": "3970->3987. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
|
||||
"_rebaseline_2026_07_04_pacote4_no_new_warnings": "4279->0. Pacote 4 do plano mestre testes+CI: a divida pre-existente (4279 warnings + violacoes das 3 regras promovidas a error em src/**) foi CONGELADA em config/quality/eslint-suppressions.json (ESLint bulk suppressions nativo) e passa a ser bloqueada NO PR que a introduziria (job lint-guard no quality.yml + npm run lint + lint-staged, todos suppressions-aware; fork = report-only, Principio Zero). collect-metrics agora mede sob o baseline congelado -> a metrica vira 'divida liquida NOVA' (~0 em regime). O aperto do ESTOQUE congelado acontece via `npx eslint . --prune-suppressions --suppressions-location config/quality/eslint-suppressions.json` na reconciliacao da release. Fim das rebaselines-surpresa de +41/+88 por ciclo."
|
||||
},
|
||||
"eslintErrors": {
|
||||
"value": 0,
|
||||
|
||||
@@ -4,6 +4,23 @@ import tseslint from "typescript-eslint";
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
const eslintConfig = [
|
||||
...nextVitals,
|
||||
// Pacote 4 (plano mestre testes+CI, 2026-07-04) — zero-warning policy: TODA regra roda
|
||||
// como "error" e a dívida pré-existente vive congelada por arquivo+regra em
|
||||
// config/quality/eslint-suppressions.json (ESLint bulk suppressions nativo). Violação
|
||||
// NOVA = vermelho no ato (lint-staged no pre-commit + job lint-guard no fast path);
|
||||
// o drift de +41/+88 warnings/ciclo que era rebaselinado às cegas na release morre no
|
||||
// PR que o introduz. Aperto do baseline: npx eslint . --prune-suppressions
|
||||
// --suppressions-location config/quality/eslint-suppressions.json (na release).
|
||||
{
|
||||
// Escopo = onde os presets do next registram estes plugins (bloco global sem `files`
|
||||
// atingiria scripts/*.mjs sem o plugin react-hooks e explodiria o flat config).
|
||||
files: ["src/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
"@next/next/no-img-element": "error",
|
||||
"import/no-anonymous-default-export": "error",
|
||||
},
|
||||
},
|
||||
// FASE-02: Security rules (strict everywhere)
|
||||
{
|
||||
rules: {
|
||||
@@ -32,7 +49,7 @@ const eslintConfig = [
|
||||
files: ["src/app/**/*.{ts,tsx}", "src/components/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.property.name='includes'][callee.object.callee.property.name='toLowerCase']",
|
||||
@@ -49,7 +66,7 @@ const eslintConfig = [
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@next/next/no-assign-module-variable": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
},
|
||||
|
||||
@@ -65,7 +65,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
* The pattern is strictly bounded (no unbounded quantifiers over overlapping
|
||||
* alternatives) so it runs in linear time on untrusted input — ReDoS-safe.
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
|
||||
const ANSI_ESCAPE_RE =
|
||||
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[A-Z\[\]\\^_`])|[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
"build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs",
|
||||
"build:native:tproxy": "cd src/mitm/tproxy/native && npx --yes node-gyp rebuild",
|
||||
"start": "node scripts/dev/run-next.mjs start",
|
||||
"lint": "eslint .",
|
||||
"lint": "eslint . --suppressions-location config/quality/eslint-suppressions.json",
|
||||
"lint:md": "npx --yes markdownlint-cli2 \"docs/**/*.md\" \"*.md\" \"!docs/i18n\" \"!docs/research\"",
|
||||
"lint:prose": "vale docs",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"",
|
||||
@@ -346,7 +346,7 @@
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,mjs}": [
|
||||
"prettier --write",
|
||||
"eslint --fix --no-error-on-unmatched-pattern"
|
||||
"eslint --fix --no-error-on-unmatched-pattern --suppressions-location config/quality/eslint-suppressions.json"
|
||||
],
|
||||
"*.{json,md,yml,yaml,css}": [
|
||||
"prettier --write"
|
||||
|
||||
@@ -15,11 +15,21 @@ import * as yaml from "js-yaml";
|
||||
const cwd = process.cwd();
|
||||
const out = {};
|
||||
|
||||
// 1) ESLint: contagem de warnings (errors devem ser 0; o lint já gata isso)
|
||||
// 1) ESLint: contagem de warnings/errors NOVOS além do baseline congelado.
|
||||
// Pacote 4 (plano mestre testes+CI, 2026-07-04): a dívida pré-existente vive em
|
||||
// config/quality/eslint-suppressions.json (ESLint bulk suppressions nativo) e é
|
||||
// bloqueada no PR que a introduziria (job lint-guard + lint-staged). A métrica do
|
||||
// ratchet passa a medir a dívida LÍQUIDA nova — em regime, ~0 — em vez do estoque
|
||||
// bruto (que driftava +41/+88 por ciclo e era rebaselinado às cegas na release).
|
||||
// O aperto do estoque acontece via --prune-suppressions na release.
|
||||
function eslintCounts() {
|
||||
let stdout;
|
||||
const args = ["eslint", ".", "--format", "json"];
|
||||
if (fs.existsSync(path.join(cwd, "config/quality/eslint-suppressions.json"))) {
|
||||
args.push("--suppressions-location", "config/quality/eslint-suppressions.json");
|
||||
}
|
||||
try {
|
||||
stdout = execFileSync("npx", ["eslint", ".", "--format", "json"], {
|
||||
stdout = execFileSync("npx", args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
});
|
||||
|
||||
@@ -150,7 +150,6 @@ export default function StreamTransformerAccordion({
|
||||
const prev = prevForceOpen.current;
|
||||
prevForceOpen.current = Boolean(forceOpen);
|
||||
if (!prev && forceOpen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing deep-link prop into local state
|
||||
setOpen(true);
|
||||
setHasOpened(true);
|
||||
onOpenChange?.(true);
|
||||
@@ -344,10 +343,7 @@ export default function StreamTransformerAccordion({
|
||||
onClick={() => handleCopy(rawSse, "input")}
|
||||
aria-label={translateOrFallback("copyInput", "Copy input")}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
{copiedField === "input" ? "check" : "content_copy"}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -358,7 +354,10 @@ export default function StreamTransformerAccordion({
|
||||
data-testid="raw-sse-input"
|
||||
className="min-h-[360px] w-full rounded-lg border border-border bg-bg-secondary px-3 py-3 text-xs font-mono text-text-main focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
spellCheck={false}
|
||||
aria-label={translateOrFallback("rawChatSseInput", "Raw chat completions SSE")}
|
||||
aria-label={translateOrFallback(
|
||||
"rawChatSseInput",
|
||||
"Raw chat completions SSE"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -378,10 +377,7 @@ export default function StreamTransformerAccordion({
|
||||
disabled={!transformedSse}
|
||||
aria-label={translateOrFallback("copyOutput", "Copy output")}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
{copiedField === "output" ? "check" : "content_copy"}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -440,9 +436,7 @@ export default function StreamTransformerAccordion({
|
||||
<th className="pb-2 pr-4">
|
||||
{translateOrFallback("eventType", "Event type")}
|
||||
</th>
|
||||
<th className="pb-2">
|
||||
{translateOrFallback("eventPreview", "Preview")}
|
||||
</th>
|
||||
<th className="pb-2">{translateOrFallback("eventPreview", "Preview")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -107,14 +107,17 @@ const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"
|
||||
|
||||
function resolveArea(urlPath: string): SkillArea | null {
|
||||
for (const [prefix, area] of PATH_AREA_MAP) {
|
||||
if (urlPath === prefix || urlPath.startsWith(prefix + "/") || urlPath.startsWith(prefix + "{")) {
|
||||
if (
|
||||
urlPath === prefix ||
|
||||
urlPath.startsWith(prefix + "/") ||
|
||||
urlPath.startsWith(prefix + "{")
|
||||
) {
|
||||
return area;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractOperations(pathsObj: Record<string, any>): OpenapiPath[] {
|
||||
const ops: OpenapiPath[] = [];
|
||||
|
||||
@@ -155,7 +158,7 @@ export function parseOpenapi(): ParsedOpenapi {
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`openapiParser: could not read ${yamlPath}. ` +
|
||||
`Run from project root. Underlying error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
`Run from project root. Underlying error: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const options = {
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || "http://localhost:20128";
|
||||
|
||||
export default function () {
|
||||
export default function soakScenario() {
|
||||
const res = http.get(`${BASE_URL}/api/monitoring/health`);
|
||||
check(res, { "status is 200": (r) => r.status === 200 });
|
||||
}
|
||||
|
||||
@@ -138,21 +138,13 @@ test("#3685 empty Claude stream (content_filter, no content blocks) is marked in
|
||||
false,
|
||||
`expected invalid for empty content-filtered stream, got valid=true (reason: ${out.reason})`
|
||||
);
|
||||
assert.match(
|
||||
out.reason ?? "",
|
||||
/empty/i,
|
||||
`reason should mention 'empty', got: "${out.reason}"`
|
||||
);
|
||||
assert.match(out.reason ?? "", /empty/i, `reason should mention 'empty', got: "${out.reason}"`);
|
||||
});
|
||||
|
||||
test("#3685 non-empty Claude stream (has content blocks) remains valid", async () => {
|
||||
const res = makeNonEmptyClaudeStream();
|
||||
const out = await validateResponseQuality(res, true, silentLog);
|
||||
assert.equal(
|
||||
out.valid,
|
||||
true,
|
||||
`expected valid for non-empty stream, got invalid: ${out.reason}`
|
||||
);
|
||||
assert.equal(out.valid, true, `expected valid for non-empty stream, got invalid: ${out.reason}`);
|
||||
// The clonedResponse must be present so the combo loop can pipe the stream body
|
||||
assert.ok(out.clonedResponse, "clonedResponse must be returned for valid streaming response");
|
||||
assert.ok(out.clonedResponse!.body, "clonedResponse must have a body stream");
|
||||
@@ -172,10 +164,7 @@ test("#3685 empty Claude stream without message_start lifecycle → valid (incom
|
||||
// A stream that only has partial events (e.g. disconnected before message_start)
|
||||
// should not trigger the failover since the lifecycle isn't complete — this is
|
||||
// handled by other mechanisms (stream readiness timeout).
|
||||
const partialEvents = [
|
||||
`event: ping\ndata: ${JSON.stringify({ type: "ping" })}`,
|
||||
"",
|
||||
];
|
||||
const partialEvents = [`event: ping\ndata: ${JSON.stringify({ type: "ping" })}`, ""];
|
||||
const res = new Response(claudeSseStream(partialEvents), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
@@ -252,7 +241,7 @@ test("#3685 streaming is preserved for non-empty response: clonedResponse body y
|
||||
// Drain the clonedResponse body and reconstruct the full byte sequence.
|
||||
const reader = out.clonedResponse!.body!.getReader();
|
||||
const receivedChunks: Uint8Array[] = [];
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
@@ -277,7 +266,10 @@ test("#3685 streaming is preserved for non-empty response: clonedResponse body y
|
||||
// Verify the response carries SSE content blocks in the decoded text,
|
||||
// confirming real content was streamed through.
|
||||
const decoded = new TextDecoder().decode(reconstructed);
|
||||
assert.ok(decoded.includes("content_block_start"), "decoded body must contain content_block_start");
|
||||
assert.ok(
|
||||
decoded.includes("content_block_start"),
|
||||
"decoded body must contain content_block_start"
|
||||
);
|
||||
assert.ok(decoded.includes("Hello"), "decoded body must contain the actual text content");
|
||||
assert.ok(decoded.includes(", world!"), "decoded body must contain the full text delta");
|
||||
});
|
||||
|
||||
@@ -93,7 +93,6 @@ function createMockRedisClient() {
|
||||
return keys.map((k) => store.get(k) ?? null);
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async eval(...args: unknown[]): Promise<unknown> {
|
||||
record("eval", ...args);
|
||||
return null;
|
||||
@@ -121,7 +120,8 @@ function createMockRedisClient() {
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test("redisQuotaStore: consume calls INCRBYFLOAT + EXPIRE and returns sliding window value", async () => {
|
||||
const { RedisQuotaStore, resetRedisClient } = await import("../../src/lib/quota/redisQuotaStore.ts");
|
||||
const { RedisQuotaStore, resetRedisClient } =
|
||||
await import("../../src/lib/quota/redisQuotaStore.ts");
|
||||
resetRedisClient();
|
||||
|
||||
const mock = createMockRedisClient();
|
||||
@@ -183,7 +183,8 @@ test("redisQuotaStore: getRedisClient throws clear error if ioredis not installe
|
||||
}
|
||||
|
||||
if (!ioredisAvailable) {
|
||||
const { getRedisClient, resetRedisClient } = await import("../../src/lib/quota/redisQuotaStore.ts");
|
||||
const { getRedisClient, resetRedisClient } =
|
||||
await import("../../src/lib/quota/redisQuotaStore.ts");
|
||||
resetRedisClient();
|
||||
|
||||
await assert.rejects(
|
||||
@@ -195,7 +196,8 @@ test("redisQuotaStore: getRedisClient throws clear error if ioredis not installe
|
||||
);
|
||||
} else {
|
||||
// ioredis is installed — just verify getRedisClient returns a client object
|
||||
const { getRedisClient, resetRedisClient } = await import("../../src/lib/quota/redisQuotaStore.ts");
|
||||
const { getRedisClient, resetRedisClient } =
|
||||
await import("../../src/lib/quota/redisQuotaStore.ts");
|
||||
resetRedisClient();
|
||||
|
||||
const client = await getRedisClient("redis://localhost:6399");
|
||||
@@ -212,34 +214,39 @@ test("redisQuotaStore: getRedisClient throws clear error if ioredis not installe
|
||||
|
||||
// ─── Real Redis integration (gated) ─────────────────────────────────────────
|
||||
|
||||
test("redisQuotaStore: real Redis integration (skipped unless RUN_QUOTA_REDIS_INT=1)", {
|
||||
skip: process.env.RUN_QUOTA_REDIS_INT !== "1",
|
||||
}, async () => {
|
||||
const { RedisQuotaStore, resetRedisClient } = await import("../../src/lib/quota/redisQuotaStore.ts");
|
||||
resetRedisClient();
|
||||
test(
|
||||
"redisQuotaStore: real Redis integration (skipped unless RUN_QUOTA_REDIS_INT=1)",
|
||||
{
|
||||
skip: process.env.RUN_QUOTA_REDIS_INT !== "1",
|
||||
},
|
||||
async () => {
|
||||
const { RedisQuotaStore, resetRedisClient } =
|
||||
await import("../../src/lib/quota/redisQuotaStore.ts");
|
||||
resetRedisClient();
|
||||
|
||||
const REDIS_URL = process.env.QUOTA_STORE_REDIS_URL ?? "redis://localhost:6379";
|
||||
const store = new RedisQuotaStore(REDIS_URL);
|
||||
const dim = { poolId: "it-pool", unit: "tokens" as const, window: "hourly" as const };
|
||||
const REDIS_URL = process.env.QUOTA_STORE_REDIS_URL ?? "redis://localhost:6379";
|
||||
const store = new RedisQuotaStore(REDIS_URL);
|
||||
const dim = { poolId: "it-pool", unit: "tokens" as const, window: "hourly" as const };
|
||||
|
||||
// Clear before test
|
||||
await store.clear("it-key", dim);
|
||||
// Clear before test
|
||||
await store.clear("it-key", dim);
|
||||
|
||||
await store.consume("it-key", dim, 100);
|
||||
await store.consume("it-key", dim, 200);
|
||||
const effective = await store.peek("it-key", dim);
|
||||
await store.consume("it-key", dim, 100);
|
||||
await store.consume("it-key", dim, 200);
|
||||
const effective = await store.peek("it-key", dim);
|
||||
|
||||
// In same bucket, prev=0 → effective≈300
|
||||
assert.ok(effective > 290, `Expected >290, got ${effective}`);
|
||||
assert.ok(effective <= 300, `Expected <=300, got ${effective}`);
|
||||
// In same bucket, prev=0 → effective≈300
|
||||
assert.ok(effective > 290, `Expected >290, got ${effective}`);
|
||||
assert.ok(effective <= 300, `Expected <=300, got ${effective}`);
|
||||
|
||||
// Cleanup
|
||||
await store.clear("it-key", dim);
|
||||
const afterClear = await store.peek("it-key", dim);
|
||||
assert.equal(afterClear, 0);
|
||||
// Cleanup
|
||||
await store.clear("it-key", dim);
|
||||
const afterClear = await store.peek("it-key", dim);
|
||||
assert.equal(afterClear, 0);
|
||||
|
||||
resetRedisClient();
|
||||
});
|
||||
resetRedisClient();
|
||||
}
|
||||
);
|
||||
|
||||
test("redisQuotaStore: sliding window decay formula is correct", async () => {
|
||||
// Unit test for the math without real Redis.
|
||||
@@ -265,7 +272,8 @@ test("redisQuotaStore: sliding window decay formula is correct", async () => {
|
||||
});
|
||||
|
||||
test("redisQuotaStore: resetRedisQuotaStore resets the store singleton", async () => {
|
||||
const { getRedisQuotaStore, resetRedisQuotaStore } = await import("../../src/lib/quota/redisQuotaStore.ts");
|
||||
const { getRedisQuotaStore, resetRedisQuotaStore } =
|
||||
await import("../../src/lib/quota/redisQuotaStore.ts");
|
||||
|
||||
const store1 = getRedisQuotaStore("redis://localhost:6399");
|
||||
resetRedisQuotaStore();
|
||||
|
||||
Reference in New Issue
Block a user