From d5f5beab38e6697290b0ff6360a20638b2c29946 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:43:35 +0200 Subject: [PATCH 01/32] chore(deps): bump the npm minor/patch group across 43 packages (#1270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt from #1251 so the group could merge, on top of the transitive-CVE overrides from #1258. Supersedes #1230 and #1251. Carries axios 1.16.0 -> 1.18.1, closing seven runtime-scope advisories including the proxy-credential leak on redirects, and sharp 0.34.5 -> 0.35.3 for the libvips CVEs. `esModuleInterop` moves to tsconfig.base.json. artplayer 5.4.0 switched from a Parcel build exposing `module.exports.default` to UMD assigning `module.exports` directly; the flag was only set in apps/web, so every lib compiled `import Artplayer from 'artplayer'` to `.default` and got undefined. Production was never affected — esbuild resolves the ESM entry. Two packages are deliberately held back, each for its own PR: - epg-parser ^0.5.0 — grouped as a minor, but 0.x minors are breaking and this one reshapes the parse output (`channel.name` -> `displayName`, icons/urls become objects, `credits` becomes role-keyed, dates switch to ISO). Its only consumer is the uncovered web-backend `/parse-xml` endpoint. - electron-builder ^26.15.3 — rewrote the snap target, and the resulting snap cannot start (`command.sh` execs a `desktop-init.sh` that never lands at the snap root under our core22 strict config). Its two required fixes go with it: the `engines` node floor from @electron/rebuild 4, and resolving upstream node-gyp instead of the dropped `@electron/node-gyp` fork. --- .changes/deps-npm-minor-patch-group.md | 9 + apps/electron-backend/build-embedded-mpv.js | 45 +- docs/architecture/workspace-shell.md | 4 + package.json | 86 +- pnpm-lock.yaml | 2938 ++++++++++++++----- tsconfig.base.json | 1 + 6 files changed, 2231 insertions(+), 852 deletions(-) create mode 100644 .changes/deps-npm-minor-patch-group.md diff --git a/.changes/deps-npm-minor-patch-group.md b/.changes/deps-npm-minor-patch-group.md new file mode 100644 index 000000000..1e2eb2a84 --- /dev/null +++ b/.changes/deps-npm-minor-patch-group.md @@ -0,0 +1,9 @@ +--- +type: internal +area: deps +--- + +Updated 43 dependencies, including the HTTP client behind every playlist and +portal request — that one closes seven advisories that affect the shipped app, +among them a proxy-credential leak on redirects. Also refreshes the ArtPlayer, +hls.js, Video.js and Shaka player engines. No behaviour change intended. diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index eac33083a..ad7ca63ae 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -677,44 +677,23 @@ function copyGenericRuntimeToNativeBuild(runtime) { return manifest; } -function resolveElectronNodeGypBin() { - const pnpmRoot = path.join(workspaceRoot, 'node_modules', '.pnpm'); - - if (!fs.existsSync(pnpmRoot)) { - throw new Error('Unable to find node_modules/.pnpm.'); - } - - const packageDirs = fs - .readdirSync(pnpmRoot, { withFileTypes: true }) - .filter( - (entry) => - entry.isDirectory() && - entry.name.startsWith('@electron+node-gyp@') - ) - .map((entry) => entry.name) - .sort(); - - for (const packageDir of packageDirs) { - const candidate = path.join( - pnpmRoot, - packageDir, - 'node_modules', - '@electron', - 'node-gyp', - 'bin', - 'node-gyp.js' +// Upstream node-gyp, resolved as a declared devDependency. This used to scan +// node_modules/.pnpm for the `@electron/node-gyp` fork, which was only ever in +// the tree as a transitive of `@electron/rebuild` 3 — rebuild 4 moved to +// upstream `node-gyp` and the scan started throwing. The Electron target is +// selected through the npm_config_* env below, not by the binary. +function resolveNodeGypBin() { + try { + return require.resolve('node-gyp/bin/node-gyp.js'); + } catch (error) { + throw new Error( + `Unable to resolve node-gyp. Is it installed? (${error.message})` ); - - if (fs.existsSync(candidate)) { - return candidate; - } } - - throw new Error('Unable to resolve @electron/node-gyp.'); } function runNodeGyp(command, env) { - const nodeGypBin = resolveElectronNodeGypBin(); + const nodeGypBin = resolveNodeGypBin(); const result = spawnSync( process.execPath, [nodeGypBin, command, '--directory', addonRoot], diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index c0f4ffbb3..9b984e897 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -312,6 +312,10 @@ Toolchain notes for the Electron 41 upgrade: 2. The pnpm override `node-abi@3.85.0 -> 3.92.0` is required so `@electron/rebuild` (via `electron-builder install-app-deps`) can map Electron 41 to its ABI. +3. Local development needs **Node >= 22.12**, declared in `engines`. + `electron-builder` 26.15.3 pulls `@electron/rebuild` 4, which sets that + floor, and the root `postinstall` runs `install-app-deps` on every + `pnpm install`. CI already runs Node 22. Known caveats: diff --git a/package.json b/package.json index 45b2170bc..0228fc507 100644 --- a/package.json +++ b/package.json @@ -82,39 +82,39 @@ "@angular/platform-browser": "21.2.17", "@angular/platform-browser-dynamic": "21.2.17", "@angular/router": "21.2.17", - "@ngrx/effects": "21.0.1", - "@ngrx/entity": "21.0.1", - "@ngrx/router-store": "21.0.1", - "@ngrx/signals": "21.0.1", - "@ngrx/store": "21.0.1", - "@ngrx/store-devtools": "21.0.1", + "@ngrx/effects": "21.1.1", + "@ngrx/entity": "21.1.1", + "@ngrx/router-store": "21.1.1", + "@ngrx/signals": "21.1.1", + "@ngrx/store": "21.1.1", + "@ngrx/store-devtools": "21.1.1", "@ngx-pwa/local-storage": "21.0.0", "@yangkghjh/videojs-aspect-ratio-panel": "0.0.1", "angularx-qrcode": "21.0.4", - "artplayer": "5.3.0", - "axios": "1.16.0", + "artplayer": "5.4.0", + "axios": "1.18.1", "better-sqlite3": "12.9.0", - "date-fns": "4.1.0", + "date-fns": "4.4.0", "drizzle-orm": "0.45.2", "electron-conf": "1.3.0", "electron-updater": "6.8.9", "epg-parser": "^0.1.6", "fix-path": "5.0.0", - "hls.js": "1.6.13", + "hls.js": "1.6.16", "iptv-playlist-parser": "github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2", - "marked": "18.0.5", + "marked": "18.0.7", "mpegts.js": "1.8.0", "ms": "2.1.3", "ngx-indexed-db": "21.0.0", "ngx-skeleton-loader": "11.3.0", "rxjs": "7.8.2", "saxes": "6.0.0", - "shaka-player": "5.2.1", + "shaka-player": "5.2.2", "uuid": "9.0.0", - "video.js": "8.23.4", + "video.js": "8.23.9", "videojs-contrib-quality-levels": "4.1.0", "videojs-quality-selector-hls": "1.1.1", - "zone.js": "~0.15.1" + "zone.js": "~0.16.2" }, "devDependencies": { "@angular-devkit/core": "21.2.17", @@ -137,17 +137,17 @@ "@angular/router": "21.2.17", "@angular/service-worker": "21.2.17", "@astrojs/mdx": "4.3.13", - "@astrojs/sitemap": "3.7.0", + "@astrojs/sitemap": "3.7.3", "@astrojs/tailwind": "6.0.2", "@electron/asar": "3.4.1", - "@eslint/eslintrc": "3.3.1", + "@eslint/eslintrc": "3.3.6", "@eslint/js": "^9.38.0", - "@faker-js/faker": "10.3.0", - "@fontsource/crimson-pro": "5.2.8", - "@fontsource/dm-sans": "5.2.8", - "@fontsource/jetbrains-mono": "5.2.8", - "@fontsource/roboto": "5.2.10", - "@ngrx/eslint-plugin": "^21.0.1", + "@faker-js/faker": "10.5.0", + "@fontsource/crimson-pro": "5.3.0", + "@fontsource/dm-sans": "5.3.0", + "@fontsource/jetbrains-mono": "5.3.0", + "@fontsource/roboto": "5.3.0", + "@ngrx/eslint-plugin": "^21.1.1", "@ngx-translate/core": "16.0.4", "@ngx-translate/http-loader": "16.0.1", "@nx/angular": "22.7.1", @@ -160,12 +160,12 @@ "@nx/playwright": "22.7.1", "@nx/web": "22.7.1", "@nx/workspace": "22.7.1", - "@playwright/test": "^1.36.0", + "@playwright/test": "^1.62.0", "@schematics/angular": "21.2.9", - "@swc-node/register": "1.11.1", - "@swc/core": "1.15.8", - "@swc/helpers": "0.5.18", - "@tailwindcss/typography": "0.5.19", + "@swc-node/register": "1.12.1", + "@swc/core": "1.15.46", + "@swc/helpers": "0.5.23", + "@tailwindcss/typography": "0.5.20", "@types/better-sqlite3": "^7.6.12", "@types/cors": "2.8.19", "@types/express": "5.0.6", @@ -173,14 +173,14 @@ "@types/mocha": "9.0.0", "@types/node": "20.19.9", "@types/uuid": "^10.0.0", - "@types/video.js": "7.3.29", - "@typescript-eslint/eslint-plugin": "^8.46.2", - "@typescript-eslint/parser": "^8.46.2", - "@typescript-eslint/utils": "^8.46.2", + "@types/video.js": "7.3.58", + "@typescript-eslint/eslint-plugin": "^8.65.0", + "@typescript-eslint/parser": "^8.65.0", + "@typescript-eslint/utils": "^8.65.0", "angular-eslint": "21.3.1", "astro": "5.18.1", "cors": "2.8.6", - "drizzle-kit": "0.31.5", + "drizzle-kit": "0.31.10", "electron": "^41.7.2", "electron-builder": "^26.0.12", "electron-playwright-helpers": "1.8.2", @@ -194,26 +194,26 @@ "istanbul-lib-coverage": "3.2.2", "istanbul-lib-report": "3.0.1", "istanbul-reports": "3.2.0", - "jest": "^30.0.2", - "jest-environment-jsdom": "^30.0.2", - "jest-environment-node": "^30.0.2", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "jest-environment-node": "^30.4.1", "jest-preset-angular": "~15.0.0", - "jest-util": "^30.0.2", + "jest-util": "^30.4.1", "jsonc-eslint-parser": "^2.1.0", "material-design-icons-iconfont": "6.7.0", - "ng-mocks": "14.15.1", + "ng-mocks": "14.15.3", "nx": "22.7.1", "nx-electron": "22.0.0", - "prettier": "^3.8.1", - "sharp": "0.34.5", + "prettier": "^3.9.6", + "sharp": "0.35.3", "tailwindcss": "^3.4.19", - "ts-jest": "^29.4.5", + "ts-jest": "^29.4.12", "ts-node": "10.9.2", "tslib": "^2.8.1", - "tsx": "4.21.0", + "tsx": "4.23.1", "typescript": "5.9.3", - "typescript-eslint": "^8.46.2", - "yaml": "2.8.2" + "typescript-eslint": "^8.65.0", + "yaml": "2.9.0" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06aa48366..43dca8014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,73 +42,73 @@ importers: dependencies: '@angular/animations': specifier: 21.2.17 - version: 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + version: 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) '@angular/cdk': specifier: 21.2.14 - version: 21.2.14(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + version: 21.2.14(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@angular/common': specifier: 21.2.17 - version: 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + version: 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@angular/compiler': specifier: 21.2.17 version: 21.2.17 '@angular/core': specifier: 21.2.17 - version: 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + version: 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) '@angular/forms': specifier: 21.2.17 - version: 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + version: 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@angular/material': specifier: 21.2.14 - version: 21.2.14(7ae818e999981ce767bfae94a8b1de8f) + version: 21.2.14(43172ba04e47f305707a39eaf8cab87a) '@angular/platform-browser': specifier: 21.2.17 - version: 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + version: 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) '@angular/platform-browser-dynamic': specifier: 21.2.17 - version: 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))) + version: 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))) '@angular/router': specifier: 21.2.17 - version: 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + version: 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@ngrx/effects': - specifier: 21.0.1 - version: 21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@ngrx/store@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(rxjs@7.8.2) + specifier: 21.1.1 + version: 21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@ngrx/store@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(rxjs@7.8.2) '@ngrx/entity': - specifier: 21.0.1 - version: 21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@ngrx/store@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(rxjs@7.8.2) + specifier: 21.1.1 + version: 21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@ngrx/store@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(rxjs@7.8.2) '@ngrx/router-store': - specifier: 21.0.1 - version: 21.0.1(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/router@21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(@ngrx/store@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(rxjs@7.8.2) + specifier: 21.1.1 + version: 21.1.1(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/router@21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2))(@ngrx/store@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(rxjs@7.8.2) '@ngrx/signals': - specifier: 21.0.1 - version: 21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + specifier: 21.1.1 + version: 21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@ngrx/store': - specifier: 21.0.1 - version: 21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + specifier: 21.1.1 + version: 21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@ngrx/store-devtools': - specifier: 21.0.1 - version: 21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@ngrx/store@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(rxjs@7.8.2) + specifier: 21.1.1 + version: 21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@ngrx/store@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(rxjs@7.8.2) '@ngx-pwa/local-storage': specifier: 21.0.0 - version: 21.0.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + version: 21.0.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@yangkghjh/videojs-aspect-ratio-panel': specifier: 0.0.1 version: 0.0.1 angularx-qrcode: specifier: 21.0.4 - version: 21.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + version: 21.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) artplayer: - specifier: 5.3.0 - version: 5.3.0 + specifier: 5.4.0 + version: 5.4.0 axios: - specifier: 1.16.0 - version: 1.16.0 + specifier: 1.18.1 + version: 1.18.1 better-sqlite3: specifier: 12.9.0 version: 12.9.0 date-fns: - specifier: 4.1.0 - version: 4.1.0 + specifier: 4.4.0 + version: 4.4.0 drizzle-orm: specifier: 0.45.2 version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.9.0) @@ -125,14 +125,14 @@ importers: specifier: 5.0.0 version: 5.0.0 hls.js: - specifier: 1.6.13 - version: 1.6.13 + specifier: 1.6.16 + version: 1.6.16 iptv-playlist-parser: specifier: github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2 version: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/d547b6be8a44431c85fbcbcdefb41f28c0dadf6d marked: - specifier: 18.0.5 - version: 18.0.5 + specifier: 18.0.7 + version: 18.0.7 mpegts.js: specifier: 1.8.0 version: 1.8.0 @@ -141,10 +141,10 @@ importers: version: 2.1.3 ngx-indexed-db: specifier: 21.0.0 - version: 21.0.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + version: 21.0.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) ngx-skeleton-loader: specifier: 11.3.0 - version: 11.3.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + version: 11.3.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) rxjs: specifier: 7.8.2 version: 7.8.2 @@ -152,23 +152,23 @@ importers: specifier: 6.0.0 version: 6.0.0 shaka-player: - specifier: 5.2.1 - version: 5.2.1 + specifier: 5.2.2 + version: 5.2.2 uuid: specifier: 9.0.0 version: 9.0.0 video.js: - specifier: 8.23.4 - version: 8.23.4 + specifier: 8.23.9 + version: 8.23.9 videojs-contrib-quality-levels: specifier: 4.1.0 - version: 4.1.0(video.js@8.23.4) + version: 4.1.0(video.js@8.23.9) videojs-quality-selector-hls: specifier: 1.1.1 version: 1.1.1 zone.js: - specifier: ~0.15.1 - version: 0.15.1 + specifier: ~0.16.2 + version: 0.16.2 devDependencies: '@angular-devkit/core': specifier: 21.2.17 @@ -181,19 +181,19 @@ importers: version: 21.3.1(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin': specifier: 21.3.1 - version: 21.3.1(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 21.3.1(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/eslint-plugin-template': specifier: 21.3.1 - version: 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/schematics': specifier: 21.3.1 - version: 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/template-parser': specifier: 21.3.1 version: 21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular/build': specifier: 21.2.17 - version: 21.2.17(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.17)(typescript@5.9.3))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/service-worker@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.97.1)(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))(terser@5.44.1)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 21.2.17(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.17)(typescript@5.9.3))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/service-worker@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.97.1)(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0))(terser@5.44.1)(tslib@2.8.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0) '@angular/cli': specifier: 21.2.17 version: 21.2.17(@types/node@20.19.9)(chokidar@5.0.0) @@ -205,97 +205,97 @@ importers: version: 21.2.17 '@angular/service-worker': specifier: 21.2.17 - version: 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + version: 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@astrojs/mdx': specifier: 4.3.13 - version: 4.3.13(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) + version: 4.3.13(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0)) '@astrojs/sitemap': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.7.3 + version: 3.7.3 '@astrojs/tailwind': specifier: 6.0.2 - version: 6.0.2(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)) + version: 6.0.2(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0))(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) '@electron/asar': specifier: 3.4.1 version: 3.4.1 '@eslint/eslintrc': - specifier: 3.3.1 - version: 3.3.1 + specifier: 3.3.6 + version: 3.3.6 '@eslint/js': specifier: ^9.38.0 version: 9.39.2 '@faker-js/faker': - specifier: 10.3.0 - version: 10.3.0 + specifier: 10.5.0 + version: 10.5.0 '@fontsource/crimson-pro': - specifier: 5.2.8 - version: 5.2.8 + specifier: 5.3.0 + version: 5.3.0 '@fontsource/dm-sans': - specifier: 5.2.8 - version: 5.2.8 + specifier: 5.3.0 + version: 5.3.0 '@fontsource/jetbrains-mono': - specifier: 5.2.8 - version: 5.2.8 + specifier: 5.3.0 + version: 5.3.0 '@fontsource/roboto': - specifier: 5.2.10 - version: 5.2.10 + specifier: 5.3.0 + version: 5.3.0 '@ngrx/eslint-plugin': - specifier: ^21.0.1 - version: 21.0.1(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3) + specifier: ^21.1.1 + version: 21.1.1(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3) '@ngx-translate/core': specifier: 16.0.4 - version: 16.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + version: 16.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) '@ngx-translate/http-loader': specifier: 16.0.1 - version: 16.0.1(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + version: 16.0.1(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) '@nx/angular': specifier: 22.7.1 - version: 22.7.1(3fcdae4966e6f655e54cd885e10979aa) + version: 22.7.1(c2f36107352a552af1b8d4017d026f40) '@nx/devkit': specifier: 22.7.1 - version: 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + version: 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) '@nx/esbuild': specifier: 22.7.1 - version: 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + version: 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) '@nx/eslint': specifier: 22.7.1 - version: 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + version: 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) '@nx/eslint-plugin': specifier: 22.7.1 - version: 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(typescript@5.9.3) + version: 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript-eslint/parser@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(typescript@5.9.3) '@nx/jest': specifier: 22.7.1 - version: 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) + version: 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) '@nx/js': specifier: 22.7.1 - version: 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + version: 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) '@nx/playwright': specifier: 22.7.1 - version: 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@playwright/test@1.57.0)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + version: 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@playwright/test@1.62.0)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) '@nx/web': specifier: 22.7.1 - version: 22.7.1(4b628920197e2640a94b2a773363406b) + version: 22.7.1(5f4f264ec7c07d507acd7660d73c4398) '@nx/workspace': specifier: 22.7.1 - version: 22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)) + version: 22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)) '@playwright/test': - specifier: ^1.36.0 - version: 1.57.0 + specifier: ^1.62.0 + version: 1.62.0 '@schematics/angular': specifier: 21.2.9 version: 21.2.9(chokidar@5.0.0) '@swc-node/register': - specifier: 1.11.1 - version: 1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3) + specifier: 1.12.1 + version: 1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3) '@swc/core': - specifier: 1.15.8 - version: 1.15.8(@swc/helpers@0.5.18) + specifier: 1.15.46 + version: 1.15.46(@swc/helpers@0.5.23) '@swc/helpers': - specifier: 0.5.18 - version: 0.5.18 + specifier: 0.5.23 + version: 0.5.23 '@tailwindcss/typography': - specifier: 0.5.19 - version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) + specifier: 0.5.20 + version: 0.5.20(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0)) '@types/better-sqlite3': specifier: ^7.6.12 version: 7.6.13 @@ -318,29 +318,29 @@ importers: specifier: ^10.0.0 version: 10.0.0 '@types/video.js': - specifier: 7.3.29 - version: 7.3.29 + specifier: 7.3.58 + version: 7.3.58 '@typescript-eslint/eslint-plugin': - specifier: ^8.46.2 - version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.65.0 + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^8.46.2 - version: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.65.0 + version: 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/utils': - specifier: ^8.46.2 - version: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.65.0 + version: 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) angular-eslint: specifier: 21.3.1 - version: 21.3.1(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3) + version: 21.3.1(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3) astro: specifier: 5.18.1 - version: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0) cors: specifier: 2.8.6 version: 2.8.6 drizzle-kit: - specifier: 0.31.5 - version: 0.31.5 + specifier: 0.31.10 + version: 0.31.10 electron: specifier: ^41.7.2 version: 41.7.2 @@ -361,7 +361,7 @@ importers: version: 10.1.8(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) + version: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-playwright: specifier: ^1.6.2 version: 1.8.3(eslint@9.39.2(jiti@1.21.7)) @@ -381,20 +381,20 @@ importers: specifier: 3.2.0 version: 3.2.0 jest: - specifier: ^30.0.2 - version: 30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)) + specifier: ^30.4.2 + version: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) jest-environment-jsdom: - specifier: ^30.0.2 - version: 30.2.0 + specifier: ^30.4.1 + version: 30.4.1 jest-environment-node: - specifier: ^30.0.2 - version: 30.2.0 + specifier: ^30.4.1 + version: 30.4.1 jest-preset-angular: specifier: ~15.0.0 - version: 15.0.3(75c2f4d3e02d43367177c3c83cdeef78) + version: 15.0.3(c4e4d73a3616691068af99860e93e092) jest-util: - specifier: ^30.0.2 - version: 30.2.0 + specifier: ^30.4.1 + version: 30.4.1 jsonc-eslint-parser: specifier: ^2.1.0 version: 2.4.2 @@ -402,44 +402,44 @@ importers: specifier: 6.7.0 version: 6.7.0 ng-mocks: - specifier: 14.15.1 - version: 14.15.1(3bd9032d10655768ae17cd8d4331ec90) + specifier: 14.15.3 + version: 14.15.3(1918f1ef5f9f83bc2aab38921c00c721) nx: specifier: 22.7.1 - version: 22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)) + version: 22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)) nx-electron: specifier: 22.0.0 - version: 22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))))(@nx/workspace@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(@swc/core@1.15.8(@swc/helpers@0.5.18))(electron-builder-squirrel-windows@26.0.12)(electron@41.7.2)(esbuild@0.28.1)(rxjs@7.8.2)(typescript@5.9.3) + version: 22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))))(@nx/workspace@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(@swc/core@1.15.46(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.0.12)(electron@41.7.2)(esbuild@0.28.1)(rxjs@7.8.2)(typescript@5.9.3) prettier: - specifier: ^3.8.1 - version: 3.8.1 + specifier: ^3.9.6 + version: 3.9.6 sharp: - specifier: 0.34.5 - version: 0.34.5 + specifier: 0.35.3 + version: 0.35.3(@types/node@20.19.9) tailwindcss: specifier: ^3.4.19 - version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) + version: 3.4.19(tsx@4.23.1)(yaml@2.9.0) ts-jest: - specifier: ^29.4.5 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.28.1)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3) + specifier: ^29.4.12 + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: 10.9.2 - version: 10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3) + version: 10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3) tslib: specifier: ^2.8.1 version: 2.8.1 tsx: - specifier: 4.21.0 - version: 4.21.0 + specifier: 4.23.1 + version: 4.23.1 typescript: specifier: 5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.46.2 - version: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.65.0 + version: 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) yaml: - specifier: 2.8.2 - version: 2.8.2 + specifier: 2.9.0 + version: 2.9.0 packages: @@ -789,8 +789,8 @@ packages: resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} - '@astrojs/sitemap@3.7.0': - resolution: {integrity: sha512-+qxjUrz6Jcgh+D5VE1gKUJTA3pSthuPHe6Ao5JCxok794Lewx8hBFaWHtOnN0ntb2lfOf7gvOi9TefUswQ/ZVA==} + '@astrojs/sitemap@3.7.3': + resolution: {integrity: sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==} '@astrojs/tailwind@6.0.2': resolution: {integrity: sha512-j3mhLNeugZq6A8dMNXVarUa8K6X9AW+QHU9u3lKNrPLMHhOQ0S7VeWhHwEeJFpEK1BTKEUY1U78VQv2gN6hNGg==} @@ -1643,6 +1643,9 @@ packages: '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@1.4.5': resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} @@ -1657,11 +1660,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -2285,8 +2288,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.2': @@ -2301,21 +2304,21 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@faker-js/faker@10.3.0': - resolution: {integrity: sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==} + '@faker-js/faker@10.5.0': + resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} - '@fontsource/crimson-pro@5.2.8': - resolution: {integrity: sha512-t+cAoAswUKEReDPqDNWWSyAp7baZkKrtF6Ry0KrDZQHJCldS9pIbRH0n2hm1u4+SbIUI7c+nUS9yfK6rEhycTw==} + '@fontsource/crimson-pro@5.3.0': + resolution: {integrity: sha512-PXQH0NGma2wwskZAySBH+s/ezAYBwyDH0I4oRURBu9u81nNUCm5DcNCJaUS9H0UR1y8qlslLfVRU6mnDBpffrA==} - '@fontsource/dm-sans@5.2.8': - resolution: {integrity: sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA==} + '@fontsource/dm-sans@5.3.0': + resolution: {integrity: sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==} - '@fontsource/jetbrains-mono@5.2.8': - resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} + '@fontsource/jetbrains-mono@5.3.0': + resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==} - '@fontsource/roboto@5.2.10': - resolution: {integrity: sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==} + '@fontsource/roboto@5.3.0': + resolution: {integrity: sha512-BapRJOWYP+LZ21zp+wBQjfpPYKRoxc4LspJ/RLuI+HSMBD5u/X4O+ESDrSvEqDSy0rAl7GwBJ+09mdc16cVQ1Q==} '@gar/promise-retry@1.0.3': resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} @@ -2353,76 +2356,155 @@ packages: resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + '@img/sharp-darwin-arm64@0.34.5': resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2430,6 +2512,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2437,6 +2526,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2444,6 +2540,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2451,6 +2554,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2458,6 +2568,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2465,6 +2582,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2472,6 +2596,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2479,29 +2610,63 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -2664,8 +2829,12 @@ packages: resolution: {integrity: sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/core@30.2.0': - resolution: {integrity: sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==} + '@jest/console@30.4.1': + resolution: {integrity: sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.4.2': + resolution: {integrity: sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -2677,6 +2846,10 @@ packages: resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/environment-jsdom-abstract@30.2.0': resolution: {integrity: sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2687,22 +2860,48 @@ packages: canvas: optional: true + '@jest/environment-jsdom-abstract@30.4.1': + resolution: {integrity: sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + jsdom: '*' + peerDependenciesMeta: + canvas: + optional: true + '@jest/environment@30.2.0': resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/environment@30.4.1': + resolution: {integrity: sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect-utils@30.2.0': resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect-utils@30.4.1': + resolution: {integrity: sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect@30.2.0': resolution: {integrity: sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect@30.4.1': + resolution: {integrity: sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/fake-timers@30.2.0': resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/fake-timers@30.4.1': + resolution: {integrity: sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/get-type@30.1.0': resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2711,10 +2910,18 @@ packages: resolution: {integrity: sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/globals@30.4.1': + resolution: {integrity: sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/pattern@30.0.1': resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/pattern@30.4.0': + resolution: {integrity: sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/reporters@30.2.0': resolution: {integrity: sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2724,14 +2931,31 @@ packages: node-notifier: optional: true + '@jest/reporters@30.4.1': + resolution: {integrity: sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + '@jest/schemas@30.0.5': resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/snapshot-utils@30.2.0': resolution: {integrity: sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/snapshot-utils@30.4.1': + resolution: {integrity: sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/source-map@30.0.1': resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2740,18 +2964,34 @@ packages: resolution: {integrity: sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/test-result@30.4.1': + resolution: {integrity: sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/test-sequencer@30.2.0': resolution: {integrity: sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/test-sequencer@30.4.1': + resolution: {integrity: sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/transform@30.2.0': resolution: {integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/transform@30.4.1': + resolution: {integrity: sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@30.2.0': resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@30.4.1': + resolution: {integrity: sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -3227,39 +3467,39 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} - '@ngrx/effects@21.0.1': - resolution: {integrity: sha512-hSdpToAiSYa5FJ/CAygQHpnCaF2S1HO7q/57ob3XvNTWmkofa0VqI/IIe4W57bojh2YOWCJ91SCn3kAjymaV3g==} + '@ngrx/effects@21.1.1': + resolution: {integrity: sha512-JQUv2tYyjkbdsE40WYtJDsiI+3qtn4V5m0Ybr2CYw9zKpVQshDV5MLLAni+SuijlHAWJkw8x4wwnYmNyG/Rmng==} peerDependencies: '@angular/core': ^21.0.0 - '@ngrx/store': 21.0.1 + '@ngrx/store': 21.1.1 rxjs: ^6.5.3 || ^7.5.0 - '@ngrx/entity@21.0.1': - resolution: {integrity: sha512-h7PjW8WzkPwSYDlicWcEw6EJ9yBfa+fbf5tmIty4cZ8yH6+Ld3aHuGAMAu8xbnV79oj0TGa/PSynu5LZq4jXdA==} + '@ngrx/entity@21.1.1': + resolution: {integrity: sha512-yDHjjw63XjKWRXimIAloxo+Ws5AK2IPUIankRJZGw+IRCinZCO6gzhHmNamfqfS2vlcUgCS1r/7ZphRunK3WAg==} peerDependencies: '@angular/core': ^21.0.0 - '@ngrx/store': 21.0.1 + '@ngrx/store': 21.1.1 rxjs: ^6.5.3 || ^7.5.0 - '@ngrx/eslint-plugin@21.0.1': - resolution: {integrity: sha512-ZFFb7UFiTF4UzXYaMRmK6hEHFOiFtoJ+Euus4O7i0A+0na+zaYVxS42cdylG/IKe3sfAoLKZWkly/idNnMSaWQ==} + '@ngrx/eslint-plugin@21.1.1': + resolution: {integrity: sha512-I2RzaBmEPuSDOE/TCwG8m0mMi7ETKo1j+1GRZ8JuhNDj+sEpzj5hQMnbLoVcRjcz2ctGJF6rFI9qADztzvg5wg==} peerDependencies: '@typescript-eslint/utils': ^8.0.0 - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '*' typescript-eslint: ^8.0.0 - '@ngrx/router-store@21.0.1': - resolution: {integrity: sha512-hacH8ciwCRMLg7p7bThslYn564GOyS5LPf9c8cX4FTyOH+iJM32eJd2Lt64cA62vN6ruofV/IM2vB09nCkxHzg==} + '@ngrx/router-store@21.1.1': + resolution: {integrity: sha512-niO2J0aYgMcplHRgg3Nf1DPjHpg4ehZjqnA282DtLOXlYtHWoP2ePZcvnPxhK1K77Am5hSGOLa7U2DjjgwlSdQ==} peerDependencies: '@angular/common': ^21.0.0 '@angular/core': ^21.0.0 '@angular/router': ^21.0.0 - '@ngrx/store': 21.0.1 + '@ngrx/store': 21.1.1 rxjs: ^6.5.3 || ^7.5.0 - '@ngrx/signals@21.0.1': - resolution: {integrity: sha512-krmZDhgHrnmZrxfEJ41bp/aM8Mc55k5B2N7oCLT5w4M3YbOkbnWPkP6bBWMv4XPI+2rqVgkLRW6DaWLwoESaBw==} + '@ngrx/signals@21.1.1': + resolution: {integrity: sha512-AXxsvO39cJ4gu2GMvLyUHwx+3qHi35Bn7OUqyIfJW0fffi/Af7ML6h0MPMWDHVROo55FBgxzJrYRswwzdew9VA==} peerDependencies: '@angular/core': ^21.0.0 rxjs: ^6.5.3 || ^7.4.0 @@ -3267,15 +3507,15 @@ packages: rxjs: optional: true - '@ngrx/store-devtools@21.0.1': - resolution: {integrity: sha512-G9fO7CFwYUpz8+JZ9uny+lVJ7iv6PcFJDg+jae5CCrAUIiflnR8gbwD8exAd2AODpxPCpmnSojuLNpLDAcwGzQ==} + '@ngrx/store-devtools@21.1.1': + resolution: {integrity: sha512-gNRDPCZZCO2uzdamco6xJ7292cZdVumHqOIAqmJsYaHkRQ4sZIKBjwFyYXAtPvS6a+Y9bnkGkHetxkF1iZzDvA==} peerDependencies: '@angular/core': ^21.0.0 - '@ngrx/store': 21.0.1 + '@ngrx/store': 21.1.1 rxjs: ^6.5.3 || ^7.5.0 - '@ngrx/store@21.0.1': - resolution: {integrity: sha512-2hGnw/c5o8nmKzyx7TrUUM7FXjE2zqjX0EF+wLbw9Oy/L+VdCmx+ZI1BFjuAR4B8PKEWHG2KSbOM13SMNkpZiA==} + '@ngrx/store@21.1.1': + resolution: {integrity: sha512-B0/IyBTqBExmwPAmrpcJrhlrn+FeeitvOW8ltf7O3kJb2vRKduV5Oi34GLiwYQ8eAukTbKyWQv/VV02gxLeEMg==} peerDependencies: '@angular/core': ^21.0.0 rxjs: ^6.5.3 || ^7.5.0 @@ -3299,6 +3539,97 @@ packages: '@angular/common': '>=16' '@angular/core': '>=16' + '@node-rs/xxhash-android-arm-eabi@1.7.6': + resolution: {integrity: sha512-ptmfpFZ8SgTef58Us+0HsZ9BKhyX/gZYbhLkuzPt7qUoMqMSJK85NC7LEgzDgjUiG+S5GahEEQ9/tfh9BVvKhw==} + engines: {node: '>= 12'} + cpu: [arm] + os: [android] + + '@node-rs/xxhash-android-arm64@1.7.6': + resolution: {integrity: sha512-n4MyZvqifuoARfBvrZ2IBqmsGzwlVI3kb2mB0gVvoHtMsPbl/q94zoDBZ7WgeP3t4Wtli+QS3zgeTCOWUbqqUQ==} + engines: {node: '>= 12'} + cpu: [arm64] + os: [android] + + '@node-rs/xxhash-darwin-arm64@1.7.6': + resolution: {integrity: sha512-6xGuE07CiCIry/KT3IiwQd/kykTOmjKzO/ZnHlE5ibGMx64NFE0qDuwJbxQ4rGyUzgJ0KuN9ZdOhUDJmepnpcw==} + engines: {node: '>= 12'} + cpu: [arm64] + os: [darwin] + + '@node-rs/xxhash-darwin-x64@1.7.6': + resolution: {integrity: sha512-Z4oNnhyznDvHhxv+s0ka+5KG8mdfLVucZMZMejj9BL+CPmamClygPiHIRiifRcPAoX9uPZykaCsULngIfLeF3Q==} + engines: {node: '>= 12'} + cpu: [x64] + os: [darwin] + + '@node-rs/xxhash-freebsd-x64@1.7.6': + resolution: {integrity: sha512-arCDOf3xZ5NfBL5fk5J52sNPjXL2cVWN6nXNB3nrtRFFdPBLsr6YXtshAc6wMVxnIW4VGaEv/5K6IpTA8AFyWw==} + engines: {node: '>= 12'} + cpu: [x64] + os: [freebsd] + + '@node-rs/xxhash-linux-arm-gnueabihf@1.7.6': + resolution: {integrity: sha512-ndLLEW+MwLH3lFS0ahlHCcmkf2ykOv/pbP8OBBeAOlz/Xc3jKztg5IJ9HpkjKOkHk470yYxgHVaw1QMoMzU00A==} + engines: {node: '>= 12'} + cpu: [arm] + os: [linux] + + '@node-rs/xxhash-linux-arm64-gnu@1.7.6': + resolution: {integrity: sha512-VX7VkTG87mAdrF2vw4aroiRpFIIN8Lj6NgtGHF+IUVbzQxPudl4kG+FPEjsNH8y04yQxRbPE7naQNgHcTKMrNw==} + engines: {node: '>= 12'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@node-rs/xxhash-linux-arm64-musl@1.7.6': + resolution: {integrity: sha512-AB5m6crGYSllM9F/xZNOQSPImotR5lOa9e4arW99Bv82S+gcpphI8fGMDOVTTCXY/RLRhvvhwzLDxmLB2O8VDg==} + engines: {node: '>= 12'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@node-rs/xxhash-linux-x64-gnu@1.7.6': + resolution: {integrity: sha512-a2A6M+5tc0PVlJlE/nl0XsLEzMpKkwg7Y1lR5urFUbW9uVQnKjJYQDrUojhlXk0Uv3VnYQPa6ThmwlacZA5mvQ==} + engines: {node: '>= 12'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@node-rs/xxhash-linux-x64-musl@1.7.6': + resolution: {integrity: sha512-WioGJSC1GoxQpmdQrG5l/uddSBAS4XCWczHNwXe895J5xadGQzyvmr0r17BNfihvbBUDH1H9jwouNYzDDeA6+A==} + engines: {node: '>= 12'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@node-rs/xxhash-wasm32-wasi@1.7.6': + resolution: {integrity: sha512-WDXXKMMFMrez+esm2DzMPHFNPFYf+wQUtaXrXwtxXeQMFEzleOLwEaqV0+bbXGJTwhPouL3zY1Qo2xmIH4kkTg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/xxhash-win32-arm64-msvc@1.7.6': + resolution: {integrity: sha512-qjDFUZJT/Zq0yFS+0TApkD86p0NBdPXlOoHur9yNeO9YX2/9/b1sC2P7N27PgOu13h61TUOvTUC00e/82jAZRQ==} + engines: {node: '>= 12'} + cpu: [arm64] + os: [win32] + + '@node-rs/xxhash-win32-ia32-msvc@1.7.6': + resolution: {integrity: sha512-s7a+mQWOTnU4NiiypRq/vbNGot/il0HheXuy9oxJ0SW2q/e4BJ8j0pnP6UBlAjsk+005A76vOwsEj01qbQw8+A==} + engines: {node: '>= 12'} + cpu: [ia32] + os: [win32] + + '@node-rs/xxhash-win32-x64-msvc@1.7.6': + resolution: {integrity: sha512-zHOHm2UaIahRhgRPJll+4Xy4Z18aAT/7KNeQW+QJupGvFz+GzOFXMGs3R/3B1Ktob/F5ui3i1MrW9GEob3CWTg==} + engines: {node: '>= 12'} + cpu: [x64] + os: [win32] + + '@node-rs/xxhash@1.7.6': + resolution: {integrity: sha512-XMisO+aQHsVpxRp/85EszTtOQTOlhPbd149P/Xa9F55wafA6UM3h2UhOgOs7aAzItnHU/Aw1WQ1FVTEg7WB43Q==} + engines: {node: '>= 12'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3735,9 +4066,9 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.57.0': - resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} - engines: {node: '>=18'} + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} hasBin: true '@rolldown/binding-android-arm64@1.0.0-rc.4': @@ -4127,91 +4458,108 @@ packages: '@sinonjs/fake-timers@13.0.5': resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} + '@sinonjs/fake-timers@15.4.0': + resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@swc-node/core@1.14.1': - resolution: {integrity: sha512-jrt5GUaZUU6cmMS+WTJEvGvaB6j1YNKPHPzC2PUi2BjaFbtxURHj6641Az6xN7b665hNniAIdvjxWcRml5yCnw==} + '@swc-node/core@1.15.0': + resolution: {integrity: sha512-WAerrrl087WgenB92XG4Th2t0NQFfMNLYSe0sW2cEMMqM/LQmP4rozsDJl0vuzTrbewjpKQryxFXI+aYig/dBg==} engines: {node: '>= 10'} peerDependencies: '@swc/core': '>= 1.13.3' '@swc/types': '>= 0.1' - '@swc-node/register@1.11.1': - resolution: {integrity: sha512-VQ0hJ5jX31TVv/fhZx4xJRzd8pwn6VvzYd2tGOHHr2TfXGCBixZoqdPDXTiEoJLCTS2MmvBf6zyQZZ0M8aGQCQ==} + '@swc-node/register@1.12.1': + resolution: {integrity: sha512-t6t+0bDos+bj0+jcSqKl7+ys/i1an5cEViC0LuIskJFSHONX871nXN8j+gxdKVOgCCpjs0buXUPNnp/0DaV4EQ==} peerDependencies: '@swc/core': '>= 1.4.13' - typescript: '>= 4.3' + typescript: '>= 4.3 < 7' '@swc-node/sourcemap-support@0.6.1': resolution: {integrity: sha512-ovltDVH5QpdHXZkW138vG4+dgcNsxfwxHVoV6BtmTbz2KKl1A8ZSlbdtxzzfNjCjbpayda8Us9eMtcHobm38dA==} - '@swc/core-darwin-arm64@1.15.8': - resolution: {integrity: sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==} + '@swc/core-darwin-arm64@1.15.46': + resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.15.8': - resolution: {integrity: sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ==} + '@swc/core-darwin-x64@1.15.46': + resolution: {integrity: sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.15.8': - resolution: {integrity: sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg==} + '@swc/core-linux-arm-gnueabihf@1.15.46': + resolution: {integrity: sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.15.8': - resolution: {integrity: sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q==} + '@swc/core-linux-arm64-gnu@1.15.46': + resolution: {integrity: sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [glibc] - '@swc/core-linux-arm64-musl@1.15.8': - resolution: {integrity: sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==} + '@swc/core-linux-arm64-musl@1.15.46': + resolution: {integrity: sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [musl] - '@swc/core-linux-x64-gnu@1.15.8': - resolution: {integrity: sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==} + '@swc/core-linux-ppc64-gnu@1.15.46': + resolution: {integrity: sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.46': + resolution: {integrity: sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.46': + resolution: {integrity: sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [glibc] - '@swc/core-linux-x64-musl@1.15.8': - resolution: {integrity: sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==} + '@swc/core-linux-x64-musl@1.15.46': + resolution: {integrity: sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [musl] - '@swc/core-win32-arm64-msvc@1.15.8': - resolution: {integrity: sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==} + '@swc/core-win32-arm64-msvc@1.15.46': + resolution: {integrity: sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.15.8': - resolution: {integrity: sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ==} + '@swc/core-win32-ia32-msvc@1.15.46': + resolution: {integrity: sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.15.8': - resolution: {integrity: sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA==} + '@swc/core-win32-x64-msvc@1.15.46': + resolution: {integrity: sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.15.8': - resolution: {integrity: sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==} + '@swc/core@1.15.46': + resolution: {integrity: sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -4222,20 +4570,20 @@ packages: '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@swc/helpers@0.5.18': - resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@swc/types@0.1.25': - resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + '@swc/types@0.1.27': + resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} - '@tailwindcss/typography@0.5.19': - resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + '@tailwindcss/typography@0.5.20': + resolution: {integrity: sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==} peerDependencies: - tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' '@tootallnate/once@2.0.1': resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} @@ -4393,9 +4741,6 @@ packages: '@types/node-forge@1.3.14': resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} - '@types/node@17.0.45': - resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} - '@types/node@20.19.9': resolution: {integrity: sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==} @@ -4462,8 +4807,8 @@ packages: '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} - '@types/video.js@7.3.29': - resolution: {integrity: sha512-7xP5O3tQC6dm5xljwwvDvMKTumlu/cag4k5FwezMUazoCAbni0t3NB5ToavQlNAH5OKLLP3/ZEJbPqP6ShocIA==} + '@types/video.js@7.3.58': + resolution: {integrity: sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==} '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -4474,20 +4819,20 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.51.0': - resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==} + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.51.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/parser': ^8.65.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.51.0': - resolution: {integrity: sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==} + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/project-service@8.51.0': resolution: {integrity: sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==} @@ -4495,16 +4840,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.51.0': resolution: {integrity: sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.51.0': resolution: {integrity: sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.51.0': resolution: {integrity: sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4512,16 +4873,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.51.0': resolution: {integrity: sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.51.0': resolution: {integrity: sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.51.0': resolution: {integrity: sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4529,10 +4907,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.51.0': resolution: {integrity: sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -4646,8 +5035,8 @@ packages: peerDependencies: video.js: ^6 || ^7 - '@videojs/http-streaming@3.17.2': - resolution: {integrity: sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==} + '@videojs/http-streaming@3.17.5': + resolution: {integrity: sha512-PRRQVu0hYW5h0XvRWpIyIpEatpMZzZtf7L1avV89Hnu+69f+0c7oLsSL9jwpg2nbvzGkdX5rQxQkz5Vc7tFR9w==} engines: {node: '>=8', npm: '>=5'} peerDependencies: video.js: ^8.19.0 @@ -4656,8 +5045,8 @@ packages: resolution: {integrity: sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==} engines: {node: '>=8', npm: '>=5'} - '@videojs/vhs-utils@4.1.1': - resolution: {integrity: sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==} + '@videojs/vhs-utils@4.1.2': + resolution: {integrity: sha512-1Dsv5dtWOq0LP1G37O46FQFA2tKAZON5UMe6dFPxT7JvPhEx8QIXcFQxan8+hHI3fX6aomFRFrUBJNgRYQDSXA==} engines: {node: '>=8', npm: '>=5'} '@videojs/xhr@2.6.0': @@ -4960,8 +5349,8 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} - artplayer@5.3.0: - resolution: {integrity: sha512-yExO39MpEg4P+bxgChxx1eJfiUPE4q1QQRLCmqGhlsj+ANuaoEkR8hF93LdI5ZyrAcIbJkuEndxEiUoKobifDw==} + artplayer@5.4.0: + resolution: {integrity: sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==} assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} @@ -5012,8 +5401,8 @@ packages: axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} - axios@1.16.0: - resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -5025,6 +5414,12 @@ packages: peerDependencies: '@babel/core': ^7.11.0 || ^8.0.0-0 + babel-jest@30.4.1: + resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + babel-loader@9.2.1: resolution: {integrity: sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==} engines: {node: '>= 14.15.0'} @@ -5045,6 +5440,10 @@ packages: resolution: {integrity: sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + babel-plugin-jest-hoist@30.4.0: + resolution: {integrity: sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -5084,6 +5483,12 @@ packages: peerDependencies: '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + babel-preset-jest@30.4.0: + resolution: {integrity: sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -5727,8 +6132,8 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} - date-fns@4.1.0: - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} date-format@4.0.14: resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==} @@ -5959,8 +6364,8 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - drizzle-kit@0.31.5: - resolution: {integrity: sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg==} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true drizzle-orm@0.45.2: @@ -6353,6 +6758,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6462,6 +6871,10 @@ packages: resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + expect@30.4.1: + resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} @@ -6833,8 +7246,8 @@ packages: handle-thing@2.0.1: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true @@ -6916,8 +7329,8 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - hls.js@1.6.13: - resolution: {integrity: sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==} + hls.js@1.6.16: + resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} homedir-polyfill@1.0.3: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} @@ -7438,16 +7851,20 @@ packages: engines: {node: '>=10'} hasBin: true - jest-changed-files@30.2.0: - resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} + jest-changed-files@30.4.1: + resolution: {integrity: sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-circus@30.2.0: resolution: {integrity: sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-cli@30.2.0: - resolution: {integrity: sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==} + jest-circus@30.4.2: + resolution: {integrity: sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.4.2: + resolution: {integrity: sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -7471,20 +7888,47 @@ packages: ts-node: optional: true + jest-config@30.4.2: + resolution: {integrity: sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + jest-diff@30.2.0: resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-docblock@30.2.0: resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-docblock@30.4.0: + resolution: {integrity: sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-each@30.2.0: resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-environment-jsdom@30.2.0: - resolution: {integrity: sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==} + jest-each@30.4.1: + resolution: {integrity: sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-jsdom@30.4.1: + resolution: {integrity: sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 @@ -7496,26 +7940,50 @@ packages: resolution: {integrity: sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-environment-node@30.4.1: + resolution: {integrity: sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-haste-map@30.2.0: resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-haste-map@30.4.1: + resolution: {integrity: sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-leak-detector@30.2.0: resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-leak-detector@30.4.1: + resolution: {integrity: sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-matcher-utils@30.2.0: resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@30.2.0: resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@30.4.1: + resolution: {integrity: sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@30.2.0: resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@30.4.1: + resolution: {integrity: sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-pnp-resolver@1.2.3: resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} engines: {node: '>=6'} @@ -7541,38 +8009,70 @@ packages: resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve-dependencies@30.2.0: - resolution: {integrity: sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==} + jest-regex-util@30.4.0: + resolution: {integrity: sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.4.2: + resolution: {integrity: sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-resolve@30.2.0: resolution: {integrity: sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-resolve@30.4.1: + resolution: {integrity: sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-runner@30.2.0: resolution: {integrity: sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-runner@30.4.2: + resolution: {integrity: sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-runtime@30.2.0: resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-runtime@30.4.2: + resolution: {integrity: sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-snapshot@30.2.0: resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-snapshot@30.4.1: + resolution: {integrity: sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-util@30.2.0: resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-util@30.4.1: + resolution: {integrity: sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-validate@30.2.0: resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-validate@30.4.1: + resolution: {integrity: sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-watcher@30.2.0: resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-watcher@30.4.1: + resolution: {integrity: sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -7581,8 +8081,12 @@ packages: resolution: {integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest@30.2.0: - resolution: {integrity: sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==} + jest-worker@30.4.1: + resolution: {integrity: sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.4.2: + resolution: {integrity: sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -7900,8 +8404,8 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@18.0.5: - resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + marked@18.0.7: + resolution: {integrity: sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==} engines: {node: '>= 20'} hasBin: true @@ -8176,6 +8680,9 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} @@ -8255,8 +8762,8 @@ packages: resolution: {integrity: sha512-fwBebvpyPUU8bOzvhX0VQZgSohncbgYwUyJJoTSNpmy7ccD2ryiCvM7oRkn/xQH5cv73/xU7rJSNCLjdGFor0Q==} hasBin: true - mpd-parser@1.3.1: - resolution: {integrity: sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==} + mpd-parser@1.4.0: + resolution: {integrity: sha512-blbA7XpAaOdC/PR0Tu8GiUxlx6CpBuRowQPYV7lhi9Yr9G9+dkPXIUjqjzc/NCn/uZZPopluaJlIYntIEI/VLw==} hasBin: true mpegts.js@1.8.0: @@ -8340,13 +8847,13 @@ packages: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} - ng-mocks@14.15.1: - resolution: {integrity: sha512-qytFlHgR8YkyH2Hsc6ssGHf817pku6UvV1vpKelh9K07+2IYxfgZJrAyTAGmwH0+9i9hjtdoTrZSP5A4KdQu4Q==} + ng-mocks@14.15.3: + resolution: {integrity: sha512-J0UafeI2K5kc5oDhIinGu+VqT990b0sP2dlKV3JxXYTlaQxQ7Mm0LLrxGz10hsGGJVtn9JEKkhjnpb6oijLT3w==} peerDependencies: - '@angular/common': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 - '@angular/core': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 - '@angular/forms': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 - '@angular/platform-browser': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 + '@angular/common': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 || 21.0.0-alpha - 21 || 22.0.0-alpha - 22 + '@angular/core': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 || 21.0.0-alpha - 21 || 22.0.0-alpha - 22 + '@angular/forms': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 || 21.0.0-alpha - 21 || 22.0.0-alpha - 22 + '@angular/platform-browser': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 || 21.0.0-alpha - 21 || 22.0.0-alpha - 22 ngx-indexed-db@21.0.0: resolution: {integrity: sha512-781n0P4ZWt0CegInpvQ7WB2Z9Z9A4Pi3GKOUuAfsKFof4ZSv85g05+IXkSx+h3jBIibCuNWcQd77VLj84xLBQA==} @@ -8808,14 +9315,14 @@ packages: resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} engines: {node: '>=14.16'} - playwright-core@1.57.0: - resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} - engines: {node: '>=18'} + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} hasBin: true - playwright@1.57.0: - resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} - engines: {node: '>=18'} + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} hasBin: true plist@3.1.0: @@ -9143,8 +9650,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} hasBin: true @@ -9152,6 +9659,10 @@ packages: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -9264,6 +9775,9 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -9754,6 +10268,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -9802,8 +10321,9 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shaka-player@5.2.1: - resolution: {integrity: sha512-Sj8Kg94Ws8hUxzjHnC570o1jQcZwB7OxIwi25vLI3mOVmL8xpso3HyIqM7hjbgAhsOgdFojpJYwtcvR/t0a9vA==} + shaka-player@5.2.2: + resolution: {integrity: sha512-1Z5Sxqz7nEAtvp4zlZKAHQSEq6cUlw5CKAdUxqmuMNeZgu23090VXMcEDdiSLU5qtMn1skFOzq+KKCVX71bHqw==} + engines: {node: '>=18'} shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} @@ -9813,6 +10333,15 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -9876,9 +10405,9 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - sitemap@8.0.2: - resolution: {integrity: sha512-LwktpJcyZDoa0IL6KT++lQ53pbSrx2c9ge41/SeLTyqy2XUNA6uR4+P9u5IVo5lPeL2arAcOKn1aZAxoYbCKlQ==} - engines: {node: '>=14.0.0', npm: '>=6.0.0'} + sitemap@9.0.1: + resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==} + engines: {node: '>=20.19.5', npm: '>=10.8.2'} hasBin: true slash@3.0.0: @@ -10302,6 +10831,12 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-checker-rspack-plugin@1.2.3: resolution: {integrity: sha512-Fq2+/sSILEJKWrqoj/H+ytnBbYrudPfRLxaULA/MuL+/3jswXuiR4MOfL30R9XzVUD2km0pSx6bj8yk95IEOaA==} peerDependencies: @@ -10314,8 +10849,8 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-jest@29.4.6: - resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} + ts-jest@29.4.12: + resolution: {integrity: sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -10326,7 +10861,7 @@ packages: esbuild: '*' jest: ^29.0.0 || ^30.0.0 jest-util: ^29.0.0 || ^30.0.0 - typescript: '>=4.3 <6' + typescript: '>=4.3 <7' peerDependenciesMeta: '@babel/core': optional: true @@ -10390,8 +10925,8 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} engines: {node: '>=18.0.0'} hasBin: true @@ -10449,12 +10984,12 @@ packages: typed-assert@1.0.9: resolution: {integrity: sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==} - typescript-eslint@8.51.0: - resolution: {integrity: sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==} + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -10719,8 +11254,8 @@ packages: video.js@7.21.7: resolution: {integrity: sha512-T2s3WFAht7Zjr2OSJamND9x9Dn2O+Z5WuHGdh8jI5SYh5mkMdVTQ7vSRmA5PYpjXJ2ycch6jpMjkJEIEU2xxqw==} - video.js@8.23.4: - resolution: {integrity: sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==} + video.js@8.23.9: + resolution: {integrity: sha512-3UCqyFW4wIusQu4TuhgRUxqtbzbj0WPT9hps0XIEXST43QBD11zb+bG7tBPrinrZztwVYdXCiOBo2pcGj9L05w==} videojs-contrib-quality-levels@4.1.0: resolution: {integrity: sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==} @@ -11096,8 +11631,8 @@ packages: engines: {node: '>= 14.6'} hasBin: true - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true @@ -11166,8 +11701,8 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zone.js@0.15.1: - resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} + zone.js@0.16.2: + resolution: {integrity: sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -11346,33 +11881,63 @@ snapshots: '@angular-eslint/bundled-angular-compiler@21.3.1': {} - '@angular-eslint/eslint-plugin-template@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/eslint-plugin-template@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.3.1 '@angular-eslint/template-parser': 21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/utils': 21.3.1(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/utils': 21.3.1(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/types': 8.51.0 - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) aria-query: 5.3.2 axobject-query: 4.1.0 eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 - '@angular-eslint/eslint-plugin@21.3.1(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/eslint-plugin-template@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.3.1 - '@angular-eslint/utils': 21.3.1(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/template-parser': 21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/utils': 21.3.1(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/utils': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + aria-query: 5.3.2 + axobject-query: 4.1.0 + eslint: 9.39.2(jiti@1.21.7) + typescript: 5.9.3 + + '@angular-eslint/eslint-plugin@21.3.1(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@angular-eslint/bundled-angular-compiler': 21.3.1 + '@angular-eslint/utils': 21.3.1(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 - '@angular-eslint/schematics@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/schematics@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-devkit/core': 21.2.17(chokidar@5.0.0) '@angular-devkit/schematics': 21.2.17(chokidar@5.0.0) - '@angular-eslint/eslint-plugin': 21.3.1(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/eslint-plugin-template': 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/eslint-plugin': 21.3.1(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/eslint-plugin-template': 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular/cli': 21.2.17(@types/node@20.19.9)(chokidar@5.0.0) + ignore: 7.0.5 + semver: 7.7.4 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - '@angular-eslint/template-parser' + - '@typescript-eslint/types' + - '@typescript-eslint/utils' + - chokidar + - eslint + - typescript + + '@angular-eslint/schematics@21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 21.2.17(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.17(chokidar@5.0.0) + '@angular-eslint/eslint-plugin': 21.3.1(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/eslint-plugin-template': 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular/cli': 21.2.17(@types/node@20.19.9)(chokidar@5.0.0) ignore: 7.0.5 semver: 7.7.4 @@ -11392,19 +11957,19 @@ snapshots: eslint-scope: 9.1.2 typescript: 5.9.3 - '@angular-eslint/utils@21.3.1(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@angular-eslint/utils@21.3.1(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.3.1 - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 - '@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))': + '@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))': dependencies: - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) tslib: 2.8.1 - '@angular/build@21.2.17(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.17)(typescript@5.9.3))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/service-worker@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.97.1)(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))(terser@5.44.1)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)': + '@angular/build@21.2.17(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.17)(typescript@5.9.3))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/service-worker@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.97.1)(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0))(terser@5.44.1)(tslib@2.8.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.17(chokidar@5.0.0) @@ -11414,7 +11979,7 @@ snapshots: '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 5.1.21(@types/node@20.19.9) - '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0)) beasties: 0.4.1 browserslist: 4.28.2 esbuild: 0.28.1 @@ -11435,16 +12000,16 @@ snapshots: tslib: 2.8.1 typescript: 5.9.3 undici: 7.24.4 - vite: 7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0) watchpack: 2.5.1 optionalDependencies: - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) - '@angular/service-worker': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) + '@angular/service-worker': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) less: 4.5.1 lmdb: 3.5.1 postcss: 8.5.6 - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) + tailwindcss: 3.4.19(tsx@4.23.1)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - chokidar @@ -11458,11 +12023,11 @@ snapshots: - tsx - yaml - '@angular/cdk@21.2.14(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)': + '@angular/cdk@21.2.14(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) parse5: 8.0.0 rxjs: 7.8.2 tslib: 2.8.1 @@ -11493,9 +12058,9 @@ snapshots: - chokidar - supports-color - '@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)': + '@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) rxjs: 7.8.2 tslib: 2.8.1 @@ -11519,62 +12084,62 @@ snapshots: dependencies: tslib: 2.8.1 - '@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)': + '@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)': dependencies: rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: '@angular/compiler': 21.2.17 - zone.js: 0.15.1 + zone.js: 0.16.2 - '@angular/forms@21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)': + '@angular/forms@21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) '@standard-schema/spec': 1.1.0 rxjs: 7.8.2 tslib: 2.8.1 '@angular/language-service@21.2.17': {} - '@angular/material@21.2.14(7ae818e999981ce767bfae94a8b1de8f)': + '@angular/material@21.2.14(43172ba04e47f305707a39eaf8cab87a)': dependencies: - '@angular/cdk': 21.2.14(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@angular/forms': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + '@angular/cdk': 21.2.14(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/forms': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/platform-browser-dynamic@21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))': + '@angular/platform-browser-dynamic@21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))': dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@angular/compiler': 21.2.17 - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) tslib: 2.8.1 - '@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))': + '@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))': dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) tslib: 2.8.1 optionalDependencies: - '@angular/animations': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + '@angular/animations': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) - '@angular/router@21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)': + '@angular/router@21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/service-worker@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)': + '@angular/service-worker@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) rxjs: 7.8.2 tslib: 2.8.1 @@ -11644,12 +12209,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@4.3.13(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))': + '@astrojs/mdx@4.3.13(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0))': dependencies: '@astrojs/markdown-remark': 6.3.10 '@mdx-js/mdx': 3.1.1 acorn: 8.15.0 - astro: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0) es-module-lexer: 1.7.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -11667,19 +12232,19 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/sitemap@3.7.0': + '@astrojs/sitemap@3.7.3': dependencies: - sitemap: 8.0.2 + sitemap: 9.0.1 stream-replace-string: 2.0.0 - zod: 3.25.76 + zod: 4.3.6 - '@astrojs/tailwind@6.0.2(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))': + '@astrojs/tailwind@6.0.2(astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0))(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))': dependencies: - astro: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0) autoprefixer: 10.4.23(postcss@8.5.6) postcss: 8.5.6 - postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)) - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + tailwindcss: 3.4.19(tsx@4.23.1)(yaml@2.9.0) transitivePeerDependencies: - ts-node @@ -12061,6 +12626,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12071,6 +12641,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12081,6 +12656,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12091,6 +12671,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12111,6 +12696,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12121,6 +12711,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12131,6 +12726,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12141,6 +12741,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12151,6 +12756,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12161,6 +12771,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12171,6 +12786,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12181,6 +12801,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12191,6 +12816,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12201,6 +12831,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12211,6 +12846,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12221,6 +12861,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12231,6 +12876,11 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12920,6 +13570,11 @@ snapshots: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.4.5': dependencies: tslib: 2.8.1 @@ -13269,7 +13924,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': + '@eslint/eslintrc@3.3.6': dependencies: ajv: 6.14.0 debug: 4.4.3 @@ -13278,7 +13933,7 @@ snapshots: ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.3.0 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -13292,15 +13947,15 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@faker-js/faker@10.3.0': {} + '@faker-js/faker@10.5.0': {} - '@fontsource/crimson-pro@5.2.8': {} + '@fontsource/crimson-pro@5.3.0': {} - '@fontsource/dm-sans@5.2.8': {} + '@fontsource/dm-sans@5.3.0': {} - '@fontsource/jetbrains-mono@5.2.8': {} + '@fontsource/jetbrains-mono@5.3.0': {} - '@fontsource/roboto@5.2.10': {} + '@fontsource/roboto@5.3.0': {} '@gar/promise-retry@1.0.3': {} @@ -13324,102 +13979,209 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.0.0': {} + '@img/colour@1.0.0': + optional: true + + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.8.1 optional: true + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.3': + optional: true + '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.3': + optional: true + '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.3': + optional: true + '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@4.3.2(@types/node@20.19.9)': @@ -13583,35 +14345,44 @@ snapshots: jest-util: 30.2.0 slash: 3.0.0 - '@jest/core@30.2.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))': + '@jest/console@30.4.1': dependencies: - '@jest/console': 30.2.0 - '@jest/pattern': 30.0.1 - '@jest/reporters': 30.2.0 - '@jest/test-result': 30.2.0 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 + '@jest/types': 30.4.1 + '@types/node': 20.19.9 + chalk: 4.1.2 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + slash: 3.0.0 + + '@jest/core@30.4.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))': + dependencies: + '@jest/console': 30.4.1 + '@jest/pattern': 30.4.0 + '@jest/reporters': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 '@types/node': 20.19.9 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 4.3.1 exit-x: 0.2.2 + fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 - jest-changed-files: 30.2.0 - jest-config: 30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)) - jest-haste-map: 30.2.0 - jest-message-util: 30.2.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.2.0 - jest-resolve-dependencies: 30.2.0 - jest-runner: 30.2.0 - jest-runtime: 30.2.0 - jest-snapshot: 30.2.0 - jest-util: 30.2.0 - jest-validate: 30.2.0 - jest-watcher: 30.2.0 - micromatch: 4.0.8 - pretty-format: 30.2.0 + jest-changed-files: 30.4.1 + jest-config: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + jest-haste-map: 30.4.1 + jest-message-util: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-resolve-dependencies: 30.4.2 + jest-runner: 30.4.2 + jest-runtime: 30.4.2 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + jest-validate: 30.4.1 + jest-watcher: 30.4.1 + pretty-format: 30.4.1 slash: 3.0.0 transitivePeerDependencies: - babel-plugin-macros @@ -13621,6 +14392,8 @@ snapshots: '@jest/diff-sequences@30.0.1': {} + '@jest/diff-sequences@30.4.0': {} + '@jest/environment-jsdom-abstract@30.2.0(jsdom@26.1.0)': dependencies: '@jest/environment': 30.2.0 @@ -13632,6 +14405,17 @@ snapshots: jest-util: 30.2.0 jsdom: 26.1.0 + '@jest/environment-jsdom-abstract@30.4.1(jsdom@26.1.0)': + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/jsdom': 21.1.7 + '@types/node': 20.19.9 + jest-mock: 30.4.1 + jest-util: 30.4.1 + jsdom: 26.1.0 + '@jest/environment@30.2.0': dependencies: '@jest/fake-timers': 30.2.0 @@ -13639,10 +14423,21 @@ snapshots: '@types/node': 20.19.9 jest-mock: 30.2.0 + '@jest/environment@30.4.1': + dependencies: + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 20.19.9 + jest-mock: 30.4.1 + '@jest/expect-utils@30.2.0': dependencies: '@jest/get-type': 30.1.0 + '@jest/expect-utils@30.4.1': + dependencies: + '@jest/get-type': 30.1.0 + '@jest/expect@30.2.0': dependencies: expect: 30.2.0 @@ -13650,6 +14445,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/expect@30.4.1': + dependencies: + expect: 30.4.1 + jest-snapshot: 30.4.1 + transitivePeerDependencies: + - supports-color + '@jest/fake-timers@30.2.0': dependencies: '@jest/types': 30.2.0 @@ -13659,6 +14461,15 @@ snapshots: jest-mock: 30.2.0 jest-util: 30.2.0 + '@jest/fake-timers@30.4.1': + dependencies: + '@jest/types': 30.4.1 + '@sinonjs/fake-timers': 15.4.0 + '@types/node': 20.19.9 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + '@jest/get-type@30.1.0': {} '@jest/globals@30.2.0': @@ -13670,11 +14481,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/globals@30.4.1': + dependencies: + '@jest/environment': 30.4.1 + '@jest/expect': 30.4.1 + '@jest/types': 30.4.1 + jest-mock: 30.4.1 + transitivePeerDependencies: + - supports-color + '@jest/pattern@30.0.1': dependencies: '@types/node': 20.19.9 jest-regex-util: 30.0.1 + '@jest/pattern@30.4.0': + dependencies: + '@types/node': 20.19.9 + jest-regex-util: 30.4.0 + '@jest/reporters@30.2.0': dependencies: '@bcoe/v8-coverage': 0.2.3 @@ -13703,10 +14528,42 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/reporters@30.4.1': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 20.19.9 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 10.5.0 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + jest-worker: 30.4.1 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + '@jest/schemas@30.0.5': dependencies: '@sinclair/typebox': 0.34.46 + '@jest/schemas@30.4.1': + dependencies: + '@sinclair/typebox': 0.34.46 + '@jest/snapshot-utils@30.2.0': dependencies: '@jest/types': 30.2.0 @@ -13714,6 +14571,13 @@ snapshots: graceful-fs: 4.2.11 natural-compare: 1.4.0 + '@jest/snapshot-utils@30.4.1': + dependencies: + '@jest/types': 30.4.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + '@jest/source-map@30.0.1': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -13727,6 +14591,13 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 collect-v8-coverage: 1.0.3 + '@jest/test-result@30.4.1': + dependencies: + '@jest/console': 30.4.1 + '@jest/types': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + '@jest/test-sequencer@30.2.0': dependencies: '@jest/test-result': 30.2.0 @@ -13734,6 +14605,13 @@ snapshots: jest-haste-map: 30.2.0 slash: 3.0.0 + '@jest/test-sequencer@30.4.1': + dependencies: + '@jest/test-result': 30.4.1 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + slash: 3.0.0 + '@jest/transform@30.2.0': dependencies: '@babel/core': 7.29.0 @@ -13754,6 +14632,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/transform@30.4.1': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 30.4.1 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + '@jest/types@30.2.0': dependencies: '@jest/pattern': 30.0.1 @@ -13764,6 +14661,16 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jest/types@30.4.1': + dependencies: + '@jest/pattern': 30.4.0 + '@jest/schemas': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.9 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -13983,7 +14890,7 @@ snapshots: '@module-federation/third-party-dts-extractor': 0.22.0 adm-zip: 0.5.16 ansi-colors: 4.1.3 - axios: 1.16.0 + axios: 1.18.1 chalk: 3.0.0 fs-extra: 9.1.0 isomorphic-ws: 5.0.0(ws@8.18.0) @@ -14018,7 +14925,7 @@ snapshots: - node-fetch - utf-8-validate - '@module-federation/enhanced@0.22.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1))': + '@module-federation/enhanced@0.22.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1))': dependencies: '@module-federation/bridge-react-webpack-plugin': 0.22.0 '@module-federation/cli': 0.22.0(typescript@5.9.3) @@ -14028,7 +14935,7 @@ snapshots: '@module-federation/inject-external-runtime-core-plugin': 0.22.0(@module-federation/runtime-tools@0.22.0) '@module-federation/managers': 0.22.0 '@module-federation/manifest': 0.22.0(typescript@5.9.3) - '@module-federation/rspack': 0.22.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(typescript@5.9.3) + '@module-federation/rspack': 0.22.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3) '@module-federation/runtime-tools': 0.22.0 '@module-federation/sdk': 0.22.0 btoa: 1.2.1 @@ -14036,7 +14943,7 @@ snapshots: upath: 2.0.1 optionalDependencies: typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -14046,7 +14953,7 @@ snapshots: - supports-color - utf-8-validate - '@module-federation/enhanced@2.4.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1))': + '@module-federation/enhanced@2.4.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1))': dependencies: '@module-federation/bridge-react-webpack-plugin': 2.4.0(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/cli': 2.4.0(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) @@ -14055,7 +14962,7 @@ snapshots: '@module-federation/inject-external-runtime-core-plugin': 2.4.0(@module-federation/runtime-tools@2.4.0(node-fetch@2.7.0(encoding@0.1.13))) '@module-federation/managers': 2.4.0(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/manifest': 2.4.0(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) - '@module-federation/rspack': 2.4.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) + '@module-federation/rspack': 2.4.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) '@module-federation/runtime-tools': 2.4.0(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/sdk': 2.4.0(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/webpack-bundler-runtime': 2.4.0(node-fetch@2.7.0(encoding@0.1.13)) @@ -14064,7 +14971,7 @@ snapshots: upath: 2.0.1 optionalDependencies: typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -14126,15 +15033,15 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/node@2.7.26(@rspack/core@1.6.8(@swc/helpers@0.5.18))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1))': + '@module-federation/node@2.7.26(@rspack/core@1.6.8(@swc/helpers@0.5.23))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1))': dependencies: - '@module-federation/enhanced': 0.22.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + '@module-federation/enhanced': 0.22.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) '@module-federation/runtime': 0.22.0 '@module-federation/sdk': 0.22.0 btoa: 1.2.1 encoding: 0.1.13 node-fetch: 2.7.0(encoding@0.1.13) - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) optionalDependencies: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -14147,7 +15054,7 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/rspack@0.22.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(typescript@5.9.3)': + '@module-federation/rspack@0.22.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3)': dependencies: '@module-federation/bridge-react-webpack-plugin': 0.22.0 '@module-federation/dts-plugin': 0.22.0(typescript@5.9.3) @@ -14156,7 +15063,7 @@ snapshots: '@module-federation/manifest': 0.22.0(typescript@5.9.3) '@module-federation/runtime-tools': 0.22.0 '@module-federation/sdk': 0.22.0 - '@rspack/core': 1.6.8(@swc/helpers@0.5.18) + '@rspack/core': 1.6.8(@swc/helpers@0.5.23) btoa: 1.2.1 optionalDependencies: typescript: 5.9.3 @@ -14166,7 +15073,7 @@ snapshots: - supports-color - utf-8-validate - '@module-federation/rspack@2.4.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)': + '@module-federation/rspack@2.4.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)': dependencies: '@module-federation/bridge-react-webpack-plugin': 2.4.0(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/dts-plugin': 2.4.0(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) @@ -14175,7 +15082,7 @@ snapshots: '@module-federation/manifest': 2.4.0(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) '@module-federation/runtime-tools': 2.4.0(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/sdk': 2.4.0(node-fetch@2.7.0(encoding@0.1.13)) - '@rspack/core': 1.6.8(@swc/helpers@0.5.18) + '@rspack/core': 1.6.8(@swc/helpers@0.5.23) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -14391,76 +15298,137 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@ngrx/effects@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@ngrx/store@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(rxjs@7.8.2)': + '@ngrx/effects@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@ngrx/store@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@ngrx/store': 21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@ngrx/store': 21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) rxjs: 7.8.2 tslib: 2.8.1 - '@ngrx/entity@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@ngrx/store@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(rxjs@7.8.2)': + '@ngrx/entity@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@ngrx/store@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@ngrx/store': 21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@ngrx/store': 21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) rxjs: 7.8.2 tslib: 2.8.1 - '@ngrx/eslint-plugin@21.0.1(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3)': + '@ngrx/eslint-plugin@21.1.1(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) strip-json-comments: 3.1.1 typescript: 5.9.3 - typescript-eslint: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + typescript-eslint: 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@ngrx/router-store@21.0.1(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/router@21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(@ngrx/store@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(rxjs@7.8.2)': + '@ngrx/router-store@21.1.1(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/router@21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2))(@ngrx/store@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@angular/router': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - '@ngrx/store': 21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/router': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + '@ngrx/store': 21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) rxjs: 7.8.2 tslib: 2.8.1 - '@ngrx/signals@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)': + '@ngrx/signals@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) tslib: 2.8.1 optionalDependencies: rxjs: 7.8.2 - '@ngrx/store-devtools@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@ngrx/store@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(rxjs@7.8.2)': + '@ngrx/store-devtools@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@ngrx/store@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@ngrx/store': 21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@ngrx/store': 21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) rxjs: 7.8.2 tslib: 2.8.1 - '@ngrx/store@21.0.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)': + '@ngrx/store@21.1.1(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) rxjs: 7.8.2 tslib: 2.8.1 - '@ngx-pwa/local-storage@21.0.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)': + '@ngx-pwa/local-storage@21.0.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)': dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) rxjs: 7.8.2 tslib: 2.8.1 - '@ngx-translate/core@16.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))': + '@ngx-translate/core@16.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))': dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) tslib: 2.8.1 - '@ngx-translate/http-loader@16.0.1(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))': + '@ngx-translate/http-loader@16.0.1(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))': dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) tslib: 2.8.1 + '@node-rs/xxhash-android-arm-eabi@1.7.6': + optional: true + + '@node-rs/xxhash-android-arm64@1.7.6': + optional: true + + '@node-rs/xxhash-darwin-arm64@1.7.6': + optional: true + + '@node-rs/xxhash-darwin-x64@1.7.6': + optional: true + + '@node-rs/xxhash-freebsd-x64@1.7.6': + optional: true + + '@node-rs/xxhash-linux-arm-gnueabihf@1.7.6': + optional: true + + '@node-rs/xxhash-linux-arm64-gnu@1.7.6': + optional: true + + '@node-rs/xxhash-linux-arm64-musl@1.7.6': + optional: true + + '@node-rs/xxhash-linux-x64-gnu@1.7.6': + optional: true + + '@node-rs/xxhash-linux-x64-musl@1.7.6': + optional: true + + '@node-rs/xxhash-wasm32-wasi@1.7.6': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/xxhash-win32-arm64-msvc@1.7.6': + optional: true + + '@node-rs/xxhash-win32-ia32-msvc@1.7.6': + optional: true + + '@node-rs/xxhash-win32-x64-msvc@1.7.6': + optional: true + + '@node-rs/xxhash@1.7.6': + optionalDependencies: + '@node-rs/xxhash-android-arm-eabi': 1.7.6 + '@node-rs/xxhash-android-arm64': 1.7.6 + '@node-rs/xxhash-darwin-arm64': 1.7.6 + '@node-rs/xxhash-darwin-x64': 1.7.6 + '@node-rs/xxhash-freebsd-x64': 1.7.6 + '@node-rs/xxhash-linux-arm-gnueabihf': 1.7.6 + '@node-rs/xxhash-linux-arm64-gnu': 1.7.6 + '@node-rs/xxhash-linux-arm64-musl': 1.7.6 + '@node-rs/xxhash-linux-x64-gnu': 1.7.6 + '@node-rs/xxhash-linux-x64-musl': 1.7.6 + '@node-rs/xxhash-wasm32-wasi': 1.7.6 + '@node-rs/xxhash-win32-arm64-msvc': 1.7.6 + '@node-rs/xxhash-win32-ia32-msvc': 1.7.6 + '@node-rs/xxhash-win32-x64-msvc': 1.7.6 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -14542,18 +15510,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@nx/angular@22.7.1(3fcdae4966e6f655e54cd885e10979aa)': + '@nx/angular@22.7.1(c2f36107352a552af1b8d4017d026f40)': dependencies: '@angular-devkit/core': 21.2.17(chokidar@5.0.0) '@angular-devkit/schematics': 21.2.17(chokidar@5.0.0) - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/eslint': 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/module-federation': 22.7.1(a3b68b84e3ec98d537bf640c6b57f01e) - '@nx/rspack': 22.7.1(22cfd6d26d5a2f461eef5bdbba0e2aae) - '@nx/web': 22.7.1(4b628920197e2640a94b2a773363406b) - '@nx/webpack': 22.7.1(@babel/traverse@7.29.7)(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(typescript@5.9.3) - '@nx/workspace': 22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/eslint': 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/module-federation': 22.7.1(446c49c3ccd268c7cef2adece6c1473f) + '@nx/rspack': 22.7.1(432b21b72e752ad5b0d4ba19aa7267cb) + '@nx/web': 22.7.1(5f4f264ec7c07d507acd7660d73c4398) + '@nx/webpack': 22.7.1(@babel/traverse@7.29.7)(@rspack/core@1.6.8(@swc/helpers@0.5.23))(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(typescript@5.9.3) + '@nx/workspace': 22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) '@schematics/angular': 21.2.9(chokidar@5.0.0) '@typescript-eslint/type-utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) @@ -14566,7 +15534,7 @@ snapshots: tslib: 2.8.1 webpack-merge: 5.10.0 optionalDependencies: - '@angular/build': 21.2.17(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.17)(typescript@5.9.3))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/service-worker@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.97.1)(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))(terser@5.44.1)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + '@angular/build': 21.2.17(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.17)(typescript@5.9.3))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/service-worker@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.6)(sass-embedded@1.97.1)(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0))(terser@5.44.1)(tslib@2.8.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0) transitivePeerDependencies: - '@babel/traverse' - '@module-federation/enhanced' @@ -14608,21 +15576,21 @@ snapshots: - webpack-cli - webpack-hot-middleware - '@nx/devkit@22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))': + '@nx/devkit@22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))': dependencies: '@zkochan/js-yaml': 0.0.7 ejs: 5.0.1 enquirer: 2.3.6 minimatch: 10.2.4 - nx: 22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)) + nx: 22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)) semver: 7.7.3 tslib: 2.8.1 yargs-parser: 21.1.1 - '@nx/esbuild@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))': + '@nx/esbuild@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))': dependencies: - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) picocolors: 1.1.1 tinyglobby: 0.2.15 tsconfig-paths: 4.2.0 @@ -14638,14 +15606,14 @@ snapshots: - supports-color - verdaccio - '@nx/eslint-plugin@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(typescript@5.9.3)': + '@nx/eslint-plugin@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@typescript-eslint/parser@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(typescript@5.9.3)': dependencies: - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/type-utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) chalk: 4.1.2 confusing-browser-globals: 1.0.11 globals: 15.9.0 @@ -14665,16 +15633,16 @@ snapshots: - typescript - verdaccio - '@nx/eslint@22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))': + '@nx/eslint@22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))': dependencies: - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) eslint: 9.39.2(jiti@1.21.7) semver: 7.7.3 tslib: 2.8.1 typescript: 5.9.3 optionalDependencies: - '@nx/jest': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) + '@nx/jest': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) '@zkochan/js-yaml': 0.0.7 transitivePeerDependencies: - '@babel/traverse' @@ -14685,17 +15653,17 @@ snapshots: - supports-color - verdaccio - '@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3)': + '@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@jest/reporters': 30.2.0 '@jest/test-result': 30.2.0 - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) identity-obj-proxy: 3.0.0 - jest-config: 30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)) + jest-config: 30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) jest-resolve: 30.2.0 - jest-util: 30.2.0 + jest-util: 30.4.1 minimatch: 10.2.4 picocolors: 1.1.1 resolve.exports: 2.0.3 @@ -14717,7 +15685,7 @@ snapshots: - typescript - verdaccio - '@nx/js@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))': + '@nx/js@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.5) @@ -14726,8 +15694,8 @@ snapshots: '@babel/preset-env': 7.28.5(@babel/core@7.28.5) '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) '@babel/runtime': 7.28.4 - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/workspace': 22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/workspace': 22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)) '@zkochan/js-yaml': 0.0.7 babel-plugin-const-enum: 1.2.0(@babel/core@7.28.5) babel-plugin-macros: 3.1.0 @@ -14753,20 +15721,20 @@ snapshots: - nx - supports-color - '@nx/module-federation@22.7.1(a3b68b84e3ec98d537bf640c6b57f01e)': + '@nx/module-federation@22.7.1(446c49c3ccd268c7cef2adece6c1473f)': dependencies: - '@module-federation/enhanced': 2.4.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - '@module-federation/node': 2.7.26(@rspack/core@1.6.8(@swc/helpers@0.5.18))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + '@module-federation/enhanced': 2.4.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + '@module-federation/node': 2.7.26(@rspack/core@1.6.8(@swc/helpers@0.5.23))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) '@module-federation/sdk': 2.4.0(node-fetch@2.7.0(encoding@0.1.13)) - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/web': 22.7.1(4b628920197e2640a94b2a773363406b) - '@rspack/core': 1.6.8(@swc/helpers@0.5.18) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/web': 22.7.1(5f4f264ec7c07d507acd7660d73c4398) + '@rspack/core': 1.6.8(@swc/helpers@0.5.23) express: 4.22.1 http-proxy-middleware: 3.0.5 picocolors: 1.1.1 tslib: 2.8.1 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) transitivePeerDependencies: - '@babel/traverse' - '@nx/cypress' @@ -14824,15 +15792,15 @@ snapshots: '@nx/nx-win32-x64-msvc@22.7.1': optional: true - '@nx/playwright@22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@playwright/test@1.57.0)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))': + '@nx/playwright@22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@playwright/test@1.62.0)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))': dependencies: - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/eslint': 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/eslint': 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) minimatch: 10.2.4 tslib: 2.8.1 optionalDependencies: - '@playwright/test': 1.57.0 + '@playwright/test': 1.62.0 transitivePeerDependencies: - '@babel/traverse' - '@nx/jest' @@ -14845,40 +15813,40 @@ snapshots: - supports-color - verdaccio - '@nx/rspack@22.7.1(22cfd6d26d5a2f461eef5bdbba0e2aae)': + '@nx/rspack@22.7.1(432b21b72e752ad5b0d4ba19aa7267cb)': dependencies: - '@module-federation/enhanced': 2.4.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - '@module-federation/node': 2.7.26(@rspack/core@1.6.8(@swc/helpers@0.5.18))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/module-federation': 22.7.1(a3b68b84e3ec98d537bf640c6b57f01e) - '@nx/web': 22.7.1(4b628920197e2640a94b2a773363406b) + '@module-federation/enhanced': 2.4.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + '@module-federation/node': 2.7.26(@rspack/core@1.6.8(@swc/helpers@0.5.23))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/module-federation': 22.7.1(446c49c3ccd268c7cef2adece6c1473f) + '@nx/web': 22.7.1(5f4f264ec7c07d507acd7660d73c4398) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) - '@rspack/core': 1.6.8(@swc/helpers@0.5.18) - '@rspack/dev-server': 1.1.5(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@types/express@5.0.6)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + '@rspack/core': 1.6.8(@swc/helpers@0.5.23) + '@rspack/dev-server': 1.1.5(@rspack/core@1.6.8(@swc/helpers@0.5.23))(@types/express@5.0.6)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) '@rspack/plugin-react-refresh': 1.5.3(react-refresh@0.18.0) autoprefixer: 10.4.23(postcss@8.5.6) browserslist: 4.28.1 - css-loader: 6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + css-loader: 6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) enquirer: 2.3.6 express: 4.22.1 http-proxy-middleware: 3.0.5 - less-loader: 12.3.2(@rspack/core@1.6.8(@swc/helpers@0.5.18))(less@4.5.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - license-webpack-plugin: 4.0.2(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + less-loader: 12.3.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(less@4.5.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + license-webpack-plugin: 4.0.2(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) loader-utils: 2.0.4 parse5: 4.0.0 picocolors: 1.1.1 postcss: 8.5.6 postcss-import: 14.1.0(postcss@8.5.6) - postcss-loader: 8.2.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + postcss-loader: 8.2.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) sass: 1.97.1 sass-embedded: 1.97.1 - sass-loader: 16.0.6(@rspack/core@1.6.8(@swc/helpers@0.5.18))(sass-embedded@1.97.1)(sass@1.97.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - source-map-loader: 5.0.0(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - style-loader: 3.3.4(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - ts-checker-rspack-plugin: 1.2.3(@rspack/core@1.6.8(@swc/helpers@0.5.18))(typescript@5.9.3) + sass-loader: 16.0.6(@rspack/core@1.6.8(@swc/helpers@0.5.23))(sass-embedded@1.97.1)(sass@1.97.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + source-map-loader: 5.0.0(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + style-loader: 3.3.4(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + ts-checker-rspack-plugin: 1.2.3(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3) tslib: 2.8.1 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) webpack-node-externals: 3.0.0 transitivePeerDependencies: - '@babel/traverse' @@ -14912,19 +15880,19 @@ snapshots: - webpack-cli - webpack-hot-middleware - '@nx/web@22.7.1(4b628920197e2640a94b2a773363406b)': + '@nx/web@22.7.1(5f4f264ec7c07d507acd7660d73c4398)': dependencies: - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) detect-port: 1.6.1 http-server: 14.1.1 picocolors: 1.1.1 tslib: 2.8.1 optionalDependencies: - '@nx/eslint': 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/jest': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) - '@nx/playwright': 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@playwright/test@1.57.0)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/webpack': 22.7.1(@babel/traverse@7.29.7)(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(typescript@5.9.3) + '@nx/eslint': 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/jest': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3) + '@nx/playwright': 22.7.1(@babel/traverse@7.29.7)(@nx/jest@22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3))(typescript@5.9.3))(@playwright/test@1.62.0)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/webpack': 22.7.1(@babel/traverse@7.29.7)(@rspack/core@1.6.8(@swc/helpers@0.5.23))(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(typescript@5.9.3) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -14934,44 +15902,44 @@ snapshots: - supports-color - verdaccio - '@nx/webpack@22.7.1(@babel/traverse@7.29.7)(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(typescript@5.9.3)': + '@nx/webpack@22.7.1(@babel/traverse@7.29.7)(@rspack/core@1.6.8(@swc/helpers@0.5.23))(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(typescript@5.9.3)': dependencies: '@babel/core': 7.28.5 - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/js': 22.7.1(@babel/traverse@7.29.7)(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) ajv: 8.18.0 autoprefixer: 10.4.23(postcss@8.5.6) - babel-loader: 9.2.1(@babel/core@7.28.5)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + babel-loader: 9.2.1(@babel/core@7.28.5)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) browserslist: 4.28.1 - copy-webpack-plugin: 14.0.0(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - css-loader: 6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - css-minimizer-webpack-plugin: 8.0.0(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + copy-webpack-plugin: 14.0.0(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + css-loader: 6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + css-minimizer-webpack-plugin: 8.0.0(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) less: 4.5.1 - less-loader: 12.3.2(@rspack/core@1.6.8(@swc/helpers@0.5.18))(less@4.5.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - license-webpack-plugin: 4.0.2(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + less-loader: 12.3.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(less@4.5.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + license-webpack-plugin: 4.0.2(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) loader-utils: 2.0.4 - mini-css-extract-plugin: 2.4.7(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + mini-css-extract-plugin: 2.4.7(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) parse5: 4.0.0 picocolors: 1.1.1 postcss: 8.5.6 postcss-import: 14.1.0(postcss@8.5.6) - postcss-loader: 8.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.18))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + postcss-loader: 8.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) rxjs: 7.8.2 sass: 1.97.1 sass-embedded: 1.97.1 - sass-loader: 16.0.6(@rspack/core@1.6.8(@swc/helpers@0.5.18))(sass-embedded@1.97.1)(sass@1.97.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - source-map-loader: 5.0.0(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - style-loader: 3.3.4(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - terser-webpack-plugin: 5.3.16(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - ts-loader: 9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + sass-loader: 16.0.6(@rspack/core@1.6.8(@swc/helpers@0.5.23))(sass-embedded@1.97.1)(sass@1.97.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + source-map-loader: 5.0.0(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + style-loader: 3.3.4(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + ts-loader: 9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) tsconfig-paths-webpack-plugin: 4.2.0 tslib: 2.8.1 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) - webpack-dev-server: 5.2.2(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) + webpack-dev-server: 5.2.2(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) webpack-node-externals: 3.0.0 - webpack-subresource-integrity: 5.1.0(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + webpack-subresource-integrity: 5.1.0(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) transitivePeerDependencies: - '@babel/traverse' - '@parcel/css' @@ -14995,13 +15963,13 @@ snapshots: - verdaccio - webpack-cli - '@nx/workspace@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))': + '@nx/workspace@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))': dependencies: - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) '@zkochan/js-yaml': 0.0.7 chalk: 4.1.2 enquirer: 2.3.6 - nx: 22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)) + nx: 22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)) picomatch: 4.0.4 semver: 7.7.3 tslib: 2.8.1 @@ -15149,9 +16117,9 @@ snapshots: '@pkgr/core@0.2.9': {} - '@playwright/test@1.57.0': + '@playwright/test@1.62.0': dependencies: - playwright: 1.57.0 + playwright: 1.62.0 '@rolldown/binding-android-arm64@1.0.0-rc.4': optional: true @@ -15324,21 +16292,21 @@ snapshots: '@rspack/binding-win32-ia32-msvc': 1.6.8 '@rspack/binding-win32-x64-msvc': 1.6.8 - '@rspack/core@1.6.8(@swc/helpers@0.5.18)': + '@rspack/core@1.6.8(@swc/helpers@0.5.23)': dependencies: '@module-federation/runtime-tools': 0.21.6 '@rspack/binding': 1.6.8 '@rspack/lite-tapable': 1.1.0 optionalDependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 - '@rspack/dev-server@1.1.5(@rspack/core@1.6.8(@swc/helpers@0.5.18))(@types/express@5.0.6)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1))': + '@rspack/dev-server@1.1.5(@rspack/core@1.6.8(@swc/helpers@0.5.23))(@types/express@5.0.6)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1))': dependencies: - '@rspack/core': 1.6.8(@swc/helpers@0.5.18) + '@rspack/core': 1.6.8(@swc/helpers@0.5.23) chokidar: 3.6.0 http-proxy-middleware: 2.0.9(@types/express@5.0.6) p-retry: 6.2.1 - webpack-dev-server: 5.2.2(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + webpack-dev-server: 5.2.2(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) ws: 8.18.3 transitivePeerDependencies: - '@types/express' @@ -15454,20 +16422,26 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers@15.4.0': + dependencies: + '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.1.0': {} - '@swc-node/core@1.14.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)': + '@swc-node/core@1.15.0(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)': dependencies: - '@swc/core': 1.15.8(@swc/helpers@0.5.18) - '@swc/types': 0.1.25 + '@swc/core': 1.15.46(@swc/helpers@0.5.23) + '@swc/types': 0.1.27 - '@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3)': + '@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3)': dependencies: - '@swc-node/core': 1.14.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25) + '@node-rs/xxhash': 1.7.6 + '@swc-node/core': 1.15.0(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27) '@swc-node/sourcemap-support': 0.6.1 - '@swc/core': 1.15.8(@swc/helpers@0.5.18) + '@swc/core': 1.15.46(@swc/helpers@0.5.23) colorette: 2.0.20 debug: 4.4.3 + fast-json-stable-stringify: 2.1.0 oxc-resolver: 11.17.1 pirates: 4.0.7 tslib: 2.8.1 @@ -15481,60 +16455,68 @@ snapshots: source-map-support: 0.5.21 tslib: 2.8.1 - '@swc/core-darwin-arm64@1.15.8': + '@swc/core-darwin-arm64@1.15.46': optional: true - '@swc/core-darwin-x64@1.15.8': + '@swc/core-darwin-x64@1.15.46': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.8': + '@swc/core-linux-arm-gnueabihf@1.15.46': optional: true - '@swc/core-linux-arm64-gnu@1.15.8': + '@swc/core-linux-arm64-gnu@1.15.46': optional: true - '@swc/core-linux-arm64-musl@1.15.8': + '@swc/core-linux-arm64-musl@1.15.46': optional: true - '@swc/core-linux-x64-gnu@1.15.8': + '@swc/core-linux-ppc64-gnu@1.15.46': optional: true - '@swc/core-linux-x64-musl@1.15.8': + '@swc/core-linux-s390x-gnu@1.15.46': optional: true - '@swc/core-win32-arm64-msvc@1.15.8': + '@swc/core-linux-x64-gnu@1.15.46': optional: true - '@swc/core-win32-ia32-msvc@1.15.8': + '@swc/core-linux-x64-musl@1.15.46': optional: true - '@swc/core-win32-x64-msvc@1.15.8': + '@swc/core-win32-arm64-msvc@1.15.46': optional: true - '@swc/core@1.15.8(@swc/helpers@0.5.18)': + '@swc/core-win32-ia32-msvc@1.15.46': + optional: true + + '@swc/core-win32-x64-msvc@1.15.46': + optional: true + + '@swc/core@1.15.46(@swc/helpers@0.5.23)': dependencies: '@swc/counter': 0.1.3 - '@swc/types': 0.1.25 + '@swc/types': 0.1.27 optionalDependencies: - '@swc/core-darwin-arm64': 1.15.8 - '@swc/core-darwin-x64': 1.15.8 - '@swc/core-linux-arm-gnueabihf': 1.15.8 - '@swc/core-linux-arm64-gnu': 1.15.8 - '@swc/core-linux-arm64-musl': 1.15.8 - '@swc/core-linux-x64-gnu': 1.15.8 - '@swc/core-linux-x64-musl': 1.15.8 - '@swc/core-win32-arm64-msvc': 1.15.8 - '@swc/core-win32-ia32-msvc': 1.15.8 - '@swc/core-win32-x64-msvc': 1.15.8 - '@swc/helpers': 0.5.18 + '@swc/core-darwin-arm64': 1.15.46 + '@swc/core-darwin-x64': 1.15.46 + '@swc/core-linux-arm-gnueabihf': 1.15.46 + '@swc/core-linux-arm64-gnu': 1.15.46 + '@swc/core-linux-arm64-musl': 1.15.46 + '@swc/core-linux-ppc64-gnu': 1.15.46 + '@swc/core-linux-s390x-gnu': 1.15.46 + '@swc/core-linux-x64-gnu': 1.15.46 + '@swc/core-linux-x64-musl': 1.15.46 + '@swc/core-win32-arm64-msvc': 1.15.46 + '@swc/core-win32-ia32-msvc': 1.15.46 + '@swc/core-win32-x64-msvc': 1.15.46 + '@swc/helpers': 0.5.23 '@swc/counter@0.1.3': {} - '@swc/helpers@0.5.18': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 - '@swc/types@0.1.25': + '@swc/types@0.1.27': dependencies: '@swc/counter': 0.1.3 @@ -15542,10 +16524,10 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))': + '@tailwindcss/typography@0.5.20(tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) + tailwindcss: 3.4.19(tsx@4.23.1)(yaml@2.9.0) '@tootallnate/once@2.0.1': {} @@ -15745,8 +16727,6 @@ snapshots: dependencies: '@types/node': 20.19.9 - '@types/node@17.0.45': {} - '@types/node@20.19.9': dependencies: undici-types: 6.21.0 @@ -15820,7 +16800,7 @@ snapshots: '@types/verror@1.10.11': optional: true - '@types/video.js@7.3.29': {} + '@types/video.js@7.3.58': {} '@types/ws@8.18.1': dependencies: @@ -15832,28 +16812,28 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.51.0 - '@typescript-eslint/type-utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.51.0 + '@typescript-eslint/parser': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 eslint: 9.39.2(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.51.0 - '@typescript-eslint/types': 8.51.0 - '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.51.0 + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 @@ -15869,15 +16849,33 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.51.0': dependencies: '@typescript-eslint/types': 8.51.0 '@typescript-eslint/visitor-keys': 8.51.0 + '@typescript-eslint/scope-manager@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/tsconfig-utils@8.51.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + '@typescript-eslint/type-utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.51.0 @@ -15890,8 +16888,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.2(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.51.0': {} + '@typescript-eslint/types@8.65.0': {} + '@typescript-eslint/typescript-estree@8.51.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.51.0(typescript@5.9.3) @@ -15900,13 +16912,28 @@ snapshots: '@typescript-eslint/visitor-keys': 8.51.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) @@ -15918,11 +16945,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.51.0': dependencies: '@typescript-eslint/types': 8.51.0 eslint-visitor-keys: 4.2.1 + '@typescript-eslint/visitor-keys@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.0': {} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -15995,16 +17038,16 @@ snapshots: mux.js: 6.0.1 video.js: 7.21.7 - '@videojs/http-streaming@3.17.2(video.js@8.23.4)': + '@videojs/http-streaming@3.17.5(video.js@8.23.9)': dependencies: '@babel/runtime': 7.28.4 - '@videojs/vhs-utils': 4.1.1 + '@videojs/vhs-utils': 4.1.2 aes-decrypter: 4.0.2 global: 4.4.0 m3u8-parser: 7.2.0 - mpd-parser: 1.3.1 + mpd-parser: 1.4.0 mux.js: 7.1.0 - video.js: 8.23.4 + video.js: 8.23.9 '@videojs/vhs-utils@3.0.5': dependencies: @@ -16012,7 +17055,7 @@ snapshots: global: 4.4.0 url-toolkit: 2.2.5 - '@videojs/vhs-utils@4.1.1': + '@videojs/vhs-utils@4.1.2': dependencies: '@babel/runtime': 7.28.4 global: 4.4.0 @@ -16029,9 +17072,9 @@ snapshots: global: 4.4.0 is-function: 1.0.2 - '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0))': dependencies: - vite: 7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0) '@webassemblyjs/ast@1.14.1': dependencies: @@ -16170,7 +17213,7 @@ snapshots: aes-decrypter@4.0.2: dependencies: '@babel/runtime': 7.28.4 - '@videojs/vhs-utils': 4.1.1 + '@videojs/vhs-utils': 4.1.2 global: 4.4.0 pkcs7: 1.0.4 @@ -16239,29 +17282,29 @@ snapshots: '@algolia/requester-fetch': 5.48.1 '@algolia/requester-node-http': 5.48.1 - angular-eslint@21.3.1(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3): + angular-eslint@21.3.1(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(typescript@5.9.3): dependencies: '@angular-devkit/core': 21.2.17(chokidar@5.0.0) '@angular-devkit/schematics': 21.2.17(chokidar@5.0.0) '@angular-eslint/builder': 21.3.1(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/eslint-plugin': 21.3.1(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/eslint-plugin-template': 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@angular-eslint/schematics': 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/eslint-plugin': 21.3.1(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/eslint-plugin-template': 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@angular-eslint/schematics': 21.3.1(@angular-eslint/template-parser@21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@angular/cli@21.2.17(@types/node@20.19.9)(chokidar@5.0.0))(@typescript-eslint/types@8.51.0)(@typescript-eslint/utils@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular-eslint/template-parser': 21.3.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@angular/cli': 21.2.17(@types/node@20.19.9)(chokidar@5.0.0) '@typescript-eslint/types': 8.51.0 - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 - typescript-eslint: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + typescript-eslint: 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - chokidar - supports-color - angularx-qrcode@21.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)): + angularx-qrcode@21.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)): dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) qrcode: 1.5.4 tslib: 2.8.1 @@ -16409,7 +17452,7 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 - artplayer@5.3.0: + artplayer@5.4.0: dependencies: option-validator: 2.0.6 @@ -16421,7 +17464,7 @@ snapshots: astring@1.9.0: {} - astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + astro@5.18.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(rollup@4.59.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0): dependencies: '@astrojs/compiler': 2.13.1 '@astrojs/internal-helpers': 0.7.6 @@ -16478,8 +17521,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.4 vfile: 6.0.3 - vite: 6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0) + vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -16554,13 +17597,15 @@ snapshots: transitivePeerDependencies: - debug - axios@1.16.0: + axios@1.18.1: dependencies: follow-redirects: 1.16.0(debug@4.4.3) form-data: 4.0.6 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color axobject-query@4.1.0: {} @@ -16577,26 +17622,25 @@ snapshots: transitivePeerDependencies: - supports-color - babel-jest@30.2.0(@babel/core@7.29.0): + babel-jest@30.4.1(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.0 - '@jest/transform': 30.2.0 + '@babel/core': 7.29.7 + '@jest/transform': 30.4.1 '@types/babel__core': 7.20.5 babel-plugin-istanbul: 7.0.1 - babel-preset-jest: 30.2.0(@babel/core@7.29.0) + babel-preset-jest: 30.4.0(@babel/core@7.29.7) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 transitivePeerDependencies: - supports-color - optional: true - babel-loader@9.2.1(@babel/core@7.28.5)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + babel-loader@9.2.1(@babel/core@7.28.5)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: '@babel/core': 7.28.5 find-cache-dir: 4.0.0 schema-utils: 4.3.3 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) babel-plugin-const-enum@1.2.0(@babel/core@7.28.5): dependencies: @@ -16621,6 +17665,10 @@ snapshots: dependencies: '@types/babel__core': 7.20.5 + babel-plugin-jest-hoist@30.4.0: + dependencies: + '@types/babel__core': 7.20.5 + babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.28.4 @@ -16696,18 +17744,36 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + babel-preset-jest@30.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 babel-plugin-jest-hoist: 30.2.0 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) - babel-preset-jest@30.2.0(@babel/core@7.29.0): + babel-preset-jest@30.4.0(@babel/core@7.29.7): dependencies: - '@babel/core': 7.29.0 - babel-plugin-jest-hoist: 30.2.0 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) - optional: true + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 30.4.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) bail@2.0.2: {} @@ -17217,7 +18283,7 @@ snapshots: dependencies: is-what: 3.14.1 - copy-webpack-plugin@12.0.2(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + copy-webpack-plugin@12.0.2(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: fast-glob: 3.3.3 glob-parent: 6.0.2 @@ -17225,16 +18291,16 @@ snapshots: normalize-path: 3.0.0 schema-utils: 4.3.3 serialize-javascript: 6.0.2 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) - copy-webpack-plugin@14.0.0(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + copy-webpack-plugin@14.0.0(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: glob-parent: 6.0.2 normalize-path: 3.0.0 schema-utils: 4.3.3 serialize-javascript: 7.0.5 tinyglobby: 0.2.15 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) core-js-compat@3.47.0: dependencies: @@ -17306,7 +18372,7 @@ snapshots: dependencies: postcss: 8.5.6 - css-loader@6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + css-loader@6.11.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 @@ -17317,10 +18383,10 @@ snapshots: postcss-value-parser: 4.2.0 semver: 7.7.4 optionalDependencies: - '@rspack/core': 1.6.8(@swc/helpers@0.5.18) - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + '@rspack/core': 1.6.8(@swc/helpers@0.5.23) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) - css-minimizer-webpack-plugin@8.0.0(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + css-minimizer-webpack-plugin@8.0.0(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: '@jridgewell/trace-mapping': 0.3.31 cssnano: 7.1.9(postcss@8.5.6) @@ -17328,7 +18394,7 @@ snapshots: postcss: 8.5.6 schema-utils: 4.3.3 serialize-javascript: 7.0.5 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) optionalDependencies: esbuild: 0.28.1 @@ -17440,7 +18506,7 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - date-fns@4.1.0: {} + date-fns@4.4.0: {} date-format@4.0.14: {} @@ -17635,14 +18701,12 @@ snapshots: dotenv@16.6.1: {} - drizzle-kit@0.31.5: + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.25.12 - esbuild-register: 3.6.0(esbuild@0.25.12) - transitivePeerDependencies: - - supports-color + tsx: 4.23.1 drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.9.0): optionalDependencies: @@ -17913,13 +18977,6 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 - esbuild-register@3.6.0(esbuild@0.25.12): - dependencies: - debug: 4.4.3 - esbuild: 0.25.12 - transitivePeerDependencies: - - supports-color - esbuild-register@3.6.0(esbuild@0.28.1): dependencies: debug: 4.4.3 @@ -18066,17 +19123,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -18087,7 +19144,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -18099,7 +19156,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -18131,6 +19188,8 @@ snapshots: eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} + eslint@9.39.2(jiti@1.21.7): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) @@ -18138,7 +19197,7 @@ snapshots: '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.1 + '@eslint/eslintrc': 3.3.6 '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -18278,6 +19337,15 @@ snapshots: jest-mock: 30.2.0 jest-util: 30.2.0 + expect@30.4.1: + dependencies: + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + exponential-backoff@3.1.3: {} express-rate-limit@8.3.0(express@5.2.1): @@ -18497,7 +19565,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -18512,7 +19580,7 @@ snapshots: semver: 7.7.4 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) form-data@4.0.6: dependencies: @@ -18763,7 +19831,7 @@ snapshots: handle-thing@2.0.1: {} - handlebars@4.7.8: + handlebars@4.7.9: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -18934,7 +20002,7 @@ snapshots: he@1.2.0: {} - hls.js@1.6.13: {} + hls.js@1.6.16: {} homedir-polyfill@1.0.3: dependencies: @@ -19460,10 +20528,10 @@ snapshots: filelist: 1.0.4 picocolors: 1.1.1 - jest-changed-files@30.2.0: + jest-changed-files@30.4.1: dependencies: execa: 5.1.1 - jest-util: 30.2.0 + jest-util: 30.4.1 p-limit: 3.1.0 jest-circus@30.2.0(babel-plugin-macros@3.1.0): @@ -19492,17 +20560,43 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)): + jest-circus@30.4.2(babel-plugin-macros@3.1.0): dependencies: - '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)) - '@jest/test-result': 30.2.0 - '@jest/types': 30.2.0 + '@jest/environment': 30.4.1 + '@jest/expect': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 20.19.9 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.1(babel-plugin-macros@3.1.0) + is-generator-fn: 2.1.0 + jest-each: 30.4.1 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-runtime: 30.4.2 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + p-limit: 3.1.0 + pretty-format: 30.4.1 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.4.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)) - jest-util: 30.2.0 - jest-validate: 30.2.0 + jest-config: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + jest-util: 30.4.1 + jest-validate: 30.4.1 yargs: 17.7.2 transitivePeerDependencies: - '@types/node' @@ -19511,7 +20605,7 @@ snapshots: - supports-color - ts-node - jest-config@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)): + jest-config@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/get-type': 30.1.0 @@ -19540,7 +20634,40 @@ snapshots: optionalDependencies: '@types/node': 20.19.9 esbuild-register: 3.6.0(esbuild@0.28.1) - ts-node: 10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.4.0 + '@jest/test-sequencer': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.4.2(babel-plugin-macros@3.1.0) + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-runner: 30.4.2 + jest-util: 30.4.1 + jest-validate: 30.4.1 + parse-json: 5.2.0 + pretty-format: 30.4.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.9 + esbuild-register: 3.6.0(esbuild@0.28.1) + ts-node: 10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -19552,10 +20679,21 @@ snapshots: chalk: 4.1.2 pretty-format: 30.2.0 + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + jest-docblock@30.2.0: dependencies: detect-newline: 3.1.0 + jest-docblock@30.4.0: + dependencies: + detect-newline: 3.1.0 + jest-each@30.2.0: dependencies: '@jest/get-type': 30.1.0 @@ -19564,12 +20702,18 @@ snapshots: jest-util: 30.2.0 pretty-format: 30.2.0 - jest-environment-jsdom@30.2.0: + jest-each@30.4.1: dependencies: - '@jest/environment': 30.2.0 - '@jest/environment-jsdom-abstract': 30.2.0(jsdom@26.1.0) - '@types/jsdom': 21.1.7 - '@types/node': 20.19.9 + '@jest/get-type': 30.1.0 + '@jest/types': 30.4.1 + chalk: 4.1.2 + jest-util: 30.4.1 + pretty-format: 30.4.1 + + jest-environment-jsdom@30.4.1: + dependencies: + '@jest/environment': 30.4.1 + '@jest/environment-jsdom-abstract': 30.4.1(jsdom@26.1.0) jsdom: 26.1.0 transitivePeerDependencies: - bufferutil @@ -19586,6 +20730,16 @@ snapshots: jest-util: 30.2.0 jest-validate: 30.2.0 + jest-environment-node@30.4.1: + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 20.19.9 + jest-mock: 30.4.1 + jest-util: 30.4.1 + jest-validate: 30.4.1 + jest-haste-map@30.2.0: dependencies: '@jest/types': 30.2.0 @@ -19601,11 +20755,31 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + jest-haste-map@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 20.19.9 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + jest-worker: 30.4.1 + picomatch: 4.0.4 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + jest-leak-detector@30.2.0: dependencies: '@jest/get-type': 30.1.0 pretty-format: 30.2.0 + jest-leak-detector@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + pretty-format: 30.4.1 + jest-matcher-utils@30.2.0: dependencies: '@jest/get-type': 30.1.0 @@ -19613,6 +20787,13 @@ snapshots: jest-diff: 30.2.0 pretty-format: 30.2.0 + jest-matcher-utils@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + jest-message-util@30.2.0: dependencies: '@babel/code-frame': 7.27.1 @@ -19625,30 +20806,53 @@ snapshots: slash: 3.0.0 stack-utils: 2.0.6 + jest-message-util@30.4.1: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 30.4.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.4.1 + picomatch: 4.0.4 + pretty-format: 30.4.1 + slash: 3.0.0 + stack-utils: 2.0.6 + jest-mock@30.2.0: dependencies: '@jest/types': 30.2.0 '@types/node': 20.19.9 jest-util: 30.2.0 + jest-mock@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 20.19.9 + jest-util: 30.4.1 + jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): optionalDependencies: jest-resolve: 30.2.0 - jest-preset-angular@15.0.3(75c2f4d3e02d43367177c3c83cdeef78): + jest-pnp-resolver@1.2.3(jest-resolve@30.4.1): + optionalDependencies: + jest-resolve: 30.4.1 + + jest-preset-angular@15.0.3(c4e4d73a3616691068af99860e93e092): dependencies: '@angular/compiler-cli': 21.2.17(@angular/compiler@21.2.17)(typescript@5.9.3) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) - '@angular/platform-browser-dynamic': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) + '@angular/platform-browser-dynamic': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))) '@jest/environment-jsdom-abstract': 30.2.0(jsdom@26.1.0) bs-logger: 0.2.6 esbuild-wasm: 0.27.2 - jest: 30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)) - jest-util: 30.2.0 + jest: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + jest-util: 30.4.1 jsdom: 26.1.0 pretty-format: 30.2.0 - ts-jest: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.28.1)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3) + ts-jest: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3) typescript: 5.9.3 optionalDependencies: esbuild: 0.28.1 @@ -19661,10 +20865,12 @@ snapshots: jest-regex-util@30.0.1: {} - jest-resolve-dependencies@30.2.0: + jest-regex-util@30.4.0: {} + + jest-resolve-dependencies@30.4.2: dependencies: - jest-regex-util: 30.0.1 - jest-snapshot: 30.2.0 + jest-regex-util: 30.4.0 + jest-snapshot: 30.4.1 transitivePeerDependencies: - supports-color @@ -19679,6 +20885,17 @@ snapshots: slash: 3.0.0 unrs-resolver: 1.11.1 + jest-resolve@30.4.1: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-pnp-resolver: 1.2.3(jest-resolve@30.4.1) + jest-util: 30.4.1 + jest-validate: 30.4.1 + slash: 3.0.0 + unrs-resolver: 1.11.1 + jest-runner@30.2.0: dependencies: '@jest/console': 30.2.0 @@ -19706,6 +20923,33 @@ snapshots: transitivePeerDependencies: - supports-color + jest-runner@30.4.2: + dependencies: + '@jest/console': 30.4.1 + '@jest/environment': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 20.19.9 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-haste-map: 30.4.1 + jest-leak-detector: 30.4.1 + jest-message-util: 30.4.1 + jest-resolve: 30.4.1 + jest-runtime: 30.4.2 + jest-util: 30.4.1 + jest-watcher: 30.4.1 + jest-worker: 30.4.1 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + jest-runtime@30.2.0: dependencies: '@jest/environment': 30.2.0 @@ -19733,6 +20977,33 @@ snapshots: transitivePeerDependencies: - supports-color + jest-runtime@30.4.2: + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/globals': 30.4.1 + '@jest/source-map': 30.0.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 20.19.9 + chalk: 4.1.2 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + jest-snapshot@30.2.0: dependencies: '@babel/core': 7.29.0 @@ -19759,6 +21030,32 @@ snapshots: transitivePeerDependencies: - supports-color + jest-snapshot@30.4.1: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.7) + '@babel/types': 7.29.7 + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 30.4.1 + graceful-fs: 4.2.11 + jest-diff: 30.4.1 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + pretty-format: 30.4.1 + semver: 7.7.4 + synckit: 0.11.11 + transitivePeerDependencies: + - supports-color + jest-util@30.2.0: dependencies: '@jest/types': 30.2.0 @@ -19768,6 +21065,15 @@ snapshots: graceful-fs: 4.2.11 picomatch: 4.0.3 + jest-util@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 20.19.9 + chalk: 4.1.2 + ci-info: 4.3.1 + graceful-fs: 4.2.11 + picomatch: 4.0.4 + jest-validate@30.2.0: dependencies: '@jest/get-type': 30.1.0 @@ -19777,6 +21083,15 @@ snapshots: leven: 3.1.0 pretty-format: 30.2.0 + jest-validate@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.4.1 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.4.1 + jest-watcher@30.2.0: dependencies: '@jest/test-result': 30.2.0 @@ -19788,6 +21103,17 @@ snapshots: jest-util: 30.2.0 string-length: 4.0.2 + jest-watcher@30.4.1: + dependencies: + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 20.19.9 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.4.1 + string-length: 4.0.2 + jest-worker@27.5.1: dependencies: '@types/node': 20.19.9 @@ -19802,12 +21128,20 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)): + jest-worker@30.4.1: dependencies: - '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)) - '@jest/types': 30.2.0 + '@types/node': 20.19.9 + '@ungap/structured-clone': 1.3.0 + jest-util: 30.4.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.4.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) + '@jest/types': 30.4.1 import-local: 3.2.0 - jest-cli: 30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)) + jest-cli: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -19950,12 +21284,12 @@ snapshots: lazy-val@1.0.5: {} - less-loader@12.3.2(@rspack/core@1.6.8(@swc/helpers@0.5.18))(less@4.5.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + less-loader@12.3.2(@rspack/core@1.6.8(@swc/helpers@0.5.23))(less@4.5.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: less: 4.5.1 optionalDependencies: - '@rspack/core': 1.6.8(@swc/helpers@0.5.18) - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + '@rspack/core': 1.6.8(@swc/helpers@0.5.23) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) less@4.5.1: dependencies: @@ -19978,11 +21312,11 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - license-webpack-plugin@4.0.2(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + license-webpack-plugin@4.0.2(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: webpack-sources: 3.3.3 optionalDependencies: - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) lilconfig@3.1.3: {} @@ -20112,7 +21446,7 @@ snapshots: m3u8-parser@7.2.0: dependencies: '@babel/runtime': 7.28.4 - '@videojs/vhs-utils': 4.1.1 + '@videojs/vhs-utils': 4.1.2 global: 4.4.0 magic-string@0.30.21: @@ -20183,7 +21517,7 @@ snapshots: markdown-table@3.0.4: {} - marked@18.0.5: {} + marked@18.0.7: {} material-design-icons-iconfont@6.7.0: {} @@ -20686,10 +22020,10 @@ snapshots: dependencies: dom-walk: 0.1.2 - mini-css-extract-plugin@2.4.7(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + mini-css-extract-plugin@2.4.7(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: schema-utils: 4.3.3 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) minimalistic-assert@1.0.1: {} @@ -20705,6 +22039,10 @@ snapshots: dependencies: brace-expansion: 1.1.13 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + minimatch@5.1.9: dependencies: brace-expansion: 2.1.0 @@ -20787,10 +22125,10 @@ snapshots: '@xmldom/xmldom': 0.8.13 global: 4.4.0 - mpd-parser@1.3.1: + mpd-parser@1.4.0: dependencies: '@babel/runtime': 7.28.4 - '@videojs/vhs-utils': 4.1.1 + '@videojs/vhs-utils': 4.1.2 '@xmldom/xmldom': 0.8.13 global: 4.4.0 @@ -20869,23 +22207,23 @@ snapshots: neotraverse@0.6.18: {} - ng-mocks@14.15.1(3bd9032d10655768ae17cd8d4331ec90): + ng-mocks@14.15.3(1918f1ef5f9f83bc2aab38921c00c721): dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@angular/forms': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/forms': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)) - ngx-indexed-db@21.0.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)): + ngx-indexed-db@21.0.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)): dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) tslib: 2.8.1 - ngx-skeleton-loader@11.3.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)): + ngx-skeleton-loader@11.3.0(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)): dependencies: - '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) - '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) + '@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2) tslib: 2.8.1 nlcst-to-string@4.0.0: @@ -21019,22 +22357,22 @@ snapshots: nwsapi@2.2.23: {} - nx-electron@22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))))(@nx/workspace@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)))(@swc/core@1.15.8(@swc/helpers@0.5.18))(electron-builder-squirrel-windows@26.0.12)(electron@41.7.2)(esbuild@0.28.1)(rxjs@7.8.2)(typescript@5.9.3): + nx-electron@22.0.0(patch_hash=f4bbde1778360c4c462b255bec47a337c0465d59cdcab14ecb601161f3467002)(@nx/devkit@22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))))(@nx/workspace@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)))(@swc/core@1.15.46(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.0.12)(electron@41.7.2)(esbuild@0.28.1)(rxjs@7.8.2)(typescript@5.9.3): dependencies: - '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18))) - '@nx/workspace': 22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)) - copy-webpack-plugin: 12.0.2(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + '@nx/devkit': 22.7.1(nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23))) + '@nx/workspace': 22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)) + copy-webpack-plugin: 12.0.2(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) electron: 41.7.2 electron-builder: 26.0.12(electron-builder-squirrel-windows@26.0.12) - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) - license-webpack-plugin: 4.0.2(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) + license-webpack-plugin: 4.0.2(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) rxjs-for-await: 1.0.0(rxjs@7.8.2) strip-json-comments: 3.1.1 - terser-webpack-plugin: 5.3.16(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) tree-kill: 1.2.2 - ts-loader: 9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + ts-loader: 9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) tsconfig-paths-webpack-plugin: 4.2.0 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) webpack-merge: 5.10.0 transitivePeerDependencies: - '@swc/core' @@ -21047,7 +22385,7 @@ snapshots: - uglify-js - webpack-cli - nx@22.7.1(@swc-node/register@1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3))(@swc/core@1.15.8(@swc/helpers@0.5.18)): + nx@22.7.1(@swc-node/register@1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3))(@swc/core@1.15.46(@swc/helpers@0.5.23)): dependencies: '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.4.5 @@ -21170,8 +22508,8 @@ snapshots: '@nx/nx-linux-x64-musl': 22.7.1 '@nx/nx-win32-arm64-msvc': 22.7.1 '@nx/nx-win32-x64-msvc': 22.7.1 - '@swc-node/register': 1.11.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(@swc/types@0.1.25)(typescript@5.9.3) - '@swc/core': 1.15.8(@swc/helpers@0.5.18) + '@swc-node/register': 1.12.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@5.9.3) + '@swc/core': 1.15.46(@swc/helpers@0.5.23) transitivePeerDependencies: - debug @@ -21540,11 +22878,11 @@ snapshots: dependencies: find-up: 6.3.0 - playwright-core@1.57.0: {} + playwright-core@1.62.0: {} - playwright@1.57.0: + playwright@1.62.0: dependencies: - playwright-core: 1.57.0 + playwright-core: 1.62.0 optionalDependencies: fsevents: 2.3.2 @@ -21621,44 +22959,44 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)): + postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 - yaml: 2.8.2 + yaml: 2.9.0 optionalDependencies: postcss: 8.5.6 - ts-node: 10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3) - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.23.1)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 - tsx: 4.21.0 - yaml: 2.8.2 + tsx: 4.23.1 + yaml: 2.9.0 - postcss-loader@8.2.0(@rspack/core@1.6.8(@swc/helpers@0.5.18))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + postcss-loader@8.2.0(@rspack/core@1.6.8(@swc/helpers@0.5.23))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: cosmiconfig: 9.0.0(typescript@5.9.3) jiti: 2.6.1 postcss: 8.5.6 semver: 7.7.4 optionalDependencies: - '@rspack/core': 1.6.8(@swc/helpers@0.5.18) - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + '@rspack/core': 1.6.8(@swc/helpers@0.5.23) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) transitivePeerDependencies: - typescript - postcss-loader@8.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.18))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + postcss-loader@8.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.23))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: cosmiconfig: 9.0.0(typescript@5.9.3) jiti: 2.6.1 postcss: 8.5.6 semver: 7.7.4 optionalDependencies: - '@rspack/core': 1.6.8(@swc/helpers@0.5.18) - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + '@rspack/core': 1.6.8(@swc/helpers@0.5.23) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) transitivePeerDependencies: - typescript @@ -21853,7 +23191,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.8.1: {} + prettier@3.9.6: {} pretty-format@30.2.0: dependencies: @@ -21861,6 +23199,13 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.8 + prismjs@1.30.0: {} proc-log@2.0.1: {} @@ -21958,6 +23303,8 @@ snapshots: react-is@18.3.1: {} + react-is@19.2.8: {} + react-refresh@0.18.0: {} react@19.2.3: {} @@ -22458,14 +23805,14 @@ snapshots: sass-embedded-win32-arm64: 1.97.1 sass-embedded-win32-x64: 1.97.1 - sass-loader@16.0.6(@rspack/core@1.6.8(@swc/helpers@0.5.18))(sass-embedded@1.97.1)(sass@1.97.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + sass-loader@16.0.6(@rspack/core@1.6.8(@swc/helpers@0.5.23))(sass-embedded@1.97.1)(sass@1.97.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: neo-async: 2.6.2 optionalDependencies: - '@rspack/core': 1.6.8(@swc/helpers@0.5.18) + '@rspack/core': 1.6.8(@swc/helpers@0.5.23) sass: 1.97.1 sass-embedded: 1.97.1 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) sass@1.97.1: dependencies: @@ -22532,6 +23879,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -22630,7 +23979,7 @@ snapshots: setprototypeof@1.2.0: {} - shaka-player@5.2.1: {} + shaka-player@5.2.2: {} shallow-clone@3.0.1: dependencies: @@ -22640,7 +23989,7 @@ snapshots: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -22666,6 +24015,40 @@ snapshots: '@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + optional: true + + sharp@0.35.3(@types/node@20.19.9): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 20.19.9 shebang-command@2.0.0: dependencies: @@ -22753,12 +24136,12 @@ snapshots: sisteransi@1.0.5: {} - sitemap@8.0.2: + sitemap@9.0.1: dependencies: - '@types/node': 17.0.45 + '@types/node': 24.13.1 '@types/sax': 1.2.7 arg: 5.0.2 - sax: 1.4.3 + sax: 1.6.0 slash@3.0.0: {} @@ -22811,11 +24194,11 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + source-map-loader@5.0.0(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) source-map-support@0.5.13: dependencies: @@ -22989,9 +24372,9 @@ snapshots: strip-json-comments@3.1.1: {} - style-loader@3.3.4(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + style-loader@3.3.4(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) style-to-js@1.1.21: dependencies: @@ -23065,7 +24448,7 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2): + tailwindcss@3.4.19(tsx@4.23.1)(yaml@2.9.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -23084,7 +24467,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.23.1)(yaml@2.9.0) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -23137,16 +24520,16 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 - terser-webpack-plugin@5.3.16(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + terser-webpack-plugin@5.3.16(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.1 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) optionalDependencies: - '@swc/core': 1.15.8(@swc/helpers@0.5.18) + '@swc/core': 1.15.46(@swc/helpers@0.5.23) esbuild: 0.28.1 terser@5.44.1: @@ -23241,7 +24624,11 @@ snapshots: dependencies: typescript: 5.9.3 - ts-checker-rspack-plugin@1.2.3(@rspack/core@1.6.8(@swc/helpers@0.5.18))(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-checker-rspack-plugin@1.2.3(@rspack/core@1.6.8(@swc/helpers@0.5.23))(typescript@5.9.3): dependencies: '@babel/code-frame': 7.27.1 '@rspack/lite-tapable': 1.1.0 @@ -23252,32 +24639,32 @@ snapshots: picocolors: 1.1.1 typescript: 5.9.3 optionalDependencies: - '@rspack/core': 1.6.8(@swc/helpers@0.5.18) + '@rspack/core': 1.6.8(@swc/helpers@0.5.23) ts-interface-checker@0.1.13: {} - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.28.1)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 - jest: 30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3)) + handlebars: 4.7.9 + jest: 30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.1))(ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 - semver: 7.7.3 + semver: 7.8.5 type-fest: 4.41.0 typescript: 5.9.3 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.29.0 - '@jest/transform': 30.2.0 - '@jest/types': 30.2.0 - babel-jest: 30.2.0(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.7) esbuild: 0.28.1 - jest-util: 30.2.0 + jest-util: 30.4.1 - ts-loader@9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + ts-loader@9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: chalk: 4.1.2 enhanced-resolve: 5.18.4 @@ -23285,9 +24672,9 @@ snapshots: semver: 7.7.4 source-map: 0.7.6 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) - ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.15.46(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -23305,7 +24692,7 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: - '@swc/core': 1.15.8(@swc/helpers@0.5.18) + '@swc/core': 1.15.46(@swc/helpers@0.5.23) tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: @@ -23335,10 +24722,9 @@ snapshots: tsscmp@1.0.6: {} - tsx@4.21.0: + tsx@4.23.1: dependencies: - esbuild: 0.27.7 - get-tsconfig: 4.13.0 + esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 @@ -23412,12 +24798,12 @@ snapshots: typed-assert@1.0.9: {} - typescript-eslint@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + typescript-eslint@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: @@ -23669,25 +25055,25 @@ snapshots: videojs-font: 3.2.0 videojs-vtt.js: 0.15.5 - video.js@8.23.4: + video.js@8.23.9: dependencies: '@babel/runtime': 7.28.4 - '@videojs/http-streaming': 3.17.2(video.js@8.23.4) - '@videojs/vhs-utils': 4.1.1 + '@videojs/http-streaming': 3.17.5(video.js@8.23.9) + '@videojs/vhs-utils': 4.1.2 '@videojs/xhr': 2.7.0 aes-decrypter: 4.0.2 global: 4.4.0 m3u8-parser: 7.2.0 - mpd-parser: 1.3.1 + mpd-parser: 1.4.0 mux.js: 7.1.0 - videojs-contrib-quality-levels: 4.1.0(video.js@8.23.4) + videojs-contrib-quality-levels: 4.1.0(video.js@8.23.9) videojs-font: 4.2.0 videojs-vtt.js: 0.15.5 - videojs-contrib-quality-levels@4.1.0(video.js@8.23.4): + videojs-contrib-quality-levels@4.1.0(video.js@8.23.9): dependencies: global: 4.4.0 - video.js: 8.23.4 + video.js: 8.23.9 videojs-font@3.2.0: {} @@ -23696,14 +25082,14 @@ snapshots: videojs-quality-selector-hls@1.1.1: dependencies: global: 4.4.0 - video.js: 8.23.4 - videojs-contrib-quality-levels: 4.1.0(video.js@8.23.4) + video.js: 8.23.9 + videojs-contrib-quality-levels: 4.1.0(video.js@8.23.9) videojs-vtt.js@0.15.5: dependencies: global: 4.4.0 - vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -23719,10 +25105,10 @@ snapshots: sass: 1.97.1 sass-embedded: 1.97.1 terser: 5.44.1 - tsx: 4.21.0 - yaml: 2.8.2 + tsx: 4.23.1 + yaml: 2.9.0 - vite@7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.5(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -23738,12 +25124,12 @@ snapshots: sass: 1.97.3 sass-embedded: 1.97.1 terser: 5.44.1 - tsx: 4.21.0 - yaml: 2.8.2 + tsx: 4.23.1 + yaml: 2.9.0 - vitefu@1.1.1(vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitefu@1.1.1(vite@6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0)): optionalDependencies: - vite: 6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0) w3c-xmlserializer@5.0.0: dependencies: @@ -23780,7 +25166,7 @@ snapshots: webidl-conversions@7.0.0: {} - webpack-dev-middleware@7.4.5(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + webpack-dev-middleware@7.4.5(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: colorette: 2.0.20 memfs: 4.51.1 @@ -23789,9 +25175,9 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.3.3 optionalDependencies: - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) - webpack-dev-server@5.2.2(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + webpack-dev-server@5.2.2(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -23819,10 +25205,10 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.5(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + webpack-dev-middleware: 7.4.5(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) ws: 8.18.3 optionalDependencies: - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) transitivePeerDependencies: - bufferutil - debug @@ -23839,12 +25225,12 @@ snapshots: webpack-sources@3.3.3: {} - webpack-subresource-integrity@5.1.0(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)): + webpack-subresource-integrity@5.1.0(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: typed-assert: 1.0.9 - webpack: 5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1) + webpack: 5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1) - webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1): + webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -23868,7 +25254,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.8(@swc/helpers@0.5.18))(esbuild@0.28.1)) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)(webpack@5.104.1(@swc/core@1.15.46(@swc/helpers@0.5.23))(esbuild@0.28.1)) watchpack: 2.5.0 webpack-sources: 3.3.3 transitivePeerDependencies: @@ -24038,7 +25424,7 @@ snapshots: yaml@2.8.0: {} - yaml@2.8.2: {} + yaml@2.9.0: {} yargs-parser@18.1.3: dependencies: @@ -24113,6 +25499,6 @@ snapshots: zod@4.3.6: {} - zone.js@0.15.1: {} + zone.js@0.16.2: {} zwitch@2.0.4: {} diff --git a/tsconfig.base.json b/tsconfig.base.json index 05edde3ed..84bd885ec 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,6 +5,7 @@ "sourceMap": true, "declaration": false, "moduleResolution": "node", + "esModuleInterop": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "importHelpers": true, From f193232daba9b884b9e6d2c1f51379057afbfb71 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:54:39 +0200 Subject: [PATCH 02/32] ci(deps): bump checkout/upload-artifact/download-artifact majors (#1264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes #1249, #1245 and #1247, which each rewrote the full-commit pins in publish-snap.yaml while the same SHAs are asserted in three packaging test files — merged separately, every one of them left those tests red. actions/checkout v4 -> v7 (docker.yml from v6), actions/upload-artifact v4 -> v7, actions/download-artifact v4 -> v8. New pins verified against the upstream tag refs: checkout 3d3c42e5 = v7.0.1, upload-artifact 043fb46d = v7.0.1, download-artifact 3e5f45b2 = v8.0.1. download-artifact v8 changes two things on the Snap publish path, both in our favour: a digest mismatch now fails the run instead of logging a warning, and decompression is skipped for non-zip Content-Types (our artifact is a normal upload-artifact zip, so unchanged). checkout v7's fork-PR block only applies to pull_request_target/workflow_run, neither of which exists here. --- .github/workflows/build-and-make.yaml | 24 +++++++++---------- .github/workflows/ci.yml | 10 ++++---- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/deploy-website.yml | 2 +- .github/workflows/docker.yml | 2 +- .github/workflows/e2e-tests.yaml | 8 +++---- .github/workflows/publish-snap.yaml | 6 ++--- .../packaging/publish-snap-workflow.test.mjs | 6 ++--- tools/packaging/release-snap-assets.test.mjs | 6 ++--- .../snap-workflow-policy.test-helpers.mjs | 12 +++++----- 10 files changed, 39 insertions(+), 39 deletions(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index abbac03eb..efc62626e 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -56,7 +56,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v4 @@ -322,7 +322,7 @@ jobs: test -s "${RUNTIME_ROOT}/source-archive-binding.json" - name: Upload staged Linux runtime - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-embedded-mpv-runtime path: vendor/embedded-mpv/linux-x64 @@ -330,7 +330,7 @@ jobs: retention-days: 7 - name: Upload Linux runtime source compliance - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-frame-copy-runtime-sources path: dist/compliance/linux-frame-copy-runtime-sources.tar.xz @@ -370,7 +370,7 @@ jobs: steps: &electron-build-steps - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -424,7 +424,7 @@ jobs: - name: Download pinned Linux Embedded MPV runtime if: matrix.os == 'linux' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: linux-embedded-mpv-runtime path: vendor/embedded-mpv/linux-x64 @@ -1187,7 +1187,7 @@ jobs: - name: Upload artifacts (macOS) if: matrix.os == 'macos' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: macos-${{ matrix.arch }}-artifacts path: | @@ -1199,7 +1199,7 @@ jobs: - name: Upload system-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'system' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-system-artifacts path: | @@ -1211,7 +1211,7 @@ jobs: - name: Upload portable-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'portable' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-portable-artifacts path: | @@ -1223,7 +1223,7 @@ jobs: - name: Upload Flatpak-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-flatpak-artifacts path: | @@ -1232,7 +1232,7 @@ jobs: - name: Upload artifacts (Windows) if: matrix.os == 'windows' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: windows-artifacts path: | @@ -1294,10 +1294,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc86e2950..14f32433b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Image pinned by digest (tag 1.7.12). False positives are # suppressed in .github/actionlint.yaml; shellcheck runs at @@ -53,7 +53,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 # The gate scripts are dependency-free Node, so this job skips # pnpm install entirely and stays cheap. @@ -92,7 +92,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: # nx affected needs the merge-base with the PR target branch. fetch-depth: 0 @@ -133,7 +133,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -167,7 +167,7 @@ jobs: - name: Upload unit coverage artifact if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: unit-coverage path: | diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 498b03e7c..dfe88308a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. PR runs analyze the merge # commit checked out above (the modern default); JavaScript is diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 01a5143c7..aa235d701 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c68aaf8a7..dff445563 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Prepare Docker metadata id: docker-meta diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 62ed7c390..abd9d577a 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -49,7 +49,7 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -87,7 +87,7 @@ jobs: - name: Upload Electron Test Results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report-electron-${{ matrix.os }} path: | @@ -103,7 +103,7 @@ jobs: NX_SKIP_NX_CACHE: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -131,7 +131,7 @@ jobs: - name: Upload Web Test Results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report-web-ubuntu path: | diff --git a/.github/workflows/publish-snap.yaml b/.github/workflows/publish-snap.yaml index b07fe5469..d74213ec2 100644 --- a/.github/workflows/publish-snap.yaml +++ b/.github/workflows/publish-snap.yaml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout released tooling - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ github.event.release.tag_name }} persist-credentials: false @@ -121,7 +121,7 @@ jobs: printf 'receipt-sha256=%s\n' "${RECEIPT_SHA256}" >> "${GITHUB_OUTPUT}" - name: Transfer verified release assets - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: verified-snap-release-assets path: /var/lib/iptvnator-snap-release/assets @@ -139,7 +139,7 @@ jobs: steps: - name: Download verified release assets - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: verified-snap-release-assets path: ${{ runner.temp }}/verified-snap-release-assets diff --git a/tools/packaging/publish-snap-workflow.test.mjs b/tools/packaging/publish-snap-workflow.test.mjs index 33085595f..9bc87f59b 100644 --- a/tools/packaging/publish-snap-workflow.test.mjs +++ b/tools/packaging/publish-snap-workflow.test.mjs @@ -174,13 +174,13 @@ test('isolates released verification from the fresh credentialed upload runner', assert.deepEqual( verifyJob.steps.filter((step) => step.uses).map((step) => step.uses), [ - 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5', - 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02', + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a', ] ); assert.deepEqual( publishJob.steps.filter((step) => step.uses).map((step) => step.uses), - ['actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'] + ['actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c'] ); const bindingStep = verifyJob.steps.find( diff --git a/tools/packaging/release-snap-assets.test.mjs b/tools/packaging/release-snap-assets.test.mjs index c1a3a4c1c..27e1cd045 100644 --- a/tools/packaging/release-snap-assets.test.mjs +++ b/tools/packaging/release-snap-assets.test.mjs @@ -1563,7 +1563,7 @@ test('publish workflow installs the source verifier and binds the release tag re ); assert.equal( checkoutStep.uses, - 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' ); assert.equal(snapcraftStep.uses, undefined); assert.match( @@ -1594,11 +1594,11 @@ test('publish workflow installs the source verifier and binds the release tag re ); assert.equal( transferStep.uses, - 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ); assert.equal( artifactDownloadStep.uses, - 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' + 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' ); assert.ok( publishJob.steps.indexOf(artifactDownloadStep) < diff --git a/tools/packaging/snap-workflow-policy.test-helpers.mjs b/tools/packaging/snap-workflow-policy.test-helpers.mjs index d69a48b89..5fc55b065 100644 --- a/tools/packaging/snap-workflow-policy.test-helpers.mjs +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -2,11 +2,11 @@ import assert from 'node:assert/strict'; import { parse } from 'yaml'; const PINNED_CHECKOUT_ACTION = - 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5'; + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'; const PINNED_UPLOAD_ARTIFACT_ACTION = - 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02'; + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a'; const PINNED_DOWNLOAD_ARTIFACT_ACTION = - 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'; + 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c'; const PUBLISH_ACTION_ALLOWLIST = Object.freeze([ PINNED_CHECKOUT_ACTION, PINNED_DOWNLOAD_ARTIFACT_ACTION, @@ -16,10 +16,10 @@ const BUILD_ACTION_ALLOWLIST = Object.freeze([ 'actions/cache/restore@v4', 'actions/cache/save@v4', 'actions/cache@v4', - 'actions/checkout@v4', - 'actions/download-artifact@v4', + 'actions/checkout@v7', + 'actions/download-artifact@v8', 'actions/setup-node@v4', - 'actions/upload-artifact@v4', + 'actions/upload-artifact@v7', 'pnpm/action-setup@v4', 'softprops/action-gh-release@v2', ]); From 23512411a0d378d0e9d331a62025bca1fc8955df Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:54:52 +0200 Subject: [PATCH 03/32] build(docker): move image to node 24 and install pnpm without corepack (#1265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes #1250, whose 22 -> 26 bump failed the image build: `corepack enable` exits 127 because Node 25 unbundled Corepack. Both stages move to node:24-alpine, the current LTS line — Node 26 stays Current until October 2026, which is the wrong target for a self-hosted runtime image. pnpm is installed globally at the exact `packageManager` version, with the `+sha512...` suffix stripped, so the next base-image major is a one-line change instead of a broken build. --- docker/Dockerfile | 11 ++++++++--- docs/architecture/pwa-self-hosted.md | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 53368c8c7..86da3f98d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,7 +1,7 @@ # The Nx builds below emit platform-independent JS/static assets. During # multi-arch publish builds, keep that heavy build stage on the native builder # platform instead of running Node/Nx through QEMU for linux/arm64. -FROM --platform=$BUILDPLATFORM node:22-alpine AS build +FROM --platform=$BUILDPLATFORM node:24-alpine AS build RUN apk add --no-cache python3 make g++ git @@ -11,7 +11,12 @@ COPY .npmrc ./ COPY package.json pnpm-lock.yaml ./ COPY patches ./patches -RUN corepack enable && pnpm install --frozen-lockfile --ignore-scripts +# Node 25 unbundled Corepack, so `corepack enable` cannot be the way pnpm gets +# installed here — it would break on the next base-image major. Install the +# exact pnpm from `packageManager` instead, which stays reproducible. +RUN PNPM_VERSION="$(node -p 'require("./package.json").packageManager.split("@")[1].split("+")[0]')" \ + && npm install --global "pnpm@${PNPM_VERSION}" \ + && pnpm install --frozen-lockfile --ignore-scripts COPY . . @@ -23,7 +28,7 @@ RUN node tools/build/inject-build-commit.mjs RUN pnpm nx build web --configuration=pwa RUN pnpm nx build web-backend -FROM node:22-alpine +FROM node:24-alpine RUN apk add --no-cache gettext nginx diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index da799457d..d95ee24ab 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -175,7 +175,10 @@ browser sidecar data. The Docker image has two stages: 1. Build stage installs dependencies and runs `web:pwa` plus `web-backend`. -2. Runtime stage uses `node:22-alpine` with nginx installed. nginx serves + pnpm is installed globally at the exact `packageManager` version rather than + through Corepack, which Node 25 unbundled, so the base-image major stays + free to move. +2. Runtime stage uses `node:24-alpine` with nginx installed. nginx serves `dist/apps/web` and proxies `/api/*` to the local Express backend. The entrypoint renders the nginx config from a `${PORT}` template, starts the backend, waits for `/health`, and then starts nginx. If either process exits From 1ab82b04a13488feff32984fc1e7dd4d531b395f Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:27:31 +0200 Subject: [PATCH 04/32] refactor(deps): drop uuid for a shared crypto-based id helper (#1266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes #1252 and #872. uuid 14 is ESM-only, apps/web/jest.config.ts only kept v9 working by mapping `^uuid$` at a `wrapper.mjs` that v14 no longer ships, and the specifier also has to be synced in libs/shared/m3u-utils/package.json or @nx/dependency-checks fails lint. All four call sites only used `v4()`, so the dependency goes away instead. `createRandomId()` prefers `crypto.randomUUID()` and falls back to building the same v4 shape from `crypto.getRandomValues()` — that fallback is load-bearing, because randomUUID is only exposed in secure contexts and the self-hosted PWA is regularly served over plain http on a LAN address. getRandomValues stays available there, and it is what uuid's own v4 used. `@types/uuid` goes too; it only existed for the untyped v9 package. --- apps/web/jest.config.ts | 1 - .../stalker-portal-import.component.ts | 7 +- .../xtream-code-import.component.ts | 6 +- .../src/lib/playlist-backup.service.ts | 6 +- libs/shared/interfaces/src/index.ts | 1 + .../interfaces/src/lib/random-id.util.spec.ts | 84 +++++++++++++++++++ .../interfaces/src/lib/random-id.util.ts | 43 ++++++++++ libs/shared/m3u-utils/package.json | 3 +- .../m3u-utils/src/lib/playlist.utils.ts | 6 +- package.json | 2 - pnpm-lock.yaml | 23 +---- 11 files changed, 143 insertions(+), 39 deletions(-) create mode 100644 libs/shared/interfaces/src/lib/random-id.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/random-id.util.ts diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index fe4335511..d218a0165 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -42,7 +42,6 @@ export default { '^shaka-player$': '/src/test-stubs/shaka-player.js', '^video.js$': '/src/test-stubs/video-js.js', '^rxjs': '/../../node_modules/rxjs/dist/bundles/rxjs.umd.js', - '^uuid$': '/../../node_modules/uuid/wrapper.mjs', }, transform: angularEsmPreset.transform, transformIgnorePatterns: [], diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 266a7b40b..0345965ad 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -17,8 +17,7 @@ import { StalkerSessionService, normalizeStalkerPortalIdentity, } from '@iptvnator/portal/stalker/data-access'; -import { Playlist } from '@iptvnator/shared/interfaces'; -import { v4 as uuid } from 'uuid'; +import { createRandomId, Playlist } from '@iptvnator/shared/interfaces'; @Component({ imports: [ @@ -55,7 +54,7 @@ export class StalkerPortalImportComponent { readonly URL_REGEX = /^(http|https|file):\/\/[^ "]+$/; readonly form = new FormGroup({ - _id: new FormControl(uuid()), + _id: new FormControl(createRandomId()), title: new FormControl('', [Validators.required]), macAddress: new FormControl('', [Validators.required]), serialNumber: new FormControl(''), @@ -82,7 +81,7 @@ export class StalkerPortalImportComponent { clearForm(): void { this.form.reset({ - _id: uuid(), + _id: createRandomId(), title: '', macAddress: '', serialNumber: '', diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index 77d1a8497..f55115de3 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -16,11 +16,11 @@ import { TranslatePipe } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { PortalStatus, PortalStatusService } from '@iptvnator/services'; import { + createRandomId, extractXtreamCredentialsFromUrl, normalizeXtreamServerUrl, Playlist, } from '@iptvnator/shared/interfaces'; -import { v4 as uuid } from 'uuid'; function xtreamServerUrlValidator( control: AbstractControl @@ -91,7 +91,7 @@ export class XtreamCodeImportComponent { URL_REGEX = /^\s*https?:\/\/[^ "]+\s*$/; form = new FormGroup({ - _id: new FormControl(uuid()), + _id: new FormControl(createRandomId()), title: new FormControl('', [Validators.required]), password: new FormControl('', [Validators.required]), username: new FormControl('', [Validators.required]), @@ -149,7 +149,7 @@ export class XtreamCodeImportComponent { clearForm(): void { this.form.reset({ - _id: uuid(), + _id: createRandomId(), title: '', password: '', username: '', diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 9a18097fb..26cd646bf 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -1,6 +1,5 @@ import { inject, Injectable } from '@angular/core'; import { firstValueFrom } from 'rxjs'; -import { v4 as uuidv4 } from 'uuid'; import { PlaylistsService } from './playlists.service'; import { SettingsStore } from './settings-store.service'; import { DatabaseService } from './database-electron.service'; @@ -23,6 +22,7 @@ import { XtreamBackupContentType, XtreamPlaylistBackupEntry, XtreamPendingRestoreState, + createRandomId, } from '@iptvnator/shared/interfaces'; export interface PlaylistBackupExportPayload { @@ -585,10 +585,10 @@ export class PlaylistBackupService { return entry.exportedId; } - let nextId = uuidv4(); + let nextId = createRandomId(); while (existingIds.has(nextId)) { - nextId = uuidv4(); + nextId = createRandomId(); } return nextId; diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index f1a1660ea..f4d51e0bc 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -30,6 +30,7 @@ export * from './lib/playlist.interface'; export * from './lib/portal-activity-item.interface'; export * from './lib/portal-debug.interface'; export * from './lib/portal-playback.interface'; +export * from './lib/random-id.util'; export * from './lib/security-policy-error.utils'; export * from './lib/settings.interface'; export * from './lib/stalker-portal-actions.enum'; diff --git a/libs/shared/interfaces/src/lib/random-id.util.spec.ts b/libs/shared/interfaces/src/lib/random-id.util.spec.ts new file mode 100644 index 000000000..776dfdcd4 --- /dev/null +++ b/libs/shared/interfaces/src/lib/random-id.util.spec.ts @@ -0,0 +1,84 @@ +import { createRandomId } from './random-id.util'; + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('createRandomId', () => { + const nativeRandomUUID = crypto.randomUUID; + + afterEach(() => { + Object.defineProperty(crypto, 'randomUUID', { + configurable: true, + value: nativeRandomUUID, + }); + }); + + it('returns a v4 uuid', () => { + expect(createRandomId()).toMatch(UUID_V4); + }); + + it('returns unique ids', () => { + const ids = new Set( + Array.from({ length: 500 }, () => createRandomId()) + ); + + expect(ids.size).toBe(500); + }); + + it('uses the native randomUUID when the context exposes it', () => { + const randomUUID = jest + .fn() + .mockReturnValue('11111111-1111-4111-8111-111111111111'); + Object.defineProperty(crypto, 'randomUUID', { + configurable: true, + value: randomUUID, + }); + + expect(createRandomId()).toBe('11111111-1111-4111-8111-111111111111'); + expect(randomUUID).toHaveBeenCalledTimes(1); + }); + + describe('without randomUUID (insecure context)', () => { + beforeEach(() => { + Object.defineProperty(crypto, 'randomUUID', { + configurable: true, + value: undefined, + }); + }); + + it('still returns a v4 uuid from getRandomValues', () => { + expect(createRandomId()).toMatch(UUID_V4); + }); + + it('returns unique ids', () => { + const ids = new Set( + Array.from({ length: 500 }, () => createRandomId()) + ); + + expect(ids.size).toBe(500); + }); + + it('sets the version and variant bits on all-zero randomness', () => { + jest.spyOn(crypto, 'getRandomValues').mockImplementation( + (buffer) => buffer + ); + + expect(createRandomId()).toBe( + '00000000-0000-4000-8000-000000000000' + ); + }); + + it('keeps the remaining bits from getRandomValues', () => { + jest.spyOn(crypto, 'getRandomValues').mockImplementation( + (buffer) => { + (buffer as Uint8Array).fill(0xff); + return buffer; + } + ); + + expect(createRandomId()).toBe( + 'ffffffff-ffff-4fff-bfff-ffffffffffff' + ); + }); + }); +}); diff --git a/libs/shared/interfaces/src/lib/random-id.util.ts b/libs/shared/interfaces/src/lib/random-id.util.ts new file mode 100644 index 000000000..fa0e78b2a --- /dev/null +++ b/libs/shared/interfaces/src/lib/random-id.util.ts @@ -0,0 +1,43 @@ +/** + * Random RFC 4122 v4 identifier used for playlist `_id` values, shared by the + * renderer, the Electron backend and the web backend. Pure function — no + * Angular/Node dependencies. + * + * `crypto.randomUUID()` is only exposed in secure contexts, and the + * self-hosted PWA is regularly served over plain http on a LAN address. So the + * fallback builds the same v4 shape from `crypto.getRandomValues()`, which + * insecure contexts do expose. This mirrors what `uuid`'s `v4()` did before it + * was dropped as a dependency. + */ + +const HEX_BY_BYTE = Array.from({ length: 256 }, (_unused, byte) => + byte.toString(16).padStart(2, '0') +); + +function randomUuidFromBytes(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + + // Version 4 and the RFC 4122 variant bits. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, (byte) => HEX_BY_BYTE[byte]); + + return [ + hex.slice(0, 4).join(''), + hex.slice(4, 6).join(''), + hex.slice(6, 8).join(''), + hex.slice(8, 10).join(''), + hex.slice(10, 16).join(''), + ].join('-'); +} + +/** Returns a lowercase RFC 4122 v4 UUID. */ +export function createRandomId(): string { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return randomUuidFromBytes(); +} diff --git a/libs/shared/m3u-utils/package.json b/libs/shared/m3u-utils/package.json index 221ae65e9..2771f45b4 100644 --- a/libs/shared/m3u-utils/package.json +++ b/libs/shared/m3u-utils/package.json @@ -7,7 +7,6 @@ "types": "./src/index.d.ts", "dependencies": { "@iptvnator/shared/interfaces": "0.0.1", - "tslib": "^2.3.0", - "uuid": "^9.0.0" + "tslib": "^2.3.0" } } diff --git a/libs/shared/m3u-utils/src/lib/playlist.utils.ts b/libs/shared/m3u-utils/src/lib/playlist.utils.ts index 1361d13a7..5688c077e 100644 --- a/libs/shared/m3u-utils/src/lib/playlist.utils.ts +++ b/libs/shared/m3u-utils/src/lib/playlist.utils.ts @@ -1,10 +1,10 @@ import { Channel, + createRandomId, ParsedPlaylist, ParsedPlaylistItem, Playlist, } from '@iptvnator/shared/interfaces'; -import { v4 as uuidv4 } from 'uuid'; import { extractDrmFromRaw } from './kodiprop.utils'; /** @@ -387,7 +387,7 @@ export const createPlaylistObject = ( }); return { - _id: uuidv4(), + _id: createRandomId(), filename: name, title: name, count: playlist.items.length, @@ -397,7 +397,7 @@ export const createPlaylistObject = ( const drm = extractDrmFromRaw(item.raw); return { ...item, - id: uuidv4(), + id: createRandomId(), ...(drm ? { drm } : {}), }; }), diff --git a/package.json b/package.json index 0228fc507..8fa71c67d 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,6 @@ "rxjs": "7.8.2", "saxes": "6.0.0", "shaka-player": "5.2.2", - "uuid": "9.0.0", "video.js": "8.23.9", "videojs-contrib-quality-levels": "4.1.0", "videojs-quality-selector-hls": "1.1.1", @@ -172,7 +171,6 @@ "@types/jest": "^30.0.0", "@types/mocha": "9.0.0", "@types/node": "20.19.9", - "@types/uuid": "^10.0.0", "@types/video.js": "7.3.58", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43dca8014..619e3b961 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,9 +154,6 @@ importers: shaka-player: specifier: 5.2.2 version: 5.2.2 - uuid: - specifier: 9.0.0 - version: 9.0.0 video.js: specifier: 8.23.9 version: 8.23.9 @@ -314,9 +311,6 @@ importers: '@types/node': specifier: 20.19.9 version: 20.19.9 - '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 '@types/video.js': specifier: 7.3.58 version: 7.3.58 @@ -4801,9 +4795,6 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@types/uuid@10.0.0': - resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} @@ -10323,7 +10314,6 @@ packages: shaka-player@5.2.2: resolution: {integrity: sha512-1Z5Sxqz7nEAtvp4zlZKAHQSEq6cUlw5CKAdUxqmuMNeZgu23090VXMcEDdiSLU5qtMn1skFOzq+KKCVX71bHqw==} - engines: {node: '>=18'} shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} @@ -11215,11 +11205,6 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true - uuid@9.0.0: - resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -13491,7 +13476,7 @@ snapshots: make-fetch-happen: 10.2.1 nopt: 6.0.0 proc-log: 2.0.1 - semver: 7.7.4 + semver: 7.8.5 tar: 6.2.1 which: 2.0.2 transitivePeerDependencies: @@ -15454,7 +15439,7 @@ snapshots: '@npmcli/fs@2.1.2': dependencies: '@gar/promisify': 1.1.3 - semver: 7.7.4 + semver: 7.8.5 '@npmcli/fs@5.0.0': dependencies: @@ -16795,8 +16780,6 @@ snapshots: '@types/unist@3.0.3': {} - '@types/uuid@10.0.0': {} - '@types/verror@1.10.11': optional: true @@ -25001,8 +24984,6 @@ snapshots: uuid@8.3.2: {} - uuid@9.0.0: {} - v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: From 08b868d6c1ea99c6e63d21a47dc4650352cb952c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:27:50 +0200 Subject: [PATCH 05/32] test(electron): harden runtime boundary coverage (#1267) Adds contract-focused regression coverage for the Electron HTTP server, remote-control events, settings events, and managed download paths, and makes Tier A coverage fail closed when instrumentation fails or a runtime-owning production file disappears from a project or from the merged Istanbul report. The old `coverage:ci` exited 0 despite a `Failed to collect coverage` diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged map. All 30 Tier A reports are now required, the merged map covers 710 files, and effects.ts is reported as 0/159 instead of silently disappearing. Also fixes remote static-file path containment for encoded, malformed, NUL, POSIX and Win32-style traversal inputs, with behavior-preserving testability seams. Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%, remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%. --- .changes/electron-remote-static-paths.md | 7 + .../events/database/downloads.events.spec.ts | 219 +- .../app/events/remote-control.events.spec.ts | 588 +++++ .../src/app/events/remote-control.events.ts | 2 +- .../src/app/events/settings.events.spec.ts | 195 +- .../src/app/server/http-server.spec.ts | 448 ++++ .../src/app/server/http-server.ts | 85 +- docs/architecture/remote-control.md | 129 +- docs/architecture/validation-map.md | 34 +- ...5-coverage-integrity-runtime-boundaries.md | 2003 +++++++++++++++++ ...age-integrity-runtime-boundaries-design.md | 447 ++++ libs/m3u-state/tsconfig.spec.json | 6 +- .../epg-timeline-scroll.controller.spec.ts | 61 + package.json | 3 +- tools/coverage/coverage-health.mjs | 171 +- tools/coverage/coverage-integrity.mjs | 579 +++++ tools/coverage/coverage-integrity.test.mjs | 904 ++++++++ tools/coverage/coverage-policy.json | 40 +- tools/coverage/merge-coverage.mjs | 27 +- tools/coverage/run-tier-a-coverage.mjs | 84 +- 20 files changed, 5866 insertions(+), 166 deletions(-) create mode 100644 .changes/electron-remote-static-paths.md create mode 100644 apps/electron-backend/src/app/events/remote-control.events.spec.ts create mode 100644 apps/electron-backend/src/app/server/http-server.spec.ts create mode 100644 docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md create mode 100644 docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md create mode 100644 tools/coverage/coverage-integrity.mjs create mode 100644 tools/coverage/coverage-integrity.test.mjs diff --git a/.changes/electron-remote-static-paths.md b/.changes/electron-remote-static-paths.md new file mode 100644 index 000000000..70313de33 --- /dev/null +++ b/.changes/electron-remote-static-paths.md @@ -0,0 +1,7 @@ +--- +type: fix +area: electron +--- + +The desktop remote-control server now blocks crafted static paths from escaping +bundled web files on Windows. diff --git a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts index ccced7e98..090eecabb 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts @@ -7,6 +7,20 @@ const mockBroadcastDownloadUpdate = jest.fn(); const mockRemovePartialDownloadFile = jest.fn(); const mockPauseDownload = jest.fn(); const mockResumeDownloadRequest = jest.fn(); +const mockExistsSync = jest.fn(); +const mockOpenPath = jest.fn(); +const mockShowItemInFolder = jest.fn(); +const mockEq = jest.fn(); +let downloadsFilePathColumn: unknown; + +const MANAGED_PATH_STATE = { + ERROR: 'error', + MANAGED: 'managed', + UNMANAGED: 'unmanaged', +} as const; + +type ManagedPathState = + (typeof MANAGED_PATH_STATE)[keyof typeof MANAGED_PATH_STATE]; function getHandler(channel: string): IpcHandler { const handler = mockRegisteredHandlers.get(channel); @@ -34,7 +48,24 @@ describe('downloads events', () => { mockRemovePartialDownloadFile.mockReset(); mockPauseDownload.mockReset(); mockResumeDownloadRequest.mockReset(); + mockExistsSync.mockReset(); + mockOpenPath.mockReset().mockResolvedValue(''); + mockShowItemInFolder.mockReset(); + mockEq.mockReset(); + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + existsSync: mockExistsSync, + })); + jest.doMock('drizzle-orm', () => { + const actual = + jest.requireActual('drizzle-orm'); + mockEq.mockImplementation(actual.eq); + return { + ...actual, + eq: mockEq, + }; + }); jest.doMock('electron', () => ({ app: { getPath: jest.fn((name: string) => @@ -50,8 +81,8 @@ describe('downloads events', () => { }), }, shell: { - openPath: jest.fn(), - showItemInFolder: jest.fn(), + openPath: mockOpenPath, + showItemInFolder: mockShowItemInFolder, }, })); jest.doMock('../../database/connection', () => ({ @@ -77,8 +108,45 @@ describe('downloads events', () => { })); await import('./downloads.events'); + const schema = await import('../../database/schema'); + downloadsFilePathColumn = schema.downloads.filePath; }); + function mockManagedPath(state: ManagedPathState) { + const limit = jest.fn(() => { + if (state === MANAGED_PATH_STATE.ERROR) { + return Promise.reject(new Error('database unavailable')); + } + return Promise.resolve( + state === MANAGED_PATH_STATE.MANAGED ? [{ id: 42 }] : [] + ); + }); + const where = jest.fn((_predicate: unknown) => ({ limit })); + const from = jest.fn(() => ({ where })); + const select = jest.fn(() => ({ from })); + const db = { select }; + mockGetDatabase.mockResolvedValue(db); + return { from, limit, select, where }; + } + + function expectManagedPathLookup( + lookup: ReturnType, + filePath: string + ) { + expect(mockGetDatabase).toHaveBeenCalledTimes(1); + expect(lookup.select).toHaveBeenCalledTimes(1); + expect(lookup.from).toHaveBeenCalledTimes(1); + expect(lookup.where).toHaveBeenCalledTimes(1); + expect(mockEq).toHaveBeenCalledTimes(1); + expect(mockEq.mock.calls[0][0] === downloadsFilePathColumn).toBe(true); + expect(mockEq.mock.calls[0][1]).toBe(filePath); + expect( + lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value + ).toBe(true); + expect(lookup.limit).toHaveBeenCalledTimes(1); + expect(lookup.limit).toHaveBeenCalledWith(1); + } + function mockDownloadRow(row: { filePath: string | null; status: string }) { const deleteWhere = jest.fn().mockResolvedValue(undefined); const db = { @@ -101,7 +169,9 @@ describe('downloads events', () => { const deleteWhere = jest.fn().mockResolvedValue(undefined); const selectWhere = jest .fn() - .mockResolvedValue(rows.map((row, index) => ({ id: index + 1, ...row }))); + .mockResolvedValue( + rows.map((row, index) => ({ id: index + 1, ...row })) + ); const db = { delete: jest.fn(() => ({ where: deleteWhere })), select: jest.fn(() => ({ @@ -117,9 +187,11 @@ describe('downloads events', () => { it('removes queued resumed partial files before deleting the row', async () => { const { deleteWhere } = mockDownloadRow(createDownloadRow('queued')); - await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({ - success: true, - }); + await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual( + { + success: true, + } + ); expect(mockRemoveDownloadFromRuntime).toHaveBeenCalledWith(42); expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith( @@ -127,7 +199,9 @@ describe('downloads events', () => { ); expect( mockRemovePartialDownloadFile.mock.invocationCallOrder[0] - ).toBeLessThan(mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0]); + ).toBeLessThan( + mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0] + ); expect( mockRemovePartialDownloadFile.mock.invocationCallOrder[0] ).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]); @@ -137,9 +211,11 @@ describe('downloads events', () => { it('removes completed partial files before deleting the row', async () => { const { deleteWhere } = mockDownloadRow(createDownloadRow('completed')); - await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({ - success: true, - }); + await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual( + { + success: true, + } + ); expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith( '/downloads/resume.mp4' @@ -328,4 +404,127 @@ describe('downloads events', () => { expect.anything() ); }); + + describe.each([ + { + channel: 'DOWNLOADS_REVEAL_FILE', + filePath: '/downloads/reveal-boundary.mp4', + operation: 'reveal', + }, + { + channel: 'DOWNLOADS_PLAY_FILE', + filePath: '/downloads/play-boundary.mp4', + operation: 'play', + }, + ])('$operation managed-path boundary', ({ channel, filePath }) => { + it('rejects an unmanaged database path before accessing the filesystem', async () => { + const lookup = mockManagedPath(MANAGED_PATH_STATE.UNMANAGED); + mockExistsSync.mockReturnValue(true); + + await expect(getHandler(channel)(null, filePath)).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockOpenPath).not.toHaveBeenCalled(); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + }); + + it('rejects a managed database path that is missing from disk', async () => { + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockExistsSync.mockReturnValue(false); + + await expect(getHandler(channel)(null, filePath)).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).toHaveBeenCalledTimes(1); + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockOpenPath).not.toHaveBeenCalled(); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + }); + + it('fails closed when the managed-path database query rejects', async () => { + const lookup = mockManagedPath(MANAGED_PATH_STATE.ERROR); + mockExistsSync.mockReturnValue(true); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + await expect( + getHandler(channel)(null, filePath) + ).resolves.toEqual({ + error: 'File not found', + success: false, + }); + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + 'Error verifying managed download path:', + expect.objectContaining({ + message: 'database unavailable', + }) + ); + } finally { + consoleError.mockRestore(); + } + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockOpenPath).not.toHaveBeenCalled(); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + }); + }); + + it('reveals a managed file that exists on disk', async () => { + const filePath = '/downloads/reveal-success.mp4'; + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockExistsSync.mockReturnValue(true); + + await expect( + getHandler('DOWNLOADS_REVEAL_FILE')(null, filePath) + ).resolves.toEqual({ success: true }); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).toHaveBeenCalledTimes(1); + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockShowItemInFolder).toHaveBeenCalledTimes(1); + expect(mockShowItemInFolder).toHaveBeenCalledWith(filePath); + expect(mockOpenPath).not.toHaveBeenCalled(); + }); + + it('waits for the native shell before reporting a managed file as played', async () => { + const filePath = '/downloads/play-success.mp4'; + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockExistsSync.mockReturnValue(true); + let resolveOpenPath!: (value: string) => void; + const openPathResult = new Promise((resolve) => { + resolveOpenPath = resolve; + }); + mockOpenPath.mockReturnValue(openPathResult); + + let responseSettled = false; + const response = getHandler('DOWNLOADS_PLAY_FILE')(null, filePath).then( + (result) => { + responseSettled = true; + return result; + } + ); + await new Promise((resolve) => setImmediate(resolve)); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).toHaveBeenCalledTimes(1); + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockOpenPath).toHaveBeenCalledTimes(1); + expect(mockOpenPath).toHaveBeenCalledWith(filePath); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + expect(responseSettled).toBe(false); + + resolveOpenPath(''); + await expect(response).resolves.toEqual({ success: true }); + }); }); diff --git a/apps/electron-backend/src/app/events/remote-control.events.spec.ts b/apps/electron-backend/src/app/events/remote-control.events.spec.ts new file mode 100644 index 000000000..67713fe93 --- /dev/null +++ b/apps/electron-backend/src/app/events/remote-control.events.spec.ts @@ -0,0 +1,588 @@ +import type * as http from 'node:http'; +import { PassThrough } from 'node:stream'; + +type HttpHandler = ( + request: http.IncomingMessage, + response: http.ServerResponse +) => void; +type IpcCallback = (...args: unknown[]) => unknown; + +interface RemoteControlSettings { + enabled: boolean; + port: number; +} + +interface ResponseSnapshot { + statusCode: number; + headers: http.OutgoingHttpHeaders; + body: string; +} + +interface ResponseRecorder { + response: http.ServerResponse; + completed: Promise; +} + +interface RequestInvocation { + response: ResponseSnapshot; + request: PassThrough; + requestClosed: Promise; + requestErrors: Error[]; +} + +interface InvokeOptions { + method: string; + body?: string | Buffer; +} + +const REMOTE_CONTROL_PATHS = { + STATUS: '/api/remote-control/status', + SELECT_NUMBER: '/api/remote-control/channel/select-number', + CHANNEL_UP: '/api/remote-control/channel/up', + CHANNEL_DOWN: '/api/remote-control/channel/down', + VOLUME_UP: '/api/remote-control/volume/up', + VOLUME_DOWN: '/api/remote-control/volume/down', + VOLUME_TOGGLE_MUTE: '/api/remote-control/volume/toggle-mute', +} as const; + +const JSON_HEADERS = { 'Content-Type': 'application/json' } as const; +const SUCCESS_RESPONSE = { + statusCode: 200, + headers: JSON_HEADERS, + body: JSON.stringify({ success: true }), +} as const; +const METHOD_NOT_ALLOWED_RESPONSE = { + statusCode: 405, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Method not allowed' }), +} as const; + +const mockHttpHandlers = new Map(); +const mockIpcHandlers = new Map(); +const mockIpcListeners = new Map(); +const mockRegisterRemoteControlHandler = jest.fn( + (path: string, handler: HttpHandler) => { + mockHttpHandlers.set(path, handler); + } +); +const mockStartHttpServer = jest.fn(); +const mockStoreGet = jest.fn(); +const mockFirstRendererSend = jest.fn(); +const mockSecondRendererSend = jest.fn(); +const mockGetAllWindows = jest.fn(); +const mockIpcHandle = jest.fn((channel: string, handler: IpcCallback) => { + mockIpcHandlers.set(channel, handler); +}); +const mockIpcOn = jest.fn((channel: string, listener: IpcCallback) => { + mockIpcListeners.set(channel, listener); +}); + +jest.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: mockGetAllWindows, + }, + ipcMain: { + handle: mockIpcHandle, + on: mockIpcOn, + }, +})); + +jest.mock('../server/http-server', () => ({ + httpServer: { + registerRemoteControlHandler: mockRegisterRemoteControlHandler, + start: mockStartHttpServer, + }, +})); + +jest.mock('../services/store.service', () => ({ + store: { + get: mockStoreGet, + }, +})); + +import { RemoteControlEvents } from './remote-control.events'; + +function createResponseRecorder(): ResponseRecorder { + let statusCode = 0; + let headers: http.OutgoingHttpHeaders = {}; + let response: http.ServerResponse; + let responseEnded = false; + let resolveCompleted: (snapshot: ResponseSnapshot) => void = () => { + throw new Error('Response completion promise is not initialized'); + }; + const completed = new Promise((resolve) => { + resolveCompleted = resolve; + }); + + response = { + writeHead: ( + nextStatusCode: number, + nextHeaders?: http.OutgoingHttpHeaders + ) => { + statusCode = nextStatusCode; + headers = { ...nextHeaders }; + return response; + }, + end: (chunk?: string | Uint8Array) => { + if (responseEnded) { + throw new Error('Response ended more than once'); + } + responseEnded = true; + const body = + typeof chunk === 'string' + ? chunk + : chunk + ? Buffer.from(chunk).toString('utf8') + : ''; + resolveCompleted({ statusCode, headers, body }); + return response; + }, + } as unknown as http.ServerResponse; + + return { response, completed }; +} + +function getHttpHandler(path: string): HttpHandler { + const handler = mockHttpHandlers.get(path); + if (!handler) { + throw new Error(`Expected HTTP handler for ${path}`); + } + + return handler; +} + +function getIpcHandler(channel: string): IpcCallback { + const handler = mockIpcHandlers.get(channel); + if (!handler) { + throw new Error(`Expected IPC handler for ${channel}`); + } + + return handler; +} + +function getIpcListener(channel: string): IpcCallback { + const listener = mockIpcListeners.get(channel); + if (!listener) { + throw new Error(`Expected IPC listener for ${channel}`); + } + + return listener; +} + +function bootstrapRemoteControl( + settings: RemoteControlSettings = { enabled: false, port: 8765 } +): RemoteControlEvents { + mockStoreGet.mockImplementation( + (key: string, fallbackValue: unknown): unknown => { + if (key === 'remoteControl') { + return settings.enabled; + } + if (key === 'remoteControlPort') { + return settings.port; + } + + return fallbackValue; + } + ); + + const events = new RemoteControlEvents(); + events.bootstrapRemoteControlEvents(); + return events; +} + +async function invokeHttpHandler( + path: string, + options: InvokeOptions +): Promise { + const request = new PassThrough(); + const requestErrors: Error[] = []; + request.on('error', (error: Error) => { + requestErrors.push(error); + }); + const requestClosed = new Promise((resolve) => { + request.once('close', resolve); + }); + const incomingMessage = Object.assign(request, { + method: options.method, + url: path, + }) as unknown as http.IncomingMessage; + const recorder = createResponseRecorder(); + + getHttpHandler(path)(incomingMessage, recorder.response); + request.end(options.body); + + return { + response: await recorder.completed, + request, + requestClosed, + requestErrors, + }; +} + +function createBodyAtByteLength(byteLength: number): string { + const prefix = '{"number":7,"padding":"'; + const suffix = '"}'; + const paddingLength = + byteLength - + Buffer.byteLength(prefix, 'utf8') - + Buffer.byteLength(suffix, 'utf8'); + if (paddingLength < 0) { + throw new Error(`Cannot create a JSON body at ${byteLength} bytes`); + } + + const body = `${prefix}${'x'.repeat(paddingLength)}${suffix}`; + if (Buffer.byteLength(body, 'utf8') !== byteLength) { + throw new Error(`Expected a ${byteLength}-byte JSON body`); + } + + return body; +} + +describe('RemoteControlEvents', () => { + let consoleLog: jest.SpyInstance; + let consoleWarn: jest.SpyInstance; + + beforeEach(() => { + mockHttpHandlers.clear(); + mockIpcHandlers.clear(); + mockIpcListeners.clear(); + mockRegisterRemoteControlHandler.mockClear(); + mockStartHttpServer.mockReset(); + mockStoreGet.mockReset(); + mockFirstRendererSend.mockReset(); + mockSecondRendererSend.mockReset(); + mockGetAllWindows.mockReset(); + mockIpcHandle.mockClear(); + mockIpcOn.mockClear(); + mockGetAllWindows.mockReturnValue([ + { webContents: { send: mockFirstRendererSend } }, + { webContents: { send: mockSecondRendererSend } }, + ]); + consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + consoleLog.mockRestore(); + consoleWarn.mockRestore(); + }); + + it('registers exactly the seven remote-control HTTP endpoints', () => { + bootstrapRemoteControl(); + + expect(mockRegisterRemoteControlHandler).toHaveBeenCalledTimes(7); + expect([...mockHttpHandlers.keys()].sort()).toEqual( + Object.values(REMOTE_CONTROL_PATHS).sort() + ); + }); + + it('starts the HTTP server once with the stored port when enabled', () => { + bootstrapRemoteControl({ enabled: true, port: 9987 }); + + expect(mockStoreGet).toHaveBeenNthCalledWith(1, 'remoteControl', false); + expect(mockStoreGet).toHaveBeenNthCalledWith( + 2, + 'remoteControlPort', + 8765 + ); + expect(mockStartHttpServer).toHaveBeenCalledTimes(1); + expect(mockStartHttpServer).toHaveBeenCalledWith(9987); + }); + + it('does not start the HTTP server when remote control is disabled', () => { + bootstrapRemoteControl({ enabled: false, port: 9987 }); + + expect(mockStartHttpServer).not.toHaveBeenCalled(); + }); + + it('registers and dispatches both main-app channel IPC handlers', () => { + bootstrapRemoteControl(); + + expect(mockIpcHandle).toHaveBeenCalledTimes(2); + expect([...mockIpcHandlers.keys()].sort()).toEqual([ + 'REMOTE_CONTROL_CHANNEL_DOWN', + 'REMOTE_CONTROL_CHANNEL_UP', + ]); + + const ipcEventPlaceholder = {}; + expect( + getIpcHandler('REMOTE_CONTROL_CHANNEL_UP')(ipcEventPlaceholder) + ).toBeUndefined(); + expect( + getIpcHandler('REMOTE_CONTROL_CHANNEL_DOWN')(ipcEventPlaceholder) + ).toBeUndefined(); + + expect(mockFirstRendererSend).toHaveBeenCalledTimes(2); + expect(mockFirstRendererSend).toHaveBeenNthCalledWith( + 1, + 'CHANNEL_CHANGE', + { direction: 'up' } + ); + expect(mockFirstRendererSend).toHaveBeenNthCalledWith( + 2, + 'CHANNEL_CHANGE', + { direction: 'down' } + ); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it.each([ + { + path: REMOTE_CONTROL_PATHS.CHANNEL_UP, + channel: 'CHANNEL_CHANGE', + payload: { direction: 'up' }, + }, + { + path: REMOTE_CONTROL_PATHS.CHANNEL_DOWN, + channel: 'CHANNEL_CHANGE', + payload: { direction: 'down' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_UP, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-up' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_DOWN, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-down' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_TOGGLE_MUTE, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-toggle-mute' }, + }, + ] as const)( + 'dispatches POST $path to the first renderer', + async ({ path, channel, payload }) => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler(path, { method: 'POST' }); + + expect(result.response).toEqual(SUCCESS_RESPONSE); + expect(mockFirstRendererSend).toHaveBeenCalledTimes(1); + expect(mockFirstRendererSend).toHaveBeenCalledWith( + channel, + payload + ); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + } + ); + + it.each([ + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + REMOTE_CONTROL_PATHS.CHANNEL_UP, + REMOTE_CONTROL_PATHS.CHANNEL_DOWN, + REMOTE_CONTROL_PATHS.VOLUME_UP, + REMOTE_CONTROL_PATHS.VOLUME_DOWN, + REMOTE_CONTROL_PATHS.VOLUME_TOGGLE_MUTE, + ])('rejects GET on POST-only endpoint %s', async (path) => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler(path, { method: 'GET' }); + + expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('rejects POST on the status endpoint', async () => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, { + method: 'POST', + }); + + expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('floors a finite positive channel number and dispatches it', async () => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body: JSON.stringify({ number: 7.9 }), + } + ); + + expect(result.response).toEqual(SUCCESS_RESPONSE); + expect(mockFirstRendererSend).toHaveBeenCalledWith( + 'REMOTE_CONTROL_COMMAND', + { + type: 'channel-select-number', + number: 7, + } + ); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it.each([ + { label: 'missing', body: '{}' }, + { label: 'zero', body: '{"number":0}' }, + { label: 'negative', body: '{"number":-2}' }, + { label: 'infinite', body: '{"number":1e309}' }, + { label: 'nonnumeric', body: '{"number":"seven"}' }, + ])('rejects a $label channel number', async ({ body }) => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body, + } + ); + + expect(result.response).toEqual({ + statusCode: 400, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Invalid channel number' }), + }); + expect(mockGetAllWindows).not.toHaveBeenCalled(); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('rejects malformed JSON without dispatching', async () => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body: '{"number":', + } + ); + + expect(result.response).toEqual({ + statusCode: 400, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Invalid JSON payload' }), + }); + expect(mockGetAllWindows).not.toHaveBeenCalled(); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('accepts a valid JSON request at the 10,240-byte body limit', async () => { + bootstrapRemoteControl(); + const body = createBodyAtByteLength(10 * 1024); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body, + } + ); + + expect(Buffer.byteLength(body, 'utf8')).toBe(10_240); + expect(result.response).toEqual(SUCCESS_RESPONSE); + expect(mockFirstRendererSend).toHaveBeenCalledWith( + 'REMOTE_CONTROL_COMMAND', + { + type: 'channel-select-number', + number: 7, + } + ); + }); + + it('rejects and destroys a 10,241-byte request without dispatching', async () => { + bootstrapRemoteControl(); + const body = Buffer.alloc(10 * 1024 + 1, 'x'); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body, + } + ); + await result.requestClosed; + + expect(body.byteLength).toBe(10_241); + expect(result.response).toEqual({ + statusCode: 413, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Payload too large' }), + }); + expect(result.request.destroyed).toBe(true); + expect(result.requestErrors).toEqual([]); + expect(mockGetAllWindows).not.toHaveBeenCalled(); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('merges partial status updates and refreshes updatedAt', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-25T10:00:00.000Z')); + bootstrapRemoteControl(); + const updateStatus = getIpcListener('REMOTE_CONTROL_STATUS_UPDATE'); + + jest.setSystemTime(new Date('2026-07-25T10:01:00.000Z')); + updateStatus( + {}, + { + portal: 'm3u', + isLiveView: true, + channelName: 'News', + volume: 35, + } + ); + jest.setSystemTime(new Date('2026-07-25T10:02:00.000Z')); + updateStatus({}, { channelName: 'Sports', muted: true }); + + const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, { + method: 'GET', + }); + + expect(result.response).toEqual({ + statusCode: 200, + headers: JSON_HEADERS, + body: JSON.stringify({ + portal: 'm3u', + isLiveView: true, + supportsVolume: false, + updatedAt: '2026-07-25T10:02:00.000Z', + channelName: 'Sports', + volume: 35, + muted: true, + }), + }); + }); + + it.each([ + { + path: REMOTE_CONTROL_PATHS.CHANNEL_UP, + warning: 'No browser windows found to send channel change', + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_UP, + warning: 'No browser windows found to send remote command', + }, + ])( + 'returns success and warns instead of throwing when $path has no renderer', + async ({ path, warning }) => { + mockGetAllWindows.mockReturnValue([]); + bootstrapRemoteControl(); + + await expect( + invokeHttpHandler(path, { method: 'POST' }) + ).resolves.toMatchObject({ + response: SUCCESS_RESPONSE, + }); + expect(consoleWarn).toHaveBeenCalledWith(warning); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + } + ); +}); diff --git a/apps/electron-backend/src/app/events/remote-control.events.ts b/apps/electron-backend/src/app/events/remote-control.events.ts index 366284ade..9f20d8bec 100644 --- a/apps/electron-backend/src/app/events/remote-control.events.ts +++ b/apps/electron-backend/src/app/events/remote-control.events.ts @@ -26,7 +26,7 @@ interface RemoteControlStatus { updatedAt?: string; } -class RemoteControlEvents { +export class RemoteControlEvents { private remoteControlStatus: RemoteControlStatus = { portal: 'unknown', isLiveView: false, diff --git a/apps/electron-backend/src/app/events/settings.events.spec.ts b/apps/electron-backend/src/app/events/settings.events.spec.ts index fa0825ea3..d115c0514 100644 --- a/apps/electron-backend/src/app/events/settings.events.spec.ts +++ b/apps/electron-backend/src/app/events/settings.events.spec.ts @@ -1,45 +1,196 @@ -const handlers = new Map unknown>(); +import type { Settings } from '@iptvnator/shared/interfaces'; + +type ExternalPlayerArgumentsSetting = string | readonly unknown[]; +type SettingsUpdatePayload = Omit< + Partial, + | 'embeddedMpvFrameCopy' + | 'language' + | 'mpvPlayerArguments' + | 'vlcPlayerArguments' +> & { + embeddedMpvFrameCopy?: boolean | number; + language?: string; + mpvPlayerArguments?: ExternalPlayerArgumentsSetting; + vlcPlayerArguments?: ExternalPlayerArgumentsSetting; +}; +type SettingsUpdateHandler = ( + event: unknown, + settings: SettingsUpdatePayload +) => unknown; + +const SETTINGS_UPDATE = 'SETTINGS_UPDATE'; +const STORE_KEYS = { + EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY', + MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS', + MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE', + VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS', + VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE', +} as const; + +const handlers = new Map(); +const mockStoreGet = jest.fn(); +const mockStoreSet = jest.fn(); +const mockUpdateSettings = jest.fn(); +const mockIpcHandle = jest.fn( + (channel: string, handler: SettingsUpdateHandler): void => { + handlers.set(channel, handler); + } +); jest.mock('electron', () => ({ ipcMain: { - handle: jest.fn( - (channel: string, handler: (...args: unknown[]) => unknown) => { - handlers.set(channel, handler); - } - ), + handle: mockIpcHandle, }, })); jest.mock('../services/store.service', () => ({ - MPV_PLAYER_ARGUMENTS: 'mpvPlayerArguments', - MPV_REUSE_INSTANCE: 'mpvReuseInstance', - VLC_PLAYER_ARGUMENTS: 'vlcPlayerArguments', - VLC_REUSE_INSTANCE: 'vlcReuseInstance', - store: { get: jest.fn(), set: jest.fn() }, + EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY, + MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS, + MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE, + VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS, + VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE, + store: { + get: mockStoreGet, + set: mockStoreSet, + }, })); jest.mock('../server/http-server', () => ({ - httpServer: { updateSettings: jest.fn() }, + httpServer: { + updateSettings: mockUpdateSettings, + }, })); -describe('SETTINGS_UPDATE logging', () => { +describe('SETTINGS_UPDATE', () => { + let consoleLogSpy: jest.SpiedFunction; + let settingsUpdateHandler: SettingsUpdateHandler; + beforeEach(async () => { - jest.spyOn(console, 'log').mockImplementation(() => undefined); + handlers.clear(); + jest.resetModules(); + mockIpcHandle.mockClear(); + mockStoreGet.mockReset(); + mockStoreSet.mockReset(); + mockUpdateSettings.mockReset(); + mockStoreGet.mockImplementation( + (_key: string, fallbackValue: unknown): unknown => fallbackValue + ); + consoleLogSpy = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + await import('./settings.events'); + + const registeredHandler = handlers.get(SETTINGS_UPDATE); + if (!registeredHandler) { + throw new Error(`Missing ipcMain handler for ${SETTINGS_UPDATE}`); + } + settingsUpdateHandler = registeredHandler; }); afterEach(() => { - jest.restoreAllMocks(); + jest.useRealTimers(); + consoleLogSpy.mockRestore(); + }); + + it('normalizes external-player arguments and preserves explicit false reuse settings', () => { + settingsUpdateHandler( + {}, + { + mpvPlayerArguments: [' --screen=1 ', '', ' --hwdec=auto-safe '], + mpvReuseInstance: false, + vlcPlayerArguments: + ' --network-caching=1000 \r\n\n --fullscreen ', + vlcReuseInstance: false, + } + ); + + expect(mockStoreSet.mock.calls).toEqual([ + [STORE_KEYS.MPV_PLAYER_ARGUMENTS, '--screen=1\n--hwdec=auto-safe'], + [ + STORE_KEYS.VLC_PLAYER_ARGUMENTS, + '--network-caching=1000\n--fullscreen', + ], + [STORE_KEYS.MPV_REUSE_INSTANCE, false], + [STORE_KEYS.VLC_REUSE_INSTANCE, false], + ]); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('does not persist or reconcile an empty settings update', () => { + settingsUpdateHandler({}, {}); + + expect(mockStoreGet).not.toHaveBeenCalled(); + expect(mockStoreSet).not.toHaveBeenCalled(); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('coerces an enabled embedded-MPV frame-copy value to boolean true', () => { + settingsUpdateHandler({}, { embeddedMpvFrameCopy: 1 }); + + expect(mockStoreSet).toHaveBeenCalledTimes(1); + expect(mockStoreSet).toHaveBeenCalledWith( + STORE_KEYS.EMBEDDED_MPV_FRAME_COPY, + true + ); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('reconciles an enabled remote-control update with the stored port', () => { + mockStoreGet.mockImplementation( + (key: string, fallbackValue: unknown): unknown => + key === 'remoteControlPort' ? 9988 : fallbackValue + ); + + settingsUpdateHandler({}, { remoteControl: true }); + + expect(mockStoreGet.mock.calls).toEqual([['remoteControlPort', 8765]]); + expect(mockStoreSet.mock.calls).toEqual([['remoteControl', true]]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9988); + }); + + it('reconciles a remote-control port update with the stored enabled state', () => { + mockStoreGet.mockImplementation( + (key: string, fallbackValue: unknown): unknown => + key === 'remoteControl' ? true : fallbackValue + ); + + settingsUpdateHandler({}, { remoteControlPort: 9123 }); + + expect(mockStoreGet.mock.calls).toEqual([['remoteControl', false]]); + expect(mockStoreSet.mock.calls).toEqual([['remoteControlPort', 9123]]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9123); + }); + + it('preserves explicit false and zero remote-control values', () => { + settingsUpdateHandler( + {}, + { remoteControl: false, remoteControlPort: 0 } + ); + + expect(mockStoreGet).not.toHaveBeenCalled(); + expect(mockStoreSet.mock.calls).toEqual([ + ['remoteControl', false], + ['remoteControlPort', 0], + ]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(false, 0); }); it('does not print a TMDB apiKey while retaining useful fields', () => { const apiKey = 'tmdb-settings-api-key-secret'; - const handler = handlers.get('SETTINGS_UPDATE'); - expect(handler).toBeDefined(); - handler?.({}, { language: 'de', tmdb: { apiKey, enabled: true } }); + settingsUpdateHandler( + {}, + { + language: 'de', + tmdb: { apiKey, enabled: true }, + } + ); - const output = JSON.stringify((console.log as jest.Mock).mock.calls); + const output = JSON.stringify(consoleLogSpy.mock.calls); expect(output).not.toContain(apiKey); expect(output).toContain('language'); expect(output).toContain('de'); @@ -49,10 +200,8 @@ describe('SETTINGS_UPDATE logging', () => { it('does not print credentials embedded in external player arguments', () => { const authorizationSecret = 'player-authorization-secret'; const cookieSecret = 'player-cookie-secret'; - const handler = handlers.get('SETTINGS_UPDATE'); - expect(handler).toBeDefined(); - handler?.( + settingsUpdateHandler( {}, { language: 'de', @@ -61,7 +210,7 @@ describe('SETTINGS_UPDATE logging', () => { } ); - const output = JSON.stringify((console.log as jest.Mock).mock.calls); + const output = JSON.stringify(consoleLogSpy.mock.calls); expect(output).not.toContain(authorizationSecret); expect(output).not.toContain(cookieSecret); expect(output).toContain('language'); diff --git a/apps/electron-backend/src/app/server/http-server.spec.ts b/apps/electron-backend/src/app/server/http-server.spec.ts new file mode 100644 index 000000000..88f3d8d38 --- /dev/null +++ b/apps/electron-backend/src/app/server/http-server.spec.ts @@ -0,0 +1,448 @@ +import { once } from 'node:events'; +import { + mkdirSync, + mkdtempSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, posix, win32 } from 'node:path'; + +import { HttpServer, resolveStaticFilePath } from './http-server'; + +interface CapturedServer { + listening: Promise; + listeningEvents: number; + server: http.Server; +} + +interface TestResponse { + body: Buffer; + contentType: string | undefined; + statusCode: number | undefined; +} + +const INDEX_BODY = Buffer.from('
IPTVnator remote control
'); +const ASSETS = { + '/app.js': { + body: Buffer.from('globalThis.remoteControlLoaded = true;'), + contentType: 'application/javascript', + }, + '/styles.css': { + body: Buffer.from('main { color: rebeccapurple; }'), + contentType: 'text/css', + }, + '/data.json': { + body: Buffer.from('{"ready":true}'), + contentType: 'application/json', + }, + '/asset.unknown': { + body: Buffer.from([0x00, 0xff, 0x49, 0x50, 0x54, 0x56]), + contentType: 'application/octet-stream', + }, +} as const; + +jest.setTimeout(15_000); + +function request(port: number, path: string): Promise { + return new Promise((resolve, reject) => { + const clientRequest = http.request( + { + host: '127.0.0.1', + method: 'GET', + path, + port, + }, + (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + response.on('end', () => { + const contentType = response.headers['content-type']; + resolve({ + body: Buffer.concat(chunks), + contentType: + typeof contentType === 'string' + ? contentType + : undefined, + statusCode: response.statusCode, + }); + }); + response.on('error', reject); + } + ); + + clientRequest.on('error', reject); + clientRequest.end(); + }); +} + +function getAddress(server: http.Server): AddressInfo { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the HTTP server to have an IP address'); + } + return address; +} + +async function closeServer(server: http.Server): Promise { + if (!server.listening) { + return; + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +async function getAvailablePort(): Promise { + const probe = http.createServer(); + + await new Promise((resolve, reject) => { + probe.once('error', reject); + probe.listen(0, '127.0.0.1', resolve); + }); + + const port = getAddress(probe).port; + await closeServer(probe); + return port; +} + +describe('resolveStaticFilePath', () => { + const POSIX_STATIC_ROOT = '/opt/iptvnator/remote-control'; + const WINDOWS_STATIC_ROOT = 'C:\\iptvnator\\remote-control'; + + it('rejects a Windows double-leading-slash traversal', () => { + expect( + resolveStaticFilePath( + WINDOWS_STATIC_ROOT, + '//../../outside-secret.txt', + win32 + ) + ).toBeNull(); + }); + + it('rejects encoded traversal segments after one decode', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/%2e%2e/outside-secret.txt', + posix + ) + ).toBeNull(); + }); + + it('fails closed for malformed percent encoding without throwing', () => { + expect( + resolveStaticFilePath(POSIX_STATIC_ROOT, '/%E0%A4%A', posix) + ).toBeNull(); + }); + + it('rejects decoded NUL bytes', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/assets/%00secret.js', + posix + ) + ).toBeNull(); + }); + + it('resolves a valid Windows-style asset inside the static root', () => { + expect( + resolveStaticFilePath( + WINDOWS_STATIC_ROOT, + '/assets\\app.js?version=1', + win32 + ) + ).toBe('C:\\iptvnator\\remote-control\\assets\\app.js'); + }); + + it('ignores query and fragment data for filesystem resolution', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/data.json?cache=1#ignored', + posix + ) + ).toBe('/opt/iptvnator/remote-control/data.json'); + }); + + it('decodes the URL pathname exactly once', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/%252e%252e/asset.js', + posix + ) + ).toBe('/opt/iptvnator/remote-control/%2e%2e/asset.js'); + }); +}); + +describe('HttpServer', () => { + let capturedServers: CapturedServer[]; + let distPath: string; + let server: HttpServer; + let tempRoot: string; + + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + + capturedServers = []; + tempRoot = mkdtempSync(join(tmpdir(), 'iptvnator-http-server-')); + distPath = join(tempRoot, 'static', 'app'); + mkdirSync(distPath, { recursive: true }); + writeFileSync(join(distPath, 'index.html'), INDEX_BODY); + + for (const [assetPath, asset] of Object.entries(ASSETS)) { + writeFileSync(join(distPath, assetPath), asset.body); + } + + writeFileSync( + join(tempRoot, 'outside-secret.txt'), + 'must-not-leave-the-static-directory' + ); + + server = new HttpServer({ + createServer: (requestListener) => { + const nodeServer = http.createServer(requestListener); + const captured: CapturedServer = { + listening: once(nodeServer, 'listening').then( + () => undefined + ), + listeningEvents: 0, + server: nodeServer, + }; + nodeServer.on('listening', () => { + captured.listeningEvents += 1; + }); + capturedServers.push(captured); + return nodeServer; + }, + distPath, + }); + }); + + afterEach(async () => { + let closeError: unknown; + + try { + await Promise.all( + capturedServers.map(({ server: nodeServer }) => + closeServer(nodeServer) + ) + ); + } catch (error) { + closeError = error; + } finally { + rmSync(tempRoot, { force: true, recursive: true }); + jest.restoreAllMocks(); + } + + if (closeError) { + throw closeError; + } + }); + + async function startServer(): Promise { + const serverIndex = capturedServers.length; + server.start(0); + const captured = capturedServers[serverIndex]; + if (!captured) { + throw new Error('Expected HttpServer.start() to create a server'); + } + + await captured.listening; + return captured; + } + + it('serves the exact index document at the root', async () => { + const captured = await startServer(); + + const response = await request(getAddress(captured.server).port, '/'); + + expect(response).toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); + + it('asks the operating system for an ephemeral port when started with zero', async () => { + const captured = await startServer(); + + expect(getAddress(captured.server).port).not.toBe(8765); + }); + + it.each(Object.entries(ASSETS))( + 'serves %s with its expected MIME type', + async (assetPath, asset) => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + assetPath + ); + + expect(response).toEqual({ + body: asset.body, + contentType: asset.contentType, + statusCode: 200, + }); + } + ); + + it('falls back to the exact index document for a client route', async () => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/channels/favorites' + ); + + expect(response).toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); + + it('returns a plain-text 404 when the index document is missing', async () => { + unlinkSync(join(distPath, 'index.html')); + const captured = await startServer(); + + const response = await request(getAddress(captured.server).port, '/'); + + expect(response).toEqual({ + body: Buffer.from('404 Not Found'), + contentType: 'text/plain', + statusCode: 404, + }); + }); + + it('dispatches a registered API request without serving a static file', async () => { + let receivedRequest: http.IncomingMessage | undefined; + server.registerRemoteControlHandler( + '/api/remote-control/status', + (incomingRequest, response) => { + receivedRequest = incomingRequest; + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end('{"source":"api-handler"}'); + } + ); + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/api/remote-control/status' + ); + + expect(receivedRequest?.url).toBe('/api/remote-control/status'); + expect(response).toEqual({ + body: Buffer.from('{"source":"api-handler"}'), + contentType: 'application/json', + statusCode: 200, + }); + expect(response.body).not.toEqual(INDEX_BODY); + }); + + it('returns a JSON 404 for an unknown remote-control API endpoint', async () => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/api/remote-control/missing' + ); + + expect(response).toEqual({ + body: Buffer.from('{"error":"Endpoint not found"}'), + contentType: 'application/json', + statusCode: 404, + }); + }); + + it.each([ + '/../../outside-secret.txt', + '//../../outside-secret.txt', + '/%2e%2e/%2e%2e/outside-secret.txt', + '/%E0%A4%A', + ])( + 'rejects the invalid static request target %s without serving an outside file', + async (requestTarget) => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + requestTarget + ); + + expect(response).toEqual({ + body: Buffer.from('404 Not Found'), + contentType: 'text/plain', + statusCode: 404, + }); + expect(response.body.toString()).not.toContain( + 'must-not-leave-the-static-directory' + ); + } + ); + + it('creates and listens on only one server when started twice', async () => { + server.start(0); + server.start(0); + + expect(capturedServers).toHaveLength(1); + await capturedServers[0].listening; + expect(capturedServers[0].listeningEvents).toBe(1); + }); + + it('stops the running server when disabled', async () => { + const captured = await startServer(); + const closed = once(captured.server, 'close'); + + server.updateSettings(false, 0); + await closed; + + expect(captured.server.listening).toBe(false); + expect(capturedServers).toHaveLength(1); + }); + + it('stops once and starts once on the new port when the port changes', async () => { + const first = await startServer(); + const newPort = await getAvailablePort(); + let closeEvents = 0; + first.server.on('close', () => { + closeEvents += 1; + }); + const firstClosed = once(first.server, 'close'); + + server.updateSettings(true, newPort); + const second = capturedServers[1]; + if (!second) { + throw new Error('Expected a replacement HTTP server'); + } + await Promise.all([firstClosed, second.listening]); + + expect(capturedServers).toHaveLength(2); + expect(closeEvents).toBe(1); + expect(first.server.listening).toBe(false); + expect(second.listeningEvents).toBe(1); + expect(getAddress(second.server).port).toBe(newPort); + await expect(request(newPort, '/')).resolves.toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); +}); diff --git a/apps/electron-backend/src/app/server/http-server.ts b/apps/electron-backend/src/app/server/http-server.ts index 23a235133..a6b24ce37 100644 --- a/apps/electron-backend/src/app/server/http-server.ts +++ b/apps/electron-backend/src/app/server/http-server.ts @@ -3,10 +3,61 @@ import * as fs from 'fs'; import * as http from 'http'; import * as path from 'path'; +type HttpServerFactory = (requestListener: http.RequestListener) => http.Server; + +interface HttpServerOptions { + createServer?: HttpServerFactory; + distPath?: string; +} + +/** + * Resolve an HTTP request target within the configured static root. + * The path implementation is injectable so platform-specific semantics remain testable. + */ +export function resolveStaticFilePath( + staticRoot: string, + requestTarget: string, + pathImplementation: path.PlatformPath = path +): string | null { + const separatorIndex = requestTarget.search(/[?#]/); + const encodedPathname = + separatorIndex === -1 + ? requestTarget + : requestTarget.slice(0, separatorIndex); + + let pathname: string; + try { + pathname = decodeURIComponent(encodedPathname); + } catch { + return null; + } + + if (pathname.includes('\0')) { + return null; + } + + const relativeCandidate = pathname.replace(/^[/\\]+/, '') || 'index.html'; + const resolvedRoot = pathImplementation.resolve(staticRoot); + const candidate = pathImplementation.resolve( + resolvedRoot, + relativeCandidate + ); + + if ( + candidate === resolvedRoot || + candidate.startsWith(`${resolvedRoot}${pathImplementation.sep}`) + ) { + return candidate; + } + + return null; +} + /** * HTTP server for serving the remote control web app and providing REST API endpoints */ export class HttpServer { + private readonly createServer: HttpServerFactory; private server: http.Server | null = null; private port = 8765; private isEnabled = false; @@ -16,6 +67,11 @@ export class HttpServer { (req: http.IncomingMessage, res: http.ServerResponse) => void > = new Map(); + constructor(options: HttpServerOptions = {}) { + this.createServer = options.createServer ?? http.createServer; + this.distPath = options.distPath ?? null; + } + /** * Get the path to the remote-control-web static files. * Lazily computed to avoid calling Electron APIs before app is ready. @@ -43,11 +99,7 @@ export class HttpServer { } else { // Production mode - files are bundled with the app // electron-builder copies remote-control-web/**/* directly to app root - this.distPath = path.join( - appPath, - 'remote-control-web', - 'browser' - ); + this.distPath = path.join(appPath, 'remote-control-web', 'browser'); } console.log('[HTTP Server] Serving from:', this.distPath); @@ -58,7 +110,7 @@ export class HttpServer { * Start the HTTP server */ start(port?: number): void { - if (port) { + if (port !== undefined) { this.port = port; } @@ -67,7 +119,7 @@ export class HttpServer { return; } - this.server = http.createServer((req, res) => { + this.server = this.createServer((req, res) => { this.handleRequest(req, res); }); @@ -153,18 +205,21 @@ export class HttpServer { * Serve static files */ private serveStaticFile(url: string, res: http.ServerResponse): void { - // Default to index.html for root path - let filePath = url === '/' ? '/index.html' : url; - - // Security: prevent directory traversal - filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, ''); - - const fullPath = path.join(this.getDistPath(), filePath); + const distPath = this.getDistPath(); + const fullPath = resolveStaticFilePath(distPath, url); + if (!fullPath) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('404 Not Found'); + return; + } fs.readFile(fullPath, (err, data) => { if (err) { // If file not found, try serving index.html (for Angular routing) - if (err.code === 'ENOENT' && filePath !== '/index.html') { + if ( + err.code === 'ENOENT' && + fullPath !== path.resolve(distPath, 'index.html') + ) { this.serveStaticFile('/', res); return; } diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 60b1b4993..f57609e37 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -28,11 +28,11 @@ Current capabilities: 1. User opens remote web UI (`http://:`). 2. Remote web app calls `/api/remote-control/*`. 3. Electron main handles API request and sends IPC to renderer: - - `CHANNEL_CHANGE` for up/down - - `REMOTE_CONTROL_COMMAND` for numeric/volume commands + - `CHANNEL_CHANGE` for up/down + - `REMOTE_CONTROL_COMMAND` for numeric/volume commands 4. Renderer-specific feature module (M3U/Xtream/Stalker) applies action. 5. Renderer pushes status snapshots back to main via: - - `REMOTE_CONTROL_STATUS_UPDATE` + - `REMOTE_CONTROL_STATUS_UPDATE` 6. Remote web app polls `/api/remote-control/status` and updates UI. ## Backend (Electron Main) @@ -41,11 +41,16 @@ Current capabilities: - File: `apps/electron-backend/src/app/server/http-server.ts` - Responsibilities: - - Serves static remote app from: - - dev: `dist/apps/remote-control-web/browser` - - prod: `/remote-control-web/browser` - - Routes `/api/remote-control/*` to registered handlers. - - Starts/stops/restarts on settings updates. + - Serves static remote app from: + - dev: `dist/apps/remote-control-web/browser` + - prod: `/remote-control-web/browser` + - Keeps every non-API request inside that configured static root: the request + pathname is decoded once, malformed encoding and NUL bytes fail closed, + and the platform-specific resolved path must remain the root or its + descendant. Angular route fallback may serve only that root's `index.html`; + it must never bypass the containment check. + - Routes `/api/remote-control/*` to registered handlers. + - Starts/stops/restarts on settings updates. ### Remote control event module @@ -66,8 +71,8 @@ IPC emitted to renderer: - `CHANNEL_CHANGE` payload: `{ direction: 'up' | 'down' }` - `REMOTE_CONTROL_COMMAND` payload: - - `{ type: 'channel-select-number', number }` - - `{ type: 'volume-up' | 'volume-down' | 'volume-toggle-mute' }` + - `{ type: 'channel-select-number', number }` + - `{ type: 'volume-up' | 'volume-down' | 'volume-toggle-mute' }` Status ingestion from renderer: @@ -78,7 +83,7 @@ Status ingestion from renderer: - Main handler: `apps/electron-backend/src/app/events/settings.events.ts` - On `SETTINGS_UPDATE`, reads `remoteControl` and `remoteControlPort`, persists to store, and calls: - - `httpServer.updateSettings(enabled, port)` + - `httpServer.updateSettings(enabled, port)` ## Preload Bridge @@ -122,23 +127,23 @@ Used by M3U, Xtream, and Stalker live integrations. Implemented behavior: - Subscribes to: - - `onChannelChange` (up/down) - - `onRemoteControlCommand` (number + volume) + - `onChannelChange` (up/down) + - `onRemoteControlCommand` (number + volume) - Applies channel up/down by active channel URL over `channels$` - Applies number select through existing `switchToChannelByNumber(...)` - Dispatches remote channel changes as explicit playback requests so MPV/VLC starts immediately even when mouse channel rows require double-click before external playback. - Applies volume commands: - - up/down in 0.1 increments - - toggle mute with last non-zero volume restore - - persists to `localStorage` - - propagates to built-in inline players: Video.js, HTML5, ArtPlayer, and radio `AudioPlayerComponent` - - does not control external MPV/VLC sessions or the experimental Embedded MPV player + - up/down in 0.1 increments + - toggle mute with last non-zero volume restore + - persists to `localStorage` + - propagates to built-in inline players: Video.js, HTML5, ArtPlayer, and radio `AudioPlayerComponent` + - does not control external MPV/VLC sessions or the experimental Embedded MPV player - Publishes status snapshots via `updateRemoteControlStatus(...)`: - - `portal: 'm3u'` - - `isLiveView: true` - - channel name/number - - EPG now fields - - `supportsVolume: true`, `volume`, `muted` + - `portal: 'm3u'` + - `isLiveView: true` + - channel name/number + - EPG now fields + - `supportsVolume: true`, `volume`, `muted` - Cleans listeners/subscriptions in `ngOnDestroy`. ## Xtream integration (live view) @@ -148,20 +153,20 @@ Implemented behavior: Implemented behavior: - Subscribes to: - - `onChannelChange` for up/down - - `onRemoteControlCommand` for number select + - `onChannelChange` for up/down + - `onRemoteControlCommand` for number select - Up/down: - - Uses selected live item `selectedItem().xtream_id` - - Navigates inside `selectItemsFromSelectedCategory()` - - Calls `playLive(nextItem, true)` so remote actions explicitly start playback + - Uses selected live item `selectedItem().xtream_id` + - Navigates inside `selectItemsFromSelectedCategory()` + - Calls `playLive(nextItem, true)` so remote actions explicitly start playback - Number select: - - Maps number to item in current category list - - Calls `playLive(channel, true)` so remote actions explicitly start playback + - Maps number to item in current category list + - Calls `playLive(channel, true)` so remote actions explicitly start playback - Publishes status via effect: - - `portal: 'xtream'` - - `isLiveView` only when selected content type is `live` and item is selected - - channel name/number + current EPG item - - `supportsVolume: false` + - `portal: 'xtream'` + - `isLiveView` only when selected content type is `live` and item is selected + - channel name/number + current EPG item + - `supportsVolume: false` - Cleans listeners in `ngOnDestroy`. ## Stalker integration (ITV live view) @@ -171,20 +176,20 @@ Implemented behavior: Implemented behavior: - Subscribes to: - - `onChannelChange` for up/down - - `onRemoteControlCommand` for number select + - `onChannelChange` for up/down + - `onRemoteControlCommand` for number select - Up/down: - - Uses `selectedItem().id` - - Navigates inside `itvChannels()` - - Calls `playChannel(nextItem, true)` so remote actions explicitly start playback + - Uses `selectedItem().id` + - Navigates inside `itvChannels()` + - Calls `playChannel(nextItem, true)` so remote actions explicitly start playback - Number select: - - Maps number into `itvChannels()` - - Calls `playChannel(channel, true)` so remote actions explicitly start playback + - Maps number into `itvChannels()` + - Calls `playChannel(channel, true)` so remote actions explicitly start playback - Publishes status via effect: - - `portal: 'stalker'` - - `isLiveView` only for selected content type `itv` with active item - - channel name/number + current EPG item - - `supportsVolume: false` + - `portal: 'stalker'` + - `isLiveView` only for selected content type `itv` with active item + - channel name/number + current EPG item + - `supportsVolume: false` - Cleans listeners in `ngOnDestroy`. ## Remote Web App @@ -199,11 +204,11 @@ Implemented behavior: ### Shared remote UI library - Component: - - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` - - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.html` - - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss` + - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` + - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.html` + - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss` - Service: - - `libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts` + - `libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts` Implemented UI behavior: @@ -217,23 +222,23 @@ Implemented UI behavior: ## Settings UI and discoverability - Files: - - `apps/web/src/app/settings/settings.component.ts` - - `apps/web/src/app/settings/settings.component.html` + - `apps/web/src/app/settings/settings.component.ts` + - `apps/web/src/app/settings/settings.component.html` - Features: - - Toggle `remoteControl` - - Configure `remoteControlPort` - - Display local URLs and QR codes for remote access - - Local IP list loaded via `getLocalIpAddresses()` + - Toggle `remoteControl` + - Configure `remoteControlPort` + - Display local URLs and QR codes for remote access + - Local IP list loaded via `getLocalIpAddresses()` ## Feature Matrix (Current) -| Capability | M3U | Xtream Live | Stalker ITV | -|---|---|---|---| -| Channel up/down | Yes | Yes | Yes | -| Number select | Yes | Yes | Yes | -| Status publish | Yes | Yes | Yes | -| Volume command handling | Yes, for built-in inline M3U players | No | No | -| `supportsVolume` in status | true | false | false | +| Capability | M3U | Xtream Live | Stalker ITV | +| -------------------------- | ------------------------------------ | ----------- | ----------- | +| Channel up/down | Yes | Yes | Yes | +| Number select | Yes | Yes | Yes | +| Status publish | Yes | Yes | Yes | +| Volume command handling | Yes, for built-in inline M3U players | No | No | +| `supportsVolume` in status | true | false | false | ## Known limitations diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 139fea90a..c41c042e9 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -55,11 +55,32 @@ Docs-only changes (Markdown, `docs/`, `.plans/`, `.codex/`, `.claude/`) and PRs no E2E validation runs in CI, which is intentional: they cannot affect app behavior. -| Tier | Rule | Validation | -| --- | --- | --- | -| A | Product/runtime Angular, Electron, backend, data-access, portal, playlist, workspace, playback, EPG, and shared UI code collects source coverage. | `pnpm run coverage:ci` | -| B | Validate behavior without percentage coverage, such as `website`, `packaging`, and Playwright E2E projects. | `pnpm nx test website`, `pnpm nx test packaging`, or the closest E2E target | -| C | Excluded from the source coverage baseline, such as mock servers, test helper libraries, and untested feature shells. | Validate through dependent flows, or add focused tests when changing behavior directly | +Tier A coverage is fail-closed. `coverage:unit:ci` relays Jest output but exits +nonzero on a `Failed to collect coverage` marker, a missing or invalid project +report, or a runtime-owning production TypeScript file absent from that report. +`coverage:merge` requires every configured Tier A report before replacing the +merged output. Strict health validation also requires the merged Istanbul map +itself to contain usable instrumentation for every runtime-owning Tier A file, +recomputes its summary, and then applies aggregate and selected critical-file +ratchets. + +Runtime-owning files are discovered from the TypeScript AST. Specs, +declarations, test setup and stubs, generated and environment files, `index.ts`, +type-only files, and pure re-export shims are excluded. Ratchets live under +`reporting.coverageRatchet` in `tools/coverage/coverage-policy.json`; update +them only from a fresh full `coverage:ci` report when every value stays level +or rises, and never lower one to accept a regression. The only exception is an +explicitly reviewed production source shrink: `minimumCovered` may follow a +lower total statement count when the PR documents the removed executable +statements and fresh coverage proves that the corresponding +`minimumPercent`, every aggregate ratchet, and the remaining behavioral +coverage do not decrease. + +| Tier | Rule | Validation | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| A | Product/runtime Angular, Electron, backend, data-access, portal, playlist, workspace, playback, EPG, and shared UI code collects source coverage. | `pnpm run coverage:ci` | +| B | Validate behavior without percentage coverage, such as `website`, `packaging`, and Playwright E2E projects. | `pnpm nx test website`, `pnpm nx test packaging`, or the closest E2E target | +| C | Excluded from the source coverage baseline, such as mock servers, test helper libraries, and untested feature shells. | Validate through dependent flows, or add focused tests when changing behavior directly | `apps/website` is an Astro marketing site. Its useful signal is a successful static build plus targeted output checks, not a merged code coverage percentage. @@ -69,9 +90,10 @@ Projects with a test target but no specs, such as `remote-control-web` and For local coverage inspection: ```bash +pnpm run coverage:tools:test pnpm run coverage:unit:ci pnpm run coverage:merge -pnpm run coverage:health +pnpm run coverage:health -- --require-report ``` The merged report is written to `coverage/merged/` as HTML, LCOV, Cobertura, diff --git a/docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md b/docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md new file mode 100644 index 000000000..9a2543990 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md @@ -0,0 +1,2003 @@ +# Coverage Integrity And Electron Runtime Boundaries Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Tier A coverage fail closed and add meaningful regression coverage for the HTTP server, remote control, settings, and download-file Electron boundaries. + +**Architecture:** A shared pure Node module classifies runtime-owning TypeScript, validates per-project Istanbul reports, scans child output for collection failures, and evaluates aggregate/per-file ratchets. Existing Electron contracts stay unchanged except for the TDD-backed rejection of a discovered static-path traversal escape; tests use real loopback/filesystem behavior where practical and mock only process, persistence, or native-shell boundaries. + +**Tech Stack:** Node.js ESM and `node:test`, TypeScript compiler API, Istanbul coverage maps, Jest/ts-jest, Electron main-process IPC, Node HTTP streams, Angular Jest configuration, Nx, Playwright. + +**Execution note (2026-07-26):** The branch was rebased onto +`origin/master` commit `7e8c2ccce16b71d72a64c45f1d96dd090dc8e077` +before the final ratchet run. A real-loopback HTTP regression exposed a Windows +double-leading-slash traversal escape; the implementation adds a tested pure +resolver and a user-facing Electron fix note. A final integrity follow-up also +validates the merged Istanbul map itself before evaluating its recomputed +summary and ratchets. + +--- + +## Scope And File Map + +Create: + +- `tools/coverage/coverage-integrity.mjs` — shared source classification, + report validation, output scanning, and ratchet evaluation. +- `tools/coverage/coverage-integrity.test.mjs` — deterministic Node tests using + temporary source/report fixtures. +- `apps/electron-backend/src/app/server/http-server.spec.ts` — real loopback + and temporary-directory HTTP server contracts. +- `apps/electron-backend/src/app/events/remote-control.events.spec.ts` — + registered HTTP handler, IPC, status, validation, and size-limit contracts. + +Modify: + +- `tools/coverage/run-tier-a-coverage.mjs` — stream child output, detect + collection failures, require complete project reports. +- `tools/coverage/merge-coverage.mjs` — reject missing/invalid Tier A inputs + before touching merged output. +- `tools/coverage/coverage-health.mjs` — independently validate completeness + and ratchets. +- `tools/coverage/coverage-policy.json` — store achieved aggregate and + critical-file ratchets. +- `package.json` — run coverage-tool unit tests inside `coverage:ci`. +- `libs/m3u-state/tsconfig.spec.json` — use bundler resolution and include the + shared Electron window declaration so `effects.ts` instruments. +- `apps/electron-backend/src/app/server/http-server.ts` — optional static-root + and server-factory seam, port `0` support for loopback tests, and a tested + static-root containment resolver. +- `apps/electron-backend/src/app/events/remote-control.events.ts` — export the + class for isolated instances while preserving the default singleton. +- `apps/electron-backend/src/app/events/settings.events.spec.ts` — persistence, + normalization, remote-control reconciliation, and redaction contracts. +- `apps/electron-backend/src/app/events/database/downloads.events.spec.ts` — + managed-path checks before native shell access. +- `docs/architecture/validation-map.md` — canonical fail-closed and ratchet + maintenance workflow. +- `docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md` + — retain the corrected Istanbul-rounded baseline values. + +Do not modify: + +- `apps/electron-backend/src/app/workers/database.worker.ts`; +- workspace dashboard or Stalker search production code; +- type-only/shared-interface source for percentage gain; +- `AGENTS.md` or `CLAUDE.md`, unless execution discovers that the implemented + commands contradict text already present there. + +### Task 0: Rebase And Bootstrap Immediately Before Implementation + +**Files:** + +- Verify only: `package.json` +- Verify only: `pnpm-lock.yaml` +- Verify only: `tools/coverage/coverage-policy.json` + +- [ ] **Step 1: Load the execution skills** + +Read completely before code changes: + +```text +using-superpowers +executing-plans +test-driven-development +electron-pro +typescript +angular-testing +iptvnator-nx-architecture +``` + +Use `systematic-debugging` before changing code in response to any unexpected +failure, and `verification-before-completion`, `release-notes`, and +`github-pr` during the final tasks. + +- [ ] **Step 2: Confirm a clean branch and rebase onto fresh master** + +Run: + +```bash +git status --short --branch +git fetch origin master --prune +git rebase origin/master +git status --short --branch +git log -2 --oneline --decorate +``` + +Expected: the branch is `agent/coverage-integrity-runtime-boundaries`, the +rebase exits 0, and only the committed design/plan are ahead of the latest +`origin/master`. If master changed a scoped source, spec, Jest config, or +coverage tool, re-run the baseline checks below and update this plan's numeric +seed before implementation rather than resolving by assumption. + +- [ ] **Step 3: Refresh locked dependencies and Nx discovery** + +Run: + +```bash +pnpm install --frozen-lockfile +pnpm nx show projects +pnpm nx show projects --withTarget test +``` + +Expected: all commands exit 0; the project list contains +`electron-backend`, `electron-backend-e2e`, and `m3u-state`; the lockfile stays +unchanged. + +- [ ] **Step 4: Reconfirm the fresh baseline** + +Run even when Step 2 reports no new master commits: + +```bash +NX_SKIP_NX_CACHE=true pnpm run coverage:ci +``` + +Expected on unchanged +`7e8c2ccce16b71d72a64c45f1d96dd090dc8e077`: the command exits 0 while printing +`Failed to collect coverage` for `libs/m3u-state/src/lib/effects.ts`, and the +30 project reports merge 709 files while omitting that runtime-owning source. +The aggregate remains 69.27/58.92/67.44/69.57, with HTTP and remote control at +0%, settings at 16 / 27 (59.25%), and downloads at 69 / 147 (46.93%). Record +the fresh aggregate and selected-file values. If master changed them, replace +the numeric ratchet seed in Task 3; do not lower a baseline already present on +newer master. + +### Task 1: Build And Unit-Test The Shared Coverage Integrity Module + +**Files:** + +- Create: `tools/coverage/coverage-integrity.test.mjs` +- Create: `tools/coverage/coverage-integrity.mjs` + +- [ ] **Step 1: Write the failing source-classification and output-scanner tests** + +Create `tools/coverage/coverage-integrity.test.mjs` with Node built-ins and +these contracts: + +```javascript +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { + createCoverageOutputScanner, + evaluateCoverageRatchets, + hasRuntimeOwnedStatement, + validateProjectCoverage, + validateRequiredProjectReports, +} from './coverage-integrity.mjs'; + +const temporaryRoots = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('hasRuntimeOwnedStatement', () => { + it('excludes type-only, import-only, and pure re-export source', () => { + assert.equal( + hasRuntimeOwnedStatement( + 'import type { Settings } from "./settings";' + ), + false + ); + assert.equal( + hasRuntimeOwnedStatement('interface Settings { enabled: boolean }'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('export type Mode = "live" | "vod";'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('export { value } from "./value";'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('declare const injected: string;'), + false + ); + }); + + it('includes emitted declarations and executable statements', () => { + assert.equal( + hasRuntimeOwnedStatement('export const enabled = true;'), + true + ); + assert.equal( + hasRuntimeOwnedStatement( + 'export class RuntimeBoundary { start() {} }' + ), + true + ); + assert.equal(hasRuntimeOwnedStatement('console.log("runtime");'), true); + }); +}); + +describe('createCoverageOutputScanner', () => { + it('detects an ANSI-colored marker split across chunks', () => { + const scanner = createCoverageOutputScanner(128); + + scanner.push('\u001b[31mFailed to collect'); + scanner.push(' coverage from /workspace/effects.ts\u001b[39m'); + + assert.equal(scanner.collectionFailed, true); + }); + + it('keeps only a bounded rolling tail', () => { + const scanner = createCoverageOutputScanner(32); + + scanner.push('x'.repeat(256)); + + assert.equal(scanner.tail.length, 32); + assert.equal(scanner.collectionFailed, false); + }); +}); +``` + +- [ ] **Step 2: Add failing temporary-report tests** + +In the same file, add helpers that write one runtime source plus a synthetic +Istanbul entry: + +```javascript +function makeProjectFixture() { + const workspaceRoot = mkdtempSync( + path.join(tmpdir(), 'iptvnator-coverage-integrity-') + ); + temporaryRoots.push(workspaceRoot); + const sourceRoot = 'libs/example/src'; + const projectRoot = 'libs/example'; + const sourcePath = path.join(workspaceRoot, sourceRoot, 'runtime.ts'); + const reportPath = path.join( + workspaceRoot, + 'coverage', + projectRoot, + 'coverage-final.json' + ); + + mkdirSync(path.dirname(sourcePath), { recursive: true }); + mkdirSync(path.dirname(reportPath), { recursive: true }); + writeFileSync(sourcePath, 'export const runtime = true;\n'); + + const project = { + name: 'example', + root: projectRoot, + sourceRoot, + }; + + return { project, reportPath, sourcePath, workspaceRoot }; +} + +function coverageEntry(filePath, hits = [0]) { + const statementMap = Object.fromEntries( + hits.map((_, index) => [ + index, + { + start: { line: index + 1, column: 0 }, + end: { line: index + 1, column: 28 }, + }, + ]) + ); + const statementHits = Object.fromEntries( + hits.map((hit, index) => [index, hit]) + ); + + return { + path: filePath, + statementMap, + fnMap: {}, + branchMap: {}, + s: statementHits, + f: {}, + b: {}, + }; +} + +describe('validateProjectCoverage', () => { + it('accepts a report containing every runtime-owning file', () => { + const fixture = makeProjectFixture(); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [fixture.sourcePath]: coverageEntry(fixture.sourcePath), + }) + ); + + const result = validateProjectCoverage(fixture); + + assert.deepEqual(result.errors, []); + assert.equal(result.report?.project.name, 'example'); + }); + + it('reports a runtime-owning source omitted from a valid report', () => { + const fixture = makeProjectFixture(); + writeFileSync(fixture.reportPath, '{}'); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /runtime\.ts/); + }); + + it('reports missing and invalid project reports', () => { + const missing = makeProjectFixture(); + const invalid = makeProjectFixture(); + writeFileSync(invalid.reportPath, '{ invalid json'); + + assert.match( + validateProjectCoverage(missing).errors[0], + /did not produce/ + ); + assert.match( + validateProjectCoverage(invalid).errors[0], + /invalid JSON/ + ); + }); + + it('requires every configured Tier A report', () => { + const fixture = makeProjectFixture(); + + const result = validateRequiredProjectReports({ + projects: [fixture.project], + workspaceRoot: fixture.workspaceRoot, + }); + + assert.equal(result.reports.length, 0); + assert.match(result.errors[0], /example/); + }); +}); +``` + +- [ ] **Step 3: Add failing aggregate and critical-file ratchet tests** + +Add: + +```javascript +describe('evaluateCoverageRatchets', () => { + it('reports aggregate percentage regression', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: { + statements: { covered: 68, total: 100, pct: 68 }, + branches: { covered: 57, total: 100, pct: 57 }, + functions: { covered: 67, total: 100, pct: 67 }, + lines: { covered: 69, total: 100, pct: 69 }, + }, + ratchet: { + merged: { + statements: 68.86, + branches: 58.66, + functions: 67.19, + lines: 69.2, + }, + criticalFiles: [], + }, + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 4); + assert.match(errors[0], /statements.*68.*68\.86/); + }); + + it('requires both covered statements and percentage for a critical file', () => { + const workspaceRoot = '/workspace'; + const filePath = path.join(workspaceRoot, 'apps/runtime.ts'); + const errors = evaluateCoverageRatchets({ + coverageData: { + [filePath]: coverageEntry(filePath, [1, 0]), + }, + mergedSummary: { + statements: { covered: 1, total: 1, pct: 100 }, + branches: { covered: 0, total: 0, pct: 100 }, + functions: { covered: 0, total: 0, pct: 100 }, + lines: { covered: 1, total: 1, pct: 100 }, + }, + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/runtime.ts', + statements: { + minimumCovered: 2, + minimumPercent: 75, + }, + }, + ], + }, + workspaceRoot, + }); + + assert.equal(errors.length, 2); + assert.match(errors[0], /apps\/runtime\.ts/); + }); + + it('reports a critical file missing from merged coverage', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: { + statements: { covered: 0, total: 0, pct: 100 }, + branches: { covered: 0, total: 0, pct: 100 }, + functions: { covered: 0, total: 0, pct: 100 }, + lines: { covered: 0, total: 0, pct: 100 }, + }, + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/missing.ts', + statements: { + minimumCovered: 1, + minimumPercent: 0, + }, + }, + ], + }, + workspaceRoot: '/workspace', + }); + + assert.match(errors[0], /missing from merged coverage/); + }); +}); +``` + +- [ ] **Step 4: Run the Node tests to verify RED** + +Run: + +```bash +node --test tools/coverage/coverage-integrity.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for +`tools/coverage/coverage-integrity.mjs`. + +- [ ] **Step 5: Implement source classification and the bounded scanner** + +Create `tools/coverage/coverage-integrity.mjs`. Use `unknown`-equivalent +runtime validation rather than trusting parsed JSON shapes. The core +classification and scanner must follow: + +```javascript +import { createRequire } from 'node:module'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); +const { createCoverageMap } = require('istanbul-lib-coverage'); + +export const COVERAGE_COLLECTION_FAILURE = 'Failed to collect coverage'; +const ANSI_ESCAPE = /\u001b\[[0-?]*[ -/]*[@-~]/g; + +function toPosix(filePath) { + return filePath.split(path.sep).join('/'); +} + +function hasDeclareModifier(statement) { + const modifiers = ts.canHaveModifiers(statement) + ? (ts.getModifiers(statement) ?? []) + : []; + return modifiers.some( + (modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword + ); +} + +export function hasRuntimeOwnedStatement(sourceText, fileName = 'source.ts') { + const source = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + return source.statements.some((statement) => { + if (hasDeclareModifier(statement)) { + return false; + } + return !( + ts.isImportDeclaration(statement) || + ts.isImportEqualsDeclaration(statement) || + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isExportDeclaration(statement) || + ts.isEmptyStatement(statement) + ); + }); +} + +export function createCoverageOutputScanner(maxCharacters = 4096) { + let tail = ''; + let collectionFailed = false; + + return { + get collectionFailed() { + return collectionFailed; + }, + get tail() { + return tail; + }, + push(chunk) { + tail = `${tail}${String(chunk).replace(ANSI_ESCAPE, '')}`.slice( + -maxCharacters + ); + if (tail.includes(COVERAGE_COLLECTION_FAILURE)) { + collectionFailed = true; + } + }, + }; +} +``` + +Add recursive enumeration with exactly these exclusions: + +```javascript +function isExcludedSource(filePath) { + const file = toPosix(filePath); + return ( + /\.(spec|test)\.ts$/.test(file) || + file.endsWith('.d.ts') || + file.endsWith('/test-setup.ts') || + file.includes('/test-stubs/') || + /\.generated\./.test(file) || + file.includes('/environments/') || + file.endsWith('/index.ts') + ); +} +``` + +`runtimeOwningSourceFiles(workspaceRoot, sourceRoot)` must recursively read +only `.ts` files, apply `isExcludedSource`, parse the file, and return sorted +absolute paths whose source has a runtime-owned statement. + +- [ ] **Step 6: Implement report validation** + +Implement these stable return shapes: + +```javascript +// validateProjectCoverage(...) +{ + errors: string[], + report: undefined | { + data: Record, + path: string, + project: object, + }, +} + +// validateRequiredProjectReports(...) +{ + errors: string[], + reports: Array<{ + data: Record, + path: string, + project: object, + }>, +} +``` + +`validateProjectCoverage({ workspaceRoot, project })` must: + +1. resolve `coverage//coverage-final.json`; +2. report a missing file using the project name and relative report path; +3. parse JSON and reject arrays, `null`, and invalid JSON; +4. normalize report keys with `path.resolve`; +5. compare them to `runtimeOwningSourceFiles`; and +6. emit one diagnostic per missing source, including project and relative + source path. + +`validateRequiredProjectReports` must call it for every project and preserve +policy order in `reports`. + +- [ ] **Step 7: Implement ratchet evaluation** + +`evaluateCoverageRatchets` must: + +- compare each merged metric with its numeric minimum; +- build an Istanbul map from `coverageData`; +- require every critical path to exist; +- obtain `fileCoverage.toSummary().toJSON().statements`; +- compare both `covered` and `pct`; and +- return diagnostics rather than exiting. + +Use messages of the form: + +```text +Merged statements coverage regressed: observed 68%, required at least 68.86%. +Critical coverage apps/runtime.ts statements covered regressed: observed 1, required at least 2. +Critical coverage apps/runtime.ts statements percent regressed: observed 50%, required at least 75%. +``` + +- [ ] **Step 8: Run the module tests to verify GREEN** + +Run: + +```bash +node --test tools/coverage/coverage-integrity.test.mjs +``` + +Expected: all classification, marker, report, and ratchet tests pass. + +- [ ] **Step 9: Commit the pure integrity module** + +Run: + +```bash +git add \ + tools/coverage/coverage-integrity.mjs \ + tools/coverage/coverage-integrity.test.mjs +git commit -m "test(coverage): add integrity primitives" +``` + +Expected: one commit containing only the pure module and its Node tests. + +### Task 2: Wire The Gate And Fix The Real `m3u-state` Instrumentation Failure + +**Files:** + +- Modify: `tools/coverage/run-tier-a-coverage.mjs` +- Modify: `tools/coverage/merge-coverage.mjs` +- Modify: `tools/coverage/coverage-health.mjs` +- Modify: `package.json` +- Modify: `libs/m3u-state/tsconfig.spec.json` + +- [ ] **Step 1: Replace synchronous inherited child output with streamed scanning** + +In `run-tier-a-coverage.mjs`, replace `spawnSync` with `spawn`, import +`createCoverageOutputScanner` and `validateProjectCoverage`, and add: + +```javascript +async function runCoverageProject(project) { + const args = buildNxArgs(project); + console.log(`\n==> Collecting coverage for ${project.name}`); + console.log(`pnpm ${args.join(' ')}`); + + const scanner = createCoverageOutputScanner(); + const child = spawn('pnpm', args, { + cwd: workspaceRoot, + env: { + ...process.env, + CI: process.env.CI ?? 'true', + NX_TASKS_RUNNER_DYNAMIC_OUTPUT: 'false', + }, + stdio: ['inherit', 'pipe', 'pipe'], + }); + + for (const [stream, destination] of [ + [child.stdout, process.stdout], + [child.stderr, process.stderr], + ]) { + stream.on('data', (chunk) => { + scanner.push(chunk); + destination.write(chunk); + }); + } + + const result = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + + if (result.code !== 0 || result.signal) { + return result.code ?? 1; + } + if (scanner.collectionFailed) { + console.error( + `Coverage collection failed while testing ${project.name}.` + ); + return 1; + } + + const validation = validateProjectCoverage({ + project, + workspaceRoot, + }); + for (const error of validation.errors) { + console.error(`Error: ${error}`); + } + return validation.errors.length === 0 ? 0 : 1; +} +``` + +Replace the final `spawnSync` loop with top-level awaited sequential calls. +Exit immediately after a nonzero project result so later projects cannot hide +the failed owner. + +- [ ] **Step 2: Make merge input fail closed before output mutation** + +In `merge-coverage.mjs`, remove the `.filter(existsSync)` construction. Call: + +```javascript +const validation = validateRequiredProjectReports({ + projects: policy.unitCoverage.tierA, + workspaceRoot, +}); + +if (validation.errors.length > 0) { + for (const error of validation.errors) { + console.error(`Error: ${error}`); + } + process.exit(1); +} + +const coverageInputs = validation.reports; +``` + +Perform this before `rmSync(outputDir, ...)`. Merge each `input.data` instead +of reopening its path. Log `coverageInputs.length`, which must be exactly 30 +on the current policy. + +- [ ] **Step 3: Add independent health validation** + +In `coverage-health.mjs`: + +- import `evaluateCoverageRatchets`, `validateMergedCoverage`, + `validateProjectCoverage`, and `validateRequiredProjectReports`; +- under `--require-report`, validate every Tier A report and append all + diagnostics to `errors`; +- without `--require-report`, validate only report files that actually exist, + treating invalid JSON or missing runtime source in a present report as an + error rather than printing 30 missing-report warnings; +- after loading merged `coverage-summary.json`, load and independently validate + merged `coverage-final.json` for every runtime-owning Tier A file; +- recompute the aggregate summary from the validated merged map, compare it + with the serialized summary, and evaluate + `policy.reporting.coverageRatchet` when the policy has one. + +Do not change existing source-root/spec ownership checks, E2E tag warnings, or +changed-file warnings. + +- [ ] **Step 4: Run targeted `m3u-state` coverage to verify the real RED** + +Run: + +```bash +node --test tools/coverage/coverage-integrity.test.mjs +pnpm run coverage:unit:ci -- --projects=m3u-state +``` + +Expected: + +- Node tests PASS. +- Targeted coverage exits nonzero after relaying the existing TypeScript + diagnostics and reports `Coverage collection failed while testing +m3u-state`. + +This is the real regression proof: before the runner wiring, the same Jest +failure exited 0. + +- [ ] **Step 5: Align the `m3u-state` spec compiler configuration** + +Replace `libs/m3u-state/tsconfig.spec.json` with: + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "preserve", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "bundler" + }, + "files": ["src/test-setup.ts", "../../global.d.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} +``` + +This matches the working Angular services test compiler mode, lets TypeScript +honor Angular Material package exports, and includes the shared +`Window.electron` contract. Do not add a local fake Material declaration to +`m3u-state`. + +- [ ] **Step 6: Add coverage-tool tests to `coverage:ci`** + +In `package.json`, add: + +```json +"coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs" +``` + +Change `coverage:ci` so it starts with: + +```json +"coverage:ci": "pnpm run coverage:tools:test && pnpm run coverage:policy:check && pnpm run coverage:unit:ci && pnpm run coverage:merge && node tools/coverage/coverage-health.mjs --require-report" +``` + +- [ ] **Step 7: Verify targeted instrumentation is GREEN and complete** + +Run: + +```bash +pnpm run coverage:tools:test +pnpm nx test m3u-state --skip-nx-cache --runInBand +pnpm run coverage:unit:ci -- --projects=m3u-state +node --input-type=module -e 'import fs from "node:fs"; import path from "node:path"; const report=JSON.parse(fs.readFileSync("coverage/libs/m3u-state/coverage-final.json","utf8")); const target=path.resolve("libs/m3u-state/src/lib/effects.ts"); if (!report[target]) throw new Error(`${target} missing`); console.log("effects.ts present");' +``` + +Expected: all commands exit 0, no `Failed to collect coverage` appears, and +the final command prints `effects.ts present`. + +- [ ] **Step 8: Commit the fail-closed runner and instrumentation fix** + +Run: + +```bash +git add \ + package.json \ + tools/coverage/run-tier-a-coverage.mjs \ + tools/coverage/merge-coverage.mjs \ + tools/coverage/coverage-health.mjs \ + libs/m3u-state/tsconfig.spec.json +git commit -m "fix(coverage): fail on incomplete instrumentation" +``` + +Expected: one commit with the gate wiring and the real `effects.ts` fix. + +### Task 3: Establish A Real Failing Critical-File Ratchet + +**Files:** + +- Modify but do not yet commit: + `tools/coverage/coverage-policy.json` + +- [ ] **Step 1: Add the provisional ratchet** + +Under `reporting`, add: + +```json +"coverageRatchet": { + "merged": { + "statements": 68.86, + "branches": 58.66, + "functions": 67.19, + "lines": 69.2 + }, + "criticalFiles": [ + { + "path": "apps/electron-backend/src/app/server/http-server.ts", + "statements": { + "minimumCovered": 1, + "minimumPercent": 0 + } + }, + { + "path": "apps/electron-backend/src/app/events/remote-control.events.ts", + "statements": { + "minimumCovered": 1, + "minimumPercent": 0 + } + }, + { + "path": "apps/electron-backend/src/app/events/settings.events.ts", + "statements": { + "minimumCovered": 17, + "minimumPercent": 59.25 + } + }, + { + "path": "apps/electron-backend/src/app/events/database/downloads.events.ts", + "statements": { + "minimumCovered": 70, + "minimumPercent": 46.93 + } + } + ] +} +``` + +The existing files use their fresh master percentage and require one +additional covered statement. The two 0% files require one covered statement. +These are minimal progress probes, not final thresholds. + +- [ ] **Step 2: Verify the critical acceptance test is RED** + +Use the fresh baseline reports from Task 0: + +```bash +pnpm run coverage:merge +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: merge succeeds with exactly 30 reports; health exits nonzero and +names all selected files still below the provisional ratchet. In particular, +`http-server.ts` and `remote-control.events.ts` report zero covered +statements. + +- [ ] **Step 3: Keep the provisional policy change unstaged** + +Run: + +```bash +git status --short +``` + +Expected: only `tools/coverage/coverage-policy.json` is intentionally dirty. +Do not stage it in Tasks 4–7; it remains the real red acceptance test until +the behavioral suites raise the observed values. + +### Task 4: Cover The HTTP Server Through Real Loopback And Filesystem Contracts + +**Files:** + +- Create: `apps/electron-backend/src/app/server/http-server.spec.ts` +- Modify: `apps/electron-backend/src/app/server/http-server.ts` + +- [ ] **Step 1: Write the complete HTTP contract suite before the seam exists** + +The spec must: + +- create a temporary static directory with `index.html`, `app.js`, + `styles.css`, `data.json`, and an unknown-extension asset; +- inject a real `http.createServer` wrapper that captures the Node server; +- call `start(0)`, await `listening`, and request + `127.0.0.1:`; +- stop and remove the temporary tree in `afterEach`. + +Use these test helpers: + +```typescript +interface HttpResponseSnapshot { + body: Buffer; + contentType: string | undefined; + statusCode: number; +} + +function request( + port: number, + requestPath: string, + method = 'GET' +): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: '127.0.0.1', + method, + path: requestPath, + port, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => + resolve({ + body: Buffer.concat(chunks), + contentType: res.headers['content-type'], + statusCode: res.statusCode ?? 0, + }) + ); + } + ); + req.once('error', reject); + req.end(); + }); +} +``` + +Add assertions for: + +1. `/` returns the exact index body as `text/html`; +2. `.js`, `.css`, `.json`, and an unknown extension return the expected MIME; +3. a client route falls back to the exact index body; +4. missing index returns plain-text 404; +5. a registered API handler receives the request and bypasses static serving; +6. unknown `/api/remote-control/...` returns JSON 404; +7. `/../../outside-secret.txt` never returns an outside file; +8. duplicate `start` creates/listens once; +9. disable stops the server; and +10. an enabled port change stops then starts once with the new port. + +- [ ] **Step 2: Run the focused spec to verify RED** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=http-server.spec.ts \ + --runInBand +``` + +Expected: FAIL at TypeScript compilation because `HttpServer` does not yet +accept the injected `distPath` and `createServer` options, and `start(0)` +currently ignores port zero. + +- [ ] **Step 3: Add the minimal constructor seam** + +In `http-server.ts`, add flat types: + +```typescript +type HttpServerFactory = (requestListener: http.RequestListener) => http.Server; + +interface HttpServerOptions { + createServer?: HttpServerFactory; + distPath?: string; +} +``` + +Add a factory field and constructor: + +```typescript +private readonly createServer: HttpServerFactory; + +constructor(options: HttpServerOptions = {}) { + this.createServer = + options.createServer ?? + ((requestListener) => http.createServer(requestListener)); + this.distPath = options.distPath ?? null; +} +``` + +Replace: + +```typescript +if (port) { +``` + +with: + +```typescript +if (port !== undefined) { +``` + +and replace the direct `http.createServer` call with `this.createServer`. +Leave the production singleton as: + +```typescript +export const httpServer = new HttpServer(); +``` + +No production caller passes port zero or constructor options, so normal +runtime behavior stays unchanged. + +- [ ] **Step 4: Run HTTP tests and Electron coverage** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=http-server.spec.ts \ + --runInBand +pnpm run coverage:unit:ci -- --projects=electron-backend +pnpm run coverage:merge +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: + +- the focused suite PASSes; +- `http-server.ts` is nonzero; +- health remains RED only for the not-yet-expanded selected boundaries and any + aggregate dip caused by adding `effects.ts`. + +- [ ] **Step 5: Commit only the HTTP slice** + +Run: + +```bash +git add \ + apps/electron-backend/src/app/server/http-server.ts \ + apps/electron-backend/src/app/server/http-server.spec.ts +git commit -m "test(electron): cover remote HTTP server" +``` + +Expected: the provisional policy remains unstaged. + +### Task 5: Cover Remote-Control HTTP, IPC, And Renderer Dispatch + +**Files:** + +- Create: + `apps/electron-backend/src/app/events/remote-control.events.spec.ts` +- Modify: + `apps/electron-backend/src/app/events/remote-control.events.ts` + +- [ ] **Step 1: Write the isolated remote-control suite** + +Mock only: + +- `BrowserWindow.getAllWindows`; +- `ipcMain.handle` and `ipcMain.on`; +- `httpServer.registerRemoteControlHandler` and `httpServer.start`; and +- `store.get`. + +Import a named `RemoteControlEvents` class and instantiate it in each test. +Capture registered handlers in maps. Use a `PassThrough` request for JSON body +tests and a response recorder with typed `writeHead`/`end` methods. + +Use this complete exchange helper: + +```typescript +interface ResponseSnapshot { + body: string; + headers: Record; + statusCode: number; +} + +function createExchange(method: string) { + const requestStream = new PassThrough(); + Object.defineProperty(requestStream, 'method', { value: method }); + + let resolveResponse!: (snapshot: ResponseSnapshot) => void; + const snapshot: ResponseSnapshot = { + body: '', + headers: {}, + statusCode: 0, + }; + const completed = new Promise((resolve) => { + resolveResponse = resolve; + }); + const response = { + writeHead(statusCode: number, headers: Record) { + snapshot.statusCode = statusCode; + snapshot.headers = headers; + return response; + }, + end(body?: string) { + snapshot.body = body ?? ''; + resolveResponse(snapshot); + return response; + }, + }; + + return { + completed, + request: requestStream as unknown as http.IncomingMessage, + requestStream, + response: response as unknown as http.ServerResponse, + }; +} + +function getHttpHandler(path: string) { + const handler = mockRegisteredHttpHandlers.get(path); + if (!handler) { + throw new Error(`Expected remote-control HTTP handler for ${path}`); + } + return handler; +} +``` + +Add exact contract cases: + +```typescript +it.each([ + ['/api/remote-control/channel/up', 'CHANNEL_CHANGE', { direction: 'up' }], + [ + '/api/remote-control/channel/down', + 'CHANNEL_CHANGE', + { direction: 'down' }, + ], + [ + '/api/remote-control/volume/up', + 'REMOTE_CONTROL_COMMAND', + { type: 'volume-up' }, + ], + [ + '/api/remote-control/volume/down', + 'REMOTE_CONTROL_COMMAND', + { type: 'volume-down' }, + ], + [ + '/api/remote-control/volume/toggle-mute', + 'REMOTE_CONTROL_COMMAND', + { type: 'volume-toggle-mute' }, + ], +] as const)( + 'dispatches POST %s to the first renderer', + async (path, channel, payload) => { + const exchange = createExchange('POST'); + + getHttpHandler(path)(exchange.request, exchange.response); + exchange.requestStream.end(); + const response = await exchange.completed; + + expect(response.statusCode).toBe(200); + expect(response.body).toBe(JSON.stringify({ success: true })); + expect(mockWebContentsSend).toHaveBeenCalledWith(channel, payload); + } +); +``` + +Also test: + +- all seven endpoint paths are registered; +- stored enabled/port starts HTTP once; disabled does not; +- GET on POST-only handlers and POST on status return 405 without dispatch; +- select-number accepts `7.9` and sends integer `7`; +- missing, zero, negative, `Infinity`, and nonnumeric values return 400; +- malformed JSON returns 400 and sends nothing; +- a 10,241-byte body returns 413, destroys the request, and sends nothing; +- partial status IPC updates merge with previous state and refresh + `updatedAt`; +- GET status returns that merged state; and +- no BrowserWindow logs a warning but does not throw. + +- [ ] **Step 2: Run the focused spec to verify RED** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=remote-control.events.spec.ts \ + --runInBand +``` + +Expected: FAIL because `RemoteControlEvents` is not exported as a named class. + +- [ ] **Step 3: Export the existing class without changing its singleton** + +Change: + +```typescript +class RemoteControlEvents { +``` + +to: + +```typescript +export class RemoteControlEvents { +``` + +Keep: + +```typescript +export default new RemoteControlEvents(); +``` + +Do not change endpoint names, methods, payloads, body limit, stored defaults, +or renderer channels unless a correct regression assertion exposes an actual +defect. If that happens, invoke `systematic-debugging`, keep the failing test, +and document the separately justified behavior fix. + +- [ ] **Step 4: Verify remote-control behavior and coverage** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=remote-control.events.spec.ts \ + --runInBand +pnpm run coverage:unit:ci -- --projects=electron-backend +pnpm run coverage:merge +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: the focused suite PASSes, `remote-control.events.ts` is nonzero, and +health no longer reports either formerly 0% selected file. + +- [ ] **Step 5: Commit only the remote-control slice** + +Run: + +```bash +git add \ + apps/electron-backend/src/app/events/remote-control.events.ts \ + apps/electron-backend/src/app/events/remote-control.events.spec.ts +git commit -m "test(electron): cover remote control events" +``` + +Expected: the provisional policy remains unstaged. + +### Task 6: Expand Settings Runtime Reconciliation Coverage + +**Files:** + +- Modify: + `apps/electron-backend/src/app/events/settings.events.spec.ts` +- Verify only: + `apps/electron-backend/src/app/events/settings.events.ts` + +- [ ] **Step 1: Make the existing test harness deterministic** + +Keep real `normalizeExternalPlayerArguments`. Add stable mocks: + +```typescript +type SettingsUpdateHandler = ( + event: unknown, + settings: Record +) => void; + +const mockStoreGet = jest.fn(); +const mockStoreSet = jest.fn(); +const mockUpdateSettings = jest.fn(); +let handler: SettingsUpdateHandler; +``` + +Include every imported key in the store mock: + +```typescript +EMBEDDED_MPV_FRAME_COPY: 'embeddedMpvFrameCopy', +MPV_PLAYER_ARGUMENTS: 'mpvPlayerArguments', +MPV_REUSE_INSTANCE: 'mpvReuseInstance', +VLC_PLAYER_ARGUMENTS: 'vlcPlayerArguments', +VLC_REUSE_INSTANCE: 'vlcReuseInstance', +store: { + get: mockStoreGet, + set: mockStoreSet, +}, +``` + +In `beforeEach`, clear the handler map, reset modules and mocks, silence +`console.log`, import `./settings.events`, and retrieve the registered +`SETTINGS_UPDATE` handler. Preserve both existing credential-redaction tests. + +- [ ] **Step 2: Confirm the provisional settings ratchet is RED before new cases** + +Run: + +```bash +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: settings reports fewer than the provisional 17 covered statements or +less than its retained 59.25% floor. + +- [ ] **Step 3: Add normalization and defined-only persistence assertions** + +Add: + +```typescript +it('normalizes player arguments and preserves explicit false reuse flags', () => { + handler( + {}, + { + mpvPlayerArguments: [' --screen=1 ', '', ' --hwdec=auto-safe '], + mpvReuseInstance: false, + vlcPlayerArguments: ' --fullscreen \n\n --no-video-title-show ', + vlcReuseInstance: false, + } + ); + + expect(mockStoreSet).toHaveBeenCalledWith( + 'mpvPlayerArguments', + '--screen=1\n--hwdec=auto-safe' + ); + expect(mockStoreSet).toHaveBeenCalledWith('mpvReuseInstance', false); + expect(mockStoreSet).toHaveBeenCalledWith( + 'vlcPlayerArguments', + '--fullscreen\n--no-video-title-show' + ); + expect(mockStoreSet).toHaveBeenCalledWith('vlcReuseInstance', false); +}); + +it('does not persist undefined settings', () => { + handler({}, {}); + + expect(mockStoreSet).not.toHaveBeenCalled(); + expect(mockUpdateSettings).not.toHaveBeenCalled(); +}); + +it('coerces the frame-copy startup preference to boolean', () => { + handler({}, { embeddedMpvFrameCopy: 1 }); + + expect(mockStoreSet).toHaveBeenCalledWith('embeddedMpvFrameCopy', true); +}); +``` + +- [ ] **Step 4: Add partial remote-control reconciliation assertions** + +Add two tests: + +```typescript +it('uses the stored port when only remote-control enabled changes', () => { + mockStoreGet.mockImplementation((key: string) => + key === 'remoteControlPort' ? 9988 : undefined + ); + + handler({}, { remoteControl: true }); + + expect(mockStoreSet).toHaveBeenCalledWith('remoteControl', true); + expect(mockStoreSet).not.toHaveBeenCalledWith( + 'remoteControlPort', + expect.anything() + ); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9988); +}); + +it('uses stored enabled state when only the remote-control port changes', () => { + mockStoreGet.mockImplementation((key: string) => + key === 'remoteControl' ? true : undefined + ); + + handler({}, { remoteControlPort: 9123 }); + + expect(mockStoreSet).toHaveBeenCalledWith('remoteControlPort', 9123); + expect(mockStoreSet).not.toHaveBeenCalledWith( + 'remoteControl', + expect.anything() + ); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9123); +}); +``` + +- [ ] **Step 5: Run settings tests and refresh Electron coverage** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=settings.events.spec.ts \ + --runInBand +pnpm run coverage:unit:ci -- --projects=electron-backend +pnpm run coverage:merge +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: all settings tests PASS; settings covered statements and percentage +are above the fresh master baseline. These are characterization tests for +unchanged behavior, so no production edit is expected. + +- [ ] **Step 6: Commit only the settings spec** + +Run: + +```bash +git add apps/electron-backend/src/app/events/settings.events.spec.ts +git commit -m "test(electron): cover settings runtime updates" +``` + +Expected: the provisional policy remains unstaged. + +### Task 7: Expand Download Managed-Path Security Coverage + +**Files:** + +- Modify: + `apps/electron-backend/src/app/events/database/downloads.events.spec.ts` +- Verify only: + `apps/electron-backend/src/app/events/database/downloads.events.ts` + +- [ ] **Step 1: Add filesystem and shell spies to the existing harness** + +Add: + +```typescript +const mockExistsSync = jest.fn(); +const mockOpenPath = jest.fn(); +const mockShowItemInFolder = jest.fn(); +``` + +Return the two shell spies from the existing Electron mock. Reset them in +`beforeEach`; make `mockOpenPath` resolve an empty string by default. +Before importing `downloads.events`, register: + +```typescript +jest.doMock('node:fs', () => ({ + existsSync: mockExistsSync, +})); +``` + +Add a database helper whose `limit(1)` returns either `[{ id: 42 }]`, `[]`, or +rejects: + +```typescript +function mockManagedPath(result: 'managed' | 'unmanaged' | 'error') { + const limit = + result === 'error' + ? jest.fn().mockRejectedValue(new Error('database unavailable')) + : jest + .fn() + .mockResolvedValue(result === 'managed' ? [{ id: 42 }] : []); + mockGetDatabase.mockResolvedValue({ + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })), + }); +} +``` + +- [ ] **Step 2: Confirm the provisional downloads ratchet is RED** + +Run: + +```bash +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: downloads is below the provisional 70-covered-statement target or +its retained 46.93% floor. + +- [ ] **Step 3: Add the managed-path rejection cases** + +For both `DOWNLOADS_REVEAL_FILE` and `DOWNLOADS_PLAY_FILE`, assert: + +```typescript +it.each(['DOWNLOADS_REVEAL_FILE', 'DOWNLOADS_PLAY_FILE'])( + '%s rejects a path not owned by a download row', + async (channel) => { + mockManagedPath('unmanaged'); + mockExistsSync.mockReturnValue(true); + + await expect( + getHandler(channel)(null, '/tmp/unmanaged.mp4') + ).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + expect(mockOpenPath).not.toHaveBeenCalled(); + } +); + +it.each(['DOWNLOADS_REVEAL_FILE', 'DOWNLOADS_PLAY_FILE'])( + '%s rejects a managed path missing on disk', + async (channel) => { + mockManagedPath('managed'); + mockExistsSync.mockReturnValue(false); + + await expect( + getHandler(channel)(null, '/downloads/missing.mp4') + ).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + expect(mockOpenPath).not.toHaveBeenCalled(); + } +); +``` + +- [ ] **Step 4: Add the authorized shell and database-failure cases** + +Add one reveal success, one play success, and a parameterized database failure: + +```typescript +it('reveals only a managed file that exists', async () => { + mockManagedPath('managed'); + mockExistsSync.mockReturnValue(true); + + await expect( + getHandler('DOWNLOADS_REVEAL_FILE')(null, '/downloads/movie.mp4') + ).resolves.toEqual({ success: true }); + + expect(mockShowItemInFolder).toHaveBeenCalledWith('/downloads/movie.mp4'); +}); + +it('opens only a managed file that exists', async () => { + mockManagedPath('managed'); + mockExistsSync.mockReturnValue(true); + + await expect( + getHandler('DOWNLOADS_PLAY_FILE')(null, '/downloads/movie.mp4') + ).resolves.toEqual({ success: true }); + + expect(mockOpenPath).toHaveBeenCalledWith('/downloads/movie.mp4'); +}); +``` + +For the database-error cases, silence `console.error`, use +`mockManagedPath('error')`, expect the existing structured not-found response, +and assert neither shell spy ran. + +- [ ] **Step 5: Run download tests and refresh Electron coverage** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=downloads.events.spec.ts \ + --runInBand +pnpm run coverage:unit:ci -- --projects=electron-backend +pnpm run coverage:merge +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: all existing destructive cleanup/pause/resume tests and all new +managed-path tests PASS. Downloads covered statements and percentage exceed +the fresh master values. + +- [ ] **Step 6: Commit only the downloads spec** + +Run: + +```bash +git add \ + apps/electron-backend/src/app/events/database/downloads.events.spec.ts +git commit -m "test(electron): cover managed download paths" +``` + +Expected: the provisional policy remains the only unstaged source change. + +### Task 8: Record Achieved Ratchets And Document The Integrity Contract + +**Files:** + +- Modify: + `tools/coverage/coverage-policy.json` +- Modify: + `docs/architecture/validation-map.md` +- Modify: + `docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md` +- Modify: + `docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md` +- Create: + `.changes/electron-remote-static-paths.md` + +- [ ] **Step 1: Generate a complete fresh candidate report** + +Run: + +```bash +NX_SKIP_NX_CACHE=true pnpm run coverage:ci +``` + +Expected: 30 Tier A reports, no `Failed to collect coverage`, no missing +runtime-owning files, and all four aggregate metrics at or above the fresh +master baseline. If any metric is lower, do not lower the baseline. Inspect +the approved boundary cases for a missing meaningful assertion, add that +failing assertion, and rerun the directly affected suite before repeating this +command. + +- [ ] **Step 2: Print exact achieved aggregate and per-file values** + +Run: + +```bash +node --input-type=module <<'NODE' +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const { createCoverageMap } = require('istanbul-lib-coverage'); +const data = JSON.parse( + fs.readFileSync('coverage/merged/coverage-final.json', 'utf8') +); +const map = createCoverageMap(data); +const targets = [ + 'apps/electron-backend/src/app/server/http-server.ts', + 'apps/electron-backend/src/app/events/remote-control.events.ts', + 'apps/electron-backend/src/app/events/settings.events.ts', + 'apps/electron-backend/src/app/events/database/downloads.events.ts', +]; +const criticalFiles = targets.map((file) => { + const statements = map + .fileCoverageFor(path.resolve(file)) + .toSummary() + .toJSON().statements; + return { + path: file, + statements: { + minimumCovered: statements.covered, + minimumPercent: statements.pct, + }, + }; +}); +const summary = map.getCoverageSummary().toJSON(); +console.log( + JSON.stringify( + { + merged: { + statements: summary.statements.pct, + branches: summary.branches.pct, + functions: summary.functions.pct, + lines: summary.lines.pct, + }, + criticalFiles, + }, + null, + 4 + ) +); +NODE +``` + +Expected: a complete `coverageRatchet` value with nonzero HTTP/remote values +and settings/download values no lower than their baseline. Copy this exact +JSON object over the provisional `reporting.coverageRatchet`; do not round +manually or retain the provisional one-statement probes. + +- [ ] **Step 3: Verify the achieved ratchet passes and rejects regressions** + +Run: + +```bash +pnpm run coverage:health -- --require-report +node --test tools/coverage/coverage-integrity.test.mjs +``` + +Expected: both exit 0. The synthetic Node tests still prove below-ratchet +metrics, missing critical files, missing reports, and collection markers fail. + +- [ ] **Step 4: Update the canonical validation map** + +Under `## Coverage Tiers` in `docs/architecture/validation-map.md`, add: + +```markdown +Tier A coverage is fail-closed. `coverage:unit:ci` relays Jest output but exits +nonzero on `Failed to collect coverage`, a missing/invalid project report, or +a runtime-owning production TypeScript file absent from that report. +`coverage:merge` requires every configured Tier A report before replacing the +merged output, and `coverage:health --require-report` repeats completeness plus +aggregate and critical-file ratchets. + +Runtime-owning files are discovered from the TypeScript AST. Specs, declarations, +test setup/stubs, generated/environment files, `index.ts`, type-only files, and +pure re-export shims do not count as executable coverage inputs. + +Ratchets live under `reporting.coverageRatchet` in +`tools/coverage/coverage-policy.json`. Update them only from a fresh full +`coverage:ci` report when every value stays level or rises; never lower a +ratchet to accept a regression. The only exception is a reviewed production +source shrink: `minimumCovered` may follow a lower statement total when the PR +identifies the removed executable statements and fresh coverage proves that +the file percentage, aggregate ratchets, and remaining behavioral coverage do +not decrease. +``` + +Also include `pnpm run coverage:tools:test` in the local coverage command block. + +- [ ] **Step 5: Confirm documentation and release-note decisions** + +Because the real-loopback regression exposed and fixed an observable Windows +static-path traversal escape, add `.changes/electron-remote-static-paths.md`: + +```markdown +--- +type: fix +area: electron +--- + +The desktop remote-control server now blocks crafted static paths from escaping +bundled web files on Windows. +``` + +Run: + +```bash +pnpm exec prettier --check \ + docs/architecture/validation-map.md \ + docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md \ + docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md \ + .changes/electron-remote-static-paths.md +pnpm run release:notes:validate +``` + +Expected: both pass. The release note records the narrow user-visible security +fix; do not apply `no-release-note` in Task 10. + +- [ ] **Step 6: Commit ratchets and docs** + +Run: + +```bash +git add \ + tools/coverage/coverage-policy.json \ + docs/architecture/validation-map.md \ + docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md \ + docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md \ + .changes/electron-remote-static-paths.md +git commit -m "ci(coverage): ratchet runtime boundaries" +``` + +Expected: the provisional policy is replaced by achieved values, the security +fix has a valid release note, and the worktree becomes clean. + +### Task 9: Run Fresh Validation And Audit The Final Diff + +**Files:** + +- Verify only: all changed files +- Verify only: + `apps/electron-backend-e2e/src/downloads.e2e.ts` +- Verify only: + `apps/electron-backend-e2e/src/remote-control.e2e.ts` +- Verify only: + `apps/electron-backend-e2e/src/settings.e2e.ts` + +- [ ] **Step 1: Run targeted unit, lint, and type checks** + +Run: + +```bash +pnpm run coverage:tools:test +pnpm nx test electron-backend --skip-nx-cache --runInBand +pnpm nx test m3u-state --skip-nx-cache --runInBand +pnpm nx lint electron-backend --skip-nx-cache +pnpm nx lint m3u-state --skip-nx-cache +pnpm run typecheck:backend +``` + +Expected: every command exits 0; no Jest suite, lint rule, or backend type +check fails. + +- [ ] **Step 2: Run the three existing Electron E2E contracts** + +Run: + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/downloads.e2e.ts --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/remote-control.e2e.ts --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/settings.e2e.ts --skip-nx-cache +``` + +Expected: all targets PASS. These prove the real packaged-style Electron IPC, +native folder/download flow, remote loopback server, renderer command, and +settings persistence contracts beyond unit mocks. + +- [ ] **Step 3: Build the production Electron target** + +Run: + +```bash +pnpm nx build electron-backend \ + --configuration=production \ + --skip-nx-cache +``` + +Expected: exit 0, including worker, web, and remote-control-web dependencies. + +- [ ] **Step 4: Run final full coverage from scratch** + +Run: + +```bash +NX_SKIP_NX_CACHE=true pnpm run coverage:ci +``` + +Expected: + +- all coverage-tool tests PASS; +- policy sees 30 Tier A projects; +- all 30 project reports are merged; +- `effects.ts` is present; +- no collection marker or runtime-owning source omission is reported; +- achieved aggregate and critical-file ratchets PASS. + +- [ ] **Step 5: Inspect exact metrics and scoped diff** + +Run: + +```bash +git diff origin/master...HEAD --check +git diff --stat origin/master...HEAD +git diff --name-only origin/master...HEAD +git status --short --branch +``` + +Expected: + +- no whitespace errors; +- only the files in this plan appear; +- no database worker, dashboard, Stalker search, type-only interface, lockfile, + AGENTS, or CLAUDE changes appear; +- the worktree is clean. + +- [ ] **Step 6: Apply verification-before-completion** + +Read and follow `verification-before-completion`. Record, without paraphrasing +away failures: + +- exact unit suite/test counts; +- exact E2E results; +- lint/typecheck/build exit results; +- 30/30 Tier A report integrity; +- aggregate before/after; +- all selected-file before/after values; +- docs and release-note decisions. + +Do not claim completion from cached or earlier output. + +### Task 10: Recheck Master, Push, And Open The Draft PR + +**Files:** + +- Verify only: current branch history and GitHub PR metadata + +- [ ] **Step 1: Recheck master before publication** + +Run: + +```bash +git fetch origin master --prune +git log --oneline HEAD..origin/master +``` + +Expected: no output. If master advanced, rebase, rerun dependency bootstrap and +all of Task 9, then refresh ratchets only upward before publication. + +- [ ] **Step 2: Review conventional history** + +Run: + +```bash +git log --oneline origin/master..HEAD +git status --short --branch +``` + +Expected: concise conventional commits, including the design and plan docs, +with a clean worktree. + +- [ ] **Step 3: Push the agent branch** + +Run: + +```bash +git push -u origin agent/coverage-integrity-runtime-boundaries +``` + +Expected: push succeeds and sets the upstream. + +- [ ] **Step 4: Create a draft PR** + +Read and follow `github-pr`. Create a draft targeting `master` with title: + +```text +test(electron): harden runtime coverage integrity +``` + +The body must contain: + +- fresh baseline commit and aggregate metrics; +- exact after metrics; +- selected-file before/after table; +- behavioral contracts for HTTP, remote control, settings, and downloads; +- the two behavior-preserving production seams; +- the `effects.ts` compiler/instrumentation correction; +- runner, merge, health, and ratchet behavior; +- exact validation commands/results; +- docs update; +- the Electron fix release note for Windows static-path containment; and +- deferred `database.worker.ts`, dashboard rails, and Stalker search. + +Use: + +```bash +gh pr create \ + --draft \ + --base master \ + --head agent/coverage-integrity-runtime-boundaries \ + --title "test(electron): harden runtime coverage integrity" \ + --body-file - +``` + +Provide the fully populated body on standard input; do not leave metric or +validation placeholders. + +- [ ] **Step 5: Verify release-note metadata** + +Run: + +```bash +gh pr view --json url,isDraft,headRefName,baseRefName,labels,files,commits,statusCheckRollup +``` + +Expected: the PR is draft, head/base are correct, +`.changes/electron-remote-static-paths.md` is included, `no-release-note` is +absent, and the command prints the PR URL. + +- [ ] **Step 6: Return the exact handoff** + +Return: + +- draft PR URL; +- branch; +- final commit SHA; +- aggregate and selected-file before/after metrics; +- exact command results and any explicit environment-only skip; +- docs updated (`docs/architecture/validation-map.md`); +- release note added (`.changes/electron-remote-static-paths.md`) with no + `no-release-note` label; +- deferred zero-coverage follow-ups. diff --git a/docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md b/docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md new file mode 100644 index 000000000..b6ebb5ee6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md @@ -0,0 +1,447 @@ +# Coverage Integrity And Electron Runtime Boundaries Design + +## Status + +Approved in the delegated coverage-audit task on 2026-07-25. + +Before recording the final ratchets, the implementation branch was rebased +onto fresh `origin/master` commit +`7e8c2ccce16b71d72a64c45f1d96dd090dc8e077`. + +## Goal + +Increase confidence in security-sensitive and runtime-sensitive Electron +behavior while making the merged Tier A coverage report fail closed when Jest +cannot instrument executable production source. + +This is a risk-weighted first change. It does not try to maximize a global +percentage by testing type-only modules, re-export shims, or low-risk code. + +## Fresh Baseline + +An uncached `pnpm run coverage:ci` on +`7e8c2ccce16b71d72a64c45f1d96dd090dc8e077` completed successfully and reported: + +| Metric | Covered / total | Percent | +| ---------- | --------------: | ------: | +| Statements | 24,982 / 36,062 | 69.27% | +| Branches | 17,465 / 29,637 | 58.92% | +| Functions | 5,785 / 8,577 | 67.44% | +| Lines | 24,323 / 34,959 | 69.57% | + +The successful command nevertheless printed `Failed to collect coverage` for +`libs/m3u-state/src/lib/effects.ts`. TypeScript could not resolve +`@angular/material/snack-bar`, inferred the injected snack bar as `unknown`, +and did not see the shared `window.electron` declaration under the project's +spec compiler configuration (`TS2307` once, `TS2571` twice, and `TS2551` +once). The file was absent from both the project report and +`coverage/merged/coverage-final.json`. + +All 30 Tier A projects produced a `coverage-final.json`, so 31 report files +including the merged report existed. The current merge included all 30 project +reports and 709 files while still missing an executable file. This demonstrates +that project-level report existence is necessary but not sufficient. + +The audit file status on the same baseline is: + +| File | Statement coverage | +| --------------------------------------------------------------------------------------- | -----------------: | +| `apps/electron-backend/src/app/workers/database.worker.ts` | 0 / 207 (0%) | +| `apps/electron-backend/src/app/events/remote-control.events.ts` | 0 / 115 (0%) | +| `apps/electron-backend/src/app/server/http-server.ts` | 0 / 73 (0%) | +| `apps/electron-backend/src/app/events/database/downloads.events.ts` | 69 / 147 (46.93%) | +| `apps/electron-backend/src/app/events/settings.events.ts` | 16 / 27 (59.25%) | +| `libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts` | 0 / 206 (0%) | +| `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` | 0 / 166 (0%) | +| `libs/m3u-state/src/lib/effects.ts` | absent | + +The original audit is therefore partly stale: `downloads.events.ts` gained a +meaningful regression suite on master, and `settings.events.ts` already had +logging coverage. Both remain in this PR because their uncovered IPC +contracts are part of the approved Electron boundary, but new tests must cover +missing behavior rather than repeat existing assertions. + +## Scope + +### Included + +- Add behavioral coverage for `HttpServer` routing, static serving, traversal + containment, MIME responses, and lifecycle. +- Add behavioral coverage for the remote-control HTTP and IPC boundary: + method checks, renderer commands, status, channel-number validation, + malformed JSON, and the 10 KiB body limit. +- Extend settings IPC coverage for argument normalization, defined-only + persistence, frame-copy boolean normalization, remote-control fallback, and + runtime server reconciliation while preserving redacted logging. +- Extend downloads IPC coverage around the managed-file authorization boundary + before revealing or opening local paths. Preserve the existing destructive + cleanup and pause/resume regression tests. +- Fix the `m3u-state` spec compiler configuration so `effects.ts` can be + instrumented and appears in its report. +- Add a fail-closed integrity gate for instrumentation errors, missing Tier A + reports, missing executable source files, merged metric regressions, and + regressions in the selected critical Electron files. +- Document the resulting coverage workflow in + `docs/architecture/validation-map.md`. + +### Deferred + +- `database.worker.ts`: its 950-line worker-thread dispatcher and database + operation surface deserve a dedicated worker-contract design and PR. +- `workspace-dashboard-rails.component.ts`: the existing same-name spec tests + extracted helpers but never instantiates the 660-line component. Component + coverage belongs in a focused Angular dashboard change. +- `stalker-search.component.ts`: the 510-line search surface and portal store + interactions belong in a focused Stalker search change. +- Broadly raising every current 0% Tier A file. +- Adding tests to type-only/shared interfaces or pure re-export modules to + inflate aggregate metrics. +- Unrelated production behavior changes. + +## Coverage Integrity Architecture + +### Shared integrity module + +Create a pure Node module under `tools/coverage/` that owns: + +- source-file exclusion rules; +- TypeScript AST classification of runtime-owning files; +- coverage path normalization; +- project- and merged-report completeness checks; +- instrumentation-error marker detection; +- merged and critical-file ratchet evaluation; and +- diagnostic formatting. + +The runner, merge script, health script, and Node unit tests use this module so +the definition of an expected executable file does not drift between stages. + +### Runtime-owning source classification + +For each Tier A `sourceRoot`, recursively consider production `.ts` files. +Exclude: + +- `*.spec.ts` and `*.test.ts`; +- `*.d.ts`; +- `test-setup.ts`, test stubs, and generated files; +- environment files; and +- `index.ts`, matching the existing Jest collection exclusions. + +Parse each candidate with the TypeScript compiler API. A file is expected in +coverage when it has at least one top-level runtime-owning statement. +Import declarations, interfaces, type aliases, pure export declarations, +empty statements, and ambient `declare` statements do not make a file +runtime-owning by themselves. Classes, functions, variables, enums, +expressions, control flow, and other emitted statements do. + +This rule intentionally checks executable ownership rather than all TypeScript +text. It excludes type-only contracts and re-export shims without maintaining +a large hand-authored manifest. A discovery prototype over the fresh baseline +classified 591 of 655 Tier A TypeScript source files as runtime-owning and +identified exactly one absent file: `libs/m3u-state/src/lib/effects.ts`. + +Coverage keys and expected source paths are normalized to absolute POSIX-style +paths before comparison so diagnostics are stable across supported CI +platforms. + +### Runner behavior + +`run-tier-a-coverage.mjs` must continue relaying each child process's stdout and +stderr as it arrives. It also scans a bounded rolling text window, with ANSI +escapes removed, for Jest's `Failed to collect coverage` marker. + +After each project command finishes, the runner must fail if: + +- the Nx/Jest command exits nonzero or terminates by signal; +- the output contains an instrumentation failure marker; +- `coverage//coverage-final.json` is missing or invalid; or +- a runtime-owning file under that project's source root is absent from the + report. + +The implementation should use asynchronous child-process streams rather than +buffering the full multi-project Jest output in memory. + +### Merge behavior + +`merge-coverage.mjs` must require exactly one valid report for every configured +Tier A project. It must list all missing or invalid reports and exit before +deleting or rewriting `coverage/merged`. + +The current `.filter(existsSync)` behavior is removed because it silently +merges partial input. + +### Health behavior + +`coverage-health.mjs --require-report` independently reruns: + +- per-project report existence and JSON validity; +- runtime-owning source completeness; +- merged-report completeness and usable instrumentation for every + runtime-owning Tier A file; +- merged metric ratchets; and +- selected critical-file ratchets. + +It computes the aggregate summary from that validated merged map before +checking the serialized summary and ratchets. This defense in depth catches +stale, incomplete, or manually merged reports even when the runner was not the +process that generated them. Local health checks without `--require-report` +retain their current warning behavior when report artifacts are not available, +but any present invalid report remains an error. + +### Ratchets + +The policy stores four merged minimum percentages and per-file statement +minimums for: + +- `http-server.ts`; +- `remote-control.events.ts`; +- `settings.events.ts`; and +- `downloads.events.ts`. + +The implementation begins with the fresh master aggregate baseline shown +above. After the approved behavioral suites and the `effects.ts` +instrumentation fix are complete, the policy is updated to the reproducible +post-change values. Those final values become the ratchet: they may stay level +or rise in future changes, but must not be lowered merely to make CI green. + +Per-file minimums are likewise recorded from the achieved behavioral suites, +not from an arbitrary aspirational percentage. The two currently uncovered +files must become nonzero; the existing `settings` and `downloads` coverage +must not regress. A reviewed production source shrink is the sole exception to +the normally monotonic absolute `minimumCovered`: a PR may lower it only when +it identifies the removed executable statements and a fresh full report proves +that the file's `minimumPercent`, all aggregate ratchets, and coverage of the +remaining behavior stay level or rise. + +## Behavioral Test Design + +### HTTP server + +Use a temporary static directory and a real loopback Node HTTP server. Verify: + +- `/` serves `index.html`; +- known static assets return their content and correct MIME type; +- an unknown client-side route falls back to `index.html`; +- a missing `index.html` returns a plain-text 404; +- registered remote-control API routes bypass static serving; +- unknown remote-control API routes return JSON 404; +- normalized traversal input cannot escape the configured static root; +- `start`, duplicate `start`, `stop`, enable/disable, and port-change restart + preserve the current lifecycle contract. + +Tests should prefer public behavior over direct calls to private methods. If +an ephemeral port or temporary static root is not observable through the +current API, introduce only the smallest constructor dependency seam required +to use real Node HTTP and filesystem behavior. The production singleton keeps +identical defaults. + +### Remote control events + +Capture the HTTP handlers registered during bootstrap and exercise them with +real readable request streams plus response-contract assertions. Verify: + +- bootstrap registers every documented endpoint and starts the server only + when stored settings enable it; +- channel up/down and volume commands accept only their documented HTTP + methods and send the expected renderer event; +- channel selection accepts finite positive values, floors fractional input, + and rejects missing, non-finite, or sub-one values; +- status updates merge partial IPC state and refresh `updatedAt`; +- status reads return the latest merged state; +- malformed JSON returns 400 without dispatching a command; +- payloads over 10 KiB return 413, destroy the request, and do not dispatch; + and +- missing BrowserWindows are contained without throwing. + +The existing Electron E2E remote-control suite remains the end-to-end check for +real volume commands and renderer status. + +### Settings events + +Extend the existing same-name spec. Use the real external-player argument +normalizer and mocked persistent store/server boundaries to verify: + +- MPV and VLC arguments are normalized before persistence; +- undefined values are not written; +- reuse flags preserve explicit false values; +- frame-copy persistence coerces to boolean; +- a partial remote-control update reads the missing half from the store; +- only explicitly supplied remote-control fields are persisted; and +- `httpServer.updateSettings` receives the resolved enabled/port pair. + +Retain the existing assertions that logging redacts TMDB and player-argument +credentials. + +### Downloads events + +Extend the current event suite rather than replacing its destructive cleanup +coverage. Verify the local-file boundary: + +- a path not present in the downloads database is never revealed or opened, + even when it exists on disk; +- a database-managed path missing on disk is never revealed or opened; +- only a database-managed path that exists reaches + `shell.showItemInFolder` or `shell.openPath`; and +- a database lookup failure returns the existing structured not-found response + and never reaches the shell. + +Folder authorization internals remain covered by +`download-directory-authorization.spec.ts`; this event suite tests only the +IPC-to-authorizer and managed-path integration where it adds distinct signal. + +## TDD Sequence + +1. Add failing Node tests for runtime classification, ANSI instrumentation + marker detection, missing/invalid reports, partial merge input, aggregate + ratchets, and critical-file ratchets. +2. Implement the shared integrity module and wire the runner, merge, and health + scripts until those tests pass. +3. Enable the selected critical-file policy; confirm fresh targeted coverage + fails because `http-server.ts` and `remote-control.events.ts` are 0%. +4. For each Electron boundary, add one contract test at a time, observe the + intended failure, and make only the smallest testability refactor needed to + pass it. +5. Add a failing integrity assertion for the absent `effects.ts`, align the + `m3u-state` spec compiler configuration, and confirm the real file appears. +6. If a behavioral test exposes a production defect, add a direct regression + assertion first and make a separately justified minimal fix. Otherwise + preserve production behavior. +7. Record fresh aggregate and per-file post-change ratchets only after all + behavioral suites are green. + +## Error Handling And Diagnostics + +Integrity failures must identify: + +- the project; +- the expected report path; +- each missing runtime-owning source path; +- the metric or critical file that fell below its ratchet; and +- the observed and required values. + +The runner must preserve the original Nx/Jest output and child exit semantics. +Integrity diagnostics are additive and must not hide the underlying +instrumentation error. + +Tests use temporary directories and deterministic synthetic coverage maps. +They must not depend on a previously generated workspace `coverage/` tree. + +## Success Criteria + +- Fresh `pnpm run coverage:ci` exits nonzero for synthetic or real + instrumentation collection failures. +- All 30 Tier A project reports are mandatory merge inputs. +- Every runtime-owning Tier A TypeScript source file is present in its project + report. +- `libs/m3u-state/src/lib/effects.ts` is present in coverage. +- The four selected Electron boundary files meet their recorded behavioral + statement ratchets; `http-server.ts` and `remote-control.events.ts` are no + longer 0%. +- Final merged statements, branches, functions, and lines are each at least + the fresh master baseline and become the new ratchet. +- Assertions verify observable contracts rather than merely invoking mocks for + line execution. +- No untested production behavior change or unrelated source change is + included. +- Deferred database worker, dashboard, and Stalker search coverage is listed + in the draft PR. + +## Implementation Outcome + +The fresh uncached implementation run merged all 30 required Tier A reports +into the 31st `coverage-final.json` and included 710 files, with no +collection-failure marker or missing runtime-owning source. The achieved +ratchets are: + +| Metric | Covered / total | Percent | +| ---------- | --------------: | ------: | +| Statements | 25,204 / 36,241 | 69.54% | +| Branches | 17,573 / 29,748 | 59.07% | +| Functions | 5,824 / 8,635 | 67.44% | +| Lines | 24,543 / 35,136 | 69.85% | + +The selected Electron boundary statement coverage changed as follows: + +| File | Baseline | After | +| -------------------------- | ----------------: | -----------------: | +| `http-server.ts` | 0 / 73 (0%) | 83 / 92 (90.21%) | +| `remote-control.events.ts` | 0 / 115 (0%) | 112 / 116 (96.55%) | +| `settings.events.ts` | 16 / 27 (59.25%) | 26 / 27 (96.29%) | +| `downloads.events.ts` | 69 / 147 (46.93%) | 86 / 147 (58.50%) | + +The integrity correction makes the denominator honest: +`libs/m3u-state/src/lib/effects.ts` is now present at 0 / 159 statements +instead of silently disappearing. The aggregate improvement therefore holds +even after surfacing those 159 previously unreported uncovered statements. +Strict health validation also rechecks the merged map itself, so a +runtime-owning file cannot disappear between valid project reports and the +merged artifact while permissive percentages still pass. + +Two production seams were sufficient for isolated contract tests: +`HttpServer` accepts an optional server factory and static distribution path, +and `RemoteControlEvents` is a named export while the production singleton is +unchanged. Real loopback HTTP tests also exposed a Windows +double-leading-slash traversal escape in the prior static-path normalization. +A direct failing regression preceded the narrow fix: production now uses the +pure, platform-testable `resolveStaticFilePath` resolver to decode once, reject +malformed or NUL paths, strip leading separators, resolve against the static +root, and enforce containment. This is a tested behavior and security fix, not +a behavior-preserving refactor. + +The release decision follows that observable fix: +`.changes/electron-remote-static-paths.md` records it as an Electron bug fix, +so the draft PR must not carry `no-release-note`. The canonical coverage +workflow is documented in `docs/architecture/validation-map.md`; `AGENTS.md` +and `CLAUDE.md` do not describe the changed coverage contract or HTTP path +logic and require no update. + +The deferred production files remain uncovered in this change: +`database.worker.ts` at 0 / 207, `workspace-dashboard-rails.component.ts` at +0 / 206, and `stalker-search.component.ts` at 0 / 166. They retain the +separate follow-up scopes described above. + +## Validation Ladder + +Run fresh, uncached validation in this order: + +```bash +node --test tools/coverage/*.test.mjs +pnpm nx test electron-backend --skip-nx-cache --runInBand +pnpm nx test m3u-state --skip-nx-cache --runInBand +pnpm run coverage:unit:ci -- --projects=electron-backend,m3u-state +pnpm nx lint electron-backend --skip-nx-cache +pnpm nx lint m3u-state --skip-nx-cache +pnpm run typecheck:backend +pnpm nx run electron-backend-e2e:e2e-ci--src/downloads.e2e.ts +pnpm nx run electron-backend-e2e:e2e-ci--src/remote-control.e2e.ts +pnpm nx run electron-backend-e2e:e2e-ci--src/settings.e2e.ts +pnpm nx build electron-backend --configuration=production --skip-nx-cache +pnpm run coverage:ci +``` + +The final report records exact command results, aggregate before/after metrics, +and before/after metrics for the selected critical files. If an environment +prevents an Electron E2E or production build, report the exact blocker and run +the strongest available lower-level contract validation; do not silently omit +it. + +## Documentation Impact + +Update `docs/architecture/validation-map.md` to make the fail-closed coverage +contract and ratchet maintenance workflow canonical. + +`AGENTS.md` and `CLAUDE.md` do not currently describe coverage commands or +integrity behavior, so no update is expected unless implementation changes a +process they do describe. + +## Draft PR Handoff + +The draft PR body will include: + +- fresh master baseline and post-change aggregate metrics; +- selected-file before/after metrics; +- behavioral contracts added; +- any production-only testability refactor and why it preserves behavior; +- coverage integrity and ratchet changes; +- all validation commands and results; +- the documentation decision; and +- deferred 0%-coverage follow-ups. diff --git a/libs/m3u-state/tsconfig.spec.json b/libs/m3u-state/tsconfig.spec.json index cc2617958..19497995e 100644 --- a/libs/m3u-state/tsconfig.spec.json +++ b/libs/m3u-state/tsconfig.spec.json @@ -2,12 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts index aa7ae36bd..0c4185104 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts @@ -8,6 +8,7 @@ import { buildTimelineBlocks, hasProgramsForDateKey, TIMELINE_MINUTE_MS, + TimelineBlock, } from './epg-timeline.utils'; function programAt( @@ -52,15 +53,73 @@ describe('TimelineScrollController', () => { }); }); + describe('focusCurrentProgram (target selection)', () => { + const axisStartMs = Date.UTC(2026, 0, 1); + const nowMs = axisStartMs + 3 * TIMELINE_MINUTE_MS; + const currentBlock: TimelineBlock = { + program: programAt(0, 2), + key: 'current', + startMs: axisStartMs + TIMELINE_MINUTE_MS, + stopMs: axisStartMs + 3 * TIMELINE_MINUTE_MS, + when: 'now', + offsetMin: 1, + durationMin: 2, + }; + + function controllerWith( + blocks: readonly TimelineBlock[] + ): TimelineScrollController { + return new TimelineScrollController({ + ribbon: () => undefined, + scale: () => 1, + axis: () => ({ + startMs: axisStartMs, + endMs: axisStartMs + 24 * 60 * TIMELINE_MINUTE_MS, + }), + blocks: () => blocks, + nowMs: () => nowMs, + viewDayKey: () => '2026-01-01', + commitDay: () => undefined, + hasProgramsForDay: () => true, + }); + } + + it('centres the midpoint of the current programme', () => { + const controller = controllerWith([currentBlock]); + const scrollSpy = jest + .spyOn(controller, 'scrollToOffset') + .mockImplementation(() => undefined); + + controller.focusCurrentProgram(false); + + expect(scrollSpy).toHaveBeenCalledWith(2, 0.5, false); + }); + + it('centres now when no programme is currently airing', () => { + const controller = controllerWith([ + { ...currentBlock, when: 'future' }, + ]); + const scrollSpy = jest + .spyOn(controller, 'scrollToOffset') + .mockImplementation(() => undefined); + + controller.focusCurrentProgram(false); + + expect(scrollSpy).toHaveBeenCalledWith(3, 0.5, false); + }); + }); + describe('maybeAutoFocus (channel-select centring)', () => { let programs: EpgProgram[]; let controller: TimelineScrollController; let scrollSpy: jest.SpyInstance; let commitDaySpy: jest.Mock; + let dateNowSpy: jest.SpyInstance; beforeEach(() => { programs = []; const nowMs = Date.now(); + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(nowMs); commitDaySpy = jest.fn(); controller = new TimelineScrollController({ ribbon: () => undefined, @@ -83,6 +142,8 @@ describe('TimelineScrollController', () => { .mockImplementation(() => undefined); }); + afterEach(() => dateNowSpy.mockRestore()); + function focus( scroller: HTMLElement | undefined, next: EpgProgram[] = programs diff --git a/package.json b/package.json index 8fa71c67d..020a8a60e 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,12 @@ "test:backend": "nx test electron-backend", "test:unit:all": "nx run-many --target=test --all --parallel=3", "test:unit:ci": "nx run-many --target=test --all --parallel=3 --output-style=static", + "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs", "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", "coverage:health": "node tools/coverage/coverage-health.mjs", "coverage:policy:check": "node tools/coverage/check-coverage-policy.mjs", - "coverage:ci": "pnpm run coverage:policy:check && pnpm run coverage:unit:ci && pnpm run coverage:merge && node tools/coverage/coverage-health.mjs --require-report", + "coverage:ci": "pnpm run coverage:tools:test && pnpm run coverage:policy:check && pnpm run coverage:unit:ci && pnpm run coverage:merge && node tools/coverage/coverage-health.mjs --require-report", "coverage:e2e:summary": "node tools/coverage/e2e-semantic-summary.mjs", "coverage:e2e:v8:web": "node tools/coverage/e2e-v8-web.mjs", "typecheck:web": "tsc -p apps/web/tsconfig.app.json --noEmit", diff --git a/tools/coverage/coverage-health.mjs b/tools/coverage/coverage-health.mjs index 555b75b32..705623e72 100644 --- a/tools/coverage/coverage-health.mjs +++ b/tools/coverage/coverage-health.mjs @@ -10,6 +10,21 @@ import { import path from 'node:path'; import process from 'node:process'; +import { + evaluateCoverageRatchets, + validateMergedCoverage, + validateProjectCoverage, + validateRequiredProjectReports, +} from './coverage-integrity.mjs'; + +const COVERAGE_METRICS = [ + 'statements', + 'branches', + 'functions', + 'lines', +]; +const COVERAGE_SUMMARY_FIELDS = ['covered', 'total', 'pct']; + const workspaceRoot = process.cwd(); const args = new Set(process.argv.slice(2)); const requireReport = args.has('--require-report'); @@ -37,6 +52,15 @@ function projectJsonPath(project) { return path.join(workspaceRoot, project.root, 'project.json'); } +function projectCoveragePath(project) { + return path.join( + workspaceRoot, + 'coverage', + project.root, + 'coverage-final.json' + ); +} + function verifyTierAProjects() { for (const project of policy.unitCoverage.tierA) { const filePath = projectJsonPath(project); @@ -58,14 +82,63 @@ function verifyTierAProjects() { } } +function verifyProjectCoverageReports() { + if (requireReport) { + const validation = validateRequiredProjectReports({ + projects: policy.unitCoverage.tierA, + workspaceRoot, + }); + errors.push(...validation.errors); + return; + } + + for (const project of policy.unitCoverage.tierA) { + if (!existsSync(projectCoveragePath(project))) { + continue; + } + + const validation = validateProjectCoverage({ + project, + workspaceRoot, + }); + errors.push(...validation.errors); + } +} + +function reportCoverageSummaryMismatches(reportedSummary, computedSummary) { + for (const metric of COVERAGE_METRICS) { + const reported = reportedSummary[metric]; + const computed = computedSummary[metric]; + if ( + COVERAGE_SUMMARY_FIELDS.every( + (field) => reported?.[field] === computed?.[field] + ) + ) { + continue; + } + + errors.push( + `Merged coverage summary mismatch for ${metric}: coverage-summary.json reports covered ${reported?.covered}, total ${reported?.total}, pct ${reported?.pct}; coverage-final.json computes covered ${computed?.covered}, total ${computed?.total}, pct ${computed?.pct}.` + ); + } +} + function verifyCoverageReport() { const summaryPath = path.join( workspaceRoot, policy.reporting.mergedCoverageDir, 'coverage-summary.json' ); + const ratchet = policy.reporting.coverageRatchet; + const coveragePath = path.join( + workspaceRoot, + policy.reporting.mergedCoverageDir, + 'coverage-final.json' + ); + const summaryExists = existsSync(summaryPath); + const coverageExists = existsSync(coveragePath); - if (!existsSync(summaryPath)) { + if (!summaryExists && !coverageExists) { const message = `Merged coverage summary not found at ${path.relative(workspaceRoot, summaryPath)}.`; if (requireReport) { errors.push(message); @@ -75,25 +148,86 @@ function verifyCoverageReport() { return; } - const summary = readJson(summaryPath).total; - console.log( - `Merged coverage: statements ${summary.statements.pct}%, branches ${summary.branches.pct}%, functions ${summary.functions.pct}%, lines ${summary.lines.pct}%.` - ); + if (summaryExists !== coverageExists) { + const existingPath = summaryExists ? summaryPath : coveragePath; + const missingPath = summaryExists ? coveragePath : summaryPath; + errors.push( + `Merged coverage artifacts are incomplete: found ${path.relative(workspaceRoot, existingPath)} but ${path.relative(workspaceRoot, missingPath)} is missing.` + ); + return; + } - if (requireReport) { - for (const project of policy.unitCoverage.tierA) { - const projectCoveragePath = path.join( + let summaryDocument; + try { + summaryDocument = readJson(summaryPath); + } catch (error) { + errors.push( + `Merged coverage summary ${path.relative(workspaceRoot, summaryPath)} contains invalid JSON: ${error.message}` + ); + return; + } + + const summary = summaryDocument?.total; + if ( + !summary || + COVERAGE_METRICS.some( + (metric) => + !summary[metric] || + !Number.isFinite(summary[metric].pct) + ) + ) { + errors.push( + `Merged coverage summary ${path.relative(workspaceRoot, summaryPath)} does not contain usable total coverage metrics.` + ); + return; + } + + let coverageData; + try { + coverageData = readJson(coveragePath); + } catch (error) { + errors.push( + `Merged coverage report ${path.relative(workspaceRoot, coveragePath)} contains invalid JSON: ${error.message}` + ); + return; + } + + const mergedValidation = validateMergedCoverage({ + coverageData, + projects: policy.unitCoverage.tierA, + reportPath: coveragePath, + workspaceRoot, + }); + errors.push(...mergedValidation.errors); + if (!mergedValidation.coverageMap) { + return; + } + const computedSummary = mergedValidation.coverageMap + .getCoverageSummary() + .toJSON(); + + console.log( + `Merged coverage: statements ${computedSummary.statements.pct}%, branches ${computedSummary.branches.pct}%, functions ${computedSummary.functions.pct}%, lines ${computedSummary.lines.pct}%.` + ); + reportCoverageSummaryMismatches(summary, computedSummary); + + if (mergedValidation.errors.length > 0 || ratchet === undefined) { + return; + } + + try { + errors.push( + ...evaluateCoverageRatchets({ + coverageData, + mergedSummary: computedSummary, + ratchet, workspaceRoot, - 'coverage', - project.root, - 'coverage-final.json' - ); - if (!existsSync(projectCoveragePath)) { - errors.push( - `Tier A project ${project.name} did not produce ${path.relative(workspaceRoot, projectCoveragePath)}.` - ); - } - } + }) + ); + } catch (error) { + errors.push( + `Merged coverage report ${path.relative(workspaceRoot, coveragePath)} is not valid Istanbul coverage: ${error.message}` + ); } } @@ -216,6 +350,7 @@ function reportChangedCriticalFiles() { } verifyTierAProjects(); +verifyProjectCoverageReports(); verifyCoverageReport(); scanE2ETags(); reportChangedCriticalFiles(); diff --git a/tools/coverage/coverage-integrity.mjs b/tools/coverage/coverage-integrity.mjs new file mode 100644 index 000000000..b73e035b4 --- /dev/null +++ b/tools/coverage/coverage-integrity.mjs @@ -0,0 +1,579 @@ +import { createRequire } from 'node:module'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); +const { createCoverageMap } = require('istanbul-lib-coverage'); + +export const COVERAGE_COLLECTION_FAILURE = 'Failed to collect coverage'; +const ANSI_ESCAPE = /\u001b\[[0-?]*[ -/]*[@-~]/g; +const COVERAGE_METRICS = [ + 'statements', + 'branches', + 'functions', + 'lines', +]; + +function toPosix(filePath) { + return filePath.split(path.sep).join('/'); +} + +function hasDeclareModifier(statement) { + const modifiers = ts.canHaveModifiers(statement) + ? (ts.getModifiers(statement) ?? []) + : []; + return modifiers.some( + (modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword + ); +} + +export function hasRuntimeOwnedStatement(sourceText, fileName = 'source.ts') { + const source = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + return source.statements.some((statement) => { + if (hasDeclareModifier(statement)) { + return false; + } + + return !( + ts.isImportDeclaration(statement) || + ts.isImportEqualsDeclaration(statement) || + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isExportDeclaration(statement) || + ts.isEmptyStatement(statement) + ); + }); +} + +export function createCoverageOutputScanner(maxCharacters = 4096) { + let tail = ''; + let collectionFailed = false; + let ansiState = 'text'; + + function stripAnsi(chunk) { + const input = String(chunk).replace(ANSI_ESCAPE, ''); + let output = ''; + + for (let index = 0; index < input.length; index += 1) { + const character = input[index]; + const code = character.charCodeAt(0); + + if (ansiState === 'text') { + if (code === 0x1b) { + ansiState = 'escape'; + } else { + output += character; + } + continue; + } + + if (ansiState === 'escape') { + if (character === '[') { + ansiState = 'parameters'; + } else { + output += '\u001b'; + ansiState = 'text'; + index -= 1; + } + continue; + } + + if (ansiState === 'parameters') { + if (code >= 0x30 && code <= 0x3f) { + continue; + } + if (code >= 0x20 && code <= 0x2f) { + ansiState = 'intermediates'; + continue; + } + if (code >= 0x40 && code <= 0x7e) { + ansiState = 'text'; + continue; + } + + ansiState = 'text'; + index -= 1; + continue; + } + + if (code >= 0x20 && code <= 0x2f) { + continue; + } + if (code >= 0x40 && code <= 0x7e) { + ansiState = 'text'; + continue; + } + + ansiState = 'text'; + index -= 1; + } + + return output; + } + + return { + get collectionFailed() { + return collectionFailed; + }, + get tail() { + return tail; + }, + push(chunk) { + const output = `${tail}${stripAnsi(chunk)}`; + if (output.includes(COVERAGE_COLLECTION_FAILURE)) { + collectionFailed = true; + } + tail = output.slice(-maxCharacters); + }, + }; +} + +function isExcludedSource(filePath) { + const file = toPosix(filePath); + return ( + /\.(spec|test)\.ts$/.test(file) || + file.endsWith('.d.ts') || + file.endsWith('/test-setup.ts') || + file.includes('/test-stubs/') || + /\.generated\./.test(file) || + file.includes('/environments/') || + file.endsWith('/index.ts') + ); +} + +function runtimeOwningSourceFiles(workspaceRoot, sourceRoot) { + const absoluteSourceRoot = path.resolve(workspaceRoot, sourceRoot); + const runtimeFiles = []; + const directories = [absoluteSourceRoot]; + + while (directories.length > 0) { + const directory = directories.pop(); + for (const name of readdirSync(directory)) { + const filePath = path.join(directory, name); + const stats = statSync(filePath); + + if (stats.isDirectory()) { + directories.push(filePath); + continue; + } + if ( + !stats.isFile() || + !filePath.endsWith('.ts') || + isExcludedSource(filePath) + ) { + continue; + } + if ( + hasRuntimeOwnedStatement( + readFileSync(filePath, 'utf8'), + filePath + ) + ) { + runtimeFiles.push(path.resolve(filePath)); + } + } + } + + return runtimeFiles.sort(); +} + +function isObjectRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot) { + const filesByAbsolutePath = new Map(); + const duplicateAbsolutePaths = new Set(); + + for (const reportedPath of coverageMap.files().sort()) { + const absolutePath = path.resolve(workspaceRoot, reportedPath); + if (filesByAbsolutePath.has(absolutePath)) { + duplicateAbsolutePaths.add(absolutePath); + continue; + } + filesByAbsolutePath.set(absolutePath, reportedPath); + } + + return { + duplicateAbsolutePaths: [...duplicateAbsolutePaths].sort(), + filesByAbsolutePath, + }; +} + +export function validateProjectCoverage({ workspaceRoot, project }) { + const reportPath = path.resolve( + workspaceRoot, + 'coverage', + project.root, + 'coverage-final.json' + ); + const relativeReportPath = toPosix( + path.relative(workspaceRoot, reportPath) + ); + + if (!existsSync(reportPath)) { + return { + errors: [ + `${project.name} did not produce ${relativeReportPath}.`, + ], + report: undefined, + }; + } + + let data; + try { + data = JSON.parse(readFileSync(reportPath, 'utf8')); + } catch (error) { + return { + errors: [ + `${project.name} coverage report ${relativeReportPath} contains invalid JSON: ${error.message}`, + ], + report: undefined, + }; + } + + if ( + !isObjectRecord(data) || + Object.values(data).some((entry) => !isObjectRecord(entry)) + ) { + return { + errors: [ + `${project.name} coverage report ${relativeReportPath} must contain a JSON object mapping source paths to JSON objects.`, + ], + report: undefined, + }; + } + + let coverageMap; + try { + coverageMap = createCoverageMap(data); + for (const filePath of coverageMap.files()) { + coverageMap.fileCoverageFor(filePath).toSummary(); + } + } catch (error) { + return { + errors: [ + `${project.name} coverage report ${relativeReportPath} is not valid Istanbul coverage: ${error.message}`, + ], + report: undefined, + }; + } + + const { + duplicateAbsolutePaths, + filesByAbsolutePath: reportedFiles, + } = indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot); + if (duplicateAbsolutePaths.length > 0) { + return { + errors: duplicateAbsolutePaths.map( + (filePath) => + `${project.name} coverage report ${relativeReportPath} contains a duplicate entry for ${toPosix( + path.relative(workspaceRoot, filePath) + )} after path normalization.` + ), + report: undefined, + }; + } + + const errors = []; + for (const filePath of runtimeOwningSourceFiles( + workspaceRoot, + project.sourceRoot + )) { + const reportedPath = reportedFiles.get(filePath); + const relativeSourcePath = toPosix( + path.relative(workspaceRoot, filePath) + ); + if (reportedPath === undefined) { + errors.push( + `${project.name} coverage report is missing runtime-owning source ${relativeSourcePath}.` + ); + continue; + } + + const summary = coverageMap + .fileCoverageFor(reportedPath) + .toSummary() + .toJSON(); + const hasUsableInstrumentation = [ + summary.statements, + summary.functions, + summary.branches, + ].some((metric) => metric.total > 0); + if (!hasUsableInstrumentation) { + errors.push( + `${project.name} coverage report ${relativeReportPath} has no usable instrumentation for runtime-owning source ${relativeSourcePath}.` + ); + } + } + + return { + errors, + report: { + data, + path: reportPath, + project, + }, + }; +} + +export function validateRequiredProjectReports({ projects, workspaceRoot }) { + const errors = []; + const reports = []; + + for (const project of projects) { + const result = validateProjectCoverage({ project, workspaceRoot }); + errors.push(...result.errors); + if (result.report) { + reports.push(result.report); + } + } + + return { errors, reports }; +} + +export function validateMergedCoverage({ + coverageData, + projects, + reportPath, + workspaceRoot, +}) { + const relativeReportPath = toPosix( + path.relative(workspaceRoot, reportPath) + ); + const reportDescription = `Merged coverage report ${relativeReportPath}`; + + if ( + !isObjectRecord(coverageData) || + Object.values(coverageData).some( + (entry) => !isObjectRecord(entry) + ) + ) { + return { + coverageMap: undefined, + errors: [ + `${reportDescription} must contain a JSON object mapping source paths to JSON objects.`, + ], + }; + } + + let coverageMap; + try { + coverageMap = createCoverageMap(coverageData); + for (const filePath of coverageMap.files()) { + coverageMap.fileCoverageFor(filePath).toSummary(); + } + } catch (error) { + return { + coverageMap: undefined, + errors: [ + `${reportDescription} is not valid Istanbul coverage: ${error.message}`, + ], + }; + } + + const { + duplicateAbsolutePaths, + filesByAbsolutePath: reportedFiles, + } = indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot); + if (duplicateAbsolutePaths.length > 0) { + return { + coverageMap: undefined, + errors: duplicateAbsolutePaths.map( + (filePath) => + `${reportDescription} contains a duplicate entry for ${toPosix( + path.relative(workspaceRoot, filePath) + )} after path normalization.` + ), + }; + } + + const errors = []; + for (const project of projects) { + for (const filePath of runtimeOwningSourceFiles( + workspaceRoot, + project.sourceRoot + )) { + const relativeSourcePath = toPosix( + path.relative(workspaceRoot, filePath) + ); + const reportedPath = reportedFiles.get(filePath); + if (reportedPath === undefined) { + errors.push( + `${reportDescription} is missing runtime-owning source ${relativeSourcePath} from Tier A project ${project.name}.` + ); + continue; + } + + const summary = coverageMap + .fileCoverageFor(reportedPath) + .toSummary() + .toJSON(); + const hasUsableInstrumentation = [ + summary.statements, + summary.functions, + summary.branches, + ].some((metric) => metric.total > 0); + if (!hasUsableInstrumentation) { + errors.push( + `${reportDescription} has no usable instrumentation for runtime-owning source ${relativeSourcePath} from Tier A project ${project.name}.` + ); + } + } + } + + return { coverageMap, errors }; +} + +function formatConfigurationValue(value) { + return typeof value === 'string' ? JSON.stringify(value) : String(value); +} + +function validateCoverageRatchetConfiguration(ratchet) { + const errors = []; + + for (const metric of COVERAGE_METRICS) { + const value = ratchet?.merged?.[metric]; + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + value < 0 || + value > 100 + ) { + errors.push( + `Invalid coverage ratchet merged.${metric}: expected a finite number between 0 and 100, received ${formatConfigurationValue(value)}.` + ); + } + } + + const criticalFiles = ratchet?.criticalFiles; + if (!Array.isArray(criticalFiles)) { + errors.push( + `Invalid coverage ratchet criticalFiles: expected an array, received ${formatConfigurationValue(criticalFiles)}.` + ); + return errors; + } + + for (const [index, criticalFile] of criticalFiles.entries()) { + const criticalPath = criticalFile?.path; + const hasValidPath = + typeof criticalPath === 'string' && + criticalPath.trim().length > 0; + if (!hasValidPath) { + errors.push( + `Invalid coverage ratchet criticalFiles[${index}].path: expected a non-empty string, received ${formatConfigurationValue(criticalPath)}.` + ); + } + const filePath = hasValidPath + ? toPosix(criticalPath) + : `criticalFiles[${index}]`; + const minimumCovered = + criticalFile?.statements?.minimumCovered; + if ( + typeof minimumCovered !== 'number' || + !Number.isFinite(minimumCovered) || + !Number.isInteger(minimumCovered) || + minimumCovered < 0 + ) { + errors.push( + `Invalid coverage ratchet ${filePath} statements.minimumCovered: expected a non-negative integer, received ${formatConfigurationValue(minimumCovered)}.` + ); + } + + const minimumPercent = + criticalFile?.statements?.minimumPercent; + if ( + typeof minimumPercent !== 'number' || + !Number.isFinite(minimumPercent) || + minimumPercent < 0 || + minimumPercent > 100 + ) { + errors.push( + `Invalid coverage ratchet ${filePath} statements.minimumPercent: expected a finite number between 0 and 100, received ${formatConfigurationValue(minimumPercent)}.` + ); + } + } + + return errors; +} + +export function evaluateCoverageRatchets({ + coverageData, + mergedSummary, + ratchet, + workspaceRoot, +}) { + const configurationErrors = + validateCoverageRatchetConfiguration(ratchet); + if (configurationErrors.length > 0) { + return configurationErrors; + } + + const errors = []; + + for (const metric of COVERAGE_METRICS) { + const required = ratchet.merged[metric]; + const observed = mergedSummary[metric]?.pct; + if (!Number.isFinite(observed) || observed < required) { + errors.push( + `Merged ${metric} coverage regressed: observed ${observed}%, required at least ${required}%.` + ); + } + } + + const coverageMap = createCoverageMap(coverageData); + const { + duplicateAbsolutePaths, + filesByAbsolutePath: reportedFiles, + } = indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot); + if (duplicateAbsolutePaths.length > 0) { + return [ + ...errors, + ...duplicateAbsolutePaths.map( + (filePath) => + `Merged coverage contains a duplicate entry for ${toPosix( + path.relative(workspaceRoot, filePath) + )} after path normalization.` + ), + ]; + } + + for (const criticalFile of ratchet.criticalFiles) { + const filePath = path.resolve(workspaceRoot, criticalFile.path); + const relativePath = toPosix(path.relative(workspaceRoot, filePath)); + const reportedPath = reportedFiles.get(filePath); + if (reportedPath === undefined) { + errors.push( + `Critical coverage ${relativePath} is missing from merged coverage.` + ); + continue; + } + + const statements = coverageMap + .fileCoverageFor(reportedPath) + .toSummary() + .toJSON().statements; + const minimumCovered = criticalFile.statements.minimumCovered; + if (statements.covered < minimumCovered) { + errors.push( + `Critical coverage ${relativePath} statements covered regressed: observed ${statements.covered}, required at least ${minimumCovered}.` + ); + } + + const minimumPercent = criticalFile.statements.minimumPercent; + if (statements.pct < minimumPercent) { + errors.push( + `Critical coverage ${relativePath} statements percent regressed: observed ${statements.pct}%, required at least ${minimumPercent}%.` + ); + } + } + + return errors; +} diff --git a/tools/coverage/coverage-integrity.test.mjs b/tools/coverage/coverage-integrity.test.mjs new file mode 100644 index 000000000..5dcc87304 --- /dev/null +++ b/tools/coverage/coverage-integrity.test.mjs @@ -0,0 +1,904 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { + createCoverageOutputScanner, + evaluateCoverageRatchets, + hasRuntimeOwnedStatement, + validateMergedCoverage, + validateProjectCoverage, + validateRequiredProjectReports, +} from './coverage-integrity.mjs'; + +const temporaryRoots = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function makeProjectFixture({ + name = 'example', + projectRoot = `libs/${name}`, + workspaceRoot = undefined, +} = {}) { + const ownsWorkspace = workspaceRoot === undefined; + const fixtureRoot = + workspaceRoot ?? + mkdtempSync(path.join(tmpdir(), 'iptvnator-coverage-integrity-')); + if (ownsWorkspace) { + temporaryRoots.push(fixtureRoot); + } + + const sourceRoot = `${projectRoot}/src`; + const sourcePath = path.join(fixtureRoot, sourceRoot, 'runtime.ts'); + const reportPath = path.join( + fixtureRoot, + 'coverage', + projectRoot, + 'coverage-final.json' + ); + + mkdirSync(path.dirname(sourcePath), { recursive: true }); + mkdirSync(path.dirname(reportPath), { recursive: true }); + writeFileSync(sourcePath, 'export const runtime = true;\n'); + + const project = { + name, + root: projectRoot, + sourceRoot, + }; + + return { + project, + reportPath, + sourcePath, + workspaceRoot: fixtureRoot, + }; +} + +function coverageEntry(filePath, hits = [0]) { + const statementMap = Object.fromEntries( + hits.map((_, index) => [ + index, + { + start: { line: index + 1, column: 0 }, + end: { line: index + 1, column: 28 }, + }, + ]) + ); + const statementHits = Object.fromEntries( + hits.map((hit, index) => [index, hit]) + ); + + return { + path: filePath, + statementMap, + fnMap: {}, + branchMap: {}, + s: statementHits, + f: {}, + b: {}, + }; +} + +function functionOnlyCoverageEntry(filePath) { + return { + path: filePath, + statementMap: {}, + fnMap: { + 0: { + name: 'runtime', + decl: { + start: { line: 1, column: 7 }, + end: { line: 1, column: 15 }, + }, + loc: { + start: { line: 1, column: 7 }, + end: { line: 1, column: 28 }, + }, + line: 1, + }, + }, + branchMap: {}, + s: {}, + f: { 0: 1 }, + b: {}, + }; +} + +function writeCompleteReport(fixture, reportKey = fixture.sourcePath) { + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [reportKey]: coverageEntry(fixture.sourcePath), + }) + ); +} + +function fullyCoveredSummary() { + return { + statements: { covered: 1, total: 1, pct: 100 }, + branches: { covered: 0, total: 0, pct: 100 }, + functions: { covered: 0, total: 0, pct: 100 }, + lines: { covered: 1, total: 1, pct: 100 }, + }; +} + +function ratchetWithCriticalFiles(criticalFiles) { + return { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles, + }; +} + +describe('hasRuntimeOwnedStatement', () => { + it('excludes type-only, import-only, and pure re-export source', () => { + assert.equal( + hasRuntimeOwnedStatement( + 'import type { Settings } from "./settings";' + ), + false + ); + assert.equal( + hasRuntimeOwnedStatement( + 'import settings = require("./settings");' + ), + false + ); + assert.equal( + hasRuntimeOwnedStatement('interface Settings { enabled: boolean }'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('export type Mode = "live" | "vod";'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('export { value } from "./value";'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('declare const injected: string;'), + false + ); + }); + + it('includes emitted declarations and executable statements', () => { + assert.equal( + hasRuntimeOwnedStatement('export const enabled = true;'), + true + ); + assert.equal( + hasRuntimeOwnedStatement( + 'export class RuntimeBoundary { start() {} }' + ), + true + ); + assert.equal(hasRuntimeOwnedStatement('console.log("runtime");'), true); + }); +}); + +describe('createCoverageOutputScanner', () => { + it('detects an ANSI-colored marker split across chunks', () => { + const scanner = createCoverageOutputScanner(128); + + scanner.push('\u001b[31mFailed to collect'); + scanner.push(' coverage from /workspace/effects.ts\u001b[39m'); + + assert.equal(scanner.collectionFailed, true); + }); + + it('detects a marker across a split ANSI escape sequence', () => { + const scanner = createCoverageOutputScanner(128); + + scanner.push('Failed to \u001b[3'); + scanner.push('1mcollect coverage from /workspace/effects.ts'); + + assert.equal(scanner.collectionFailed, true); + assert.equal(scanner.tail.includes('\u001b'), false); + }); + + it('keeps only a bounded rolling tail', () => { + const scanner = createCoverageOutputScanner(32); + + scanner.push('x'.repeat(256)); + + assert.equal(scanner.tail.length, 32); + assert.equal(scanner.collectionFailed, false); + }); + + it('detects the marker before trimming a long chunk', () => { + const scanner = createCoverageOutputScanner(32); + + scanner.push(`Failed to collect coverage${'x'.repeat(256)}`); + + assert.equal(scanner.tail.length, 32); + assert.equal(scanner.collectionFailed, true); + }); +}); + +describe('validateProjectCoverage', () => { + it('accepts a report containing every runtime-owning file', () => { + const fixture = makeProjectFixture(); + const relativeReportKey = path.relative( + fixture.workspaceRoot, + fixture.sourcePath + ); + writeCompleteReport(fixture, relativeReportKey); + + const result = validateProjectCoverage(fixture); + + assert.deepEqual(result.errors, []); + assert.equal(result.report?.project.name, 'example'); + assert.equal(result.report?.path, fixture.reportPath); + }); + + it('reports a runtime-owning source omitted from a valid report', () => { + const fixture = makeProjectFixture(); + writeFileSync(fixture.reportPath, '{}'); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /example/); + assert.match(result.errors[0], /libs\/example\/src\/runtime\.ts/); + }); + + it('rejects a malformed Istanbul coverage entry', () => { + const fixture = makeProjectFixture(); + writeFileSync( + fixture.reportPath, + JSON.stringify({ [fixture.sourcePath]: {} }) + ); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.report, undefined); + assert.match( + result.errors[0], + /example.*coverage\/libs\/example\/coverage-final\.json.*valid Istanbul/ + ); + }); + + it('rejects duplicate report paths after normalization', () => { + const fixture = makeProjectFixture(); + const relativePath = path.relative( + fixture.workspaceRoot, + fixture.sourcePath + ); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [relativePath]: coverageEntry(relativePath), + [fixture.sourcePath]: coverageEntry(fixture.sourcePath), + }) + ); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.report, undefined); + assert.equal(result.errors.length, 1); + assert.match( + result.errors[0], + /example.*coverage\/libs\/example\/coverage-final\.json.*duplicate.*libs\/example\/src\/runtime\.ts.*normalization/ + ); + }); + + it('rejects a runtime source without usable instrumentation', () => { + const fixture = makeProjectFixture(); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [fixture.sourcePath]: coverageEntry(fixture.sourcePath, []), + }) + ); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /example/); + assert.match(result.errors[0], /libs\/example\/src\/runtime\.ts/); + assert.match(result.errors[0], /usable instrumentation/); + }); + + it('accepts function-only instrumentation for a runtime source', () => { + const fixture = makeProjectFixture(); + writeFileSync( + fixture.sourcePath, + 'export function runtime() {}\n' + ); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [fixture.sourcePath]: functionOnlyCoverageEntry( + fixture.sourcePath + ), + }) + ); + + const result = validateProjectCoverage(fixture); + + assert.deepEqual(result.errors, []); + assert.equal(result.report?.project.name, 'example'); + }); + + it('does not require excluded or type-only TypeScript sources', () => { + const fixture = makeProjectFixture(); + const excludedSources = [ + 'feature.spec.ts', + 'feature.test.ts', + 'ambient.d.ts', + 'test-setup.ts', + 'test-stubs/runtime.ts', + 'feature.generated.ts', + 'environments/environment.ts', + 'index.ts', + ]; + for (const relativePath of excludedSources) { + const filePath = path.join( + fixture.workspaceRoot, + fixture.project.sourceRoot, + relativePath + ); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, 'export const omitted = true;\n'); + } + writeFileSync( + path.join( + fixture.workspaceRoot, + fixture.project.sourceRoot, + 'contract.ts' + ), + 'export interface Contract { enabled: boolean }\n' + ); + writeCompleteReport(fixture); + + assert.deepEqual(validateProjectCoverage(fixture).errors, []); + }); + + it('reports missing, malformed, and non-object project reports', () => { + const missing = makeProjectFixture(); + const malformed = makeProjectFixture(); + const array = makeProjectFixture(); + const nullValue = makeProjectFixture(); + writeFileSync(malformed.reportPath, '{ invalid json'); + writeFileSync(array.reportPath, '[]'); + writeFileSync(nullValue.reportPath, 'null'); + + assert.match( + validateProjectCoverage(missing).errors[0], + /example.*did not produce.*coverage\/libs\/example\/coverage-final\.json/ + ); + assert.match( + validateProjectCoverage(malformed).errors[0], + /example.*invalid JSON/ + ); + assert.match( + validateProjectCoverage(array).errors[0], + /example.*JSON object/ + ); + assert.match( + validateProjectCoverage(nullValue).errors[0], + /example.*JSON object/ + ); + }); + + it('requires every configured report and preserves policy order', () => { + const first = makeProjectFixture({ + name: 'first', + projectRoot: 'libs/first', + }); + const missing = makeProjectFixture({ + name: 'missing', + projectRoot: 'libs/missing', + workspaceRoot: first.workspaceRoot, + }); + const last = makeProjectFixture({ + name: 'last', + projectRoot: 'libs/last', + workspaceRoot: first.workspaceRoot, + }); + writeCompleteReport(first); + writeCompleteReport(last); + + const result = validateRequiredProjectReports({ + projects: [last.project, missing.project, first.project], + workspaceRoot: first.workspaceRoot, + }); + + assert.deepEqual( + result.reports.map((report) => report.project.name), + ['last', 'first'] + ); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /missing/); + }); +}); + +describe('validateMergedCoverage', () => { + it('reports the exact runtime source omitted from an otherwise valid merged map', () => { + const included = makeProjectFixture({ + name: 'included', + projectRoot: 'libs/included', + }); + const omitted = makeProjectFixture({ + name: 'omitted', + projectRoot: 'libs/omitted', + workspaceRoot: included.workspaceRoot, + }); + writeCompleteReport(included); + writeCompleteReport(omitted); + + assert.deepEqual(validateProjectCoverage(included).errors, []); + assert.deepEqual(validateProjectCoverage(omitted).errors, []); + + const result = validateMergedCoverage({ + coverageData: { + [included.sourcePath]: coverageEntry(included.sourcePath), + }, + projects: [included.project, omitted.project], + reportPath: path.join( + included.workspaceRoot, + 'coverage/merged/coverage-final.json' + ), + workspaceRoot: included.workspaceRoot, + }); + + assert.deepEqual(result.errors, [ + 'Merged coverage report coverage/merged/coverage-final.json is missing runtime-owning source libs/omitted/src/runtime.ts from Tier A project omitted.', + ]); + }); + + it('fails completeness when permissive ratchets accept a recomputed summary', () => { + const fixture = makeProjectFixture({ + name: 'm3u-state', + projectRoot: 'libs/m3u-state', + }); + const effectsPath = path.join( + fixture.workspaceRoot, + fixture.project.sourceRoot, + 'effects.ts' + ); + writeFileSync( + effectsPath, + 'export const effects = () => "runtime";\n' + ); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [fixture.sourcePath]: coverageEntry(fixture.sourcePath, [1]), + [effectsPath]: coverageEntry(effectsPath, [0]), + }) + ); + assert.deepEqual(validateProjectCoverage(fixture).errors, []); + + const coverageData = { + [fixture.sourcePath]: coverageEntry(fixture.sourcePath, [1]), + }; + const completeness = validateMergedCoverage({ + coverageData, + projects: [fixture.project], + reportPath: path.join( + fixture.workspaceRoot, + 'coverage/merged/coverage-final.json' + ), + workspaceRoot: fixture.workspaceRoot, + }); + const recomputedSummary = completeness.coverageMap + .getCoverageSummary() + .toJSON(); + + assert.deepEqual( + evaluateCoverageRatchets({ + coverageData, + mergedSummary: recomputedSummary, + ratchet: ratchetWithCriticalFiles([]), + workspaceRoot: fixture.workspaceRoot, + }), + [] + ); + assert.deepEqual(completeness.errors, [ + 'Merged coverage report coverage/merged/coverage-final.json is missing runtime-owning source libs/m3u-state/src/effects.ts from Tier A project m3u-state.', + ]); + }); + + it('accepts a complete merged map with normalized relative and absolute paths', () => { + const relative = makeProjectFixture({ + name: 'relative', + projectRoot: 'libs/relative', + }); + const absolute = makeProjectFixture({ + name: 'absolute', + projectRoot: 'libs/absolute', + workspaceRoot: relative.workspaceRoot, + }); + const relativePath = path.relative( + relative.workspaceRoot, + relative.sourcePath + ); + + const result = validateMergedCoverage({ + coverageData: { + [relativePath]: coverageEntry(relativePath), + [absolute.sourcePath]: coverageEntry(absolute.sourcePath), + }, + projects: [relative.project, absolute.project], + reportPath: path.join( + relative.workspaceRoot, + 'coverage/merged/coverage-final.json' + ), + workspaceRoot: relative.workspaceRoot, + }); + + assert.deepEqual(result.errors, []); + }); + + it('rejects unusable merged instrumentation for a runtime source', () => { + const fixture = makeProjectFixture(); + + const result = validateMergedCoverage({ + coverageData: { + [fixture.sourcePath]: coverageEntry(fixture.sourcePath, []), + }, + projects: [fixture.project], + reportPath: path.join( + fixture.workspaceRoot, + 'coverage/merged/coverage-final.json' + ), + workspaceRoot: fixture.workspaceRoot, + }); + + assert.deepEqual(result.errors, [ + 'Merged coverage report coverage/merged/coverage-final.json has no usable instrumentation for runtime-owning source libs/example/src/runtime.ts from Tier A project example.', + ]); + }); + + it('rejects duplicate merged paths after normalization', () => { + const fixture = makeProjectFixture(); + const relativePath = path.relative( + fixture.workspaceRoot, + fixture.sourcePath + ); + + const result = validateMergedCoverage({ + coverageData: { + [relativePath]: coverageEntry(relativePath), + [fixture.sourcePath]: coverageEntry(fixture.sourcePath), + }, + projects: [fixture.project], + reportPath: path.join( + fixture.workspaceRoot, + 'coverage/merged/coverage-final.json' + ), + workspaceRoot: fixture.workspaceRoot, + }); + + assert.deepEqual(result.errors, [ + 'Merged coverage report coverage/merged/coverage-final.json contains a duplicate entry for libs/example/src/runtime.ts after path normalization.', + ]); + }); +}); + +describe('evaluateCoverageRatchets', () => { + it('reports all aggregate percentage regressions', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: { + statements: { covered: 68, total: 100, pct: 68 }, + branches: { covered: 57, total: 100, pct: 57 }, + functions: { covered: 67, total: 100, pct: 67 }, + lines: { covered: 69, total: 100, pct: 69 }, + }, + ratchet: { + merged: { + statements: 68.86, + branches: 58.66, + functions: 67.19, + lines: 69.2, + }, + criticalFiles: [], + }, + workspaceRoot: '/workspace', + }); + + assert.deepEqual(errors, [ + 'Merged statements coverage regressed: observed 68%, required at least 68.86%.', + 'Merged branches coverage regressed: observed 57%, required at least 58.66%.', + 'Merged functions coverage regressed: observed 67%, required at least 67.19%.', + 'Merged lines coverage regressed: observed 69%, required at least 69.2%.', + ]); + }); + + it('rejects missing, non-numeric, and out-of-range aggregate minima', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + branches: '58.66', + functions: -1, + lines: 101, + }, + criticalFiles: [], + }, + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 4); + assert.match(errors[0], /merged\.statements.*undefined/); + assert.match(errors[1], /merged\.branches.*"58\.66"/); + assert.match(errors[2], /merged\.functions.*-1/); + assert.match(errors[3], /merged\.lines.*101/); + }); + + it('accepts an explicitly empty criticalFiles array', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: ratchetWithCriticalFiles([]), + workspaceRoot: '/workspace', + }); + + assert.deepEqual(errors, []); + }); + + it('requires an explicit criticalFiles array', () => { + const merged = ratchetWithCriticalFiles([]).merged; + const ratchets = [ + { merged }, + { merged, criticalFiles: null }, + ]; + + for (const ratchet of ratchets) { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet, + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 1); + assert.match(errors[0], /criticalFiles.*array/); + } + }); + + it('rejects a non-array criticalFiles container without throwing', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: ratchetWithCriticalFiles({}), + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 1); + assert.match(errors[0], /criticalFiles.*array.*object/); + }); + + it('requires a non-empty string path for every critical file', () => { + for (const invalidPath of [undefined, null, 42, '', ' ']) { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: ratchetWithCriticalFiles([ + { + path: invalidPath, + statements: { + minimumCovered: 0, + minimumPercent: 0, + }, + }, + ]), + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 1); + assert.match(errors[0], /criticalFiles\[0\]\.path/); + } + }); + + it('rejects invalid critical-file statement minima', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/missing-values.ts', + statements: {}, + }, + { + path: 'apps/string-values.ts', + statements: { + minimumCovered: '2', + minimumPercent: '75', + }, + }, + { + path: 'apps/out-of-range.ts', + statements: { + minimumCovered: -1, + minimumPercent: 101, + }, + }, + { + path: 'apps/fractional.ts', + statements: { + minimumCovered: 1.5, + minimumPercent: 75, + }, + }, + ], + }, + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 7); + assert.match( + errors[0], + /apps\/missing-values\.ts.*minimumCovered.*undefined/ + ); + assert.match( + errors[1], + /apps\/missing-values\.ts.*minimumPercent.*undefined/ + ); + assert.match( + errors[2], + /apps\/string-values\.ts.*minimumCovered.*"2"/ + ); + assert.match( + errors[3], + /apps\/string-values\.ts.*minimumPercent.*"75"/ + ); + assert.match( + errors[4], + /apps\/out-of-range\.ts.*minimumCovered.*-1/ + ); + assert.match( + errors[5], + /apps\/out-of-range\.ts.*minimumPercent.*101/ + ); + assert.match( + errors[6], + /apps\/fractional\.ts.*minimumCovered.*1\.5/ + ); + }); + + it('requires both covered statements and percentage for a critical file', () => { + const workspaceRoot = '/workspace'; + const filePath = path.join(workspaceRoot, 'apps/runtime.ts'); + const errors = evaluateCoverageRatchets({ + coverageData: { + [filePath]: coverageEntry(filePath, [1, 0]), + }, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/runtime.ts', + statements: { + minimumCovered: 2, + minimumPercent: 75, + }, + }, + ], + }, + workspaceRoot, + }); + + assert.deepEqual(errors, [ + 'Critical coverage apps/runtime.ts statements covered regressed: observed 1, required at least 2.', + 'Critical coverage apps/runtime.ts statements percent regressed: observed 50%, required at least 75%.', + ]); + }); + + it('looks up a relative Istanbul key by its normalized critical path', () => { + const workspaceRoot = process.cwd(); + const relativePath = 'apps/runtime.ts'; + const errors = evaluateCoverageRatchets({ + coverageData: { + [relativePath]: coverageEntry(relativePath, [1, 1]), + }, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: relativePath, + statements: { + minimumCovered: 2, + minimumPercent: 100, + }, + }, + ], + }, + workspaceRoot, + }); + + assert.deepEqual(errors, []); + }); + + it('rejects duplicate Istanbul keys after path normalization', () => { + const workspaceRoot = process.cwd(); + const relativePath = 'apps/runtime.ts'; + const absolutePath = path.join(workspaceRoot, relativePath); + const errors = evaluateCoverageRatchets({ + coverageData: { + [relativePath]: coverageEntry(relativePath, [1]), + [absolutePath]: coverageEntry(absolutePath, [1]), + }, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [], + }, + workspaceRoot, + }); + + assert.equal(errors.length, 1); + assert.match( + errors[0], + /duplicate.*apps\/runtime\.ts.*normalization/ + ); + }); + + it('reports a critical file missing from merged coverage', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/missing.ts', + statements: { + minimumCovered: 1, + minimumPercent: 0, + }, + }, + ], + }, + workspaceRoot: '/workspace', + }); + + assert.deepEqual(errors, [ + 'Critical coverage apps/missing.ts is missing from merged coverage.', + ]); + }); +}); diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index 2c1b8011e..03128351e 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -3,7 +3,45 @@ "reporting": { "mergedCoverageDir": "coverage/merged", "e2eSummaryDir": "coverage/e2e", - "unitCodecovFlag": "unit" + "unitCodecovFlag": "unit", + "coverageRatchet": { + "merged": { + "statements": 69.54, + "branches": 59.07, + "functions": 67.44, + "lines": 69.85 + }, + "criticalFiles": [ + { + "path": "apps/electron-backend/src/app/server/http-server.ts", + "statements": { + "minimumCovered": 83, + "minimumPercent": 90.21 + } + }, + { + "path": "apps/electron-backend/src/app/events/remote-control.events.ts", + "statements": { + "minimumCovered": 112, + "minimumPercent": 96.55 + } + }, + { + "path": "apps/electron-backend/src/app/events/settings.events.ts", + "statements": { + "minimumCovered": 26, + "minimumPercent": 96.29 + } + }, + { + "path": "apps/electron-backend/src/app/events/database/downloads.events.ts", + "statements": { + "minimumCovered": 86, + "minimumPercent": 58.5 + } + } + ] + } }, "unitCoverage": { "description": "Tier A projects collect and trend source coverage. Tier B projects are validated without percentage coverage. Tier C projects are excluded from the source coverage baseline.", diff --git a/tools/coverage/merge-coverage.mjs b/tools/coverage/merge-coverage.mjs index 36b006dfb..901907005 100644 --- a/tools/coverage/merge-coverage.mjs +++ b/tools/coverage/merge-coverage.mjs @@ -2,7 +2,6 @@ import { createRequire } from 'node:module'; import { - existsSync, mkdirSync, readFileSync, rmSync, @@ -11,6 +10,8 @@ import { import path from 'node:path'; import process from 'node:process'; +import { validateRequiredProjectReports } from './coverage-integrity.mjs'; + const require = createRequire(import.meta.url); const { createCoverageMap } = require('istanbul-lib-coverage'); const libReport = require('istanbul-lib-report'); @@ -22,11 +23,20 @@ const policy = JSON.parse( ); const outputDir = path.join(workspaceRoot, policy.reporting.mergedCoverageDir); -const coverageFiles = policy.unitCoverage.tierA - .map((project) => path.join(workspaceRoot, 'coverage', project.root, 'coverage-final.json')) - .filter((coverageFile) => existsSync(coverageFile)); +const validation = validateRequiredProjectReports({ + projects: policy.unitCoverage.tierA, + workspaceRoot, +}); -if (coverageFiles.length === 0) { +if (validation.errors.length > 0) { + for (const error of validation.errors) { + console.error(`Error: ${error}`); + } + process.exit(1); +} + +const coverageInputs = validation.reports; +if (coverageInputs.length === 0) { console.error('No Tier A coverage-final.json files found under coverage/.'); process.exit(1); } @@ -36,9 +46,8 @@ mkdirSync(outputDir, { recursive: true }); const coverageMap = createCoverageMap({}); -for (const coverageFile of coverageFiles) { - const data = JSON.parse(readFileSync(coverageFile, 'utf8')); - coverageMap.merge(data); +for (const input of coverageInputs) { + coverageMap.merge(input.data); } const context = libReport.createContext({ @@ -56,7 +65,7 @@ writeFileSync( `${JSON.stringify(summary, null, 4)}\n` ); -console.log(`Merged ${coverageFiles.length} coverage files into ${policy.reporting.mergedCoverageDir}`); +console.log(`Merged ${coverageInputs.length} coverage files into ${policy.reporting.mergedCoverageDir}`); console.log( `Statements: ${summary.statements.pct}% | Branches: ${summary.branches.pct}% | Functions: ${summary.functions.pct}% | Lines: ${summary.lines.pct}%` ); diff --git a/tools/coverage/run-tier-a-coverage.mjs b/tools/coverage/run-tier-a-coverage.mjs index ecf553363..c9dda4974 100644 --- a/tools/coverage/run-tier-a-coverage.mjs +++ b/tools/coverage/run-tier-a-coverage.mjs @@ -1,10 +1,15 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { existsSync, readFileSync, rmSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { + createCoverageOutputScanner, + validateProjectCoverage, +} from './coverage-integrity.mjs'; + const workspaceRoot = process.cwd(); const policyPath = path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'); const policy = JSON.parse(readFileSync(policyPath, 'utf8')); @@ -120,6 +125,64 @@ function buildNxArgs(project) { ); } +function spawnCoverage(args, scanner) { + return new Promise((resolve, reject) => { + const child = spawn('pnpm', args, { + cwd: workspaceRoot, + env: { + ...process.env, + CI: process.env.CI ?? 'true', + NX_TASKS_RUNNER_DYNAMIC_OUTPUT: 'false', + }, + stdio: ['inherit', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (chunk) => { + scanner.push(chunk); + process.stdout.write(chunk); + }); + child.stderr.on('data', (chunk) => { + scanner.push(chunk); + process.stderr.write(chunk); + }); + child.once('error', reject); + child.once('close', (code, signal) => { + resolve({ code, signal }); + }); + }); +} + +async function collectProjectCoverage(project) { + const args = buildNxArgs(project); + console.log(`\n==> Collecting coverage for ${project.name}`); + console.log(`pnpm ${args.join(' ')}`); + + const scanner = createCoverageOutputScanner(); + const result = await spawnCoverage(args, scanner); + let failed = result.code !== 0 || result.signal !== null; + + if (scanner.collectionFailed) { + console.error( + `Coverage collection failed while testing ${project.name}.` + ); + failed = true; + } + + if (failed) { + return result.code && result.code !== 0 ? result.code : 1; + } + + const validation = validateProjectCoverage({ + project, + workspaceRoot, + }); + for (const error of validation.errors) { + console.error(`Error: ${error}`); + } + + return validation.errors.length === 0 ? 0 : 1; +} + for (const project of tierAProjects) { const coverageDir = path.join(workspaceRoot, 'coverage', project.root); rmSync(coverageDir, { recursive: true, force: true }); @@ -136,21 +199,8 @@ if (requestedProjects.size === 0) { } for (const project of tierAProjects) { - const args = buildNxArgs(project); - console.log(`\n==> Collecting coverage for ${project.name}`); - console.log(`pnpm ${args.join(' ')}`); - - const result = spawnSync('pnpm', args, { - cwd: workspaceRoot, - env: { - ...process.env, - CI: process.env.CI ?? 'true', - NX_TASKS_RUNNER_DYNAMIC_OUTPUT: 'false', - }, - stdio: 'inherit', - }); - - if (result.status !== 0) { - process.exit(result.status ?? 1); + const status = await collectProjectCoverage(project); + if (status !== 0) { + process.exit(status); } } From 28ed38906d07089cea122d2204b73fb63fa9e4d2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 10:00:24 +0200 Subject: [PATCH 06/32] test(perf): add production renderer benchmark build --- apps/electron-backend-e2e/project.json | 9 +- .../performance-build-config.spec.ts | 173 ++++++++++++++++++ apps/electron-backend/project.json | 26 +++ apps/remote-control-web/project.json | 11 ++ apps/web/project.json | 27 +++ 5 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 1918e34fa..51ed910a8 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -12,6 +12,13 @@ "e2e": { "dependsOn": ["electron-backend:build-e2e"] }, + "test-performance-harness": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec tsx --test src/performance/*.spec.ts" + } + }, "benchmark-m3u-refresh-cancellation": { "dependsOn": ["electron-backend:build-e2e"], "executor": "nx:run-commands", @@ -19,7 +26,7 @@ "parallelism": false, "options": { "cwd": "apps/electron-backend-e2e", - "command": "pnpm exec playwright test --config=playwright.performance.config.ts" + "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts" } }, "packaged-frame-copy-smoke": { diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts new file mode 100644 index 000000000..c333d54ab --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -0,0 +1,173 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based configuration contract tests. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { join } from 'node:path'; + +interface TargetConfiguration { + configurations?: Record>; + dependsOn?: unknown; + executor?: unknown; + options?: Record; +} + +interface ProjectConfiguration { + targets: Record; +} + +const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url)); + +function readProject(relativePath: string): ProjectConfiguration { + return JSON.parse( + readFileSync(join(workspaceRoot, relativePath), 'utf8') + ) as ProjectConfiguration; +} + +const e2eProject = readProject('apps/electron-backend-e2e/project.json'); +const electronProject = readProject('apps/electron-backend/project.json'); +const remoteControlProject = readProject( + 'apps/remote-control-web/project.json' +); +const webProject = readProject('apps/web/project.json'); + +test('the performance harness target runs only Node performance specs', () => { + const target = e2eProject.targets['test-performance-harness']; + + assert.ok( + target, + 'electron-backend-e2e must define test-performance-harness' + ); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal(target.options?.['cwd'], 'apps/electron-backend-e2e'); + assert.equal( + target.options?.['command'], + 'pnpm exec tsx --test src/performance/*.spec.ts' + ); +}); + +test('the web performance build keeps production renderer behavior with profiling source maps', () => { + const build = webProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'web:build must define production'); + assert.ok(performance, 'web:build must define electron-performance'); + assert.equal(performance['baseHref'], './'); + assert.equal(performance['serviceWorker'], false); + assert.deepEqual(performance['optimization'], production['optimization']); + assert.equal(performance['outputHashing'], production['outputHashing']); + assert.deepEqual( + performance['fileReplacements'], + production['fileReplacements'] + ); + assert.equal(performance['sourceMap'], true); +}); + +test('the remote-control performance build keeps optimized hashed output with profiling source maps', () => { + const build = remoteControlProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'remote-control-web:build must define production'); + assert.ok( + performance, + 'remote-control-web:build must define electron-performance' + ); + assert.equal(performance['optimization'], true); + assert.equal(performance['outputHashing'], production['outputHashing']); + assert.equal(performance['sourceMap'], true); +}); + +test('the Electron performance build keeps production main-process behavior with profiling source maps', () => { + const build = electronProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'electron-backend:build must define production'); + assert.ok( + performance, + 'electron-backend:build must define electron-performance' + ); + assert.equal(performance['optimization'], production['optimization']); + assert.equal(performance['inspect'], production['inspect']); + assert.deepEqual( + performance['fileReplacements'], + production['fileReplacements'] + ); + assert.equal(performance['sourceMap'], true); +}); + +test('the regular Electron build keeps its existing renderer dependency contract', () => { + const build = electronProject.targets['build']; + const dependencies = build.dependsOn as Array< + Record | string + >; + const rendererBuild = dependencies.find( + (dependency): dependency is Record => + typeof dependency === 'object' && + dependency !== null && + dependency['target'] === 'build' + ); + + assert.deepEqual(rendererBuild, { + projects: ['web', 'remote-control-web'], + target: 'build', + }); +}); + +test('the web performance wrapper selects the profiling configuration', () => { + const target = webProject.targets['build-performance']; + + assert.ok(target, 'web must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal( + target.options?.['command'], + 'pnpm nx run web:build:electron-performance' + ); +}); + +test('the remote-control performance wrapper selects the profiling configuration', () => { + const target = remoteControlProject.targets['build-performance']; + + assert.ok(target, 'remote-control-web must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal( + target.options?.['command'], + 'pnpm nx run remote-control-web:build:electron-performance' + ); +}); + +test('the Electron performance wrapper owns build dependencies before its profiled build', () => { + const target = electronProject.targets['build-performance']; + + assert.ok(target, 'electron-backend must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.deepEqual(target.dependsOn, [ + 'electron-backend:build-worker', + 'electron-backend:build-embedded-mpv', + { + projects: ['web', 'remote-control-web'], + target: 'build-performance', + }, + ]); + assert.equal( + target.options?.['command'], + 'pnpm nx run electron-backend:build:electron-performance --excludeTaskDependencies' + ); +}); + +test('the cancellation benchmark keeps its existing E2E build dependency', () => { + const target = e2eProject.targets['benchmark-m3u-refresh-cancellation']; + + assert.deepEqual(target.dependsOn, ['electron-backend:build-e2e']); +}); + +test('the cancellation benchmark command is pinned to its Playwright test file', () => { + const target = e2eProject.targets['benchmark-m3u-refresh-cancellation']; + + assert.equal( + target.options?.['command'], + 'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts' + ); +}); diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 8e6b9febb..37b1e7e07 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -81,9 +81,35 @@ "with": "apps/electron-backend/src/environments/environment.prod.ts" } ] + }, + "electron-performance": { + "optimization": true, + "extractLicenses": true, + "inspect": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/electron-backend/src/environments/environment.ts", + "with": "apps/electron-backend/src/environments/environment.prod.ts" + } + ] } } }, + "build-performance": { + "dependsOn": [ + "electron-backend:build-worker", + "electron-backend:build-embedded-mpv", + { + "projects": ["web", "remote-control-web"], + "target": "build-performance" + } + ], + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run electron-backend:build:electron-performance --excludeTaskDependencies" + } + }, "build-e2e": { "dependsOn": [ "electron-backend:build-worker", diff --git a/apps/remote-control-web/project.json b/apps/remote-control-web/project.json index 5819a8bf5..8c65582c8 100644 --- a/apps/remote-control-web/project.json +++ b/apps/remote-control-web/project.json @@ -49,6 +49,11 @@ ], "outputHashing": "all" }, + "electron-performance": { + "optimization": true, + "outputHashing": "all", + "sourceMap": true + }, "development": { "optimization": false, "extractLicenses": false, @@ -63,6 +68,12 @@ "command": "pnpm nx run remote-control-web:build:development" } }, + "build-performance": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run remote-control-web:build:electron-performance" + } + }, "serve": { "continuous": true, "executor": "@angular/build:dev-server", diff --git a/apps/web/project.json b/apps/web/project.json index f7b179709..3fb7392b3 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -122,6 +122,27 @@ "extractLicenses": false, "sourceMap": true }, + "electron-performance": { + "baseHref": "./", + "serviceWorker": false, + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false, + "removeSpecialComments": true + }, + "fonts": true + }, + "outputHashing": "all", + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/web/src/environments/environment.ts", + "with": "apps/web/src/environments/environment.prod.ts" + } + ] + }, "electron-e2e": { "baseHref": "./", "serviceWorker": false, @@ -138,6 +159,12 @@ "command": "pnpm nx run web:build:electron-e2e" } }, + "build-performance": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run web:build:electron-performance" + } + }, "serve": { "continuous": true, "executor": "@angular/build:dev-server", From 2cc7d0acb11f4f444fc4e1956f5f6ec5d315915d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 10:24:12 +0200 Subject: [PATCH 07/32] fix(perf): declare renderer cache output path --- .../performance-build-config.spec.ts | 53 +++++++++++++++++++ apps/web/project.json | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index c333d54ab..33f81351b 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable playwright/expect-expect -- These are Node assertion-based configuration contract tests. */ import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; @@ -16,6 +17,20 @@ interface ProjectConfiguration { targets: Record; } +interface NxGraphTask { + outputs?: string[]; + target: { + project: string; + target: string; + }; +} + +interface NxGraph { + tasks: { + tasks: Record; + }; +} + const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url)); function readProject(relativePath: string): ProjectConfiguration { @@ -24,6 +39,37 @@ function readProject(relativePath: string): ProjectConfiguration { ) as ProjectConfiguration; } +function readResolvedWebBuildTask(): NxGraphTask | undefined { + const environment = { + ...process.env, + FORCE_COLOR: '0', + NX_DAEMON: 'false', + }; + delete environment['NO_COLOR']; + + const graph = JSON.parse( + execFileSync( + process.execPath, + [ + join(workspaceRoot, 'node_modules/nx/dist/bin/nx.js'), + 'run', + 'web:build', + '--graph=stdout', + ], + { + cwd: workspaceRoot, + encoding: 'utf8', + env: environment, + } + ) + ) as NxGraph; + + return Object.values(graph.tasks.tasks).find( + (task) => + task.target.project === 'web' && task.target.target === 'build' + ); +} + const e2eProject = readProject('apps/electron-backend-e2e/project.json'); const electronProject = readProject('apps/electron-backend/project.json'); const remoteControlProject = readProject( @@ -64,6 +110,13 @@ test('the web performance build keeps production renderer behavior with profilin assert.equal(performance['sourceMap'], true); }); +test('the resolved web build cache output is the renderer directory', () => { + const task = readResolvedWebBuildTask(); + + assert.ok(task, 'the Nx graph must include web:build'); + assert.deepEqual(task.outputs, ['dist/apps/web']); +}); + test('the remote-control performance build keeps optimized hashed output with profiling source maps', () => { const build = remoteControlProject.targets['build']; const production = build.configurations?.['production']; diff --git a/apps/web/project.json b/apps/web/project.json index 3fb7392b3..302ea2e37 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -13,7 +13,7 @@ "targets": { "build": { "executor": "@angular/build:application", - "outputs": ["{options.outputPath}"], + "outputs": ["{workspaceRoot}/dist/apps/web"], "options": { "outputPath": { "base": "dist/apps/web", From e3ce60a35ee9b861128dce33b8e56586af020453 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 11:10:01 +0200 Subject: [PATCH 08/32] chore(electron): add preload performance markers --- AGENTS.md | 1 + CLAUDE.md | 1 + .../main.preload.performance.contract.spec.ts | 318 ++++++++++++++++ .../main.preload.performance.gates.spec.ts | 149 ++++++++ .../main.preload.performance.test-helpers.ts | 162 +++++++++ .../src/app/api/main.preload.ts | 145 +++++--- .../api/preload-performance-capture.spec.ts | 50 +++ .../app/api/preload-performance-capture.ts | 191 ++++++++++ ...reload-performance-correlation.complete.ts | 235 ++++++++++++ ...performance-correlation.completion.spec.ts | 314 ++++++++++++++++ .../preload-performance-correlation.model.ts | 178 +++++++++ ...d-performance-correlation.sequence.spec.ts | 338 ++++++++++++++++++ .../preload-performance-correlation.start.ts | 251 +++++++++++++ ...ad-performance-correlation.test-helpers.ts | 29 ++ .../api/preload-performance-correlation.ts | 60 ++++ ...performance-correlation.validation.spec.ts | 117 ++++++ .../src/app/services/debug-trace.ts | 4 + libs/shared/interfaces/src/index.ts | 1 + .../preload-performance-marker.interface.ts | 52 +++ 19 files changed, 2547 insertions(+), 49 deletions(-) create mode 100644 apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts create mode 100644 apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts create mode 100644 apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-capture.spec.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-capture.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.model.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.start.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts create mode 100644 libs/shared/interfaces/src/lib/preload-performance-marker.interface.ts diff --git a/AGENTS.md b/AGENTS.md index 014efff5b..f3cd7a990 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal + - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset - Settings, portal request/response, and trace payloads must use diff --git a/CLAUDE.md b/CLAUDE.md index cb8cbbe18..72eed72c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,6 +141,7 @@ Useful narrower flags: - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal +- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset Settings, portal request/response, and trace payloads must use diff --git a/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts new file mode 100644 index 000000000..f2956075d --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts @@ -0,0 +1,318 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type Playlist, + type PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { + createPlaylist, + createPreloadPerformanceHarness, + createRefreshPayload, + expectFixedMarkers, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker contract', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + }); + + async function loadPerformancePreload(): Promise { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + } + + it('preserves calls while emitting ordered, correlated, fixed, redacted markers', async () => { + const secrets = { + favorite: 'sentinel-favorite-secret', + filePath: '/sentinel/private/playlist.m3u', + host: 'sentinel.private.example', + item: 'sentinel-item-secret', + result: 'sentinel-result-secret', + title: 'sentinel-title-secret', + url: 'https://sentinel.example/private.m3u?token=secret', + }; + const payload = createRefreshPayload({ + filePath: secrets.filePath, + title: secrets.title, + trustedInsecureTlsHosts: [secrets.host], + url: secrets.url, + }); + const refreshedPlaylist = createPlaylist(payload.playlistId, { + favorites: [secrets.favorite], + items: [{ name: secrets.item }], + title: secrets.title, + url: secrets.url, + }); + const storedPlaylist = createPlaylist(payload.playlistId, { + filePath: secrets.filePath, + title: secrets.title, + }); + const upsertPlaylist = createPlaylist(payload.playlistId, { + favorites: [secrets.favorite], + items: [{ name: secrets.item }], + title: secrets.title, + }); + const upsertResult = { + secretResult: secrets.result, + success: true, + }; + await loadPerformancePreload(); + harness.ipcRenderer.invoke + .mockResolvedValueOnce(refreshedPlaylist) + .mockResolvedValueOnce(storedPlaylist) + .mockResolvedValueOnce(upsertResult); + const api = harness.getApi(); + + await expect(api.refreshPlaylist(payload)).resolves.toBe( + refreshedPlaylist + ); + await expect(api.dbGetAppPlaylist(payload.playlistId)).resolves.toBe( + storedPlaylist + ); + await expect(api.dbUpsertAppPlaylist(upsertPlaylist)).resolves.toBe( + upsertResult + ); + + expect(harness.ipcRenderer.invoke.mock.calls).toEqual([ + ['PLAYLIST:REFRESH', payload], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', upsertPlaylist], + ]); + const markers = harness.getMarkers(); + expect(markers).toHaveLength(6); + expect( + markers.map(({ ipcCallId, method, phase }) => [ + ipcCallId, + method, + phase, + ]) + ).toEqual([ + [1, PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, 'start'], + [1, PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, 'success'], + [2, PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, 'start'], + [2, PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, 'success'], + [3, PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, 'start'], + [3, PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, 'success'], + ]); + expect(markers.map(({ correlationState }) => correlationState)).toEqual( + [ + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + ] + ); + expect(markers.map(({ operationId }) => operationId)).toEqual( + Array(6).fill(payload.operationId) + ); + expect(markers.map(({ playlistId }) => playlistId)).toEqual( + Array(6).fill(payload.playlistId) + ); + expect(markers.map(({ invalidReason }) => invalidReason)).toEqual( + Array(6).fill(null) + ); + expectFixedMarkers(markers); + + const markerSendOrders = harness.ipcRenderer.send.mock.calls.flatMap( + ([channel], index) => + channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL + ? [harness.ipcRenderer.send.mock.invocationCallOrder[index]] + : [] + ); + const invokeOrders = + harness.ipcRenderer.invoke.mock.invocationCallOrder; + for (const [index, invokeOrder] of invokeOrders.entries()) { + expect(markerSendOrders[index * 2]).toBeLessThan(invokeOrder); + } + + const serializedMarkers = JSON.stringify(markers); + for (const secret of Object.values(secrets)) { + expect(serializedMarkers).not.toContain(secret); + } + }); + + it('preserves rejection identity and emits a redacted error', async () => { + const payload = createRefreshPayload({ + title: 'sentinel-rejection-title', + url: 'https://sentinel.example/rejected.m3u', + }); + const rejection = new Error('sentinel-rejection-error'); + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection); + + await expect(harness.getApi().refreshPlaylist(payload)).rejects.toBe( + rejection + ); + + const markers = harness.getMarkers(); + expect(markerPhases(markers)).toEqual([ + [1, 'start'], + [1, 'error'], + ]); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + operationId: payload.operationId, + playlistId: payload.playlistId, + }); + expect(JSON.stringify(markers)).not.toContain(rejection.message); + expect(JSON.stringify(markers)).not.toContain(payload.title); + expect(JSON.stringify(markers)).not.toContain(payload.url); + }); + + it('preserves synchronous throw identity and emits a redacted error', async () => { + const synchronousError = new Error('sentinel-synchronous-error'); + const payload = createRefreshPayload(); + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockImplementationOnce(() => { + throw synchronousError; + }); + + let caughtError: unknown; + try { + harness.getApi().refreshPlaylist(payload); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBe(synchronousError); + const markers = harness.getMarkers(); + expect(markerPhases(markers)).toEqual([ + [1, 'start'], + [1, 'error'], + ]); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + }); + expect(JSON.stringify(markers)).not.toContain(synchronousError.message); + }); + + it('closes a structured refresh cancellation with a fixed reason', async () => { + const payload = createRefreshPayload(); + const cancelledResult = { + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + } as const; + await loadPerformancePreload(); + harness.ipcRenderer.invoke + .mockResolvedValueOnce(cancelledResult) + .mockResolvedValueOnce(null); + const api = harness.getApi(); + + await expect(api.refreshPlaylist(payload)).resolves.toBe( + cancelledResult + ); + await api.dbGetAppPlaylist(payload.playlistId); + + const markers = harness.getMarkers(); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED, + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: payload.operationId, + phase: 'success', + playlistId: payload.playlistId, + }); + expect(markers[2]).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + }); + }); + + it('uses the exact upsert _id and keeps one malformed reason in both phases', async () => { + const malformedPlaylist = { + ...createPlaylist('placeholder'), + _id: { + secret: 'sentinel-malformed-playlist-id', + }, + } as unknown as Playlist; + const result = { success: true }; + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect( + harness.getApi().dbUpsertAppPlaylist(malformedPlaylist) + ).resolves.toBe(result); + expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith( + 'DB_UPSERT_APP_PLAYLIST', + malformedPlaylist + ); + + const markers = harness.getMarkers(); + expect( + markers.map( + ({ correlationState, invalidReason, phase, playlistId }) => ({ + correlationState, + invalidReason, + phase, + playlistId, + }) + ) + ).toEqual([ + { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + phase: 'start', + playlistId: null, + }, + { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + phase: 'success', + playlistId: null, + }, + ]); + expect(markers[0].ipcCallId).toBe(markers[1].ipcCallId); + expect(JSON.stringify(markers)).not.toContain( + 'sentinel-malformed-playlist-id' + ); + }); + + it('keeps results unchanged when marker delivery throws', async () => { + const payload = createRefreshPayload(); + const result = createPlaylist(payload.playlistId); + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + harness.ipcRenderer.send.mockImplementation((channel: string) => { + if (channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL) { + throw new Error('marker-send-failed'); + } + }); + + await expect(harness.getApi().refreshPlaylist(payload)).resolves.toBe( + result + ); + expect( + harness.ipcRenderer.send.mock.calls.filter( + ([channel]) => channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL + ) + ).toHaveLength(2); + }); +}); + +function markerPhases( + markers: PreloadPerformanceMarker[] +): [number, PreloadPerformanceMarker['phase']][] { + return markers.map(({ ipcCallId, phase }) => [ipcCallId, phase]); +} diff --git a/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts new file mode 100644 index 000000000..36b8a2b1b --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts @@ -0,0 +1,149 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type Settings, +} from '@iptvnator/shared/interfaces'; +import { DEBUG_TRACE_EVENT_CHANNEL } from '../services/debug-trace'; +import { + createPreloadPerformanceHarness, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker gates', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + }); + + it.each([undefined, '', '0', 'false', 'no', 'off'])( + 'emits no marker when the performance flag is %s', + async (flagValue) => { + await harness.load( + flagValue === undefined + ? {} + : { + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue, + } + ); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect(harness.getMarkers()).toEqual([]); + } + ); + + it.each(['1', ' TRUE ', 'yes', 'On'])( + 'accepts the existing true flag value %s', + async (flagValue) => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue, + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness.getMarkers().map(({ correlationState, phase }) => ({ + correlationState, + phase, + })) + ).toEqual([ + { + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + phase: 'start', + }, + { + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + phase: 'success', + }, + ]); + } + ); + + it('keeps trace-only calls on the existing debug channel', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness + .getSentPayloads< + Record + >(DEBUG_TRACE_EVENT_CHANNEL) + .map(({ method, phase }) => ({ method, phase })) + ).toEqual([ + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + ]); + expect(harness.getMarkers()).toEqual([]); + }); + + it('keeps debug tracing unchanged when both gates are enabled', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness + .getSentPayloads< + Record + >(DEBUG_TRACE_EVENT_CHANNEL) + .map(({ method, phase }) => ({ method, phase })) + ).toEqual([ + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + ]); + expect( + harness.getSentPayloads(PRELOAD_PERFORMANCE_MARKER_CHANNEL).length + ).toBe(2); + }); + + it('does not inspect or mark non-target calls in performance-only mode', async () => { + const result = { success: true }; + const settings = Object.defineProperty({}, 'sentinel', { + enumerable: true, + get: () => { + throw new Error('non-target arguments must stay opaque'); + }, + }) as Partial; + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect(harness.getApi().updateSettings(settings)).resolves.toBe( + result + ); + + expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith( + 'SETTINGS_UPDATE', + settings + ); + expect(harness.getMarkers()).toEqual([]); + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts b/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts new file mode 100644 index 000000000..69c8a7e0f --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts @@ -0,0 +1,162 @@ +import type { + ElectronBridgeApi, + Playlist, + PlaylistRefreshPayload, + PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; + +export const PRELOAD_ENVIRONMENT = { + PERF_CAPTURE: 'IPTVNATOR_PERF_CAPTURE', + TRACE_IPC: 'IPTVNATOR_TRACE_IPC', + TRACE_STARTUP: 'IPTVNATOR_TRACE_STARTUP', +} as const; + +type PreloadEnvironmentName = + (typeof PRELOAD_ENVIRONMENT)[keyof typeof PRELOAD_ENVIRONMENT]; + +type PreloadEnvironment = Partial>; + +export type ExposedElectronApi = ElectronBridgeApi & + Record unknown>; + +export interface MockIpcRenderer { + invoke: jest.Mock; + off: jest.Mock; + on: jest.Mock; + send: jest.Mock; +} + +export interface PreloadPerformanceHarness { + cleanup: () => void; + getApi: () => ExposedElectronApi; + getMarkers: () => PreloadPerformanceMarker[]; + getSentPayloads: (channel: string) => T[]; + ipcRenderer: MockIpcRenderer; + load: (environment?: PreloadEnvironment) => Promise; +} + +const MARKER_KEYS = [ + 'correlationState', + 'invalidReason', + 'ipcCallId', + 'method', + 'operationId', + 'phase', + 'playlistId', + 'sourceEpochMs', +] as const; + +export function createPlaylist( + playlistId: string, + overrides: Partial = {} +): Playlist { + return { + _id: playlistId, + autoRefresh: false, + count: 1, + importDate: '2026-07-26T00:00:00.000Z', + lastUsage: '2026-07-26T00:00:00.000Z', + title: 'Performance fixture', + ...overrides, + }; +} + +export function createRefreshPayload( + overrides: Partial = {} +): PlaylistRefreshPayload { + return { + operationId: 'operation-1', + playlistId: 'playlist-1', + title: 'Performance fixture', + ...overrides, + }; +} + +export function expectFixedMarkers(markers: PreloadPerformanceMarker[]): void { + for (const [index, marker] of markers.entries()) { + expect(Object.keys(marker).sort()).toEqual([...MARKER_KEYS].sort()); + expect(Number.isFinite(marker.sourceEpochMs)).toBe(true); + if (index > 0) { + expect(marker.sourceEpochMs).toBeGreaterThanOrEqual( + markers[index - 1].sourceEpochMs + ); + } + } +} + +export function createPreloadPerformanceHarness(): PreloadPerformanceHarness { + const originalEnvironment = new Map< + PreloadEnvironmentName, + string | undefined + >( + Object.values(PRELOAD_ENVIRONMENT).map((name) => [ + name, + process.env[name], + ]) + ); + let exposedApi: ExposedElectronApi | null = null; + const ipcRenderer: MockIpcRenderer = { + invoke: jest.fn(), + off: jest.fn(), + on: jest.fn(), + send: jest.fn(), + }; + + function getSentPayloads(channel: string): T[] { + return ipcRenderer.send.mock.calls + .filter(([sentChannel]) => sentChannel === channel) + .map(([, payload]) => payload as T); + } + + return { + cleanup: () => { + for (const [name, value] of originalEnvironment) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + jest.dontMock('electron'); + }, + getApi: () => { + if (!exposedApi) { + throw new Error('Expected preload API to be exposed'); + } + return exposedApi; + }, + getMarkers: () => + getSentPayloads( + 'IPTVNATOR_PERF_CAPTURE_MARKER' + ), + getSentPayloads, + ipcRenderer, + load: async (environment = {}) => { + jest.resetModules(); + for (const name of Object.values(PRELOAD_ENVIRONMENT)) { + delete process.env[name]; + } + Object.assign(process.env, environment); + exposedApi = null; + ipcRenderer.invoke.mockReset().mockResolvedValue({ success: true }); + ipcRenderer.off.mockReset(); + ipcRenderer.on.mockReset(); + ipcRenderer.send.mockReset(); + jest.doMock('electron', () => ({ + contextBridge: { + exposeInMainWorld: jest.fn( + (_name: string, api: ExposedElectronApi) => { + exposedApi = api; + } + ), + }, + ipcRenderer, + webUtils: { + getPathForFile: jest.fn(), + }, + })); + + await import('./main.preload'); + }, + }; +} diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index bd078b223..e93d611dd 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -11,6 +11,10 @@ import { attachEmbeddedMpvFrameView, detachEmbeddedMpvFrameView, } from './embedded-mpv-frame-pump'; +import { + createPreloadPerformanceCapture, + toPreloadPerformanceTargetMethod, +} from './preload-performance-capture'; import type { EmbeddedMpvBounds, EmbeddedMpvRecordingStartOptions, @@ -48,6 +52,7 @@ import type { } from '@iptvnator/shared/interfaces'; import { DEBUG_TRACE_EVENT_CHANNEL, + isPerformanceCaptureEnabled, isRendererApiTraceEnabled, roundTraceDuration, summarizeForTrace, @@ -72,6 +77,11 @@ const dbSaveContentProgressListeners = new Set< >(); const shouldTraceRendererApi = isRendererApiTraceEnabled(); +const shouldCapturePerformance = isPerformanceCaptureEnabled(); +const preloadPerformanceCapture = createPreloadPerformanceCapture( + shouldCapturePerformance, + (channel, marker) => ipcRenderer.send(channel, marker) +); function emitRendererTrace(payload: { method: string; @@ -89,7 +99,7 @@ function emitRendererTrace(payload: { } function wrapElectronApi(api: T): T { - if (!shouldTraceRendererApi) { + if (!shouldTraceRendererApi && !shouldCapturePerformance) { return api; } @@ -98,7 +108,9 @@ function wrapElectronApi(api: T): T { if ( typeof value !== 'function' || name.startsWith('on') || - name.startsWith('remove') + name.startsWith('remove') || + (!shouldTraceRendererApi && + !toPreloadPerformanceTargetMethod(name)) ) { return [name, value]; } @@ -108,13 +120,20 @@ function wrapElectronApi(api: T): T { return [ name, (...args: unknown[]) => { - const startedAt = - globalThis.performance?.now?.() ?? Date.now(); - emitRendererTrace({ - args: summarizeForTrace(args), - method: name, - phase: 'start', - }); + const startedAt = shouldTraceRendererApi + ? (globalThis.performance?.now?.() ?? Date.now()) + : 0; + if (shouldTraceRendererApi) { + emitRendererTrace({ + args: summarizeForTrace(args), + method: name, + phase: 'start', + }); + } + const performanceCall = preloadPerformanceCapture.start( + name, + args + ); try { const result = original(...args); @@ -126,54 +145,74 @@ function wrapElectronApi(api: T): T { ) { return (result as Promise) .then((resolvedValue) => { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - method: name, - phase: 'success', - result: summarizeForTrace( - resolvedValue - ), - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + method: name, + phase: 'success', + result: summarizeForTrace( + resolvedValue + ), + }); + } + preloadPerformanceCapture.success( + performanceCall, + resolvedValue + ); return resolvedValue; }) .catch((error: unknown) => { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - error: summarizeForTrace(error), - method: name, - phase: 'error', - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + error: summarizeForTrace(error), + method: name, + phase: 'error', + }); + } + preloadPerformanceCapture.error( + performanceCall + ); throw error; }); } - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - method: name, - phase: 'success', - result: summarizeForTrace(result), - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + method: name, + phase: 'success', + result: summarizeForTrace(result), + }); + } + preloadPerformanceCapture.success( + performanceCall, + result + ); return result; } catch (error) { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - error: summarizeForTrace(error), - method: name, - phase: 'error', - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + error: summarizeForTrace(error), + method: name, + phase: 'error', + }); + } + preloadPerformanceCapture.error(performanceCall); throw error; } }, @@ -530,8 +569,16 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('EPG_MAPPING_GET', { channelKey }), getEpgMappingsBatch: (channelKeys: string[]) => ipcRenderer.invoke('EPG_MAPPING_GET_BATCH', { channelKeys }), - setEpgMapping: (channelKey: string, epgChannelId: string, playlistId?: string) => - ipcRenderer.invoke('EPG_MAPPING_SET', { channelKey, epgChannelId, playlistId }), + setEpgMapping: ( + channelKey: string, + epgChannelId: string, + playlistId?: string + ) => + ipcRenderer.invoke('EPG_MAPPING_SET', { + channelKey, + epgChannelId, + playlistId, + }), deleteEpgMapping: (channelKey: string) => ipcRenderer.invoke('EPG_MAPPING_DELETE', { channelKey }), searchEpgChannels: (searchTerm: string, limit?: number) => diff --git a/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts b/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts new file mode 100644 index 000000000..9d900e13a --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts @@ -0,0 +1,50 @@ +import { + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { createPreloadPerformanceCapture } from './preload-performance-capture'; + +describe('preload performance capture timestamps', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses fractional monotonic time on top of the epoch origin', () => { + const markers: PreloadPerformanceMarker[] = []; + const monotonicNow = jest + .spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(10.125) + .mockReturnValueOnce(10.375); + const capture = createPreloadPerformanceCapture( + true, + (channel, marker) => { + expect(channel).toBe(PRELOAD_PERFORMANCE_MARKER_CHANNEL); + markers.push(marker); + } + ); + + const call = capture.start( + PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + [ + { + operationId: 'operation-1', + playlistId: 'playlist-1', + }, + ] + ); + capture.success(call, {}); + + expect(monotonicNow).toHaveBeenCalledTimes(2); + expect(markers).toHaveLength(2); + expect( + markers[0].sourceEpochMs - globalThis.performance.timeOrigin + ).toBeCloseTo(10.125, 3); + expect( + markers[1].sourceEpochMs - globalThis.performance.timeOrigin + ).toBeCloseTo(10.375, 3); + expect(markers[1].sourceEpochMs).toBeGreaterThan( + markers[0].sourceEpochMs + ); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-capture.ts b/apps/electron-backend/src/app/api/preload-performance-capture.ts new file mode 100644 index 000000000..c6543d3d3 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-capture.ts @@ -0,0 +1,191 @@ +import { + isPlaylistRefreshCancelledResult, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type PreloadPerformanceMarker, + type PreloadPerformanceMethod, + type PreloadPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { + advancePreloadPerformanceCorrelation, + createPreloadPerformanceCorrelationState, + normalizePreloadPerformanceIdentifier, + type PreloadPerformanceCorrelationState, +} from './preload-performance-correlation'; + +export interface PreloadPerformanceCallContext { + ipcCallId: number; + method: PreloadPerformanceMethod; + operationId: string | null; + playlistId: string | null; +} + +export interface PreloadPerformanceCapture { + error: (call: PreloadPerformanceCallContext | null) => void; + start: ( + methodName: string, + args: readonly unknown[] + ) => PreloadPerformanceCallContext | null; + success: ( + call: PreloadPerformanceCallContext | null, + result: unknown + ) => void; +} + +type MarkerSender = ( + channel: typeof PRELOAD_PERFORMANCE_MARKER_CHANNEL, + marker: PreloadPerformanceMarker +) => void; + +function readProperty(value: unknown, property: string): unknown { + if ( + (typeof value !== 'object' || value === null) && + typeof value !== 'function' + ) { + return undefined; + } + + try { + return Reflect.get(value, property); + } catch { + return undefined; + } +} + +export function toPreloadPerformanceTargetMethod( + methodName: string +): PreloadPerformanceMethod | null { + switch (methodName) { + case PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST: + case PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST: + case PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST: + return methodName; + default: + return null; + } +} + +function readHighResolutionEpochMs(): number { + const timeOrigin = globalThis.performance?.timeOrigin; + const monotonicNow = globalThis.performance?.now?.(); + const highResolutionEpoch = + typeof timeOrigin === 'number' && typeof monotonicNow === 'number' + ? timeOrigin + monotonicNow + : Number.NaN; + + return Number.isFinite(highResolutionEpoch) + ? highResolutionEpoch + : Date.now(); +} + +function extractIdentifiers( + method: PreloadPerformanceMethod, + args: readonly unknown[] +): Pick { + if (method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + const payload = args[0]; + return { + operationId: normalizePreloadPerformanceIdentifier( + readProperty(payload, 'operationId') + ), + playlistId: normalizePreloadPerformanceIdentifier( + readProperty(payload, 'playlistId') + ), + }; + } + + if (method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { + return { + operationId: null, + playlistId: normalizePreloadPerformanceIdentifier(args[0]), + }; + } + + return { + operationId: null, + playlistId: normalizePreloadPerformanceIdentifier( + readProperty(args[0], '_id') + ), + }; +} + +export function createPreloadPerformanceCapture( + enabled: boolean, + sendMarker: MarkerSender +): PreloadPerformanceCapture { + let correlationState: PreloadPerformanceCorrelationState = + createPreloadPerformanceCorrelationState(); + let lastSourceEpochMs = 0; + let nextIpcCallId = 1; + + function emit( + call: PreloadPerformanceCallContext, + phase: PreloadPerformancePhase, + refreshCancelled = false + ): void { + try { + const transition = advancePreloadPerformanceCorrelation( + correlationState, + { + ...call, + phase, + refreshCancelled, + } + ); + correlationState = transition.state; + const sourceEpochMs = Math.max( + readHighResolutionEpochMs(), + lastSourceEpochMs + ); + lastSourceEpochMs = sourceEpochMs; + sendMarker(PRELOAD_PERFORMANCE_MARKER_CHANNEL, { + ...transition.marker, + sourceEpochMs, + }); + } catch { + // Performance instrumentation must never affect the bridge call. + } + } + + return { + error: (call) => { + if (call) { + emit(call, 'error'); + } + }, + start: (methodName, args) => { + if (!enabled) { + return null; + } + + const method = toPreloadPerformanceTargetMethod(methodName); + if (!method) { + return null; + } + + const call: PreloadPerformanceCallContext = { + ipcCallId: nextIpcCallId, + method, + ...extractIdentifiers(method, args), + }; + nextIpcCallId += 1; + emit(call, 'start'); + return call; + }, + success: (call, result) => { + if (!call) { + return; + } + + let refreshCancelled = false; + if (call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + try { + refreshCancelled = isPlaylistRefreshCancelledResult(result); + } catch { + refreshCancelled = false; + } + } + emit(call, 'success', refreshCancelled); + }, + }; +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts new file mode 100644 index 000000000..26e79ef46 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts @@ -0,0 +1,235 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + PRELOAD_PERFORMANCE_SEQUENCE_STAGE, + closeInvalidPreloadPerformanceSequenceWhenSettled, + createPreloadPerformanceMarkerMetadata, + invalidatePreloadPerformanceSequence, + type PreloadPerformanceCall, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceMarkerMetadata, + type PreloadPerformanceSequence, +} from './preload-performance-correlation.model'; + +function invalidCompletion( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall +): PreloadPerformanceMarkerMetadata { + const invalidSequence = + call.playlistId === null + ? undefined + : invalidatePreloadPerformanceSequence( + calls, + sequences, + call.playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + invalidSequence?.operationId ?? call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); +} + +function completeCorrelatedCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall & { playlistId: string } +): PreloadPerformanceMarkerMetadata { + const sequence = sequences.get(call.playlistId); + const expectedStage = + event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED + : event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; + if ( + !sequence || + sequence.stage !== expectedStage || + sequence.activeCallId !== event.ipcCallId || + sequence.operationId !== call.operationId + ) { + return invalidCompletion(calls, sequences, event, call); + } + + if (event.phase === 'error') { + const invalidSequence = invalidatePreloadPerformanceSequence( + calls, + sequences, + call.playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR + ); + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR + ); + } + + if ( + event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && + event.refreshCancelled === true + ) { + sequences.delete(call.playlistId); + calls.delete(event.ipcCallId); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED + ); + } + + calls.delete(event.ipcCallId); + if (event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + sequences.set(call.playlistId, { + ...sequence, + activeCallId: null, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED, + }); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); + } + + if (event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { + sequences.set(call.playlistId, { + ...sequence, + activeCallId: null, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED, + }); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); + } + + sequences.delete(call.playlistId); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + null + ); +} + +export function completePreloadPerformanceCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + const call = calls.get(event.ipcCallId); + if (!call) { + const sequence = playlistId ? sequences.get(playlistId) : undefined; + const invalidSequence = playlistId + ? invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ) + : undefined; + const invalidReason = + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + invalidSequence?.operationId ?? sequence?.operationId ?? null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + const identityMatches = + call.method === event.method && + call.playlistId === playlistId && + (event.method !== PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST || + call.expectedOperationId === operationId); + if (!identityMatches) { + return invalidCompletion(calls, sequences, event, call); + } + + if ( + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) { + calls.delete(event.ipcCallId); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + null + ); + } + + if ( + call.correlationState === PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID + ) { + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + call.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + } + + if (call.playlistId === null) { + return invalidCompletion(calls, sequences, event, call); + } + return completeCorrelatedCall( + calls, + sequences, + event, + call as PreloadPerformanceCall & { playlistId: string } + ); +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts new file mode 100644 index 000000000..97f13f081 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts @@ -0,0 +1,314 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance marker correlation completions', () => { + let advance: CorrelationHarness['advance']; + let reset: CorrelationHarness['reset']; + + beforeEach(() => { + ({ advance, reset } = createCorrelationHarness()); + }); + + it.each(['success', 'error'] as const)( + 'rejects a %s completion with the wrong call ID', + (phase) => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const mismatchedCompletion = advance({ + ipcCallId: 99, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase, + playlistId: PLAYLIST_ONE, + }); + const originalCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(mismatchedCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect(originalCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + } + ); + + it('rejects a refresh completion whose playlist or operation ID changed', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const changedOperation = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(changedOperation).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + + reset(); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const changedPlaylist = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + + expect(changedPlaylist).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + }); + + it('rejects a completion whose method differs from the started call', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const wrongMethod = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const restart = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(wrongMethod).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect(restart.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + }); + + it('rejects a duplicate completion instead of advancing the chain twice', () => { + const refresh = { + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + } as const; + advance({ ...refresh, phase: 'start' }); + advance({ ...refresh, phase: 'success' }); + + const duplicate = advance({ ...refresh, phase: 'success' }); + const laterGet = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(duplicate).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect(laterGet).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + }); + }); + + it('rejects a database completion for a different playlist', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const changedPlaylist = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + const laterUpsert = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(changedPlaylist).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect(laterUpsert).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + }); + }); + + it('invalidates a correlated sequence when a target call errors', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const error = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'error', + playlistId: PLAYLIST_ONE, + }); + const laterUpsert = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const restart = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(error).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + operationId: OPERATION_ONE, + }); + expect(laterUpsert).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + }); + expect(restart.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + }); + + it('closes a cancelled refresh without awaiting database persistence', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const cancelled = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + refreshCancelled: true, + }); + const laterGet = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(cancelled).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED, + operationId: OPERATION_ONE, + }); + expect(laterGet).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: null, + }); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts new file mode 100644 index 000000000..fe4ec4711 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts @@ -0,0 +1,178 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + type PreloadPerformanceCorrelationStateName, + type PreloadPerformanceInvalidReason, + type PreloadPerformanceMarker, + type PreloadPerformanceMethod, + type PreloadPerformancePhase, +} from '@iptvnator/shared/interfaces'; + +const MAX_IDENTIFIER_LENGTH = 128; +const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; + +export const PRELOAD_PERFORMANCE_SEQUENCE_STAGE = { + DB_GET_STARTED: 'db-get-started', + DB_GET_SUCCEEDED: 'db-get-succeeded', + DB_UPSERT_STARTED: 'db-upsert-started', + INVALID: 'invalid', + REFRESH_STARTED: 'refresh-started', + REFRESH_SUCCEEDED: 'refresh-succeeded', +} as const; + +export type PreloadPerformanceSequenceStage = + (typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE)[keyof typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE]; + +export interface PreloadPerformanceSequence { + activeCallId: number | null; + invalidReason: PreloadPerformanceInvalidReason | null; + operationId: string; + stage: PreloadPerformanceSequenceStage; +} + +export interface PreloadPerformanceCall { + correlationState: PreloadPerformanceCorrelationStateName; + expectedOperationId: string | null; + invalidReason: PreloadPerformanceInvalidReason | null; + method: PreloadPerformanceMethod; + operationId: string | null; + playlistId: string | null; +} + +export interface PreloadPerformanceCorrelationEvent { + ipcCallId: number; + method: PreloadPerformanceMethod; + operationId: unknown; + phase: PreloadPerformancePhase; + playlistId: unknown; + refreshCancelled?: boolean; +} + +export interface PreloadPerformanceCorrelationState { + readonly calls: ReadonlyMap; + readonly sequences: ReadonlyMap; +} + +export interface PreloadPerformanceCorrelationResult { + marker: PreloadPerformanceMarkerMetadata; + state: PreloadPerformanceCorrelationState; +} + +export type PreloadPerformanceMarkerMetadata = Omit< + PreloadPerformanceMarker, + 'sourceEpochMs' +>; + +export function normalizePreloadPerformanceIdentifier( + value: unknown +): string | null { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_IDENTIFIER_LENGTH || + value !== value.trim() || + !SAFE_IDENTIFIER_PATTERN.test(value) + ) { + return null; + } + + return value; +} + +export function createPreloadPerformanceMarkerMetadata( + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null, + correlationState: PreloadPerformanceCorrelationStateName, + invalidReason: PreloadPerformanceInvalidReason | null +): PreloadPerformanceMarkerMetadata { + return { + correlationState, + invalidReason, + ipcCallId: event.ipcCallId, + method: event.method, + operationId, + phase: event.phase, + playlistId, + }; +} + +export function createInvalidPreloadPerformanceCall( + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null, + invalidReason: PreloadPerformanceInvalidReason, + expectedOperationId = operationId +): PreloadPerformanceCall { + return { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + expectedOperationId, + invalidReason, + method: event.method, + operationId, + playlistId, + }; +} + +export function invalidatePreloadPerformanceSequence( + calls: Map, + sequences: Map, + playlistId: string, + reason: PreloadPerformanceInvalidReason +): PreloadPerformanceSequence | undefined { + const sequence = sequences.get(playlistId); + if (!sequence) { + return undefined; + } + + const invalidReason = + sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ? (sequence.invalidReason ?? reason) + : reason; + const invalidSequence: PreloadPerformanceSequence = { + ...sequence, + activeCallId: null, + invalidReason, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID, + }; + sequences.set(playlistId, invalidSequence); + + for (const [callId, call] of calls) { + if ( + call.playlistId === playlistId && + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ) { + calls.set(callId, { + ...call, + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason, + }); + } + } + + return invalidSequence; +} + +export function closeInvalidPreloadPerformanceSequenceWhenSettled( + calls: ReadonlyMap, + sequences: Map, + playlistId: string | null +): void { + if ( + playlistId === null || + sequences.get(playlistId)?.stage !== + PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ) { + return; + } + + const hasInvalidCall = Array.from(calls.values()).some( + (call) => + call.playlistId === playlistId && + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID + ); + if (!hasInvalidCall) { + sequences.delete(playlistId); + } +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts new file mode 100644 index 000000000..266c1c986 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts @@ -0,0 +1,338 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, + type CorrelationEvent, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance marker correlation sequences', () => { + let advance: CorrelationHarness['advance']; + let reset: CorrelationHarness['reset']; + + beforeEach(() => { + ({ advance, reset } = createCorrelationHarness()); + }); + + it('correlates the successful refresh persistence sequence and propagates its operation ID', () => { + const refreshStart = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const refreshSuccess = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const getStart = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const getSuccess = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const upsertStart = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const upsertSuccess = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(refreshStart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(refreshSuccess).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + operationId: OPERATION_ONE, + }); + expect(getStart.operationId).toBe(OPERATION_ONE); + expect(getSuccess.operationId).toBe(OPERATION_ONE); + expect(upsertStart.operationId).toBe(OPERATION_ONE); + expect(upsertSuccess).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + invalidReason: null, + operationId: OPERATION_ONE, + }); + }); + + it('fails closed for wrong-order and duplicate targeted database calls', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const wrongOrder = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(wrongOrder).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + }); + + reset(); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const duplicate = advance({ + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const originalCompletion = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(duplicate.invalidReason).toBe( + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + expect(originalCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + }); + + it('invalidates concurrent refreshes for the same playlist', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const concurrentStart = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const firstCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const concurrentCompletion = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const restart = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: 'operation-3', + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(concurrentStart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + operationId: OPERATION_TWO, + }); + expect(concurrentCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + operationId: OPERATION_TWO, + }); + expect(restart.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + expect(firstCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + }); + }); + + it('keeps different playlists independent when one sequence invalidates', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const playlistTwoGet = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + + expect(playlistTwoGet).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + }); + + it('allows a new refresh after the prior sequence completes', () => { + const events: CorrelationEvent[] = [ + { + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + ]; + events.forEach((event) => advance(event)); + + const restart = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(restart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_TWO, + }); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts new file mode 100644 index 000000000..271c02e15 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts @@ -0,0 +1,251 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + PRELOAD_PERFORMANCE_SEQUENCE_STAGE, + createInvalidPreloadPerformanceCall, + createPreloadPerformanceMarkerMetadata, + invalidatePreloadPerformanceSequence, + type PreloadPerformanceCall, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceMarkerMetadata, + type PreloadPerformanceSequence, +} from './preload-performance-correlation.model'; + +function startRefresh( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string, + operationId: string +): PreloadPerformanceMarkerMetadata { + const sequence = sequences.get(playlistId); + if (sequence) { + const invalidSequence = invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ? (sequence.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER) + : PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH + ); + const invalidReason = + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + operationId, + invalidReason, + operationId + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + sequences.set(playlistId, { + activeCallId: event.ipcCallId, + invalidReason: null, + operationId, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED, + }); + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + expectedOperationId: operationId, + invalidReason: null, + method: event.method, + operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); +} + +function startDatabaseCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string +): PreloadPerformanceMarkerMetadata { + const sequence = sequences.get(playlistId); + if (!sequence) { + calls.set(event.ipcCallId, { + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + expectedOperationId: null, + invalidReason: null, + method: event.method, + operationId: null, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + null + ); + } + + if (sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID) { + const invalidReason = + sequence.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + sequence.operationId, + invalidReason, + null + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + const isGet = + event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST; + const expectedStage = isGet + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED; + const startedStage = isGet + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; + if (sequence.stage !== expectedStage) { + const invalidSequence = invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + const invalidReason = + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + sequence.operationId, + invalidReason, + null + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + sequences.set(playlistId, { + ...sequence, + activeCallId: event.ipcCallId, + stage: startedStage, + }); + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + expectedOperationId: null, + invalidReason: null, + method: event.method, + operationId: sequence.operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + sequence.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); +} + +export function startPreloadPerformanceCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + if ( + playlistId === null || + (event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && + operationId === null) + ) { + if (playlistId !== null) { + invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER + ); + } + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + null, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER + ); + } + + const existingCall = calls.get(event.ipcCallId); + if (existingCall) { + const sequence = + existingCall.playlistId === null + ? undefined + : invalidatePreloadPerformanceSequence( + calls, + sequences, + existingCall.playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + sequence?.operationId ?? null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + sequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + } + + return event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + ? startRefresh(calls, sequences, event, playlistId, operationId) + : startDatabaseCall(calls, sequences, event, playlistId); +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts new file mode 100644 index 000000000..bf4d29321 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts @@ -0,0 +1,29 @@ +import { + advancePreloadPerformanceCorrelation, + createPreloadPerformanceCorrelationState, + type PreloadPerformanceCorrelationEvent, +} from './preload-performance-correlation'; + +export const PLAYLIST_ONE = 'playlist-1'; +export const PLAYLIST_TWO = 'playlist-2'; +export const OPERATION_ONE = 'operation-1'; +export const OPERATION_TWO = 'operation-2'; + +export type CorrelationEvent = PreloadPerformanceCorrelationEvent; + +export function createCorrelationHarness() { + let state = createPreloadPerformanceCorrelationState(); + + return { + advance: (event: PreloadPerformanceCorrelationEvent) => { + const result = advancePreloadPerformanceCorrelation(state, event); + state = result.state; + return result.marker; + }, + reset: () => { + state = createPreloadPerformanceCorrelationState(); + }, + }; +} + +export type CorrelationHarness = ReturnType; diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.ts new file mode 100644 index 000000000..05a7de9e4 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.ts @@ -0,0 +1,60 @@ +import { PRELOAD_PERFORMANCE_METHOD } from '@iptvnator/shared/interfaces'; +import { completePreloadPerformanceCall } from './preload-performance-correlation.complete'; +import { + normalizePreloadPerformanceIdentifier, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceCorrelationResult, + type PreloadPerformanceCorrelationState, +} from './preload-performance-correlation.model'; +import { startPreloadPerformanceCall } from './preload-performance-correlation.start'; + +export type { + PreloadPerformanceCorrelationEvent, + PreloadPerformanceCorrelationResult, + PreloadPerformanceCorrelationState, +} from './preload-performance-correlation.model'; +export { normalizePreloadPerformanceIdentifier } from './preload-performance-correlation.model'; + +export function createPreloadPerformanceCorrelationState(): PreloadPerformanceCorrelationState { + return { + calls: new Map(), + sequences: new Map(), + }; +} + +export function advancePreloadPerformanceCorrelation( + state: PreloadPerformanceCorrelationState, + event: PreloadPerformanceCorrelationEvent +): PreloadPerformanceCorrelationResult { + const calls = new Map(state.calls); + const sequences = new Map(state.sequences); + const playlistId = normalizePreloadPerformanceIdentifier(event.playlistId); + const operationId = + event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + ? normalizePreloadPerformanceIdentifier(event.operationId) + : null; + const marker = + event.phase === 'start' + ? startPreloadPerformanceCall( + calls, + sequences, + event, + playlistId, + operationId + ) + : completePreloadPerformanceCall( + calls, + sequences, + event, + playlistId, + operationId + ); + + return { + marker, + state: { + calls, + sequences, + }, + }; +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts new file mode 100644 index 000000000..9252bf378 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts @@ -0,0 +1,117 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance marker correlation validation', () => { + let advance: CorrelationHarness['advance']; + + beforeEach(() => { + ({ advance } = createCorrelationHarness()); + }); + + it('uses null identifiers and a fixed reason for malformed inputs', () => { + const malformedPlaylist = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: 'https://secret.example/private.m3u', + }); + const malformedOperation = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: { secret: OPERATION_TWO }, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const malformedDbId = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: undefined, + }); + const malformedPlaylistCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: 'https://secret.example/private.m3u', + }); + + expect(malformedPlaylist).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + expect(malformedOperation).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: PLAYLIST_ONE, + }); + expect(malformedDbId).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + expect(malformedPlaylistCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + }); + + it('keeps targeted database calls outside a sequence visible and uncorrelated', () => { + const getStart = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const getSuccess = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(getStart).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: null, + }); + expect(getSuccess).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: null, + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/debug-trace.ts b/apps/electron-backend/src/app/services/debug-trace.ts index f134dc7f3..6b926a6c3 100644 --- a/apps/electron-backend/src/app/services/debug-trace.ts +++ b/apps/electron-backend/src/app/services/debug-trace.ts @@ -52,6 +52,10 @@ export function isRendererApiTraceEnabled(): boolean { return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_IPC'); } +export function isPerformanceCaptureEnabled(): boolean { + return readFlag('IPTVNATOR_PERF_CAPTURE'); +} + export function isDbTraceEnabled(): boolean { return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_DB'); } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index f4d51e0bc..c3aa8bf8c 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -23,6 +23,7 @@ export * from './lib/playback-position.interface'; export * from './lib/playlist-auto-update.interface'; export * from './lib/playlist-backup.interface'; export * from './lib/playlist-meta.type'; +export * from './lib/preload-performance-marker.interface'; export * from './lib/playlist-recently-viewed.interface'; export * from './lib/playlist-recently-viewed.utils'; export * from './lib/playlist-refresh.interface'; diff --git a/libs/shared/interfaces/src/lib/preload-performance-marker.interface.ts b/libs/shared/interfaces/src/lib/preload-performance-marker.interface.ts new file mode 100644 index 000000000..268f848ae --- /dev/null +++ b/libs/shared/interfaces/src/lib/preload-performance-marker.interface.ts @@ -0,0 +1,52 @@ +export const PRELOAD_PERFORMANCE_MARKER_CHANNEL = + 'IPTVNATOR_PERF_CAPTURE_MARKER'; + +export const PRELOAD_PERFORMANCE_METHOD = { + DB_GET_APP_PLAYLIST: 'dbGetAppPlaylist', + DB_UPSERT_APP_PLAYLIST: 'dbUpsertAppPlaylist', + REFRESH_PLAYLIST: 'refreshPlaylist', +} as const; + +export type PreloadPerformanceMethod = + (typeof PRELOAD_PERFORMANCE_METHOD)[keyof typeof PRELOAD_PERFORMANCE_METHOD]; + +export const PRELOAD_PERFORMANCE_PHASE = { + ERROR: 'error', + START: 'start', + SUCCESS: 'success', +} as const; + +export type PreloadPerformancePhase = + (typeof PRELOAD_PERFORMANCE_PHASE)[keyof typeof PRELOAD_PERFORMANCE_PHASE]; + +export const PRELOAD_PERFORMANCE_CORRELATION_STATE = { + COMPLETE: 'complete', + CORRELATED: 'correlated', + INVALID: 'invalid', + UNCORRELATED: 'uncorrelated', +} as const; + +export type PreloadPerformanceCorrelationStateName = + (typeof PRELOAD_PERFORMANCE_CORRELATION_STATE)[keyof typeof PRELOAD_PERFORMANCE_CORRELATION_STATE]; + +export const PRELOAD_PERFORMANCE_INVALID_REASON = { + CONCURRENT_REFRESH: 'concurrent-refresh', + IPC_ERROR: 'ipc-error', + MALFORMED_IDENTIFIER: 'malformed-identifier', + OUT_OF_ORDER: 'out-of-order', + REFRESH_CANCELLED: 'refresh-cancelled', +} as const; + +export type PreloadPerformanceInvalidReason = + (typeof PRELOAD_PERFORMANCE_INVALID_REASON)[keyof typeof PRELOAD_PERFORMANCE_INVALID_REASON]; + +export interface PreloadPerformanceMarker { + method: PreloadPerformanceMethod; + phase: PreloadPerformancePhase; + sourceEpochMs: number; + ipcCallId: number; + playlistId: string | null; + operationId: string | null; + correlationState: PreloadPerformanceCorrelationStateName; + invalidReason: PreloadPerformanceInvalidReason | null; +} From f9e71a6d8d836b60f845c837a6f5cbb3ffe309e3 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 19:32:23 +0200 Subject: [PATCH 09/32] fix(electron): isolate refresh performance correlation --- .../m3u-refresh-cancellation.benchmark.ts | 1 + .../performance-build-config.spec.ts | 12 + .../main.preload.performance.contract.spec.ts | 45 +++- .../main.preload.performance.gates.spec.ts | 98 ++++++++ .../main.preload.performance.identity.spec.ts | 79 +++++++ .../src/app/api/main.preload.ts | 4 +- .../app/api/preload-performance-capture.ts | 14 +- ...reload-performance-correlation.complete.ts | 98 ++++---- ...performance-correlation.completion.spec.ts | 209 +----------------- ...d-performance-correlation.identity.spec.ts | 183 +++++++++++++++ .../preload-performance-correlation.model.ts | 7 +- ...d-performance-correlation.sequence.spec.ts | 28 +-- .../preload-performance-correlation.start.ts | 118 +++++----- .../api/preload-performance-correlation.ts | 8 +- libs/m3u-state/src/lib/actions.ts | 9 +- libs/m3u-state/src/lib/effects.ts | 46 ++-- .../src/lib/playlist-update.effect-handler.ts | 29 +++ .../src/lib/playlist-update.effect.spec.ts | 30 +++ .../playlist-refresh-action.service.spec.ts | 1 + .../lib/playlist-refresh-action.service.ts | 8 +- .../recent-playlists.component.spec.ts | 10 + .../recent-playlists.component.ts | 1 + .../src/lib/playlists.service.spec.ts | 31 +++ libs/services/src/lib/playlists.service.ts | 49 ++-- .../src/lib/electron-api.interface.ts | 12 +- 25 files changed, 728 insertions(+), 402 deletions(-) create mode 100644 apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts create mode 100644 apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts create mode 100644 libs/m3u-state/src/lib/playlist-update.effect-handler.ts create mode 100644 libs/m3u-state/src/lib/playlist-update.effect.spec.ts diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index fcfd83d1f..ce4cd5e0b 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -166,6 +166,7 @@ async function runIteration( ], env: { IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_CAPTURE: '1', IPTVNATOR_PERF_WORKER_PROFILING: '1', IPTVNATOR_TRACE_RENDERER_CONSOLE: process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0', diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index 33f81351b..94a64095a 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -224,3 +224,15 @@ test('the cancellation benchmark command is pinned to its Playwright test file', 'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts' ); }); + +test('the cancellation benchmark enables preload performance capture', () => { + const source = readFileSync( + join( + workspaceRoot, + 'apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts' + ), + 'utf8' + ); + + assert.match(source, /IPTVNATOR_PERF_CAPTURE:\s*'1'/); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts index f2956075d..e4dc78a44 100644 --- a/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts @@ -77,12 +77,12 @@ describe('main preload performance marker contract', () => { await expect(api.refreshPlaylist(payload)).resolves.toBe( refreshedPlaylist ); - await expect(api.dbGetAppPlaylist(payload.playlistId)).resolves.toBe( - storedPlaylist - ); - await expect(api.dbUpsertAppPlaylist(upsertPlaylist)).resolves.toBe( - upsertResult - ); + await expect( + api.dbGetAppPlaylist(payload.playlistId, payload.operationId) + ).resolves.toBe(storedPlaylist); + await expect( + api.dbUpsertAppPlaylist(upsertPlaylist, payload.operationId) + ).resolves.toBe(upsertResult); expect(harness.ipcRenderer.invoke.mock.calls).toEqual([ ['PLAYLIST:REFRESH', payload], @@ -289,12 +289,15 @@ describe('main preload performance marker contract', () => { ); }); - it('keeps results unchanged when marker delivery throws', async () => { + it('poisons correlation after the first marker delivery failure', async () => { const payload = createRefreshPayload(); const result = createPlaylist(payload.playlistId); await loadPerformancePreload(); - harness.ipcRenderer.invoke.mockResolvedValueOnce(result); - harness.ipcRenderer.send.mockImplementation((channel: string) => { + harness.ipcRenderer.invoke + .mockResolvedValueOnce(result) + .mockResolvedValueOnce(result) + .mockResolvedValueOnce({ success: true }); + harness.ipcRenderer.send.mockImplementationOnce((channel: string) => { if (channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL) { throw new Error('marker-send-failed'); } @@ -303,11 +306,33 @@ describe('main preload performance marker contract', () => { await expect(harness.getApi().refreshPlaylist(payload)).resolves.toBe( result ); + await expect( + harness + .getApi() + .dbGetAppPlaylist(payload.playlistId, payload.operationId) + ).resolves.toBe(result); + await expect( + harness.getApi().dbUpsertAppPlaylist(result, payload.operationId) + ).resolves.toEqual({ success: true }); expect( harness.ipcRenderer.send.mock.calls.filter( ([channel]) => channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL ) - ).toHaveLength(2); + ).toHaveLength(1); + expect(harness.getMarkers()).toHaveLength(1); + expect(harness.getMarkers()[0]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + phase: 'start', + }); + expect( + harness + .getMarkers() + .some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); }); }); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts index 36b8a2b1b..518f9376d 100644 --- a/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts @@ -3,7 +3,9 @@ import { PRELOAD_PERFORMANCE_MARKER_CHANNEL, PRELOAD_PERFORMANCE_METHOD, type Settings, + Theme, } from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; import { DEBUG_TRACE_EVENT_CHANNEL } from '../services/debug-trace'; import { createPreloadPerformanceHarness, @@ -20,6 +22,7 @@ describe('main preload performance marker gates', () => { afterEach(() => { harness.cleanup(); + jest.restoreAllMocks(); }); it.each([undefined, '', '0', 'false', 'no', 'off'])( @@ -146,4 +149,99 @@ describe('main preload performance marker gates', () => { ); expect(harness.getMarkers()).toEqual([]); }); + + it('preserves the full legacy trace payload for non-target success', async () => { + const settings = { + password: 'sentinel-argument-secret', + theme: Theme.DarkTheme, + } as Partial; + const result = { + password: 'sentinel-result-secret', + success: true, + }; + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + jest.spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(10) + .mockReturnValueOnce(12.34); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect(harness.getApi().updateSettings(settings)).resolves.toBe( + result + ); + + const traces = harness.getSentPayloads>( + DEBUG_TRACE_EVENT_CHANNEL + ); + expect(traces).toEqual([ + { + args: { + items: [ + { + password: 'sentinel-argument-secret', + theme: Theme.DarkTheme, + }, + ], + length: 1, + type: 'array', + }, + method: 'updateSettings', + phase: 'start', + }, + { + durationMs: 2.3, + method: 'updateSettings', + phase: 'success', + result, + }, + ]); + const redacted = JSON.stringify(redactSensitiveData(traces)); + expect(redacted).not.toContain('sentinel-argument-secret'); + expect(redacted).not.toContain('sentinel-result-secret'); + expect(harness.getMarkers()).toEqual([]); + }); + + it('preserves the full legacy trace payload for non-target errors', async () => { + const rejection = new Error('token=sentinel-error-secret'); + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + jest.spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(20) + .mockReturnValueOnce(23.26); + harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection); + + await expect( + harness.getApi().updateSettings({ theme: Theme.LightTheme }) + ).rejects.toBe(rejection); + + const traces = harness.getSentPayloads>( + DEBUG_TRACE_EVENT_CHANNEL + ); + expect(traces).toEqual([ + { + args: { + items: [{ theme: Theme.LightTheme }], + length: 1, + type: 'array', + }, + method: 'updateSettings', + phase: 'start', + }, + { + durationMs: 3.3, + error: { + message: rejection.message, + name: 'Error', + }, + method: 'updateSettings', + phase: 'error', + }, + ]); + expect(JSON.stringify(redactSensitiveData(traces))).not.toContain( + 'sentinel-error-secret' + ); + expect(harness.getMarkers()).toEqual([]); + }); }); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts new file mode 100644 index 000000000..ae6306c18 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts @@ -0,0 +1,79 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createPlaylist, + createPreloadPerformanceHarness, + createRefreshPayload, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker identities', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(async () => { + harness = createPreloadPerformanceHarness(); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + }); + + afterEach(() => { + harness.cleanup(); + }); + + it('completes only for database calls carrying the refresh operation ID', async () => { + const payload = createRefreshPayload(); + const playlist = createPlaylist(payload.playlistId); + harness.ipcRenderer.invoke.mockResolvedValue(playlist); + const api = harness.getApi(); + + await api.refreshPlaylist(payload); + await api.dbGetAppPlaylist(payload.playlistId); + await api.dbUpsertAppPlaylist(playlist); + await api.dbGetAppPlaylist(payload.playlistId, 'other-operation'); + await api.dbUpsertAppPlaylist(playlist, 'other-operation'); + + expect( + harness + .getMarkers() + .slice(2) + .every( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) + ).toBe(true); + expect( + harness + .getMarkers() + .some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + + await api.dbGetAppPlaylist(payload.playlistId, payload.operationId); + await api.dbUpsertAppPlaylist(playlist, payload.operationId); + + const markers = harness.getMarkers(); + expect(markers.at(-1)).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: payload.operationId, + playlistId: payload.playlistId, + }); + expect(harness.ipcRenderer.invoke.mock.calls).toEqual([ + ['PLAYLIST:REFRESH', payload], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ]); + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index e93d611dd..6f39995da 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -618,14 +618,14 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist), dbGetPlaylist: (playlistId: string) => ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId), - dbUpsertAppPlaylist: (playlist: Playlist) => + dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) => ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist), dbUpsertAppPlaylists: (playlists: Playlist[]) => ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists), dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'), dbGetAppPlaylistMetas: () => ipcRenderer.invoke('DB_GET_APP_PLAYLIST_METAS'), - dbGetAppPlaylist: (playlistId: string) => + dbGetAppPlaylist: (playlistId: string, _operationId?: string) => ipcRenderer.invoke('DB_GET_APP_PLAYLIST', playlistId), dbGetAppPlaylistFavoriteChannels: (playlistId: string) => ipcRenderer.invoke('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', playlistId), diff --git a/apps/electron-backend/src/app/api/preload-performance-capture.ts b/apps/electron-backend/src/app/api/preload-performance-capture.ts index c6543d3d3..62ae3e25d 100644 --- a/apps/electron-backend/src/app/api/preload-performance-capture.ts +++ b/apps/electron-backend/src/app/api/preload-performance-capture.ts @@ -96,13 +96,13 @@ function extractIdentifiers( if (method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { return { - operationId: null, + operationId: normalizePreloadPerformanceIdentifier(args[1]), playlistId: normalizePreloadPerformanceIdentifier(args[0]), }; } return { - operationId: null, + operationId: normalizePreloadPerformanceIdentifier(args[1]), playlistId: normalizePreloadPerformanceIdentifier( readProperty(args[0], '_id') ), @@ -115,6 +115,7 @@ export function createPreloadPerformanceCapture( ): PreloadPerformanceCapture { let correlationState: PreloadPerformanceCorrelationState = createPreloadPerformanceCorrelationState(); + let markerDeliveryFailed = false; let lastSourceEpochMs = 0; let nextIpcCallId = 1; @@ -123,6 +124,10 @@ export function createPreloadPerformanceCapture( phase: PreloadPerformancePhase, refreshCancelled = false ): void { + if (markerDeliveryFailed) { + return; + } + try { const transition = advancePreloadPerformanceCorrelation( correlationState, @@ -132,17 +137,18 @@ export function createPreloadPerformanceCapture( refreshCancelled, } ); - correlationState = transition.state; const sourceEpochMs = Math.max( readHighResolutionEpochMs(), lastSourceEpochMs ); - lastSourceEpochMs = sourceEpochMs; sendMarker(PRELOAD_PERFORMANCE_MARKER_CHANNEL, { ...transition.marker, sourceEpochMs, }); + correlationState = transition.state; + lastSourceEpochMs = sourceEpochMs; } catch { + markerDeliveryFailed = true; // Performance instrumentation must never affect the bridge call. } } diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts index 26e79ef46..b27e327b3 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts @@ -14,12 +14,25 @@ import { type PreloadPerformanceSequence, } from './preload-performance-correlation.model'; +function eventForRememberedCall( + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall +): PreloadPerformanceCorrelationEvent { + return { + ...event, + method: call.method, + operationId: call.operationId, + playlistId: call.playlistId, + }; +} + function invalidCompletion( calls: Map, sequences: Map, event: PreloadPerformanceCorrelationEvent, call: PreloadPerformanceCall ): PreloadPerformanceMarkerMetadata { + const rememberedEvent = eventForRememberedCall(event, call); const invalidSequence = call.playlistId === null ? undefined @@ -36,11 +49,12 @@ function invalidCompletion( call.playlistId ); return createPreloadPerformanceMarkerMetadata( - event, + rememberedEvent, call.playlistId, invalidSequence?.operationId ?? call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, invalidSequence?.invalidReason ?? + call.invalidReason ?? PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER ); } @@ -49,26 +63,28 @@ function completeCorrelatedCall( calls: Map, sequences: Map, event: PreloadPerformanceCorrelationEvent, - call: PreloadPerformanceCall & { playlistId: string } + call: PreloadPerformanceCall & { + operationId: string; + playlistId: string; + } ): PreloadPerformanceMarkerMetadata { const sequence = sequences.get(call.playlistId); const expectedStage = - event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED - : event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST + : call.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; if ( !sequence || - sequence.stage !== expectedStage || - sequence.activeCallId !== event.ipcCallId || - sequence.operationId !== call.operationId + sequence.operationId !== call.operationId || + sequence.stage !== expectedStage ) { return invalidCompletion(calls, sequences, event, call); } if (event.phase === 'error') { - const invalidSequence = invalidatePreloadPerformanceSequence( + invalidatePreloadPerformanceSequence( calls, sequences, call.playlistId, @@ -83,54 +99,51 @@ function completeCorrelatedCall( return createPreloadPerformanceMarkerMetadata( event, call.playlistId, - sequence.operationId, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidSequence?.invalidReason ?? - PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR + PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR ); } if ( - event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && + call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && event.refreshCancelled === true ) { - sequences.delete(call.playlistId); calls.delete(event.ipcCallId); + sequences.delete(call.playlistId); return createPreloadPerformanceMarkerMetadata( event, call.playlistId, - sequence.operationId, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED ); } calls.delete(event.ipcCallId); - if (event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + if (call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { sequences.set(call.playlistId, { ...sequence, - activeCallId: null, stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED, }); return createPreloadPerformanceMarkerMetadata( event, call.playlistId, - sequence.operationId, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, null ); } - if (event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { + if (call.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { sequences.set(call.playlistId, { ...sequence, - activeCallId: null, stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED, }); return createPreloadPerformanceMarkerMetadata( event, call.playlistId, - sequence.operationId, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, null ); @@ -140,7 +153,7 @@ function completeCorrelatedCall( return createPreloadPerformanceMarkerMetadata( event, call.playlistId, - sequence.operationId, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, null ); @@ -155,50 +168,33 @@ export function completePreloadPerformanceCall( ): PreloadPerformanceMarkerMetadata { const call = calls.get(event.ipcCallId); if (!call) { - const sequence = playlistId ? sequences.get(playlistId) : undefined; - const invalidSequence = playlistId - ? invalidatePreloadPerformanceSequence( - calls, - sequences, - playlistId, - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER - ) - : undefined; - const invalidReason = - invalidSequence?.invalidReason ?? - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; - closeInvalidPreloadPerformanceSequenceWhenSettled( - calls, - sequences, - playlistId - ); return createPreloadPerformanceMarkerMetadata( event, playlistId, - invalidSequence?.operationId ?? sequence?.operationId ?? null, + operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER ); } const identityMatches = call.method === event.method && - call.playlistId === playlistId && - (event.method !== PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST || - call.expectedOperationId === operationId); + call.operationId === operationId && + call.playlistId === playlistId; if (!identityMatches) { return invalidCompletion(calls, sequences, event, call); } + const rememberedEvent = eventForRememberedCall(event, call); if ( call.correlationState === PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED ) { calls.delete(event.ipcCallId); return createPreloadPerformanceMarkerMetadata( - event, + rememberedEvent, call.playlistId, - null, + call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, null ); @@ -214,7 +210,7 @@ export function completePreloadPerformanceCall( call.playlistId ); return createPreloadPerformanceMarkerMetadata( - event, + rememberedEvent, call.playlistId, call.operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, @@ -223,13 +219,13 @@ export function completePreloadPerformanceCall( ); } - if (call.playlistId === null) { - return invalidCompletion(calls, sequences, event, call); - } return completeCorrelatedCall( calls, sequences, - event, - call as PreloadPerformanceCall & { playlistId: string } + rememberedEvent, + call as PreloadPerformanceCall & { + operationId: string; + playlistId: string; + } ); } diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts index 97f13f081..6cde5c2cc 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts @@ -8,210 +8,14 @@ import { OPERATION_ONE, OPERATION_TWO, PLAYLIST_ONE, - PLAYLIST_TWO, type CorrelationHarness, } from './preload-performance-correlation.test-helpers'; describe('preload performance marker correlation completions', () => { let advance: CorrelationHarness['advance']; - let reset: CorrelationHarness['reset']; beforeEach(() => { - ({ advance, reset } = createCorrelationHarness()); - }); - - it.each(['success', 'error'] as const)( - 'rejects a %s completion with the wrong call ID', - (phase) => { - advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - const mismatchedCompletion = advance({ - ipcCallId: 99, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase, - playlistId: PLAYLIST_ONE, - }); - const originalCompletion = advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'success', - playlistId: PLAYLIST_ONE, - }); - - expect(mismatchedCompletion).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - expect(originalCompletion).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - } - ); - - it('rejects a refresh completion whose playlist or operation ID changed', () => { - advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - const changedOperation = advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_TWO, - phase: 'success', - playlistId: PLAYLIST_ONE, - }); - - expect(changedOperation).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - - reset(); - advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - const changedPlaylist = advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'success', - playlistId: PLAYLIST_TWO, - }); - - expect(changedPlaylist).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - }); - - it('rejects a completion whose method differs from the started call', () => { - advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - const wrongMethod = advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, - phase: 'success', - playlistId: PLAYLIST_ONE, - }); - const restart = advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_TWO, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - expect(wrongMethod).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - expect(restart.correlationState).toBe( - PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED - ); - }); - - it('rejects a duplicate completion instead of advancing the chain twice', () => { - const refresh = { - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - playlistId: PLAYLIST_ONE, - } as const; - advance({ ...refresh, phase: 'start' }); - advance({ ...refresh, phase: 'success' }); - - const duplicate = advance({ ...refresh, phase: 'success' }); - const laterGet = advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - expect(duplicate).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - expect(laterGet).toMatchObject({ - correlationState: - PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, - invalidReason: null, - }); - }); - - it('rejects a database completion for a different playlist', () => { - advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - advance({ - ipcCallId: 1, - method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, - operationId: OPERATION_ONE, - phase: 'success', - playlistId: PLAYLIST_ONE, - }); - advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - const changedPlaylist = advance({ - ipcCallId: 2, - method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, - phase: 'success', - playlistId: PLAYLIST_TWO, - }); - const laterUpsert = advance({ - ipcCallId: 3, - method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, - phase: 'start', - playlistId: PLAYLIST_ONE, - }); - - expect(changedPlaylist).toMatchObject({ - correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, - }); - expect(laterUpsert).toMatchObject({ - correlationState: - PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, - invalidReason: null, - }); + ({ advance } = createCorrelationHarness()); }); it('invalidates a correlated sequence when a target call errors', () => { @@ -232,7 +36,7 @@ describe('preload performance marker correlation completions', () => { advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); @@ -240,14 +44,14 @@ describe('preload performance marker correlation completions', () => { const error = advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'error', playlistId: PLAYLIST_ONE, }); const laterUpsert = advance({ ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); @@ -268,6 +72,7 @@ describe('preload performance marker correlation completions', () => { correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, invalidReason: null, + operationId: OPERATION_ONE, }); expect(restart.correlationState).toBe( PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED @@ -294,7 +99,7 @@ describe('preload performance marker correlation completions', () => { const laterGet = advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); @@ -308,7 +113,7 @@ describe('preload performance marker correlation completions', () => { correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, invalidReason: null, - operationId: null, + operationId: OPERATION_ONE, }); }); }); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts new file mode 100644 index 000000000..aec9e4c7b --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts @@ -0,0 +1,183 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance correlation identities', () => { + it('advances only database calls tagged with the refresh operation', () => { + const { advance } = createCorrelationHarness(); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const unrelated = [ + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_ONE, + }), + ]; + + expect( + unrelated.every( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) + ).toBe(true); + + for (const event of [ + { + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + { + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + phase: 'start', + }, + ] as const) { + advance({ + ...event, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + } + const complete = advance({ + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(complete).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + }); + + it.each([ + { + label: 'operation', + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_ONE, + }, + { + label: 'playlist', + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_TWO, + }, + { + label: 'method', + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }, + ])( + 'fails closed when the completion changes the started $label', + ({ method, operationId, playlistId }) => { + const { advance } = createCorrelationHarness(); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const completion = advance({ + ipcCallId: 2, + method, + operationId, + phase: 'success', + playlistId, + }); + const laterUpsert = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(completion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(laterUpsert).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + }); + } + ); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts index fe4ec4711..65c63c3cf 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts @@ -23,7 +23,6 @@ export type PreloadPerformanceSequenceStage = (typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE)[keyof typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE]; export interface PreloadPerformanceSequence { - activeCallId: number | null; invalidReason: PreloadPerformanceInvalidReason | null; operationId: string; stage: PreloadPerformanceSequenceStage; @@ -31,7 +30,6 @@ export interface PreloadPerformanceSequence { export interface PreloadPerformanceCall { correlationState: PreloadPerformanceCorrelationStateName; - expectedOperationId: string | null; invalidReason: PreloadPerformanceInvalidReason | null; method: PreloadPerformanceMethod; operationId: string | null; @@ -100,12 +98,10 @@ export function createInvalidPreloadPerformanceCall( event: PreloadPerformanceCorrelationEvent, playlistId: string | null, operationId: string | null, - invalidReason: PreloadPerformanceInvalidReason, - expectedOperationId = operationId + invalidReason: PreloadPerformanceInvalidReason ): PreloadPerformanceCall { return { correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - expectedOperationId, invalidReason, method: event.method, operationId, @@ -130,7 +126,6 @@ export function invalidatePreloadPerformanceSequence( : reason; const invalidSequence: PreloadPerformanceSequence = { ...sequence, - activeCallId: null, invalidReason, stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID, }; diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts index 266c1c986..c73f50769 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts @@ -39,28 +39,28 @@ describe('preload performance marker correlation sequences', () => { const getStart = advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); const getSuccess = advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'success', playlistId: PLAYLIST_ONE, }); const upsertStart = advance({ ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); const upsertSuccess = advance({ ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'success', playlistId: PLAYLIST_ONE, }); @@ -104,7 +104,7 @@ describe('preload performance marker correlation sequences', () => { const wrongOrder = advance({ ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); @@ -133,21 +133,21 @@ describe('preload performance marker correlation sequences', () => { advance({ ipcCallId: 4, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); const duplicate = advance({ ipcCallId: 5, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); const originalCompletion = advance({ ipcCallId: 4, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'success', playlistId: PLAYLIST_ONE, }); @@ -253,7 +253,7 @@ describe('preload performance marker correlation sequences', () => { advance({ ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }); @@ -261,7 +261,7 @@ describe('preload performance marker correlation sequences', () => { const playlistTwoGet = advance({ ipcCallId: 4, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_TWO, phase: 'start', playlistId: PLAYLIST_TWO, }); @@ -293,28 +293,28 @@ describe('preload performance marker correlation sequences', () => { { ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }, { ipcCallId: 2, method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'success', playlistId: PLAYLIST_ONE, }, { ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'start', playlistId: PLAYLIST_ONE, }, { ipcCallId: 3, method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, - operationId: null, + operationId: OPERATION_ONE, phase: 'success', playlistId: PLAYLIST_ONE, }, diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts index 271c02e15..3363ba13c 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts @@ -14,6 +14,28 @@ import { type PreloadPerformanceSequence, } from './preload-performance-correlation.model'; +function rememberUncorrelatedCall( + calls: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + method: event.method, + operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + null + ); +} + function startRefresh( calls: Map, sequences: Map, @@ -21,28 +43,27 @@ function startRefresh( playlistId: string, operationId: string ): PreloadPerformanceMarkerMetadata { - const sequence = sequences.get(playlistId); - if (sequence) { + const existing = sequences.get(playlistId); + if (existing) { + const reason = + existing.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ? (existing.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER) + : PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH; const invalidSequence = invalidatePreloadPerformanceSequence( calls, sequences, playlistId, - sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID - ? (sequence.invalidReason ?? - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER) - : PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH + reason ); - const invalidReason = - invalidSequence?.invalidReason ?? - PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH; + const invalidReason = invalidSequence?.invalidReason ?? reason; calls.set( event.ipcCallId, createInvalidPreloadPerformanceCall( event, playlistId, operationId, - invalidReason, - operationId + invalidReason ) ); return createPreloadPerformanceMarkerMetadata( @@ -55,14 +76,12 @@ function startRefresh( } sequences.set(playlistId, { - activeCallId: event.ipcCallId, invalidReason: null, operationId, stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED, }); calls.set(event.ipcCallId, { correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, - expectedOperationId: operationId, invalidReason: null, method: event.method, operationId, @@ -81,26 +100,16 @@ function startDatabaseCall( calls: Map, sequences: Map, event: PreloadPerformanceCorrelationEvent, - playlistId: string + playlistId: string, + operationId: string | null ): PreloadPerformanceMarkerMetadata { + if (operationId === null) { + return rememberUncorrelatedCall(calls, event, playlistId, operationId); + } + const sequence = sequences.get(playlistId); - if (!sequence) { - calls.set(event.ipcCallId, { - correlationState: - PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, - expectedOperationId: null, - invalidReason: null, - method: event.method, - operationId: null, - playlistId, - }); - return createPreloadPerformanceMarkerMetadata( - event, - playlistId, - null, - PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, - null - ); + if (!sequence || sequence.operationId !== operationId) { + return rememberUncorrelatedCall(calls, event, playlistId, operationId); } if (sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID) { @@ -112,15 +121,14 @@ function startDatabaseCall( createInvalidPreloadPerformanceCall( event, playlistId, - sequence.operationId, - invalidReason, - null + operationId, + invalidReason ) ); return createPreloadPerformanceMarkerMetadata( event, playlistId, - sequence.operationId, + operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, invalidReason ); @@ -134,6 +142,7 @@ function startDatabaseCall( const startedStage = isGet ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; + if (sequence.stage !== expectedStage) { const invalidSequence = invalidatePreloadPerformanceSequence( calls, @@ -149,15 +158,14 @@ function startDatabaseCall( createInvalidPreloadPerformanceCall( event, playlistId, - sequence.operationId, - invalidReason, - null + operationId, + invalidReason ) ); return createPreloadPerformanceMarkerMetadata( event, playlistId, - sequence.operationId, + operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, invalidReason ); @@ -165,21 +173,19 @@ function startDatabaseCall( sequences.set(playlistId, { ...sequence, - activeCallId: event.ipcCallId, stage: startedStage, }); calls.set(event.ipcCallId, { correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, - expectedOperationId: null, invalidReason: null, method: event.method, - operationId: sequence.operationId, + operationId, playlistId, }); return createPreloadPerformanceMarkerMetadata( event, playlistId, - sequence.operationId, + operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, null ); @@ -211,8 +217,7 @@ export function startPreloadPerformanceCall( event, playlistId, null, - PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, - operationId + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER ) ); return createPreloadPerformanceMarkerMetadata( @@ -224,28 +229,7 @@ export function startPreloadPerformanceCall( ); } - const existingCall = calls.get(event.ipcCallId); - if (existingCall) { - const sequence = - existingCall.playlistId === null - ? undefined - : invalidatePreloadPerformanceSequence( - calls, - sequences, - existingCall.playlistId, - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER - ); - return createPreloadPerformanceMarkerMetadata( - event, - playlistId, - sequence?.operationId ?? null, - PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - sequence?.invalidReason ?? - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER - ); - } - return event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST ? startRefresh(calls, sequences, event, playlistId, operationId) - : startDatabaseCall(calls, sequences, event, playlistId); + : startDatabaseCall(calls, sequences, event, playlistId, operationId); } diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.ts index 05a7de9e4..a42c82749 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.ts @@ -1,4 +1,3 @@ -import { PRELOAD_PERFORMANCE_METHOD } from '@iptvnator/shared/interfaces'; import { completePreloadPerformanceCall } from './preload-performance-correlation.complete'; import { normalizePreloadPerformanceIdentifier, @@ -29,10 +28,9 @@ export function advancePreloadPerformanceCorrelation( const calls = new Map(state.calls); const sequences = new Map(state.sequences); const playlistId = normalizePreloadPerformanceIdentifier(event.playlistId); - const operationId = - event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST - ? normalizePreloadPerformanceIdentifier(event.operationId) - : null; + const operationId = normalizePreloadPerformanceIdentifier( + event.operationId + ); const marker = event.phase === 'start' ? startPreloadPerformanceCall( diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 9fcdaf2ca..3ff7df791 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -1,5 +1,10 @@ import { createActionGroup, emptyProps, props } from '@ngrx/store'; -import { Channel, EpgProgram, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + Channel, + EpgProgram, + Playlist, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; export const PlaylistActions = createActionGroup({ source: 'Playlists', @@ -11,6 +16,8 @@ export const PlaylistActions = createActionGroup({ 'Remove Playlist': props<{ playlistId: string }>(), 'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(), 'Update Playlist': props<{ + /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + operationId?: string; playlist: Playlist; playlistId: string; refreshEpg?: boolean; diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index ae0e5f0f5..d827eb6cd 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -55,6 +55,7 @@ import { } from './external-player-payload.util'; import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util'; import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util'; +import { persistPlaylistUpdate } from './playlist-update.effect-handler'; @Injectable({ providedIn: 'any' }) export class PlaylistEffects { @@ -305,10 +306,7 @@ export class PlaylistEffects { return this.actions$.pipe( ofType(PlaylistActions.updatePlaylist), switchMap((action) => - this.playlistsService.updatePlaylist(action.playlistId, { - ...action.playlist, - _id: action.playlistId, - }).pipe( + persistPlaylistUpdate(this.playlistsService, action).pipe( tap(() => { this.fetchPlaylistScopedEpg(action.playlist, { force: action.refreshEpg === true, @@ -353,12 +351,14 @@ export class PlaylistEffects { if ('isTemporary' in action && action.isTemporary) { return EMPTY; } - return this.playlistsService.addPlaylist(action.playlist).pipe( - tap(() => { - this.fetchPlaylistScopedEpg(action.playlist); - this.navigateToPlaylist(action.playlist); - }) - ); + return this.playlistsService + .addPlaylist(action.playlist) + .pipe( + tap(() => { + this.fetchPlaylistScopedEpg(action.playlist); + this.navigateToPlaylist(action.playlist); + }) + ); }) ); }, @@ -370,17 +370,21 @@ export class PlaylistEffects { return this.actions$.pipe( ofType(PlaylistActions.updatePlaylistMeta), switchMap((action) => - this.playlistsService.updatePlaylistMeta(action.playlist).pipe( - tap(() => { - if ( - this.hasPlaylistScopedEpgSourceChange( - action.playlist - ) - ) { - this.fetchPlaylistScopedEpg(action.playlist); - } - }) - ) + this.playlistsService + .updatePlaylistMeta(action.playlist) + .pipe( + tap(() => { + if ( + this.hasPlaylistScopedEpgSourceChange( + action.playlist + ) + ) { + this.fetchPlaylistScopedEpg( + action.playlist + ); + } + }) + ) ) ); }, diff --git a/libs/m3u-state/src/lib/playlist-update.effect-handler.ts b/libs/m3u-state/src/lib/playlist-update.effect-handler.ts new file mode 100644 index 000000000..899fb349f --- /dev/null +++ b/libs/m3u-state/src/lib/playlist-update.effect-handler.ts @@ -0,0 +1,29 @@ +import type { Playlist } from '@iptvnator/shared/interfaces'; + +interface PlaylistUpdateAction { + operationId?: string; + playlist: Playlist; + playlistId: string; +} + +interface PlaylistUpdater { + updatePlaylist: ( + playlistId: string, + playlist: Playlist, + operationId?: string + ) => TResult; +} + +export function persistPlaylistUpdate( + playlistsService: PlaylistUpdater, + action: PlaylistUpdateAction +): TResult { + return playlistsService.updatePlaylist( + action.playlistId, + { + ...action.playlist, + _id: action.playlistId, + }, + action.operationId + ); +} diff --git a/libs/m3u-state/src/lib/playlist-update.effect.spec.ts b/libs/m3u-state/src/lib/playlist-update.effect.spec.ts new file mode 100644 index 000000000..6041442a3 --- /dev/null +++ b/libs/m3u-state/src/lib/playlist-update.effect.spec.ts @@ -0,0 +1,30 @@ +import { type Playlist } from '@iptvnator/shared/interfaces'; +import { of } from 'rxjs'; +import { PlaylistActions } from './actions'; +import { persistPlaylistUpdate } from './playlist-update.effect-handler'; + +describe('PlaylistEffects updatePlaylist', () => { + it('forwards the refresh operation ID to playlist persistence', () => { + const playlistsService = { + updatePlaylist: jest.fn(() => of(undefined)), + }; + const playlist = { + _id: 'playlist-1', + playlist: { items: [] }, + } as unknown as Playlist; + const action = PlaylistActions.updatePlaylist({ + playlist, + playlistId: playlist._id, + refreshEpg: true, + operationId: 'playlist-refresh-operation', + }); + + persistPlaylistUpdate(playlistsService, action); + + expect(playlistsService.updatePlaylist).toHaveBeenCalledWith( + playlist._id, + playlist, + 'playlist-refresh-operation' + ); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 76ea80fa1..2ba8bb51e 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -630,6 +630,7 @@ describe('PlaylistRefreshActionService', () => { playlist: refreshedPlaylist, playlistId: item._id, refreshEpg: true, + operationId: 'playlist-refresh-op', }) ); }); diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index 8ca73a67a..43aed4ab6 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -205,12 +205,11 @@ export class PlaylistRefreshActionService { } try { + const operationId = + this.databaseService.createOperationId('playlist-refresh'); const refreshedPlaylist = await this.playlistRefreshService.refreshPlaylist({ - operationId: - this.databaseService.createOperationId( - 'playlist-refresh' - ), + operationId, playlistId: item._id, title: item.title, url: item.url, @@ -228,6 +227,7 @@ export class PlaylistRefreshActionService { }, playlistId: item._id, refreshEpg: true, + operationId, }) ); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index ff7e04cf1..dca8d0fa0 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -549,5 +549,15 @@ describe('RecentPlaylistsComponent busy state', () => { expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); await Promise.resolve(); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylist({ + playlist: expect.objectContaining({ + _id: item._id, + }), + playlistId: item._id, + refreshEpg: true, + operationId: 'playlist-refresh-op', + }) + ); }); }); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index a615e96ec..bbada321d 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -463,6 +463,7 @@ export class RecentPlaylistsComponent { }, playlistId: item._id, refreshEpg: true, + operationId, }) ); diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index fa269989d..f240dd9fd 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -1336,6 +1336,37 @@ describe('PlaylistsService', () => { expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2); }); + it('forwards refresh instrumentation only to its SQLite read and write', async () => { + const { electron } = createStatefulElectronStore( + createBasePlaylist('playlist-instrumented-refresh') + ); + testWindow.electron = electron; + const service = createService(); + const operationId = 'playlist-refresh-operation'; + + await firstValueFrom( + service.updatePlaylist( + 'playlist-instrumented-refresh', + { + _id: 'playlist-instrumented-refresh', + playlist: { items: [] }, + } as Playlist, + operationId + ) + ); + + expect(electron.dbGetAppPlaylist).toHaveBeenCalledWith( + 'playlist-instrumented-refresh', + operationId + ); + expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'playlist-instrumented-refresh', + }), + operationId + ); + }); + it('keeps both favorites when two rapid favorite adds overlap (SQLite)', async () => { const { store, electron } = createStatefulElectronStore( createBasePlaylist('portal-race-two-favorites') diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 9233d8d3c..85b332a10 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -52,7 +52,10 @@ type PlaylistRawItem = { type PlaylistStorageElectronApi = { dbDeleteAllPlaylists: () => Promise; dbDeletePlaylist: (playlistId: string) => Promise; - dbGetAppPlaylist: (playlistId: string) => Promise; + dbGetAppPlaylist: ( + playlistId: string, + operationId?: string + ) => Promise; dbGetAppPlaylistFavoriteChannels?: ( playlistId: string ) => Promise; @@ -60,7 +63,10 @@ type PlaylistStorageElectronApi = { dbGetAppPlaylists: () => Promise; dbGetAppState: (key: string) => Promise; dbSetAppState: (key: string, value: string) => Promise; - dbUpsertAppPlaylist: (playlist: Playlist) => Promise; + dbUpsertAppPlaylist: ( + playlist: Playlist, + operationId?: string + ) => Promise; dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise; }; @@ -362,14 +368,18 @@ export class PlaylistsService { } } - private upsertSqlitePlaylist(playlist: Playlist) { + private upsertSqlitePlaylist(playlist: Playlist, operationId?: string) { return this.runOnSqlite(async () => { const electron = this.electronApi; if (!electron) { return playlist; } - await electron.dbUpsertAppPlaylist(playlist); + if (operationId === undefined) { + await electron.dbUpsertAppPlaylist(playlist); + } else { + await electron.dbUpsertAppPlaylist(playlist, operationId); + } return playlist; }); } @@ -411,9 +421,14 @@ export class PlaylistsService { }); } - private persistPlaylistMutation(nextPlaylist: Playlist) { + private persistPlaylistMutation( + nextPlaylist: Playlist, + operationId?: string + ) { if (this.isElectronStorageAvailable) { - return firstValueFrom(this.upsertSqlitePlaylist(nextPlaylist)); + return firstValueFrom( + this.upsertSqlitePlaylist(nextPlaylist, operationId) + ); } return firstValueFrom( @@ -554,10 +569,14 @@ export class PlaylistsService { }; } - updatePlaylist(playlistId: string, updatedPlaylist: Playlist) { + updatePlaylist( + playlistId: string, + updatedPlaylist: Playlist, + operationId?: string + ) { return this.serializePlaylistWrite(playlistId, async () => { const currentPlaylist = await firstValueFrom( - this.getPlaylistById(playlistId) + this.getPlaylistById(playlistId, operationId) ); const mergedPlaylist = this.mergeRefreshedPlaylist( currentPlaylist, @@ -565,17 +584,21 @@ export class PlaylistsService { playlistId ); - return this.persistPlaylistMutation(mergedPlaylist); + return this.persistPlaylistMutation(mergedPlaylist, operationId); }); } - getPlaylistById(id: string) { + getPlaylistById(id: string, operationId?: string) { if (this.isElectronStorageAvailable) { return this.runOnSqlite(async () => { const electron = this.electronApi; - const playlist = electron - ? await electron.dbGetAppPlaylist(id) - : null; + let playlist: Playlist | null = null; + if (electron) { + playlist = + operationId === undefined + ? await electron.dbGetAppPlaylist(id) + : await electron.dbGetAppPlaylist(id, operationId); + } return playlist ? this.createSqliteFallbackPlaylist(playlist as Playlist) : (undefined as unknown as Playlist); diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 08214323f..3fd7dec36 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -684,13 +684,21 @@ export interface ElectronBridgeApi { dbGetPlaylist: ( playlistId: string ) => Promise; - dbUpsertAppPlaylist: (playlist: Playlist) => Promise; + dbUpsertAppPlaylist: ( + playlist: Playlist, + /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + operationId?: string + ) => Promise; dbUpsertAppPlaylists: ( playlists: Playlist[] ) => Promise; dbGetAppPlaylists: () => Promise; dbGetAppPlaylistMetas: () => Promise; - dbGetAppPlaylist: (playlistId: string) => Promise; + dbGetAppPlaylist: ( + playlistId: string, + /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + operationId?: string + ) => Promise; dbGetAppPlaylistFavoriteChannels: ( playlistId: string ) => Promise; From 0579d253c4e9a1273423c1be72cd76a3bef8bf4b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 19:49:17 +0200 Subject: [PATCH 10/32] fix(electron): fail closed on unknown performance completions --- ...reload-performance-correlation.complete.ts | 19 +- ...performance-correlation.completion.spec.ts | 192 ++++++++++++++++++ libs/m3u-state/src/lib/actions.ts | 5 +- ...=> playlist-update.effect-handler.spec.ts} | 2 +- .../src/lib/electron-api.interface.ts | 10 +- 5 files changed, 222 insertions(+), 6 deletions(-) rename libs/m3u-state/src/lib/{playlist-update.effect.spec.ts => playlist-update.effect-handler.spec.ts} (95%) diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts index b27e327b3..e8dc4e699 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts @@ -168,12 +168,27 @@ export function completePreloadPerformanceCall( ): PreloadPerformanceMarkerMetadata { const call = calls.get(event.ipcCallId); if (!call) { + const invalidSequence = + playlistId === null + ? undefined + : invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + playlistId + ); return createPreloadPerformanceMarkerMetadata( event, playlistId, - operationId, + invalidSequence?.operationId ?? operationId, PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, - PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER ); } diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts index 6cde5c2cc..e10251ddd 100644 --- a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts @@ -8,9 +8,48 @@ import { OPERATION_ONE, OPERATION_TWO, PLAYLIST_ONE, + PLAYLIST_TWO, type CorrelationHarness, } from './preload-performance-correlation.test-helpers'; +function completeDatabaseSequence( + advance: CorrelationHarness['advance'], + firstCallId: number, + playlistId: string, + operationId: string +) { + return [ + advance({ + ipcCallId: firstCallId, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId, + phase: 'start', + playlistId, + }), + advance({ + ipcCallId: firstCallId, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId, + phase: 'success', + playlistId, + }), + advance({ + ipcCallId: firstCallId + 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId, + phase: 'start', + playlistId, + }), + advance({ + ipcCallId: firstCallId + 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId, + phase: 'success', + playlistId, + }), + ]; +} + describe('preload performance marker correlation completions', () => { let advance: CorrelationHarness['advance']; @@ -18,6 +57,159 @@ describe('preload performance marker correlation completions', () => { ({ advance } = createCorrelationHarness()); }); + it.each(['success', 'error'] as const)( + 'invalidates only the matching playlist after an unknown call ID %s', + (phase) => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + + const unknownCompletion = advance({ + ipcCallId: 99, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase, + playlistId: PLAYLIST_ONE, + }); + const matchingCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const samePlaylistPersistence = completeDatabaseSequence( + advance, + 20, + PLAYLIST_ONE, + OPERATION_ONE + ); + + const unrelatedRefreshCompletion = advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + const unrelatedPersistence = completeDatabaseSequence( + advance, + 30, + PLAYLIST_TWO, + OPERATION_TWO + ); + + expect(unknownCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(matchingCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect( + [matchingCompletion, ...samePlaylistPersistence].some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + expect(unrelatedRefreshCompletion.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + expect(unrelatedPersistence.at(-1)).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + } + ); + + it('invalidates persistence after a duplicate completion without touching another playlist', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const duplicateCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const samePlaylistPersistence = completeDatabaseSequence( + advance, + 20, + PLAYLIST_ONE, + OPERATION_ONE + ); + + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + const unrelatedPersistence = completeDatabaseSequence( + advance, + 30, + PLAYLIST_TWO, + OPERATION_TWO + ); + + expect(duplicateCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect( + samePlaylistPersistence.some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + expect(unrelatedPersistence.at(-1)).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + }); + it('invalidates a correlated sequence when a target call errors', () => { advance({ ipcCallId: 1, diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 3ff7df791..3f6008509 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -16,7 +16,10 @@ export const PlaylistActions = createActionGroup({ 'Remove Playlist': props<{ playlistId: string }>(), 'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(), 'Update Playlist': props<{ - /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + /** + * Instrumentation-only; stripped before the DB invoke. + * Never enters the DB worker payload or persisted playlist data. + */ operationId?: string; playlist: Playlist; playlistId: string; diff --git a/libs/m3u-state/src/lib/playlist-update.effect.spec.ts b/libs/m3u-state/src/lib/playlist-update.effect-handler.spec.ts similarity index 95% rename from libs/m3u-state/src/lib/playlist-update.effect.spec.ts rename to libs/m3u-state/src/lib/playlist-update.effect-handler.spec.ts index 6041442a3..3e80ff0d4 100644 --- a/libs/m3u-state/src/lib/playlist-update.effect.spec.ts +++ b/libs/m3u-state/src/lib/playlist-update.effect-handler.spec.ts @@ -3,7 +3,7 @@ import { of } from 'rxjs'; import { PlaylistActions } from './actions'; import { persistPlaylistUpdate } from './playlist-update.effect-handler'; -describe('PlaylistEffects updatePlaylist', () => { +describe('persistPlaylistUpdate', () => { it('forwards the refresh operation ID to playlist persistence', () => { const playlistsService = { updatePlaylist: jest.fn(() => of(undefined)), diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 3fd7dec36..de3695b46 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -686,7 +686,10 @@ export interface ElectronBridgeApi { ) => Promise; dbUpsertAppPlaylist: ( playlist: Playlist, - /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + /** + * Instrumentation-only; stripped before the DB invoke. + * Never enters the DB worker payload or persisted playlist data. + */ operationId?: string ) => Promise; dbUpsertAppPlaylists: ( @@ -696,7 +699,10 @@ export interface ElectronBridgeApi { dbGetAppPlaylistMetas: () => Promise; dbGetAppPlaylist: ( playlistId: string, - /** Instrumentation-only; never persisted or sent to main/worker IPC. */ + /** + * Instrumentation-only; stripped before the DB invoke. + * Never enters the DB worker payload or persisted playlist data. + */ operationId?: string ) => Promise; dbGetAppPlaylistFavoriteChannels: ( From da3b657277221b6caf0b2b9179fbae4903d303a7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 22:37:29 +0200 Subject: [PATCH 11/32] fix(perf): make worker profiling request scoped --- AGENTS.md | 2 +- CLAUDE.md | 2 +- .../database-request-identity-capture.spec.ts | 229 +++++++++++ .../database-request-identity-capture.ts | 270 +++++++++++++ .../m3u-refresh-cancellation-contract.ts | 44 ++- .../m3u-refresh-cancellation-report.ts | 108 +++-- .../performance/m3u-refresh-main-capture.ts | 362 ++++++++++------- ...orker-request-performance-envelope.spec.ts | 172 ++++++++ .../worker-request-performance.spec.ts | 302 ++++++++++++++ .../performance/worker-request-performance.ts | 302 ++++++++++++++ .../src/app/workers/database.worker.ts | 146 +++++-- .../app/workers/playlist-refresh.worker.ts | 98 +++-- .../worker-performance-cancellation.spec.ts | 284 +++++++++++++ ...er-performance-capture.concurrency.spec.ts | 95 +++++ ...rker-performance-capture.histogram.spec.ts | 39 ++ .../worker-performance-capture.histogram.ts | 136 +++++++ .../worker-performance-capture.metrics.ts | 235 +++++++++++ .../worker-performance-capture.model.ts | 94 +++++ .../worker-performance-capture.runtime.ts | 45 +++ .../worker-performance-capture.spec.ts | 373 +++++++++++++++++- ...worker-performance-capture.test-harness.ts | 158 ++++++++ .../app/workers/worker-performance-capture.ts | 285 +++++++++++-- .../worker-performance-integration.spec.ts | 118 ++++++ docs/architecture/m3u-playlist-module.md | 26 +- docs/architecture/sqlite-db-worker.md | 29 ++ 25 files changed, 3631 insertions(+), 323 deletions(-) create mode 100644 apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts create mode 100644 apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/worker-request-performance.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.model.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts diff --git a/AGENTS.md b/AGENTS.md index f3cd7a990..c1b428902 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset - - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset + - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset - Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching diff --git a/CLAUDE.md b/CLAUDE.md index 72eed72c7..bd872ab79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,7 @@ Useful narrower flags: - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset -- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset +- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching diff --git a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts new file mode 100644 index 000000000..2e9672718 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts @@ -0,0 +1,229 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import { + installDatabaseRequestIdentityCaptureInMain, + type DatabaseRequestIdentityCaptureApi, +} from './database-request-identity-capture'; + +const CHANNEL = 'IPTVNATOR_PERF_CAPTURE_MARKER'; +let nextStateId = 1; + +function createCapture(): { + api: DatabaseRequestIdentityCaptureApi; + ipcMain: EventEmitter; +} { + const ipcMain = new EventEmitter(); + const stateKey = `__identityCapture${nextStateId}`; + nextStateId += 1; + installDatabaseRequestIdentityCaptureInMain({ ipcMain } as never, stateKey); + const api = (globalThis as unknown as Record)[ + stateKey + ] as DatabaseRequestIdentityCaptureApi; + api.start(); + return { api, ipcMain }; +} + +function marker(overrides: Record = {}) { + return { + correlationState: 'correlated', + invalidReason: null, + ipcCallId: 2, + method: 'dbGetAppPlaylist', + operationId: 'refresh-operation-1', + phase: 'start', + playlistId: 'playlist-1', + sourceEpochMs: 100, + ...overrides, + }; +} + +test('correlates the real preload marker to a DB request whose payload omits operationId', () => { + const { api, ipcMain } = createCapture(); + ipcMain.emit(CHANNEL, {}, marker()); + + const identity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-1', + type: 'request', + }); + + assert.deepEqual(identity, { + operationId: 'refresh-operation-1', + operationIdUnavailableReason: null, + }); +}); + +test('resolves a response-retained request identity when the marker arrives later and resets between captures', () => { + const { api, ipcMain } = createCapture(); + const identity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-before-marker', + type: 'request', + }); + assert.equal( + identity.operationIdUnavailableReason, + 'preload-performance-marker-missing' + ); + const responseOutcome = { identity }; + + ipcMain.emit(CHANNEL, {}, marker()); + assert.deepEqual(responseOutcome.identity, { + operationId: 'refresh-operation-1', + operationIdUnavailableReason: null, + }); + + api.stop(); + ipcMain.emit(CHANNEL, {}, marker({ operationId: 'marker-while-stopped' })); + api.start(); + const afterReset = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-after-reset', + type: 'request', + }); + assert.deepEqual(afterReset, { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-missing', + }); +}); + +test('fails closed for missing and ambiguous preload markers', () => { + const missingCapture = createCapture(); + const missing = missingCapture.api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-missing', + type: 'request', + }); + assert.deepEqual(missing, { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-missing', + }); + + const ambiguousCapture = createCapture(); + ambiguousCapture.ipcMain.emit(CHANNEL, {}, marker()); + ambiguousCapture.ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 3, + operationId: 'refresh-operation-2', + }) + ); + const ambiguous = ambiguousCapture.api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-ambiguous', + type: 'request', + }); + assert.deepEqual(ambiguous, { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + }); +}); + +test('quarantines a key after response-before-marker ambiguity', () => { + const { api, ipcMain } = createCapture(); + const requestA = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-a', + type: 'request', + }); + const requestB = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-b', + type: 'request', + }); + + ipcMain.emit( + CHANNEL, + {}, + marker({ ipcCallId: 2, operationId: 'refresh-operation-a' }) + ); + ipcMain.emit( + CHANNEL, + {}, + marker({ ipcCallId: 3, operationId: 'refresh-operation-b' }) + ); + + const requestC = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-c', + type: 'request', + }); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 4, + operationId: 'other-playlist-operation', + playlistId: 'playlist-2', + }) + ); + const otherPlaylist = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-2' }, + requestId: 'other-playlist-request', + type: 'request', + }); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 5, + method: 'dbUpsertAppPlaylist', + operationId: 'other-db-operation', + }) + ); + const otherOperation = api.matchDatabaseRequest({ + operation: 'DB_UPSERT_APP_PLAYLIST', + payload: { _id: 'playlist-1' }, + requestId: 'other-operation-request', + type: 'request', + }); + + const ambiguousIdentity = { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + }; + assert.deepEqual(requestA, ambiguousIdentity); + assert.deepEqual(requestB, ambiguousIdentity); + assert.deepEqual(requestC, ambiguousIdentity); + assert.deepEqual(otherPlaylist, { + operationId: 'other-playlist-operation', + operationIdUnavailableReason: null, + }); + assert.deepEqual(otherOperation, { + operationId: 'other-db-operation', + operationIdUnavailableReason: null, + }); + + api.stop(); + api.start(); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 6, + operationId: 'fresh-capture-operation', + }) + ); + const freshCaptureIdentity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'fresh-capture-request', + type: 'request', + }); + assert.deepEqual(freshCaptureIdentity, { + operationId: 'fresh-capture-operation', + operationIdUnavailableReason: null, + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts new file mode 100644 index 000000000..a0912d3e3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts @@ -0,0 +1,270 @@ +import type { ElectronApplication } from '@playwright/test'; + +export const DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY = + '__iptvnatorM3uRefreshDatabaseRequestIdentityCapture'; + +export interface DatabaseRequestIdentity { + operationId: string | null; + operationIdUnavailableReason: string | null; +} + +export interface DatabaseRequestIdentityCaptureApi { + matchDatabaseRequest(message: unknown): DatabaseRequestIdentity; + start(): void; + stop(): void; +} + +interface DatabaseRequestIdentityElectron { + readonly ipcMain: { + on( + channel: string, + listener: (event: unknown, marker: unknown) => void + ): void; + }; +} + +export function installDatabaseRequestIdentityCaptureInMain( + { ipcMain }: DatabaseRequestIdentityElectron, + stateKey: string +): void { + type JsonRecord = Record; + interface PendingMarker { + readonly ipcCallId: number; + readonly operation: string; + readonly operationId: string; + readonly playlistId: string; + } + interface PendingRequest { + readonly identity: DatabaseRequestIdentity; + readonly operation: string; + readonly playlistId: string; + } + interface QuarantinedKey { + readonly operation: string; + readonly playlistId: string; + } + + const target = globalThis as unknown as Record; + if (target[stateKey] !== undefined) { + return; + } + + const markerChannel = 'IPTVNATOR_PERF_CAPTURE_MARKER'; + const markerMethodToOperation: Readonly> = { + dbGetAppPlaylist: 'DB_GET_APP_PLAYLIST', + dbUpsertAppPlaylist: 'DB_UPSERT_APP_PLAYLIST', + }; + let active = false; + let pendingMarkers: PendingMarker[] = []; + let pendingRequests: PendingRequest[] = []; + let quarantinedKeys: QuarantinedKey[] = []; + + const readRecord = (value: unknown): JsonRecord | null => + typeof value === 'object' && value !== null + ? (value as JsonRecord) + : null; + const readPlaylistId = ( + operation: string, + payload: unknown + ): string | null => { + const value = readRecord(payload); + if (!value) { + return null; + } + const playlistId = + operation === 'DB_UPSERT_APP_PLAYLIST' + ? value['_id'] + : value['playlistId']; + return typeof playlistId === 'string' && playlistId.length > 0 + ? playlistId + : null; + }; + const matches = ( + candidate: { operation: string; playlistId: string }, + operation: string, + playlistId: string + ): boolean => + candidate.operation === operation && + candidate.playlistId === playlistId; + const markAmbiguous = (requests: PendingRequest[]): void => { + for (const request of requests) { + request.identity.operationId = null; + request.identity.operationIdUnavailableReason = + 'preload-performance-marker-ambiguous'; + } + }; + const isQuarantined = (operation: string, playlistId: string): boolean => + quarantinedKeys.some((key) => matches(key, operation, playlistId)); + const quarantine = (operation: string, playlistId: string): void => { + if (!isQuarantined(operation, playlistId)) { + quarantinedKeys.push({ operation, playlistId }); + } + const requestCandidates = pendingRequests.filter((request) => + matches(request, operation, playlistId) + ); + markAmbiguous(requestCandidates); + pendingRequests = pendingRequests.filter( + (request) => !matches(request, operation, playlistId) + ); + pendingMarkers = pendingMarkers.filter( + (marker) => !matches(marker, operation, playlistId) + ); + }; + + ipcMain.on(markerChannel, (_event, input) => { + if (!active) { + return; + } + const marker = readRecord(input); + if ( + !marker || + marker['phase'] !== 'start' || + marker['correlationState'] !== 'correlated' || + marker['invalidReason'] !== null + ) { + return; + } + const operation = + typeof marker['method'] === 'string' + ? markerMethodToOperation[marker['method']] + : undefined; + const operationId = marker['operationId']; + const playlistId = marker['playlistId']; + const ipcCallId = marker['ipcCallId']; + if ( + operation === undefined || + typeof operationId !== 'string' || + operationId.length === 0 || + typeof playlistId !== 'string' || + playlistId.length === 0 || + !Number.isSafeInteger(ipcCallId) || + Number(ipcCallId) < 1 + ) { + return; + } + + if (isQuarantined(operation, playlistId)) { + return; + } + const requestCandidates = pendingRequests.filter((request) => + matches(request, operation, playlistId) + ); + if (requestCandidates.length === 1) { + const request = requestCandidates[0]; + if (!request) { + return; + } + request.identity.operationId = operationId; + request.identity.operationIdUnavailableReason = null; + pendingRequests = pendingRequests.filter( + (candidate) => candidate !== request + ); + return; + } + if (requestCandidates.length > 1) { + quarantine(operation, playlistId); + return; + } + + pendingMarkers.push({ + ipcCallId: Number(ipcCallId), + operation, + operationId, + playlistId, + }); + pendingMarkers.sort((left, right) => left.ipcCallId - right.ipcCallId); + }); + + const api: DatabaseRequestIdentityCaptureApi = { + matchDatabaseRequest: (input) => { + const message = readRecord(input); + const operation = message?.['operation']; + const playlistId = + typeof operation === 'string' + ? readPlaylistId(operation, message?.['payload']) + : null; + if ( + !active || + (operation !== 'DB_GET_APP_PLAYLIST' && + operation !== 'DB_UPSERT_APP_PLAYLIST') || + playlistId === null + ) { + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-not-applicable', + }; + } + + if (isQuarantined(operation, playlistId)) { + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-ambiguous', + }; + } + const markerCandidates = pendingMarkers.filter((marker) => + matches(marker, operation, playlistId) + ); + if (markerCandidates.length === 1) { + const marker = markerCandidates[0]; + if (!marker) { + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-missing', + }; + } + pendingMarkers = pendingMarkers.filter( + (candidate) => candidate !== marker + ); + return { + operationId: marker.operationId, + operationIdUnavailableReason: null, + }; + } + if (markerCandidates.length > 1) { + quarantine(operation, playlistId); + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-ambiguous', + }; + } + + const identity: DatabaseRequestIdentity = { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-missing', + }; + pendingRequests.push({ + identity, + operation, + playlistId, + }); + return identity; + }, + start: () => { + pendingMarkers = []; + pendingRequests = []; + quarantinedKeys = []; + active = true; + }, + stop: () => { + active = false; + pendingMarkers = []; + pendingRequests = []; + quarantinedKeys = []; + }, + }; + target[stateKey] = api; +} + +export async function installDatabaseRequestIdentityCapture( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate( + installDatabaseRequestIdentityCaptureInMain, + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY + ); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts index f0abcd22f..344767515 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -48,6 +48,29 @@ export interface MainTimelineRecord { readonly type: string; } +export interface WorkerRequestPerformanceMetrics { + readonly eventLoopDelay: EventLoopDelayMetrics | null; + readonly eventLoopDelayUnavailableReason: string | null; + readonly eventLoopUtilization: number | null; + readonly eventLoopUtilizationUnavailableReason: string | null; + readonly histogramFlushedEpochMs: number | null; + readonly invalidReason: string | null; + readonly operation: string | null; + readonly operationId: string | null; + readonly operationIdUnavailableReason: string | null; + readonly performanceCaptureUnavailableReason: string | null; + readonly playlistId: string | null; + readonly requestId: string | null; + readonly requestReceivedEpochMs: number | null; + readonly responseEpochMs: number; + readonly success: boolean; + readonly threadCpuSystemMicros: number | null; + readonly threadCpuUnavailableReason: string | null; + readonly threadCpuUserMicros: number | null; + readonly workEndedEpochMs: number | null; + readonly workStartedEpochMs: number | null; +} + export interface WorkerCaptureMetrics { readonly cancelPostedEpochMs: number | null; readonly cpuSystemMicros: number | null; @@ -62,6 +85,7 @@ export interface WorkerCaptureMetrics { readonly playlistId: string | null; readonly postGcHeapUsedBytes: number | null; readonly profilePath: string | null; + readonly requests: readonly WorkerRequestPerformanceMetrics[]; readonly responseEpochMs: number | null; readonly snapshotPath: string | null; readonly terminatedEpochMs: number | null; @@ -176,13 +200,15 @@ export interface CancellationBenchmarkSummary { readonly cancelToDurableTerminalMs: NumericDistribution; readonly cancelToWorkerTerminatedMs: NumericDistribution; readonly cancelTransportLatencyMs: NumericDistribution; - readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution; - readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution; - readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution; - readonly databaseWorkerEventLoopUtilization: NumericDistribution; readonly databaseWorkerExternalPeakBytes: NumericDistribution; readonly databaseWorkerHeapPeakBytes: NumericDistribution; readonly databaseWorkerPostGcHeapBytes: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerRequestEventLoopUtilization: NumericDistribution; + readonly databaseWorkerRequestThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerRequestThreadCpuUserMicros: NumericDistribution; readonly dataFetchMs: NumericDistribution; readonly dbCommitProxyMs: NumericDistribution; readonly ipcStoreDispatchProxyMs: NumericDistribution; @@ -196,13 +222,15 @@ export interface CancellationBenchmarkSummary { readonly mainRssPostGcBytes: NumericDistribution; readonly parseNormalizeCloneProxyMs: NumericDistribution; readonly persistencePreparationProxyMs: NumericDistribution; - readonly playlistWorkerEventLoopUtilization: NumericDistribution; - readonly playlistWorkerEventLoopDelayMaxMs: NumericDistribution; - readonly playlistWorkerEventLoopDelayP95Ms: NumericDistribution; - readonly playlistWorkerEventLoopDelayP99Ms: NumericDistribution; readonly playlistWorkerExternalPeakBytes: NumericDistribution; readonly playlistWorkerHeapPeakBytes: NumericDistribution; readonly playlistWorkerPostGcHeapBytes: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayMaxMs: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayP95Ms: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayP99Ms: NumericDistribution; + readonly playlistWorkerRequestEventLoopUtilization: NumericDistribution; + readonly playlistWorkerRequestThreadCpuSystemMicros: NumericDistribution; + readonly playlistWorkerRequestThreadCpuUserMicros: NumericDistribution; readonly rendererFrameGapMs: NumericDistribution; readonly rendererHeartbeatDelayMs: NumericDistribution; readonly rendererHeapPeakBytes: NumericDistribution; diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts index 2f063cd0a..7a8e4c220 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -9,6 +9,7 @@ import type { NumericDistribution, PerformanceWorkerKind, RendererCaptureMetrics, + WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { PERFORMANCE_ITERATION_KIND, @@ -63,15 +64,28 @@ export function createCancellationBenchmarkSummary( return worker ? select(worker) : null; }) ); - const workerEventLoopDelayMetric = ( + const workerRequestMetric = ( + kind: PerformanceWorkerKind, + select: (request: WorkerRequestPerformanceMetrics) => number | null + ): NumericDistribution => + summarizeNumbers( + measured.flatMap((iteration) => + iteration.main.workers + .filter((worker) => worker.kind === kind) + .flatMap((worker) => + worker.requests.map((request) => select(request)) + ) + ) + ); + const workerRequestEventLoopDelayMetric = ( kind: PerformanceWorkerKind, percentile: keyof NonNullable< - MainCaptureMetrics['workers'][number]['eventLoopDelay'] + WorkerRequestPerformanceMetrics['eventLoopDelay'] > ): NumericDistribution => - workerMetric( + workerRequestMetric( kind, - (worker) => worker.eventLoopDelay?.[percentile] ?? null + (request) => request.eventLoopDelay?.[percentile] ?? null ); return Object.freeze({ @@ -105,22 +119,6 @@ export function createCancellationBenchmarkSummary( (iteration) => iteration.phases.cancelTransportLatencyMs ) ), - databaseWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'maxMs' - ), - databaseWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'p95Ms' - ), - databaseWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'p99Ms' - ), - databaseWorkerEventLoopUtilization: workerMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - (worker) => worker.eventLoopUtilization - ), databaseWorkerExternalPeakBytes: workerMetric( PERFORMANCE_WORKER_KIND.DATABASE, (worker) => worker.peakExternalBytes @@ -133,6 +131,33 @@ export function createCancellationBenchmarkSummary( PERFORMANCE_WORKER_KIND.DATABASE, (worker) => worker.postGcHeapUsedBytes ), + databaseWorkerRequestEventLoopDelayMaxMs: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'maxMs' + ), + databaseWorkerRequestEventLoopDelayP95Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p95Ms' + ), + databaseWorkerRequestEventLoopDelayP99Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p99Ms' + ), + databaseWorkerRequestEventLoopUtilization: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.eventLoopUtilization + ), + databaseWorkerRequestThreadCpuSystemMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.threadCpuSystemMicros + ), + databaseWorkerRequestThreadCpuUserMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.threadCpuUserMicros + ), dataFetchMs: summarizeNumbers( measured.map((iteration) => iteration.phases.dataFetchMs) ), @@ -185,22 +210,6 @@ export function createCancellationBenchmarkSummary( iteration.phases.persistencePreparationProxyMs ) ), - playlistWorkerEventLoopUtilization: workerMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - (worker) => worker.eventLoopUtilization - ), - playlistWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'maxMs' - ), - playlistWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'p95Ms' - ), - playlistWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'p99Ms' - ), playlistWorkerExternalPeakBytes: workerMetric( PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, (worker) => worker.peakExternalBytes @@ -213,6 +222,33 @@ export function createCancellationBenchmarkSummary( PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, (worker) => worker.postGcHeapUsedBytes ), + playlistWorkerRequestEventLoopDelayMaxMs: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'maxMs' + ), + playlistWorkerRequestEventLoopDelayP95Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p95Ms' + ), + playlistWorkerRequestEventLoopDelayP99Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p99Ms' + ), + playlistWorkerRequestEventLoopUtilization: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.eventLoopUtilization + ), + playlistWorkerRequestThreadCpuSystemMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.threadCpuSystemMicros + ), + playlistWorkerRequestThreadCpuUserMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.threadCpuUserMicros + ), rendererFrameGapMs: summarizeNumbers( flattenRenderer( (iteration) => iteration.renderer.probe.frameGapsMs diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts index fd9435445..f61fa12b4 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -1,7 +1,17 @@ /* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */ import type { ElectronApplication } from '@playwright/test'; +import { + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + installDatabaseRequestIdentityCapture, + type DatabaseRequestIdentity, + type DatabaseRequestIdentityCaptureApi, +} from './database-request-identity-capture'; import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; +import { + selectMainCaptureGeneration, + type MainCaptureGenerationTransport, +} from './worker-request-performance'; const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; @@ -23,7 +33,13 @@ export interface MainCaptureStatus { export async function installMainCapture( electronApp: ElectronApplication ): Promise { - await electronApp.evaluate(async ({ app, BrowserWindow }, stateKey) => { + await installDatabaseRequestIdentityCapture(electronApp); + const captureStateKeys = { + databaseRequestIdentityStateKey: + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + stateKey: MAIN_CAPTURE_STATE_KEY, + }; + await electronApp.evaluate(async ({ app, BrowserWindow }, input) => { type JsonRecord = Record; type WorkerTransferable = import('node:worker_threads').Transferable; interface CpuProfileHandle { @@ -42,6 +58,15 @@ export async function installMainCapture( idle: number; utilization: number; } + interface WorkerRequestPerformance { + identity: DatabaseRequestIdentity; + operation: string | null; + performanceCapture: unknown; + playlistId: string | null; + requestId: string | null; + responseEpochMs: number; + success: boolean; + } interface InstrumentedWorker { cpuUsage?(): Promise; getHeapSnapshot?(): Promise; @@ -62,15 +87,11 @@ export async function installMainCapture( } interface WorkerRecord { cancelPostedEpochMs: number | null; + captureGeneration: number | null; cpuFirst: WorkerCpuUsage | null; cpuLast: WorkerCpuUsage | null; elu: number | null; eluStart: WorkerElu | null; - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - } | null; externalPeak: number; finalized: boolean; finalizing: Promise | null; @@ -81,6 +102,7 @@ export async function installMainCapture( postGcHeapUsed: number | null; profileHandle: Promise | null; profilePath: string | null; + requestPerformance: WorkerRequestPerformance[]; responseEpochMs: number | null; sampleBusy: boolean; sampleTimer: NodeJS.Timeout | null; @@ -99,9 +121,12 @@ export async function installMainCapture( } const target = globalThis as unknown as Record; - if (target[stateKey] !== undefined) { + if (target[input.stateKey] !== undefined) { return; } + const databaseRequestIdentityCapture = target[ + input.databaseRequestIdentityStateKey + ] as DatabaseRequestIdentityCaptureApi; const runtimeProcess = process as typeof process & { getBuiltinModule(id: string): unknown; @@ -132,6 +157,7 @@ export async function installMainCapture( const dbRequests = new Map< string, { + identity: DatabaseRequestIdentity; operation: string; playlistId: string | null; record: WorkerRecord; @@ -140,6 +166,7 @@ export async function installMainCapture( const state = { active: false, + captureGeneration: 0, cpuStart: null as NodeJS.CpuUsage | null, diagnostic: false, eventLoopDelay: null as ReturnType< @@ -200,6 +227,9 @@ export async function installMainCapture( const playlistId = value['playlistId'] ?? value['_id']; return typeof playlistId === 'string' ? playlistId : null; }; + const isCurrentCaptureRecord = (record: WorkerRecord): boolean => + state.active && + record.captureGeneration === state.captureGeneration; const createWorkerRecord = ( worker: InstrumentedWorker, kind: WorkerRecord['kind'] @@ -210,11 +240,11 @@ export async function installMainCapture( } const record: WorkerRecord = { cancelPostedEpochMs: null, + captureGeneration: null, cpuFirst: null, cpuLast: null, elu: null, eluStart: null, - eventLoopDelay: null, externalPeak: 0, finalized: false, finalizing: null, @@ -225,6 +255,7 @@ export async function installMainCapture( postGcHeapUsed: null, profileHandle: null, profilePath: null, + requestPerformance: [], responseEpochMs: null, sampleBusy: false, sampleTimer: null, @@ -238,30 +269,13 @@ export async function installMainCapture( return; } const message = incoming as JsonRecord; - const performanceCapture = readWorkerPerformance(message); - if (performanceCapture) { - record.eventLoopDelay = record.eventLoopDelay - ? { - maxMs: Math.max( - record.eventLoopDelay.maxMs, - performanceCapture.eventLoopDelay.maxMs - ), - p95Ms: Math.max( - record.eventLoopDelay.p95Ms, - performanceCapture.eventLoopDelay.p95Ms - ), - p99Ms: Math.max( - record.eventLoopDelay.p99Ms, - performanceCapture.eventLoopDelay.p99Ms - ), - } - : performanceCapture.eventLoopDelay; + if (!isCurrentCaptureRecord(record)) { + return; } if (record.kind === 'playlist-refresh.worker') { if (message['type'] === 'event') { const event = message['event'] as - | JsonRecord - | undefined; + JsonRecord | undefined; recordTimeline({ operationId: record.operationId ?? undefined, playlistId: record.playlistId ?? undefined, @@ -270,7 +284,20 @@ export async function installMainCapture( )}:${String(event?.['phase'] ?? 'none')}`, }); } else if (message['type'] === 'response') { - record.responseEpochMs = nowEpochMs(); + const responseEpochMs = nowEpochMs(); + record.responseEpochMs = responseEpochMs; + record.requestPerformance.push({ + identity: { + operationId: record.operationId, + operationIdUnavailableReason: null, + }, + operation: null, + performanceCapture: message['performance'] ?? null, + playlistId: record.playlistId, + requestId: null, + responseEpochMs, + success: message['success'] === true, + }); recordTimeline({ operationId: record.operationId ?? undefined, playlistId: record.playlistId ?? undefined, @@ -285,10 +312,23 @@ export async function installMainCapture( typeof message['requestId'] === 'string' ) { const request = dbRequests.get(message['requestId']); + if (request) { + record.requestPerformance.push({ + identity: request.identity, + operation: request.operation, + performanceCapture: message['performance'] ?? null, + playlistId: request.playlistId, + requestId: message['requestId'], + responseEpochMs: nowEpochMs(), + success: message['success'] === true, + }); + } if (request) { dbRequests.delete(message['requestId']); recordTimeline({ operation: request.operation, + operationId: + request.identity.operationId ?? undefined, playlistId: request.playlistId ?? undefined, requestId: message['requestId'], success: message['success'] === true, @@ -299,46 +339,6 @@ export async function installMainCapture( }); return record; }; - const readWorkerPerformance = ( - message: JsonRecord - ): { - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - }; - } | null => { - const performanceCapture = message['performance']; - if ( - typeof performanceCapture !== 'object' || - performanceCapture === null - ) { - return null; - } - const eventLoopDelay = (performanceCapture as JsonRecord)[ - 'eventLoopDelay' - ]; - if (typeof eventLoopDelay !== 'object' || eventLoopDelay === null) { - return null; - } - const value = eventLoopDelay as JsonRecord; - const maxMs = value['maxMs']; - const p95Ms = value['p95Ms']; - const p99Ms = value['p99Ms']; - if ( - typeof maxMs !== 'number' || - !Number.isFinite(maxMs) || - typeof p95Ms !== 'number' || - !Number.isFinite(p95Ms) || - typeof p99Ms !== 'number' || - !Number.isFinite(p99Ms) - ) { - return null; - } - return { - eventLoopDelay: { maxMs, p95Ms, p99Ms }, - }; - }; const sampleWorker = async (record: WorkerRecord): Promise => { if (record.sampleBusy || record.finalized) { return; @@ -373,14 +373,45 @@ export async function installMainCapture( record.sampleBusy = false; } }; + const resetWorkerForCapture = (record: WorkerRecord): void => { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); + } + record.cancelPostedEpochMs = null; + record.captureGeneration = state.captureGeneration; + record.cpuFirst = null; + record.cpuLast = null; + record.elu = null; + record.eluStart = null; + record.externalPeak = 0; + record.finalized = false; + record.finalizing = null; + record.heapPeak = 0; + record.operationId = null; + record.playlistId = null; + record.postGcHeapUsed = null; + record.profileHandle = null; + record.profilePath = null; + record.requestPerformance = []; + record.responseEpochMs = null; + record.sampleBusy = false; + record.sampleTimer = null; + record.snapshotPath = null; + record.terminatedEpochMs = null; + }; const startWorker = (record: WorkerRecord): void => { - if (!state.active || record.sampleTimer !== null) { + if (!state.active) { + return; + } + if (record.captureGeneration !== state.captureGeneration) { + resetWorkerForCapture(record); + } + if (record.sampleTimer !== null) { return; } record.elu = null; record.eluStart = record.worker.performance?.eventLoopUtilization() ?? null; - record.eventLoopDelay = null; void sampleWorker(record); record.sampleTimer = setInterval( () => void sampleWorker(record), @@ -456,7 +487,11 @@ export async function installMainCapture( const kind = classifyRequest(value); if (kind) { const record = createWorkerRecord(this, kind); - if (kind === 'playlist-refresh.worker') { + startWorker(record); + if ( + isCurrentCaptureRecord(record) && + kind === 'playlist-refresh.worker' + ) { const payload = value['payload'] as JsonRecord; record.operationId = String(payload['operationId']); record.playlistId = String(payload['playlistId']); @@ -467,23 +502,47 @@ export async function installMainCapture( type: 'playlist-request', }); } else if ( + isCurrentCaptureRecord(record) && typeof value['requestId'] === 'string' && typeof value['operation'] === 'string' ) { + const payload = value['payload']; + const payloadOperationId = + typeof payload === 'object' && + payload !== null && + typeof (payload as JsonRecord)[ + 'operationId' + ] === 'string' && + String((payload as JsonRecord)['operationId']) + .length > 0 + ? String( + (payload as JsonRecord)['operationId'] + ) + : null; + const identity = + payloadOperationId === null + ? databaseRequestIdentityCapture.matchDatabaseRequest( + value + ) + : { + operationId: payloadOperationId, + operationIdUnavailableReason: null, + }; dbRequests.set(value['requestId'], { + identity, operation: value['operation'], playlistId: readDatabasePlaylistId(value), record, }); recordTimeline({ operation: value['operation'], + operationId: identity.operationId ?? undefined, playlistId: readDatabasePlaylistId(value) ?? undefined, requestId: value['requestId'], type: 'db-request', }); } - startWorker(record); } } else if ( value['type'] === 'cancel' && @@ -505,7 +564,7 @@ export async function installMainCapture( this: InstrumentedWorker ): Promise { const record = records.get(this); - if (!record || !state.active) { + if (!record || !isCurrentCaptureRecord(record)) { return originalTerminate.call(this); } const markTerminated = (code: number): number => { @@ -627,6 +686,7 @@ export async function installMainCapture( ).length, playlistTerminated: [...records.values()].filter( (record) => + isCurrentCaptureRecord(record) && record.kind === 'playlist-refresh.worker' && record.terminatedEpochMs !== null ).length, @@ -645,7 +705,11 @@ export async function installMainCapture( ).length, }), start: async (options: MainCaptureStartOptions): Promise => { + state.captureGeneration += 1; state.active = true; + databaseRequestIdentityCapture.start(); + dbRequests.clear(); + operationWorkers.clear(); state.diagnostic = options.diagnostic; state.outputDirectory = options.outputDirectory; state.timeline = []; @@ -682,7 +746,7 @@ export async function installMainCapture( ); } }, - stop: async (): Promise => { + stop: async (): Promise => { if (state.sampleTimer) { clearInterval(state.sampleTimer); state.sampleTimer = null; @@ -705,6 +769,7 @@ export async function installMainCapture( const delay = state.eventLoopDelay; const databaseRecords = [...records.values()].filter( (record) => + record.captureGeneration === state.captureGeneration && record.kind === 'database.worker' && record.sampleTimer !== null ); @@ -756,78 +821,91 @@ export async function installMainCapture( } state.windowListeners = []; state.active = false; + databaseRequestIdentityCapture.stop(); const workers = [...records.values()] .filter( (record) => record.kind === 'playlist-refresh.worker' || - record.heapPeak > 0 + record.heapPeak > 0 || + record.requestPerformance.length > 0 ) .map((record) => ({ - cancelPostedEpochMs: record.cancelPostedEpochMs, - cpuSystemMicros: - record.cpuFirst && record.cpuLast - ? record.cpuLast.system - record.cpuFirst.system - : null, - cpuUserMicros: - record.cpuFirst && record.cpuLast - ? record.cpuLast.user - record.cpuFirst.user - : null, - eventLoopDelay: record.eventLoopDelay, - eventLoopDelayUnavailableReason: - record.eventLoopDelay === null - ? record.terminatedEpochMs !== null - ? 'worker-terminated-before-profile-flush' - : 'worker-self-profile-result-unavailable' - : null, - eventLoopUtilization: record.elu, - kind: record.kind, - operationId: record.operationId, - peakExternalBytes: record.externalPeak, - peakHeapUsedBytes: record.heapPeak, - playlistId: record.playlistId, - postGcHeapUsedBytes: record.postGcHeapUsed, - profilePath: record.profilePath, - responseEpochMs: record.responseEpochMs, - snapshotPath: record.snapshotPath, - terminatedEpochMs: record.terminatedEpochMs, + captureGeneration: record.captureGeneration, + metrics: { + cancelPostedEpochMs: record.cancelPostedEpochMs, + cpuSystemMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.system - + record.cpuFirst.system + : null, + cpuUserMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.user - record.cpuFirst.user + : null, + eventLoopUtilization: record.elu, + kind: record.kind, + operationId: record.operationId, + peakExternalBytes: record.externalPeak, + peakHeapUsedBytes: record.heapPeak, + playlistId: record.playlistId, + postGcHeapUsedBytes: record.postGcHeapUsed, + profilePath: record.profilePath, + responseEpochMs: record.responseEpochMs, + snapshotPath: record.snapshotPath, + terminatedEpochMs: record.terminatedEpochMs, + }, + requests: record.requestPerformance.map((request) => ({ + operation: request.operation, + operationId: request.identity.operationId, + operationIdUnavailableReason: + request.identity.operationIdUnavailableReason, + performanceCapture: request.performanceCapture, + playlistId: request.playlistId, + requestId: request.requestId, + responseEpochMs: request.responseEpochMs, + success: request.success, + })), })); return { - cpuProfilePath: state.mainProfilePath, - cpuSystemMicros: cpu.system, - cpuUserMicros: cpu.user, - eventLoopDelay: { - maxMs: Number(delay?.max ?? 0) / 1e6, - p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6, - p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6, + captureGeneration: state.captureGeneration, + metrics: { + cpuProfilePath: state.mainProfilePath, + cpuSystemMicros: cpu.system, + cpuUserMicros: cpu.user, + eventLoopDelay: { + maxMs: Number(delay?.max ?? 0) / 1e6, + p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6, + p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6, + }, + // Electron's Chromium-owned main message pump currently + // leaves Node's libuv ELU counters at zero. Preserve that + // as unavailable instead of reporting a misleading 0%. + eventLoopUtilization, + eventLoopUtilizationUnavailableReason: + eventLoopUtilization === null + ? 'electron-main-embedded-event-loop' + : null, + heapSnapshotPath: state.mainSnapshotPath, + memory: { + peakHeapUsedBytes: state.mainPeakHeap, + peakRssBytes: state.mainPeakRss, + postGcHeapUsedBytes: state.postGcHeap, + postGcRssBytes: state.postGcRss, + }, + rendererPeakRssBytes: state.rendererPeakRss, + responsiveEvents: state.responsiveEvents, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + timeline: state.timeline, + unresponsiveEvents: state.unresponsiveEvents, }, - // Electron's Chromium-owned main message pump currently - // leaves Node's libuv ELU counters at zero. Preserve that - // as unavailable instead of reporting a misleading 0%. - eventLoopUtilization, - eventLoopUtilizationUnavailableReason: - eventLoopUtilization === null - ? 'electron-main-embedded-event-loop' - : null, - heapSnapshotPath: state.mainSnapshotPath, - memory: { - peakHeapUsedBytes: state.mainPeakHeap, - peakRssBytes: state.mainPeakRss, - postGcHeapUsedBytes: state.postGcHeap, - postGcRssBytes: state.postGcRss, - }, - rendererPeakRssBytes: state.rendererPeakRss, - responsiveEvents: state.responsiveEvents, - rssScope: - 'electron-main-process-including-worker-threads-and-native-memory', - timeline: state.timeline, - unresponsiveEvents: state.unresponsiveEvents, workers, }; }, }; - target[stateKey] = api; - }, MAIN_CAPTURE_STATE_KEY); + target[input.stateKey] = api; + }, captureStateKeys); } export async function startMainCapture( @@ -861,11 +939,15 @@ export async function readMainCaptureStatus( export async function stopMainCapture( electronApp: ElectronApplication ): Promise { - return electronApp.evaluate(async (_electron, stateKey) => { - const target = globalThis as unknown as Record; - const api = target[stateKey] as { - stop(): Promise; - }; - return api.stop(); - }, MAIN_CAPTURE_STATE_KEY); + const transport = await electronApp.evaluate( + async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + stop(): Promise; + }; + return api.stop(); + }, + MAIN_CAPTURE_STATE_KEY + ); + return selectMainCaptureGeneration(transport); } diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts new file mode 100644 index 000000000..e87e3c345 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts @@ -0,0 +1,172 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import { normalizeWorkerRequestPerformanceOutcome } from './worker-request-performance'; + +const validCapture = { + eventLoopDelay: { maxMs: 6, p95Ms: 5, p99Ms: 5.5 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.5, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1_050, + invalidReason: null, + requestReceivedEpochMs: 1_000, + threadCpuSystemMicros: 50, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 100, + workEndedEpochMs: 1_040, + workStartedEpochMs: 1_010, +} as const; + +function normalize(performanceCapture: unknown) { + return normalizeWorkerRequestPerformanceOutcome({ + operation: 'DB_GET_APP_PLAYLIST', + operationId: 'operation-42', + operationIdUnavailableReason: null, + performanceCapture, + playlistId: 'playlist-1', + requestId: 'request-1', + responseEpochMs: 1_060, + success: true, + }); +} + +function assertClosedCapture( + capture: WorkerRequestPerformanceMetrics, + reason: string +): void { + assert.equal(capture.performanceCaptureUnavailableReason, reason); + assert.equal(capture.operationId, 'operation-42'); + for (const value of [ + capture.eventLoopDelay, + capture.eventLoopDelayUnavailableReason, + capture.eventLoopUtilization, + capture.eventLoopUtilizationUnavailableReason, + capture.histogramFlushedEpochMs, + capture.invalidReason, + capture.requestReceivedEpochMs, + capture.threadCpuSystemMicros, + capture.threadCpuUnavailableReason, + capture.threadCpuUserMicros, + capture.workEndedEpochMs, + capture.workStartedEpochMs, + ]) { + assert.equal(value, null); + } +} + +test('absent captures are missing while present non-record captures are invalid', () => { + for (const missing of [undefined, null]) { + assertClosedCapture( + normalize(missing), + 'worker-performance-capture-missing' + ); + } + for (const malformed of [42, [], 'capture']) { + assertClosedCapture( + normalize(malformed), + 'worker-performance-capture-invalid' + ); + } +}); + +test('malformed nested fields and cross-field invariants fail the whole capture closed', () => { + const malformed = [ + { ...validCapture, eventLoopDelay: [] }, + { + ...validCapture, + eventLoopDelay: { maxMs: 6, p95Ms: 'bad', p99Ms: 5.5 }, + }, + { + ...validCapture, + eventLoopDelay: { maxMs: 5, p95Ms: 6, p99Ms: 5.5 }, + }, + { ...validCapture, eventLoopDelayUnavailableReason: 42 }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + histogramFlushedEpochMs: null, + }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'event-loop-delay-capture-unavailable', + }, + { ...validCapture, eventLoopUtilization: 1.01 }, + { + ...validCapture, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: null, + }, + { ...validCapture, threadCpuSystemMicros: null }, + { + ...validCapture, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: null, + threadCpuUserMicros: null, + }, + { ...validCapture, threadCpuUnavailableReason: 'unavailable' }, + { ...validCapture, histogramFlushedEpochMs: null }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-invalid', + histogramFlushedEpochMs: null, + }, + { ...validCapture, histogramFlushedEpochMs: 1_039 }, + { ...validCapture, workStartedEpochMs: 999 }, + { + ...validCapture, + invalidReason: 'overlapping-database-worker-requests', + }, + { ...validCapture, threadCpuUserMicros: undefined }, + ]; + for (const performanceCapture of malformed) { + assertClosedCapture( + normalize(performanceCapture), + 'worker-performance-capture-invalid' + ); + } +}); + +test('coherent metric-unavailable and invalid worker results remain valid raw captures', () => { + const unavailable = { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-capture-unavailable', + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'event-loop-utilization-unavailable', + histogramFlushedEpochMs: null, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: 'thread-cpu-usage-unavailable', + threadCpuUserMicros: null, + }; + for (const capture of [ + unavailable, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-invalid', + }, + ]) { + const accepted = normalize(capture); + assert.equal(accepted.performanceCaptureUnavailableReason, null); + assert.equal(accepted.requestReceivedEpochMs, 1_000); + } + + const invalidReason = 'overlapping-database-worker-requests'; + const legitimateInvalid = normalize({ + ...unavailable, + eventLoopDelayUnavailableReason: invalidReason, + eventLoopUtilizationUnavailableReason: invalidReason, + invalidReason, + threadCpuUnavailableReason: invalidReason, + }); + assert.equal(legitimateInvalid.performanceCaptureUnavailableReason, null); + assert.equal(legitimateInvalid.invalidReason, invalidReason); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts new file mode 100644 index 000000000..72a3ae522 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts @@ -0,0 +1,302 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { + type CancellationBenchmarkManifest, + type CancellationIterationResult, + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, + type WorkerCaptureMetrics, + type WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + normalizeWorkerRequestPerformanceOutcome, + selectMainCaptureGeneration, +} from './worker-request-performance'; +import { createCancellationBenchmarkSummary } from './m3u-refresh-cancellation-report'; + +type RequestPerformanceFixture = WorkerRequestPerformanceMetrics; +function requestPerformance( + requestId: string, + p95Ms: number, + threadCpuUserMicros: number +): RequestPerformanceFixture { + return normalizeWorkerRequestPerformanceOutcome( + requestTransport( + { + eventLoopDelay: { + maxMs: p95Ms + 1, + p95Ms, + p99Ms: p95Ms + 0.5, + }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: p95Ms / 100, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1_050 + p95Ms, + invalidReason: null, + requestReceivedEpochMs: 1_000, + threadCpuSystemMicros: threadCpuUserMicros / 2, + threadCpuUnavailableReason: null, + threadCpuUserMicros, + workEndedEpochMs: 1_040, + workStartedEpochMs: 1_010, + }, + requestId + ) + ); +} +function requestTransport( + performanceCapture: unknown, + requestId = 'request-1' +) { + return { + operation: 'DB_GET_APP_PLAYLIST', + operationId: 'operation-42', + operationIdUnavailableReason: null, + performanceCapture, + playlistId: 'playlist-1', + requestId, + responseEpochMs: 1_060, + success: true, + } as const; +} +function assertClosedCapture( + capture: WorkerRequestPerformanceMetrics, + reason: string +): void { + assert.equal(capture.performanceCaptureUnavailableReason, reason); + assert.equal(capture.operationId, 'operation-42'); + for (const value of [ + capture.eventLoopDelay, + capture.eventLoopDelayUnavailableReason, + capture.eventLoopUtilization, + capture.eventLoopUtilizationUnavailableReason, + capture.histogramFlushedEpochMs, + capture.invalidReason, + capture.requestReceivedEpochMs, + capture.threadCpuSystemMicros, + capture.threadCpuUnavailableReason, + capture.threadCpuUserMicros, + capture.workEndedEpochMs, + capture.workStartedEpochMs, + ]) { + assert.equal(value, null); + } +} +function databaseWorker( + requests: readonly RequestPerformanceFixture[] +): WorkerCaptureMetrics { + return { + kind: PERFORMANCE_WORKER_KIND.DATABASE, + operationId: null, + peakExternalBytes: 0, + peakHeapUsedBytes: 0, + playlistId: null, + postGcHeapUsedBytes: null, + requests, + responseEpochMs: null, + terminatedEpochMs: null, + } as WorkerCaptureMetrics; +} +function playlistWorker( + request: RequestPerformanceFixture, + operationId: string, + terminatedEpochMs: number +): WorkerCaptureMetrics { + return { + ...databaseWorker([request]), + kind: PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + operationId, + playlistId: request.playlistId, + responseEpochMs: request.responseEpochMs, + terminatedEpochMs, + }; +} + +function measuredIteration( + workers: readonly WorkerCaptureMetrics[] +): CancellationIterationResult { + return { + cancellationEffectObserved: true, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: { + eventLoopDelay: { maxMs: 0, p95Ms: 0, p99Ms: 0 }, + eventLoopUtilization: null, + memory: { + peakHeapUsedBytes: 0, + peakRssBytes: 0, + postGcHeapUsedBytes: null, + postGcRssBytes: null, + }, + rendererPeakRssBytes: 0, + responsiveEvents: 0, + unresponsiveEvents: 0, + workers, + } as CancellationIterationResult['main'], + phases: {} as CancellationIterationResult['phases'], + renderer: { + peakHeapUsedBytes: 0, + postGcHeapUsedBytes: null, + probe: { + frameGapsMs: [], + heartbeatDelaysMs: [], + longTasksMs: [], + }, + } as CancellationIterationResult['renderer'], + runId: 'measured-1', + }; +} + +test('summary distributions use every request capture instead of a worker-level percentile maximum', () => { + const requests = [ + requestPerformance('request-1', 5, 100), + requestPerformance('request-2', 15, 300), + ]; + const summary = createCancellationBenchmarkSummary( + {} as CancellationBenchmarkManifest, + [measuredIteration([databaseWorker(requests)])] + ); + const measured = summary.measured as unknown as Record< + string, + { count: number; max: number | null; min: number | null } + >; + + assert.deepEqual( + summary.measured.databaseWorkerRequestEventLoopDelayP95Ms, + { + count: 2, + max: 15, + mean: 10, + median: 10, + min: 5, + p95: 14.5, + p99: 14.9, + } + ); + assert.deepEqual(measured['databaseWorkerRequestThreadCpuUserMicros'], { + count: 2, + max: 300, + mean: 200, + median: 200, + min: 100, + p95: 290, + p99: 298, + }); + assert.equal( + summary.iterations[0]?.main.workers[0]?.requests[0]?.operationId, + 'operation-42' + ); + assert.equal( + 'databaseWorkerEventLoopDelayP95Ms' in summary.measured, + false, + 'request-percentile distributions must not look like an operation-wide percentile' + ); +}); + +test('main capture retains raw request identity, timestamps, metrics, and reasons', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + + assert.match(source, /requestPerformance: \[\]/); + assert.match(source, /record\.requestPerformance\.length > 0/); + assert.match(source, /record\.requestPerformance\.map/); + assert.match(source, /operationId: request\.identity\.operationId/); + assert.match(source, /identity: request\.identity/); + assert.match(source, /performanceCapture:\s+message\['performance'\]/); + assert.match(source, /captureGeneration: state\.captureGeneration/); + assert.doesNotMatch( + source, + /record\.eventLoopDelay = record\.eventLoopDelay/ + ); +}); + +test('capture-generation selection excludes a pre-start seed worker', () => { + const seed = playlistWorker( + requestPerformance('seed-request', 999, 999), + 'seed-operation', + 1_100 + ); + const measured = playlistWorker( + { + ...requestPerformance('measured-request', 8, 80), + success: false, + }, + 'measured-cancel-operation', + 2_100 + ); + + const mainMetrics = measuredIteration([]).main; + const toTransportWorker = ( + captureGeneration: number | null, + worker: WorkerCaptureMetrics + ) => ({ + captureGeneration, + metrics: worker, + requests: worker.requests.map((request) => ({ + ...requestTransport(request, request.requestId ?? undefined), + success: request.success, + })), + }); + const selected = selectMainCaptureGeneration({ + captureGeneration: 2, + metrics: mainMetrics, + workers: [ + toTransportWorker(null, seed), + toTransportWorker(2, measured), + ], + }); + + assert.equal(selected.workers.length, 1); + assert.equal( + selected.workers[0]?.requests[0]?.requestId, + 'measured-request' + ); + assert.equal(selected.workers[0]?.operationId, 'measured-cancel-operation'); + assert.equal(selected.workers[0]?.terminatedEpochMs, 2_100); +}); + +test('raw outcomes retain missing and malformed captures while summaries exclude their null metrics', () => { + const valid = normalizeWorkerRequestPerformanceOutcome( + requestTransport(requestPerformance('request-1', 5, 100)) + ); + const missing = normalizeWorkerRequestPerformanceOutcome( + requestTransport(null, 'request-2') + ); + const malformed = normalizeWorkerRequestPerformanceOutcome( + requestTransport( + { + requestReceivedEpochMs: 'invalid', + workEndedEpochMs: 1_080, + workStartedEpochMs: 1_075, + }, + 'request-3' + ) + ); + const worker = databaseWorker([valid, missing, malformed]); + const summary = createCancellationBenchmarkSummary( + {} as CancellationBenchmarkManifest, + [measuredIteration([worker])] + ); + + assert.equal(worker.requests.length, 3); + assertClosedCapture( + worker.requests[1] as WorkerRequestPerformanceMetrics, + 'worker-performance-capture-missing' + ); + assertClosedCapture( + worker.requests[2] as WorkerRequestPerformanceMetrics, + 'worker-performance-capture-invalid' + ); + assert.equal( + summary.measured.databaseWorkerRequestEventLoopDelayP95Ms.count, + 1 + ); + assert.equal( + summary.measured.databaseWorkerRequestThreadCpuUserMicros.count, + 1 + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts new file mode 100644 index 000000000..edf157282 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts @@ -0,0 +1,302 @@ +import type { + EventLoopDelayMetrics, + MainCaptureMetrics, + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; + +export interface WorkerRequestPerformanceOutcomeTransport { + readonly operation: string | null; + readonly operationId: string | null; + readonly operationIdUnavailableReason: string | null; + readonly performanceCapture: unknown; + readonly playlistId: string | null; + readonly requestId: string | null; + readonly responseEpochMs: number; + readonly success: boolean; +} + +export interface MainCaptureGenerationTransport { + readonly captureGeneration: number; + readonly metrics: Omit; + readonly workers: readonly { + readonly captureGeneration: number | null; + readonly metrics: Omit< + WorkerCaptureMetrics, + 'eventLoopDelay' | 'eventLoopDelayUnavailableReason' | 'requests' + >; + readonly requests: readonly WorkerRequestPerformanceOutcomeTransport[]; + }[]; +} + +type ParsedWorkerPerformanceCapture = Pick< + WorkerRequestPerformanceMetrics, + | 'eventLoopDelay' + | 'eventLoopDelayUnavailableReason' + | 'eventLoopUtilization' + | 'eventLoopUtilizationUnavailableReason' + | 'histogramFlushedEpochMs' + | 'invalidReason' + | 'requestReceivedEpochMs' + | 'threadCpuSystemMicros' + | 'threadCpuUnavailableReason' + | 'threadCpuUserMicros' + | 'workEndedEpochMs' + | 'workStartedEpochMs' +>; + +const INVALID_REASONS = new Set(['overlapping-database-worker-requests']); +const EVENT_LOOP_DELAY_REASONS = new Set([ + ...INVALID_REASONS, + 'event-loop-delay-arm-timeout', + 'event-loop-delay-capture-unavailable', + 'event-loop-delay-flush-timeout', + 'event-loop-delay-invalid', +]); +const EVENT_LOOP_UTILIZATION_REASONS = new Set([ + ...INVALID_REASONS, + 'event-loop-utilization-unavailable', +]); +const THREAD_CPU_REASONS = new Set([ + ...INVALID_REASONS, + 'thread-cpu-usage-invalid', + 'thread-cpu-usage-unavailable', +]); + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isNullableFiniteNonNegativeNumber( + value: unknown +): value is number | null { + return value === null || isFiniteNonNegativeNumber(value); +} + +function isNullableReason( + value: unknown, + allowed: ReadonlySet +): value is string | null { + return value === null || (typeof value === 'string' && allowed.has(value)); +} + +function parseEventLoopDelay( + input: unknown +): EventLoopDelayMetrics | null | undefined { + if (input === null) { + return null; + } + if (!isRecord(input)) { + return undefined; + } + const maxMs = input['maxMs']; + const p95Ms = input['p95Ms']; + const p99Ms = input['p99Ms']; + if ( + !isFiniteNonNegativeNumber(maxMs) || + !isFiniteNonNegativeNumber(p95Ms) || + !isFiniteNonNegativeNumber(p99Ms) || + p95Ms > p99Ms || + p99Ms > maxMs + ) { + return undefined; + } + return { maxMs, p95Ms, p99Ms }; +} + +function parseWorkerPerformanceCapture( + input: unknown +): ParsedWorkerPerformanceCapture | null { + if (!isRecord(input)) { + return null; + } + + const eventLoopDelay = parseEventLoopDelay(input['eventLoopDelay']); + const eventLoopDelayUnavailableReason = + input['eventLoopDelayUnavailableReason']; + const eventLoopUtilization = input['eventLoopUtilization']; + const eventLoopUtilizationUnavailableReason = + input['eventLoopUtilizationUnavailableReason']; + const histogramFlushedEpochMs = input['histogramFlushedEpochMs']; + const invalidReason = input['invalidReason']; + const requestReceivedEpochMs = input['requestReceivedEpochMs']; + const threadCpuSystemMicros = input['threadCpuSystemMicros']; + const threadCpuUnavailableReason = input['threadCpuUnavailableReason']; + const threadCpuUserMicros = input['threadCpuUserMicros']; + const workEndedEpochMs = input['workEndedEpochMs']; + const workStartedEpochMs = input['workStartedEpochMs']; + + if ( + eventLoopDelay === undefined || + !isNullableReason( + eventLoopDelayUnavailableReason, + EVENT_LOOP_DELAY_REASONS + ) || + !isNullableFiniteNonNegativeNumber(eventLoopUtilization) || + (eventLoopUtilization !== null && eventLoopUtilization > 1) || + !isNullableReason( + eventLoopUtilizationUnavailableReason, + EVENT_LOOP_UTILIZATION_REASONS + ) || + !isNullableFiniteNonNegativeNumber(histogramFlushedEpochMs) || + !isNullableReason(invalidReason, INVALID_REASONS) || + !isFiniteNonNegativeNumber(requestReceivedEpochMs) || + !isNullableFiniteNonNegativeNumber(threadCpuSystemMicros) || + !isNullableReason(threadCpuUnavailableReason, THREAD_CPU_REASONS) || + !isNullableFiniteNonNegativeNumber(threadCpuUserMicros) || + !isFiniteNonNegativeNumber(workEndedEpochMs) || + !isFiniteNonNegativeNumber(workStartedEpochMs) + ) { + return null; + } + + const timestampsAreOrdered = + requestReceivedEpochMs <= workStartedEpochMs && + workStartedEpochMs <= workEndedEpochMs && + (histogramFlushedEpochMs === null || + workEndedEpochMs <= histogramFlushedEpochMs); + const eventLoopDelayIsCoherent = + eventLoopDelay === null + ? eventLoopDelayUnavailableReason !== null && + (eventLoopDelayUnavailableReason === 'event-loop-delay-invalid' + ? histogramFlushedEpochMs !== null + : histogramFlushedEpochMs === null) + : eventLoopDelayUnavailableReason === null && + histogramFlushedEpochMs !== null; + const eventLoopUtilizationIsCoherent = + eventLoopUtilization === null + ? eventLoopUtilizationUnavailableReason !== null + : eventLoopUtilizationUnavailableReason === null; + const threadCpuIsCoherent = + threadCpuSystemMicros === null && threadCpuUserMicros === null + ? threadCpuUnavailableReason !== null + : threadCpuSystemMicros !== null && + threadCpuUserMicros !== null && + threadCpuUnavailableReason === null; + const invalidCaptureIsCoherent = + invalidReason === null || + (eventLoopDelay === null && + eventLoopDelayUnavailableReason === invalidReason && + eventLoopUtilization === null && + eventLoopUtilizationUnavailableReason === invalidReason && + histogramFlushedEpochMs === null && + threadCpuSystemMicros === null && + threadCpuUnavailableReason === invalidReason && + threadCpuUserMicros === null); + if ( + !timestampsAreOrdered || + !eventLoopDelayIsCoherent || + !eventLoopUtilizationIsCoherent || + !threadCpuIsCoherent || + !invalidCaptureIsCoherent + ) { + return null; + } + + return { + eventLoopDelay, + eventLoopDelayUnavailableReason, + eventLoopUtilization, + eventLoopUtilizationUnavailableReason, + histogramFlushedEpochMs, + invalidReason, + requestReceivedEpochMs, + threadCpuSystemMicros, + threadCpuUnavailableReason, + threadCpuUserMicros, + workEndedEpochMs, + workStartedEpochMs, + }; +} + +export function normalizeWorkerRequestPerformanceOutcome( + input: WorkerRequestPerformanceOutcomeTransport +): WorkerRequestPerformanceMetrics { + const identity = { + operation: input.operation, + operationId: input.operationId, + operationIdUnavailableReason: input.operationIdUnavailableReason, + playlistId: input.playlistId, + requestId: input.requestId, + responseEpochMs: input.responseEpochMs, + success: input.success, + }; + const unavailable = ( + reason: + | 'worker-performance-capture-invalid' + | 'worker-performance-capture-missing' + ): WorkerRequestPerformanceMetrics => ({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: null, + invalidReason: null, + ...identity, + performanceCaptureUnavailableReason: reason, + requestReceivedEpochMs: null, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: null, + threadCpuUserMicros: null, + workEndedEpochMs: null, + workStartedEpochMs: null, + }); + + if ( + input.performanceCapture === undefined || + input.performanceCapture === null + ) { + return unavailable('worker-performance-capture-missing'); + } + + const performanceCapture = parseWorkerPerformanceCapture( + input.performanceCapture + ); + if (!performanceCapture) { + return unavailable('worker-performance-capture-invalid'); + } + + return { + ...performanceCapture, + ...identity, + performanceCaptureUnavailableReason: null, + }; +} + +export function selectMainCaptureGeneration( + input: MainCaptureGenerationTransport +): MainCaptureMetrics { + const workers = input.workers + .filter( + (worker) => worker.captureGeneration === input.captureGeneration + ) + .map((worker): WorkerCaptureMetrics => { + const requests = worker.requests.map( + normalizeWorkerRequestPerformanceOutcome + ); + const onlyRequest = + requests.length === 1 ? (requests[0] ?? null) : null; + return { + ...worker.metrics, + eventLoopDelay: onlyRequest?.eventLoopDelay ?? null, + eventLoopDelayUnavailableReason: onlyRequest + ? (onlyRequest.eventLoopDelayUnavailableReason ?? + onlyRequest.performanceCaptureUnavailableReason ?? + 'worker-self-profile-result-unavailable') + : requests.length > 1 + ? 'multiple-request-scoped-captures' + : worker.metrics.terminatedEpochMs !== null + ? 'worker-terminated-before-profile-flush' + : 'worker-self-profile-result-unavailable', + requests, + }; + }); + return { + ...input.metrics, + workers, + }; +} diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index b3b4d968a..2310d3398 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -90,8 +90,11 @@ import { } from '../database/operations/xtream.operations'; import { armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + registerDatabaseWorkerPerformanceCapture, + releaseDatabaseWorkerPerformanceCapture, startWorkerPerformanceCapture, + type WorkerPerformanceCapture, } from './worker-performance-capture'; const loggerLabel = '[DB Worker]'; @@ -104,6 +107,11 @@ type ActiveOperationState = { cancelled: boolean; }; +type PreRegisteredActiveOperation = { + operationId: string; + state: ActiveOperationState; +}; + type OperationController = { control: { checkpoint: () => Promise; @@ -129,6 +137,7 @@ type OperationController = { }; const activeOperations = new Map(); +const activePerformanceCaptures = new Set(); if (!parentPort) { throw new Error('Database worker must be started with a parent port'); @@ -178,17 +187,22 @@ async function pauseBetweenBatches(): Promise { await new Promise((resolve) => setTimeout(resolve, batchDelayMs)); } -function createOperationController(config: { - operationId?: string; - operation: string; - playlistId?: string; - requestId: string; - cancellable?: boolean; -}): OperationController { +function createOperationController( + config: { + operationId?: string; + operation: string; + playlistId?: string; + requestId: string; + cancellable?: boolean; + }, + preRegisteredState?: ActiveOperationState +): OperationController { const { operationId, operation, playlistId, requestId } = config; const cancellable = config.cancellable ?? true; const activeState = - operationId && cancellable ? { cancelled: false } : null; + operationId && cancellable + ? (preRegisteredState ?? { cancelled: false }) + : null; if (operationId && activeState) { activeOperations.set(operationId, activeState); @@ -255,7 +269,10 @@ function createOperationController(config: { }); }, cleanup: () => { - if (operationId) { + if ( + operationId && + activeOperations.get(operationId) === activeState + ) { activeOperations.delete(operationId); } }, @@ -264,9 +281,10 @@ function createOperationController(config: { async function executeTrackedOperation( config: Parameters[0], - handler: (controller: OperationController) => Promise + handler: (controller: OperationController) => Promise, + preRegisteredState?: ActiveOperationState ): Promise { - const controller = createOperationController(config); + const controller = createOperationController(config, preRegisteredState); try { return await handler(controller); @@ -282,7 +300,53 @@ async function executeTrackedOperation( } } -async function executeRequest(message: DbWorkerRequestMessage) { +function preRegisterCancellableOperation( + message: DbWorkerRequestMessage +): PreRegisteredActiveOperation | null { + switch (message.operation) { + case 'DB_SAVE_CONTENT': + case 'DB_DELETE_PLAYLIST': + case 'DB_DELETE_XTREAM_CONTENT': + case 'DB_RESTORE_XTREAM_USER_DATA': + break; + default: + return null; + } + + if ( + typeof message.payload !== 'object' || + message.payload === null || + Array.isArray(message.payload) + ) { + return null; + } + const operationId = (message.payload as Record)[ + 'operationId' + ]; + if (typeof operationId !== 'string' || operationId.length === 0) { + return null; + } + + const state: ActiveOperationState = { cancelled: false }; + activeOperations.set(operationId, state); + return { operationId, state }; +} + +function releasePreRegisteredOperation( + operation: PreRegisteredActiveOperation | null +): void { + if ( + operation && + activeOperations.get(operation.operationId) === operation.state + ) { + activeOperations.delete(operation.operationId); + } +} + +async function executeRequest( + message: DbWorkerRequestMessage, + preRegisteredState?: ActiveOperationState +) { const db = await getWorkerDatabase(); switch (message.operation) { @@ -413,7 +477,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -586,7 +651,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -695,7 +761,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -739,7 +806,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -929,33 +997,49 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { return; } - const performanceCapture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(performanceCapture); - try { - const result = await executeRequest(message); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); + const preRegisteredOperation = preRegisterCancellableOperation(message); + let performanceCapture: WorkerPerformanceCapture | null = null; + const execution = await (async () => { + try { + performanceCapture = startWorkerPerformanceCapture(); + registerDatabaseWorkerPerformanceCapture( + activePerformanceCaptures, + performanceCapture + ); + await armWorkerPerformanceCapture(performanceCapture); + return await executeWithWorkerPerformanceCapture( + performanceCapture, + () => executeRequest(message, preRegisteredOperation?.state) + ); + } finally { + releaseDatabaseWorkerPerformanceCapture( + activePerformanceCaptures, + performanceCapture + ); + releasePreRegisteredOperation(preRegisteredOperation); + } + })(); + + if (execution.success) { postMessage({ type: 'response', requestId: message.requestId, success: true, - result, - performance, + result: execution.result, + performance: execution.performance, }); - } catch (error) { + } else { console.error( loggerLabel, `Error handling ${message.operation}:`, - error + execution.error ); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', requestId: message.requestId, success: false, - error: serializeError(error), - performance, + error: serializeError(execution.error), + performance: execution.performance, }); } }); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index 790d6ac55..3bd0059ba 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -24,7 +24,7 @@ import { requestWithValidatedRedirects } from '../util/validated-axios'; import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source'; import { armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, startWorkerPerformanceCapture, } from './worker-performance-capture'; @@ -86,6 +86,15 @@ function checkpoint(payload: PlaylistRefreshPayload): void { } } +function releaseActiveRefresh( + operationId: string, + activeRefresh: ActiveRefreshState +): void { + if (activeRefreshes.get(operationId) === activeRefresh) { + activeRefreshes.delete(operationId); + } +} + async function fetchPlaylistFromUrl( payload: PlaylistRefreshPayload, controller: AbortController @@ -159,23 +168,18 @@ async function fetchPlaylistFromFile( } async function executeRefresh( - payload: PlaylistRefreshPayload + payload: PlaylistRefreshPayload, + activeRefresh: ActiveRefreshState ): Promise { - const controller = new AbortController(); - activeRefreshes.set(payload.operationId, { - cancelled: false, - controller, - }); - try { const playlist = payload.url - ? await fetchPlaylistFromUrl(payload, controller) + ? await fetchPlaylistFromUrl(payload, activeRefresh.controller) : await fetchPlaylistFromFile(payload); checkpoint(payload); return playlist; } finally { - activeRefreshes.delete(payload.operationId); + releaseActiveRefresh(payload.operationId, activeRefresh); } } @@ -191,39 +195,55 @@ parentPort.on( return; } - const performanceCapture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(performanceCapture); + const payload = message.payload; + const activeRefresh: ActiveRefreshState = { + cancelled: false, + controller: new AbortController(), + }; + activeRefreshes.set(payload.operationId, activeRefresh); + try { - const result = await executeRefresh(message.payload); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); - postMessage({ - type: 'response', - success: true, - result, - performance, - }); - } catch (error) { - const payload = message.payload; - if (error instanceof Error && error.name === 'AbortError') { - emitEvent(payload, { status: 'cancelled', phase: 'parsing' }); + const performanceCapture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(performanceCapture); + const execution = await executeWithWorkerPerformanceCapture( + performanceCapture, + () => executeRefresh(payload, activeRefresh) + ); + + if (execution.success) { + postMessage({ + type: 'response', + success: true, + result: execution.result, + performance: execution.performance, + }); } else { - emitEvent(payload, { - status: 'error', - phase: payload.url ? 'fetching' : 'reading-file', - error: - error instanceof Error ? error.message : String(error), + const error = execution.error; + if (error instanceof Error && error.name === 'AbortError') { + emitEvent(payload, { + status: 'cancelled', + phase: 'parsing', + }); + } else { + emitEvent(payload, { + status: 'error', + phase: payload.url ? 'fetching' : 'reading-file', + error: + error instanceof Error + ? error.message + : String(error), + }); + } + + postMessage({ + type: 'response', + success: false, + error: serializeError(error), + performance: execution.performance, }); } - - const performance = - await finishWorkerPerformanceCapture(performanceCapture); - postMessage({ - type: 'response', - success: false, - error: serializeError(error), - performance, - }); + } finally { + releaseActiveRefresh(payload.operationId, activeRefresh); } } ); diff --git a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts new file mode 100644 index 000000000..eef0171f3 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts @@ -0,0 +1,284 @@ +import type { + DbWorkerIncomingMessage, + DbWorkerMessage, +} from './database-worker.types'; +import type { + PlaylistRefreshWorkerIncomingMessage, + PlaylistRefreshWorkerMessage, +} from './playlist-refresh.worker.types'; + +type WorkerMessageHandler = ( + message: TMessage +) => Promise | void; + +interface ArmGate { + readonly promise: Promise; + readonly release: () => void; +} + +function createArmGate(): ArmGate { + let release = (): void => undefined; + const promise = new Promise((resolvePromise) => { + release = resolvePromise; + }); + return { promise, release }; +} + +function createParentPortHarness() { + let messageHandler: WorkerMessageHandler | null = null; + const postMessage = jest.fn(); + const parentPort = { + on: jest.fn( + (event: string, handler: WorkerMessageHandler): void => { + if (event === 'message') { + messageHandler = handler; + } + } + ), + postMessage, + }; + + return { + getMessageHandler: (): WorkerMessageHandler => { + if (!messageHandler) { + throw new Error('Worker message handler was not registered'); + } + return messageHandler; + }, + parentPort, + postMessage, + }; +} + +function mockPerformanceCapture(armGate: ArmGate): void { + jest.doMock('./worker-performance-capture', () => ({ + armWorkerPerformanceCapture: jest.fn(() => armGate.promise), + executeWithWorkerPerformanceCapture: jest.fn( + async (_capture: unknown, execute: () => Promise) => { + try { + return { + error: null, + performance: undefined, + result: await execute(), + success: true, + }; + } catch (error) { + return { + error, + performance: undefined, + result: undefined, + success: false, + }; + } + } + ), + registerDatabaseWorkerPerformanceCapture: jest.fn(), + releaseDatabaseWorkerPerformanceCapture: jest.fn(), + startWorkerPerformanceCapture: jest.fn(() => ({})), + })); +} + +describe('worker cancellation while performance capture arms', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it('cancels playlist work before file access when cancel arrives during arming', async () => { + const armGate = createArmGate(); + const port = createParentPortHarness< + PlaylistRefreshWorkerIncomingMessage, + PlaylistRefreshWorkerMessage + >(); + const readFile = jest.fn().mockResolvedValue('#EXTM3U'); + mockPerformanceCapture(armGate); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('node:fs/promises', () => ({ readFile })); + jest.doMock('iptv-playlist-parser', () => ({ + parse: jest.fn(() => ({ items: [] })), + })); + jest.doMock('@iptvnator/shared/m3u-utils', () => ({ + createPlaylistObject: jest.fn(() => ({ id: 'playlist-1' })), + getFilenameFromUrl: jest.fn(() => 'playlist.m3u'), + })); + + await import('./playlist-refresh.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + payload: { + filePath: '/fixtures/playlist.m3u', + operationId: 'refresh-1', + playlistId: 'playlist-1', + title: 'Fixture', + }, + }); + + await handleMessage({ + type: 'cancel', + operationId: 'refresh-1', + }); + armGate.release(); + await requestPromise; + + expect(readFile).not.toHaveBeenCalled(); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + operationId: 'refresh-1', + status: 'cancelled', + }), + type: 'event', + }) + ); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ name: 'AbortError' }), + success: false, + type: 'response', + }) + ); + }); + + it('cancels cancellable database work when cancel arrives during arming', async () => { + const armGate = createArmGate(); + const port = createParentPortHarness< + DbWorkerIncomingMessage, + DbWorkerMessage + >(); + const saveContent = jest.fn( + async ( + _db: unknown, + _playlistId: string, + _streams: unknown[], + _type: string, + control: { checkpoint: () => Promise } + ) => { + await control.checkpoint(); + return { count: 0 }; + } + ); + mockPerformanceCapture(armGate); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('./database.worker-connection', () => ({ + closeWorkerDatabase: jest.fn(), + getWorkerDatabase: jest.fn().mockResolvedValue({}), + })); + jest.doMock('../database/operations/content.operations', () => ({ + saveContent, + })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await import('./database.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'database-operation-1', + playlistId: 'playlist-1', + streams: [], + type: 'movie', + }, + requestId: 'request-1', + }); + + await handleMessage({ + type: 'cancel', + operationId: 'database-operation-1', + }); + armGate.release(); + await requestPromise; + + expect(saveContent).toHaveBeenCalledTimes(1); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + operationId: 'database-operation-1', + status: 'cancelled', + }), + requestId: 'request-1', + type: 'event', + }) + ); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ name: 'AbortError' }), + requestId: 'request-1', + success: false, + type: 'response', + }) + ); + }); + + it('does not make an explicitly non-cancellable database operation cancellable during arming', async () => { + const armGate = createArmGate(); + const port = createParentPortHarness< + DbWorkerIncomingMessage, + DbWorkerMessage + >(); + const deleteAllPlaylists = jest.fn( + async ( + _db: unknown, + control: { checkpoint: () => Promise } + ) => { + await control.checkpoint(); + return { success: true }; + } + ); + mockPerformanceCapture(armGate); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('./database.worker-connection', () => ({ + closeWorkerDatabase: jest.fn(), + getWorkerDatabase: jest.fn().mockResolvedValue({}), + })); + jest.doMock('../database/operations/playlist.operations', () => ({ + deleteAllPlaylists, + })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await import('./database.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + operation: 'DB_DELETE_ALL_PLAYLISTS', + payload: { + operationId: 'database-operation-2', + }, + requestId: 'request-2', + }); + + await handleMessage({ + type: 'cancel', + operationId: 'database-operation-2', + }); + armGate.release(); + await requestPromise; + + expect(deleteAllPlaylists).toHaveBeenCalledTimes(1); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'request-2', + result: { success: true }, + success: true, + type: 'response', + }) + ); + expect(port.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ status: 'cancelled' }), + requestId: 'request-2', + type: 'event', + }) + ); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts new file mode 100644 index 000000000..900f4ccde --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts @@ -0,0 +1,95 @@ +import { + WORKER_PERFORMANCE_INVALID_REASON, + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + registerDatabaseWorkerPerformanceCapture, + releaseDatabaseWorkerPerformanceCapture, + startWorkerPerformanceCapture, + type WorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +describe('worker performance capture concurrency and real timers', () => { + const originalProfilingValue = process.env[PROFILING_ENV]; + + afterEach(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + }); + + it('fails every overlapping database request capture closed without serializing execution', async () => { + const activeCaptures = new Set(); + const firstHarness = createFakeRuntime(); + const secondHarness = createFakeRuntime(); + const first = startWorkerPerformanceCapture({ + enabled: true, + runtime: firstHarness.runtime, + }); + const second = startWorkerPerformanceCapture({ + enabled: true, + runtime: secondHarness.runtime, + }); + registerDatabaseWorkerPerformanceCapture(activeCaptures, first); + registerDatabaseWorkerPerformanceCapture(activeCaptures, second); + await Promise.all([ + armWorkerPerformanceCapture(first), + armWorkerPerformanceCapture(second), + ]); + + const [firstExecution, secondExecution] = await Promise.all([ + executeWithWorkerPerformanceCapture(first, async () => 'first'), + executeWithWorkerPerformanceCapture(second, async () => 'second'), + ]); + releaseDatabaseWorkerPerformanceCapture(activeCaptures, first); + releaseDatabaseWorkerPerformanceCapture(activeCaptures, second); + + for (const execution of [firstExecution, secondExecution]) { + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + histogramFlushedEpochMs: null, + invalidReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + threadCpuUserMicros: null, + }); + } + expect(firstExecution.result).toBe('first'); + expect(secondExecution.result).toBe('second'); + expect(activeCaptures.size).toBe(0); + }); + + it('reliably observes a real 20ms event-loop block after condition-based arming', async () => { + process.env[PROFILING_ENV] = '1'; + + const capture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + const blockStartedAt = performance.now(); + while (performance.now() - blockStartedAt < 20) { + // This deliberate block is the behavior under measurement. + } + } + ); + + expect(execution.success).toBe(true); + expect(execution.performance?.eventLoopDelay).not.toBeNull(); + expect(execution.performance?.eventLoopDelay?.maxMs).toBeGreaterThan( + 10 + ); + expect(execution.performance?.histogramFlushedEpochMs).not.toBeNull(); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts new file mode 100644 index 000000000..19462fe5a --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts @@ -0,0 +1,39 @@ +import { + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +describe('worker performance histogram lifecycle', () => { + it('accepts a nonthrowing false disable result because the histogram is already disabled', async () => { + const harness = createFakeRuntime({ + histogramDisableResult: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopDelayUnavailableReason: null, + histogramFlushedEpochMs: 145, + }, + }); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts new file mode 100644 index 000000000..a4951d6cc --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts @@ -0,0 +1,136 @@ +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerEventLoopDelayHistogram, + type WorkerEventLoopDelayMetrics, + type WorkerPerformanceCapture, + type WorkerPerformanceMetricUnavailableReason, +} from './worker-performance-capture.model'; +import { + HISTOGRAM_POLL_INTERVAL_MS, + HISTOGRAM_WAIT_CAP_MS, +} from './worker-performance-capture.runtime'; + +export function disableWorkerPerformanceHistogram( + capture: WorkerPerformanceCapture +): boolean { + if (!capture.eventLoopDelay || capture.histogramDisabled) { + return true; + } + + try { + capture.eventLoopDelay.disable(); + capture.histogramDisabled = true; + return true; + } catch { + // Performance instrumentation must never affect worker execution. + return false; + } +} + +export function markEventLoopDelayUnavailable( + capture: WorkerPerformanceCapture, + reason: WorkerPerformanceMetricUnavailableReason +): void { + capture.eventLoopDelayUnavailableReason ??= reason; + disableWorkerPerformanceHistogram(capture); +} + +export async function waitForHistogramCondition( + capture: WorkerPerformanceCapture, + condition: (histogram: WorkerEventLoopDelayHistogram) => boolean +): Promise { + const histogram = capture.eventLoopDelay; + if ( + !histogram || + capture.histogramDisabled || + capture.invalidReason !== null + ) { + return false; + } + + let startedAt: number; + try { + startedAt = capture.runtime.readMonotonicMs(); + } catch { + return false; + } + + const maximumPolls = Math.ceil( + HISTOGRAM_WAIT_CAP_MS / HISTOGRAM_POLL_INTERVAL_MS + ); + let pollCount = 0; + + while (true) { + if (capture.invalidReason !== null || capture.histogramDisabled) { + return false; + } + try { + if (condition(histogram)) { + return true; + } + } catch { + return false; + } + + let elapsedMs: number; + try { + elapsedMs = capture.runtime.readMonotonicMs() - startedAt; + } catch { + return false; + } + if ( + !Number.isFinite(elapsedMs) || + elapsedMs >= HISTOGRAM_WAIT_CAP_MS || + pollCount >= maximumPolls + ) { + return false; + } + + const delayMs = Math.min( + HISTOGRAM_POLL_INTERVAL_MS, + HISTOGRAM_WAIT_CAP_MS - Math.max(0, elapsedMs) + ); + try { + pollCount += 1; + await new Promise((resolvePromise) => { + capture.runtime.scheduleTimeout(resolvePromise, delayMs); + }); + } catch { + return false; + } + } +} + +export function readWorkerEventLoopDelay( + capture: WorkerPerformanceCapture +): WorkerEventLoopDelayMetrics | null { + if ( + capture.invalidReason !== null || + capture.eventLoopDelayUnavailableReason !== null || + capture.histogramFlushedEpochMs === null || + !capture.eventLoopDelay + ) { + return null; + } + + try { + const metrics: WorkerEventLoopDelayMetrics = { + maxMs: capture.eventLoopDelay.max / 1e6, + p95Ms: capture.eventLoopDelay.percentile(95) / 1e6, + p99Ms: capture.eventLoopDelay.percentile(99) / 1e6, + }; + if ( + Object.values(metrics).every( + (value) => Number.isFinite(value) && value >= 0 + ) + ) { + return metrics; + } + } catch { + // The fixed reason below is intentionally payload-free. + } + + capture.eventLoopDelayUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_INVALID; + return null; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts new file mode 100644 index 000000000..4d1fb3a1f --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts @@ -0,0 +1,235 @@ +import type { EventLoopUtilization } from 'node:perf_hooks'; +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerPerformanceCapture, + type WorkerPerformanceCaptureResult, +} from './worker-performance-capture.model'; +import { readWorkerEventLoopDelay } from './worker-performance-capture.histogram'; +import { safeReadEpochMs } from './worker-performance-capture.runtime'; + +function readThreadCpuBoundary( + capture: WorkerPerformanceCapture +): NodeJS.CpuUsage | null { + if (!capture.runtime.readThreadCpuUsage) { + capture.threadCpuUnavailableReason ??= + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE; + return null; + } + + try { + const usage = capture.runtime.readThreadCpuUsage(); + if ( + !Number.isFinite(usage.system) || + !Number.isFinite(usage.user) || + usage.system < 0 || + usage.user < 0 + ) { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return null; + } + return usage; + } catch { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE; + return null; + } +} + +function readEventLoopUtilizationBoundary( + capture: WorkerPerformanceCapture +): EventLoopUtilization | null { + try { + const utilization = capture.runtime.readEventLoopUtilization(); + if ( + !Number.isFinite(utilization.active) || + !Number.isFinite(utilization.idle) + ) { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } + return utilization; + } catch { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } +} + +export function beginWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null +): void { + if (!capture) { + return; + } + + capture.workStartedEpochMs = safeReadEpochMs(capture.runtime); + capture.threadCpuStart = readThreadCpuBoundary(capture); + capture.eventLoopUtilizationStart = + readEventLoopUtilizationBoundary(capture); +} + +function calculateThreadCpu( + capture: WorkerPerformanceCapture +): Pick< + WorkerPerformanceCaptureResult, + 'threadCpuSystemMicros' | 'threadCpuUserMicros' +> { + if ( + capture.invalidReason !== null || + capture.threadCpuUnavailableReason !== null || + !capture.threadCpuStart || + !capture.threadCpuEnd + ) { + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } + + try { + const system = + capture.threadCpuEnd.system - capture.threadCpuStart.system; + const user = capture.threadCpuEnd.user - capture.threadCpuStart.user; + if ( + !Number.isFinite(system) || + !Number.isFinite(user) || + system < 0 || + user < 0 + ) { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } + + return { + threadCpuSystemMicros: system, + threadCpuUserMicros: user, + }; + } catch { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } +} + +function calculateEventLoopUtilization( + capture: WorkerPerformanceCapture +): number | null { + if ( + capture.invalidReason !== null || + capture.eventLoopUtilizationUnavailableReason !== null || + !capture.eventLoopUtilizationStart || + !capture.eventLoopUtilizationEnd + ) { + return null; + } + + try { + const active = + capture.eventLoopUtilizationEnd.active - + capture.eventLoopUtilizationStart.active; + const idle = + capture.eventLoopUtilizationEnd.idle - + capture.eventLoopUtilizationStart.idle; + const total = active + idle; + if ( + !Number.isFinite(active) || + !Number.isFinite(idle) || + active < 0 || + idle < 0 || + total < 0 + ) { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } + + return total === 0 ? 0 : active / total; + } catch { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } +} + +function createFallbackResult( + capture: WorkerPerformanceCapture +): WorkerPerformanceCaptureResult { + const invalidReason = capture.invalidReason; + return { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + invalidReason ?? + capture.eventLoopDelayUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + invalidReason ?? + capture.eventLoopUtilizationUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE, + histogramFlushedEpochMs: null, + invalidReason, + requestReceivedEpochMs: capture.requestReceivedEpochMs, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + invalidReason ?? + capture.threadCpuUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + workEndedEpochMs: + capture.workEndedEpochMs ?? capture.requestReceivedEpochMs, + workStartedEpochMs: + capture.workStartedEpochMs ?? capture.requestReceivedEpochMs, + }; +} + +export function createWorkerPerformanceCaptureResult( + capture: WorkerPerformanceCapture +): WorkerPerformanceCaptureResult { + try { + const overlapReason = capture.invalidReason; + if (overlapReason) { + capture.eventLoopDelayUnavailableReason = overlapReason; + capture.eventLoopUtilizationUnavailableReason = overlapReason; + capture.threadCpuUnavailableReason = overlapReason; + capture.histogramFlushedEpochMs = null; + } + const threadCpu = calculateThreadCpu(capture); + + return { + eventLoopDelay: readWorkerEventLoopDelay(capture), + eventLoopDelayUnavailableReason: + capture.eventLoopDelayUnavailableReason, + eventLoopUtilization: calculateEventLoopUtilization(capture), + eventLoopUtilizationUnavailableReason: + capture.eventLoopUtilizationUnavailableReason, + histogramFlushedEpochMs: capture.histogramFlushedEpochMs, + invalidReason: capture.invalidReason, + requestReceivedEpochMs: capture.requestReceivedEpochMs, + ...threadCpu, + threadCpuUnavailableReason: capture.threadCpuUnavailableReason, + workEndedEpochMs: + capture.workEndedEpochMs ?? capture.requestReceivedEpochMs, + workStartedEpochMs: + capture.workStartedEpochMs ?? capture.requestReceivedEpochMs, + }; + } catch { + return createFallbackResult(capture); + } +} + +export function finishWorkerPerformanceMetricBoundaries( + capture: WorkerPerformanceCapture +): void { + capture.workEndedEpochMs = safeReadEpochMs(capture.runtime); + capture.threadCpuEnd = readThreadCpuBoundary(capture); + capture.eventLoopUtilizationEnd = readEventLoopUtilizationBoundary(capture); +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts new file mode 100644 index 000000000..f86576eda --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts @@ -0,0 +1,94 @@ +import type { EventLoopUtilization } from 'node:perf_hooks'; + +export const WORKER_PERFORMANCE_INVALID_REASON = { + OVERLAPPING_DATABASE_WORKER_REQUESTS: + 'overlapping-database-worker-requests', +} as const; + +export type WorkerPerformanceInvalidReason = + (typeof WORKER_PERFORMANCE_INVALID_REASON)[keyof typeof WORKER_PERFORMANCE_INVALID_REASON]; + +export const WORKER_PERFORMANCE_UNAVAILABLE_REASON = { + EVENT_LOOP_DELAY_ARM_TIMEOUT: 'event-loop-delay-arm-timeout', + EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE: + 'event-loop-delay-capture-unavailable', + EVENT_LOOP_DELAY_FLUSH_TIMEOUT: 'event-loop-delay-flush-timeout', + EVENT_LOOP_DELAY_INVALID: 'event-loop-delay-invalid', + EVENT_LOOP_UTILIZATION_UNAVAILABLE: 'event-loop-utilization-unavailable', + THREAD_CPU_USAGE_INVALID: 'thread-cpu-usage-invalid', + THREAD_CPU_USAGE_UNAVAILABLE: 'thread-cpu-usage-unavailable', +} as const; + +export type WorkerPerformanceUnavailableReason = + (typeof WORKER_PERFORMANCE_UNAVAILABLE_REASON)[keyof typeof WORKER_PERFORMANCE_UNAVAILABLE_REASON]; + +export type WorkerPerformanceMetricUnavailableReason = + WorkerPerformanceInvalidReason | WorkerPerformanceUnavailableReason; + +export interface WorkerEventLoopDelayMetrics { + maxMs: number; + p95Ms: number; + p99Ms: number; +} + +export interface WorkerEventLoopDelayHistogram { + readonly count: number; + readonly max: number; + disable: () => boolean; + enable: () => boolean; + percentile: (percentile: number) => number; +} + +export interface WorkerPerformanceCaptureResult { + eventLoopDelay: WorkerEventLoopDelayMetrics | null; + eventLoopDelayUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + eventLoopUtilization: number | null; + eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + histogramFlushedEpochMs: number | null; + invalidReason: WorkerPerformanceInvalidReason | null; + requestReceivedEpochMs: number; + threadCpuSystemMicros: number | null; + threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + threadCpuUserMicros: number | null; + workEndedEpochMs: number; + workStartedEpochMs: number; +} + +export interface WorkerPerformanceCaptureRuntime { + createEventLoopDelayHistogram: () => WorkerEventLoopDelayHistogram; + readEpochMs: () => number; + readEventLoopUtilization: () => EventLoopUtilization; + readMonotonicMs: () => number; + readThreadCpuUsage: (() => NodeJS.CpuUsage) | null; + scheduleTimeout: (callback: () => void, delayMs: number) => void; +} + +export interface WorkerPerformanceCaptureOptions { + enabled?: boolean; + runtime?: WorkerPerformanceCaptureRuntime; +} + +export interface WorkerPerformanceExecutionResult { + error: unknown | null; + performance: WorkerPerformanceCaptureResult | undefined; + result: TResult | undefined; + success: boolean; +} + +export interface WorkerPerformanceCapture { + eventLoopDelay: WorkerEventLoopDelayHistogram | null; + eventLoopDelayUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + eventLoopUtilizationEnd: EventLoopUtilization | null; + eventLoopUtilizationStart: EventLoopUtilization | null; + eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + histogramDisabled: boolean; + histogramFlushedEpochMs: number | null; + invalidReason: WorkerPerformanceInvalidReason | null; + requestReceivedEpochMs: number; + runtime: WorkerPerformanceCaptureRuntime; + threadCpuEnd: NodeJS.CpuUsage | null; + threadCpuStart: NodeJS.CpuUsage | null; + threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + workEndedEpochMs: number | null; + workStartedEpochMs: number | null; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts new file mode 100644 index 000000000..15c0a4308 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts @@ -0,0 +1,45 @@ +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; +import type { + WorkerPerformanceCaptureRuntime, + WorkerEventLoopDelayHistogram, +} from './worker-performance-capture.model'; + +export const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +export const HISTOGRAM_POLL_INTERVAL_MS = 1; +export const HISTOGRAM_WAIT_CAP_MS = 50; + +function readRuntimeThreadCpuUsage(): (() => NodeJS.CpuUsage) | null { + const candidate: unknown = Reflect.get(process, 'threadCpuUsage'); + if (typeof candidate !== 'function') { + return null; + } + + return () => + (candidate as (this: NodeJS.Process) => NodeJS.CpuUsage).call(process); +} + +export const DEFAULT_WORKER_PERFORMANCE_RUNTIME: WorkerPerformanceCaptureRuntime = + { + createEventLoopDelayHistogram: (): WorkerEventLoopDelayHistogram => + monitorEventLoopDelay({ + resolution: HISTOGRAM_POLL_INTERVAL_MS, + }), + readEpochMs: () => performance.timeOrigin + performance.now(), + readEventLoopUtilization: () => performance.eventLoopUtilization(), + readMonotonicMs: () => performance.now(), + readThreadCpuUsage: readRuntimeThreadCpuUsage(), + scheduleTimeout: (callback, delayMs) => { + setTimeout(callback, delayMs); + }, + }; + +export function safeReadEpochMs( + runtime: WorkerPerformanceCaptureRuntime +): number { + try { + const epochMs = runtime.readEpochMs(); + return Number.isFinite(epochMs) ? epochMs : Date.now(); + } catch { + return Date.now(); + } +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts index 2801935c3..7bebd4fe9 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts @@ -1,8 +1,10 @@ import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, startWorkerPerformanceCapture, } from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; @@ -23,26 +25,355 @@ describe('worker performance capture', () => { expect(startWorkerPerformanceCapture()).toBeNull(); }); - it('reports finite worker event-loop metrics when opted in', async () => { - process.env[PROFILING_ENV] = '1'; - - const capture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(capture); - const blockStartedAt = performance.now(); - while (performance.now() - blockStartedAt < 20) { - // Deliberately block the loop so the histogram contract is tested. - } - const result = await finishWorkerPerformanceCapture(capture); - - expect(result).toEqual({ - eventLoopDelay: { - maxMs: expect.any(Number), - p95Ms: expect.any(Number), - p99Ms: expect.any(Number), - }, - eventLoopUtilization: expect.any(Number), + it('captures exact work-boundary CPU, ELU, timestamps, and a flushed fresh histogram', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + harness.lifecycle.push('execute'); + await Promise.resolve(); + harness.lifecycle.push('execute:settled'); + return 'result'; + } + ); + + expect(execution).toEqual({ + error: null, + performance: { + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.75, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 145, + invalidReason: null, + requestReceivedEpochMs: 100, + threadCpuSystemMicros: 30, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 80, + workEndedEpochMs: 140, + workStartedEpochMs: 110, + }, + result: 'result', + success: true, + }); + expect(harness.lifecycle).toEqual([ + 'epoch:100', + 'histogram:create', + 'timeout:1', + 'epoch:110', + 'cpu:1', + 'elu:1', + 'execute', + 'execute:settled', + 'epoch:140', + 'cpu:2', + 'elu:2', + 'timeout:1', + 'epoch:145', + ]); + expect(harness.histogramLifecycle.enableCalls).toBe(1); + expect(harness.histogramLifecycle.disableCalls).toBe(1); + expect('reset' in harness.histogram).toBe(false); + }); + + it('finishes profiling before returning a rejected execution to worker side effects', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + const failure = new Error('worker failed'); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + harness.lifecycle.push('execute'); + await Promise.resolve(); + harness.lifecycle.push('execute:rejected'); + throw failure; + } + ); + harness.lifecycle.push('worker:emit-error'); + + expect(execution).toMatchObject({ + error: failure, + success: false, + }); + expect(harness.lifecycle.indexOf('epoch:140')).toBe( + harness.lifecycle.indexOf('execute:rejected') + 1 + ); + expect(harness.lifecycle.indexOf('worker:emit-error')).toBeGreaterThan( + harness.lifecycle.indexOf('epoch:145') + ); + }); + + it('times out arming after a monotonic 50ms without blocking work metrics', async () => { + const harness = createFakeRuntime({ + armHistogram: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect( + harness.scheduledTimeouts.reduce( + (total, timeout) => total + timeout, + 0 + ) + ).toBe(50); + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }); + }); + + it('cannot poll forever when the monotonic runtime clock stalls', async () => { + const harness = createFakeRuntime({ + armHistogram: false, + stallMonotonicClock: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect(harness.scheduledTimeouts).toHaveLength(50); + expect(execution).toMatchObject({ + result: 'result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + }, + }); + }); + + it('times out flushing after a monotonic 50ms while preserving work CPU and ELU', async () => { + const harness = createFakeRuntime({ + flushHistogram: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect( + harness.scheduledTimeouts + .slice(1) + .reduce((total, timeout) => total + timeout, 0) + ).toBe(50); + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_FLUSH_TIMEOUT, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }); + }); + + it('reports unavailable thread CPU without falling back to process CPU usage', async () => { + const harness = createFakeRuntime({ + threadCpuAvailable: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect(execution.performance).toMatchObject({ + eventLoopUtilization: 0.75, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + }); + expect(harness.lifecycle).not.toContain('cpu:1'); + expect(harness.lifecycle).not.toContain('cpu:2'); + }); + + it('preserves the business result when CPU and ELU runtime callbacks throw', async () => { + const harness = createFakeRuntime({ + throwBoundaryCallbacks: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + }, + }); + }); + + it('preserves work metrics and the business result when timer scheduling throws', async () => { + const harness = createFakeRuntime({ + throwScheduleTimeout: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + }); + + it.each([ + { + label: 'count getter during finish', + options: { + throwHistogramCountAtRead: 3, + }, + reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + }, + { + label: 'metric getter after flush', + options: { + throwHistogramMetric: true, + }, + reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_INVALID, + }, + ])( + 'preserves the business result when the histogram $label throws', + async ({ options, reason }) => { + const harness = createFakeRuntime(options); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: reason, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + } + ); + + it('keeps the business result and rejects delay metrics when histogram disable throws', async () => { + const harness = createFakeRuntime({ + throwHistogramDisable: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, }); - expect(result?.eventLoopUtilization).toBeGreaterThanOrEqual(0); - expect(result?.eventLoopDelay.maxMs).toBeGreaterThan(10); }); }); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts new file mode 100644 index 000000000..54517e637 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts @@ -0,0 +1,158 @@ +import type { + WorkerEventLoopDelayHistogram, + WorkerPerformanceCaptureRuntime, +} from './worker-performance-capture'; + +interface FakeRuntimeOptions { + armHistogram?: boolean; + flushHistogram?: boolean; + histogramDisableResult?: boolean; + stallMonotonicClock?: boolean; + threadCpuAvailable?: boolean; + throwBoundaryCallbacks?: boolean; + throwHistogramDisable?: boolean; + throwHistogramCountAtRead?: number; + throwHistogramMetric?: boolean; + throwScheduleTimeout?: boolean; +} + +export interface FakeRuntimeHarness { + histogram: WorkerEventLoopDelayHistogram; + histogramLifecycle: { + disableCalls: number; + enableCalls: number; + }; + lifecycle: string[]; + runtime: WorkerPerformanceCaptureRuntime; + scheduledTimeouts: number[]; +} + +export function createFakeRuntime( + options: FakeRuntimeOptions = {} +): FakeRuntimeHarness { + const lifecycle: string[] = []; + const scheduledTimeouts: number[] = []; + let epochIndex = 0; + let monotonicMs = 0; + let timeoutCount = 0; + let cpuIndex = 0; + let eluIndex = 0; + let histogramCount = 0; + let histogramCountReads = 0; + const histogramLifecycle = { + disableCalls: 0, + enableCalls: 0, + }; + const epochValues = [100, 110, 140, 145]; + const cpuValues = [ + { system: 50, user: 100 }, + { system: 80, user: 180 }, + ]; + const eluValues = [ + { active: 10, idle: 20, utilization: 1 / 3 }, + { active: 40, idle: 30, utilization: 4 / 7 }, + ]; + const histogram = { + get count() { + histogramCountReads += 1; + if (histogramCountReads === options.throwHistogramCountAtRead) { + throw new Error('histogram count unavailable'); + } + return histogramCount; + }, + disable: () => { + histogramLifecycle.disableCalls += 1; + if (options.throwHistogramDisable === true) { + throw new Error('histogram disable unavailable'); + } + return options.histogramDisableResult ?? true; + }, + enable: () => { + histogramLifecycle.enableCalls += 1; + return true; + }, + get max() { + if (options.throwHistogramMetric === true) { + throw new Error('histogram max unavailable'); + } + return 24_000_000; + }, + percentile: (percentile: number) => { + if (options.throwHistogramMetric === true) { + throw new Error('histogram percentile unavailable'); + } + if (percentile === 95) { + return 18_000_000; + } + if (percentile === 99) { + return 22_000_000; + } + return 0; + }, + } satisfies WorkerEventLoopDelayHistogram; + const runtime: WorkerPerformanceCaptureRuntime = { + createEventLoopDelayHistogram: () => { + lifecycle.push('histogram:create'); + return histogram; + }, + readEpochMs: () => { + const value = epochValues[epochIndex] ?? 145 + epochIndex; + epochIndex += 1; + lifecycle.push(`epoch:${value}`); + return value; + }, + readEventLoopUtilization: () => { + if (options.throwBoundaryCallbacks === true) { + throw new Error('ELU callback unavailable'); + } + const value = eluValues[eluIndex] ?? eluValues.at(-1)!; + eluIndex += 1; + lifecycle.push(`elu:${eluIndex}`); + return value; + }, + readMonotonicMs: () => monotonicMs, + readThreadCpuUsage: + options.threadCpuAvailable === false + ? null + : () => { + if (options.throwBoundaryCallbacks === true) { + throw new Error('thread CPU callback unavailable'); + } + const value = cpuValues[cpuIndex] ?? cpuValues.at(-1)!; + cpuIndex += 1; + lifecycle.push(`cpu:${cpuIndex}`); + return value; + }, + scheduleTimeout: (callback, delayMs) => { + lifecycle.push(`timeout:${delayMs}`); + scheduledTimeouts.push(delayMs); + if (options.throwScheduleTimeout === true) { + throw new Error('timer callback unavailable'); + } + if (options.stallMonotonicClock !== true) { + monotonicMs += delayMs; + } else if (timeoutCount >= 55) { + throw new Error('test scheduler fail-safe'); + } + timeoutCount += 1; + if (timeoutCount === 1 && options.armHistogram !== false) { + histogramCount += 1; + } else if ( + timeoutCount > 1 && + options.armHistogram !== false && + options.flushHistogram !== false + ) { + histogramCount += 1; + } + callback(); + }, + }; + + return { + histogram, + histogramLifecycle, + lifecycle, + runtime, + scheduledTimeouts, + }; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.ts index db86f234d..a29d97f0a 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.ts @@ -1,47 +1,119 @@ import { - monitorEventLoopDelay, - performance, - type IntervalHistogram, -} from 'node:perf_hooks'; + WORKER_PERFORMANCE_INVALID_REASON, + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerPerformanceCapture, + type WorkerPerformanceCaptureOptions, + type WorkerPerformanceCaptureResult, + type WorkerPerformanceExecutionResult, +} from './worker-performance-capture.model'; +import { + disableWorkerPerformanceHistogram, + markEventLoopDelayUnavailable, + waitForHistogramCondition, +} from './worker-performance-capture.histogram'; +import { + beginWorkerPerformanceCapture, + createWorkerPerformanceCaptureResult, + finishWorkerPerformanceMetricBoundaries, +} from './worker-performance-capture.metrics'; +import { + DEFAULT_WORKER_PERFORMANCE_RUNTIME, + safeReadEpochMs, + WORKER_PROFILING_ENV, +} from './worker-performance-capture.runtime'; -const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +export { + WORKER_PERFORMANCE_INVALID_REASON, + WORKER_PERFORMANCE_UNAVAILABLE_REASON, +} from './worker-performance-capture.model'; +export type { + WorkerEventLoopDelayHistogram, + WorkerEventLoopDelayMetrics, + WorkerPerformanceCapture, + WorkerPerformanceCaptureOptions, + WorkerPerformanceCaptureResult, + WorkerPerformanceCaptureRuntime, + WorkerPerformanceExecutionResult, + WorkerPerformanceInvalidReason, + WorkerPerformanceUnavailableReason, +} from './worker-performance-capture.model'; -export interface WorkerPerformanceCaptureResult { - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - }; - eventLoopUtilization: number; -} - -interface WorkerPerformanceCapture { - eventLoopDelay: IntervalHistogram; - eventLoopUtilizationStart: ReturnType< - typeof performance.eventLoopUtilization - >; -} - -export function startWorkerPerformanceCapture(): WorkerPerformanceCapture | null { - if (process.env[WORKER_PROFILING_ENV] !== '1') { +export function startWorkerPerformanceCapture( + options: WorkerPerformanceCaptureOptions = {} +): WorkerPerformanceCapture | null { + const enabled = + options.enabled ?? process.env[WORKER_PROFILING_ENV] === '1'; + if (!enabled) { return null; } - const eventLoopDelay = monitorEventLoopDelay({ resolution: 1 }); - eventLoopDelay.enable(); - return { - eventLoopDelay, - eventLoopUtilizationStart: performance.eventLoopUtilization(), + const runtime = options.runtime ?? DEFAULT_WORKER_PERFORMANCE_RUNTIME; + const capture: WorkerPerformanceCapture = { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + eventLoopUtilizationEnd: null, + eventLoopUtilizationStart: null, + eventLoopUtilizationUnavailableReason: null, + histogramDisabled: false, + histogramFlushedEpochMs: null, + invalidReason: null, + requestReceivedEpochMs: safeReadEpochMs(runtime), + runtime, + threadCpuEnd: null, + threadCpuStart: null, + threadCpuUnavailableReason: + runtime.readThreadCpuUsage === null + ? WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE + : null, + workEndedEpochMs: null, + workStartedEpochMs: null, }; + + try { + capture.eventLoopDelay = runtime.createEventLoopDelayHistogram(); + capture.eventLoopDelay.enable(); + } catch { + capture.eventLoopDelay = null; + capture.eventLoopDelayUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE; + } + + return capture; } export async function armWorkerPerformanceCapture( capture: WorkerPerformanceCapture | null ): Promise { - if (capture) { - await new Promise((resolvePromise) => - setTimeout(resolvePromise, 2) + if (!capture) { + return; + } + + try { + if ( + capture.invalidReason !== null || + capture.eventLoopDelayUnavailableReason !== null + ) { + return; + } + const armed = await waitForHistogramCondition( + capture, + (histogram) => histogram.count > 0 ); + if (!armed && capture.invalidReason === null) { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT + ); + } + } catch { + try { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } catch { + // Instrumentation must never prevent business execution. + } } } @@ -52,17 +124,148 @@ export async function finishWorkerPerformanceCapture( return undefined; } - await new Promise((resolvePromise) => setTimeout(resolvePromise, 2)); - capture.eventLoopDelay.disable(); - const eventLoopUtilization = performance.eventLoopUtilization( - capture.eventLoopUtilizationStart + finishWorkerPerformanceMetricBoundaries(capture); + + if ( + capture.invalidReason === null && + capture.eventLoopDelayUnavailableReason === null && + capture.eventLoopDelay + ) { + try { + const workEndedHistogramCount = capture.eventLoopDelay.count; + const flushed = await waitForHistogramCondition( + capture, + (histogram) => histogram.count > workEndedHistogramCount + ); + if (flushed) { + const disabled = disableWorkerPerformanceHistogram(capture); + if (disabled) { + capture.histogramFlushedEpochMs = safeReadEpochMs( + capture.runtime + ); + } else { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } + } else if ( + capture.invalidReason === null && + capture.eventLoopDelayUnavailableReason === null + ) { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_FLUSH_TIMEOUT + ); + } + } catch { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } + } else { + disableWorkerPerformanceHistogram(capture); + } + + return createWorkerPerformanceCaptureResult(capture); +} + +export async function executeWithWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null, + execute: () => Promise, + finish: ( + capture: WorkerPerformanceCapture | null + ) => Promise< + WorkerPerformanceCaptureResult | undefined + > = finishWorkerPerformanceCapture +): Promise> { + try { + beginWorkerPerformanceCapture(capture); + } catch { + // Instrumentation setup must never prevent business execution. + } + let result: TResult; + try { + result = await execute(); + } catch (error) { + const performance = await safelyFinishWorkerPerformanceCapture( + capture, + finish + ); + return { + error, + performance, + result: undefined, + success: false, + }; + } + + const performance = await safelyFinishWorkerPerformanceCapture( + capture, + finish ); return { - eventLoopDelay: { - maxMs: capture.eventLoopDelay.max / 1e6, - p95Ms: capture.eventLoopDelay.percentile(95) / 1e6, - p99Ms: capture.eventLoopDelay.percentile(99) / 1e6, - }, - eventLoopUtilization: eventLoopUtilization.utilization, + error: null, + performance, + result, + success: true, }; } + +async function safelyFinishWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null, + finish: ( + capture: WorkerPerformanceCapture | null + ) => Promise +): Promise { + try { + return await finish(capture); + } catch { + if (!capture) { + return undefined; + } + try { + if (capture.workEndedEpochMs === null) { + finishWorkerPerformanceMetricBoundaries(capture); + } + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + return createWorkerPerformanceCaptureResult(capture); + } catch { + return undefined; + } + } +} + +export function registerDatabaseWorkerPerformanceCapture( + activeCaptures: Set, + capture: WorkerPerformanceCapture | null +): void { + if (!capture) { + return; + } + + if (activeCaptures.size > 0) { + for (const activeCapture of activeCaptures) { + activeCapture.invalidReason = + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS; + disableWorkerPerformanceHistogram(activeCapture); + } + capture.invalidReason = + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS; + disableWorkerPerformanceHistogram(capture); + } + activeCaptures.add(capture); +} + +export function releaseDatabaseWorkerPerformanceCapture( + activeCaptures: Set, + capture: WorkerPerformanceCapture | null +): void { + if (capture) { + activeCaptures.delete(capture); + } +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts new file mode 100644 index 000000000..e13a11f29 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts @@ -0,0 +1,118 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +function readWorkerSource(filename: string): string { + return readFileSync( + join(process.cwd(), 'apps/electron-backend/src/app/workers', filename), + 'utf8' + ); +} + +describe('worker performance integration order', () => { + it('profiles overlapping database requests without serializing their execution', () => { + const source = readWorkerSource('database.worker.ts'); + const handler = source.slice( + source.lastIndexOf("parentPort.on('message'") + ); + + expect(handler).toContain('registerDatabaseWorkerPerformanceCapture'); + expect(handler).toContain('executeWithWorkerPerformanceCapture'); + expect(handler).toContain('releaseDatabaseWorkerPerformanceCapture'); + expect(handler).toContain('finally'); + expect(handler).not.toContain('finishWorkerPerformanceCapture'); + expect( + handler.indexOf('executeWithWorkerPerformanceCapture') + ).toBeLessThan(handler.indexOf('console.error')); + expect( + handler.indexOf('executeWithWorkerPerformanceCapture') + ).toBeLessThan(handler.indexOf('postMessage')); + expect(handler).not.toContain('await previous'); + expect(handler).not.toContain('requestQueue'); + }); + + it('finishes playlist profiling before cancellation/error events and response serialization', () => { + const source = readWorkerSource('playlist-refresh.worker.ts'); + const handler = source.slice(source.lastIndexOf('parentPort.on(')); + const executionIndex = handler.indexOf( + 'executeWithWorkerPerformanceCapture' + ); + + expect(executionIndex).toBeGreaterThanOrEqual(0); + expect(handler).not.toContain('finishWorkerPerformanceCapture'); + expect(executionIndex).toBeLessThan( + handler.indexOf('emitEvent', executionIndex) + ); + expect(executionIndex).toBeLessThan( + handler.indexOf('serializeError', executionIndex) + ); + expect(executionIndex).toBeLessThan( + handler.indexOf('postMessage', executionIndex) + ); + }); + + it('preserves success and business errors when the finish seam throws once', async () => { + const successHarness = createFakeRuntime(); + const successCapture = startWorkerPerformanceCapture({ + enabled: true, + runtime: successHarness.runtime, + }); + await armWorkerPerformanceCapture(successCapture); + const failingSuccessFinish = jest.fn(async () => { + throw new Error('finish failed'); + }); + + const success = await executeWithWorkerPerformanceCapture( + successCapture, + async () => 'business-result', + failingSuccessFinish + ); + + expect(success).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + expect(failingSuccessFinish).toHaveBeenCalledTimes(1); + + const errorHarness = createFakeRuntime(); + const errorCapture = startWorkerPerformanceCapture({ + enabled: true, + runtime: errorHarness.runtime, + }); + await armWorkerPerformanceCapture(errorCapture); + const businessError = new Error('business failed'); + const failingErrorFinish = jest.fn(async () => { + throw new Error('finish failed'); + }); + + const failure = await executeWithWorkerPerformanceCapture( + errorCapture, + async () => { + throw businessError; + }, + failingErrorFinish + ); + + expect(failure).toMatchObject({ + error: businessError, + result: undefined, + success: false, + }); + expect(failingErrorFinish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 48a4a468b..855da02a3 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -121,11 +121,27 @@ before/after claims. The target reserves and verifies CDP port 9222, freezes renderer long-task, frame-gap, and heartbeat probes before forced post-GC heap collection, and -enables opt-in worker profiling. Worker event-loop delay is read from a -request-scoped `node:perf_hooks` capture; a worker terminated before it can flush -the capture reports the metric as unavailable rather than zero. Diagnostic CPU -profiles, heap snapshots, and Chromium traces are excluded from the five-run -headline distributions. +enables opt-in worker profiling. Each worker response retains a raw +request-scoped record containing request/operation identity, received/work/flush +timestamps, thread CPU, event-loop utilization, event-loop delay, and fixed +unavailability or invalid reasons. Missing or malformed profiling metadata +still produces an outcome with its request identity, nullable metrics, and a +fixed capture-unavailable reason. A worker terminated before it can flush the +capture reports the metric as unavailable rather than zero. + +The harness excludes workers created by pre-capture seed setup through an exact +capture-generation marker. Since the production M3U database payload +deliberately omits the refresh operation ID, the benchmark correlates only a +valid preload `start` marker to the exact database operation and playlist. A +missing or ambiguous marker leaves the raw operation ID `null` with a fixed +reason; it does not infer identity from timing alone. + +Headline worker fields named `*WorkerRequest*` are distributions of individual +request metrics. In particular, request p95/p99 distributions are not presented +as an operation-wide or process-wide percentile, and database request +percentiles are never collapsed with `Math.max`. Diagnostic CPU profiles, heap +snapshots, and Chromium traces are excluded from the five-run headline +distributions. ### Reporting The Auto-Update Result diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 0e2a3705b..52bdbb860 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -118,6 +118,35 @@ The worker contract lives in 3. `DbWorkerEventMessage` 4. `DbOperationEvent` +### Opt-in request performance capture + +`IPTVNATOR_PERF_WORKER_PROFILING=1` adds development/test-only performance +metadata to each database-worker response. It is disabled by default and must +stay disabled for production launches. + +Each enabled request gets a fresh event-loop-delay histogram and records: + +- `requestReceivedEpochMs`, `workStartedEpochMs`, `workEndedEpochMs`, and + `histogramFlushedEpochMs` +- worker-thread CPU user/system microseconds from `process.threadCpuUsage()` +- event-loop utilization across the exact work interval +- event-loop-delay max/p95/p99 from the request's own histogram +- fixed invalid or unavailable reasons whenever a metric cannot be attributed + +Histogram arming waits until the histogram has a sample; flushing waits for its +sample count to advance after work ends. Both waits use condition-based timer +polling. Each wait stops after 50 ms of observed monotonic time or its bounded +poll count; arming and flushing have separate caps, and timer scheduling may +overshoot wall-clock time. A timeout or profiling API failure never replaces +the business response: timestamps and any independently available CPU/ELU +metrics remain valid, while event-loop delay is `null` with a fixed reason. + +The long-lived database worker still executes concurrent requests without a +profiling queue. If captures overlap, every overlapping response carries +`invalidReason: "overlapping-database-worker-requests"` and all attributable +CPU, ELU, and event-loop-delay values are `null`. This avoids assigning shared +worker activity to one request while preserving normal worker concurrency. + ### Progress event contract The worker now emits request-scoped events with: From 8bafd629321a11506c0320c374fedbefdd3808e6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 26 Jul 2026 22:49:36 +0200 Subject: [PATCH 12/32] fix(perf): benchmark the performance build --- apps/electron-backend-e2e/project.json | 2 +- .../src/performance/performance-build-config.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 51ed910a8..9c1f45538 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -20,7 +20,7 @@ } }, "benchmark-m3u-refresh-cancellation": { - "dependsOn": ["electron-backend:build-e2e"], + "dependsOn": ["electron-backend:build-performance"], "executor": "nx:run-commands", "cache": false, "parallelism": false, diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index 94a64095a..dd2ddf7e6 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -210,10 +210,10 @@ test('the Electron performance wrapper owns build dependencies before its profil ); }); -test('the cancellation benchmark keeps its existing E2E build dependency', () => { +test('the cancellation benchmark uses the production-equivalent performance build', () => { const target = e2eProject.targets['benchmark-m3u-refresh-cancellation']; - assert.deepEqual(target.dependsOn, ['electron-backend:build-e2e']); + assert.deepEqual(target.dependsOn, ['electron-backend:build-performance']); }); test('the cancellation benchmark command is pinned to its Playwright test file', () => { From f9ea3070ee16c4ff779c6b7cae0b700ff51261d0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:57:16 +0200 Subject: [PATCH 13/32] refactor(settings): split the settings page into per-section facades (#1274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings.component.ts had grown to 819 lines — past the CLAUDE.md target (<300) and hard maximum, passing lint only because it sat in the max-lines baseline. The behaviour moves into facades the template binds to directly, following the precedent already in this folder: new app-update (218), form (197), epg (123), embedded-mpv (74) and remote-control (37) facades, with playlist-reset extended to 143 and settings-options to 200. The component is now a 259-line coordinator holding capability flags, section nav, players() and the cross-facade flows. settings.component.ts is removed from the max-lines baseline. No behaviour change. One ordering detail: applyChangedSettings now applies language/theme before kicking off the EPG re-fetch; changeTheme only touches DOM theme sync and translate.use does not touch the form, so the two are independent. --- .../settings/settings-app-update.facade.ts | 218 ++++++ .../settings/settings-embedded-mpv.facade.ts | 74 ++ .../src/app/settings/settings-epg.facade.ts | 123 +++ .../src/app/settings/settings-form.facade.ts | 197 +++++ apps/web/src/app/settings/settings-options.ts | 27 + .../settings-playlist-reset.facade.ts | 71 +- .../settings-remote-control.facade.ts | 37 + .../src/app/settings/settings.component.html | 58 +- .../app/settings/settings.component.spec.ts | 162 ++-- .../src/app/settings/settings.component.ts | 708 ++---------------- docs/architecture/remote-control.md | 6 +- tools/eslint/max-lines-baseline.mjs | 1 - 12 files changed, 931 insertions(+), 751 deletions(-) create mode 100644 apps/web/src/app/settings/settings-app-update.facade.ts create mode 100644 apps/web/src/app/settings/settings-embedded-mpv.facade.ts create mode 100644 apps/web/src/app/settings/settings-epg.facade.ts create mode 100644 apps/web/src/app/settings/settings-form.facade.ts create mode 100644 apps/web/src/app/settings/settings-remote-control.facade.ts diff --git a/apps/web/src/app/settings/settings-app-update.facade.ts b/apps/web/src/app/settings/settings-app-update.facade.ts new file mode 100644 index 000000000..05659f6f7 --- /dev/null +++ b/apps/web/src/app/settings/settings-app-update.facade.ts @@ -0,0 +1,218 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + ELECTRON_BRIDGE_APP_UPDATE_STATUSES, + ElectronBridgeAppUpdateStatus, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { take } from 'rxjs'; +import { SettingsService } from '../services/settings.service'; +import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component'; + +const APP_UPDATE_STATUS_LOAD_ATTEMPTS = 60; +const APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS = 250; + +/** + * Owns everything the About section needs to talk about versions: the + * electron-updater status stream and the "is my build outdated" message. + */ +@Injectable() +export class SettingsAppUpdateFacade { + private readonly dataService = inject(DataService); + private readonly matDialog = inject(MatDialog); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsService = inject(SettingsService); + private readonly translate = inject(TranslateService); + + /** Latest updater status, polled once and then pushed by the backend */ + readonly status = signal(null); + + /** Current version of the app */ + readonly version = signal(''); + + /** Update message to show */ + readonly updateMessage = signal(''); + + private unsubscribeStatus: (() => void) | null = null; + + /** Subscribes to status pushes and kicks off the initial status load */ + init(): void { + this.bindStatusEvents(); + void this.loadStatus(); + } + + dispose(): void { + this.unsubscribeStatus?.(); + this.unsubscribeStatus = null; + } + + async checkForAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.checkForAppUpdate) { + return; + } + + this.status.set(await window.electron.checkForAppUpdate()); + } + + async downloadAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.downloadAppUpdate) { + return; + } + + this.status.set(await window.electron.downloadAppUpdate()); + } + + async installAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.installAppUpdate) { + return; + } + + this.status.set(await window.electron.installAppUpdate()); + } + + openManualAppUpdate(): void { + const manualDownloadUrl = this.status()?.manualDownloadUrl; + + if (!manualDownloadUrl) { + return; + } + + window.open(manualDownloadUrl, '_blank', 'noreferrer'); + } + + openReleaseNotes(): void { + const status = this.status(); + const isUpdateRelease = + status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available || + status?.status === + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading || + status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded; + const initialReleaseNotesVersion = isUpdateRelease + ? (status?.latestVersion ?? + status?.release?.version ?? + status?.currentVersion) + : status?.currentVersion; + + this.matDialog.open(AppUpdateReleaseNotesDialogComponent, { + autoFocus: false, + data: { + ...(!isUpdateRelease ? { fallbackToLatest: true } : {}), + initialVersion: initialReleaseNotesVersion, + }, + maxWidth: 'calc(100vw - 32px)', + restoreFocus: true, + width: '720px', + }); + } + + /** + * Checks whether the latest version of the application + * is used and updates the version message in the + * settings UI + */ + checkAppVersion(): void { + this.settingsService + .getAppVersion() + .pipe(take(1)) + .subscribe((version) => this.showVersionInformation(version)); + } + + /** + * Updates the message in settings UI about the used + * version of the app + * @param currentVersion current version of the application + */ + showVersionInformation(currentVersion: string): void { + const isOutdated = this.isCurrentVersionOutdated(currentVersion); + + if (isOutdated) { + this.updateMessage.set( + `${ + this.translate.instant( + 'SETTINGS.NEW_VERSION_AVAILABLE' + ) as string + }: ${currentVersion}` + ); + } else { + this.updateMessage.set( + this.translate.instant('SETTINGS.LATEST_VERSION') + ); + } + } + + /** + * Compares actual with latest version of the + * application + * @param latestVersion latest version + * @returns returns true if an update is available + */ + isCurrentVersionOutdated(latestVersion: string): boolean { + this.version.set(this.dataService.getAppVersion()); + return this.settingsService.isVersionOutdated( + this.version(), + latestVersion + ); + } + + private bindStatusEvents(): void { + if ( + !this.runtime.isElectron || + !window.electron?.onAppUpdateStatusChange + ) { + return; + } + + this.unsubscribeStatus = window.electron.onAppUpdateStatusChange( + (status) => { + this.status.set(status); + } + ); + } + + /** + * The updater IPC handlers can still be registering while the settings + * page mounts, so the first load retries until the bridge answers. + */ + private async loadStatus(): Promise { + if (!this.runtime.isElectron) { + return; + } + + let lastError: unknown; + + for ( + let attempt = 1; + attempt <= APP_UPDATE_STATUS_LOAD_ATTEMPTS; + attempt += 1 + ) { + const electron = window.electron; + + if (electron?.getAppUpdateStatus) { + try { + this.status.set(await electron.getAppUpdateStatus()); + return; + } catch (error) { + lastError = error; + } + } + + if (attempt === APP_UPDATE_STATUS_LOAD_ATTEMPTS) { + console.warn( + 'Failed to load app update status:', + lastError ?? + new Error('Desktop app update bridge is unavailable') + ); + return; + } + + await this.waitForRetry(); + } + } + + private async waitForRetry(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS); + }); + } +} diff --git a/apps/web/src/app/settings/settings-embedded-mpv.facade.ts b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts new file mode 100644 index 000000000..cfe738c72 --- /dev/null +++ b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts @@ -0,0 +1,74 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces'; + +/** + * Probes the desktop backend for embedded MPV support so the playback + * section can hide options the current build cannot deliver. + */ +@Injectable() +export class SettingsEmbeddedMpvFacade { + private readonly runtime = inject(RuntimeCapabilitiesService); + + readonly support = signal(null); + + readonly supported = computed( + () => this.runtime.isElectron && !!this.support()?.supported + ); + + readonly frameCopyAvailable = computed( + () => this.runtime.isElectron && !!this.support()?.frameCopyAvailable + ); + + readonly frameCopyActive = computed( + () => this.support()?.engine === 'frame-copy' + ); + + async load(): Promise { + if (!this.runtime.isElectron) { + this.support.set({ + supported: false, + platform: 'web', + reason: 'Embedded MPV requires the Electron desktop build.', + }); + return; + } + + if (!window.electron?.getEmbeddedMpvSupport) { + this.support.set({ + supported: false, + platform: window.electron.platform, + reason: 'Embedded MPV support is not available in this build.', + }); + return; + } + + try { + this.support.set(await window.electron.getEmbeddedMpvSupport()); + } catch (error) { + this.support.set({ + supported: false, + platform: window.electron.platform, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Opens the native folder picker for MPV recordings. + * @returns the selected folder, or null when the user cancelled or the + * desktop bridge cannot show the dialog + */ + async selectRecordingFolder(): Promise { + if ( + !this.runtime.isElectron || + !window.electron?.selectEmbeddedMpvRecordingFolder + ) { + return null; + } + + return ( + (await window.electron.selectEmbeddedMpvRecordingFolder()) ?? null + ); + } +} diff --git a/apps/web/src/app/settings/settings-epg.facade.ts b/apps/web/src/app/settings/settings-epg.facade.ts new file mode 100644 index 000000000..7f8670e09 --- /dev/null +++ b/apps/web/src/app/settings/settings-epg.facade.ts @@ -0,0 +1,123 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; +import { DialogService } from '@iptvnator/ui/components'; +import { TranslateService } from '@ngx-translate/core'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsFormFacade } from './settings-form.facade'; +import { SettingsSnackbarService } from './settings-snackbar.service'; + +/** + * EPG maintenance actions offered by the settings page: force refreshes and + * the destructive "clear all EPG data" flow. + */ +@Injectable() +export class SettingsEpgFacade { + private readonly dialogService = inject(DialogService); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly epgService = inject(EpgService); + private readonly formFacade = inject(SettingsFormFacade); + private readonly settingsSnackbar = inject(SettingsSnackbarService); + private readonly settingsStore = inject(SettingsStore); + private readonly translate = inject(TranslateService); + + readonly isClearing = signal(false); + + /** + * Force-fetch EPG for a single URL, bypassing the 12-hour freshness check. + * The plain fetchEpg would short-circuit on fresh data and click the + * refresh button would be a no-op — that's exactly not what the user + * intends when clicking "Refresh". + */ + refresh(url: string): void { + if (!this.epgBridge.supportsDataManagement || !url) { + return; + } + void this.epgBridge.forceFetchEpg( + url, + this.settingsStore.getTrustOptions() + ); + } + + /** + * Force-fetch every configured EPG URL sequentially. Empty fields are + * skipped. Each URL flows through the normal progress panel so the user + * gets visible per-URL feedback. + */ + refreshAll(): void { + if (!this.epgBridge.supportsDataManagement) return; + const options = this.settingsStore.getTrustOptions(); + this.formFacade.epgUrls.forEach( + (url) => void this.epgBridge.forceFetchEpg(url, options) + ); + } + + /** + * Clears all EPG data from database and immediately re-fetches every + * configured URL so the user isn't left staring at an empty state. + * Tracks progress with `isClearing` so the UI can show a spinner + * and block double-clicks, and surfaces failures via a dedicated snackbar. + */ + clear(): void { + this.dialogService.openConfirmDialog({ + title: this.translate.instant('SETTINGS.CLEAR_EPG_DIALOG.TITLE'), + message: this.translate.instant( + 'SETTINGS.CLEAR_EPG_DIALOG.MESSAGE' + ), + onConfirm: async (): Promise => { + if ( + !this.epgBridge.supportsDataManagement || + this.isClearing() + ) { + return; + } + + this.isClearing.set(true); + try { + const result = await this.epgBridge.clearEpgData(); + if (result && result.success === false) { + throw new Error('Clear EPG returned success=false'); + } + this.settingsSnackbar.open( + this.translate.instant('SETTINGS.EPG_DATA_CLEARED') + ); + this.refreshAll(); + } catch (error) { + console.error('Failed to clear EPG data:', error); + this.settingsSnackbar.open( + this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED') + ); + } finally { + this.isClearing.set(false); + } + }, + }); + } + + /** + * Re-fetches the EPG sources that were just saved so the guide reflects + * the new configuration without a restart. + */ + fetchConfiguredEpg(): void { + if (!this.formFacade.supportsEpg) { + return; + } + + const epgUrl = this.formFacade.form.value.epgUrl; + + if (!epgUrl) { + return; + } + + const validEpgUrls = (Array.isArray(epgUrl) ? epgUrl : [epgUrl]).filter( + (url): url is string => typeof url === 'string' && url !== '' + ); + + if (validEpgUrls.length > 0) { + // Fetch all EPG URLs at once + this.epgService.fetchEpg(validEpgUrls); + } + } +} diff --git a/apps/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts new file mode 100644 index 000000000..a1b4a31a1 --- /dev/null +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -0,0 +1,197 @@ +import { DestroyRef, inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormArray, FormBuilder } from '@angular/forms'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + CoverSize, + EpgViewMode, + Language, + Theme, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsService } from '../services/settings.service'; +import { + applyEpgUrlsToFormArray, + createEpgUrlControl, + createSettingsForm, + createSettingsFromFormValue, + SettingsForm, +} from './settings-form.utils'; + +type SettingsFormPatch = Parameters[0]; + +/** + * Owns the settings form: creation, hydration from the store, the small + * mutations the section components trigger, and persisting on submit. + */ +@Injectable() +export class SettingsFormFacade { + private readonly destroyRef = inject(DestroyRef); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly formBuilder = inject(FormBuilder); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsService = inject(SettingsService); + private readonly settingsStore = inject(SettingsStore); + private readonly translate = inject(TranslateService); + + readonly supportsEpg = + this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement; + + /** Settings form object */ + readonly form = createSettingsForm(this.formBuilder, this.supportsEpg); + + /** Form array with epg sources — absent when EPG is unsupported */ + readonly epgUrl = this.form.get('epgUrl') as FormArray; + + /** Trimmed, non-empty EPG source URLs currently in the form */ + get epgUrls(): string[] { + return ((this.epgUrl?.value as string[] | undefined) ?? []) + .map((url) => url?.trim()) + .filter((url): url is string => Boolean(url)); + } + + /** Waits for the persisted settings to be available */ + loadSettings(): Promise { + return this.settingsStore.loadSettings(); + } + + /** + * Sets saved settings from the indexed db store + */ + hydrateFromStore(): void { + const currentSettings = this.settingsStore.getSettings(); + this.form.patchValue(currentSettings); + this.syncDashboardControlsEnabledState( + currentSettings.showDashboard ?? true + ); + + if (this.supportsEpg && currentSettings.epgUrl) { + this.epgUrl.clear(); + this.setEpgUrls(currentSettings.epgUrl); + } + } + + bindDashboardControlsEnabledState(): void { + this.form + .get('showDashboard') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((showDashboard) => + this.syncDashboardControlsEnabledState(showDashboard ?? true) + ); + } + + selectTheme(theme: Theme): void { + if (this.form.value.theme === theme) { + return; + } + + this.patchAndMarkDirty({ theme }, 'theme'); + this.settingsService.changeTheme(theme); + } + + selectCoverSize(coverSize: CoverSize): void { + if (this.form.value.coverSize === coverSize) { + return; + } + + this.patchAndMarkDirty({ coverSize }, 'coverSize'); + void this.settingsStore.updateSettings({ coverSize }); + } + + selectEpgViewMode(epgViewMode: EpgViewMode): void { + if (this.form.value.epgViewMode === epgViewMode) { + return; + } + + this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode'); + void this.settingsStore.updateSettings({ epgViewMode }); + } + + setRecordingFolder(recordingFolder: string): void { + this.patchAndMarkDirty({ recordingFolder }, 'recordingFolder'); + } + + /** + * Sets the epg urls to the form array + * @param epgUrls urls of the EPG sources + */ + setEpgUrls(epgUrls: string[] | string): void { + applyEpgUrlsToFormArray(this.epgUrl, epgUrls); + } + + /** + * Initializes new entry in form array for EPG URL + */ + addEpgSource(): void { + this.epgUrl.insert(this.epgUrl.length, createEpgUrlControl()); + } + + /** + * Removes entry from form array for EPG URL + * @param index index of the item to remove + */ + removeEpgSource(index: number): void { + this.epgUrl.removeAt(index); + this.form.markAsDirty(); + } + + /** + * Persists the form to the settings store and mirrors the result to the + * desktop backend. + * @param onSaved runs after the store write, before the backend is + * notified, so the UI confirmation is not delayed by IPC + */ + async save(onSaved: () => void): Promise { + const settings = createSettingsFromFormValue( + this.form, + this.settingsStore.getSettings() + ); + + await this.settingsStore.updateSettings(settings); + onSaved(); + + if (!window.electron) { + return; + } + + window.electron.updateSettings(settings); + + if (this.runtime.supportsExternalPlayerPathSettings) { + window.electron.setMpvPlayerPath(settings.mpvPlayerPath); + window.electron.setVlcPlayerPath(settings.vlcPlayerPath); + } + } + + /** Applies the saved language/theme and resets the dirty state */ + applySavedSettings(): void { + this.form.markAsPristine(); + this.translate.use(this.form.value.language ?? Language.ENGLISH); + this.settingsService.changeTheme( + this.form.value.theme ?? Theme.SystemTheme + ); + } + + private patchAndMarkDirty( + value: SettingsFormPatch, + controlName: string + ): void { + this.form.patchValue(value); + this.form.get(controlName)?.markAsDirty(); + this.form.markAsDirty(); + } + + private syncDashboardControlsEnabledState(showDashboard: boolean): void { + const dashboardRails = this.form.get('dashboardRails'); + if (!dashboardRails) { + return; + } + + if (showDashboard) { + dashboardRails.enable({ emitEvent: false }); + } else { + dashboardRails.disable({ emitEvent: false }); + } + } +} diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index 5add3d301..8df774bc1 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -100,6 +100,33 @@ export const SETTINGS_EMBEDDED_PLAYER_OPTIONS: SettingsPlayerOption[] = [ }, ]; +export interface SettingsPlayerAvailability { + supportsEmbeddedMpv: boolean; + supportsManagedExternalPlayers: boolean; +} + +/** + * Built-in web players are always offered; the OS-backed ones only show up + * when the current runtime can actually launch them. + */ +export function buildSettingsPlayerOptions({ + supportsEmbeddedMpv, + supportsManagedExternalPlayers, +}: SettingsPlayerAvailability): SettingsPlayerOption[] { + return [ + ...SETTINGS_EMBEDDED_PLAYER_OPTIONS, + ...(supportsEmbeddedMpv + ? [ + { + id: VideoPlayer.EmbeddedMpv, + labelKey: 'SETTINGS.PLAYER_EMBEDDED_MPV', + }, + ] + : []), + ...(supportsManagedExternalPlayers ? SETTINGS_OS_PLAYER_OPTIONS : []), + ]; +} + export interface SettingsSectionVisibility { supportsEpg: boolean; supportsRemoteControl: boolean; diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.ts index 2a427760a..8ec1a1fa8 100644 --- a/apps/web/src/app/settings/settings-playlist-reset.facade.ts +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.ts @@ -1,19 +1,30 @@ -import { inject, Injectable, signal } from '@angular/core'; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { firstValueFrom } from 'rxjs'; -import { PlaylistActions } from '@iptvnator/m3u-state'; +import { firstValueFrom, take } from 'rxjs'; +import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { DatabaseService, DbOperationEvent, PlaylistsService, RuntimeCapabilitiesService, } from '@iptvnator/services'; +import { + SettingsDeleteAllPlaylistsDialogComponent, + SettingsDeleteAllPlaylistsDialogData, +} from './settings-delete-all-playlists-dialog.component'; +import { + buildRemoveAllProgressLabel, + buildSettingsPlaylistDeleteSummary, +} from './settings-playlist-summary.utils'; +import { SettingsPlaylistDeleteSummary } from './settings.models'; import { SettingsSnackbarService } from './settings-snackbar.service'; @Injectable() export class SettingsPlaylistResetFacade { private readonly databaseService = inject(DatabaseService); + private readonly matDialog = inject(MatDialog); private readonly playlistsService = inject(PlaylistsService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsSnackbar = inject(SettingsSnackbarService); @@ -23,7 +34,59 @@ export class SettingsPlaylistResetFacade { readonly isRemovingAllPlaylists = signal(false); readonly removeAllProgress = signal(null); - async removeAllConfirmed( + private readonly playlists = this.store.selectSignal( + selectAllPlaylistsMeta + ); + + readonly deleteSummary = computed(() => + buildSettingsPlaylistDeleteSummary(this.playlists()) + ); + + readonly canRemoveAll = computed( + () => !this.isRemovingAllPlaylists() && this.deleteSummary().total > 0 + ); + + readonly removeAllProgressLabel = computed(() => + buildRemoveAllProgressLabel({ + isRemovingAllPlaylists: this.isRemovingAllPlaylists(), + progress: this.removeAllProgress(), + translate: (key, params) => this.translate.instant(key, params), + }) + ); + + /** + * Asks for confirmation in a dedicated dialog before wiping every + * playlist, so the summary of what is about to be lost is visible. + */ + confirmAndRemoveAll(waitForUiFeedbackFrame: () => Promise): void { + if (!this.canRemoveAll()) { + return; + } + + this.matDialog + .open< + SettingsDeleteAllPlaylistsDialogComponent, + SettingsDeleteAllPlaylistsDialogData, + boolean + >(SettingsDeleteAllPlaylistsDialogComponent, { + autoFocus: false, + data: { + summary: this.deleteSummary(), + }, + maxWidth: 'calc(100vw - 32px)', + restoreFocus: true, + width: '460px', + }) + .afterClosed() + .pipe(take(1)) + .subscribe((confirmed) => { + if (confirmed) { + void this.removeAllConfirmed(waitForUiFeedbackFrame); + } + }); + } + + private async removeAllConfirmed( waitForUiFeedbackFrame: () => Promise ): Promise { if (this.isRemovingAllPlaylists()) { diff --git a/apps/web/src/app/settings/settings-remote-control.facade.ts b/apps/web/src/app/settings/settings-remote-control.facade.ts new file mode 100644 index 000000000..6796510cd --- /dev/null +++ b/apps/web/src/app/settings/settings-remote-control.facade.ts @@ -0,0 +1,37 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; + +/** + * Backs the remote-control section: the reachable LAN addresses and which of + * them currently shows its pairing QR code. + */ +@Injectable() +export class SettingsRemoteControlFacade { + private readonly runtime = inject(RuntimeCapabilitiesService); + + /** Local IP addresses for remote control URL display */ + readonly localIpAddresses = signal([]); + + /** Currently visible QR code IP (null = none visible) */ + readonly visibleQrCodeIp = signal(null); + + /** + * Fetches local IP addresses for remote control URL display + */ + async fetchLocalIpAddresses(): Promise { + if ( + this.runtime.supportsRemoteControl && + window.electron?.getLocalIpAddresses + ) { + const addresses = await window.electron.getLocalIpAddresses(); + this.localIpAddresses.set(addresses); + } + } + + /** + * Toggle QR code visibility for a given IP address + */ + toggleQrCode(ip: string): void { + this.visibleQrCodeIp.update((visible) => (visible === ip ? null : ip)); + } +} diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 0ef375d3d..0bdbf2bd0 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -38,8 +38,8 @@ [themeOptions]="themeOptions" [coverSizeOptions]="coverSizeOptions" [startupBehaviorOptions]="startupBehaviorOptions" - (selectTheme)="selectTheme($event)" - (selectCoverSize)="selectCoverSize($event)" + (selectTheme)="form.selectTheme($event)" + (selectCoverSize)="form.selectCoverSize($event)" /> @@ -59,15 +59,15 @@ } @@ -80,9 +80,9 @@ } @@ -94,33 +94,33 @@