mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-27 09:32:11 +03:00
Compare commits
6 Commits
fix/v3850-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925feb27b8 | ||
|
|
aa52351113 | ||
|
|
8778ea7d18 | ||
|
|
c44c0a29e8 | ||
|
|
5458026c21 | ||
|
|
b4ec7807ab |
63
.github/workflows/docker-publish.yml
vendored
63
.github/workflows/docker-publish.yml
vendored
@@ -185,6 +185,13 @@ jobs:
|
||||
|
||||
- name: Build and push BUN base platform image by digest
|
||||
id: build-bun-base
|
||||
# Bun is a best-effort compatibility target, not a supported runtime
|
||||
# (AGENTS.md -> Environment). Its `bun run build` has been OOM-killing on
|
||||
# both arches; letting that sink the whole publish means the SUPPORTED
|
||||
# runner-base / runner-web images never reach the registry either. The
|
||||
# image is still built and pushed whenever it succeeds — only its power to
|
||||
# block the release is removed.
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
@@ -203,6 +210,13 @@ jobs:
|
||||
|
||||
- name: Build and push BUN web platform image by digest
|
||||
id: build-bun-web
|
||||
# Bun is a best-effort compatibility target, not a supported runtime
|
||||
# (AGENTS.md -> Environment). Its `bun run build` has been OOM-killing on
|
||||
# both arches; letting that sink the whole publish means the SUPPORTED
|
||||
# runner-base / runner-web images never reach the registry either. The
|
||||
# image is still built and pushed whenever it succeeds — only its power to
|
||||
# block the release is removed.
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
@@ -230,8 +244,15 @@ jobs:
|
||||
mkdir -p /tmp/digests/base /tmp/digests/web /tmp/digests/bun-base /tmp/digests/bun-web
|
||||
touch "/tmp/digests/base/${DIGEST_BASE#sha256:}"
|
||||
touch "/tmp/digests/web/${DIGEST_WEB#sha256:}"
|
||||
touch "/tmp/digests/bun-base/${DIGEST_BUN_BASE#sha256:}"
|
||||
touch "/tmp/digests/bun-web/${DIGEST_BUN_WEB#sha256:}"
|
||||
# Empty when the (non-blocking) bun build produced no image. `if` blocks,
|
||||
# not `[ -n ] && touch`: under `set -e` a failing AND-list aborts the step,
|
||||
# which is precisely the case being handled here.
|
||||
if [ -n "$DIGEST_BUN_BASE" ]; then
|
||||
touch "/tmp/digests/bun-base/${DIGEST_BUN_BASE#sha256:}"
|
||||
fi
|
||||
if [ -n "$DIGEST_BUN_WEB" ]; then
|
||||
touch "/tmp/digests/bun-web/${DIGEST_BUN_WEB#sha256:}"
|
||||
fi
|
||||
|
||||
- name: Upload base digests
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -254,7 +275,11 @@ jobs:
|
||||
with:
|
||||
name: digests-bun-base-${{ matrix.arch }}
|
||||
path: /tmp/digests/bun-base/*
|
||||
if-no-files-found: error
|
||||
# `ignore`, not `error`: the bun build is non-blocking, so an absent
|
||||
# digest is the expected outcome of a failed/skipped bun image — the
|
||||
# manifest step already treats these tags as optional. Leaving `error`
|
||||
# here just relocates the blocker from the manifest to the upload.
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload bun-web digests
|
||||
@@ -262,7 +287,11 @@ jobs:
|
||||
with:
|
||||
name: digests-bun-web-${{ matrix.arch }}
|
||||
path: /tmp/digests/bun-web/*
|
||||
if-no-files-found: error
|
||||
# `ignore`, not `error`: the bun build is non-blocking, so an absent
|
||||
# digest is the expected outcome of a failed/skipped bun image — the
|
||||
# manifest step already treats these tags as optional. Leaving `error`
|
||||
# here just relocates the blocker from the manifest to the upload.
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
@@ -320,6 +349,9 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download bun-base digests
|
||||
# Non-blocking: the bun image is best-effort, so its artifact may not
|
||||
# exist at all. The manifest step treats these tags as optional.
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: digests-bun-base-*
|
||||
@@ -327,6 +359,9 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download bun-web digests
|
||||
# Non-blocking: the bun image is best-effort, so its artifact may not
|
||||
# exist at all. The manifest step treats these tags as optional.
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: digests-bun-web-*
|
||||
@@ -338,7 +373,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
create_manifest() {
|
||||
local image="$1" suffix="$2" dir="$3"
|
||||
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
|
||||
local tags=(-t "${image}:${VERSION}${suffix}")
|
||||
if [ "$PROMOTE_LATEST" = "true" ]; then
|
||||
tags+=(-t "${image}:latest${suffix}")
|
||||
@@ -348,6 +383,10 @@ jobs:
|
||||
refs+=("${image}@sha256:$(basename "$digest_file")")
|
||||
done < <(find "$dir" -type f | sort)
|
||||
if [ "${#refs[@]}" -eq 0 ]; then
|
||||
if [ -n "$optional" ]; then
|
||||
echo "::warning::No image digests in $dir — skipping optional tag ${image}:${VERSION}${suffix}" >&2
|
||||
return 0
|
||||
fi
|
||||
echo "No image digests in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -356,15 +395,15 @@ jobs:
|
||||
|
||||
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
|
||||
create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web
|
||||
create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base
|
||||
create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web
|
||||
create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
|
||||
create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
|
||||
|
||||
- name: Create GHCR manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
create_manifest() {
|
||||
local image="$1" suffix="$2" dir="$3"
|
||||
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
|
||||
local tags=(-t "${image}:${VERSION}${suffix}")
|
||||
if [ "$PROMOTE_LATEST" = "true" ]; then
|
||||
tags+=(-t "${image}:latest${suffix}")
|
||||
@@ -374,6 +413,10 @@ jobs:
|
||||
refs+=("${image}@sha256:$(basename "$digest_file")")
|
||||
done < <(find "$dir" -type f | sort)
|
||||
if [ "${#refs[@]}" -eq 0 ]; then
|
||||
if [ -n "$optional" ]; then
|
||||
echo "::warning::No image digests in $dir — skipping optional tag ${image}:${VERSION}${suffix}" >&2
|
||||
return 0
|
||||
fi
|
||||
echo "No image digests in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -382,8 +425,8 @@ jobs:
|
||||
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
|
||||
|
||||
- name: Inspect image
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
|
||||
22
.github/workflows/npm-publish.yml
vendored
22
.github/workflows/npm-publish.yml
vendored
@@ -226,6 +226,28 @@ jobs:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
run: npm run build:cli
|
||||
|
||||
# `build:cli` assembles dist/ but does NOT write dist/BUILD_SHA — only
|
||||
# `build:release` does, by calling write-build-sha.mjs. The #10427 provenance
|
||||
# guard inside check:pack-artifact rejects an artifact with no SHA (and rejects
|
||||
# it even under OMNIROUTE_ALLOW_CANARY_BUILD=1: what cannot be identified cannot
|
||||
# be vouched for). Without this step the build+validate pair in this job is
|
||||
# structurally incompatible and fails 100% of the time — the same gap that was
|
||||
# fixed in ci.yml's Package Artifact job.
|
||||
- name: Stamp dist/BUILD_SHA for the provenance guard (#10427)
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
OMNIROUTE_BUILD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}"
|
||||
node scripts/build/write-build-sha.mjs
|
||||
|
||||
# The guard checks ancestry against origin/main by default, which is correct
|
||||
# here (a release tag is cut from main), but the ref has to exist locally for
|
||||
# `git merge-base` to resolve it.
|
||||
- name: Fetch main for the provenance probe
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: git fetch --no-tags --depth=50 origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
- name: Validate npm package artifact
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: npm run check:pack-artifact
|
||||
|
||||
40
.mailmap
Normal file
40
.mailmap
Normal file
@@ -0,0 +1,40 @@
|
||||
# .mailmap — canonical author identities for git log/shortlog/blame.
|
||||
#
|
||||
# Why this file exists: between 2026-08-13 and 2026-08-26 this checkout carried a
|
||||
# `git config --local` whose user.name was one contributor's ("Xiangzhe" / @xz-dev)
|
||||
# and whose user.email was ANOTHER contributor's (@backryun). Every commit produced
|
||||
# on this machine in that window was therefore signed with @backryun's address —
|
||||
# 237 commits, all in the -0300 timezone, while @backryun's own work commits from
|
||||
# +0900 and continued normally throughout. The local override was removed on
|
||||
# 2026-08-26; this file repairs the RECORD without rewriting published history
|
||||
# (those commits live on release/v3.8.50 and release/v3.8.51, which other sessions
|
||||
# and open PRs build on — a rewrite would force-push both and orphan the v3.8.50 tag).
|
||||
#
|
||||
# Format: Canonical Name <canonical@email> Commit Name <commit@email>
|
||||
|
||||
# --- Maintainer: several addresses used over the project's life ---
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouza.pw@gmail.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouza.pw@outlook.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouzapw@users.noreply.github.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Diego Souza <8016841+diegosouzapw@users.noreply.github.com>
|
||||
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diego.souza.pw@gmail.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <souzamiriamrodrigues790@gmail.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diego.souza@cdwasolutions.com.br>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouzapw@devbox.local>
|
||||
|
||||
# --- The misattribution window: name Xiangzhe + @backryun's email, from -0300.
|
||||
# These are maintainer/session commits, NOT @backryun's contributions.
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Xiangzhe <bakryun0718@proton.me>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Xiangzhe <diegosouza.pw@gmail.com>
|
||||
|
||||
# --- Xiangzhe (@xz-dev) — a distinct contributor; keep their own work intact ---
|
||||
Xiangzhe <32761048+xz-dev@users.noreply.github.com> <xz-dev@users.noreply.github.com>
|
||||
Xiangzhe <32761048+xz-dev@users.noreply.github.com> <xiangzhedev@gmail.com>
|
||||
|
||||
# --- @backryun's own alternate addresses (their real work, kept intact) ---
|
||||
backryun <24198422+backryun@users.noreply.github.com> <bakryun0718@proton.me>
|
||||
backryun <24198422+backryun@users.noreply.github.com> <backryun@daonlab.local>
|
||||
backryun <24198422+backryun@users.noreply.github.com> <busan011@ormbiz.co.kr>
|
||||
backryun <24198422+backryun@users.noreply.github.com> <backryun@users.noreply.github.com>
|
||||
71
CHANGELOG.md
71
CHANGELOG.md
@@ -93,6 +93,67 @@
|
||||
|
||||
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._
|
||||
|
||||
### 📊 Release by the numbers
|
||||
|
||||
| | |
|
||||
| --- | ---: |
|
||||
| 👥 People who contributed | **248** |
|
||||
| 📝 Commits in the cycle | **1,714** |
|
||||
| 🔀 Pull requests referenced | **1,666** |
|
||||
| 📋 Changelog entries | **1,182** |
|
||||
| 🙌 Contributors credited in entries | **256** |
|
||||
| 🤖 Automated dependency commits | 22 |
|
||||
|
||||
**Entries by type**
|
||||
|
||||
| Type | Count |
|
||||
| --- | ---: |
|
||||
| 🐛 Fixes | 779 |
|
||||
| ✨ Features | 169 |
|
||||
| 📚 Docs | 29 |
|
||||
| 🧹 Chore | 27 |
|
||||
| 🧪 Tests | 15 |
|
||||
| ♻️ Refactor | 5 |
|
||||
| ⚡ Performance | 3 |
|
||||
| providers | 2 |
|
||||
| 🔒 Security | 2 |
|
||||
| ⚙️ CI | 2 |
|
||||
| deps | 2 |
|
||||
| maint | 2 |
|
||||
|
||||
### 🏆 Top 25 contributors this cycle
|
||||
|
||||
_By commits in `ed2db6cb19..v3.8.50`, author identities consolidated via `.mailmap`. Bots excluded._
|
||||
|
||||
| # | Contributor | Commits |
|
||||
| ---: | --- | ---: |
|
||||
| 🥇 | diegosouzapw | 738 |
|
||||
| 🥈 | backryun | 88 |
|
||||
| 🥉 | Dizzle | 66 |
|
||||
| 4 | Ravi Tharuma | 52 |
|
||||
| 5 | Markus Hartung | 48 |
|
||||
| 6 | Bob.Hou | 42 |
|
||||
| 7 | Rouzbeh† | 38 |
|
||||
| 8 | Xiangzhe | 31 |
|
||||
| 9 | Paco Cartones | 28 |
|
||||
| 10 | Nguyen Thanh Dat | 23 |
|
||||
| 11 | Aman | 22 |
|
||||
| 12 | Will Gordon | 19 |
|
||||
| 13 | 小妍儿 ✨ | 17 |
|
||||
| 14 | adevwithpurpose | 16 |
|
||||
| 15 | Andrew B. | 10 |
|
||||
| 16 | NOXX - Commiter | 10 |
|
||||
| 17 | ignamiranda | 10 |
|
||||
| 18 | Jonathan Bailey | 9 |
|
||||
| 19 | Ke Jin | 9 |
|
||||
| 20 | Austin Liu | 8 |
|
||||
| 21 | Chewji | 8 |
|
||||
| 22 | Prudhvi Vuda | 7 |
|
||||
| 23 | benzntech | 7 |
|
||||
| 24 | rinseaid | 7 |
|
||||
| 25 | stanley | 7 |
|
||||
|
||||
|
||||
### ✨ New Features
|
||||
- **feat(search):** first-class X Search provider (`x-search`) on `POST /v1/search` and MCP `omniroute_x_search` using SuperGrok / xAI server-side `x_search`. Explicit provider or `search_type: "x"` only — never auto-selected for web. Reuses `xai-oauth` / `xao` / `xai` credentials. Not the X Developer Platform MCP. ([#10985](https://github.com/diegosouzapw/OmniRoute/issues/10985))
|
||||
- **feat(core):** add Layer A capability filter at router (#5696)
|
||||
@@ -1111,6 +1172,12 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
|
||||
- **fix(models):** health-check-excluded models are hidden from the `/v1/models` catalog ([#10026](https://github.com/diegosouzapw/OmniRoute/issues/10026) — thanks @ritheshcn25)
|
||||
- **fix(skills):** the CLI skills left stale by the quota subcommands are regenerated ([#10698](https://github.com/diegosouzapw/OmniRoute/issues/10698))
|
||||
- **fix(mcp):** CLI MCP call protocol issues were resolved ([#10960](https://github.com/diegosouzapw/OmniRoute/issues/10960) — thanks @YunyunZhai)
|
||||
- **fix(dashboard):** expert mode in the Combo Builder can type a provider/model pair by hand again ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — [#8285](https://github.com/diegosouzapw/OmniRoute/pull/8285) (global model search) replaced the "Manual model" block positionally with the new search panel, removing the only way to enter a model that is not in the catalog. The state and handlers behind it survived as dead code, so neither typecheck nor lint noticed the loss. The block is restored above the search panel, unchanged from its pre-#8285 form, and guarded by `tests/e2e/combos-flow.spec.ts` ("expert mode shows a single-page combo form with manual model entry").
|
||||
- **fix(sse):** a combo step pinned to an explicit connection (or a request pinned with `x-omniroute-connection`) is honored on fallback instead of silently rotating to a sibling account ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — the generic account-fallback branch excluded the pinned connection after an upstream failure and re-selected another account of the same provider, so a priority combo repeating one provider/model with two different fixed accounts ran **both** attempts under the first step; the second step and its own pin never executed, and per-step attribution (`comboStepId` / `comboExecutionKey`) was wrong. Rotation is now gated on there being no forced connection, matching the stream-readiness, pre-response-timeout and account-semaphore branches. Cooldown recording is unchanged, and unpinned selection still skips burned connections.
|
||||
- **fix(cli):** the local CLI sees the full `/api/monitoring/health` payload again — `version` included — restoring the `check:pack-boot` release gate. [#11040](https://github.com/diegosouzapw/OmniRoute/pull/11040) reduced that route to a liveness-only view for non-management callers (GHSA-mvf8-qc78-5mxm), but the route is classified PUBLIC and `runAuthzPipeline` strips the machine-token header for every route class — so the PUBLIC policy stamped `anonymous` and the loopback CLI could never be recognized as a management principal. The PUBLIC policy now stamps the same loopback-gated `local-cli-token` subject the MANAGEMENT policy already did; anonymous callers still get liveness only.
|
||||
- **fix(search):** a configured search connection (Serper, Brave, Tavily…) is now used instead of being silently passed over for the free `duckduckgo-free` fallback ([#11524](https://github.com/diegosouzapw/OmniRoute/issues/11524)) — when no provider was named explicitly and the cheapest auto-selected one had no credentials, the last-resort loop ran first and `duckduckgo-free` (cost 0, no auth) always won it with empty credentials. On `/v1/responses` the call then returned `success: true` with **zero results**, so web search looked healthy while the paid connection the operator had set up was never called. Credentialed regular providers are now swept before the last-resort loop, and fallback-only providers can no longer outrank a configured one on price.
|
||||
- **fix(api):** `/v1/models` no longer blocks the stale response while it rebuilds the catalog ([#11551](https://github.com/diegosouzapw/OmniRoute/issues/11551)) — the route has been passing a `scheduleBackgroundRefresh` option since #10198, but the parameter had already been removed from `getUnifiedModelsResponse()`, so the object was silently dropped and the stale-while-revalidate rebuild still ran on a `setTimeout(..., 0)`. The builder is essentially synchronous under the App Router, so it pinned the event loop **before** the cached response was flushed — the [#8728](https://github.com/diegosouzapw/OmniRoute/pull/8728) guarantee did not actually exist for operators with large catalogs. `catalogCache` now schedules through `after()` (with a macrotask fallback outside a request scope) and the option is threaded end to end. The extra argument was invisible to CI because `typecheck:core` is a curated allowlist and `next.config.mjs` sets `ignoreBuildErrors: true`.
|
||||
- **fix(sse):** a universal handoff whose summary comes back unusable is no longer regenerated on every single model switch, which was burning paid quota on upstream calls whose answers were thrown away ([#11552](https://github.com/diegosouzapw/OmniRoute/issues/11552)) — nothing is persisted when the summary does not parse, so the next switch in the same session re-issued the same full-history summarization request and discarded it again, forever. With a switch-heavy combo strategy (weighted, random, round-robin, p2c) that landed on a large share of requests: measured at n=200, roughly **one request in four carried an extra discarded upstream call**, and that traffic skewed a weighted 70/30 combo to an observed 0.895 share for one provider even though the share actually delivered to the client was a correct 0.70. There is now an exponential back-off per (session, combo) — 5 min up to 1 h, cleared on the first successful handoff, capped at 500 tracked keys. A transient upstream failure is deliberately **not** tracked, so it still retries immediately. After the fix: 201 upstream calls for 200 requests, and the measured share matches the delivered one.
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
@@ -1355,6 +1422,10 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
|
||||
- **docs(i18n):** localization contributions — a complete Persian user guide ([#11254](https://github.com/diegosouzapw/OmniRoute/issues/11254)), improved and completed Turkish documentation ([#11237](https://github.com/diegosouzapw/OmniRoute/issues/11237)), the Italian README restored ([#11246](https://github.com/diegosouzapw/OmniRoute/issues/11246)), a Farsi README ([#10777](https://github.com/diegosouzapw/OmniRoute/issues/10777) — thanks @farshidrezaei), a `SETUP_GUIDE.md` correction ([#10490](https://github.com/diegosouzapw/OmniRoute/issues/10490) — thanks @realize000), a `python_requests.py` example fix ([#10731](https://github.com/diegosouzapw/OmniRoute/issues/10731) — thanks @pandaaaa1990), and a retranslation of the CLI reference and integrations guide across all 42 locales
|
||||
- **chore(repo):** repository hygiene — the self-referential `_tasks` symlink was untracked and `.gitignore` anchored so a `_tasks` symlink can never be tracked again, `.source/dynamic.ts`, `.source`, `/output/` and the Playwright CLI artifact directory were ignored, an initial `.cbmignore` was added for codebase-memory indexing, unused `.source/dynamic.ts` and `source.config.mjs` files were removed, the stray unresolved conflict marker in `ENVIRONMENT.md` was cleaned up, and the Open Collective sponsorship link was removed from the README
|
||||
- **chore(release):** localized `llm.txt` mirrors and the v3.8.50 base quality docs were synchronized, and the 363 `changelog.d` fragments were aggregated into this section
|
||||
- **fix(ci):** the pack-artifact provenance gate now checks the branch under test instead of `origin/main` ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — the guard added for [#10427](https://github.com/diegosouzapw/OmniRoute/issues/10427) is correct at publish time (that workflow runs on `main`), but in a `pull_request` context the head is by construction not an ancestor of `main` and the shallow checkout never fetches it, so the probe always answered false and the job failed 100% of the time. It became visible only once it stopped being cancelled behind Build. Pre-merge it resolves the ref from `refs/pull/<N>/head`, which exists on origin even for fork PRs.
|
||||
- **test(dashboard):** the proxy-registry e2e smoke flow opens the toolbar's "More actions" overflow menu before clicking bulk assign ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — [#9870](https://github.com/diegosouzapw/OmniRoute/pull/9870) moved the action into that menu, which only renders its items while open, so the locator never resolved and the test burned its full 180 s budget. Every existing assertion is unchanged.
|
||||
- **test(api):** the `/v1/models` e2e check accepts the auth gate introduced by [#9320](https://github.com/diegosouzapw/OmniRoute/pull/9320) ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — the catalog now requires auth whenever management auth is configured, and the harness boots with `INITIAL_PASSWORD` set, so the endpoint had been answering 401 since 2026-08-04 and the check was red the whole time, hidden behind a cancelled job. It now mirrors the sibling `/api/providers` check: assert the catalog shape when the catalog is served, otherwise pin the gate by status **and** error type so an unrelated 401 cannot pass for the deliberate one.
|
||||
- **chore(quality):** refreshed the combos-page ESLint suppressions after the manual-model restore ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — three `no-unused-vars` suppressions existed only because #8285 had deleted the JSX consuming that state; with the block back they are live again, and a stale suppression makes ESLint exit 2, which is what actually turned the Lint job red. The 7 `react-hooks/set-state-in-effect` plus 1 `react-hooks/immutability` errors in the same file are pre-existing (reproducible on the file's `091e2ba4da` content) and surfaced only because touching the file evicted it from the restored `.eslintcache`; they are frozen here and tracked separately rather than refactored mid-release.
|
||||
|
||||
|
||||
### 🙌 Contributors
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(cli):** the local CLI sees the full `/api/monitoring/health` payload again — `version` included — restoring the `check:pack-boot` release gate. [#11040](https://github.com/diegosouzapw/OmniRoute/pull/11040) reduced that route to a liveness-only view for non-management callers (GHSA-mvf8-qc78-5mxm), but the route is classified PUBLIC and `runAuthzPipeline` strips the machine-token header for every route class — so the PUBLIC policy stamped `anonymous` and the loopback CLI could never be recognized as a management principal. The PUBLIC policy now stamps the same loopback-gated `local-cli-token` subject the MANAGEMENT policy already did; anonymous callers still get liveness only.
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(search):** a configured search connection (Serper, Brave, Tavily…) is now used instead of being silently passed over for the free `duckduckgo-free` fallback ([#11524](https://github.com/diegosouzapw/OmniRoute/issues/11524)) — when no provider was named explicitly and the cheapest auto-selected one had no credentials, the last-resort loop ran first and `duckduckgo-free` (cost 0, no auth) always won it with empty credentials. On `/v1/responses` the call then returned `success: true` with **zero results**, so web search looked healthy while the paid connection the operator had set up was never called. Credentialed regular providers are now swept before the last-resort loop, and fallback-only providers can no longer outrank a configured one on price.
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(api):** `/v1/models` no longer blocks the stale response while it rebuilds the catalog ([#11551](https://github.com/diegosouzapw/OmniRoute/issues/11551)) — the route has been passing a `scheduleBackgroundRefresh` option since #10198, but the parameter had already been removed from `getUnifiedModelsResponse()`, so the object was silently dropped and the stale-while-revalidate rebuild still ran on a `setTimeout(..., 0)`. The builder is essentially synchronous under the App Router, so it pinned the event loop **before** the cached response was flushed — the [#8728](https://github.com/diegosouzapw/OmniRoute/pull/8728) guarantee did not actually exist for operators with large catalogs. `catalogCache` now schedules through `after()` (with a macrotask fallback outside a request scope) and the option is threaded end to end. The extra argument was invisible to CI because `typecheck:core` is a curated allowlist and `next.config.mjs` sets `ignoreBuildErrors: true`.
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(sse):** a universal handoff whose summary comes back unusable is no longer regenerated on every single model switch, which was burning paid quota on upstream calls whose answers were thrown away ([#11552](https://github.com/diegosouzapw/OmniRoute/issues/11552)) — nothing is persisted when the summary does not parse, so the next switch in the same session re-issued the same full-history summarization request and discarded it again, forever. With a switch-heavy combo strategy (weighted, random, round-robin, p2c) that landed on a large share of requests: measured at n=200, roughly **one request in four carried an extra discarded upstream call**, and that traffic skewed a weighted 70/30 combo to an observed 0.895 share for one provider even though the share actually delivered to the client was a correct 0.70. There is now an exponential back-off per (session, combo) — 5 min up to 1 h, cleared on the first successful handoff, capped at 500 tracked keys. A transient upstream failure is deliberately **not** tracked, so it still retries immediately. After the fix: 201 upstream calls for 200 requests, and the measured share matches the delivered one.
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(dashboard):** expert mode in the Combo Builder can type a provider/model pair by hand again ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — [#8285](https://github.com/diegosouzapw/OmniRoute/pull/8285) (global model search) replaced the "Manual model" block positionally with the new search panel, removing the only way to enter a model that is not in the catalog. The state and handlers behind it survived as dead code, so neither typecheck nor lint noticed the loss. The block is restored above the search panel, unchanged from its pre-#8285 form, and guarded by `tests/e2e/combos-flow.spec.ts` ("expert mode shows a single-page combo form with manual model entry").
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(sse):** a combo step pinned to an explicit connection (or a request pinned with `x-omniroute-connection`) is honored on fallback instead of silently rotating to a sibling account ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — the generic account-fallback branch excluded the pinned connection after an upstream failure and re-selected another account of the same provider, so a priority combo repeating one provider/model with two different fixed accounts ran **both** attempts under the first step; the second step and its own pin never executed, and per-step attribution (`comboStepId` / `comboExecutionKey`) was wrong. Rotation is now gated on there being no forced connection, matching the stream-readiness, pre-response-timeout and account-semaphore branches. Cooldown recording is unchanged, and unpinned selection still skips burned connections.
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(dashboard):** replaced the Model Lockout card's unprovenanced notification MP3 with a short, locally synthesized Web Audio chime ([#11731](https://github.com/diegosouzapw/OmniRoute/pull/11731)) — both toggles retain optional audible feedback without shipping replacement media or third-party code, and unsupported, suspended, or rejected audio contexts fail open without blocking the setting change.
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(ci):** the pack-artifact provenance gate now checks the branch under test instead of `origin/main` ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — the guard added for [#10427](https://github.com/diegosouzapw/OmniRoute/issues/10427) is correct at publish time (that workflow runs on `main`), but in a `pull_request` context the head is by construction not an ancestor of `main` and the shallow checkout never fetches it, so the probe always answered false and the job failed 100% of the time. It became visible only once it stopped being cancelled behind Build. Pre-merge it resolves the ref from `refs/pull/<N>/head`, which exists on origin even for fork PRs.
|
||||
@@ -1 +0,0 @@
|
||||
- **test(dashboard):** the proxy-registry e2e smoke flow opens the toolbar's "More actions" overflow menu before clicking bulk assign ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — [#9870](https://github.com/diegosouzapw/OmniRoute/pull/9870) moved the action into that menu, which only renders its items while open, so the locator never resolved and the test burned its full 180 s budget. Every existing assertion is unchanged.
|
||||
@@ -1 +0,0 @@
|
||||
- **test(api):** the `/v1/models` e2e check accepts the auth gate introduced by [#9320](https://github.com/diegosouzapw/OmniRoute/pull/9320) ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — the catalog now requires auth whenever management auth is configured, and the harness boots with `INITIAL_PASSWORD` set, so the endpoint had been answering 401 since 2026-08-04 and the check was red the whole time, hidden behind a cancelled job. It now mirrors the sibling `/api/providers` check: assert the catalog shape when the catalog is served, otherwise pin the gate by status **and** error type so an unrelated 401 cannot pass for the deliberate one.
|
||||
@@ -1 +0,0 @@
|
||||
- **chore(quality):** refreshed the combos-page ESLint suppressions after the manual-model restore ([#8875](https://github.com/diegosouzapw/OmniRoute/pull/8875)) — three `no-unused-vars` suppressions existed only because #8285 had deleted the JSX consuming that state; with the block back they are live again, and a stale suppression makes ESLint exit 2, which is what actually turned the Lint job red. The 7 `react-hooks/set-state-in-effect` plus 1 `react-hooks/immutability` errors in the same file are pre-existing (reproducible on the file's `091e2ba4da` content) and surfaced only because touching the file evicted it from the restored `.eslintcache`; they are frozen here and tracked separately rather than refactored mid-release.
|
||||
BIN
public/audio/ui-notify.mp3
Normal file
BIN
public/audio/ui-notify.mp3
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Card, Toggle } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -23,66 +23,6 @@ const DEFAULTS: ModelLockoutSettings = {
|
||||
useExponentialBackoff: true,
|
||||
};
|
||||
|
||||
type WebkitAudioWindow = Window & {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
};
|
||||
|
||||
function scheduleNotifyChime(context: AudioContext): void {
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
const startsAt = context.currentTime;
|
||||
const endsAt = startsAt + 0.1;
|
||||
|
||||
// A short, locally synthesized tone avoids shipping a third-party audio asset.
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(880, startsAt);
|
||||
gain.gain.setValueAtTime(0.0001, startsAt);
|
||||
gain.gain.exponentialRampToValueAtTime(0.045, startsAt + 0.012);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, startsAt + 0.09);
|
||||
|
||||
oscillator.connect(gain);
|
||||
gain.connect(context.destination);
|
||||
oscillator.onended = () => {
|
||||
oscillator.disconnect();
|
||||
gain.disconnect();
|
||||
};
|
||||
oscillator.start(startsAt);
|
||||
oscillator.stop(endsAt);
|
||||
}
|
||||
|
||||
function playNotifyChime(contextRef: { current: AudioContext | null }): void {
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext ?? (window as WebkitAudioWindow).webkitAudioContext;
|
||||
if (!AudioContextConstructor) return;
|
||||
|
||||
if (!contextRef.current || contextRef.current.state === "closed") {
|
||||
contextRef.current = new AudioContextConstructor();
|
||||
}
|
||||
|
||||
const context = contextRef.current;
|
||||
if (context.state !== "running") {
|
||||
void context
|
||||
.resume()
|
||||
.then(() => {
|
||||
try {
|
||||
scheduleNotifyChime(context);
|
||||
} catch {
|
||||
// Sound is optional and must never block a settings change.
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleNotifyChime(context);
|
||||
} catch {
|
||||
// Sound is optional and must never block a settings change.
|
||||
}
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
@@ -131,7 +71,6 @@ export default function ModelLockoutCard() {
|
||||
const t = useTranslations("settings");
|
||||
const tc = useTranslations("common");
|
||||
const notify = useNotificationStore();
|
||||
const notifyAudioContextRef = useRef<AudioContext | null>(null);
|
||||
|
||||
const [data, setData] = useState<ModelLockoutSettings>(DEFAULTS);
|
||||
const [draft, setDraft] = useState<ModelLockoutSettings>(DEFAULTS);
|
||||
@@ -139,21 +78,6 @@ export default function ModelLockoutCard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const context = notifyAudioContextRef.current;
|
||||
notifyAudioContextRef.current = null;
|
||||
if (context && context.state !== "closed") {
|
||||
try {
|
||||
void context.close().catch(() => undefined);
|
||||
} catch {
|
||||
// Sound cleanup is optional and must never block the page from unmounting.
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
@@ -330,6 +254,22 @@ export default function ModelLockoutCard() {
|
||||
return `${ms}ms`;
|
||||
};
|
||||
|
||||
const notifyRef = useRef<HTMLAudioElement | null>(null);
|
||||
const playNotify = useCallback(() => {
|
||||
try {
|
||||
if (notifyRef.current) {
|
||||
notifyRef.current.pause();
|
||||
notifyRef.current.currentTime = 0;
|
||||
} else {
|
||||
notifyRef.current = new Audio("/audio/ui-notify.mp3");
|
||||
notifyRef.current.volume = 0.3;
|
||||
}
|
||||
void notifyRef.current.play();
|
||||
} catch {
|
||||
// Audio is optional.
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
@@ -374,7 +314,7 @@ export default function ModelLockoutCard() {
|
||||
checked={draft.enabled}
|
||||
onChange={(checked) => {
|
||||
setDraft((prev) => ({ ...prev, enabled: checked }));
|
||||
playNotifyChime(notifyAudioContextRef);
|
||||
playNotify();
|
||||
}}
|
||||
label={t("modelLockoutEnabled")}
|
||||
description={t("modelLockoutEnabledDescription")}
|
||||
@@ -503,7 +443,7 @@ export default function ModelLockoutCard() {
|
||||
...prev,
|
||||
useExponentialBackoff: checked,
|
||||
}));
|
||||
playNotifyChime(notifyAudioContextRef);
|
||||
playNotify();
|
||||
}}
|
||||
label={t("modelLockoutExponentialBackoff")}
|
||||
description={t("modelLockoutExponentialBackoffDescription")}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const translate = (key: string) => key;
|
||||
const notifications = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => translate,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/notificationStore", () => ({
|
||||
useNotificationStore: () => notifications,
|
||||
}));
|
||||
|
||||
import ModelLockoutCard from "../../../src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard";
|
||||
|
||||
type OscillatorMock = {
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
frequency: { setValueAtTime: ReturnType<typeof vi.fn> };
|
||||
onended: (() => void) | null;
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
type: OscillatorType;
|
||||
};
|
||||
|
||||
function createAudioContextMock(
|
||||
options: { resumeRejects?: boolean; state?: AudioContextState } = {}
|
||||
) {
|
||||
const contexts: AudioContextMock[] = [];
|
||||
const oscillators: OscillatorMock[] = [];
|
||||
const gains: Array<{
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
gain: {
|
||||
cancelScheduledValues: ReturnType<typeof vi.fn>;
|
||||
exponentialRampToValueAtTime: ReturnType<typeof vi.fn>;
|
||||
setValueAtTime: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}> = [];
|
||||
|
||||
class AudioContextMock {
|
||||
close = vi.fn().mockResolvedValue(undefined);
|
||||
currentTime = 1;
|
||||
destination = {};
|
||||
state: AudioContextState = options.state ?? "running";
|
||||
resume = options.resumeRejects
|
||||
? vi.fn().mockRejectedValue(new Error("audio resume denied"))
|
||||
: vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
constructor() {
|
||||
contexts.push(this);
|
||||
}
|
||||
|
||||
createOscillator() {
|
||||
const oscillator: OscillatorMock = {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
frequency: { setValueAtTime: vi.fn() },
|
||||
onended: null,
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
type: "sine",
|
||||
};
|
||||
oscillators.push(oscillator);
|
||||
return oscillator;
|
||||
}
|
||||
|
||||
createGain() {
|
||||
const gain = {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
gain: {
|
||||
cancelScheduledValues: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
setValueAtTime: vi.fn(),
|
||||
},
|
||||
};
|
||||
gains.push(gain);
|
||||
return gain;
|
||||
}
|
||||
}
|
||||
|
||||
return { AudioContextMock, contexts, gains, oscillators };
|
||||
}
|
||||
|
||||
const roots: Array<{ container: HTMLDivElement; root: Root }> = [];
|
||||
|
||||
async function renderCard(): Promise<{ container: HTMLDivElement; root: Root }> {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push({ container, root });
|
||||
|
||||
await act(async () => {
|
||||
root.render(<ModelLockoutCard />);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
function disposeCard(rendered: { container: HTMLDivElement; root: Root }): void {
|
||||
act(() => rendered.root.unmount());
|
||||
rendered.container.remove();
|
||||
const index = roots.findIndex(({ root }) => root === rendered.root);
|
||||
if (index >= 0) roots.splice(index, 1);
|
||||
}
|
||||
|
||||
describe("Model lockout optional notification sound", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
modelLockout: {
|
||||
enabled: false,
|
||||
errorCodes: [403, 404, 429, 502, 503, 504],
|
||||
baseCooldownMs: 120_000,
|
||||
maxCooldownMs: 1_800_000,
|
||||
maxBackoffSteps: 10,
|
||||
useExponentialBackoff: true,
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { container, root } of roots) {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
roots.length = 0;
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not ship or reference the unprovenanced MP3", () => {
|
||||
const legacyAssetName = "ui-notify.mp3";
|
||||
const legacyAssetUrl = ["/audio", legacyAssetName].join("/");
|
||||
const assetPath = path.join(process.cwd(), "public/audio", legacyAssetName);
|
||||
const componentPath = path.join(
|
||||
process.cwd(),
|
||||
"src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx"
|
||||
);
|
||||
|
||||
expect(fs.existsSync(assetPath)).toBe(false);
|
||||
expect(fs.readFileSync(componentPath, "utf8")).not.toContain(legacyAssetUrl);
|
||||
});
|
||||
|
||||
it("plays generated feedback for both model-lockout toggles", async () => {
|
||||
const { AudioContextMock, contexts, gains, oscillators } = createAudioContextMock();
|
||||
vi.stubGlobal("AudioContext", AudioContextMock);
|
||||
const legacyAudio = vi.fn(() => ({
|
||||
currentTime: 0,
|
||||
pause: vi.fn(),
|
||||
play: vi.fn(),
|
||||
volume: 1,
|
||||
}));
|
||||
vi.stubGlobal("Audio", legacyAudio);
|
||||
|
||||
const { container } = await renderCard();
|
||||
const toggles = [...container.querySelectorAll<HTMLButtonElement>('button[role="switch"]')];
|
||||
expect(toggles).toHaveLength(2);
|
||||
|
||||
act(() => toggles[0]?.click());
|
||||
act(() => toggles[1]?.click());
|
||||
|
||||
expect(contexts).toHaveLength(1);
|
||||
expect(oscillators).toHaveLength(2);
|
||||
expect(gains).toHaveLength(2);
|
||||
oscillators.forEach((oscillator, index) => {
|
||||
const gain = gains[index];
|
||||
expect(gain).toBeDefined();
|
||||
expect(oscillator.type).toBe("sine");
|
||||
expect(oscillator.frequency.setValueAtTime).toHaveBeenCalledWith(880, 1);
|
||||
expect(oscillator.connect).toHaveBeenCalledWith(gain);
|
||||
expect(gain?.connect).toHaveBeenCalledWith(contexts[0]?.destination);
|
||||
expect(gain?.gain.setValueAtTime).toHaveBeenCalledWith(0.0001, 1);
|
||||
expect(gain?.gain.exponentialRampToValueAtTime).toHaveBeenNthCalledWith(1, 0.045, 1.012);
|
||||
expect(gain?.gain.exponentialRampToValueAtTime).toHaveBeenNthCalledWith(2, 0.0001, 1.09);
|
||||
expect(oscillator.start).toHaveBeenCalledWith(1);
|
||||
expect(oscillator.stop).toHaveBeenCalledWith(1.1);
|
||||
|
||||
oscillator.onended?.();
|
||||
expect(oscillator.disconnect).toHaveBeenCalledOnce();
|
||||
expect(gain?.disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(legacyAudio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the prefixed Web Audio constructor when AudioContext is unavailable", async () => {
|
||||
const { AudioContextMock, contexts, oscillators } = createAudioContextMock();
|
||||
vi.stubGlobal("AudioContext", undefined);
|
||||
vi.stubGlobal("webkitAudioContext", AudioContextMock);
|
||||
|
||||
const { container } = await renderCard();
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[role="switch"]');
|
||||
act(() => toggle?.click());
|
||||
|
||||
expect(contexts).toHaveLength(1);
|
||||
expect(oscillators).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("starts the optional chime after a suspended context resumes", async () => {
|
||||
const { AudioContextMock, contexts, oscillators } = createAudioContextMock({
|
||||
state: "suspended",
|
||||
});
|
||||
vi.stubGlobal("AudioContext", AudioContextMock);
|
||||
|
||||
const { container } = await renderCard();
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[role="switch"]');
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(contexts[0]?.resume).toHaveBeenCalledOnce();
|
||||
expect(oscillators).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps both toggles working when Web Audio is unavailable", async () => {
|
||||
vi.stubGlobal("AudioContext", undefined);
|
||||
|
||||
const { container } = await renderCard();
|
||||
const toggles = [...container.querySelectorAll<HTMLButtonElement>('button[role="switch"]')];
|
||||
act(() => toggles[0]?.click());
|
||||
act(() => toggles[1]?.click());
|
||||
|
||||
expect(toggles[0]?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(toggles[1]?.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps the toggle working when a suspended context cannot resume", async () => {
|
||||
const { AudioContextMock, contexts, oscillators } = createAudioContextMock({
|
||||
resumeRejects: true,
|
||||
state: "suspended",
|
||||
});
|
||||
vi.stubGlobal("AudioContext", AudioContextMock);
|
||||
|
||||
const { container } = await renderCard();
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[role="switch"]');
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(contexts[0]?.resume).toHaveBeenCalledOnce();
|
||||
expect(oscillators).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("releases its audio context when the settings card unmounts", async () => {
|
||||
const { AudioContextMock, contexts } = createAudioContextMock();
|
||||
vi.stubGlobal("AudioContext", AudioContextMock);
|
||||
|
||||
const rendered = await renderCard();
|
||||
const toggle = rendered.container.querySelector<HTMLButtonElement>('button[role="switch"]');
|
||||
act(() => toggle?.click());
|
||||
expect(contexts).toHaveLength(1);
|
||||
|
||||
disposeCard(rendered);
|
||||
|
||||
expect(contexts[0]?.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user