mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 05:34:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c05d4fd6a1 | ||
|
|
950e348357 | ||
|
|
a18a818e8b | ||
|
|
5cb4ea0623 |
@@ -123,3 +123,28 @@ jobs:
|
||||
sys.exit(1)
|
||||
print(f"Validated {len(manifests)} manifest(s) — OK")
|
||||
EOF
|
||||
|
||||
lint:
|
||||
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
|
||||
# dev tooling, never on the serve/Docker path — same category as
|
||||
# scripts/build-tailwind.sh). max-lines WARNS (the 1,500-line size ratchet;
|
||||
# non-blocking), while import-x/no-unresolved + no-cycle HARD-ERROR on the
|
||||
# ES-module graphs the refactor produces. Exemptions: docs/size-exemptions.md.
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# npm ci runs third-party postinstall scripts; don't leave the token in
|
||||
# git config for them (this job never pushes).
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ESLint (size norm + module hygiene)
|
||||
run: npm run lint
|
||||
|
||||
@@ -48,11 +48,30 @@ output is committed, so Docker / desktop / end users never build. New
|
||||
features extend `app.js` and the existing globals (`window.playSong`,
|
||||
`window.showScreen`, `window.createHighway`, `window.feedBack`).
|
||||
|
||||
Native ES modules are a first-class, build-free extension mechanism.
|
||||
Because `<script type="module">` and `import` are browser features — not
|
||||
a bundler — a large source file MAY be split into an `import`-ed module
|
||||
graph of plain source files, with **no build step and no framework**. A
|
||||
plugin opts in with `"scriptType": "module"` in `plugin.json`: its
|
||||
`screen.js` becomes a one-line `import './src/main.js'`, and the host
|
||||
serves the `src/` subtree from the sandboxed `/api/plugins/<id>/src/…`
|
||||
route and injects the entry as `<script type="module">`. The classic
|
||||
global-scope `screen.js` path remains fully supported; both coexist, and
|
||||
module scripts are still source-served — the no-bundler, no-transpiler,
|
||||
build-free-at-serve rule is unchanged. Core's own `static/` tree may
|
||||
migrate to the same module-graph shape (`static/js/…`) over time under
|
||||
this rule.
|
||||
|
||||
**Non-negotiable rules**
|
||||
|
||||
- Do not introduce a frontend framework, JSX, or a JS build pipeline in
|
||||
core. Plugins MAY ship their own bundled assets but core MUST remain
|
||||
source-served.
|
||||
- ES-module plugins remain source-served: no bundler or transpiler, and
|
||||
their own asset URLs (worklets, WASM, images) resolve via
|
||||
`import.meta.url` — never `document.currentScript`, which is `null`
|
||||
inside a module. `scriptType:"module"` and the optional `minHost`
|
||||
version floor are the only new `plugin.json` keys the module path adds.
|
||||
- Because the core Tailwind stylesheet is prebuilt, it contains only the
|
||||
classes present in core source at build time. Core's committed
|
||||
`static/tailwind.min.css` MUST stay in sync with source — CI enforces
|
||||
@@ -214,6 +233,15 @@ no `..`, no absolute paths).
|
||||
runs first). Plugins MUST tolerate dependent globals being absent
|
||||
at load time and check at runtime
|
||||
(`typeof window.X === 'function'`).
|
||||
- **Module load contract**: a `scriptType:"module"` plugin is injected
|
||||
as `<script type="module">`, whose load event fires only after its
|
||||
whole static-import graph fetches and evaluates — so the loader's
|
||||
completion-by-`onload` guarantee (and the `playSong` wrapper-chain
|
||||
order above) is preserved exactly. The host loads `screen.js` once per
|
||||
version and `showScreen` re-injects nothing, so a plugin's per-visit
|
||||
re-initialization comes from its `screen:changed` handler, not from
|
||||
screen.js re-running; ES-module plugins inherit this unchanged (module
|
||||
top-level code does not re-execute on same-version re-mount).
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -256,4 +284,4 @@ no `..`, no absolute paths).
|
||||
higher-numbered principle's escape hatch is to live in a plugin
|
||||
with its own bundled assets.
|
||||
|
||||
**Version**: 1.1.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-06-01
|
||||
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
|
||||
|
||||
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
|
||||
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
|
||||
- **Perf-baseline harness (R0).** `scripts/perf-baseline.mjs` (maintainer-only, Playwright-driven) captures server p50/p95/p99 latency, cold boot-to-interactive, JS-heap after an idle soak, and the injected plugin-script shape (how many are `type=module`), so every refactor phase can be checked for "screen-entry and frame-time no worse." Methodology + the R0 baseline live in `docs/perf-baseline.md`; playback frame-time and chart-loaded screen-entry rows need a seeded library and are re-taken per environment.
|
||||
- **Sort and filter the library by your personal difficulty rating — now visible at a glance, not just in the edit drawer.** `song_user_meta.user_difficulty` (the 1–5 planning rating, settable manually or seeded by a plugin like the community `difficulty_tagger`) already existed but was only readable by opening a song's per-song details drawer. The library API gains `sort=difficulty` / `sort=difficulty-desc` — a correlated subquery over `song_user_meta`, following the same unrated-rows-sort-to-the-bottom-in-both-directions pattern as the existing `mastery` sort — and library cards now show the rating as a `◆N` badge (v2 grid/tree views and the v3 grid alike), next to the tuning and lyrics badges. The classic tree view's `query_artists` batch-attaches `user_difficulty` the same way `query_page` already did for the grid, so the badge actually renders there too instead of staying dark. Tests: `tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom`, `tests/test_library_filters.py::test_tree_view_songs_carry_user_difficulty`.
|
||||
- **`lib/midi_import.py`: `convert_midi_tempo_map` — MIDI imports can finally carry
|
||||
their bars.** The keys/drums note converters always computed a tempo-aware
|
||||
@@ -31,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
- **3D Keys Highway: key layout modes, lane-color opacity & octave lines.** A new **Highway layout** settings section rebuilds how sharps/flats and lanes draw on the 3D piano highway. **Sharps & flats layout** (`keys3d_bg_sharpMode`) picks between **floating** (the original raised-sharp look), **flat** (one plane, zero-overlap piano-shaped tiled lanes with the naturals evened out), and **realistic** (one plane, bars sized like the physical keys) — default **realistic**; the geometry lives in pure, unit-tested `laneSpanFlat()`/`laneSpanReal()` helpers. **Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the pitch-class lane tint from full vivid color down to a dark floor with guide lines only at the key-block boundaries (E→F and each octave); the lane strips, per-lane separators and block lines crossfade with the value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) and **Octave line contrast** (`keys3d_bg_octaveContrast`, 0–1) control the B→C octave divider, which auto-shifts from a dark to a bright layer as lane opacity fades. Settings re-read on init and apply on the next chart build. `plugins/keys_highway_3d` → 0.2.0. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (new defaults, sharp-mode setting, lane-geometry tiling/evening for flat, uniform/overlap for realistic, and an active-range boundary case where a white key's edge stays untrimmed when its neighboring sharp falls outside the active range).
|
||||
- **Unmapped-percussion capture now records velocities alongside times.** Both drum converters' opt-in `out_unmapped` reporting (`lib/midi_import.py` `convert_drum_track_from_midi`, `lib/gp2rs.py` `convert_drum_track_to_drumtab`) gain an index-aligned `velocities` list next to `times`, carrying each dropped note's real dynamics (MIDI velocity verbatim; GP velocity with the same 1–127 gate as mapped hits, falling back to the 100 import default). This lets a hand-mapping UI (the editor's unmapped-notes dialog) restore mapped notes at their source dynamics instead of flattening everything to `v:100`. The GP path's chronological sort now reorders times and velocities in lockstep so multi-voice measures can't silently reassign dynamics. Additive — callers that ignore the new key are unaffected. Tests: `tests/test_midi_import_drums.py`, `tests/test_gp2rs_drums.py`.
|
||||
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
|
||||
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing Okabe–Ito "Colorblind-friendly" preset — contributed by a deuteranopic player who found the Okabe–Ito set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
|
||||
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117–123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
|
||||
@@ -40,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
|
||||
|
||||
### Fixed
|
||||
- **`playback.loop-api` bridge no longer fires dozens of times per second.** Every `window.feedBack.getLoop()` call recorded a full bridge hit — compat-shim bookkeeping, a `playback:bridge-hit` event, and a diagnostics snapshot rebuild + stringify — so a plugin polling loop state from a HUD tick (note_detect at ~30 Hz) flooded the capability inspector and burned main-thread time even with no song playing. `_recordPlaybackBridge` now throttles per bridge/surface (5 s window): bridge hits are a "surface still in use" signal, not a call counter. The manual A/B loop buttons (`setLoopEnd`) also now emit the same `loop-set` transport event as `setLoop()`, so plugins can react to loop changes via `playback:loop-set` / `playback:loop-cleared` events instead of polling `getLoop()`.
|
||||
- **3D Highway: recover from a WebGL context loss instead of crashing on alt-tab.** Switching the active window / alt-tabbing away from the app (most often on Windows) can trigger a GPU context reset; the 3D highway's WebGL renderer had **no `webglcontextlost` handler**, so a lost context was left to escalate into a render-process crash — matching the intermittent "randomly crashes when I change windows" desktop reports. The renderer now binds `webglcontextlost`/`webglcontextrestored` on its own WebGL canvas (`ren.domElement`): the loss is `preventDefault()`'d so the browser keeps the context restorable, `draw()` bails while the context is down so no GL work runs on a dead context, and on restore the viewport is re-applied and rendering resumes (Three re-uploads scene resources on the next frame). Listeners are torn down with the renderer. `plugins/highway_3d` → 3.31.3. Tests: `tests/js/highway_3d_context_loss.test.js`. (The sibling `keys_highway_3d` / `drum_highway_3d` renderers share the same gap — tracked as a follow-up in their repos.)
|
||||
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
|
||||
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
|
||||
|
||||
@@ -117,6 +117,8 @@ Notes:
|
||||
|
||||
**Frontend scripts** — `screen.js` runs in the global scope via a `<script>` tag. It can access `window.playSong`, `window.showScreen`, `window.createHighway`, the `<audio>` element, and the `window.feedBack` event emitter.
|
||||
|
||||
**ES-module plugins (`scriptType:"module"`)** — a plugin may instead ship a native ES-module graph with **no build step**: set `"scriptType": "module"` in `plugin.json`, make `screen.js` a one-line `import './src/main.js'`, and put the module tree under `src/` (served by the sandboxed `/api/plugins/<id>/src/{path}` route). The host injects it as `<script type="module">`, whose `onload` fires only after the whole static-import graph evaluates — so the loader's completion-by-`onload` + `_loadingPluginId` + `playSong` wrapper-chain ordering all hold. Resolve your own asset URLs (worklets, WASM) with `import.meta.url` — `document.currentScript` is `null` in a module. Module top-level code does **not** re-run when the user re-enters the screen at the same version (the host loads screen.js once and `showScreen` re-injects nothing), so keep per-visit re-init in a `screen:changed` handler, exactly as classic plugins do. Classic global-scope `screen.js` remains fully supported. See `docs/plugin-modules.md`.
|
||||
|
||||
**The playSong wrapper chain** — Plugins commonly wrap `window.playSong` to hook into song playback. Plugins load alphabetically, so the last-loaded (alphabetically later) wrapper runs first, while the alphabetically first plugin runs closest to the original. Be aware that `await` calls in inner wrappers yield to the event loop — WebSocket messages can arrive before outer wrappers finish setup.
|
||||
|
||||
## Plugin Best Practices
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Perf baseline — module-migration refactor
|
||||
|
||||
The refactor promises "measured runtime wins, no hand-waved perf claims" and
|
||||
"screen-entry and frame-time no worse." This is the baseline to hold it to.
|
||||
Rerun the harness after every phase (R0 → R3c) and compare.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
# 1. start core against a library with real charts (see caveat below)
|
||||
CONFIG_DIR=… DLC_DIR=/path/to/songs PYTHONPATH=lib \
|
||||
python3 -m uvicorn server:app --host 127.0.0.1 --port 8000
|
||||
|
||||
# 2. capture (maintainer/CI-only; uses the committed Playwright chromium)
|
||||
node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 --n 60 --soak 30
|
||||
```
|
||||
|
||||
The script prints a markdown block; paste it under "Results" below with the date
|
||||
and the commit it was taken at.
|
||||
|
||||
## What it measures
|
||||
|
||||
- **Server latency** — p50/p95/p99 over N requests for `/api/version`,
|
||||
`/api/plugins`, `/api/library`, `/api/library/artists`.
|
||||
- **Cold boot → interactive** — full page load to `networkidle`.
|
||||
- **JS heap** — `performance.memory.usedJSHeapSize` after load and after an idle
|
||||
soak (a leak signal across a session).
|
||||
- **Plugin-script shape** — how many plugin `<script>`s the loader injected (a
|
||||
"the app booted with its plugins" sanity signal).
|
||||
|
||||
**Not yet captured — needs a seeded library with charts** (fill in when run
|
||||
against a real environment): playback **frame-time p95** on the 2D and 3D
|
||||
highway, and **screen-entry** (plugin inject → interactive) for
|
||||
editor / notedetect / highway_3d with a chart loaded. These are the
|
||||
perf-sensitive numbers that gate the `highway.js` split (R3c); the harness has
|
||||
the hooks, they just need real songs in `DLC_DIR`.
|
||||
|
||||
## Results
|
||||
|
||||
### R0 baseline — 2026-07-08 (branch `feat/r0-plugin-module-rails`)
|
||||
|
||||
> ⚠️ A quick capture (`--n 50 --soak 8`) against an **empty** library (no charts
|
||||
> in `DLC_DIR`), so the `/api/library*` and boot numbers are floor values —
|
||||
> re-take on a seeded environment with the recommended `--n 60 --soak 30` for the
|
||||
> real R0 baseline before comparing R1+ against it. Recorded here to prove the
|
||||
> harness and lock the methodology.
|
||||
|
||||
Server latency (ms), n=50:
|
||||
|
||||
| Endpoint | status | p50 | p95 | p99 |
|
||||
|---|---|---|---|---|
|
||||
| `/api/version` | 200 | 0.9 | 1.8 | 22.3 |
|
||||
| `/api/plugins` | 200 | 1.6 | 2.1 | 3.4 |
|
||||
| `/api/library?limit=60` | 200 | 1.4 | 1.7 | 2.9 |
|
||||
| `/api/library/artists` | 200 | 1.3 | 1.8 | 2.7 |
|
||||
|
||||
Client:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Cold boot → networkidle | 1268 ms |
|
||||
| JS heap after load | 10.1 MB |
|
||||
| JS heap after idle soak | 10.1 MB (no idle growth) |
|
||||
| Plugin scripts injected | 12 |
|
||||
|
||||
No plugin has migrated yet, so all 12 are classic. When the R1 pilot (stems)
|
||||
lands, cold-boot / heap should not regress.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Plugin ES-module migration playbook
|
||||
|
||||
How to move a plugin off a single global-scope `screen.js` IIFE onto a native
|
||||
ES-module graph — **no build step, no framework, no bundler**. This is the
|
||||
mechanism the monolith-killing refactor uses; the host rails for it shipped in
|
||||
R0 (see `.specify/memory/constitution.md` Principle II + the "Module load
|
||||
contract" in Operating Constraints).
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
plugin.json + "scriptType": "module" ← opt in
|
||||
screen.js import './src/main.js'; ← the entire file
|
||||
src/
|
||||
state.js (0) module state + accessors
|
||||
util/… (1) pure helpers — real-import testable
|
||||
…/… (2..4) model → render/audio/io → input
|
||||
globals.js (5) THE ONLY file that writes window.*
|
||||
main.js (5) boot: wire modules, register screen:changed
|
||||
assets/… worklets / WASM / images (unchanged, served as today)
|
||||
```
|
||||
|
||||
`screen.js` becomes a one-line static `import`. The host injects it as
|
||||
`<script type="module">`, whose load event fires **only after the whole
|
||||
static-import graph fetches and evaluates** — so the loader's
|
||||
completion-by-`onload` + `_loadingPluginId` window + `playSong` wrapper-chain
|
||||
order are all preserved. (A classic IIFE that fired a fire-and-forget
|
||||
`import()` would break that contract — don't do that; use `scriptType:"module"`.)
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
1. **Source-served, no build.** Modules are plain source files fetched from
|
||||
`/api/plugins/<id>/src/<path>`. No bundler, transpiler, or TypeScript.
|
||||
2. **Layering points downward** — `state → util → commands/model →
|
||||
render/audio/io → input → globals/main`. A lint check (`import-x/no-cycle`)
|
||||
enforces acyclicity; extract bottom-up so each move only imports
|
||||
already-extracted layers.
|
||||
3. **`globals.js` is the only writer of `window.*`.** The deliberate global
|
||||
surface shrinks to one auditable file; everything else is module-scoped.
|
||||
4. **Import-time purity.** `node --test` runs a module's top-level code on
|
||||
import, so a module you want to unit-test must be side-effect-free at import:
|
||||
no `document` / `window` / `localStorage` at module top level — lift init
|
||||
into an exported `init()` called by `main.js`. (Constitution Principle V's
|
||||
"no implicit IO at import time", applied to the frontend.) Tests are `.mjs`
|
||||
and use real `import`, retiring the regex/`extractFunction` harness.
|
||||
5. **Assets resolve via `import.meta.url`.** `document.currentScript` is `null`
|
||||
inside a module. `assets/` lives at the plugin root, so a `src/` module must
|
||||
climb out of `src/`: from `src/main.js`, `new URL('../assets/x.js',
|
||||
import.meta.url)` (deeper modules need more `../`). Simpler and
|
||||
depth-independent: the absolute route `/api/plugins/<id>/assets/x.js`.
|
||||
Worklets run in a *separate* module graph (`AudioWorkletGlobalScope`) and
|
||||
cannot share modules with `src/`.
|
||||
6. **Re-init comes from `screen:changed`, not re-execution.** The host loads
|
||||
`screen.js` once per version and `showScreen` re-injects nothing, so module
|
||||
top-level code does **not** re-run when the user re-enters the screen at the
|
||||
same version. Keep per-visit setup/teardown in a `window.feedBack.on(
|
||||
'screen:changed', …)` handler — exactly as classic plugins (tuner,
|
||||
minigames) already do. Do not rely on the IIFE re-running.
|
||||
7. **Inline `onclick=` keeps working** during migration via `globals.js` (which
|
||||
keeps every referenced symbol on `window`); retire inline handlers to
|
||||
module-side `addEventListener` opportunistically, never as a blocking step.
|
||||
|
||||
## The live-edit loop
|
||||
|
||||
The host serves `screen.js`, `src/**`, and `assets/**` with
|
||||
`Cache-Control: no-cache` + a weak `ETag` and honors `If-None-Match` → `304`.
|
||||
So: edit a `src/` file → **refresh the browser** → the edited module returns
|
||||
`200` and reloads while every unchanged module `304`s. There is no hot-reload;
|
||||
the loop is edit → refresh → see change, exactly as before. The `?v=<version>`
|
||||
query on `screen.js` is the legacy version buster; it does **not** propagate
|
||||
into the `src/` graph and does not need to — ETag/mtime is the correctness
|
||||
authority for the whole graph.
|
||||
|
||||
## Host-version floor (`minHost`)
|
||||
|
||||
A migrated plugin *requires* a host new enough to serve `src/` and inject
|
||||
`type=module`. Declare the floor with `"minHost": "X.Y.Z"` in `plugin.json`.
|
||||
(R0 plumbs the field through `/api/plugins`; enforcement — refuse-with-message
|
||||
on an older host — is deferred, so bundled plugins are unaffected. Community
|
||||
plugins should state the floor and not migrate below it.)
|
||||
|
||||
## Migration mechanics
|
||||
|
||||
- **Move-only PRs.** One slice extracts one module: cut code, add
|
||||
imports/exports, update `globals.js` — zero behavior change. Behavior fixes
|
||||
are separate PRs. (Init-lifts for import purity are the one non-pure move —
|
||||
budget them.)
|
||||
- **Bottom-up, layer by layer.** Within a layer, independent modules are
|
||||
independent PRs (a DAG, not a chain); use a git worktree per branch.
|
||||
- Tests move with their subject and convert to real `.mjs` imports in the same
|
||||
PR (assertions unchanged).
|
||||
- Size norm: no source file over **1,500 lines**; legitimate exceptions
|
||||
(hot renderers, etc.) go in the signed register at `docs/size-exemptions.md`.
|
||||
|
||||
## Verifying a migration
|
||||
|
||||
`node --test <plugin>/tests/*.mjs`; load the plugin on the `:8000` testbed and
|
||||
confirm it boots (`<script type=module>` in DevTools, the `src/` graph in
|
||||
Network); edit a `src/` file → refresh → change visible (`200` on the edited
|
||||
file, `304` on the rest); leave and re-enter the screen at the same version →
|
||||
it re-inits via `screen:changed`. The R1 pilots (stems, then studio) certify
|
||||
this end-to-end before the flagship repos migrate.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Size-exemption register
|
||||
|
||||
The working norm (constitution Principle II; enforced by the `max-lines` lint
|
||||
gate) is **no source file over 1,500 lines**. A few files are allowed to exceed
|
||||
it because splitting them would do more harm than good — hot per-frame
|
||||
renderers, C++, offline generators, cohesive registries. This register is the
|
||||
list of those exceptions: each row is a **deliberate, signed** decision with a
|
||||
ceiling, a rationale, and a review trigger. Without it, "no file over 1,500
|
||||
without a *signed* exemption" is unenforceable.
|
||||
|
||||
**Rules**
|
||||
- One row per file: a ceiling, a rationale, a signer, a review trigger.
|
||||
- The `max-lines` per-file ceilings in `eslint.config.js` mirror this table —
|
||||
keep them in sync (this register is canonical).
|
||||
- Files with a scheduled split **plan** are *not* exempt — they live in
|
||||
"Planned, not exempt" at the bottom so nothing falls between the two states.
|
||||
- **Signers** (decided 2026-07-08): **Byron** signs core + bundled rows;
|
||||
**Christian** signs the authored-plugin row (virtuoso, its own repo/track).
|
||||
|
||||
## Permanent exemptions (structural rationale)
|
||||
|
||||
| Repo / file | Lines (7-07) | Ceiling | Rationale | Signer | Review |
|
||||
|---|---|---|---|---|---|
|
||||
| core `static/highway.js` → residual `renderer-2d.js` (post-split) | ~2,400–2,900 est. | **3,000** | 60 fps hot path; no module boundary inside the per-frame loop | Byron | after the highway.js split |
|
||||
| core `plugins/highway_3d/` → residual renderer | sized at split; likely **>3,000** | set at split, flagged now | same hot-path rule; the draw core can't be cut without behavior risk | Byron | after the highway_3d split |
|
||||
| core `static/capabilities.js` | 1,538 | 1,600 | cohesive registry + `window.feedBack` bus, 38 lines over; a split spends credibility for nothing | Byron | R4 |
|
||||
| tutorials `builtin/reading-the-highway/generate.py` | 1,818 | 2,000 | offline content generator, never imported at runtime, deps not in runtime requirements | Byron | if a 3rd builtin pack appears |
|
||||
| desktop `src/audio/NodeAddon.cpp` | 3,542 | as-is | C++, outside the ESM/routes playbooks; under active use-after-free crash work — do not churn | Byron | after crash-class work settles |
|
||||
| desktop `src/audio/AudioEngine.cpp` | 2,977 | as-is | same | Byron | same |
|
||||
| desktop `src/vst-host/main.cpp` | 1,928 | as-is | same | Byron | same |
|
||||
| virtuoso `screen.js` (authored, own track) | 25,741 | as-is until its own split | authored plugin on a separate roadmap; migrates on its own schedule | Christian | virtuoso split kickoff |
|
||||
|
||||
## Split-when-touched (no scheduled train; row retires when split)
|
||||
|
||||
| Repo / file | Lines | Ceiling | Rationale | Signer | Review |
|
||||
|---|---|---|---|---|---|
|
||||
| core `lib/gp2rs_gpx.py` | 2,540 | as-is | import converter, off the serve-path hot loop | Byron | when next touched |
|
||||
| core `lib/gp2rs.py` | 2,055 | as-is | same | Byron | when next touched |
|
||||
| core `lib/song.py` | 1,689 | as-is | data models + wire format; cohesive | Byron | when next touched |
|
||||
| core `lib/gp_autosync.py` | 1,572 | as-is | under active dev (#787/#791) — don't collide | Byron | after in-flight work lands |
|
||||
| core `plugins/capability_inspector/screen.js` | 1,752 | as-is | bundled diagnostics plugin, low churn | Byron | when next touched |
|
||||
| core `plugins/folder_library/screen.js` | 1,672 | as-is | bundled plugin, low churn | Byron | when next touched |
|
||||
|
||||
## Temporary rows (cleared by a scheduled PR)
|
||||
|
||||
| Repo / file | Lines | Cleared by |
|
||||
|---|---|---|
|
||||
| core `plugins/__init__.py` | ~2,470 (grew under R0) | the `plugins/_routes.py` + `plugins/_registry.py` split (rides the server.py router work) |
|
||||
|
||||
## Watch list (under the norm — no row needed, re-census each phase)
|
||||
|
||||
`musicxml-import/mxml2notation.py` (1,456) · core `static/capabilities/audio-effects.js`
|
||||
(1,436) · `studio routes.py` (1,399) · `update-manager screen.js` (1,492 — zero headroom).
|
||||
|
||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||
|
||||
core `static/app.js` (11,821) · `static/highway.js` (4,154, whole file) · `server.py`
|
||||
(13,948) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
|
||||
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
||||
by policy — the norm governs source files.
|
||||
@@ -0,0 +1,66 @@
|
||||
// Flat ESLint config — MAINTAINER / CI ONLY. Never runs on the serve or Docker
|
||||
// path (constitution Principle I: dev-only tooling is exempt, same category as
|
||||
// scripts/build-tailwind.sh). It enforces the module-migration guardrails:
|
||||
//
|
||||
// * max-lines — the 1,500-line size norm, as a WARNING ratchet. Legacy
|
||||
// monoliths warn (the "this is over the norm, split it" signal) and shrink
|
||||
// as the refactor lands; warnings do not fail CI. Genuinely-large files are
|
||||
// exempted below, mirroring the signed register in docs/size-exemptions.md.
|
||||
// * import-x/no-unresolved + no-cycle — module hygiene, scoped to the real
|
||||
// ES-module graphs the refactor produces (a plugin's src/ tree, .mjs
|
||||
// tests). no-unresolved (a HARD error) catches broken import paths;
|
||||
// no-cycle enforces the downward-only layering rule. Core's classic scripts
|
||||
// have no import graph, so both are dormant today and become live gates the
|
||||
// moment module code appears — validated against the first real module
|
||||
// plugin (R1 pilot).
|
||||
|
||||
const importX = require('eslint-plugin-import-x');
|
||||
|
||||
// Per-file size ceilings — a mirror of docs/size-exemptions.md (canonical).
|
||||
// Keep in sync; each entry corresponds to a signed row in the register.
|
||||
const SIZE_EXEMPTIONS = [
|
||||
{ files: ['**/static/capabilities.js'], max: 1600 },
|
||||
{ files: ['**/plugins/capability_inspector/screen.js'], max: 100000 },
|
||||
{ files: ['**/plugins/folder_library/screen.js'], max: 100000 },
|
||||
];
|
||||
|
||||
const sizeRule = (max) => ['warn', { max, skipBlankLines: false, skipComments: false }];
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'static/vendor/**',
|
||||
'plugins/**/assets/vendor/**',
|
||||
'**/*.min.js',
|
||||
'static/tailwind.min.css',
|
||||
],
|
||||
},
|
||||
// Size norm across all first-party JS. Classic scripts are parsed as
|
||||
// scripts (no import/export); module files get their own block below.
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
||||
rules: { 'max-lines': sizeRule(1500) },
|
||||
},
|
||||
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
|
||||
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
|
||||
// entry `import './src/main.js'` screen.js must parse as a module — add its
|
||||
// glob here in that plugin's migration PR (classic screen.js stays a script).
|
||||
{
|
||||
files: ['**/src/**/*.js', '**/*.mjs'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: { 'import-x': importX },
|
||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||
// it the import rules silently skip imports they can't resolve.
|
||||
settings: { 'import-x/resolver-next': [importX.createNodeResolver()] },
|
||||
rules: {
|
||||
'max-lines': sizeRule(1500),
|
||||
'import-x/no-unresolved': 'error',
|
||||
'import-x/no-cycle': 'error',
|
||||
},
|
||||
},
|
||||
// Signed size exemptions (docs/size-exemptions.md) — raise the ceiling so
|
||||
// registered files don't warn below it.
|
||||
...SIZE_EXEMPTIONS.map(({ files, max }) => ({ files, rules: { 'max-lines': sizeRule(max) } })),
|
||||
];
|
||||
+33
-9
@@ -1836,9 +1836,10 @@ def convert_drum_track_to_drumtab(
|
||||
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
|
||||
skipped — round-tripping them would require teaching `lib/drums.py` first.
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...]}}``,
|
||||
times capped at 100 samples per note) so they can surface a warning or
|
||||
offer a manual mapping UI.
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...],
|
||||
"velocities": [...]}}``, times/velocities index-aligned and capped at
|
||||
100 samples per note — velocities carry the source notes' real dynamics)
|
||||
so they can surface a warning or offer a manual mapping UI.
|
||||
|
||||
Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when
|
||||
``expand_repeats`` is true — same `_build_playback_schedule` machinery
|
||||
@@ -1894,18 +1895,29 @@ def convert_drum_track_to_drumtab(
|
||||
# NB: do NOT shadow the outer `entry` loop
|
||||
# variable from `for entry in schedule:`.
|
||||
unmapped_rec = out_unmapped.setdefault(
|
||||
int(midi_note), {"count": 0, "times": []})
|
||||
int(midi_note),
|
||||
{"count": 0, "times": [], "velocities": []})
|
||||
unmapped_rec["count"] += 1
|
||||
if len(unmapped_rec["times"]) < 100:
|
||||
unmapped_rec["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real
|
||||
# dynamics (same 1-127 gate as mapped hits,
|
||||
# falling back to the 100 import default) so
|
||||
# a hand-mapping UI doesn't flatten them.
|
||||
_uv = int(getattr(note, "velocity", 0) or 0)
|
||||
unmapped_rec["velocities"].append(
|
||||
_uv if 1 <= _uv <= 127 else 100)
|
||||
continue
|
||||
|
||||
hit: dict = {"t": round(t, 3), "p": piece}
|
||||
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly; default
|
||||
# is 95 (Velocities.default). Pass through verbatim,
|
||||
# clamping defensively so a corrupt file can't poison the
|
||||
# wire format.
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly. Note
|
||||
# this is GP's *authoring* default (95, Velocities.default)
|
||||
# — unrelated to the drumtab render default of 100
|
||||
# (DEFAULT_VELOCITY, lib/drums.py:179), which only applies
|
||||
# when `v` is omitted from a hit. Pass the GP value through
|
||||
# verbatim, clamping defensively so a corrupt file can't
|
||||
# poison the wire format.
|
||||
vel = int(getattr(note, "velocity", 0) or 0)
|
||||
if 1 <= vel <= 127:
|
||||
hit["v"] = vel
|
||||
@@ -1946,9 +1958,21 @@ def convert_drum_track_to_drumtab(
|
||||
# Times for unmapped notes were collected in beat-iteration order;
|
||||
# multi-voice measures can produce out-of-order beats, so sort each
|
||||
# entry's `times` list chronologically before returning to the caller.
|
||||
# Velocities are index-aligned with times, so they must sort in
|
||||
# LOCKSTEP — sorting times alone would silently reassign dynamics.
|
||||
if out_unmapped is not None:
|
||||
for _rec in out_unmapped.values():
|
||||
_rec["times"].sort()
|
||||
_vels = _rec.get("velocities")
|
||||
if _vels and len(_vels) == len(_rec["times"]):
|
||||
_pairs = sorted(zip(_rec["times"], _vels))
|
||||
_rec["times"] = [p[0] for p in _pairs]
|
||||
_rec["velocities"] = [p[1] for p in _pairs]
|
||||
else:
|
||||
# Belt-and-suspenders: times & velocities are always appended
|
||||
# together under the same `len(times) < 100` guard above, so
|
||||
# in practice the lengths can't diverge. Kept as a defensive
|
||||
# fallback, not a real divergence case.
|
||||
_rec["times"].sort()
|
||||
|
||||
return {
|
||||
"version": drums_mod.SCHEMA_VERSION,
|
||||
|
||||
+10
-5
@@ -634,10 +634,12 @@ def convert_drum_track_from_midi(
|
||||
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a
|
||||
per-MIDI record of every channel-9 note_on that didn't resolve to a
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
|
||||
capped at 100 samples per note). The default path skips this
|
||||
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
|
||||
no extra work.
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...],
|
||||
"velocities": [int, ...]}}``, times/velocities index-aligned and
|
||||
capped at 100 samples per note — velocities carry the source notes'
|
||||
real dynamics so a hand-mapping UI doesn't have to flatten them to a
|
||||
default). The default path skips this capture entirely so MIDIs
|
||||
heavy with cowbell/tambourine/etc. take no extra work.
|
||||
"""
|
||||
offset = float(audio_offset)
|
||||
if not math.isfinite(offset):
|
||||
@@ -675,10 +677,13 @@ def convert_drum_track_from_midi(
|
||||
continue
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
entry = out_unmapped.setdefault(
|
||||
midi_note, {"count": 0, "times": []})
|
||||
midi_note, {"count": 0, "times": [], "velocities": []})
|
||||
entry["count"] += 1
|
||||
if len(entry["times"]) < 100:
|
||||
entry["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real dynamics,
|
||||
# so hand-mapping doesn't flatten everything to 100.
|
||||
entry["velocities"].append(int(msg.velocity))
|
||||
continue
|
||||
# Mapped note: compute t once for the raw entry.
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
|
||||
Generated
+1758
-1
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -8,9 +8,12 @@
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "playwright test --debug",
|
||||
"test:js": "node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'",
|
||||
"install:playwright": "playwright install chromium"
|
||||
"install:playwright": "playwright install chromium",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1"
|
||||
"@playwright/test": "^1.59.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-import-x": "^4.17.1"
|
||||
}
|
||||
}
|
||||
|
||||
+99
-15
@@ -18,6 +18,54 @@ from safepath import safe_join
|
||||
log = logging.getLogger("feedBack.plugins")
|
||||
|
||||
|
||||
def _plugin_media_type(path: Path) -> str:
|
||||
"""Best-effort Content-Type for a served plugin file. `.js`/`.css` must come
|
||||
back as JavaScript/CSS so `<script type=module>` / `addModule()` / a `<link>`
|
||||
accept them; `mimetypes.guess_type` can miss these on a stripped platform
|
||||
registry, so fall back explicitly (mirrors the assets/ route)."""
|
||||
media_type = mimetypes.guess_type(path.name)[0]
|
||||
if media_type is None and path.suffix == ".js":
|
||||
return "application/javascript"
|
||||
if media_type is None and path.suffix == ".css":
|
||||
return "text/css"
|
||||
return media_type or "application/octet-stream"
|
||||
|
||||
|
||||
def _plugin_file_etag(path: Path) -> str | None:
|
||||
"""Weak ETag from mtime+size — cheap, stable across reads, changes on edit.
|
||||
This is what makes the live-edit loop work for module graphs: a conditional
|
||||
GET revalidates and 304s unchanged files on refresh instead of re-downloading
|
||||
the whole `src/` tree. Returns None if the file can't be stat'd."""
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return f'W/"{st.st_mtime_ns:x}-{st.st_size:x}"'
|
||||
|
||||
|
||||
def _if_none_match(request: Request, etag: str) -> bool:
|
||||
"""True when the client's If-None-Match already holds `etag`."""
|
||||
# ponytail: we serve one weak ETag; the browser echoes it back verbatim, so
|
||||
# a direct compare is enough (comma-split tolerates a proxy concatenation).
|
||||
return etag in [t.strip() for t in request.headers.get("if-none-match", "").split(",")]
|
||||
|
||||
|
||||
def _plugin_file_response(request: Request, path: Path, media_type: str) -> Response:
|
||||
"""Serve a plugin source/asset file with the live-edit cache contract:
|
||||
`Cache-Control: no-cache` (browser may store but MUST revalidate) + a weak
|
||||
ETag, and a bodyless 304 when the client's If-None-Match already matches.
|
||||
Starlette's `FileResponse` emits an ETag but never evaluates If-None-Match
|
||||
itself, so the conditional handling has to live here."""
|
||||
headers = {"Cache-Control": "no-cache"}
|
||||
etag = _plugin_file_etag(path)
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if _if_none_match(request, etag):
|
||||
return Response(status_code=304, headers=headers)
|
||||
# FileResponse sets etag/last-modified via setdefault, so the ETag above wins.
|
||||
return FileResponse(path, media_type=media_type, headers=headers)
|
||||
|
||||
|
||||
PLUGINS_DIR = Path(__file__).parent
|
||||
# Holds only *ready* (loaded) plugins — those whose dependencies installed
|
||||
# and whose routes registered. A plugin GRADUATES from PENDING_PLUGINS into
|
||||
@@ -1373,6 +1421,12 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
||||
"version": manifest.get("version"),
|
||||
"has_screen": bool(manifest.get("screen")),
|
||||
"has_script": bool(manifest.get("script")),
|
||||
# Module-migration (R0): `scriptType:"module"` tells the loader to
|
||||
# inject screen.js as <script type="module">; `minHost` is the
|
||||
# min core version a migrated plugin needs (passthrough only in R0 —
|
||||
# enforcement is deferred to R4, master §4b). None when unset.
|
||||
"script_type": manifest.get("scriptType"),
|
||||
"min_host": manifest.get("minHost"),
|
||||
"has_settings": bool(manifest.get("settings")),
|
||||
"settings_category": _settings_category,
|
||||
# Drives the v3 shell's immersive (full-screen) mode for this
|
||||
@@ -2089,6 +2143,11 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": p.get("fallback", False),
|
||||
"has_screen": p["has_screen"],
|
||||
"has_script": p["has_script"],
|
||||
# Module-migration passthrough (R0). Re-read from the manifest
|
||||
# like `version` above so stubbed test entries (built without
|
||||
# _nav_entry) don't need the key.
|
||||
"script_type": (p.get("_manifest") or {}).get("scriptType"),
|
||||
"min_host": (p.get("_manifest") or {}).get("minHost"),
|
||||
"has_settings": p["has_settings"],
|
||||
# v3 immersive screen opt-in (full-screen plugin UI).
|
||||
"fullscreen": p.get("fullscreen", False),
|
||||
@@ -2142,6 +2201,9 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": False,
|
||||
"has_screen": e.get("has_screen", False),
|
||||
"has_script": e.get("has_script", False),
|
||||
# Pending entries come from _nav_entry, so they carry these.
|
||||
"script_type": e.get("script_type"),
|
||||
"min_host": e.get("min_host"),
|
||||
"has_settings": e.get("has_settings", False),
|
||||
"settings_category": e.get("settings_category"),
|
||||
"fullscreen": e.get("fullscreen", False),
|
||||
@@ -2307,7 +2369,7 @@ def register_plugin_api(app: FastAPI):
|
||||
return HTMLResponse("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/screen.js")
|
||||
def plugin_screen_js(plugin_id: str):
|
||||
def plugin_screen_js(request: Request, plugin_id: str):
|
||||
with PLUGINS_LOCK:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
@@ -2315,8 +2377,11 @@ def register_plugin_api(app: FastAPI):
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
script_file = p["_dir"] / p["_manifest"].get("script", "screen.js")
|
||||
if script_file.exists():
|
||||
return Response(script_file.read_text(encoding="utf-8"), media_type="application/javascript")
|
||||
if script_file.is_file():
|
||||
# no-cache + ETag/304 so an edited screen.js reloads on
|
||||
# refresh while an unchanged one revalidates cheaply — the
|
||||
# same live-edit contract the src/ module graph relies on.
|
||||
return _plugin_file_response(request, script_file, "application/javascript")
|
||||
return Response("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/settings.html")
|
||||
@@ -2377,7 +2442,7 @@ def register_plugin_api(app: FastAPI):
|
||||
return Response("{}", status_code=404, media_type="application/json")
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/assets/{asset_path:path}")
|
||||
def plugin_asset(plugin_id: str, asset_path: str):
|
||||
def plugin_asset(request: Request, plugin_id: str, asset_path: str):
|
||||
"""Serve a static file a plugin bundles under its own ``assets/``
|
||||
directory (e.g. an AudioWorklet module, WASM, or image). Unlike the
|
||||
fixed screen.js/settings.html handlers above, this is a generic
|
||||
@@ -2399,16 +2464,35 @@ def register_plugin_api(app: FastAPI):
|
||||
log.warning("Plugin %r: asset path rejected: %r", plugin_id, asset_path)
|
||||
break
|
||||
if target.is_file():
|
||||
media_type = mimetypes.guess_type(target.name)[0]
|
||||
# .js must come back as JavaScript so addModule() / <script>
|
||||
# accept it; guess_type can miss this on some platforms.
|
||||
if media_type is None and target.suffix == ".js":
|
||||
media_type = "application/javascript"
|
||||
# .css must come back as text/css so a <link rel=stylesheet>
|
||||
# (the styles capability) is honoured; guess_type can miss it
|
||||
# on a stripped platform mimetypes registry, same as .js.
|
||||
elif media_type is None and target.suffix == ".css":
|
||||
media_type = "text/css"
|
||||
return FileResponse(target, media_type=media_type or "application/octet-stream")
|
||||
# no-cache + ETag/304 so a live-edited worklet/asset reloads
|
||||
# on refresh (bare FileResponse emits an ETag but never 304s).
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/src/{src_path:path}")
|
||||
def plugin_src(request: Request, plugin_id: str, src_path: str):
|
||||
"""Serve a file from a plugin's ES-module source tree under ``src/``.
|
||||
|
||||
This is the R0 host capability that lets a migrated plugin's
|
||||
``screen.js`` (a one-line ``import './src/main.js'``) load its whole
|
||||
module graph. Containment mirrors the assets/ route exactly —
|
||||
``safe_join`` against ``<plugin>/src`` rejects ``..``, absolute paths,
|
||||
and NUL bytes — and the live-edit cache contract (no-cache + ETag/304)
|
||||
makes an edited module reload on refresh while unchanged ones 304.
|
||||
Read-only; the src/ tree is source files, never executed server-side.
|
||||
"""
|
||||
with PLUGINS_LOCK:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
if p["id"] == plugin_id:
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
target = safe_join(p["_dir"] / "src", src_path)
|
||||
if target is None:
|
||||
log.warning("Plugin %r: src path rejected: %r", plugin_id, src_path)
|
||||
break
|
||||
if target.is_file():
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
|
||||
@@ -813,20 +813,36 @@
|
||||
_writeStore(STORE_KEYS.midiPick, JSON.stringify({ id: id || '', name: name || '', key: key || '' }));
|
||||
}
|
||||
|
||||
function _midiAutoConnect(allowFallback) {
|
||||
// Recovery (sources-changed after unplug) passes false: never switch to a
|
||||
// fallback input, because _midiConnect persists the pick and that would
|
||||
// overwrite the user's saved device on a transient multi-device unplug
|
||||
// (the original returns on replug and reconnects then).
|
||||
if (allowFallback === undefined) allowFallback = true;
|
||||
const inputs = _midiSources();
|
||||
if (!inputs.length) return;
|
||||
const saved = _readSavedPick();
|
||||
// Explicit "None" opt-out.
|
||||
if (saved && saved.id === '' && saved.name === '') return;
|
||||
// Prefer the globally-unique logicalSourceKey, then the legacy bare
|
||||
// sourceId, then case-insensitive name (Chrome on Linux regenerates ids
|
||||
// per page load), then first non-loopback.
|
||||
// Pure decision logic (exported via __test): pick which device to
|
||||
// auto-connect to from the current source list, the domain-wide selection
|
||||
// (`globalKey`, from Settings → Input Setup), and this plugin's own legacy
|
||||
// saved pick. Returns null for "connect to nothing" (explicit None opt-out,
|
||||
// or the configured device currently absent during hotplug recovery).
|
||||
//
|
||||
// The domain-wide selection is the SOURCE OF TRUTH (checked first): a device
|
||||
// configured globally must never be overridden by a stale plugin-local pick
|
||||
// or an arbitrary first-device fallback — that override was the bug. The
|
||||
// local pick is retained only as a fallback BELOW the global (and for
|
||||
// name-recovery when the global's logicalSourceKey went stale, e.g. a
|
||||
// browser that regenerates MIDI port ids across reloads). Auto-connect no
|
||||
// longer writes the local pick, so it only ever holds a value an explicit
|
||||
// selection put there (or a stale one from a pre-fix build — the global
|
||||
// still wins over it).
|
||||
function _pickMidiTarget(inputs, saved, globalKey, allowFallback) {
|
||||
if (!inputs.length) return null;
|
||||
const notBlocked = (i) => !!i && !_MIDI_BLOCKLIST_RE.test(i.name || '');
|
||||
// Explicit "None" opt-out (set only via the device-select API).
|
||||
if (saved && saved.id === '' && saved.name === '') return null;
|
||||
|
||||
// 1. Domain-wide selection (Settings → Input Setup) — source of truth.
|
||||
if (globalKey) {
|
||||
const g = inputs.find(i => i.key === globalKey);
|
||||
if (notBlocked(g)) return g;
|
||||
}
|
||||
|
||||
// 2. Legacy plugin-local pick, as a fallback below the global. Prefer the
|
||||
// globally-unique logicalSourceKey, then the legacy bare sourceId, then
|
||||
// case-insensitive name (Chrome on Linux regenerates ids per page load).
|
||||
let target = null;
|
||||
if (saved && saved.key) target = inputs.find(i => i.key === saved.key) || null;
|
||||
if (!target && saved && saved.id) target = inputs.find(i => i.id === saved.id) || null;
|
||||
@@ -834,23 +850,50 @@
|
||||
const n = saved.name.toLowerCase();
|
||||
target = inputs.find(i => (i.name || '').toLowerCase() === n) || null;
|
||||
}
|
||||
// Never honour a saved pick that's a loopback / "Midi Through" port — it
|
||||
// carries no device input, so a stale pick silently eats every note. The
|
||||
// saved-pick lookups above bypass the block-list; re-apply it here.
|
||||
if (target && _MIDI_BLOCKLIST_RE.test(target.name || '')) target = null;
|
||||
if (!target) {
|
||||
// Skip the substitute ONLY when a saved pick exists but is currently
|
||||
// absent (recovery: preserve it, don't clobber on a transient unplug).
|
||||
// With no saved pick at all, a fallback is the intended first-hotplug
|
||||
// auto-connect — allow it even in recovery.
|
||||
const hasSavedPick = !!(saved && (saved.key || saved.id || saved.name));
|
||||
if (!allowFallback && hasSavedPick) return;
|
||||
target = inputs.find(i => !_MIDI_BLOCKLIST_RE.test(i.name || '')) || inputs[0];
|
||||
}
|
||||
// Never honour a saved pick that resolves to a loopback / "Midi Through"
|
||||
// port — it carries no device input, so it silently eats every note.
|
||||
if (target && !notBlocked(target)) target = null;
|
||||
if (target) return target;
|
||||
|
||||
// 3. Nothing configured resolved to a present device. In recovery
|
||||
// (allowFallback=false) with a configured preference — a global pick or a
|
||||
// saved pick — that's currently absent, preserve it rather than switching
|
||||
// to an arbitrary device on a transient multi-device unplug. With no
|
||||
// preference at all, a first-device grab is the intended first-hotplug
|
||||
// auto-connect, allowed even in recovery.
|
||||
const hasPreference = !!(globalKey || (saved && (saved.key || saved.id || saved.name)));
|
||||
if (!allowFallback && hasPreference) return null;
|
||||
// Connect to nothing rather than a loopback: if every present device is
|
||||
// blocklisted, a first-device grab would attach to a "Midi Through"/IAC
|
||||
// port that carries no input and silently eats every note.
|
||||
return inputs.find(notBlocked) || null;
|
||||
}
|
||||
|
||||
function _midiAutoConnect(allowFallback) {
|
||||
// Recovery (sources-changed after unplug) passes false: never switch to a
|
||||
// fallback input on a transient multi-device unplug (the configured
|
||||
// device returns on replug and reconnects then). Auto-connect is
|
||||
// non-persisting (persist omitted → false): it opens the resolved device
|
||||
// for this session WITHOUT writing the plugin-local pick or the shared
|
||||
// domain selection, so opening this highway can't clobber the user's
|
||||
// globally-configured device.
|
||||
if (allowFallback === undefined) allowFallback = true;
|
||||
const inputs = _midiSources();
|
||||
const saved = _readSavedPick();
|
||||
const mi = _mi();
|
||||
const globalKey = mi && typeof mi.getSelected === 'function' ? mi.getSelected() : null;
|
||||
const target = _pickMidiTarget(inputs, saved, globalKey, allowFallback);
|
||||
if (!target) return;
|
||||
_midiConnect(target.id, target.name, target.key);
|
||||
}
|
||||
|
||||
async function _midiConnect(id, name, key) {
|
||||
// `persist` gates the two preference writes. Only an EXPLICIT device
|
||||
// selection (the device-select API) persists: it writes the plugin-local
|
||||
// pick AND the shared domain selection (`mi.select`, so the user's choice
|
||||
// becomes the global default). Auto-connect and programmatic opens pass
|
||||
// falsy — they open the resolved device for this session only, never
|
||||
// touching either store, so they can't clobber a globally-configured device.
|
||||
async function _midiConnect(id, name, key, persist) {
|
||||
// Capture our generation AFTER _midiDetach()'s own bump, so a later
|
||||
// detach (device removal / new connect / opt-out) reliably supersedes us.
|
||||
_midiDetach();
|
||||
@@ -861,7 +904,7 @@
|
||||
for (const inst of _instances) {
|
||||
if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
|
||||
}
|
||||
_writeSavedPick(id || '', name || '', key || '');
|
||||
if (persist) _writeSavedPick(id || '', name || '', key || '');
|
||||
const mi = _mi();
|
||||
if ((id || key) && mi) {
|
||||
// Prefer the globally-unique logicalSourceKey so two providers that
|
||||
@@ -874,13 +917,19 @@
|
||||
const lkey = src.key || ('web-midi::' + src.id);
|
||||
_midiInput = { id: src.id, name: src.name, key: lkey };
|
||||
_midiJustConnected = true;
|
||||
// Only an explicit selection writes the shared global default;
|
||||
// open takes the logicalSourceKey directly, so select() is not
|
||||
// needed to open — it exists purely to set the global. Persist it
|
||||
// BEFORE the no-instance early return so a settings-panel pick with
|
||||
// no live renderer still updates the shared default (best-effort:
|
||||
// a select hiccup must not abort the connect).
|
||||
if (persist) { try { await mi.select(lkey); } catch (_) { /* best-effort */ } }
|
||||
// No live renderer to consume OR release a session — don't hold one
|
||||
// open (settings-only ensure-init, or the last instance was torn
|
||||
// down during async discovery). The pick is saved; a later renderer
|
||||
// mount re-runs auto-connect and opens for real, releasing on destroy.
|
||||
// down during async discovery). A later renderer mount re-runs
|
||||
// auto-connect and opens for real, releasing on destroy.
|
||||
if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; }
|
||||
try {
|
||||
await mi.select(lkey);
|
||||
const res = await mi.open({ requester: PLUGIN_ID, logicalSourceKey: lkey });
|
||||
// A newer _midiConnect (device switch / None / replug) ran while
|
||||
// we awaited open — discard this stale session so we don't wire a
|
||||
@@ -1039,10 +1088,11 @@
|
||||
window.keysH3dGetMidiInputId = function () { return _midiInput ? _midiInput.id : ''; };
|
||||
window.keysH3dSetMidiInput = function (id) {
|
||||
// `id` may be a logicalSourceKey (new host calls) or a legacy sourceId.
|
||||
// Explicit user selection → persist (local pick + shared global default).
|
||||
const src = id
|
||||
? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id))
|
||||
: null;
|
||||
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '');
|
||||
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '', true);
|
||||
return true;
|
||||
};
|
||||
window.keysH3dGetMidiChannel = function () { return _cfg.midiChannel; };
|
||||
@@ -1547,6 +1597,8 @@
|
||||
|
||||
function _aiOpen(req) {
|
||||
// Opening a MIDI source connects the corresponding Web MIDI input.
|
||||
// Programmatic open (audio-input source.open) — non-persisting: it must
|
||||
// not rewrite the user's saved pick or the shared global default.
|
||||
const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey));
|
||||
const inputs = _midiSources(); // carries .key (logicalSourceKey), unlike _midiListInputs()
|
||||
if (idx == null || idx >= inputs.length) {
|
||||
@@ -3993,6 +4045,7 @@
|
||||
FX_DEFAULTS,
|
||||
FX_RANGES,
|
||||
_classifyTiming,
|
||||
_pickMidiTarget,
|
||||
};
|
||||
|
||||
// Headless verification hook: lets Playwright drive synthetic note-ons
|
||||
|
||||
@@ -188,3 +188,99 @@ test('measureMarkers extracts idx/t pairs', () => {
|
||||
[{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }],
|
||||
);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: no plugin-local pick defers to the domain-wide selection, not "first device"', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
// Fresh install / never picked here — must use the Input Setup global,
|
||||
// NOT fall through to inputs[0].
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::b', true);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: the domain-wide selection is the source of truth — it wins over a stale plugin-local pick', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
// A stale local pick (e.g. left by a pre-fix build's auto-connect) must
|
||||
// NOT override the device the user configured in Settings → Input Setup.
|
||||
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, 'web-midi::b', true);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: local pick is used as a fallback when no global is configured', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, null, true);
|
||||
assert.equal(target.id, 'a');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: local pick name-recovers when its logicalSourceKey went stale (id regeneration)', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
// Same physical device, new id/key across a reload; the saved key/id miss
|
||||
// but the name still matches.
|
||||
const inputs = [{ id: 'a2', name: 'Device A', key: 'web-midi::a2' }];
|
||||
const target = _pickMidiTarget(inputs, { id: 'a1', name: 'Device A', key: 'web-midi::a1' }, null, true);
|
||||
assert.equal(target.id, 'a2');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: domain-wide selection is ignored if it names a blocklisted loopback port', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'thru', name: 'IAC Driver Bus 1', key: 'web-midi::thru' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::thru', true);
|
||||
assert.equal(target.id, 'b'); // falls through to the first non-loopback device
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: when every present device is a loopback, connect to nothing (never a dead port)', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'thru', name: 'MIDI Through Port-0', key: 'web-midi::thru' },
|
||||
{ id: 'iac', name: 'IAC Driver Bus 1', key: 'web-midi::iac' },
|
||||
];
|
||||
// No non-loopback device exists — must NOT fall back to inputs[0] (a port
|
||||
// that carries no input and would silently eat every note).
|
||||
const target = _pickMidiTarget(inputs, null, null, true);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: explicit "None" opt-out still wins over any global default', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'a', name: 'Device A', key: 'web-midi::a' }];
|
||||
const target = _pickMidiTarget(inputs, { id: '', name: '' }, 'web-midi::a', true);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: a present global wins even during hotplug recovery', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
// The configured global device is present — reconnect to it, don't bail.
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::b', false);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: recovery (allowFallback=false) preserves an absent configured device instead of grabbing a random one', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
// The configured device ('x', global) is currently unplugged; a transient
|
||||
// recovery must NOT switch to the unrelated device that is present.
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::x', false);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: recovery with no preference at all still allows a first-hotplug grab', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
const target = _pickMidiTarget(inputs, null, null, false);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Perf-baseline harness for the module-migration refactor (R0).
|
||||
//
|
||||
// Rerun this after every phase (R0 → R3c) to prove the split does not regress
|
||||
// screen-entry, frame-time, memory, or server latency. It writes a markdown
|
||||
// results block to stdout; paste it into docs/perf-baseline.md (or redirect).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 [--n 60] [--soak 30]
|
||||
//
|
||||
// Maintainer/CI-only dev tooling (uses the committed @playwright/test browser);
|
||||
// never part of the serve or Docker path. Metrics that need a seeded library
|
||||
// with charts (playback frame-time, screen-entry into a live highway) are
|
||||
// clearly labelled — run those against an environment with real songs.
|
||||
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require('@playwright/test');
|
||||
|
||||
const args = new Map();
|
||||
for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replace(/^--/, ''), process.argv[i + 1]);
|
||||
const BASE = args.get('base') || 'http://127.0.0.1:8000';
|
||||
const N = parseInt(args.get('n') || '60', 10);
|
||||
const SOAK_S = parseInt(args.get('soak') || '30', 10);
|
||||
|
||||
const pct = (xs, p) => {
|
||||
if (!xs.length) return null;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
||||
};
|
||||
const ms = (x) => (x == null ? '—' : `${x.toFixed(1)}`);
|
||||
|
||||
// ── Server latency: p50/p95/p99 over N requests per endpoint ──────────────────
|
||||
async function serverLatency(paths) {
|
||||
const rows = [];
|
||||
for (const path of paths) {
|
||||
const t = [];
|
||||
let status = 0;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const r = await fetch(BASE + path);
|
||||
status = r.status;
|
||||
await r.arrayBuffer();
|
||||
} catch { status = -1; }
|
||||
t.push(performance.now() - t0);
|
||||
}
|
||||
rows.push({ path, status, p50: pct(t, 50), p95: pct(t, 95), p99: pct(t, 99) });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Client: cold boot-to-interactive + idle memory after a soak ───────────────
|
||||
async function clientMetrics() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const t0 = Date.now();
|
||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
const bootMs = Date.now() - t0;
|
||||
|
||||
// performance.memory is Chromium-only; JS heap after settle.
|
||||
const mem0 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
|
||||
await page.waitForTimeout(SOAK_S * 1000);
|
||||
const mem1 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
|
||||
|
||||
const scripts = await page.evaluate(() =>
|
||||
document.querySelectorAll('script[data-plugin-id]').length);
|
||||
|
||||
await browser.close();
|
||||
return { bootMs, memStartMB: mem0 && mem0 / 1048576, memSoakMB: mem1 && mem1 / 1048576, scripts };
|
||||
}
|
||||
|
||||
const server = await serverLatency([
|
||||
'/api/version',
|
||||
'/api/plugins',
|
||||
'/api/library?limit=60',
|
||||
'/api/library/artists',
|
||||
]);
|
||||
const client = await clientMetrics();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let out = `\n<!-- generated by scripts/perf-baseline.mjs @ ${now} against ${BASE} (n=${N}, soak=${SOAK_S}s) -->\n\n`;
|
||||
out += `### Server latency (ms)\n\n| Endpoint | status | p50 | p95 | p99 |\n|---|---|---|---|---|\n`;
|
||||
for (const r of server) out += `| \`${r.path}\` | ${r.status} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} |\n`;
|
||||
out += `\n### Client\n\n| Metric | Value |\n|---|---|\n`;
|
||||
out += `| Cold boot → networkidle | ${client.bootMs} ms |\n`;
|
||||
out += `| JS heap after load | ${client.memStartMB ? client.memStartMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| JS heap after ${SOAK_S}s idle soak | ${client.memSoakMB ? client.memSoakMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| Plugin scripts injected | ${client.scripts} |\n`;
|
||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
||||
out += `> on the 2D + 3D highway, and screen-entry (plugin inject → interactive) for\n`;
|
||||
out += `> editor / notedetect / highway_3d with a real chart loaded. Run this harness\n`;
|
||||
out += `> against an environment with charts in \`DLC_DIR\` to fill those in.\n`;
|
||||
|
||||
console.log(out);
|
||||
@@ -5568,9 +5568,24 @@ function _playbackApi() {
|
||||
: null;
|
||||
}
|
||||
|
||||
// Bridge hits are a "this legacy surface is still in use" signal, not a call
|
||||
// counter — but recordBridgeHit is not cheap (compat-shim bookkeeping, a
|
||||
// playback:bridge-hit event, and a diagnostics snapshot rebuild per call).
|
||||
// Plugins legitimately poll read surfaces like window.feedBack.getLoop() from
|
||||
// HUD ticks (note_detect polled at ~30 Hz), which turned every tick into a
|
||||
// snapshot serialization on the main thread and saturated the inspector's
|
||||
// hitCount. Throttle per surface: the first call records immediately, repeats
|
||||
// within the window are dropped.
|
||||
const _bridgeRecordLast = new Map();
|
||||
const _BRIDGE_RECORD_MIN_MS = 5000;
|
||||
function _recordPlaybackBridge(bridgeId, legacySurface, reason) {
|
||||
const playback = _playbackApi();
|
||||
if (!playback || typeof playback.recordBridgeHit !== 'function') return;
|
||||
const key = `${bridgeId}|${legacySurface}`;
|
||||
const now = Date.now();
|
||||
const last = _bridgeRecordLast.get(key);
|
||||
if (last != null && now - last < _BRIDGE_RECORD_MIN_MS) return;
|
||||
_bridgeRecordLast.set(key, now);
|
||||
playback.recordBridgeHit({
|
||||
bridgeId,
|
||||
legacySurface,
|
||||
@@ -7944,6 +7959,10 @@ function setLoopEnd() {
|
||||
if (loopB <= loopA) { loopB = null; return; }
|
||||
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
updateLoopUI();
|
||||
// Manual A/B arming is a loop mutation like setLoop()'s — emit the same
|
||||
// transport event so event-driven consumers (note_detect drill sync) see
|
||||
// button-armed loops without having to poll getLoop().
|
||||
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
|
||||
}
|
||||
|
||||
function clearLoop(options) {
|
||||
@@ -11548,6 +11567,14 @@ async function loadPlugins() {
|
||||
// URL ?v=mtime convention elsewhere in this file).
|
||||
const v = encodeURIComponent(wantedVersion);
|
||||
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
||||
// Module-migration (R0): a migrated plugin declares
|
||||
// scriptType:"module" and its screen.js is `import
|
||||
// './src/main.js'`. A <script type="module"> fires load
|
||||
// only after its whole static-import graph evaluates, so
|
||||
// the await-onload completion + _loadingPluginId contract
|
||||
// below is preserved (a classic-IIFE dynamic import()
|
||||
// would not). Classic plugins are unaffected.
|
||||
if (plugin.script_type === 'module') script.type = 'module';
|
||||
script.dataset.pluginId = plugin.id;
|
||||
script.dataset.pluginVersion = wantedVersion;
|
||||
window.feedBack._loadingPluginId = plugin.id;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Guards the R0 module-migration loader change in static/app.js: a migrated
|
||||
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
|
||||
// injected as <script type="module"> so its screen.js `import './src/main.js'`
|
||||
// graph loads, while classic plugins stay untouched.
|
||||
//
|
||||
// The injection is a single line inside the large async loadPlugins() closure
|
||||
// (it depends on loadedScripts, _removePluginScriptTags, and the
|
||||
// _loadingPluginId completion window), so a faithful behavioural harness would
|
||||
// need to stub the whole loader. Instead this asserts the *structural*
|
||||
// contract in source — the guard exists, is gated (not unconditional), and sits
|
||||
// inside the screen.js injection block before appendChild. The behavioural proof
|
||||
// is the R0 end-to-end live-edit check (a real module plugin booting in-browser).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
|
||||
// Isolate the screen.js <script> injection block: from where its src is built
|
||||
// to where the element is appended.
|
||||
function injectionBlock() {
|
||||
const start = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
||||
assert.ok(start !== -1, 'screen.js injection src not found — loader moved?');
|
||||
const end = src.indexOf('document.body.appendChild(script)', start);
|
||||
assert.ok(end !== -1, 'appendChild(script) not found after screen.js src');
|
||||
return src.slice(start, end);
|
||||
}
|
||||
|
||||
test('module plugins are injected as <script type="module">', () => {
|
||||
const block = injectionBlock();
|
||||
assert.match(
|
||||
block,
|
||||
/if\s*\(\s*plugin\.script_type\s*===\s*['"]module['"]\s*\)\s*script\.type\s*=\s*['"]module['"]\s*;/,
|
||||
'expected a guarded `script.type = "module"` keyed on plugin.script_type === "module"',
|
||||
);
|
||||
});
|
||||
|
||||
test('the module type is gated, never set unconditionally', () => {
|
||||
const block = injectionBlock();
|
||||
// Every assignment of script.type in the block must be on the same line as
|
||||
// the plugin.script_type guard (i.e. no bare `script.type = 'module'`).
|
||||
for (const line of block.split('\n')) {
|
||||
if (/script\.type\s*=/.test(line)) {
|
||||
assert.match(line, /plugin\.script_type\s*===\s*['"]module['"]/,
|
||||
`unguarded script.type assignment: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the module guard sits before appendChild, after the src assignment', () => {
|
||||
const guardAt = src.indexOf('script.type = \'module\'');
|
||||
const srcAt = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
||||
const appendAt = src.indexOf('document.body.appendChild(script)', srcAt);
|
||||
assert.ok(guardAt > srcAt && guardAt < appendAt,
|
||||
'the module guard must live inside the screen.js injection block');
|
||||
});
|
||||
@@ -223,15 +223,15 @@ def test_unmapped_percussion_silently_skipped(monkeypatch):
|
||||
|
||||
def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
|
||||
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
||||
times) so a caller can surface a warning / mapping UI."""
|
||||
times + velocities) so a caller can surface a warning / mapping UI."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56, 36, 54],
|
||||
beats=[
|
||||
(0.0, [_fake_note(string_idx=1)]), # cowbell — drop
|
||||
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
||||
(1.5, [_fake_note(string_idx=3)]), # tambourine — drop
|
||||
(2.0, [_fake_note(string_idx=1)]), # cowbell again — drop
|
||||
(0.0, [_fake_note(string_idx=1, velocity=88)]), # cowbell — drop
|
||||
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
||||
(1.5, [_fake_note(string_idx=3, velocity=25)]), # tambourine — drop
|
||||
(2.0, [_fake_note(string_idx=1, velocity=44)]), # cowbell again — drop
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
@@ -247,6 +247,44 @@ def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
|
||||
# Times are captured (rounded to 3 dp).
|
||||
assert unmapped[56]["times"] == [0.0, 2.0]
|
||||
assert unmapped[54]["times"] == [1.5]
|
||||
# Velocities ride index-aligned with times — the mapping UI can carry
|
||||
# the source dynamics through instead of flattening to a default.
|
||||
assert unmapped[56]["velocities"] == [88, 44]
|
||||
assert unmapped[54]["velocities"] == [25]
|
||||
|
||||
|
||||
def test_unmapped_velocities_sort_in_lockstep_with_times(monkeypatch):
|
||||
"""Multi-voice measures can capture times out of order; the final sort
|
||||
must reorder velocities WITH their times, not leave them behind."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56],
|
||||
beats=[
|
||||
# Deliberately reversed chronology within the measure.
|
||||
(2.0, [_fake_note(string_idx=1, velocity=44)]),
|
||||
(0.0, [_fake_note(string_idx=1, velocity=88)]),
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
unmapped: dict[int, dict] = {}
|
||||
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
|
||||
assert unmapped[56]["times"] == [0.0, 2.0]
|
||||
assert unmapped[56]["velocities"] == [88, 44], \
|
||||
"velocity must follow its time through the sort"
|
||||
|
||||
|
||||
def test_unmapped_out_of_range_velocity_falls_back_to_default(monkeypatch):
|
||||
"""A corrupt/zero GP velocity records the 100 import default rather
|
||||
than poisoning the aligned list."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56],
|
||||
beats=[(0.0, [_fake_note(string_idx=1, velocity=0)])],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
unmapped: dict[int, dict] = {}
|
||||
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
|
||||
assert unmapped[56]["velocities"] == [100]
|
||||
|
||||
|
||||
def test_zero_velocity_omitted_from_wire(monkeypatch):
|
||||
|
||||
@@ -185,18 +185,18 @@ def test_unmapped_drum_note_skipped(tmp_path):
|
||||
|
||||
def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
|
||||
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
||||
times) so a caller can surface a warning / mapping UI."""
|
||||
times + velocities) so a caller can surface a warning / mapping UI."""
|
||||
mid = mido.MidiFile(type=1, ticks_per_beat=480)
|
||||
track = mido.MidiTrack()
|
||||
mid.tracks.append(track)
|
||||
track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell — drop
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=88, time=0)) # cowbell — drop
|
||||
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
|
||||
track.append(mido.Message("note_on", channel=9, note=36, velocity=100, time=0)) # kick — keep
|
||||
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=240))
|
||||
track.append(mido.Message("note_on", channel=9, note=54, velocity=100, time=0)) # tambourine — drop
|
||||
track.append(mido.Message("note_on", channel=9, note=54, velocity=25, time=0)) # tambourine — drop
|
||||
track.append(mido.Message("note_off", channel=9, note=54, velocity=0, time=240))
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell again — drop
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=44, time=0)) # cowbell again — drop
|
||||
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
|
||||
|
||||
unmapped: dict[int, dict] = {}
|
||||
@@ -209,6 +209,10 @@ def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
|
||||
# Each unmapped MIDI carries the times at which it fired (rounded 3 dp).
|
||||
assert all(isinstance(t, float) for t in unmapped[56]["times"])
|
||||
assert len(unmapped[56]["times"]) == 2
|
||||
# Velocities ride index-aligned with times — the mapping UI can carry
|
||||
# the source dynamics through instead of flattening to a default.
|
||||
assert unmapped[56]["velocities"] == [88, 44]
|
||||
assert unmapped[54]["velocities"] == [25]
|
||||
|
||||
|
||||
def test_non_channel9_events_ignored(tmp_path):
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for the plugin `src/` module-serving route and the live-edit cache
|
||||
contract added in R0 (module-migration rails).
|
||||
|
||||
Covers:
|
||||
* GET /api/plugins/{id}/src/{path} serves a plugin's ES-module source tree
|
||||
with the right Content-Type, including nested paths.
|
||||
* Path containment: `..`, absolute, and NUL are rejected (404) — the same
|
||||
`safe_join` guard the assets/ route uses.
|
||||
* The live-edit cache contract: no-cache + a weak ETag, a bodyless 304 on
|
||||
matching If-None-Match, and no stale 304 after an in-place edit.
|
||||
* screen.js and assets/ now also emit an ETag and honor If-None-Match
|
||||
(previously screen.js sent no headers and assets/ never returned 304).
|
||||
|
||||
The routes read the module-global `plugins.LOADED_PLUGINS`, so each test
|
||||
registers a fake ready plugin directly (save/restore that global) and drives
|
||||
`register_plugin_api` on a fresh FastAPI app — no full server import needed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import plugins
|
||||
|
||||
|
||||
PLUGIN_ID = "srctest"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path):
|
||||
"""A TestClient with `register_plugin_api` wired and a single fake ready
|
||||
plugin whose dir (`tmp_path`) holds a src/ tree, an asset, and a screen.js.
|
||||
Restores LOADED_PLUGINS afterward."""
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "main.js").write_text("import './util/x.js';\nexport const boot = 1;\n")
|
||||
(tmp_path / "src" / "util").mkdir()
|
||||
(tmp_path / "src" / "util" / "x.js").write_text("export const x = 42;\n")
|
||||
(tmp_path / "src" / "theme.css").write_text(".a{color:red}\n")
|
||||
(tmp_path / "assets").mkdir()
|
||||
(tmp_path / "assets" / "worklet.js").write_text("// worklet\n")
|
||||
(tmp_path / "screen.js").write_text("import './src/main.js';\n")
|
||||
|
||||
saved = list(plugins.LOADED_PLUGINS)
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.LOADED_PLUGINS.append({
|
||||
"id": PLUGIN_ID,
|
||||
"status": "ready",
|
||||
"_dir": tmp_path,
|
||||
"_manifest": {"script": "screen.js", "scriptType": "module"},
|
||||
})
|
||||
app = FastAPI()
|
||||
plugins.register_plugin_api(app)
|
||||
c = TestClient(app, raise_server_exceptions=True)
|
||||
try:
|
||||
yield c, tmp_path
|
||||
finally:
|
||||
c.close()
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.LOADED_PLUGINS.extend(saved)
|
||||
|
||||
|
||||
def test_src_file_served_with_js_media_type(client):
|
||||
c, _ = client
|
||||
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js")
|
||||
assert r.status_code == 200
|
||||
# Either application/javascript or text/javascript is a valid module-script
|
||||
# MIME (guess_type returns text/javascript on newer platforms); browsers
|
||||
# accept both for <script type=module>.
|
||||
assert "javascript" in r.headers["content-type"]
|
||||
assert "export const boot" in r.text
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
assert r.headers.get("etag")
|
||||
|
||||
|
||||
def test_src_nested_path_and_css_media_type(client):
|
||||
c, _ = client
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/util/x.js").status_code == 200
|
||||
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/theme.css")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/css")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"..%2f..%2fplugin.json", # escape the src/ dir
|
||||
"..%2f..%2f..%2fetc%2fpasswd",
|
||||
"%2fetc%2fpasswd", # absolute
|
||||
"util%2f..%2f..%2fscreen.js",
|
||||
])
|
||||
def test_src_traversal_rejected(client, bad):
|
||||
c, _ = client
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/{bad}").status_code == 404
|
||||
|
||||
|
||||
def test_src_missing_is_404(client):
|
||||
c, _ = client
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/nope.js").status_code == 404
|
||||
|
||||
|
||||
def test_src_conditional_304(client):
|
||||
c, _ = client
|
||||
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js")
|
||||
etag = r1.headers["etag"]
|
||||
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js", headers={"If-None-Match": etag})
|
||||
assert r2.status_code == 304
|
||||
assert r2.content == b""
|
||||
|
||||
|
||||
def test_src_no_stale_304_after_edit(client):
|
||||
c, root = client
|
||||
etag = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").headers["etag"]
|
||||
(root / "src" / "main.js").write_text("export const boot = 2; // edited, longer body\n")
|
||||
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js", headers={"If-None-Match": etag})
|
||||
assert r.status_code == 200
|
||||
assert "boot = 2" in r.text
|
||||
assert r.headers["etag"] != etag
|
||||
|
||||
|
||||
def test_screen_js_now_conditional(client):
|
||||
c, _ = client
|
||||
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/screen.js")
|
||||
assert r1.status_code == 200
|
||||
assert r1.headers["cache-control"] == "no-cache"
|
||||
etag = r1.headers["etag"]
|
||||
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/screen.js", headers={"If-None-Match": etag})
|
||||
assert r2.status_code == 304
|
||||
|
||||
|
||||
def test_asset_now_conditional(client):
|
||||
c, _ = client
|
||||
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/assets/worklet.js")
|
||||
assert r1.status_code == 200
|
||||
etag = r1.headers["etag"]
|
||||
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/assets/worklet.js", headers={"If-None-Match": etag})
|
||||
assert r2.status_code == 304
|
||||
|
||||
|
||||
def test_unready_plugin_src_is_404(client):
|
||||
c, _ = client
|
||||
plugins.LOADED_PLUGINS[0]["status"] = "installing"
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").status_code == 404
|
||||
Reference in New Issue
Block a user