mirror of
https://github.com/penpot/penpot.git
synced 2026-10-30 06:20:32 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
65a76a21cd
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: local-ci
|
||||
description: Run local CI-style checks with ./scripts/ci (lint, tests, format) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, or repairing Clojure delimiter errors.
|
||||
description: Run local CI-style checks with ./scripts/ci (lint, tests, format, and on request production builds) per monorepo module. Use when verifying changes before declaring work done, running lint or tests locally, fixing formatting, repairing Clojure delimiter errors, or checking that a module still compiles.
|
||||
---
|
||||
|
||||
# Local CI
|
||||
@ -29,10 +29,12 @@ Run from the repo root:
|
||||
```
|
||||
|
||||
Modules: `frontend` `backend` `common` `render-wasm` `exporter` `mcp`
|
||||
`plugins` `library`, or `--all` for every module.
|
||||
`plugins` `library` `media-processor`, or `--all` for every module.
|
||||
|
||||
With no task flags it runs three tasks per module, in order: **lint**,
|
||||
**test**, **fmt** (format check; `--fix` formats files instead).
|
||||
**test**, **fmt** (format check; `--fix` formats files instead). **build**
|
||||
is opt-in: it compiles the module the way production does and is the slow
|
||||
one, so it never runs unless you ask for it.
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
@ -41,6 +43,7 @@ With no task flags it runs three tasks per module, in order: **lint**,
|
||||
| `--lint` / `--no-lint` | Run only lint / drop lint |
|
||||
| `--test` / `--no-test` | Run only tests / drop tests |
|
||||
| `--fmt` / `--no-fmt` | Run only format check / drop it |
|
||||
| `--build` / `--no-build` | Run only the production build / drop it |
|
||||
| `--fix` | Format files instead of checking (other tasks unaffected) |
|
||||
| `--paren-repair` | Fix delimiter errors in Clojure/CLJS files |
|
||||
| `--fail-fast` | Stop at the first failure |
|
||||
@ -72,6 +75,12 @@ log path.
|
||||
# Format the module without running the test suite
|
||||
./scripts/ci --fix --no-test frontend
|
||||
|
||||
# Check that the module still compiles (slow: minutes)
|
||||
./scripts/ci --build frontend
|
||||
|
||||
# Compile every module (8 builds, ~4 min with warm caches)
|
||||
./scripts/ci --build --all
|
||||
|
||||
# Broke delimiters in Clojure/CLJS files: repair first, then lint
|
||||
./scripts/ci --paren-repair frontend
|
||||
./scripts/ci --lint frontend
|
||||
@ -89,7 +98,12 @@ log path.
|
||||
- Test tasks are long-running (backend runs `clojure -M:dev:test`); give
|
||||
the bash call a generous timeout (10–20 minutes) instead of letting it
|
||||
time out mid-run.
|
||||
- `mcp` has no lint task — it shows as skipped, not failed.
|
||||
- `mcp` and `media-processor` have no lint task — they show as skipped,
|
||||
not failed. `common` has no build task for the same reason.
|
||||
- Build runs are the slow ones (frontend ~2 min even with warm caches;
|
||||
cold caches roughly double it). Give the shell a generous timeout and
|
||||
do not run two builds at once in the same checkout: `render-wasm` takes
|
||||
a build lock.
|
||||
- `--paren-repair` only fixes delimiters; run lint afterwards to catch
|
||||
what remains. See `mem:scripts/paren-repair`.
|
||||
- What to run and how to read test results: `mem:testing`.
|
||||
|
||||
2
.github/workflows/plugins-deploy-api-doc.yml
vendored
2
.github/workflows/plugins-deploy-api-doc.yml
vendored
@ -51,7 +51,7 @@ jobs:
|
||||
uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1
|
||||
with:
|
||||
working-directory: plugins
|
||||
runtime: node@24.22.1
|
||||
runtime: node@24.22.2
|
||||
install: false
|
||||
|
||||
- name: Install deps
|
||||
|
||||
@ -49,7 +49,7 @@ jobs:
|
||||
uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1
|
||||
with:
|
||||
working-directory: plugins
|
||||
runtime: node@24.22.1
|
||||
runtime: node@24.22.2
|
||||
install: false
|
||||
|
||||
- name: Install deps
|
||||
|
||||
@ -18,9 +18,10 @@
|
||||
- The waiting counters are a field of the `ConcurrencyLimiter` component (a `ConcurrentHashMap` of name to `AtomicLong`), so they start at zero, die with the component, and the sampler that closes over that map republishes only its own names every 15 s; the sampler stops with the component in `halt-key!`. `impl-invoke` pairs one decrement with one increment through a per-call flag: the flag turns a second call into a no-op and the `finally` guarantees that there is one. The increment sits inside the `try` and the decrement lives only in the acquired callback and the `finally`, so a handler that throws, a rejection and a throwable from the publishing path all leave the count where it was and the thread without the limiter marked as held (a leftover mark would skip that limiter for that key for ever). Concurrent `inc`/`dec` publications of two threads can land out of order and the value can read low for a moment; the republish corrects it.
|
||||
- The `app.rpc.climit` log lines carry `queue`, `queue-max`, `permits`, `elapsed`, `code` and `reason`, and their `id` carries the key (`name/<profile or key>`). That is the only place where the profile behind a queue is visible: a pile-up is chased there, on demand, not in the metrics. The `queue` of the line is the calls inside the limiter minus its permits, so it reads 0 for a lone caller that times out: that line goes out at TRACE instead of WARN, and the rejection count has to come from the metric.
|
||||
- Nothing in the limiter path has a timeout unless the config sets one, and `psm/acquire` then calls `Semaphore.acquire`, which parks the calling thread forever. A parked thread still counts as in-system for every outer limiter of its chain, so the outermost one (the method's own by-profile limiter) stops granting permits and answers 429 to everything. A limiter without `:queue` gets an unlimited queue and therefore never rejects: its pile-up is silent, with no 429 and no log line except the WARN `enqueued`/`acquired` lines of the keyed id.
|
||||
- Deadlock shape between a command's own limits and the root ones (fixed): a command declaring its own limits held `process-image/by-profile` before `root/by-profile`, while a command with no limits of its own takes the roots first and then re-enters with the same keyed limits from inside its handler (`create-file-media-object-from-url`, `upload-photo`) and waits for them holding a root permit. Two calls of one profile then held the same two limiters inverted and parked for good: 69 threads at 0 CPU, an audit limiter with 0 acquisitions in 24h and 100% 429s, on an instance that never served a 204.
|
||||
- `climit/wrap` now appends the root limiters after the command's own, so `root/global`/`root/by-profile` end up outermost and every path meets them first; the nested `climit/invoke!` chains carry only the command's own limits and run inside them, which puts both paths in the same order. `impl-invoke` also keeps, per thread, the set of limiters that thread is inside of and runs the handler inline when a call asks for one it already holds, instead of queueing behind itself. That check relies on `pbh/invoke!` running the handler on the calling thread; the mark is removed in a `finally`. Commands declare the by-profile limit before the global one, which makes it the outermost of the pair: keep that declaration order, the reverse one would put two paths back in inverted order. The two nested call sites (`create-file-media-object-from-url`, `upload-photo`) still share `process-image` with the enclosing call instead of getting a limiter of their own, so the comment there about keeping the download out of that limit no longer holds.
|
||||
- The shipped `backend/resources/climit.edn` sets a timeout on every limiter, 60s for operations that can be slow and 30s otherwise, with a test that fails when an entry ships without one, and `init-key` logs a WARN for every entry without a timeout. The production file lives outside the repository: mirror it there, and remember that a parked thread only goes away with a restart or a deploy.
|
||||
- Deadlock shape between a command's own limits and the root ones (fixed): a command declaring its own limits held `process-image/by-profile` before `root/by-profile`, while a command with no limits of its own takes the roots first and then re-enters with the same keyed limits from inside its handler (`create-file-media-object-from-url`, `upload-photo`) and waits for them holding a root permit. Two calls of one profile then held the same two limiters inverted and parked for good: 69 threads at 0 CPU, an audit limiter with 0 acquisitions in 24h and 100% 429s, on an instance that never served a 204. Both commands declare their limits now, so the shape cannot happen any more: the root limits are taken last and every path takes the limiters in the same order.
|
||||
- `climit/wrap` takes the command's own limits before the root ones: the root limiters are appended to the sequence first and the last element of the sequence is the outermost one, so `root/global`/`root/by-profile` end up innermost. A request waiting for its own limits no longer holds the root permits of its profile, which is what kept a pile-up of heavy requests from blocking the other commands of that profile. Every acquisition goes through that wrapper, so every path takes the limiters in the same order: there is no way to enter the limiter from inside a handler, and a command that calls another command internally would take the limiters twice (the second call waits for a permit the first one holds and answers 429 when the timeout runs out). The key of a limit is a function of the params (a keyword is looked up in them, a function is applied to them, any other value is the key itself). Commands declare the by-profile limit before the global one, which makes it the outermost of the pair, and a test fails when the relation between all the limits the commands declare cannot be sorted: two commands that take the same limits in opposite order, or a ring of three, leave the paths of one profile holding them in opposite order.
|
||||
- The two commands that used to enter climit from inside their handler declare their limits now, so the whole command runs inside them: the download of an external image takes a `process-image` permit, and that limit has one permit per profile, so a slow download holds the image processing of that profile for as long as it takes. It takes the global permit of that limit too (8 permits, queue 20), so eight slow downloads of eight different profiles leave the image processing of every profile waiting. The copy of the body has no timeout (the 30 s of the request covers the connection and the headers), so a server that stops sending keeps the permits until the operating system gives up. The storage write of the profile photo runs inside its limit too.
|
||||
- The shipped `backend/resources/climit.edn` sets a timeout and a queue on every limiter, 60s for operations that can be slow and 30s otherwise, with a test that fails when an entry ships without one, and `init-key` logs a WARN for every entry without a timeout. The queue matters because the root limits no longer bound the waiters: a command takes its own limits first, so a pile-up waits in the queue of that limit. The file and the declarations have to list the same ids: a `::climit/id` without an entry makes `wrap` log `no config found for specified queue` and run the handler with no limiter at all, and a test fails when a command declares an id the file does not list. Only key a limit by profile when the work contends on something shared (`update-file`, images, fonts, thumbnails, the profile row): a global cap on a cheap command adds a rejection path nobody needs, which is why `profile-plugin-ops` and `submit-audit-events` declare only `by-profile`. The production file lives outside the repository: mirror it there, and remember that a parked thread only goes away with a restart or a deploy.
|
||||
|
||||
## DB helpers
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
- Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line)
|
||||
- Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace layout, `scripts/sync-pnpm-version` flow, the stamp-missing-field and ignored-builds gotchas, verification steps)
|
||||
- Before a Node version update → `mem:workflow/updating-node` (the six pin sites in `.nvmrc`, the devenv and image Dockerfiles and the plugins-deploy workflows, the DHI tag and digest rules, what to verify)
|
||||
- Before bumping a non-JS dependency pinned in `docker/images/` (JDK, ImageMagick) → `mem:workflow/docker-images`
|
||||
- **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked.
|
||||
- **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via the `update-changelog` skill flow or on explicit user request.
|
||||
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
|
||||
@ -25,6 +26,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
- Align `let` binding values: when a `let` form has multiple bindings spanning several lines, align the value forms to the same column with spaces.
|
||||
- If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files, fix them with `scripts/paren-repair` BEFORE running lint/format checks. See `mem:scripts/paren-repair` for usage.
|
||||
- Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra.
|
||||
- **Never wait for background work with a sleep.** A long command moved to the background reports its own completion: do not poll it with `sleep 150` (or any other wait) inside a foreground command, and do not re-read its output file to check whether it finished. Keep working on something else, or end the response and wait for the notification.
|
||||
|
||||
# Project modules
|
||||
|
||||
@ -61,7 +63,7 @@ The memory is structured in a way that you can get the critical information abou
|
||||
- `scripts/error-reports.mjs` — Query error reports via RPC API with token authentication. Supports list/get operations with filtering and pagination. See `mem:scripts/error-reports`.
|
||||
- `scripts/clean-node-modules` — Remove stale `node_modules` from all pnpm workspaces (root, modules, member packages). Keeps the shared pnpm store at `<repo>/.pnpm-store` unless `--store`; ignores `external/` and `.opencode/`. Usage and reinstall steps: `mem:workflow/updating-pnpm`.
|
||||
- `scripts/setup-opencode` — Install the opencode CLI in-container as a global pnpm package in `$PNPM_HOME`. Not part of the devenv image; see `mem:devenv/core`.
|
||||
- `scripts/ci` — CI orchestration script: runs lint, tests, and format checks per module (`frontend backend common render-wasm exporter mcp plugins library`). Logs go to `.ci-logs/`; read the log file on failure. It is the preferred way to run the backend tests, and it exports `PENPOT_FLAGS` with `enable-backend-asserts`, which is what makes `clojure.core/assert` exist in the compiled test code. See `mem:scripts/ci`.
|
||||
- `scripts/ci` — CI orchestration script: runs lint, tests and format checks per module, plus the production build on request (`--build`). Modules: `frontend backend common render-wasm exporter mcp plugins library media-processor`. Logs go to `.ci-logs/`; read the log file on failure. It is the preferred way to run the backend tests, and it exports `PENPOT_FLAGS` with `enable-backend-asserts`, which is what makes `clojure.core/assert` exist in the compiled test code. See `mem:scripts/ci`.
|
||||
|
||||
# Dependency graph
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ No manual line wrapping: one line per bullet or paragraph, however long. Memorie
|
||||
## Add/update threshold
|
||||
|
||||
Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future.
|
||||
Do not restate what the code already says (comments included): keep only what code cannot represent — decisions and their reasons, cross-file invariants, ordering constraints, environment and tool behavior, and traps that break builds.
|
||||
Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon.
|
||||
|
||||
## Maintenance Actions
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
# CI (scripts/ci)
|
||||
|
||||
`scripts/ci` runs CI-style checks — lint, tests, format — for one or more
|
||||
monorepo modules and prints a per-task summary. It is the local equivalent
|
||||
of CI; use it to verify changes before declaring work done.
|
||||
`scripts/ci` runs CI-style checks — lint, tests, format — and, on request,
|
||||
the production build of each module, for one or more monorepo modules, and
|
||||
prints a per-task summary. It is the local equivalent of CI; use it to
|
||||
verify changes before declaring work done.
|
||||
|
||||
It also exports `PENPOT_FLAGS` with `enable-backend-asserts` appended (keeping
|
||||
whatever the caller already set), which is what puts `clojure.core/assert`
|
||||
@ -25,6 +26,7 @@ Run from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/ci MODULE... # lint + test + fmt per module
|
||||
./scripts/ci --build MODULE # production build only
|
||||
./scripts/ci --all --no-test # lint + fmt on all modules
|
||||
./scripts/ci --lint frontend # lint only
|
||||
./scripts/ci --fix --no-test frontend # format files, skip tests
|
||||
@ -32,14 +34,15 @@ Run from the repo root:
|
||||
./scripts/ci --dry-run --all # preview what would run
|
||||
```
|
||||
|
||||
Modules: `frontend backend common render-wasm exporter mcp plugins library`.
|
||||
Modules: `frontend backend common render-wasm exporter mcp plugins library
|
||||
media-processor`.
|
||||
|
||||
Flags:
|
||||
|
||||
- Default tasks: `lint`, `test`, `fmt` (format check; `--fix` formats
|
||||
instead).
|
||||
- `--lint` / `--test` / `--fmt` run one task only; `--no-lint` /
|
||||
`--no-test` / `--no-fmt` drop one task from the default set.
|
||||
instead). Build is **not** in the default set.
|
||||
- `--lint` / `--test` / `--fmt` / `--build` run one task only; `--no-lint` /
|
||||
`--no-test` / `--no-fmt` / `--no-build` drop one task from the default set.
|
||||
- `--paren-repair` runs only the delimiter repair — it wraps
|
||||
`scripts/paren-repair` over each module's Clojure/CLJS sources; see
|
||||
`mem:scripts/paren-repair`.
|
||||
@ -48,6 +51,49 @@ Flags:
|
||||
output; `--dry-run` prints commands without running; `--clean` removes
|
||||
the log directory.
|
||||
|
||||
## Build task
|
||||
|
||||
`--build` compiles each module the way production does, reusing caches
|
||||
(`.shadow-cljs`, `target/`, `node_modules`) instead of clearing them. It
|
||||
does not package bundles: `manage.sh build-bundle` remains the way to
|
||||
produce deployable artifacts.
|
||||
|
||||
| Module | Command | Output |
|
||||
|---|---|---|
|
||||
| `frontend` | `NODE_ENV=production pnpm run build:app:main && pnpm run build:app:libs && pnpm run build:app:assets` | `frontend/resources/public/`, `frontend/target/` |
|
||||
| `backend` | `clojure -T:build jar` | `backend/target/penpot.jar` |
|
||||
| `common` | *(none; skipped)* | — |
|
||||
| `render-wasm` | `./build` (targets `frontend` and `export`) | `render-wasm/target/` |
|
||||
| `exporter` | `NODE_ENV=production pnpm run build:app` | `exporter/target/` |
|
||||
| `mcp` | `pnpm run build` | `mcp/*/dist` |
|
||||
| `plugins` | `pnpm run build:runtime && pnpm run build:plugins && pnpm run build:styles-example && pnpm run build:doc` | `plugins/dist/` |
|
||||
| `library` | `NODE_ENV=production clojure -M:dev:shadow-cljs release library` | `library/target/library/` |
|
||||
| `media-processor` | `pnpm run types:check && pnpm run build` | `media-processor/dist/` |
|
||||
|
||||
- Every output path above is gitignored, so a build leaves `git status`
|
||||
clean.
|
||||
- The module `build` scripts in each `scripts/build` are **not** used: they
|
||||
delete `node_modules`, wipe `resources/public` and run `pnpm install`,
|
||||
which belongs to the bundle pipeline, not to a local check.
|
||||
- `frontend` and `library` have a `build` script that clears the shadow-cljs
|
||||
cache first; the task calls the inner commands to stay incremental.
|
||||
`exporter` uses `build:app` for the same reason.
|
||||
- `common` has no artifact of its own: its compilation is exercised by its
|
||||
`test` task and by the builds of `frontend`, `backend` and `exporter`.
|
||||
- `media-processor` is the ninth module. It has no lint, so that task shows
|
||||
as skipped; its build runs `tsc --noEmit` before esbuild because esbuild
|
||||
does not type-check.
|
||||
- Cold-cache cost measured on the devenv: `library` ~120 s, `frontend` ~120 s,
|
||||
`exporter` ~80 s, `backend` ~35 s. With warm caches the whole set (8
|
||||
builds) takes ~4 min. The first run of `clojure -T:build jar` downloads
|
||||
`tools.build` if it is not in `~/.m2`.
|
||||
- `pnpm run` may trigger a dependency check/install when `node_modules`
|
||||
drifts from the lockfile (`verify-deps-before-run`), so the first run
|
||||
after a dependency change can need network.
|
||||
- `render-wasm/build` takes a lock (`_build_lock`): never run two builds at
|
||||
once in the same checkout.
|
||||
|
||||
|
||||
## Logs and exit codes
|
||||
|
||||
- Full output of every task: `.ci-logs/<module>-<task>.log`.
|
||||
@ -59,12 +105,16 @@ Flags:
|
||||
|
||||
## Notes
|
||||
|
||||
- `mcp` has no lint task (shows as skipped). `render-wasm` uses `./lint`,
|
||||
`./test`, and `cargo fmt`.
|
||||
- `mcp` has no lint task and `media-processor` has no lint task; both show
|
||||
as skipped. `render-wasm` uses `./lint`, `./test`, and `cargo fmt`.
|
||||
- `scripts/test_ci.py` (unittest, `python3 scripts/test_ci.py`) covers the
|
||||
flag wiring, one build command per module and the help text through
|
||||
`--dry-run`; it executes no real build.
|
||||
- Exports `PENPOT_FLAGS` with `enable-backend-asserts` appended to any
|
||||
existing value, so backend test runs compile in and exercise
|
||||
precondition asserts; see `mem:backend/testing`.
|
||||
- Test tasks are long-running (backend: `clojure -M:dev:test`); use a
|
||||
generous timeout when calling it from an agent shell.
|
||||
- Test and build tasks are long-running (backend: `clojure -M:dev:test`;
|
||||
frontend build: ~2 min warm); use a generous timeout when calling it from
|
||||
an agent shell.
|
||||
- Skill entry point: `.agents/skills/local-ci/SKILL.md`.
|
||||
- Testing principles and output discipline: `mem:testing`.
|
||||
|
||||
6
.serena/memories/workflow/docker-images.md
Normal file
6
.serena/memories/workflow/docker-images.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Non-JS dependencies in the production images
|
||||
|
||||
Node and pnpm pins: `mem:workflow/updating-node` and `mem:workflow/updating-pnpm`. Only the parts the Dockerfiles cannot show:
|
||||
|
||||
- JDK: the image tracks a non-LTS line, so there is no minor to pick — the last build of the line is the ceiling; moving line is a major and needs a real build to validate the `jlink` module list.
|
||||
- ImageMagick: publish `penpotapp/imagemagick:<ver>` **before** bumping the `COPY --from` refs in the backend, exporter and devenv Dockerfiles; otherwise both builds break on the missing tag.
|
||||
@ -5,7 +5,7 @@ No single source of truth: nothing reads `.nvmrc` at build time, and the version
|
||||
## Pin sites
|
||||
|
||||
- `.nvmrc` → `v<X.Y.Z>`, with the leading `v`.
|
||||
- `docker/devenv/Dockerfile`, `setup-node` stage → `ENV NODE_VERSION=v<X.Y.Z>`; downloads `https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-{arm64,x64}.tar.gz`, one tarball per arch, and verifies each against a `NODE_SHA256` right below its `BINARY_URL`. Both hashes come from `SHASUMS256.txt` in that same dist directory, so a bump must recompute the two.
|
||||
- `docker/devenv/Dockerfile`, `setup-node` stage → `ENV NODE_VERSION=v<X.Y.Z>`; downloads `https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-{arm64,x64}.tar.gz`, one tarball per arch, and verifies each against a `NODE_SHA256` right below its `BINARY_URL`. Both hashes come from `SHASUMS256.txt` in that same dist directory, so a bump must recompute the two; only the arch SHAs change, the structure stays.
|
||||
- `docker/images/Dockerfile.mcp` → two stages: `FROM dhi.io/node:<X.Y.Z>-debian13-dev AS build` and `FROM dhi.io/node:<X.Y.Z>-debian13 AS image`. The release stage carries no `-dev`.
|
||||
- `docker/images/Dockerfile.media-processor` and `docker/images/Dockerfile.exporter` → single `FROM dhi.io/node:<X.Y.Z>-debian13-dev`.
|
||||
- `.github/workflows/plugins-deploy-api-doc.yml` and `.github/workflows/plugins-deploy-styles-doc.yml` → `runtime: node@<X.Y.Z>` in the `pnpm/setup` step. Its `working-directory: plugins` makes pnpm's own version come from the manifest, so this pin is the Node one; keep it equal to `.nvmrc` minus the `v`.
|
||||
@ -18,6 +18,7 @@ No single source of truth: nothing reads `.nvmrc` at build time, and the version
|
||||
- Node 24.x is the Krypton LTS line: take the newest patch of that major unless a major bump is the point.
|
||||
- Renovate also opens these bumps, on `renovate-*` branches and with its config living outside this repo; a manual bump and Renovate's must not diverge on any of the six sites.
|
||||
- A backport merge can deliver a newer Node into `docker/` and the plugins-deploy workflows while main's `.nvmrc` stays behind, because main receives partial backports of `.github/` and `docker/` from develop. Merging main into staging therefore conflicts on the pin lines: take main's set and finish the bump (`.nvmrc` plus the `packageManager` fields via `mem:workflow/updating-pnpm`), or the six sites drift.
|
||||
- The newer version wins in both directions. A maintenance→main merge (`main-2.18.x` into `main`) can carry the newer Node while `.nvmrc` and the `packageManager` fields stay on the older pair; then adopt the newer set and finish the bump on all six sites plus the 35 `packageManager` fields.
|
||||
- Both workflows must stay valid YAML. The `python3` in the devenv has no `yaml` module, so validate with `uv run --with pyyaml python3 -c ...` instead of a bare import.
|
||||
|
||||
## Verification
|
||||
|
||||
@ -27,8 +27,8 @@ file (never pipe tool output through filters).
|
||||
members) must carry a `packageManager` field with the identical
|
||||
`pnpm@<version>+sha512.<hash>` value. Do not let them drift.
|
||||
`external/` (vendored trees with their own lifecycles), `.opencode/`,
|
||||
`.pnpm-store/`, `bundles/` and `docker/images/bundle-*` copies are
|
||||
never stamped.
|
||||
`.pnpm-store/`, `bundles/`, `target/` build output and
|
||||
`docker/images/bundle-*` copies are never stamped.
|
||||
- pnpm is a system binary everywhere (devenv image, CI runners, Docker
|
||||
images). Nothing may call corepack: it is gone from Node 25+. pnpm
|
||||
auto-downloads the `packageManager` version on mismatch
|
||||
@ -71,6 +71,7 @@ file (never pipe tool output through filters).
|
||||
4. Run `scripts/sync-pnpm-version --install` (plain `pnpm install` in
|
||||
each of the 11 workspace roots) to refresh lockfile metadata, then
|
||||
fix any failing workspace (see gotchas) and re-run that directory.
|
||||
For a version-only bump, `pnpm install --lockfile-only` in each root is enough: it rewrites only the pnpm self-reference entries and needs no node_modules. The repo-root `pnpm-lock.yaml` is rewritten by itself on the first `pnpm` call that self-installs the target version.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@ -96,6 +97,9 @@ file (never pipe tool output through filters).
|
||||
section in lockfiles last written by older pnpm. Large diffs mean
|
||||
re-resolution; inspect them before accepting.
|
||||
- A backport merge of main into staging can bring a newer `PNPM_VERSION` in `docker/` while every `packageManager` field stays behind, since main receives partial `.github/`+`docker/` backports from develop. Resolve those conflicts with main's version and run `scripts/sync-pnpm-version <version>` in the same merge.
|
||||
- Direction does not decide the winner, the version does. A maintenance→main merge (`main-2.18.x` into `main`) can carry the newer Node and pnpm pins in `docker/` and the `plugins-deploy-*` workflows while main's `.nvmrc` and 35 `packageManager` fields stay on the older pair. Take the newer pair on every pin site (`docker/`, workflows, `.nvmrc`, `packageManager`) and run `scripts/sync-pnpm-version <version>` in the same merge; check with `--check` plus `pnpm install --lockfile-only --frozen-lockfile` per workspace.
|
||||
- Those merges conflict on the pnpm self-reference block in all 11 lockfiles (importers `packageManagerDependencies`, `packages/@pnpm/exe.*@<ver>`, `pnpm@<ver>` and the matching `snapshots` entries). Resolve each workspace's lockfile keeping its own importer/package resolution and swapping only that block to the newer version. Do not start from the older side and regenerate with `pnpm install --lockfile-only`: pnpm re-resolves every changed range upward (this merge took root `@playwright/test` from 1.63.0 to 1.64.1 and `@modelcontextprotocol/server` from 2.3.1 to 2.4.0), which pollutes the merge with unrelated bumps. If a workspace later needs a real re-resolution after a manifest bump, `pnpm install --lockfile-only --fix-lockfile` accepts the hand-merged file and only fills what the manifests demand.
|
||||
- `pnpm install` cannot run in `frontend` or `exporter` when `github.com` does not resolve: the `@penpot/svgo` git dependency triggers a `git fetch`. Frontend's lockfile entry carries no integrity, so pnpm always refetches; exporter's has one and still works. For frontend, resolve the self-reference hunks only.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@ -155,6 +155,6 @@ precision while maintaining a strong focus on maintainability and performance.
|
||||
- `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend).
|
||||
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
|
||||
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
|
||||
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `mem:scripts/ci`.
|
||||
- `scripts/ci` — CI orchestration script for running lint, tests, format checks and, on request (`--build`), production builds across modules. See `mem:scripts/ci`.
|
||||
- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`.
|
||||
|
||||
|
||||
@ -6,72 +6,74 @@
|
||||
;; 60s for operations that can be slow (file updates, images, fonts, thumbnails,
|
||||
;; snapshots, imports) and 30s for the rest.
|
||||
;; Note: a queue bounds how many waiters pile up, a timeout bounds how long one
|
||||
;; waiter waits. Every entry sets a timeout; the by-profile ones also set a
|
||||
;; queue, which is where a pile-up hurts.
|
||||
;; waiter waits. Every entry sets a timeout and a queue: a command takes the
|
||||
;; limits it declares before the root ones, so the queue of a limit is where
|
||||
;; its own pile-up waits. The root limits bound what runs at the same time,
|
||||
;; not what waits for it.
|
||||
;; The production file lives outside this repository: mirror the shape there,
|
||||
;; not these numbers. It has to list every id a command declares: an id without
|
||||
;; an entry runs with no limiter at all.
|
||||
{:update-file/global
|
||||
{:permits 20 :timeout 60000}
|
||||
{:permits 20 :queue 50 :timeout 60000}
|
||||
:update-file/by-profile
|
||||
{:permits 1 :queue 5 :timeout 60000}
|
||||
|
||||
:process-font/global
|
||||
{:permits 4 :timeout 60000}
|
||||
{:permits 4 :queue 10 :timeout 60000}
|
||||
:process-font/by-profile
|
||||
{:permits 1 :timeout 60000}
|
||||
{:permits 1 :queue 5 :timeout 60000}
|
||||
|
||||
:process-image/global
|
||||
{:permits 8 :timeout 60000}
|
||||
{:permits 8 :queue 20 :timeout 60000}
|
||||
:process-image/by-profile
|
||||
{:permits 1 :timeout 60000}
|
||||
{:permits 1 :queue 5 :timeout 60000}
|
||||
|
||||
:auth/global
|
||||
{:permits 8 :timeout 30000}
|
||||
{:permits 8 :queue 20 :timeout 30000}
|
||||
|
||||
:root/global
|
||||
{:permits 40 :timeout 30000}
|
||||
{:permits 40 :queue 100 :timeout 30000}
|
||||
|
||||
:root/by-profile
|
||||
{:permits 10 :queue 30 :timeout 30000}
|
||||
|
||||
:file-thumbnail-ops/global
|
||||
{:permits 20 :timeout 60000}
|
||||
{:permits 20 :queue 50 :timeout 60000}
|
||||
:file-thumbnail-ops/by-profile
|
||||
{:permits 2 :timeout 60000}
|
||||
{:permits 2 :queue 10 :timeout 60000}
|
||||
|
||||
:submit-audit-events/by-profile
|
||||
{:permits 1 :queue 3 :timeout 30000}
|
||||
|
||||
:restore-file-snapshot/global
|
||||
{:permits 3 :timeout 60000}
|
||||
{:permits 3 :queue 5 :timeout 60000}
|
||||
|
||||
:restore-file-snapshot/by-profile
|
||||
{:permits 1 :queue 2 :timeout 60000}
|
||||
|
||||
:create-file-snapshot/global
|
||||
{:permits 3 :timeout 60000}
|
||||
{:permits 3 :queue 5 :timeout 60000}
|
||||
|
||||
:create-file-snapshot/by-profile
|
||||
{:permits 1 :queue 2 :timeout 60000}
|
||||
|
||||
:send-user-feedback/global
|
||||
{:permits 4 :timeout 30000}
|
||||
{:permits 4 :queue 10 :timeout 30000}
|
||||
|
||||
:send-user-feedback/by-profile
|
||||
{:permits 1 :queue 3 :timeout 30000}
|
||||
|
||||
:import-binfile/global
|
||||
{:permits 4 :timeout 60000}
|
||||
{:permits 4 :queue 10 :timeout 60000}
|
||||
|
||||
:import-binfile/by-profile
|
||||
{:permits 1 :queue 2 :timeout 60000}
|
||||
|
||||
:create-import-binfile-job/global
|
||||
{:permits 4 :timeout 60000}
|
||||
{:permits 4 :queue 10 :timeout 60000}
|
||||
|
||||
:create-import-binfile-job/by-profile
|
||||
{:permits 1 :queue 2 :timeout 60000}
|
||||
|
||||
:profile-plugin-ops/global
|
||||
{:permits 4 :timeout 30000}
|
||||
|
||||
:profile-plugin-ops/by-profile
|
||||
{:permits 1 :queue 2 :timeout 30000}}
|
||||
|
||||
@ -5,7 +5,16 @@
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.rpc.climit
|
||||
"Concurrencly limiter for RPC."
|
||||
"Concurrencly limiter for RPC.
|
||||
|
||||
A command declares its limits in its `::climit/id` metadata and takes them
|
||||
before the root ones, so a request waiting for its own limits does not hold
|
||||
the root permits of its profile.
|
||||
|
||||
Every acquisition goes through this wrapper, so every path takes the
|
||||
limiters in the same order. A command that calls another command from
|
||||
inside its handler would take them twice: the second call waits for a
|
||||
permit the first one holds, and answers 429 when the timeout runs out."
|
||||
(:refer-clojure :exclude [run!])
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
@ -221,16 +230,6 @@
|
||||
|
||||
(def ^:private idseq (AtomicLong. 0))
|
||||
|
||||
(def ^:private ^ThreadLocal held-limiters
|
||||
;; Limiters a thread is inside of, identified by the limiter instance, which
|
||||
;; already encodes the limit id and its key: the cache is per id+key pair.
|
||||
;; `pbh/invoke!` runs the handler on the calling thread, so a handler that
|
||||
;; enters climit again for a limit it holds finds it here. Without this a
|
||||
;; thread parks forever behind a permit it holds itself.
|
||||
(ThreadLocal/withInitial
|
||||
(reify java.util.function.Supplier
|
||||
(get [_] (java.util.HashSet.)))))
|
||||
|
||||
(defn- impl-invoke
|
||||
[metrics counters cache config handler]
|
||||
(let [limit-id (::id config)
|
||||
@ -249,69 +248,57 @@
|
||||
:permits (or (:permits config) (:concurrency config) 1)
|
||||
:queue-max (or (:queue config) (:queue-size config))}
|
||||
limiter (cache/get cache limit-id (partial create-limiter config))
|
||||
held ^java.util.Set (.get ^ThreadLocal held-limiters)]
|
||||
tpoint (ct/tpoint)
|
||||
req-id (.incrementAndGet ^AtomicLong idseq)
|
||||
waiting (volatile! true)
|
||||
stop-waiting (fn []
|
||||
;; Exactly one decrement per increment: the flag
|
||||
;; makes a second call a no-op and the `finally`
|
||||
;; guarantees that there is one.
|
||||
(when @waiting
|
||||
(vreset! waiting false)
|
||||
(waiting-dec metrics counters limit-name)))]
|
||||
|
||||
(if (.contains held limiter)
|
||||
;; The calling thread already holds this limiter for this key: run the
|
||||
;; handler inline instead of queueing behind itself.
|
||||
(do
|
||||
(l/trc :hint "reentrant invocation" :id limit-id :label limit-label)
|
||||
(handler))
|
||||
(try
|
||||
;; The increment lives inside the `try`, so a throwable from the
|
||||
;; publishing path unwinds through the `finally`: the count comes back
|
||||
;; down instead of staying up for good.
|
||||
(waiting-inc metrics counters limit-name)
|
||||
|
||||
(let [tpoint (ct/tpoint)
|
||||
req-id (.incrementAndGet ^AtomicLong idseq)
|
||||
waiting (volatile! true)
|
||||
stop-waiting (fn []
|
||||
;; Exactly one decrement per increment: the flag
|
||||
;; makes a second call a no-op and the `finally`
|
||||
;; guarantees that there is one.
|
||||
(when @waiting
|
||||
(vreset! waiting false)
|
||||
(waiting-dec metrics counters limit-name)))]
|
||||
(let [stats (pbh/get-stats limiter)]
|
||||
(log limit "enqueued" req-id stats nil nil nil))
|
||||
|
||||
(.add held limiter)
|
||||
(pbh/invoke! limiter (fn []
|
||||
(stop-waiting)
|
||||
(let [elapsed (tpoint)
|
||||
stats (pbh/get-stats limiter)]
|
||||
(measure metrics mlabels elapsed)
|
||||
(log limit "acquired" req-id stats elapsed nil nil)
|
||||
(handler))))
|
||||
|
||||
(try
|
||||
;; The increment lives inside the `try`, so a throwable from the
|
||||
;; publishing path unwinds through the `finally`: the thread drops the
|
||||
;; mark and the count comes back down instead of staying up for good.
|
||||
(waiting-inc metrics counters limit-name)
|
||||
|
||||
(let [stats (pbh/get-stats limiter)]
|
||||
(log limit "enqueued" req-id stats nil nil nil))
|
||||
|
||||
(pbh/invoke! limiter (fn []
|
||||
(stop-waiting)
|
||||
(let [elapsed (tpoint)
|
||||
stats (pbh/get-stats limiter)]
|
||||
(measure metrics mlabels elapsed)
|
||||
(log limit "acquired" req-id stats elapsed nil nil)
|
||||
(handler))))
|
||||
|
||||
(catch ExceptionInfo cause
|
||||
(let [{:keys [type code] :as data} (ex-data cause)]
|
||||
(if (= :bulkhead-error type)
|
||||
(let [elapsed (tpoint)
|
||||
stats (pbh/get-stats limiter)
|
||||
reason (if (some? (:timeout data)) "timeout" "queue-full")]
|
||||
(mtx/run! metrics
|
||||
:id :rpc-climit-rejected
|
||||
:inc 1
|
||||
:labels (into-array String [limit-name reason]))
|
||||
(log limit "rejected" req-id stats elapsed code reason)
|
||||
(ex/raise :type :concurrency-limit
|
||||
:code code
|
||||
:hint "concurrency limit reached"
|
||||
:cause cause))
|
||||
(throw cause))))
|
||||
|
||||
(finally
|
||||
(.remove held limiter)
|
||||
(stop-waiting)
|
||||
(catch ExceptionInfo cause
|
||||
(let [{:keys [type code] :as data} (ex-data cause)]
|
||||
(if (= :bulkhead-error type)
|
||||
(let [elapsed (tpoint)
|
||||
stats (pbh/get-stats limiter)]
|
||||
stats (pbh/get-stats limiter)
|
||||
reason (if (some? (:timeout data)) "timeout" "queue-full")]
|
||||
(mtx/run! metrics
|
||||
:id :rpc-climit-rejected
|
||||
:inc 1
|
||||
:labels (into-array String [limit-name reason]))
|
||||
(log limit "rejected" req-id stats elapsed code reason)
|
||||
(ex/raise :type :concurrency-limit
|
||||
:code code
|
||||
:hint "concurrency limit reached"
|
||||
:cause cause))
|
||||
(throw cause))))
|
||||
|
||||
(log limit "finished" req-id stats elapsed nil nil))))))))
|
||||
(finally
|
||||
(stop-waiting)
|
||||
(let [elapsed (tpoint)
|
||||
stats (pbh/get-stats limiter)]
|
||||
|
||||
(log limit "finished" req-id stats elapsed nil nil))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; MIDDLEWARE
|
||||
@ -340,6 +327,16 @@
|
||||
:else
|
||||
(throw (IllegalArgumentException. "unable to normalize limit")))))
|
||||
|
||||
(defn- resolve-limit-key
|
||||
"The key of a limit, resolved the same way in every path: a keyword is
|
||||
looked up in the params, a function is applied to them, any other value is
|
||||
the key itself."
|
||||
[key params]
|
||||
(cond
|
||||
(keyword? key) (get params key)
|
||||
(fn? key) (key params)
|
||||
:else key))
|
||||
|
||||
(defn wrap
|
||||
[cfg handler {label ::sv/name :as mdata}]
|
||||
(if-let [climit (::rpc/climit cfg)]
|
||||
@ -363,7 +360,7 @@
|
||||
|
||||
(fn [cfg params]
|
||||
(let [config (-> config
|
||||
(assoc ::key (key-fn params))
|
||||
(assoc ::key (resolve-limit-key key-fn params))
|
||||
(assoc ::label label)
|
||||
;; NOTE: only used for debugging output
|
||||
(assoc ::params params))]
|
||||
@ -373,40 +370,11 @@
|
||||
(l/wrn :hint "no config found for specified queue" :id (id->str limit-id))
|
||||
handler)))
|
||||
handler
|
||||
;; The root limiters go last so they end up outermost: every path
|
||||
;; meets them before the command's own limits. The nested
|
||||
;; `climit/invoke!` chains carry only the command's own limits and
|
||||
;; run inside those of the enclosing call, so this puts both paths
|
||||
;; in the same order. With the opposite order two calls of the same
|
||||
;; profile hold the same limiters inverted and park for good.
|
||||
(concat (get-limits mdata) global-limits))
|
||||
;; The root limiters go first so they end up innermost: a command
|
||||
;; takes the limits it declares before the root ones. Every path
|
||||
;; meets the limiters in the same order, because every acquisition
|
||||
;; goes through this wrapper.
|
||||
(concat global-limits (get-limits mdata)))
|
||||
|
||||
handler))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PUBLIC API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- build-exec-chain
|
||||
[{:keys [::label ::rpc/climit] :as cfg} f]
|
||||
(reduce (fn [handler [limit-id limit-key]]
|
||||
(if-let [config (get-config climit limit-id)]
|
||||
(let [config (-> config
|
||||
(assoc ::key limit-key)
|
||||
(assoc ::label label))]
|
||||
(fn [cfg params]
|
||||
(let [config (assoc config ::params params)]
|
||||
(invoke climit config (partial handler cfg params)))))
|
||||
(do
|
||||
(l/wrn :hint "config not found" :label label :id limit-id)
|
||||
f)))
|
||||
f
|
||||
(get-limits cfg)))
|
||||
|
||||
(defn invoke!
|
||||
"Run a function in context of climit."
|
||||
[{:keys [::rpc/climit] :as cfg} f params]
|
||||
(let [f (if climit
|
||||
(build-exec-chain cfg f)
|
||||
f)]
|
||||
(f cfg params)))
|
||||
|
||||
@ -239,6 +239,8 @@
|
||||
(sv/defmethod ::create-file-media-object-from-url
|
||||
{::doc/added "1.17"
|
||||
::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]]
|
||||
::climit/id [[:process-image/by-profile ::rpc/profile-id]
|
||||
[:process-image/global]]
|
||||
::sm/params schema:create-file-media-object-from-url}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(files/check-edition-permissions! pool profile-id file-id)
|
||||
@ -262,16 +264,7 @@
|
||||
(assoc :from-url? true)
|
||||
(assoc :content content)
|
||||
(assoc :name (d/nilv name "unknown")))]
|
||||
|
||||
;; NOTE: we use the climit here in a dynamic invocation because we
|
||||
;; don't want saturate the process-image limit with IO (download
|
||||
;; of external image)
|
||||
|
||||
(-> cfg
|
||||
(assoc ::climit/id [[:process-image/by-profile (:profile-id params)]
|
||||
[:process-image/global]])
|
||||
(assoc ::climit/label "create-file-media-object-from-url")
|
||||
(climit/invoke! #(db/run! %1 create-file-media-object %2) params))))
|
||||
(db/run! cfg create-file-media-object params)))
|
||||
|
||||
|
||||
;; --- Clone File Media object (Upload and create from url)
|
||||
|
||||
@ -34,8 +34,7 @@
|
||||
|
||||
(sv/defmethod ::add-profile-plugin
|
||||
{::doc/added "2.18"
|
||||
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
|
||||
[:profile-plugin-ops/global]]
|
||||
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]]
|
||||
::sm/params schema:add-profile-plugin
|
||||
::sm/result ctp/schema:registry-entry
|
||||
::db/transaction true}
|
||||
@ -69,8 +68,7 @@
|
||||
|
||||
(sv/defmethod ::remove-profile-plugin
|
||||
{::doc/added "2.18"
|
||||
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
|
||||
[:profile-plugin-ops/global]]
|
||||
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]]
|
||||
::sm/params schema:remove-profile-plugin
|
||||
::sm/result :nil
|
||||
::db/transaction true}
|
||||
|
||||
@ -306,6 +306,8 @@
|
||||
|
||||
(sv/defmethod ::update-profile-photo
|
||||
{:doc/added "1.1"
|
||||
::climit/id [[:process-image/by-profile ::rpc/profile-id]
|
||||
[:process-image/global]]
|
||||
::sm/params schema:update-profile-photo
|
||||
::rpc/multipart-only-params [:file]
|
||||
::sm/result :nil}
|
||||
@ -355,13 +357,12 @@
|
||||
:content-type (:mtype thumb)}))
|
||||
|
||||
(defn upload-photo
|
||||
[{:keys [::sto/storage] :as cfg} {:keys [file] :as params}]
|
||||
(let [params (-> cfg
|
||||
(assoc ::climit/id [[:process-image/by-profile (:profile-id params)]
|
||||
[:process-image/global]])
|
||||
(assoc ::climit/label "upload-photo")
|
||||
(climit/invoke! generate-thumbnail file))]
|
||||
(sto/put-object! storage params)))
|
||||
"Generate the thumbnail of a photo and store it.
|
||||
|
||||
The caller declares the `process-image` limits: this helper runs inside
|
||||
them, so a command that calls it has to take them."
|
||||
[{:keys [::sto/storage] :as cfg} {:keys [file]}]
|
||||
(sto/put-object! storage (generate-thumbnail cfg file)))
|
||||
|
||||
;; --- MUTATION: Delete Photo
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
[app.msgbus :as mbus]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
@ -1094,6 +1095,8 @@
|
||||
|
||||
(sv/defmethod ::update-team-photo
|
||||
{::doc/added "1.17"
|
||||
::climit/id [[:process-image/by-profile ::rpc/profile-id]
|
||||
[:process-image/global]]
|
||||
::sm/params schema:update-team-photo
|
||||
::rpc/multipart-only-params [:file]}
|
||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||
|
||||
@ -38,20 +38,16 @@
|
||||
(mapcat :labels)
|
||||
(vec)))
|
||||
|
||||
(defn- saw-call?
|
||||
"Wait until a metrics call mentions `label`."
|
||||
[label timeout]
|
||||
(let [deadline (+ (System/currentTimeMillis) timeout)]
|
||||
(loop []
|
||||
(cond
|
||||
(some (fn [call] (some #{label} (:labels call))) @calls)
|
||||
true
|
||||
|
||||
(> (System/currentTimeMillis) deadline)
|
||||
false
|
||||
|
||||
:else
|
||||
(do (Thread/sleep 10) (recur))))))
|
||||
(defn- invoke!
|
||||
"Run `f` under the limits the cfg declares, the way a command does: the key
|
||||
of each limit comes from the params, and the root limiters come after the
|
||||
ones the cfg declares."
|
||||
[cfg f params]
|
||||
(let [wrapped (climit/wrap cfg
|
||||
f
|
||||
{::sv/name "test-command"
|
||||
::climit/id (::climit/id cfg)})]
|
||||
(wrapped cfg params)))
|
||||
|
||||
(defn- failed-type
|
||||
"Run `f` and return its value, or the type of the error it raised."
|
||||
@ -101,11 +97,10 @@
|
||||
;; ACQUISITION ORDER
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(t/deftest acquires-root-limits-before-the-command-own-limits
|
||||
;; A command declaring its own limits must meet the root limiters first.
|
||||
;; The nested `climit/invoke!` calls take only the command's own limits and
|
||||
;; run inside the root ones, so any other order lets two calls of the same
|
||||
;; profile hold the same limiters in opposite order and park for good.
|
||||
(t/deftest acquires-the-command-own-limits-before-the-root-limits
|
||||
;; A command declaring its own limits must meet them before the root
|
||||
;; limiters. No command enters climit from inside its handler any more, so
|
||||
;; every path takes the limiters in the same order.
|
||||
(record-metrics
|
||||
(fn []
|
||||
(th/with-climit [climit test-config]
|
||||
@ -113,124 +108,57 @@
|
||||
wrapped (climit/wrap {::rpc/climit climit} (fn [_ _] :ok) mdata)
|
||||
result (wrapped {::rpc/climit climit} {::rpc/profile-id (uuid/next)})]
|
||||
(t/is (= :ok result))
|
||||
(t/is (= ["root/by-profile"
|
||||
"root/global"
|
||||
"process-image/by-profile"
|
||||
"process-image/global"]
|
||||
(t/is (= ["process-image/by-profile"
|
||||
"process-image/global"
|
||||
"root/by-profile"
|
||||
"root/global"]
|
||||
(acquired-in-order))))))))
|
||||
|
||||
(t/deftest nested-limits-do-not-deadlock-with-the-command-own-limits
|
||||
;; Two paths over one profile: a command declaring its own limits (like
|
||||
;; `upload-file-media-object`) and a command that takes the root limiters and
|
||||
;; then asks for the same limits from inside its handler (like
|
||||
;; `create-file-media-object-from-url`). Each path holds what the other one
|
||||
;; waits for the moment the order differs, and nothing releases.
|
||||
(t/deftest the-key-of-a-limit-comes-from-the-params
|
||||
;; The key of a limit is a function of the params: a keyword is looked up in
|
||||
;; them, and that is what tells one profile from another. Two calls with the
|
||||
;; same key share the limiter, and two calls with a different key do not.
|
||||
(th/with-climit [climit {:test-key/by-profile {:permits 1 :timeout 2000}}]
|
||||
(let [pid (uuid/next)
|
||||
limits [[:test-key/by-profile ::rpc/profile-id]]
|
||||
cfg {::rpc/climit climit
|
||||
::climit/id limits}
|
||||
gate (promise)
|
||||
entered (promise)
|
||||
wrapped (climit/wrap cfg
|
||||
(fn [_ params]
|
||||
;; only the call of the first profile waits
|
||||
(when (= pid (::rpc/profile-id params))
|
||||
(deliver entered true)
|
||||
@gate)
|
||||
:ok)
|
||||
{::sv/name "test-command" ::climit/id limits})
|
||||
holder (future (wrapped cfg {::rpc/profile-id pid}))]
|
||||
|
||||
;; The call of the same profile waits for the permit this thread holds.
|
||||
(t/is (true? (deref entered 5000 false)))
|
||||
(t/is (= :timeout (deref (future (wrapped cfg {::rpc/profile-id pid})) 300 :timeout)))
|
||||
|
||||
;; The call of another profile takes the permit of its own key.
|
||||
(t/is (= :ok (deref (future (wrapped cfg {::rpc/profile-id (uuid/next)})) 5000 :timeout)))
|
||||
|
||||
(deliver gate true)
|
||||
(t/is (= :ok (deref holder 5000 :timeout))))))
|
||||
|
||||
(t/deftest a-command-without-limits-takes-the-root-limits
|
||||
;; A command that declares no limits of its own still takes the root ones,
|
||||
;; and in the same order as the ones of a command that declares them.
|
||||
(record-metrics
|
||||
(fn []
|
||||
(th/with-climit [climit test-config]
|
||||
(let [pid (uuid/next)
|
||||
gate (promise)
|
||||
entered (promise)
|
||||
(let [mdata {::sv/name "test-command"}
|
||||
wrapped (climit/wrap {::rpc/climit climit} (fn [_ _] :ok) mdata)
|
||||
result (wrapped {::rpc/climit climit} {::rpc/profile-id (uuid/next)})]
|
||||
(t/is (= :ok result))
|
||||
(t/is (= ["root/by-profile"
|
||||
"root/global"]
|
||||
(acquired-in-order))))))))
|
||||
|
||||
handler-b (fn [cfg params]
|
||||
(deliver entered true)
|
||||
@gate
|
||||
(-> cfg
|
||||
(assoc ::climit/id [[:process-image/by-profile pid]
|
||||
[:process-image/global]])
|
||||
(assoc ::climit/label "path-b")
|
||||
(climit/invoke! (fn [_ _] :nested-ok) params)))
|
||||
|
||||
wrapped-a (climit/wrap {::rpc/climit climit}
|
||||
(fn [_ _] :ok)
|
||||
{::sv/name "path-a" ::climit/id image-limits})
|
||||
|
||||
wrapped-b (climit/wrap {::rpc/climit climit}
|
||||
handler-b
|
||||
{::sv/name "path-b"})]
|
||||
|
||||
;; Path B takes the root limiters and waits inside its handler.
|
||||
(let [fb (future (failed-type #(wrapped-b {::rpc/climit climit}
|
||||
{::rpc/profile-id pid})))]
|
||||
(t/is (true? (deref entered 5000 false)))
|
||||
|
||||
;; Path A, started now, takes its own limiters and then waits for the
|
||||
;; root ones, which B holds. The recorded metrics are how the test
|
||||
;; knows A got that far instead of guessing with a sleep.
|
||||
(reset! calls [])
|
||||
(let [fa (future (failed-type #(wrapped-a {::rpc/climit climit}
|
||||
{::rpc/profile-id pid})))]
|
||||
(t/is (true? (saw-call? "root/by-profile" 5000)))
|
||||
|
||||
;; Let B ask for the limits A holds: if the order differs, each path
|
||||
;; now waits for a permit the other one holds.
|
||||
(deliver gate true)
|
||||
|
||||
(t/is (= :ok (deref fa 15000 :timeout)))
|
||||
(t/is (= :nested-ok (deref fb 15000 :timeout))))))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; REENTRANCY
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(t/deftest reentrant-invoke-does-not-consume-a-second-permit
|
||||
;; A handler entering climit for a limit it already holds must not queue
|
||||
;; behind itself: it runs inline and takes no second permit. Another thread
|
||||
;; asking for the same limiter still waits, so the limit stays in place.
|
||||
(th/with-climit [climit {:process-image/by-profile
|
||||
{:permits 1 :timeout 2000}}]
|
||||
(let [pid (uuid/next)
|
||||
cfg {::rpc/climit climit
|
||||
::climit/id [[:process-image/by-profile pid]]}
|
||||
inner (promise)
|
||||
other (promise)
|
||||
handler (fn [cfg params]
|
||||
(deliver inner
|
||||
(failed-type #(climit/invoke! cfg (fn [_ _] :inner-ok) params)))
|
||||
(deliver other (future (climit/invoke! cfg (fn [_ _] :other-ok) params)))
|
||||
(t/is (= :waiting (deref @other 300 :waiting)))
|
||||
:outer-ok)
|
||||
|
||||
outcome (failed-type #(climit/invoke! cfg handler {}))]
|
||||
|
||||
;; With no reentrancy handling the inner call waits for the permit this very
|
||||
;; thread holds and ends failing by timeout.
|
||||
(t/is (= :inner-ok (deref inner 5000 :timeout)))
|
||||
(t/is (= :outer-ok outcome))
|
||||
|
||||
;; once the outer call releases, the other thread gets its permit
|
||||
(t/is (= :other-ok (deref @other 5000 :timeout))))))
|
||||
|
||||
(t/deftest reentrant-tracking-is-cleaned-up-when-the-handler-fails
|
||||
;; If the mark is not removed, the thread keeps believing it holds the
|
||||
;; limiter and every later call of that key would skip the limit.
|
||||
(th/with-climit [climit {:process-image/by-profile
|
||||
{:permits 1 :timeout 1000}}]
|
||||
(let [pid (uuid/next)
|
||||
cfg {::rpc/climit climit
|
||||
::climit/id [[:process-image/by-profile pid]]}
|
||||
holding (promise)
|
||||
gate (promise)]
|
||||
|
||||
;; The failing call runs on this thread, so this thread is the one that
|
||||
;; must forget it held the limiter.
|
||||
(t/is (thrown? clojure.lang.ExceptionInfo
|
||||
(climit/invoke! cfg (fn [_ _] (throw (ex-info "boom" {}))) {})))
|
||||
|
||||
;; another thread takes the only permit
|
||||
(let [fb (future (climit/invoke! cfg (fn [_ _]
|
||||
(deliver holding true)
|
||||
@gate
|
||||
:held)
|
||||
{}))]
|
||||
(t/is (true? (deref holding 5000 false)))
|
||||
|
||||
;; this thread must wait for it, not skip the limiter
|
||||
(t/is (= :concurrency-limit
|
||||
(failed-type #(climit/invoke! cfg (fn [_ _] :inner-ok) {}))))
|
||||
|
||||
(deliver gate true)
|
||||
(t/is (= :held (deref fb 5000 :timeout)))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SHIPPED CONFIG
|
||||
@ -239,14 +167,19 @@
|
||||
(t/deftest example-config-bounds-every-wait
|
||||
;; A limiter without `:timeout` parks the calling thread forever, which is
|
||||
;; how a limit nobody releases turns into lost worker threads and days of
|
||||
;; silent 429s. Every entry of the shipped config must carry one.
|
||||
;; silent 429s. Every entry of the shipped config must carry one. A limiter
|
||||
;; without `:queue` lets its waiters pile up without bound, and a command
|
||||
;; takes its own limits before the root ones, so the queue of a limit is
|
||||
;; where its own pile-up waits.
|
||||
(let [path (io/resource "climit.edn")]
|
||||
(t/is (some? path))
|
||||
(let [data (edn/read-string (slurp path))]
|
||||
(t/is (seq data))
|
||||
(doseq [[id config] data]
|
||||
(t/is (int? (:timeout config))
|
||||
(str id " must declare a timeout"))))))
|
||||
(str id " must declare a timeout"))
|
||||
(t/is (int? (:queue config))
|
||||
(str id " must declare a queue"))))))
|
||||
|
||||
(t/deftest warns-when-a-limiter-has-no-timeout
|
||||
;; The config that production loads lives outside the repository and is
|
||||
@ -321,17 +254,17 @@
|
||||
(let [name (limiter-name :test-waiting/by-profile)
|
||||
entered (promise)
|
||||
gate (promise)
|
||||
holder (future (climit/invoke! (limiter-cfg climit :test-waiting/by-profile "a")
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
holder (future (invoke! (limiter-cfg climit :test-waiting/by-profile "a")
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
|
||||
(t/is (true? (deref entered 5000 false)))
|
||||
|
||||
;; the holder is running, not waiting
|
||||
(reset! calls [])
|
||||
(let [waiter (future (climit/invoke! (limiter-cfg climit :test-waiting/by-profile "a")
|
||||
(fn [_ _] :waited)
|
||||
{}))]
|
||||
(let [waiter (future (invoke! (limiter-cfg climit :test-waiting/by-profile "a")
|
||||
(fn [_ _] :waited)
|
||||
{}))]
|
||||
(t/is (true? (wait-until #(= 1 (waiting-last name)) 5000)))
|
||||
(t/is (= 1 (waiting-count climit name)))
|
||||
|
||||
@ -350,30 +283,30 @@
|
||||
(fn []
|
||||
(th/with-climit [climit {:test-waiting-keys/by-profile {:permits 1 :timeout 3000}}]
|
||||
(let [name (limiter-name :test-waiting-keys/by-profile)
|
||||
gates {:a (promise) :b (promise)}
|
||||
gates {"a" (promise) "b" (promise)}
|
||||
holders
|
||||
(into {}
|
||||
(map (fn [k]
|
||||
(let [entered (promise)]
|
||||
[k {:entered entered
|
||||
:future (future (climit/invoke! (limiter-cfg climit :test-waiting-keys/by-profile k)
|
||||
(fn [_ _]
|
||||
(deliver entered true)
|
||||
@(gates k)
|
||||
:held)
|
||||
{}))}])))
|
||||
[:a :b])]
|
||||
:future (future (invoke! (limiter-cfg climit :test-waiting-keys/by-profile k)
|
||||
(fn [_ _]
|
||||
(deliver entered true)
|
||||
@(gates k)
|
||||
:held)
|
||||
{}))}])))
|
||||
["a" "b"])]
|
||||
|
||||
(doseq [k [:a :b]]
|
||||
(doseq [k ["a" "b"]]
|
||||
(t/is (true? (deref (:entered (holders k)) 5000 false))))
|
||||
|
||||
;; one call per key, waiting behind its own holder
|
||||
(let [waiters (into {}
|
||||
(map (fn [k]
|
||||
[k (future (climit/invoke! (limiter-cfg climit :test-waiting-keys/by-profile k)
|
||||
(fn [_ _] :waited)
|
||||
{}))]))
|
||||
[:a :b])]
|
||||
[k (future (invoke! (limiter-cfg climit :test-waiting-keys/by-profile k)
|
||||
(fn [_ _] :waited)
|
||||
{}))]))
|
||||
["a" "b"])]
|
||||
|
||||
(t/is (true? (wait-until #(= 2 (waiting-count climit name)) 5000)))
|
||||
;; the increment that reaches 2 always publishes 2, whichever waiter
|
||||
@ -381,9 +314,9 @@
|
||||
;; contract, the value is.
|
||||
(t/is (some #(= 2 %) (waiting-value-of name)))
|
||||
|
||||
(doseq [k [:a :b]] (deliver (gates k) true))
|
||||
(doseq [k ["a" "b"]] (deliver (gates k) true))
|
||||
|
||||
(doseq [k [:a :b]]
|
||||
(doseq [k ["a" "b"]]
|
||||
(t/is (= :waited (deref (waiters k) 5000 :timeout)))
|
||||
(t/is (= :held (deref (:future (holders k)) 5000 :timeout))))
|
||||
|
||||
@ -413,17 +346,17 @@
|
||||
cfg (limiter-cfg climit :test-rejected/by-profile "a")
|
||||
entered (promise)
|
||||
gate (promise)
|
||||
holder (future (climit/invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
holder (future (invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
|
||||
(t/is (true? (deref entered 5000 false)))
|
||||
(reset! calls [])
|
||||
|
||||
;; the queue holds one waiter, so the next call is rejected right away
|
||||
(let [queued (future (failed-type #(climit/invoke! cfg (fn [_ _] :queued) {})))]
|
||||
(let [queued (future (failed-type #(invoke! cfg (fn [_ _] :queued) {})))]
|
||||
(t/is (true? (wait-until #(= 1 (waiting-last name)) 5000)))
|
||||
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :extra) {}))))
|
||||
(t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :extra) {}))))
|
||||
(t/is (= [[name "queue-full"]] (rejections)))
|
||||
|
||||
(deliver gate true)
|
||||
@ -436,13 +369,13 @@
|
||||
cfg (limiter-cfg climit :test-timeout/by-profile "a")
|
||||
entered (promise)
|
||||
gate (promise)
|
||||
holder (future (climit/invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
holder (future (invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
|
||||
(t/is (true? (deref entered 5000 false)))
|
||||
(reset! calls [])
|
||||
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :late) {}))))
|
||||
(t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :late) {}))))
|
||||
(t/is (= [[name "timeout"]] (rejections)))
|
||||
|
||||
(deliver gate true)
|
||||
@ -458,14 +391,14 @@
|
||||
cfg (limiter-cfg climit :test-rejected-baseline/by-profile "a")
|
||||
entered (promise)
|
||||
gate (promise)
|
||||
holder (future (climit/invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
holder (future (invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
|
||||
(t/is (true? (deref entered 5000 false)))
|
||||
(reset! calls [])
|
||||
|
||||
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :extra) {}))))
|
||||
(t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :extra) {}))))
|
||||
(t/is (= 0 (waiting-count climit name)))
|
||||
(t/is (true? (wait-until #(= 0 (waiting-last name)) 5000)))
|
||||
|
||||
@ -482,7 +415,7 @@
|
||||
|
||||
(reset! calls [])
|
||||
(t/is (thrown? clojure.lang.ExceptionInfo
|
||||
(climit/invoke! cfg (fn [_ _] (throw (ex-info "boom" {}))) {})))
|
||||
(invoke! cfg (fn [_ _] (throw (ex-info "boom" {}))) {})))
|
||||
|
||||
(t/is (= 0 (waiting-count climit name)))
|
||||
(t/is (true? (wait-until #(= 0 (waiting-last name)) 5000))))))))
|
||||
@ -510,7 +443,7 @@
|
||||
(reset! calls [])
|
||||
(with-redefs [mtx/run! (failing-first-waiting-publish calls)]
|
||||
(t/is (thrown? clojure.lang.ExceptionInfo
|
||||
(climit/invoke! cfg (fn [_ _] :never) {}))))
|
||||
(invoke! cfg (fn [_ _] :never) {}))))
|
||||
|
||||
;; the count came back down instead of staying up for good
|
||||
(t/is (= 0 (waiting-count climit name)))
|
||||
@ -520,11 +453,11 @@
|
||||
;; run inline instead of waiting for the permit another thread holds.
|
||||
(let [entered (promise)
|
||||
gate (promise)
|
||||
holder (future (climit/invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
holder (future (invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
(t/is (true? (deref entered 5000 false)))
|
||||
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :inline) {}))))
|
||||
(t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :inline) {}))))
|
||||
(deliver gate true)
|
||||
(t/is (= :held (deref holder 5000 :timeout)))))))
|
||||
|
||||
@ -549,13 +482,13 @@
|
||||
(let [cfg (limiter-cfg climit :test-registry/by-profile "a")
|
||||
entered (promise)
|
||||
gate (promise)
|
||||
holder (future (climit/invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
holder (future (invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
|
||||
(t/is (true? (deref entered 5000 false)))
|
||||
|
||||
(let [waiter (future (climit/invoke! cfg (fn [_ _] :waited) {}))]
|
||||
(let [waiter (future (invoke! cfg (fn [_ _] :waited) {}))]
|
||||
(t/is (true? (wait-until #(= 1.0 (gauge-value metrics name)) 5000)))
|
||||
(deliver gate true)
|
||||
(t/is (= :waited (deref waiter 5000 :timeout)))
|
||||
@ -592,20 +525,20 @@
|
||||
cfg (limiter-cfg climit :test-log/by-profile "a")
|
||||
entered (promise)
|
||||
gate (promise)
|
||||
holder (future (climit/invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
holder (future (invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
|
||||
(t/is (true? (deref entered 5000 false)))
|
||||
(reset! calls [])
|
||||
|
||||
(let [q1 (future (climit/invoke! cfg (fn [_ _] :q1) {}))
|
||||
q2 (future (climit/invoke! cfg (fn [_ _] :q2) {}))]
|
||||
(let [q1 (future (invoke! cfg (fn [_ _] :q1) {}))
|
||||
q2 (future (invoke! cfg (fn [_ _] :q2) {}))]
|
||||
|
||||
;; the two of them fill the queue, so the next one is rejected
|
||||
(t/is (true? (wait-until #(= 2 (waiting-count climit name)) 5000)))
|
||||
(t/is (some #(= 2 %) (waiting-value-of name)))
|
||||
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :x) {}))))
|
||||
(t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :x) {}))))
|
||||
|
||||
(let [props (::l/props (rejected-record records "queue-full"))]
|
||||
(t/is (= 1 (get props :permits)))
|
||||
@ -630,14 +563,14 @@
|
||||
gate (promise)
|
||||
capture (fn [_ action _req-id _stats _elapsed code reason]
|
||||
(swap! lines conj [action code reason]))
|
||||
holder (future (climit/invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
holder (future (invoke! cfg
|
||||
(fn [_ _] (deliver entered true) @gate :held)
|
||||
{}))]
|
||||
|
||||
(t/is (true? (deref entered 5000 false)))
|
||||
|
||||
(with-redefs [climit/log capture]
|
||||
(t/is (= :concurrency-limit (failed-type #(climit/invoke! cfg (fn [_ _] :late) {}))))
|
||||
(t/is (= :concurrency-limit (failed-type #(invoke! cfg (fn [_ _] :late) {}))))
|
||||
(t/is (= [["rejected" :capacity-limit-reached "timeout"]]
|
||||
(filterv #(= "rejected" (first %)) @lines))))
|
||||
|
||||
|
||||
@ -627,13 +627,19 @@
|
||||
(defn init-climit
|
||||
"Build a concurrency limiter component from an inline config map. The
|
||||
optional second argument is the metrics instance to publish to (a
|
||||
`metrics-stub` by default, where every `run!` is a no-op)."
|
||||
`metrics-stub` by default, where every `run!` is a no-op).
|
||||
|
||||
The root limiters are added with permits that no test reaches: every
|
||||
request goes through them, and a test that does not list them would log a
|
||||
warning per request."
|
||||
([config]
|
||||
(init-climit config (metrics-stub)))
|
||||
([config metrics]
|
||||
(let [file (doto (java.io.File/createTempFile "climit" ".edn")
|
||||
(.deleteOnExit))]
|
||||
(spit file (pr-str config))
|
||||
(spit file (pr-str (merge {:root/global {:permits 1000 :timeout 3000}
|
||||
:root/by-profile {:permits 1000 :timeout 3000}}
|
||||
config)))
|
||||
(ig/init-key ::rpc/climit
|
||||
{::climit/enabled true
|
||||
::climit/config (.getAbsolutePath file)
|
||||
|
||||
@ -6,13 +6,22 @@
|
||||
|
||||
(ns backend-tests.rpc-climit-test
|
||||
(:require
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.util.services :as-alias sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
|
||||
(defn- command-limits
|
||||
"The `::climit/id` declaration of the RPC command named `name`."
|
||||
[name]
|
||||
(let [[mdata _] (get (:app.rpc/methods th/*system*) (keyword name))]
|
||||
(::climit/id mdata)))
|
||||
|
||||
(defn- method-limit-declarations
|
||||
"The `::climit/id` declarations of every RPC command, as the running system
|
||||
wraps them."
|
||||
@ -24,11 +33,87 @@
|
||||
(when-let [limits (::climit/id mdata)]
|
||||
[mdata limits])))))
|
||||
|
||||
(defn- declared-ids
|
||||
"The limit ids a command declares, in the order it declares them."
|
||||
[limits]
|
||||
(cond
|
||||
(keyword? limits)
|
||||
[limits]
|
||||
|
||||
(vector? (first limits))
|
||||
(mapv first limits)
|
||||
|
||||
:else
|
||||
[(first limits)]))
|
||||
|
||||
(defn- limit-order-edges
|
||||
"The pairs of limit ids the commands declare, as edges: the first one of the
|
||||
pair is taken before the second one."
|
||||
[declarations]
|
||||
(reduce (fn [edges [mdata limits]]
|
||||
(let [ids (declared-ids limits)
|
||||
size (count ids)]
|
||||
(into edges
|
||||
(for [i (range size)
|
||||
j (range (inc i) size)]
|
||||
[(nth ids i) (nth ids j)]))))
|
||||
#{}
|
||||
declarations))
|
||||
|
||||
(defn- limit-order-cycle
|
||||
"The limit ids whose relative order the commands disagree about: the ones
|
||||
left when the relation cannot be sorted.
|
||||
|
||||
Two commands that take the same limiters in opposite order leave two paths
|
||||
of one profile holding them in opposite order, and the threads park for
|
||||
good. A ring of three does the same, so the relation is checked as a whole
|
||||
and not pair by pair."
|
||||
[edges]
|
||||
(let [nodes (into #{} (mapcat identity) edges)
|
||||
outgoing (reduce (fn [acc [a b]] (update acc a (fnil conj #{}) b)) {} edges)
|
||||
indegree (reduce (fn [acc [_ b]] (update acc b (fnil inc 0))) {} edges)]
|
||||
(loop [pending nodes
|
||||
degrees indegree]
|
||||
(let [ready (filterv #(zero? (get degrees % 0)) pending)]
|
||||
(if (empty? ready)
|
||||
(seq pending)
|
||||
(recur (apply disj pending ready)
|
||||
(reduce (fn [degrees node]
|
||||
(reduce (fn [degrees next]
|
||||
(update degrees next (fnil dec 0)))
|
||||
degrees
|
||||
(get outgoing node)))
|
||||
degrees
|
||||
ready)))))))
|
||||
|
||||
(t/deftest the-media-object-from-url-command-declares-its-own-limits
|
||||
;; The command used to enter climit from inside its handler, which forced
|
||||
;; every path to take the root limiters before the command ones. It declares
|
||||
;; its limits now, so there is a single place where they are read.
|
||||
(t/is (= [[:process-image/by-profile ::rpc/profile-id]
|
||||
[:process-image/global]]
|
||||
(command-limits "create-file-media-object-from-url"))))
|
||||
|
||||
(t/deftest the-update-profile-photo-command-declares-its-own-limits
|
||||
;; The command used to enter climit from inside its handler, which forced
|
||||
;; every path to take the root limiters before the command ones. It declares
|
||||
;; its limits now, so there is a single place where they are read.
|
||||
(t/is (= [[:process-image/by-profile ::rpc/profile-id]
|
||||
[:process-image/global]]
|
||||
(command-limits "update-profile-photo"))))
|
||||
|
||||
(t/deftest the-update-team-photo-command-declares-its-own-limits
|
||||
;; The command generates the thumbnail of a team through `upload-photo`,
|
||||
;; which used to enter climit on its own. It declares the limits now, so the
|
||||
;; image processing of a team is bounded like the one of a profile.
|
||||
(t/is (= [[:process-image/by-profile ::rpc/profile-id]
|
||||
[:process-image/global]]
|
||||
(command-limits "update-team-photo"))))
|
||||
|
||||
(t/deftest commands-declare-the-by-profile-limit-before-the-global-one
|
||||
;; `climit/get-limits` reverses a declared vector of pairs, so the first one
|
||||
;; declared is acquired first, and the nested `climit/invoke!` chains read the
|
||||
;; same declaration. A pair declared global first puts a command back in the
|
||||
;; inverted order that parked two paths of one profile for good.
|
||||
;; declared is acquired first. A pair declared global first puts a command
|
||||
;; back in the inverted order that parked two paths of one profile for good.
|
||||
(let [declarations (method-limit-declarations)]
|
||||
|
||||
;; the system wraps every command, so a short list means the scan went stale
|
||||
@ -49,3 +134,46 @@
|
||||
(t/is (< idx gidx)
|
||||
(str where " must declare " id " before "
|
||||
(keyword (namespace id) "global")))))))))
|
||||
|
||||
(t/deftest every-command-takes-the-shared-limits-in-the-same-order
|
||||
;; Two commands that share a limit and take it in opposite order can hold
|
||||
;; the same limiters in opposite order and park for good. The order is free
|
||||
;; now that no command enters climit from inside its handler, so this test
|
||||
;; is what keeps the commands consistent with each other.
|
||||
(let [declarations (method-limit-declarations)
|
||||
edges (limit-order-edges declarations)
|
||||
cycle (limit-order-cycle edges)]
|
||||
|
||||
;; the system wraps every command, so a short list means the scan went stale
|
||||
(t/is (<= 10 (count declarations)))
|
||||
(t/is (seq edges))
|
||||
(t/is (nil? cycle)
|
||||
(str "the commands take these limits in different orders: "
|
||||
(pr-str cycle)))))
|
||||
|
||||
(t/deftest a-ring-of-three-limits-is-a-cycle
|
||||
;; A ring of three limits does the same as a pair in opposite order: three
|
||||
;; paths hold the three limiters and wait for each other. The relation is
|
||||
;; checked as a whole for that reason, and not pair by pair.
|
||||
(t/is (= #{:a :b :c}
|
||||
(set (limit-order-cycle #{[:a :b] [:b :c] [:c :a]}))))
|
||||
(t/is (nil? (limit-order-cycle #{[:a :b] [:b :c]}))))
|
||||
|
||||
(t/deftest every-declared-limit-has-an-entry-in-the-shipped-config
|
||||
;; `climit/wrap` logs a warning and runs the handler with no limiter at all
|
||||
;; when the config has no entry for a declared id, so the command runs on
|
||||
;; every thread that asks for it. The production file is maintained by hand
|
||||
;; outside the repository and mirrors this one, so a command declaring a limit
|
||||
;; the file does not list is the shape of an unbounded command in production.
|
||||
(let [path (io/resource "climit.edn")
|
||||
config (edn/read-string (slurp path))
|
||||
declared (into #{}
|
||||
(mapcat (fn [[_ limits]] (declared-ids limits)))
|
||||
(method-limit-declarations))]
|
||||
|
||||
;; the system wraps every command, so an empty set means the scan went stale
|
||||
(t/is (seq declared))
|
||||
|
||||
(doseq [id declared]
|
||||
(t/is (some? (get config id))
|
||||
(str id " is declared by a command but has no entry in climit.edn")))))
|
||||
|
||||
@ -235,8 +235,7 @@
|
||||
:enable-stroke-per-side
|
||||
:enable-token-combobox
|
||||
:enable-custom-shortcuts
|
||||
:enable-token-lib-sync
|
||||
:enable-link-unfurl])
|
||||
:enable-token-lib-sync])
|
||||
|
||||
(defn parse
|
||||
[& flags]
|
||||
|
||||
@ -16,22 +16,24 @@
|
||||
|
||||
(defn generate-update-property-name
|
||||
[changes variant-id pos new-name]
|
||||
(let [data (pcb/get-library-data changes)
|
||||
objects (pcb/get-objects changes)
|
||||
related-components (cfv/find-variant-components data objects variant-id)
|
||||
(if-let [new-name (ctv/prepare-property-name new-name)]
|
||||
(let [data (pcb/get-library-data changes)
|
||||
objects (pcb/get-objects changes)
|
||||
related-components (cfv/find-variant-components data objects variant-id)
|
||||
|
||||
props (-> related-components last :variant-properties)
|
||||
prop-names (mapv :name props)
|
||||
prop-names (concat (subvec prop-names 0 pos) (subvec prop-names (inc pos)))
|
||||
new-name (ctv/update-number-in-repeated-item prop-names new-name)]
|
||||
(reduce (fn [changes component]
|
||||
(pcb/update-component
|
||||
changes (:id component)
|
||||
(fn [component]
|
||||
(d/update-in-when component [:variant-properties pos] #(assoc % :name new-name)))
|
||||
{:apply-changes-local-library? true}))
|
||||
changes
|
||||
related-components)))
|
||||
props (-> related-components last :variant-properties)
|
||||
prop-names (mapv :name props)
|
||||
prop-names (concat (subvec prop-names 0 pos) (subvec prop-names (inc pos)))
|
||||
new-name (ctv/update-number-in-repeated-item prop-names new-name)]
|
||||
(reduce (fn [changes component]
|
||||
(pcb/update-component
|
||||
changes (:id component)
|
||||
(fn [component]
|
||||
(d/update-in-when component [:variant-properties pos] #(assoc % :name new-name)))
|
||||
{:apply-changes-local-library? true}))
|
||||
changes
|
||||
related-components))
|
||||
changes))
|
||||
|
||||
|
||||
(defn generate-remove-property
|
||||
@ -57,16 +59,18 @@
|
||||
|
||||
(defn generate-update-property-value
|
||||
[changes component-id pos value]
|
||||
(let [data (pcb/get-library-data changes)
|
||||
component (ctcl/get-component data component-id true)
|
||||
main-id (:main-instance-id component)
|
||||
name (-> (:variant-properties component)
|
||||
(update pos assoc :value value)
|
||||
ctv/properties-to-name)]
|
||||
(-> changes
|
||||
(pcb/update-component component-id #(assoc-in % [:variant-properties pos :value] value)
|
||||
{:apply-changes-local-library? true})
|
||||
(pcb/update-shapes [main-id] #(assoc % :variant-name name)))))
|
||||
(if-let [value (ctv/prepare-property-value value)]
|
||||
(let [data (pcb/get-library-data changes)
|
||||
component (ctcl/get-component data component-id true)
|
||||
main-id (:main-instance-id component)
|
||||
name (-> (:variant-properties component)
|
||||
(update pos assoc :value value)
|
||||
ctv/properties-to-name)]
|
||||
(-> changes
|
||||
(pcb/update-component component-id #(assoc-in % [:variant-properties pos :value] value)
|
||||
{:apply-changes-local-library? true})
|
||||
(pcb/update-shapes [main-id] #(assoc % :variant-name name))))
|
||||
changes))
|
||||
|
||||
|
||||
(defn generate-set-variant-error
|
||||
@ -108,40 +112,45 @@
|
||||
|
||||
props (-> related-components last :variant-properties)
|
||||
next-prop-num (ctv/next-property-number props)
|
||||
property-name (or property-name (str ctv/property-prefix next-prop-num))
|
||||
property-name (if (some? property-name)
|
||||
(ctv/prepare-property-name property-name)
|
||||
(str ctv/property-prefix next-prop-num))]
|
||||
(if (nil? property-name)
|
||||
changes
|
||||
(let [prop-names (mapv :name props)
|
||||
property-name (ctv/update-number-in-repeated-item prop-names property-name)
|
||||
property-value (not-empty
|
||||
(when property-value
|
||||
(ctv/prepare-property-value property-value)))
|
||||
mdata (if editing? {:editing? true} nil)
|
||||
|
||||
prop-names (mapv :name props)
|
||||
property-name (ctv/update-number-in-repeated-item prop-names property-name)
|
||||
[_ changes]
|
||||
(reduce (fn [[num changes] component]
|
||||
(let [main-id (:main-instance-id component)
|
||||
|
||||
mdata (if editing? {:editing? true} nil)
|
||||
update-props #(-> (d/nilv % [])
|
||||
(conj (with-meta {:name property-name
|
||||
:value (cond fill-values? (str ctv/value-prefix num)
|
||||
property-value property-value
|
||||
:else "")}
|
||||
mdata)))
|
||||
|
||||
[_ changes]
|
||||
(reduce (fn [[num changes] component]
|
||||
(let [main-id (:main-instance-id component)
|
||||
|
||||
update-props #(-> (d/nilv % [])
|
||||
(conj (with-meta {:name property-name
|
||||
:value (cond fill-values? (str ctv/value-prefix num)
|
||||
property-value property-value
|
||||
:else "")}
|
||||
mdata)))
|
||||
|
||||
update-name #(cond fill-values? (if (str/empty? %)
|
||||
(str ctv/value-prefix num)
|
||||
(str % ", " ctv/value-prefix num))
|
||||
property-value (if (str/empty? %)
|
||||
property-value
|
||||
(str % ", " property-value))
|
||||
:else %)]
|
||||
[(inc num)
|
||||
(-> changes
|
||||
(pcb/update-component (:id component)
|
||||
#(update % :variant-properties update-props)
|
||||
{:apply-changes-local-library? true})
|
||||
(pcb/update-shapes [main-id] #(update % :variant-name update-name)))]))
|
||||
[1 changes]
|
||||
related-components)]
|
||||
changes))
|
||||
update-name #(cond fill-values? (if (str/empty? %)
|
||||
(str ctv/value-prefix num)
|
||||
(str % ", " ctv/value-prefix num))
|
||||
property-value (if (str/empty? %)
|
||||
property-value
|
||||
(str % ", " property-value))
|
||||
:else %)]
|
||||
[(inc num)
|
||||
(-> changes
|
||||
(pcb/update-component (:id component)
|
||||
#(update % :variant-properties update-props)
|
||||
{:apply-changes-local-library? true})
|
||||
(pcb/update-shapes [main-id] #(update % :variant-name update-name)))]))
|
||||
[1 changes]
|
||||
related-components)]
|
||||
changes))))
|
||||
|
||||
(defn- generate-make-shape-no-variant
|
||||
[changes shape]
|
||||
@ -175,7 +184,7 @@
|
||||
props (ctv/add-new-props props (repeat new-props ""))]
|
||||
|
||||
(if add-name?
|
||||
(ctv/add-new-prop props (:name component))
|
||||
(ctv/add-new-prop props (or (ctv/sanitize-property-text (:name component)) ""))
|
||||
props)))
|
||||
|
||||
(defn- create-new-properties-from-non-variant
|
||||
|
||||
@ -55,19 +55,47 @@
|
||||
[value]
|
||||
(some-> value str/trim))
|
||||
|
||||
(def ^:private forbidden-property-chars-re #"[,=]")
|
||||
|
||||
(defn sanitize-property-text
|
||||
"Remove `,` and `=` from property name/value text, then trim.
|
||||
Non-strings (including nil) return nil — never call str/replace on them."
|
||||
[value]
|
||||
(when (string? value)
|
||||
(-> value
|
||||
(str/replace forbidden-property-chars-re "")
|
||||
normalize-property-text)))
|
||||
|
||||
(defn valid-property-name?
|
||||
[value]
|
||||
(let [value (normalize-property-text value)]
|
||||
(and (string? value)
|
||||
(not (str/blank? value))
|
||||
(nil? (re-find forbidden-property-chars-re value))
|
||||
(<= (count value) property-max-length))))
|
||||
|
||||
(defn valid-property-value?
|
||||
[value]
|
||||
(let [value (normalize-property-text value)]
|
||||
(and (string? value)
|
||||
(nil? (re-find forbidden-property-chars-re value))
|
||||
(<= (count value) property-max-length))))
|
||||
|
||||
(defn prepare-property-name
|
||||
"Sanitize then validate a property name. Returns the cleaned string or nil."
|
||||
[value]
|
||||
(let [value (sanitize-property-text value)]
|
||||
(when (valid-property-name? value)
|
||||
value)))
|
||||
|
||||
(defn prepare-property-value
|
||||
"Sanitize then validate a property value. nil becomes \"\".
|
||||
Other non-strings yield nil. Empty string remains valid."
|
||||
[value]
|
||||
(let [value (sanitize-property-text (if (nil? value) "" value))]
|
||||
(when (valid-property-value? value)
|
||||
value)))
|
||||
|
||||
(defn can-remove-property?
|
||||
[properties]
|
||||
(> (count properties) 1))
|
||||
@ -112,7 +140,8 @@
|
||||
([path properties]
|
||||
(path-to-properties path properties 0))
|
||||
([path properties min-props]
|
||||
(let [cpath (cpn/split-path path)
|
||||
(let [cpath (->> (cpn/split-path path)
|
||||
(mapv #(or (sanitize-property-text %) "")))
|
||||
total-props (max (count cpath) min-props)
|
||||
assigned (mapv #(assoc % :value (nth cpath %2 "")) properties (range))
|
||||
;; Add empty strings to the end of cpath to reach the minimum number of properties
|
||||
|
||||
@ -16,7 +16,9 @@
|
||||
[app.common.test-helpers.ids-map :as thi]
|
||||
[app.common.test-helpers.shapes :as ths]
|
||||
[app.common.test-helpers.variants :as thv]
|
||||
[clojure.test :as t]))
|
||||
[app.common.types.variant :as ctv]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(t/use-fixtures :each thi/test-fixture)
|
||||
|
||||
@ -234,6 +236,178 @@
|
||||
(t/is (= (-> comp01' :variant-properties first :value) "NewValue1"))
|
||||
(t/is (= (-> comp02' :variant-properties first :value) "NewValue2"))))
|
||||
|
||||
(t/deftest test-update-property-value-sanitizes
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
page (thf/current-page file)
|
||||
comp01 (thc/get-component file :c01)
|
||||
before (-> comp01 :variant-properties first :value)
|
||||
|
||||
changes (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page-id (:id page))
|
||||
(pcb/with-library-data (:data file))
|
||||
(pcb/with-objects (:objects page))
|
||||
(clvp/generate-update-property-value (:id comp01) 0 "a=b,"))
|
||||
|
||||
file' (thf/apply-changes file changes)
|
||||
comp01' (thc/get-component file' :c01)
|
||||
main' (ths/get-shape file' :m01)
|
||||
|
||||
noop (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page-id (:id page))
|
||||
(pcb/with-library-data (:data file))
|
||||
(pcb/with-objects (:objects page))
|
||||
(clvp/generate-update-property-value (:id comp01) 0 42))
|
||||
file-noop (thf/apply-changes file noop)
|
||||
comp-noop (thc/get-component file-noop :c01)
|
||||
|
||||
cleared (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page-id (:id page))
|
||||
(pcb/with-library-data (:data file))
|
||||
(pcb/with-objects (:objects page))
|
||||
(clvp/generate-update-property-value (:id comp01) 0 "=,="))
|
||||
file-cleared (thf/apply-changes file cleared)
|
||||
comp-cleared (thc/get-component file-cleared :c01)
|
||||
main-cleared (ths/get-shape file-cleared :m01)
|
||||
|
||||
nilled (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page-id (:id page))
|
||||
(pcb/with-library-data (:data file))
|
||||
(pcb/with-objects (:objects page))
|
||||
(clvp/generate-update-property-value (:id comp01) 0 nil))
|
||||
file-nilled (thf/apply-changes file nilled)
|
||||
comp-nilled (thc/get-component file-nilled :c01)]
|
||||
|
||||
(t/is (= "ab" (-> comp01' :variant-properties first :value)))
|
||||
(t/is (= "ab" (:variant-name main')))
|
||||
(t/is (= before (-> comp-noop :variant-properties first :value)))
|
||||
(t/is (= "" (-> comp-cleared :variant-properties first :value)))
|
||||
(t/is (not (str/includes? (str (:variant-name main-cleared)) "=")))
|
||||
(t/is (= "" (-> comp-nilled :variant-properties first :value)))))
|
||||
|
||||
(t/deftest test-update-property-name-sanitizes-and-noops
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant-two-properties :v01 :c01 :m01 :c02 :m02))
|
||||
v-id (-> (ths/get-shape file :v01) :id)
|
||||
page (thf/current-page file)
|
||||
before (-> (thc/get-component file :c01) :variant-properties first :name)
|
||||
|
||||
changes (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page-id (:id page))
|
||||
(pcb/with-library-data (:data file))
|
||||
(pcb/with-objects (:objects page))
|
||||
(clvp/generate-update-property-name v-id 0 "Size=Big"))
|
||||
|
||||
file' (thf/apply-changes file changes)
|
||||
|
||||
noop (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page-id (:id page))
|
||||
(pcb/with-library-data (:data file))
|
||||
(pcb/with-objects (:objects page))
|
||||
(clvp/generate-update-property-name v-id 0 "=,="))
|
||||
file-noop (thf/apply-changes file noop)]
|
||||
|
||||
(t/is (= "SizeBig" (-> (thc/get-component file' :c01) :variant-properties first :name)))
|
||||
(t/is (= before (-> (thc/get-component file-noop :c01) :variant-properties first :name)))))
|
||||
|
||||
(t/deftest test-add-new-property-sanitizes
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
v-id (-> (ths/get-shape file :v01) :id)
|
||||
page (thf/current-page file)
|
||||
before-count (-> (thc/get-component file :c01) :variant-properties count)
|
||||
|
||||
changes (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page-id (:id page))
|
||||
(pcb/with-library-data (:data file))
|
||||
(pcb/with-objects (:objects page))
|
||||
(clvp/generate-add-new-property v-id {:property-name "Size=Big"
|
||||
:property-value "a=b,"}))
|
||||
|
||||
file' (thf/apply-changes file changes)
|
||||
comp01' (thc/get-component file' :c01)
|
||||
|
||||
noop (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page-id (:id page))
|
||||
(pcb/with-library-data (:data file))
|
||||
(pcb/with-objects (:objects page))
|
||||
(clvp/generate-add-new-property v-id {:property-name "=,="}))
|
||||
file-noop (thf/apply-changes file noop)]
|
||||
|
||||
(t/is (= "SizeBig" (-> comp01' :variant-properties last :name)))
|
||||
(t/is (= "ab" (-> comp01' :variant-properties last :value)))
|
||||
(t/is (= before-count
|
||||
(-> (thc/get-component file-noop :c01) :variant-properties count)))))
|
||||
|
||||
(t/deftest test-add-new-property-blank-value-does-not-trail-separator
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
v-id (-> (ths/get-shape file :v01) :id)
|
||||
page (thf/current-page file)
|
||||
main (ths/get-shape file :m01)
|
||||
before (:variant-name main)
|
||||
changes (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page-id (:id page))
|
||||
(pcb/with-library-data (:data file))
|
||||
(pcb/with-objects (:objects page))
|
||||
(clvp/generate-add-new-property v-id {:property-name "Size"
|
||||
:property-value "=,="}))
|
||||
file' (thf/apply-changes file changes)
|
||||
comp01' (thc/get-component file' :c01)
|
||||
main' (ths/get-shape file' :m01)]
|
||||
|
||||
(t/is (= before (:variant-name main')))
|
||||
(t/is (not (str/ends-with? (str (:variant-name main')) ", ")))
|
||||
(t/is (= "" (-> comp01' :variant-properties last :value)))
|
||||
(t/is (= "Size" (-> comp01' :variant-properties last :name)))))
|
||||
|
||||
(t/deftest test-update-property-value-from-formula-strips-equal-in-value
|
||||
;; Option A / M2: formula parse may leave `=` inside a value; write path strips it.
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02))
|
||||
page (thf/current-page file)
|
||||
comp01 (thc/get-component file :c01)
|
||||
parsed (ctv/properties-formula->map "border=yes color=yes")
|
||||
value (-> parsed first :value)
|
||||
changes (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page-id (:id page))
|
||||
(pcb/with-library-data (:data file))
|
||||
(pcb/with-objects (:objects page))
|
||||
(clvp/generate-update-property-value (:id comp01) 0 value))
|
||||
file' (thf/apply-changes file changes)
|
||||
comp01' (thc/get-component file' :c01)
|
||||
main' (ths/get-shape file' :m01)]
|
||||
|
||||
(t/is (= "yes color=yes" value))
|
||||
(t/is (= "yes coloryes" (-> comp01' :variant-properties first :value)))
|
||||
(t/is (= "yes coloryes" (:variant-name main')))
|
||||
(t/is (not= "yes color=yes" (-> comp01' :variant-properties first :value)))))
|
||||
|
||||
(t/deftest test-make-shapes-variant-sanitizes-component-name-as-value
|
||||
;; create-new-properties-from-variant adds (:name component) as a value when
|
||||
;; moving a variant into a differently named container. Production always
|
||||
;; pairs generate-make-shapes-variant with change-parent.
|
||||
(let [file (-> (thf/sample-file :file1)
|
||||
(thv/add-variant :v01 :c01 :m01 :c02 :m02)
|
||||
(thv/add-variant :v02 :c03 :m03 :c04 :m04)
|
||||
(thc/update-component :c03 {:name "Big=red"}))
|
||||
page (thf/current-page file)
|
||||
container (ths/get-shape file :v01)
|
||||
shape (ths/get-shape file :m03)
|
||||
changes (-> (pcb/empty-changes nil)
|
||||
(pcb/with-page-id (:id page))
|
||||
(pcb/with-library-data (:data file))
|
||||
(pcb/with-objects (:objects page))
|
||||
(clvp/generate-make-shapes-variant [shape] container)
|
||||
(pcb/change-parent (:id container) [shape] 0))
|
||||
file' (thf/apply-changes file changes)
|
||||
values (->> (thc/get-component file' :c03)
|
||||
:variant-properties
|
||||
(map :value)
|
||||
set)]
|
||||
|
||||
(t/is (contains? values "Bigred"))))
|
||||
|
||||
|
||||
(t/deftest test-duplicate-variant-container
|
||||
(let [;; ==== Setup
|
||||
|
||||
@ -171,3 +171,45 @@
|
||||
(t/is (= (ctv/find-boolean-pair ["false" "true"]) {"true" true "false" false}))
|
||||
(t/is (= (ctv/find-boolean-pair ["off" "on" "other"]) nil))
|
||||
(t/is (= (ctv/find-boolean-pair ["hello" "bye"]) nil))))
|
||||
|
||||
|
||||
(t/deftest sanitize-and-prepare-property-text
|
||||
(t/testing "sanitize-property-text"
|
||||
(t/is (= (ctv/sanitize-property-text "a=b") "ab"))
|
||||
(t/is (= (ctv/sanitize-property-text "a,b") "ab"))
|
||||
(t/is (= (ctv/sanitize-property-text " Big, red ") "Big red"))
|
||||
(t/is (= (ctv/sanitize-property-text " a=b, ") "ab"))
|
||||
(t/is (= (ctv/sanitize-property-text ",=") ""))
|
||||
(t/is (nil? (ctv/sanitize-property-text nil)))
|
||||
(t/is (nil? (ctv/sanitize-property-text 42))))
|
||||
|
||||
(t/testing "valid-property-name? rejects forbidden chars"
|
||||
(t/is (true? (ctv/valid-property-name? "ok")))
|
||||
(t/is (false? (ctv/valid-property-name? "a=b")))
|
||||
(t/is (false? (ctv/valid-property-name? "a,b")))
|
||||
(t/is (false? (ctv/valid-property-name? ""))))
|
||||
|
||||
(t/testing "valid-property-value? rejects forbidden chars"
|
||||
(t/is (true? (ctv/valid-property-value? "ok")))
|
||||
(t/is (true? (ctv/valid-property-value? "")))
|
||||
(t/is (false? (ctv/valid-property-value? "a=b")))
|
||||
(t/is (false? (ctv/valid-property-value? "a,b"))))
|
||||
|
||||
(t/testing "prepare-property-name"
|
||||
(t/is (= (ctv/prepare-property-name "Size=Big") "SizeBig"))
|
||||
(t/is (nil? (ctv/prepare-property-name "=,=")))
|
||||
(t/is (= (ctv/prepare-property-name " ok ") "ok"))
|
||||
(t/is (nil? (ctv/prepare-property-name 42))))
|
||||
|
||||
(t/testing "prepare-property-value"
|
||||
(t/is (= (ctv/prepare-property-value "a=b,") "ab"))
|
||||
(t/is (= (ctv/prepare-property-value "=,=") ""))
|
||||
(t/is (= (ctv/prepare-property-value nil) ""))
|
||||
(t/is (nil? (ctv/prepare-property-value 42)))))
|
||||
|
||||
|
||||
(t/deftest path-to-properties-sanitizes-values
|
||||
(let [props [{:name "Property 1" :value ""}]
|
||||
result (ctv/path-to-properties "Button / Big, red" props)]
|
||||
(t/is (= "Button" (-> result first :value)))
|
||||
(t/is (= "Big red" (-> result second :value)))))
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
"playwright": "1.63.0",
|
||||
"raw-body": "^4.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"undici": "^8.11.2",
|
||||
"undici": "^8.12.3",
|
||||
"xml-js": "^1.6.11",
|
||||
"xregexp": "^5.1.3"
|
||||
},
|
||||
|
||||
32
exporter/pnpm-lock.yaml
generated
32
exporter/pnpm-lock.yaml
generated
@ -203,8 +203,8 @@ importers:
|
||||
specifier: ^0.5.21
|
||||
version: 0.5.21
|
||||
undici:
|
||||
specifier: ^8.11.2
|
||||
version: 8.11.2
|
||||
specifier: ^8.12.3
|
||||
version: 8.12.3
|
||||
xml-js:
|
||||
specifier: ^1.6.11
|
||||
version: 1.6.11
|
||||
@ -218,8 +218,8 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/runtime-corejs3@7.29.7':
|
||||
resolution: {integrity: sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==}
|
||||
'@babel/runtime-corejs3@7.29.10':
|
||||
resolution: {integrity: sha512-R4FKrg4Vy0hB7PfAxf9LOorEPd5/36SCIXPXAvjlQaQmPvtrjwoDXV15jsSm2ecsqBW1Iewwb7mEnG83xIEApg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@ioredis/commands@2.0.0':
|
||||
@ -505,8 +505,8 @@ packages:
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readable-stream@4.7.0:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
readable-stream@4.8.0:
|
||||
resolution: {integrity: sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
readdir-glob@3.0.0:
|
||||
@ -574,8 +574,8 @@ packages:
|
||||
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
|
||||
engines: {node: '>=0.6.x'}
|
||||
|
||||
undici@8.11.2:
|
||||
resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==}
|
||||
undici@8.12.3:
|
||||
resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
@ -606,7 +606,7 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/runtime-corejs3@7.29.7':
|
||||
'@babel/runtime-corejs3@7.29.10':
|
||||
dependencies:
|
||||
core-js-pure: 3.50.0
|
||||
|
||||
@ -631,7 +631,7 @@ snapshots:
|
||||
is-stream: 4.0.1
|
||||
lazystream: 1.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
readable-stream: 4.8.0
|
||||
readdir-glob: 3.0.0
|
||||
tar-stream: 3.2.2
|
||||
zip-stream: 7.0.5
|
||||
@ -702,7 +702,7 @@ snapshots:
|
||||
crc32-stream: 7.0.1
|
||||
is-stream: 4.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
readable-stream: 4.8.0
|
||||
|
||||
cookies@0.9.2:
|
||||
dependencies:
|
||||
@ -718,7 +718,7 @@ snapshots:
|
||||
crc32-stream@7.0.1:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
readable-stream: 4.7.0
|
||||
readable-stream: 4.8.0
|
||||
|
||||
css-select@7.0.0:
|
||||
dependencies:
|
||||
@ -873,7 +873,7 @@ snapshots:
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@4.7.0:
|
||||
readable-stream@4.8.0:
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
buffer: 6.0.3
|
||||
@ -953,7 +953,7 @@ snapshots:
|
||||
|
||||
tsscmp@1.0.6: {}
|
||||
|
||||
undici@8.11.2: {}
|
||||
undici@8.12.3: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
@ -965,10 +965,10 @@ snapshots:
|
||||
|
||||
xregexp@5.1.3:
|
||||
dependencies:
|
||||
'@babel/runtime-corejs3': 7.29.7
|
||||
'@babel/runtime-corejs3': 7.29.10
|
||||
|
||||
zip-stream@7.0.5:
|
||||
dependencies:
|
||||
compress-commons: 7.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
readable-stream: 4.8.0
|
||||
|
||||
@ -259,6 +259,36 @@ test.describe("Tokens: Apply token", () => {
|
||||
await expect(opacityLowPill).toBeVisible();
|
||||
});
|
||||
|
||||
test("Token dropdown of a scrubbable numeric input shows the default cursor", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { workspacePage } = await setupTokensFileRender(page);
|
||||
|
||||
await page.getByRole("tab", { name: "Layers" }).click();
|
||||
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
|
||||
|
||||
const layerMenuSection = page.getByRole("region", {
|
||||
name: "Layer menu section",
|
||||
});
|
||||
const opacityWrapper = layerMenuSection
|
||||
.getByTestId("numeric-input-wrapper")
|
||||
.first();
|
||||
await expect(opacityWrapper).toHaveCSS("cursor", "ew-resize");
|
||||
|
||||
await layerMenuSection
|
||||
.getByRole("button", { name: "Open token list" })
|
||||
.click();
|
||||
|
||||
// The scrub cursor belongs to the input, not to the dropdown it opens.
|
||||
const tokenList = layerMenuSection.getByRole("listbox");
|
||||
await expect(tokenList).toBeVisible();
|
||||
await expect(tokenList).toHaveCSS("cursor", "default");
|
||||
await expect(tokenList.getByRole("option").first()).toHaveCSS(
|
||||
"cursor",
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
test("User applies typography token to a text shape", async ({ page }) => {
|
||||
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
|
||||
await setupTypographyTokensFileRender(page);
|
||||
|
||||
@ -165,9 +165,22 @@ export async function compileSassAll(worker) {
|
||||
|
||||
export function concatSass(data) {
|
||||
const output = [];
|
||||
const charset = '@charset "UTF-8";';
|
||||
let hasCharset = false;
|
||||
|
||||
// Sass adds @charset to each file with non-ASCII characters. Browsers
|
||||
// only honor it as the first rule of the stylesheet, so move it there.
|
||||
for (let path of data.items) {
|
||||
output.push(data.index[path]);
|
||||
let css = data.index[path];
|
||||
if (css.includes(charset)) {
|
||||
hasCharset = true;
|
||||
css = css.replaceAll(charset + "\n", "").replaceAll(charset, "");
|
||||
}
|
||||
output.push(css);
|
||||
}
|
||||
|
||||
if (hasCharset) {
|
||||
output.unshift(charset);
|
||||
}
|
||||
|
||||
return output.join("\n");
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
[app.main.worker]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.globals :as g]
|
||||
[app.util.http :as http]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[app.util.timers :as ts]
|
||||
[app.util.webapi :as wapi]
|
||||
@ -439,16 +440,31 @@
|
||||
|
||||
(defn on-update-loop
|
||||
"Handler for the rumext update scheduler trips: logs the throttled
|
||||
update loop with the events being processed, at most once per
|
||||
`report-window-ms`. Runs inside store watchers and effect bodies, so it
|
||||
never throws."
|
||||
[_]
|
||||
update loop and reports it as an unhandled exception with the events
|
||||
being processed, at most once per `report-window-ms`. Runs inside store
|
||||
watchers and effect bodies, so it never throws."
|
||||
[{:keys [rounds]}]
|
||||
(try
|
||||
(let [now (inst-ms (ct/now))]
|
||||
(when (>= (- now @update-loop-warned-at) report-window-ms)
|
||||
(vreset! update-loop-warned-at now)
|
||||
(.warn js/console "[update-scheduler] update loop throttled; in-flight events:"
|
||||
(into-array (map ptk/repr-event (ptk/in-flight-events st/state))))))
|
||||
(let [events (ptk/in-flight-events st/state)
|
||||
cause (ex-info "update loop throttled"
|
||||
{:type :internal
|
||||
:code :update-loop-throttled
|
||||
:rounds rounds})]
|
||||
(.warn js/console "[update-scheduler] update loop throttled; in-flight events:"
|
||||
(into-array (map ptk/repr-event events)))
|
||||
(when (seq events)
|
||||
(.set in-flight-by-cause cause (in-flight-lines events)))
|
||||
;; Report outside the loop that tripped the scheduler, as an
|
||||
;; unhandled exception: a throttled loop is an application defect,
|
||||
;; so it must reach the error reports and alerts, not only audit.
|
||||
(ts/schedule
|
||||
#(submit-report :event-name "unhandled-exception"
|
||||
:hint (ex-message cause)
|
||||
:report (fn [] (generate-report cause))
|
||||
:cause cause)))))
|
||||
(catch :default _
|
||||
nil)))
|
||||
|
||||
@ -1123,6 +1139,15 @@
|
||||
(and (= (.-name ^js cause) "NotFoundError")
|
||||
(str/includes? message "removeChild")))))
|
||||
|
||||
(defn network-failure->error
|
||||
"Wrap a raw network TypeError as a connectivity error, so it is reported
|
||||
as an environment failure instead of an application crash."
|
||||
[cause]
|
||||
(ex-info (ex-message cause)
|
||||
{:type (if (false? (.-onLine ^js g/navigator)) :offline :network)
|
||||
:hint "unable to perform fetch operation"}
|
||||
cause))
|
||||
|
||||
(defn- from-plugin?
|
||||
"Check if the error is marked as originating from plugin code. The
|
||||
plugin runtime tracks plugin errors in a WeakMap, which works even
|
||||
@ -1149,6 +1174,10 @@
|
||||
(is-ignorable-exception? cause)
|
||||
nil
|
||||
|
||||
;; Raw fetch failures: connectivity, not a crash
|
||||
(http/network-failure? cause)
|
||||
(on-error (network-failure->error cause))
|
||||
|
||||
;; All other errors: show exception page
|
||||
:else
|
||||
|
||||
@ -1176,6 +1205,10 @@
|
||||
(is-ignorable-exception? cause)
|
||||
nil
|
||||
|
||||
;; Raw fetch failures: connectivity, not a crash
|
||||
(http/network-failure? cause)
|
||||
(on-error (network-failure->error cause))
|
||||
|
||||
;; All other errors: show exception page
|
||||
:else
|
||||
(let [data (ex-data cause)
|
||||
|
||||
@ -609,6 +609,8 @@
|
||||
|
||||
(defn workspace-thumbnail-by-id
|
||||
[object-id]
|
||||
;; Resolving a media id builds a new map on each read; compare by
|
||||
;; value so subscribers do not re-render on every store change.
|
||||
(l/derived
|
||||
(fn [state]
|
||||
(when-let [entry (dm/get-in state [:thumbnails object-id])]
|
||||
@ -616,7 +618,8 @@
|
||||
(and (:uri entry)
|
||||
(not (resolved-uri? (:uri entry))))
|
||||
(update :uri cf/resolve-media))))
|
||||
st/state))
|
||||
st/state
|
||||
=))
|
||||
|
||||
(def workspace-text-modifier
|
||||
(l/derived :workspace-text-modifier st/state))
|
||||
|
||||
@ -29,6 +29,10 @@
|
||||
z-index: var(--z-index-dropdown);
|
||||
box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
|
||||
|
||||
// Do not inherit the cursor of the control that opens it (e.g. the
|
||||
// scrub cursor of the numeric input).
|
||||
cursor: default;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@ -396,7 +396,7 @@
|
||||
(mf/use-fn
|
||||
(mf/deps component-ids)
|
||||
(fn [pos value]
|
||||
(let [value (ctv/normalize-property-text (d/nilv value ""))]
|
||||
(when-let [value (ctv/prepare-property-value value)]
|
||||
(doseq [id component-ids]
|
||||
(st/emit!
|
||||
(ev/event {::ev/name "variant-edit-property-value" ::ev/origin "workspace:combo-design-tab"})
|
||||
@ -407,11 +407,11 @@
|
||||
(mf/use-fn
|
||||
(mf/deps variant-id)
|
||||
(fn [event]
|
||||
(let [value (ctv/normalize-property-text (dom/get-target-val event))
|
||||
(let [value (ctv/prepare-property-name (dom/get-target-val event))
|
||||
pos (-> (dom/get-current-target event)
|
||||
(dom/get-data "position")
|
||||
int)]
|
||||
(when (ctv/valid-property-name? value)
|
||||
(when value
|
||||
(st/emit!
|
||||
(dwv/update-property-name variant-id pos value {:trigger "workspace:design-tab-variant"}))))))
|
||||
|
||||
@ -1246,11 +1246,11 @@
|
||||
(mf/use-fn
|
||||
(mf/deps variant-id)
|
||||
(fn [event]
|
||||
(let [value (ctv/normalize-property-text (dom/get-target-val event))
|
||||
(let [value (ctv/prepare-property-name (dom/get-target-val event))
|
||||
pos (-> (dom/get-current-target event)
|
||||
(dom/get-data "position")
|
||||
int)]
|
||||
(when (ctv/valid-property-name? value)
|
||||
(when value
|
||||
(st/emit!
|
||||
(dwv/update-property-name variant-id pos value {:trigger "workspace:design-tab-component"}))))))
|
||||
|
||||
|
||||
@ -779,12 +779,13 @@
|
||||
|
||||
:renameProperty
|
||||
(fn [pos name]
|
||||
(let [nprops (->> (get-variant-components file-id id) first :variant-properties count)]
|
||||
(let [nprops (->> (get-variant-components file-id id) first :variant-properties count)
|
||||
prepared (ctv/prepare-property-name name)]
|
||||
(cond
|
||||
(or (not (nat-int? pos)) (>= pos nprops))
|
||||
(u/not-valid plugin-id :pos pos)
|
||||
|
||||
(not (ctv/valid-property-name? name))
|
||||
(nil? prepared)
|
||||
(u/not-valid plugin-id :name name)
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
@ -792,7 +793,7 @@
|
||||
|
||||
:else
|
||||
(st/emit!
|
||||
(dwv/update-property-name id pos (ctv/normalize-property-text name)
|
||||
(dwv/update-property-name id pos prepared
|
||||
{:trigger "plugin:rename-property"})))))))
|
||||
|
||||
(set! shape/variant-proxy variant-proxy)
|
||||
@ -1090,12 +1091,13 @@
|
||||
|
||||
:setVariantProperty
|
||||
(fn [pos value]
|
||||
(let [nprops (-> (u/locate-library-component file-id id) :variant-properties count)]
|
||||
(let [nprops (-> (u/locate-library-component file-id id) :variant-properties count)
|
||||
prepared (ctv/prepare-property-value value)]
|
||||
(cond
|
||||
(or (not (nat-int? pos)) (>= pos nprops))
|
||||
(u/not-valid plugin-id :pos (str pos))
|
||||
|
||||
(not (ctv/valid-property-value? value))
|
||||
(nil? prepared)
|
||||
(u/not-valid plugin-id :value value)
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
@ -1104,7 +1106,7 @@
|
||||
:else
|
||||
(st/emit!
|
||||
(se/event plugin-id "variant-edit-property-value")
|
||||
(dwv/update-property-value id pos (ctv/normalize-property-text value))))))))
|
||||
(dwv/update-property-value id pos prepared)))))))
|
||||
|
||||
(defn library-proxy? [p]
|
||||
(obj/type-of? p "LibraryProxy"))
|
||||
|
||||
@ -146,6 +146,26 @@
|
||||
:body (.-body ^js response)
|
||||
::response response})
|
||||
|
||||
;; Messages of the TypeError a failed `fetch` rejects with, or a failed
|
||||
;; read of its response body.
|
||||
(def ^:private network-failure-messages
|
||||
#{"Failed to fetch" ; Chrome
|
||||
"NetworkError when attempting to fetch resource." ; Firefox
|
||||
"Load failed" ; Safari
|
||||
"network error" ; Chrome, body read
|
||||
"Error in body stream"}) ; Firefox, body read
|
||||
|
||||
(defn network-failure?
|
||||
"True when the error is the browser's TypeError for a request that never
|
||||
got an answer. Other TypeErrors, such as reading a body twice, are bugs
|
||||
and do not match."
|
||||
[cause]
|
||||
(and (= (.-name ^js cause) "TypeError")
|
||||
(let [message (ex-message cause)]
|
||||
(or (contains? network-failure-messages message)
|
||||
;; Newer Chrome versions append the host in parentheses
|
||||
(str/starts-with? message "Failed to fetch (")))))
|
||||
|
||||
(defn process-response-type
|
||||
[response-type response]
|
||||
(let [native-response (::response response)
|
||||
@ -156,7 +176,19 @@
|
||||
:blob (.blob ^js native-response))]
|
||||
(->> (rx/from body)
|
||||
(rx/map (fn [body]
|
||||
(assoc response :body body))))))
|
||||
(assoc response :body body)))
|
||||
;; The connection can drop while the body streams in, after
|
||||
;; `fetch` already resolved; the browser then rejects the read
|
||||
;; with a bare TypeError. Other errors, such as parse failures
|
||||
;; (SyntaxError), pass as is.
|
||||
(rx/catch (fn [cause]
|
||||
(if (network-failure? cause)
|
||||
(rx/throw (ex-info (ex-message cause)
|
||||
{:type :network
|
||||
:hint "unable to read response body"
|
||||
:uri (:uri response)}
|
||||
cause))
|
||||
(rx/throw cause)))))))
|
||||
|
||||
(defn send!
|
||||
[{:keys [response-type] :or {response-type :text} :as params}]
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
(ns frontend-tests.errors-test
|
||||
(:require
|
||||
[app.main.errors :as errors]
|
||||
[app.util.globals :as g]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
(defn- make-error
|
||||
@ -148,3 +149,33 @@
|
||||
;; V8 gives native rejections such as a failed fetch no frames at all.
|
||||
(let [cause (make-error "TypeError" "Failed to fetch" :stack "TypeError: Failed to fetch")]
|
||||
(t/is (false? (errors/is-ignorable-exception? cause))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; network-failure->error tests
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- with-online
|
||||
"Call `f` while `navigator.onLine` reads `online?`."
|
||||
[online? f]
|
||||
(js/Object.defineProperty g/navigator "onLine"
|
||||
#js {:value online? :configurable true})
|
||||
(try
|
||||
(f)
|
||||
(finally
|
||||
(js-delete g/navigator "onLine"))))
|
||||
|
||||
(t/deftest test-network-failure-becomes-environment-error
|
||||
(t/testing "A raw fetch failure is wrapped as an environment error"
|
||||
(let [cause (make-error "TypeError" "Failed to fetch")
|
||||
error (errors/network-failure->error cause)]
|
||||
(t/is (true? (errors/environment-error? error)))
|
||||
(t/is (= "Failed to fetch" (ex-message error)))
|
||||
(t/is (identical? cause (ex-cause error))))))
|
||||
|
||||
(t/deftest test-network-failure-type-follows-connection
|
||||
(let [cause (make-error "TypeError" "Failed to fetch")
|
||||
type #(:type (ex-data (errors/network-failure->error cause)))]
|
||||
(t/testing "Offline browsers report :offline"
|
||||
(t/is (= :offline (with-online false type))))
|
||||
(t/testing "Online browsers report :network"
|
||||
(t/is (= :network (with-online true type))))))
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.store :as st]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
(def ^:private resolved-uri? @#'refs/resolved-uri?)
|
||||
@ -28,3 +29,23 @@
|
||||
(t/is (false? (resolved-uri? (uuid/next))))
|
||||
(t/is (false? (resolved-uri? nil)))
|
||||
(t/is (false? (resolved-uri? 42))))
|
||||
|
||||
(t/deftest thumbnail-ref-ignores-unrelated-state-changes
|
||||
;; A thumbnail loaded from the server keeps a media id as :uri, and
|
||||
;; the ref resolves it to a new map on each read. Subscribers (root
|
||||
;; frames, assets) must not re-render on every unrelated store change.
|
||||
(let [object-id (str (uuid/next))
|
||||
ref (refs/workspace-thumbnail-by-id object-id)
|
||||
calls (atom 0)]
|
||||
(swap! st/state assoc-in [:thumbnails object-id] {:uri (uuid/next) :rendered-at nil})
|
||||
(add-watch ref ::probe (fn [_ _ _ _] (swap! calls inc)))
|
||||
(try
|
||||
(swap! st/state assoc ::probe (uuid/next))
|
||||
(t/is (zero? @calls) "unrelated change does not notify")
|
||||
(swap! st/state assoc-in [:thumbnails object-id :uri] (uuid/next))
|
||||
(t/is (= 1 @calls) "a new thumbnail uri notifies")
|
||||
(finally
|
||||
(remove-watch ref ::probe)
|
||||
(swap! st/state #(-> %
|
||||
(dissoc ::probe)
|
||||
(update :thumbnails dissoc object-id)))))))
|
||||
|
||||
@ -14,7 +14,8 @@
|
||||
- events in flight – the full report lists the events the
|
||||
store was processing when it caught the exception; a failing stream
|
||||
pipeline lists the emitters of the value, not its own event
|
||||
- on-update-loop – never throws into the scheduler's caller
|
||||
- on-update-loop – never throws into the scheduler's caller;
|
||||
reports the throttled loop as unhandled, with the events in flight
|
||||
- flash schedules async report and toast – neither the report nor
|
||||
ntf/show is emitted synchronously
|
||||
- organization SSO recovery – expired SSO sessions go back to the provider
|
||||
@ -554,6 +555,30 @@
|
||||
{ptk/in-flight-events (fn [_] (throw (js/Error. "in-flight read failure")))}
|
||||
(t/is (nil? (errors/on-update-loop {:rounds 26}))))))
|
||||
|
||||
(t/deftest ^:async update-loop-is-reported-with-the-events-in-flight
|
||||
(errors/reset-report-governor!)
|
||||
(vreset! @#'errors/update-loop-warned-at 0)
|
||||
(let [events (atom [])
|
||||
loop-ev (ptk/reify ::test-update-loop-source)]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||||
ptk/in-flight-events (fn [_] [loop-ev])
|
||||
st/format-last-events (mock/stub (fn [& _] "(stub last events)"))
|
||||
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||||
tm/schedule (mock/stub (fn [f] (f)))}
|
||||
(errors/on-update-loop {:rounds 26})
|
||||
;; A second trip within the window is not reported again
|
||||
(errors/on-update-loop {:rounds 26})
|
||||
(t/is (= 1 (count @events)))
|
||||
(let [props (deref (first @events))]
|
||||
;; An unhandled report: it reaches the error reports and alerts,
|
||||
;; not only the audit log.
|
||||
(t/is (= "unhandled-exception" (::ev/name props)))
|
||||
(t/is (= "update loop throttled" (:hint props)))
|
||||
(t/is (str/includes? (in-flight-section (:report props))
|
||||
"test-update-loop-source")))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Expired organization SSO session
|
||||
;;
|
||||
|
||||
@ -388,6 +388,48 @@
|
||||
(t/is (= [plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission"]
|
||||
(first @errors)))))))
|
||||
|
||||
(t/deftest variant-rename-property-sanitizes-name
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
id (uuid/next)
|
||||
renamed (atom nil)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly true)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}])
|
||||
dwv/update-property-name (fn [vid pos name opts]
|
||||
(reset! renamed {:id vid :pos pos :name name :opts opts})
|
||||
::rename-event)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (library/variant-proxy plugin-id file-id id)]
|
||||
(.renameProperty proxy 0 "a=b")
|
||||
(t/is (empty? @errors))
|
||||
(t/is (= "ab" (:name @renamed)))
|
||||
(.renameProperty proxy 0 "=,=")
|
||||
(t/is (= 1 (count @errors)))
|
||||
(t/is (= :name (second (first @errors))))))))
|
||||
|
||||
(t/deftest component-set-variant-property-sanitizes-value
|
||||
(let [plugin-id "test-plugin"
|
||||
file-id (uuid/next)
|
||||
id (uuid/next)
|
||||
updated (atom nil)
|
||||
errors (atom [])]
|
||||
(with-redefs [r/check-permission (constantly true)
|
||||
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
|
||||
u/locate-library-component (constantly {:id id :variant-properties [{:name "color"}]})
|
||||
dwv/update-property-value (fn [cid pos value]
|
||||
(reset! updated {:id cid :pos pos :value value})
|
||||
::value-event)
|
||||
st/emit! mock/noop]
|
||||
(let [proxy (library/lib-component-proxy plugin-id file-id id)]
|
||||
(.setVariantProperty proxy 0 "a=b")
|
||||
(t/is (empty? @errors))
|
||||
(t/is (= "ab" (:value @updated)))
|
||||
(.setVariantProperty proxy 0 "=,=")
|
||||
(t/is (empty? @errors))
|
||||
(t/is (= "" (:value @updated)))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; connectLibrary
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@ -147,6 +147,7 @@
|
||||
[frontend-tests.ui.text-attrs-multiple-test]
|
||||
[frontend-tests.ui.workspace-libraries-test]
|
||||
[frontend-tests.util-clipboard-test]
|
||||
[frontend-tests.util-http-test]
|
||||
[frontend-tests.util-object-test]
|
||||
[frontend-tests.util-queue-test]
|
||||
[frontend-tests.util-range-tree-test]
|
||||
@ -318,6 +319,7 @@
|
||||
'frontend-tests.ui.text-attrs-multiple-test
|
||||
'frontend-tests.ui.workspace-libraries-test
|
||||
'frontend-tests.util-clipboard-test
|
||||
'frontend-tests.util-http-test
|
||||
'frontend-tests.util-object-test
|
||||
'frontend-tests.util-queue-test
|
||||
'frontend-tests.util-range-tree-test
|
||||
|
||||
75
frontend/test/frontend_tests/util_http_test.cljs
Normal file
75
frontend/test/frontend_tests/util_http_test.cljs
Normal file
@ -0,0 +1,75 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns frontend-tests.util-http-test
|
||||
(:require
|
||||
[app.util.http :as http]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.async :as async]))
|
||||
|
||||
(def ^:private uri "https://example.com/assets/file.json")
|
||||
|
||||
(defn- failing-response
|
||||
"A response map whose native body read rejects with `cause`."
|
||||
[cause]
|
||||
{:status 200
|
||||
:uri uri
|
||||
:headers {}
|
||||
::http/response #js {:text (fn [] (js/Promise.reject cause))}})
|
||||
|
||||
(defn- ^:async read-error
|
||||
"Read the body of `response` as text and resolve with the error raised."
|
||||
[response]
|
||||
(try
|
||||
(await (async/->promise (http/process-response-type :text response)))
|
||||
nil
|
||||
(catch :default cause
|
||||
cause)))
|
||||
|
||||
(t/deftest ^:async body-read-network-failure-is-a-network-error
|
||||
(let [cause (js/TypeError. "network error")
|
||||
error (await (read-error (failing-response cause)))
|
||||
data (ex-data error)]
|
||||
(t/is (= :network (:type data)))
|
||||
(t/is (= uri (:uri data)))
|
||||
(t/is (identical? cause (ex-cause error)))))
|
||||
|
||||
(t/deftest ^:async body-parse-failure-passes-unchanged
|
||||
(let [cause (js/SyntaxError. "Unexpected token < in JSON")
|
||||
error (await (read-error (failing-response cause)))]
|
||||
(t/is (identical? cause error))))
|
||||
|
||||
(t/deftest ^:async body-read-programming-error-passes-unchanged
|
||||
(let [cause (js/TypeError. "body stream already read")
|
||||
error (await (read-error (failing-response cause)))]
|
||||
(t/is (identical? cause error))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; network-failure? tests
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- make-error
|
||||
[error-name message]
|
||||
(let [err (js/Error. message)]
|
||||
(set! (.-name err) error-name)
|
||||
err))
|
||||
|
||||
(t/deftest test-network-failure-browser-messages
|
||||
(t/testing "The TypeError each browser gives a failed fetch is a network failure"
|
||||
(doseq [message ["Failed to fetch"
|
||||
"Failed to fetch (design.penpot.app)"
|
||||
"NetworkError when attempting to fetch resource."
|
||||
"Load failed"
|
||||
"network error"
|
||||
"Error in body stream"]]
|
||||
(t/is (true? (http/network-failure? (make-error "TypeError" message)))
|
||||
message))))
|
||||
|
||||
(t/deftest test-network-failure-needs-type-error
|
||||
(t/testing "Other errors with the same message are not network failures"
|
||||
(t/is (false? (http/network-failure? (make-error "Error" "Failed to fetch")))))
|
||||
(t/testing "Other TypeErrors are not network failures"
|
||||
(t/is (false? (http/network-failure? (make-error "TypeError" "x is not a function"))))))
|
||||
@ -25,7 +25,7 @@
|
||||
},
|
||||
"packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1",
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.5",
|
||||
"prettier": "^3.9.9"
|
||||
"concurrently": "^10.0.6",
|
||||
"prettier": "^3.9.10"
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.2",
|
||||
"vite": "^8.3.5",
|
||||
"vite-live-preview": "^0.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,9 +33,9 @@
|
||||
"@modelcontextprotocol/server": "^2.3.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"express": "^5.2.1",
|
||||
"express": "^5.3.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"js-yaml": "^5.4.2",
|
||||
"js-yaml": "^5.5.0",
|
||||
"nrepl-client": "^0.3.0",
|
||||
"pino": "^10.4.0",
|
||||
"pino-loki": "^3.0.0",
|
||||
@ -43,14 +43,14 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sharp": "^0.35.5",
|
||||
"ws": "^8.22.0",
|
||||
"zod": "^4.6.5"
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/client": "^2.3.1",
|
||||
"@penpot/mcp-common": "workspace:../common",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/ws": "^8.18.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.28.1",
|
||||
|
||||
324
mcp/pnpm-lock.yaml
generated
324
mcp/pnpm-lock.yaml
generated
@ -171,11 +171,11 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
concurrently:
|
||||
specifier: ^10.0.5
|
||||
version: 10.0.5
|
||||
specifier: ^10.0.6
|
||||
version: 10.0.6
|
||||
prettier:
|
||||
specifier: ^3.9.9
|
||||
version: 3.9.9
|
||||
specifier: ^3.9.10
|
||||
version: 3.9.10
|
||||
|
||||
packages/common:
|
||||
devDependencies:
|
||||
@ -199,11 +199,11 @@ importers:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.3.2
|
||||
version: 8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15)
|
||||
specifier: ^8.3.5
|
||||
version: 8.3.5(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15)
|
||||
vite-live-preview:
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15))
|
||||
version: 0.4.0(vite@8.3.5(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15))
|
||||
|
||||
packages/server:
|
||||
dependencies:
|
||||
@ -220,14 +220,14 @@ importers:
|
||||
specifier: ^0.15.1
|
||||
version: 0.15.1
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1(supports-color@10.2.2)
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0(supports-color@10.2.2)
|
||||
ioredis:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0(supports-color@10.2.2)
|
||||
js-yaml:
|
||||
specifier: ^5.4.2
|
||||
version: 5.4.2
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
nrepl-client:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0
|
||||
@ -245,13 +245,13 @@ importers:
|
||||
version: 0.2.2
|
||||
sharp:
|
||||
specifier: ^0.35.5
|
||||
version: 0.35.5(@types/node@26.6.4)
|
||||
version: 0.35.5(@types/node@26.6.5)
|
||||
ws:
|
||||
specifier: ^8.22.0
|
||||
version: 8.22.0
|
||||
zod:
|
||||
specifier: ^4.6.5
|
||||
version: 4.6.5
|
||||
specifier: ^4.6.7
|
||||
version: 4.6.7
|
||||
devDependencies:
|
||||
'@modelcontextprotocol/client':
|
||||
specifier: ^2.3.1
|
||||
@ -266,8 +266,8 @@ importers:
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
'@types/node':
|
||||
specifier: ^26.6.4
|
||||
version: 26.6.4
|
||||
specifier: ^26.6.5
|
||||
version: 26.6.5
|
||||
'@types/ws':
|
||||
specifier: ^8.18.2
|
||||
version: 8.18.2
|
||||
@ -279,7 +279,7 @@ importers:
|
||||
version: 0.28.2
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@26.6.4)(typescript@6.0.3)
|
||||
version: 10.9.2(@types/node@26.6.5)(typescript@6.0.3)
|
||||
tsx:
|
||||
specifier: ^4.23.15
|
||||
version: 4.23.15
|
||||
@ -654,8 +654,8 @@ packages:
|
||||
resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@oxc-project/types@0.152.0':
|
||||
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
|
||||
'@oxc-project/types@0.155.0':
|
||||
resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
|
||||
|
||||
'@penpot/plugin-styles@1.5.0':
|
||||
resolution: {integrity: sha512-rwXFBRPd0IZs3ens+eRT8oA5WqkQgF6xlW6fCfH7U1NHRjej9HH7vTGXmfjoIVml6Xx1sbyYHZVxkQC8Ub8+zA==}
|
||||
@ -663,98 +663,98 @@ packages:
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.12':
|
||||
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
|
||||
'@rolldown/binding-android-arm-eabi@1.2.15':
|
||||
resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-android-arm64@1.2.12':
|
||||
resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
|
||||
'@rolldown/binding-android-arm64@1.2.15':
|
||||
resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.2.12':
|
||||
resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
|
||||
'@rolldown/binding-darwin-arm64@1.2.15':
|
||||
resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.2.12':
|
||||
resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
|
||||
'@rolldown/binding-darwin-x64@1.2.15':
|
||||
resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.2.12':
|
||||
resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
|
||||
'@rolldown/binding-freebsd-x64@1.2.15':
|
||||
resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.12':
|
||||
resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.15':
|
||||
resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.12':
|
||||
resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.15':
|
||||
resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.12':
|
||||
resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.15':
|
||||
resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.12':
|
||||
resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.15':
|
||||
resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.12':
|
||||
resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.15':
|
||||
resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.12':
|
||||
resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.15':
|
||||
resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.2.12':
|
||||
resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
|
||||
'@rolldown/binding-linux-x64-musl@1.2.15':
|
||||
resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.2.12':
|
||||
resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
|
||||
'@rolldown/binding-openharmony-arm64@1.2.15':
|
||||
resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.12':
|
||||
resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.15':
|
||||
resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.12':
|
||||
resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.15':
|
||||
resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@ -801,6 +801,9 @@ packages:
|
||||
'@types/node@26.6.4':
|
||||
resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==}
|
||||
|
||||
'@types/node@26.6.5':
|
||||
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
|
||||
|
||||
'@types/qs@6.15.1':
|
||||
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
||||
|
||||
@ -843,8 +846,8 @@ packages:
|
||||
arg@4.1.3:
|
||||
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
argparse@3.0.4:
|
||||
resolution: {integrity: sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
@ -853,8 +856,8 @@ packages:
|
||||
bencode@2.0.3:
|
||||
resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==}
|
||||
|
||||
body-parser@2.3.0:
|
||||
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
|
||||
body-parser@2.4.0:
|
||||
resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
bytes@3.1.2:
|
||||
@ -890,19 +893,15 @@ packages:
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
concurrently@10.0.5:
|
||||
resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==}
|
||||
concurrently@10.0.6:
|
||||
resolution: {integrity: sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==}
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
|
||||
content-disposition@1.1.0:
|
||||
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
|
||||
content-disposition@2.0.1:
|
||||
resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
content-type@1.0.5:
|
||||
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
content-type@2.1.0:
|
||||
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
|
||||
engines: {node: '>=18'}
|
||||
@ -1012,8 +1011,8 @@ packages:
|
||||
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
express@5.3.0:
|
||||
resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
fast-copy@4.1.2:
|
||||
@ -1118,8 +1117,8 @@ packages:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
js-yaml@5.4.2:
|
||||
resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==}
|
||||
js-yaml@5.5.0:
|
||||
resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==}
|
||||
hasBin: true
|
||||
|
||||
libphonenumber-js@1.13.14:
|
||||
@ -1248,8 +1247,8 @@ packages:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
on-finished@2.4.2:
|
||||
resolution: {integrity: sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
once@1.4.0:
|
||||
@ -1296,17 +1295,17 @@ packages:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
|
||||
postcss@8.5.29:
|
||||
resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
|
||||
postcss@8.5.32:
|
||||
resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prettier@3.9.9:
|
||||
resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==}
|
||||
prettier@3.9.10:
|
||||
resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
process-warning@5.1.0:
|
||||
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
|
||||
process-warning@5.1.1:
|
||||
resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==}
|
||||
|
||||
proxy-addr@2.0.8:
|
||||
resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
|
||||
@ -1319,6 +1318,10 @@ packages:
|
||||
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
qs@6.17.0:
|
||||
resolution: {integrity: sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
@ -1340,8 +1343,8 @@ packages:
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
rolldown@1.2.12:
|
||||
resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==}
|
||||
rolldown@1.2.15:
|
||||
resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@ -1359,8 +1362,8 @@ packages:
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
secure-json-parse@4.1.0:
|
||||
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||
secure-json-parse@4.1.1:
|
||||
resolution: {integrity: sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==}
|
||||
|
||||
semver@7.8.5:
|
||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||
@ -1522,13 +1525,13 @@ packages:
|
||||
peerDependencies:
|
||||
vite: '>=5.4.0'
|
||||
|
||||
vite@8.3.2:
|
||||
resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
|
||||
vite@8.3.5:
|
||||
resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
'@vitejs/devtools': ^0.7.1
|
||||
'@vitejs/devtools': ^0.7.1 || ^0.8.0
|
||||
esbuild: ^0.27.0 || ^0.28.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
@ -1605,8 +1608,8 @@ packages:
|
||||
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
zod@4.6.5:
|
||||
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
|
||||
zod@4.6.7:
|
||||
resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
@ -1828,11 +1831,11 @@ snapshots:
|
||||
eventsource-parser: 3.1.1
|
||||
jose: 6.2.12
|
||||
pkce-challenge: 5.0.1
|
||||
zod: 4.6.5
|
||||
zod: 4.6.7
|
||||
|
||||
'@modelcontextprotocol/core@2.3.1':
|
||||
dependencies:
|
||||
zod: 4.6.5
|
||||
zod: 4.6.7
|
||||
|
||||
'@modelcontextprotocol/node@2.1.1(@modelcontextprotocol/server@2.3.1)':
|
||||
dependencies:
|
||||
@ -1843,57 +1846,57 @@ snapshots:
|
||||
'@modelcontextprotocol/server@2.3.1':
|
||||
dependencies:
|
||||
'@modelcontextprotocol/core': 2.3.1
|
||||
zod: 4.6.5
|
||||
zod: 4.6.7
|
||||
|
||||
'@oxc-project/types@0.152.0': {}
|
||||
'@oxc-project/types@0.155.0': {}
|
||||
|
||||
'@penpot/plugin-styles@1.5.0': {}
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.12':
|
||||
'@rolldown/binding-android-arm-eabi@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-android-arm64@1.2.12':
|
||||
'@rolldown/binding-android-arm64@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.2.12':
|
||||
'@rolldown/binding-darwin-arm64@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.2.12':
|
||||
'@rolldown/binding-darwin-x64@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.2.12':
|
||||
'@rolldown/binding-freebsd-x64@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.12':
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.12':
|
||||
'@rolldown/binding-linux-arm64-gnu@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.12':
|
||||
'@rolldown/binding-linux-arm64-musl@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.12':
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.12':
|
||||
'@rolldown/binding-linux-s390x-gnu@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.12':
|
||||
'@rolldown/binding-linux-x64-gnu@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.2.12':
|
||||
'@rolldown/binding-linux-x64-musl@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.2.12':
|
||||
'@rolldown/binding-openharmony-arm64@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.12':
|
||||
'@rolldown/binding-win32-arm64-msvc@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.12':
|
||||
'@rolldown/binding-win32-x64-msvc@1.2.15':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
@ -1913,15 +1916,15 @@ snapshots:
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 26.6.4
|
||||
'@types/node': 26.6.5
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 26.6.4
|
||||
'@types/node': 26.6.5
|
||||
|
||||
'@types/express-serve-static-core@5.1.3':
|
||||
dependencies:
|
||||
'@types/node': 26.6.4
|
||||
'@types/node': 26.6.5
|
||||
'@types/qs': 6.15.1
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
@ -1940,18 +1943,22 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 8.9.0
|
||||
|
||||
'@types/node@26.6.5':
|
||||
dependencies:
|
||||
undici-types: 8.9.0
|
||||
|
||||
'@types/qs@6.15.1': {}
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 26.6.4
|
||||
'@types/node': 26.6.5
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 26.6.4
|
||||
'@types/node': 26.6.5
|
||||
|
||||
'@types/validator@13.15.10': {}
|
||||
|
||||
@ -1976,21 +1983,21 @@ snapshots:
|
||||
|
||||
arg@4.1.3: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
argparse@3.0.4: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
bencode@2.0.3: {}
|
||||
|
||||
body-parser@2.3.0(supports-color@10.2.2):
|
||||
body-parser@2.4.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 2.1.0
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.3
|
||||
on-finished: 2.4.1
|
||||
qs: 6.16.0
|
||||
on-finished: 2.4.2
|
||||
qs: 6.17.0
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
@ -2028,7 +2035,7 @@ snapshots:
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
concurrently@10.0.5:
|
||||
concurrently@10.0.6:
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
rxjs: 7.8.2
|
||||
@ -2037,9 +2044,7 @@ snapshots:
|
||||
tree-kill: 1.2.2
|
||||
yargs: 18.0.0
|
||||
|
||||
content-disposition@1.1.0: {}
|
||||
|
||||
content-type@1.0.5: {}
|
||||
content-disposition@2.0.1: {}
|
||||
|
||||
content-type@2.1.0: {}
|
||||
|
||||
@ -2143,12 +2148,12 @@ snapshots:
|
||||
dependencies:
|
||||
eventsource-parser: 3.1.1
|
||||
|
||||
express@5.2.1(supports-color@10.2.2):
|
||||
express@5.3.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.3.0(supports-color@10.2.2)
|
||||
content-disposition: 1.1.0
|
||||
content-type: 1.0.5
|
||||
body-parser: 2.4.0(supports-color@10.2.2)
|
||||
content-disposition: 2.0.1
|
||||
content-type: 2.1.0
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
@ -2161,7 +2166,7 @@ snapshots:
|
||||
http-errors: 2.0.1
|
||||
merge-descriptors: 2.0.0
|
||||
mime-types: 3.0.2
|
||||
on-finished: 2.4.1
|
||||
on-finished: 2.4.2
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.8
|
||||
@ -2189,7 +2194,7 @@ snapshots:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
on-finished: 2.4.2
|
||||
parseurl: 1.3.3
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
@ -2273,9 +2278,9 @@ snapshots:
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-yaml@5.4.2:
|
||||
js-yaml@5.5.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
argparse: 3.0.4
|
||||
|
||||
libphonenumber-js@1.13.14: {}
|
||||
|
||||
@ -2361,7 +2366,7 @@ snapshots:
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
on-finished@2.4.2:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
||||
@ -2400,7 +2405,7 @@ snapshots:
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 3.0.0
|
||||
pump: 3.0.4
|
||||
secure-json-parse: 4.1.0
|
||||
secure-json-parse: 4.1.1
|
||||
sonic-boom: 5.0.1
|
||||
strip-json-comments: 5.0.3
|
||||
|
||||
@ -2413,7 +2418,7 @@ snapshots:
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 3.0.0
|
||||
pino-std-serializers: 7.1.0
|
||||
process-warning: 5.1.0
|
||||
process-warning: 5.1.1
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 1.0.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
@ -2422,15 +2427,15 @@ snapshots:
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
postcss@8.5.29:
|
||||
postcss@8.5.32:
|
||||
dependencies:
|
||||
nanoid: 3.3.20
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
prettier@3.9.9: {}
|
||||
prettier@3.9.10: {}
|
||||
|
||||
process-warning@5.1.0: {}
|
||||
process-warning@5.1.1: {}
|
||||
|
||||
proxy-addr@2.0.8:
|
||||
dependencies:
|
||||
@ -2447,6 +2452,11 @@ snapshots:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
|
||||
qs@6.17.0:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
range-parser@1.3.0: {}
|
||||
@ -2464,26 +2474,26 @@ snapshots:
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
rolldown@1.2.12:
|
||||
rolldown@1.2.15:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.152.0
|
||||
'@oxc-project/types': 0.155.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.12
|
||||
'@rolldown/binding-android-arm64': 1.2.12
|
||||
'@rolldown/binding-darwin-arm64': 1.2.12
|
||||
'@rolldown/binding-darwin-x64': 1.2.12
|
||||
'@rolldown/binding-freebsd-x64': 1.2.12
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.12
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.12
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.12
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.12
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.12
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.12
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.15
|
||||
'@rolldown/binding-android-arm64': 1.2.15
|
||||
'@rolldown/binding-darwin-arm64': 1.2.15
|
||||
'@rolldown/binding-darwin-x64': 1.2.15
|
||||
'@rolldown/binding-freebsd-x64': 1.2.15
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.15
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.15
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.15
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.15
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.15
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.15
|
||||
|
||||
router@2.2.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
@ -2503,7 +2513,7 @@ snapshots:
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
secure-json-parse@4.1.0: {}
|
||||
secure-json-parse@4.1.1: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
@ -2517,7 +2527,7 @@ snapshots:
|
||||
http-errors: 2.0.1
|
||||
mime-types: 3.0.2
|
||||
ms: 2.1.3
|
||||
on-finished: 2.4.1
|
||||
on-finished: 2.4.2
|
||||
range-parser: 1.3.0
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
@ -2534,7 +2544,7 @@ snapshots:
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
sharp@0.35.5(@types/node@26.6.4):
|
||||
sharp@0.35.5(@types/node@26.6.5):
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
@ -2565,7 +2575,7 @@ snapshots:
|
||||
'@img/sharp-win32-arm64': 0.35.5
|
||||
'@img/sharp-win32-ia32': 0.35.5
|
||||
'@img/sharp-win32-x64': 0.35.5
|
||||
'@types/node': 26.6.4
|
||||
'@types/node': 26.6.5
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
@ -2646,14 +2656,14 @@ snapshots:
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
ts-node@10.9.2(@types/node@26.6.4)(typescript@6.0.3):
|
||||
ts-node@10.9.2(@types/node@26.6.5)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
'@tsconfig/node10': 1.0.13
|
||||
'@tsconfig/node12': 1.0.11
|
||||
'@tsconfig/node14': 1.0.3
|
||||
'@tsconfig/node16': 1.0.4
|
||||
'@types/node': 26.6.4
|
||||
'@types/node': 26.6.5
|
||||
acorn: 8.19.0
|
||||
acorn-walk: 8.3.5
|
||||
arg: 4.1.3
|
||||
@ -2690,25 +2700,25 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite-live-preview@0.4.0(vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15)):
|
||||
vite-live-preview@0.4.0(vite@8.3.5(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15)):
|
||||
dependencies:
|
||||
'@seahax/deep-copy': 0.1.0
|
||||
'@seahax/semaphore': 0.5.1
|
||||
'@types/ws': 8.18.2
|
||||
escape-goat: 4.0.0
|
||||
strip-ansi: 7.2.0
|
||||
vite: 8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15)
|
||||
vite: 8.3.5(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15)
|
||||
ws: 8.22.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
vite@8.3.2(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15):
|
||||
vite@8.3.5(@types/node@26.6.4)(esbuild@0.28.2)(tsx@4.23.15):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.29
|
||||
rolldown: 1.2.12
|
||||
postcss: 8.5.32
|
||||
rolldown: 1.2.15
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.4
|
||||
@ -2745,4 +2755,4 @@ snapshots:
|
||||
|
||||
yn@3.1.1: {}
|
||||
|
||||
zod@4.6.5: {}
|
||||
zod@4.6.7: {}
|
||||
|
||||
38
scripts/ci
38
scripts/ci
@ -24,7 +24,7 @@ BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Available modules
|
||||
ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugins" "library")
|
||||
ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugins" "library" "media-processor")
|
||||
|
||||
# Module commands
|
||||
declare -A LINT_CMD=(
|
||||
@ -36,6 +36,7 @@ declare -A LINT_CMD=(
|
||||
[mcp]=""
|
||||
[plugins]="pnpm run lint"
|
||||
[library]="pnpm run lint"
|
||||
[media-processor]=""
|
||||
)
|
||||
|
||||
declare -A TEST_CMD=(
|
||||
@ -47,6 +48,7 @@ declare -A TEST_CMD=(
|
||||
[mcp]="pnpm run test"
|
||||
[plugins]="pnpm run test"
|
||||
[library]="pnpm run test"
|
||||
[media-processor]="pnpm run test"
|
||||
)
|
||||
|
||||
declare -A FMT_CHECK_CMD=(
|
||||
@ -58,6 +60,7 @@ declare -A FMT_CHECK_CMD=(
|
||||
[mcp]="pnpm run fmt:check"
|
||||
[plugins]="pnpm run format:check"
|
||||
[library]="pnpm run check-fmt:clj"
|
||||
[media-processor]="pnpm run fmt:check"
|
||||
)
|
||||
|
||||
declare -A FMT_FIX_CMD=(
|
||||
@ -69,6 +72,19 @@ declare -A FMT_FIX_CMD=(
|
||||
[mcp]="pnpm run fmt"
|
||||
[plugins]="pnpm run format"
|
||||
[library]="pnpm run fmt:clj"
|
||||
[media-processor]="pnpm run fmt"
|
||||
)
|
||||
|
||||
declare -A BUILD_CMD=(
|
||||
[frontend]="NODE_ENV=production pnpm run build:app:main && pnpm run build:app:libs && pnpm run build:app:assets"
|
||||
[backend]="clojure -T:build jar"
|
||||
[common]=""
|
||||
[render-wasm]="./build"
|
||||
[exporter]="NODE_ENV=production pnpm run build:app"
|
||||
[mcp]="pnpm run build"
|
||||
[plugins]="pnpm run build:runtime && pnpm run build:plugins && pnpm run build:styles-example && pnpm run build:doc"
|
||||
[library]="NODE_ENV=production clojure -M:dev:shadow-cljs release library"
|
||||
[media-processor]="pnpm run types:check && pnpm run build"
|
||||
)
|
||||
|
||||
declare -A PAREN_REPAIR_CMD=(
|
||||
@ -80,6 +96,7 @@ declare -A PAREN_REPAIR_CMD=(
|
||||
[mcp]=""
|
||||
[plugins]=""
|
||||
[library]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair"
|
||||
[media-processor]=""
|
||||
)
|
||||
|
||||
# Default options
|
||||
@ -197,6 +214,9 @@ run_module() {
|
||||
cmd="${FMT_CHECK_CMD[$module]:-}"
|
||||
fi
|
||||
;;
|
||||
build)
|
||||
cmd="${BUILD_CMD[$module]:-}"
|
||||
;;
|
||||
paren-repair)
|
||||
cmd="${PAREN_REPAIR_CMD[$module]:-}"
|
||||
;;
|
||||
@ -282,8 +302,8 @@ Usage: $(basename "$0") [OPTIONS] [MODULES...]
|
||||
CI orchestration script for Penpot monorepo.
|
||||
|
||||
MODULES:
|
||||
frontend, backend, common, render-wasm, exporter, mcp, plugins, library
|
||||
Use --all to run all modules.
|
||||
frontend, backend, common, render-wasm, exporter, mcp, plugins, library,
|
||||
media-processor. Use --all to run all modules.
|
||||
|
||||
OPTIONS:
|
||||
--all Run all modules
|
||||
@ -294,6 +314,8 @@ OPTIONS:
|
||||
--no-test Skip tests
|
||||
--fmt Run format check only
|
||||
--no-fmt Skip format check
|
||||
--build Run production build only
|
||||
--no-build Skip build
|
||||
--fix Run format fix instead of check
|
||||
--paren-repair Fix delimiter errors in Clojure/CLJS files
|
||||
--fail-fast Stop on first failure
|
||||
@ -307,6 +329,8 @@ EXAMPLES:
|
||||
$(basename "$0") frontend backend # Run lint, test, fmt on frontend and backend
|
||||
$(basename "$0") --all --no-test # Run lint and fmt on all modules
|
||||
$(basename "$0") --lint common # Run only lint on common
|
||||
$(basename "$0") --build frontend # Run only the production build of frontend
|
||||
$(basename "$0") --build --all # Build every module
|
||||
$(basename "$0") --fix frontend # Fix formatting in frontend
|
||||
$(basename "$0") --paren-repair frontend # Fix delimiter errors in frontend
|
||||
$(basename "$0") --paren-repair --all # Fix delimiter errors in all Clojure modules
|
||||
@ -353,6 +377,14 @@ main() {
|
||||
TASKS=("${TASKS[@]/fmt/}")
|
||||
shift
|
||||
;;
|
||||
--build)
|
||||
TASKS=("build")
|
||||
shift
|
||||
;;
|
||||
--no-build)
|
||||
TASKS=("${TASKS[@]/build/}")
|
||||
shift
|
||||
;;
|
||||
--paren-repair)
|
||||
TASKS=("paren-repair")
|
||||
shift
|
||||
|
||||
@ -27,7 +27,8 @@ Options:
|
||||
-h, --help Show this help.
|
||||
|
||||
external/ (vendored trees with their own lifecycles), .opencode/,
|
||||
.pnpm-store/, bundles/ and docker build-context copies are never stamped.
|
||||
.pnpm-store/, bundles/, build output (target/) and docker build-context
|
||||
copies are never stamped.
|
||||
EOF
|
||||
}
|
||||
|
||||
@ -91,7 +92,7 @@ mapfile -t files < <(
|
||||
find . \
|
||||
\( -name .git -o -name node_modules -o -name .pnpm-store \
|
||||
-o -name external -o -name .opencode -o -name bundles \
|
||||
-o -name .angular \) -type d -prune \
|
||||
-o -name target -o -name .angular \) -type d -prune \
|
||||
-o -path './docker/images/bundle-*' -prune \
|
||||
-o -name package.json -type f -print | sort
|
||||
)
|
||||
|
||||
159
scripts/test_ci.py
Normal file
159
scripts/test_ci.py
Normal file
@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for scripts/ci.
|
||||
|
||||
Run with:
|
||||
|
||||
python3 scripts/test_ci.py
|
||||
|
||||
Covers the wiring of the orchestrator around the `build` task and the
|
||||
`media-processor` module: the flag set, one build command per module, the
|
||||
help text, the dry-run mode (which must execute and write nothing), and the
|
||||
default task list staying free of build.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
CI = REPO_ROOT / "scripts" / "ci"
|
||||
LOG_DIR = REPO_ROOT / ".ci-logs"
|
||||
|
||||
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
# Modules with a production build. `common` has no build of its own: its
|
||||
# compilation happens in its test task and in the modules consuming it.
|
||||
BUILD_MODULES = (
|
||||
"frontend",
|
||||
"backend",
|
||||
"render-wasm",
|
||||
"exporter",
|
||||
"mcp",
|
||||
"plugins",
|
||||
"library",
|
||||
"media-processor",
|
||||
)
|
||||
|
||||
ALL_MODULES = BUILD_MODULES + ("common",)
|
||||
|
||||
|
||||
def run_ci(*args, cwd=REPO_ROOT):
|
||||
"""Run scripts/ci and return (exit_code, output without ANSI escapes)."""
|
||||
proc = subprocess.run(
|
||||
[str(CI), *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return proc.returncode, ANSI.sub("", proc.stdout + proc.stderr)
|
||||
|
||||
|
||||
def log_snapshot():
|
||||
"""Names and mtimes of every entry under .ci-logs, or None when absent."""
|
||||
if not LOG_DIR.exists():
|
||||
return None
|
||||
return sorted((p.name, p.stat().st_mtime_ns) for p in LOG_DIR.iterdir())
|
||||
|
||||
|
||||
class BuildTaskTests(unittest.TestCase):
|
||||
def test_build_all_has_one_command_per_module_without_common(self):
|
||||
code, out = run_ci("--build", "--dry-run", "--all")
|
||||
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertEqual(out.count("build: would run"), len(BUILD_MODULES), out)
|
||||
self.assertIn("build: not defined for common, skipping", out)
|
||||
for module in BUILD_MODULES:
|
||||
self.assertIn(f" in {module}/", out)
|
||||
|
||||
def test_build_is_not_part_of_the_default_tasks(self):
|
||||
code, out = run_ci("--dry-run", "--all")
|
||||
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertNotIn("build:", out)
|
||||
for task in ("lint", "test", "fmt"):
|
||||
self.assertIn(f"{task}: would run", out)
|
||||
|
||||
def test_build_flag_runs_build_only(self):
|
||||
code, out = run_ci("--build", "--dry-run", "frontend")
|
||||
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertIn("build: would run", out)
|
||||
for task in ("lint", "test", "fmt"):
|
||||
self.assertNotIn(f"{task}:", out)
|
||||
|
||||
def test_no_build_flag_is_accepted(self):
|
||||
code, out = run_ci("--no-build", "--dry-run", "frontend")
|
||||
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertIn("lint: would run", out)
|
||||
self.assertNotIn("build:", out)
|
||||
|
||||
def test_dry_run_executes_nothing(self):
|
||||
before = log_snapshot()
|
||||
|
||||
run_ci("--build", "--dry-run", "mcp")
|
||||
|
||||
self.assertEqual(before, log_snapshot())
|
||||
|
||||
|
||||
class MediaProcessorModuleTests(unittest.TestCase):
|
||||
def test_module_is_accepted_and_lint_is_skipped(self):
|
||||
code, out = run_ci("--dry-run", "media-processor")
|
||||
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertIn(
|
||||
"lint: not defined for media-processor, skipping",
|
||||
out,
|
||||
)
|
||||
for task in ("test", "fmt"):
|
||||
self.assertIn(f"{task}: would run", out)
|
||||
|
||||
def test_module_is_included_in_all(self):
|
||||
code, out = run_ci("--dry-run", "--all")
|
||||
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertIn("media-processor", out)
|
||||
self.assertIn("media-processor:test", out)
|
||||
|
||||
def test_build_runs_types_check_before_esbuild(self):
|
||||
code, out = run_ci("--build", "--dry-run", "media-processor")
|
||||
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertIn(
|
||||
"pnpm run types:check && pnpm run build",
|
||||
out,
|
||||
)
|
||||
|
||||
|
||||
class HelpTests(unittest.TestCase):
|
||||
def help_options(self, out):
|
||||
"""Option tokens from the OPTIONS section, e.g. '--build'."""
|
||||
options = []
|
||||
for line in out.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("--"):
|
||||
options.append(stripped.split()[0])
|
||||
return options
|
||||
|
||||
def test_help_documents_the_build_flags(self):
|
||||
code, out = run_ci("--help")
|
||||
|
||||
self.assertEqual(code, 0, out)
|
||||
options = self.help_options(out)
|
||||
self.assertIn("--build", options)
|
||||
self.assertIn("--no-build", options)
|
||||
|
||||
def test_help_lists_every_module(self):
|
||||
code, out = run_ci("--help")
|
||||
|
||||
self.assertEqual(code, 0, out)
|
||||
for module in ALL_MODULES:
|
||||
self.assertIn(module, out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -134,6 +134,23 @@ class SyncPnpmVersionTests(unittest.TestCase):
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
self.assertIn("missing/package.json", proc.stdout + proc.stderr)
|
||||
|
||||
def test_target_dirs_are_never_stamped(self):
|
||||
generated = self.tmp / "module" / "target"
|
||||
generated.mkdir(parents=True)
|
||||
package = generated / "package.json"
|
||||
package.write_text(
|
||||
'{\n'
|
||||
' "name": "generated",\n'
|
||||
' "packageManager": "pnpm@10.0.0+sha512.' + "00" * 64 + '"\n'
|
||||
'}\n'
|
||||
)
|
||||
before = package.read_bytes()
|
||||
|
||||
self.assertEqual(self.stamp().returncode, 0)
|
||||
self.assertEqual(package.read_bytes(), before)
|
||||
# The stale copy must not make --check fail either.
|
||||
self.assertEqual(self.stamp("--check").returncode, 0)
|
||||
|
||||
def test_stamp_is_idempotent(self):
|
||||
self.assertEqual(self.stamp().returncode, 0)
|
||||
first = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user