diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc
new file mode 100644
index 0000000..0b3bf93
--- /dev/null
+++ b/.cursor/rules/ultracite.mdc
@@ -0,0 +1,227 @@
+---
+description: Ultracite Rules - AI-Ready Formatter and Linter
+globs: "**/*.{ts,tsx,js,jsx,json,jsonc,html,vue,svelte,astro,css,yaml,yml,graphql,gql,md,mdx,grit}"
+alwaysApply: false
+---
+
+Avoid `accessKey` attr and distracting els
+No `aria-hidden="true"` on focusable els
+No ARIA roles, states, props on unsupported els
+Use `scope` prop only on `
` els
+No non-interactive ARIA roles on interactive els
+Label els need text and associated input
+No event handlers on non-interactive els
+No interactive ARIA roles on non-interactive els
+No `tabIndex` on non-interactive els
+No positive integers on `tabIndex` prop
+No `image`, `picture`, or `photo` in img alt props
+No explicit role matching implicit role
+Valid role attrs on static, visible els w/ click handlers
+Use `title` el for `svg` els
+Provide meaningful alt text for all els requiring it
+Anchors need accessible content
+Assign `tabIndex` to non-interactive els w/ `aria-activedescendant`
+Include all required ARIA attrs for els w/ ARIA roles
+Use valid ARIA props for the el's role
+Use `type` attr on `button` els
+Make els w/ interactive roles and handlers focusable
+Heading els need accessible content
+Add `lang` attr to `html` el
+Use `title` attr on `iframe` els
+Pair `onClick` w/ `onKeyUp`, `onKeyDown`, or `onKeyPress`
+Pair `onMouseOver`/`onMouseOut` w/ `onFocus`/`onBlur`
+Add caption tracks to audio and video els
+Use semantic els vs role attrs
+All anchors must be valid and navigable
+Use valid, non-abstract ARIA props, roles, states, and values
+Use valid values for `autocomplete` attr
+Use correct ISO language codes in `lang` attr
+Include generic font family in font families
+No consecutive spaces in regex literals
+Avoid `arguments`, comma op, and primitive type aliases
+No empty type params in type aliases and interfaces
+Keep fns under Cognitive Complexity limit
+Limit nesting depth of `describe()` in tests
+No unnecessary boolean casts or callbacks on `flatMap`
+Use `for...of` vs `Array.forEach`
+No classes w/ only static members
+No `this` and `super` in static contexts
+No unnecessary catch clauses, ctors, `continue`, escape sequences in regex literals, fragments, labels, or nested blocks
+No empty exports
+No renaming imports, exports, or destructured assignments to same name
+No unnecessary string/template literal concatenation or useless cases in switch stmts, `this` aliasing, or `String.raw` without escape sequences
+Use simpler alternatives to ternary ops if possible
+No `any` or `unknown` as type constraints or initializing vars to `undefined`
+Avoid `void` op
+Use arrow fns vs function exprs
+Use `Date.now()` for milliseconds since Unix Epoch
+Use `.flatMap()` vs `map().flat()`
+Use `indexOf`/`lastIndexOf` vs `findIndex`/`findLastIndex` for simple lookups
+Use literal property access vs computed property access
+Use binary, octal, or hex literals vs `parseInt()`
+Use concise optional chains vs chained logical exprs
+Use regex literals vs `RegExp` ctor
+Use base 10 or underscore separators for number literal object member names
+Remove redundant terms from logical exprs
+Use `while` loops vs `for` loops if initializer and update aren't needed
+No reassigning `const` vars or constant exprs in conditions
+No `Math.min`/`Math.max` to clamp values where result is constant
+No return values from ctors or setters
+No empty character classes in regex literals or destructuring patterns
+No `__dirname` and `__filename` in global scope
+No calling global object props as fns or declaring fns and `var` accessible outside their block
+Instantiate builtins correctly
+Use `super()` correctly in classes
+Use standard direction values for linear gradient fns
+Use valid named grid areas in CSS Grid Layouts
+Use `@import` at-rules in valid positions
+No vars and params before their decl
+Include `var` fn for CSS vars
+No `\8` and `\9` escape sequences in strings
+No literal numbers that lose precision, configured els, or assigning where both sides are same
+Compare string case modifications w/ compliant values
+No lexical decls in switch clauses or undeclared vars
+No unknown CSS value fns, media feature names, props, pseudo-class/pseudo-element selectors, type selectors, or units
+No unmatchable An+B selectors or unreachable code
+Call `super()` exactly once before accessing `this` in ctors
+No control flow stmts in `finally` blocks
+No optional chaining where `undefined` is not allowed
+No unused fn params, imports, labels, private class members, or vars
+No return values from fns w/ return type `void`
+Specify all dependencies correctly in React hooks and names for GraphQL operations
+Call React hooks from top level of component fns
+Use `isNaN()` when checking for NaN
+Use `{ type: "json" }` for JSON module imports
+Use radix arg w/ `parseInt()`
+Start JSDoc comment lines w/ single asterisk
+Move `for` loop counters in right direction
+Compare `typeof` exprs to valid values
+Include `yield` in generator fns
+No importing deprecated exports, duplicate dependencies, or Promises where they're likely a mistake
+No non-null assertions after optional chaining or shadowing vars from outer scope
+No expr stmts that aren't fn calls or assignments or useless `undefined`
+Add `href` attr to `` els and `width`/`height` attrs to ` ` els
+Use consistent arrow fn bodies and either `interface` or `type` consistently
+Specify deletion date w/ `@deprecated` directive
+Make switch-case stmts exhaustive and limit number of fn params
+Sort CSS utility classes
+No spread syntax on accumulators, barrel files, `delete` op, dynamic namespace import access, namespace imports, or duplicate polyfills from Polyfill.io
+Use `preconnect` attr w/ Google Fonts
+Declare regex literals at top level
+Add `rel="noopener"` when using `target="_blank"`
+No dangerous JSX props
+No both `children` and `dangerouslySetInnerHTML` props
+No global `eval()`
+No callbacks in async tests and hooks, TS enums, exporting imported vars, type annotations for vars initialized w/ literals, magic numbers without named constants, or TS namespaces
+No negating `if` conditions when there's an `else` clause, nested ternary exprs, non-null assertions (`!`), reassigning fn params, parameter props in class ctors, specified global var names, importing specified modules, or specified user-defined types
+No constants where value is upper-case version of name, template literals without interpolation or special chars, `else` blocks when `if` block breaks early, yoda exprs, or `Array` ctors
+Use `String.slice()` vs `String.substr()` and `String.substring()`
+Use `as const` vs literal type annotations and `at()` vs integer index access
+Follow curly brace conventions
+Use `else if` vs nested `if` in `else` clauses and single `if` vs nested `if` clauses
+Use `T[]` vs `Array`
+Use `new` for all builtins except `String`, `Number`, and `Boolean`
+Use consistent accessibility modifiers on class props and methods
+Declare object literals consistently
+Use `const` for vars only assigned once
+Put default and optional fn params last
+Include `default` clause in switch stmts
+Specify reason arg w/ `@deprecated` directive
+Explicitly initialize each enum member value
+Use `**` op vs `Math.pow`
+Use `export type` and `import type` for types
+Use kebab-case, ASCII filenames
+Use `for...of` vs `for` loops w/ array index access
+Use `<>...>` vs `...`
+Capitalize all enum values
+Place getters and setters for same prop adjacent
+Use literal values for all enum members
+Use `node:assert/strict` vs `node:assert`
+Use `node:` protocol for Node.js builtin modules
+Use `Number` props vs global ones
+Use numeric separators in numeric literals
+Use object spread vs `Object.assign()` for new objects
+Mark members `readonly` if never modified outside ctor
+No extra closing tags for comps without children
+Use assignment op shorthand
+Use fn types vs object types w/ call signatures
+Add description param to `Symbol()`
+Use template literals vs string concatenation
+Use `new` when throwing an error
+No throwing non-`Error` values
+Use `String.trimStart()`/`String.trimEnd()` vs `String.trimLeft()`/`String.trimRight()`
+No overload signatures that can be unified
+No lower specificity selectors after higher specificity selectors
+No `@value` rule in CSS modules
+No `alert`, `confirm`, and `prompt`
+Use standard constants vs approximated literals
+No assigning in exprs
+No async fns as Promise executors
+No `!` pattern in first position of `files.includes`
+No bitwise ops
+No reassigning exceptions in catch clauses
+No reassigning class members
+No inserting comments as text nodes
+No comparing against `-0`
+No labeled stmts that aren't loops
+No `void` type outside generic or return types
+No `console`
+No TS const enums
+No exprs where op doesn't affect value
+No control chars in regex literals
+No `debugger`
+No assigning directly to `document.cookie`
+Use `===` and `!==`
+No duplicate `@import` rules, case labels, class members, custom props, conditions in if-else-if chains, GraphQL fields, font family names, object keys, fn param names, decl block props, keyframe selectors, or describe hooks
+No empty CSS blocks, block stmts, static blocks, or interfaces
+No letting vars evolve into `any` type through reassignments
+No `any` type
+No `export` or `module.exports` in test files
+No misusing non-null assertion op (`!`)
+No fallthrough in switch clauses
+No focused or disabled tests
+No reassigning fn decls
+No assigning to native objects and read-only global vars
+Use `Number.isFinite` and `Number.isNaN` vs global `isFinite` and `isNaN`
+No implicit `any` type on var decls
+No assigning to imported bindings
+No `!important` within keyframe decls
+No irregular whitespace chars
+No labels that share name w/ var
+No chars made w/ multiple code points in char classes
+Use `new` and `constructor` properly
+Place assertion fns inside `it()` fn calls
+No shorthand assign when var appears on both sides
+No octal escape sequences in strings
+No `Object.prototype` builtins directly
+No `quickfix.biome` in editor settings
+No redeclaring vars, fns, classes, and types in same scope
+No redundant `use strict`
+No comparing where both sides are same
+No shadowing restricted names
+No shorthand props that override related longhand props
+No sparse arrays
+No template literal placeholder syntax in regular strings
+No `then` prop
+No `@ts-ignore` directive
+No `let` or `var` vars that are read but never assigned
+No unknown at-rules
+No merging interface and class decls unsafely
+No unsafe negation (`!`)
+No unnecessary escapes in strings or useless backreferences in regex literals
+No `var`
+No `with` stmts
+No separating overload signatures
+Use `await` in async fns
+Use correct syntax for ignoring folders in config
+Put default clauses in switch stmts last
+Pass message value when creating built-in errors
+Return value from get methods
+Use recommended display strategy w/ Google Fonts
+Include `if` stmt in for-in loops
+Use `Array.isArray()` vs `instanceof Array`
+Return consistent values in iterable callbacks
+Use `namespace` keyword vs `module` keyword
+Use digits arg w/ `Number#toFixed()`
+Use static `Response` methods vs `new Response()`
+Use `use strict` directive in script files
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 3a70dd7..4460cdc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@ node_modules
# Results
results.json
+benchmarks/**/traces
# Testing
coverage
@@ -42,4 +43,4 @@ yarn-error.log*
test-results
-playwright-report
\ No newline at end of file
+.pnpm-store
diff --git a/METHODOLOGY.md b/METHODOLOGY.md
new file mode 100644
index 0000000..64a7d39
--- /dev/null
+++ b/METHODOLOGY.md
@@ -0,0 +1,290 @@
+# Benchmark Methodology
+
+This document explains how CookieBench measures and evaluates cookie banner performance. Our methodology prioritizes transparency, reproducibility, and real-world user experience.
+
+## Introduction
+
+CookieBench measures the performance impact of cookie consent solutions on web applications. We focus on metrics that directly impact user experience, following industry standards like Core Web Vitals and Lighthouse.
+
+## Measurement Approach
+
+### Dual Banner Timing Modes (DOM Presence vs User-Visible)
+
+Every benchmark run collects **both** timing modes for the cookie banner:
+
+**DOM presence time** (technical render):
+
+- Measures when the banner element first appears in the DOM
+- Recorded when the element has dimensions (`width > 0`, `height > 0`) and is not hidden
+- Exposed in results as `domPresenceTime` (alias: `bannerRenderTime` / `renderStart`)
+- Tracked for reference and displayed alongside user-visible time; **not used for scoring**
+
+**User-visible time** (opacity-threshold visibility):
+
+- Measures when the banner becomes visible to users (opacity > 0.5)
+- Uses opacity threshold of **0.5** to account for CSS animations and transitions
+- Exposed in results as `userVisibleTime` (alias: `bannerVisibilityTime` / `visibilityTime`)
+- **This metric is used for scoring and primary comparisons** by default
+
+**Why This Distinction Matters**:
+
+- A banner that renders instantly but fades in slowly should score differently than one that renders slower but is immediately visible
+- CSS animations can delay user-perceived visibility even after technical rendering
+- Measuring visibility aligns with Core Web Vitals' focus on user experience
+
+### Opacity Threshold
+
+We use an opacity threshold of **0.5** (50% opacity) to determine visibility:
+
+- An element with opacity ≤ 0.5 is considered not visible to users
+- An element with opacity > 0.5 is considered visible
+- This accounts for CSS fade-in animations that gradually reveal the banner
+- This threshold balances technical accuracy with user perception
+
+### Measurement Baseline
+
+All timing measurements start from `navigationStart`:
+
+- Includes Time to First Byte (TTFB)
+- Includes server response time
+- Includes network latency
+- Aligns with Core Web Vitals measurement standards
+- Represents total time from page load initiation to metric completion
+
+This means results reflect the complete user experience, including server-side performance and network conditions.
+
+## Network Conditions
+
+### Current Implementation
+
+Currently, benchmarks run on **localhost** with **no network throttling**:
+
+- Ideal network conditions (~9-35ms TTFB)
+- No bandwidth limitations
+- No latency simulation
+- Results reflect optimal performance
+
+**Impact on Results**:
+
+- Results represent best-case performance
+- Real-world performance will vary based on network conditions
+- Differences between implementations are isolated from network variability
+- Fair comparison when all implementations tested under same conditions
+
+### Future Considerations
+
+We are evaluating network throttling options:
+
+- **Cable Profile** (5 Mbps, 28ms latency): Minimal impact, maintains speed
+- **Fast 3G** (1.6 Mbps, 562ms latency): More realistic, significantly impacts TTFB
+- **Slow 4G**: Balanced approach for modern connections
+
+Any future changes to network conditions will be clearly documented and versioned.
+
+## Metrics Tracked
+
+### Primary Metrics (Used for Scoring)
+
+**User-visible time** (used for scoring):
+
+- Time from `navigationStart` until banner opacity > 0.5
+- Primary metric for UX scoring; scoring is based on this value, not DOM presence time
+- Accounts for CSS animations
+- Measured in milliseconds
+- Both DOM presence and user-visible times are collected each run and shown in CLI results
+
+**Banner Interactive Time**:
+
+- Time from `navigationStart` until banner buttons become clickable
+- Measures when users can actually interact with the banner
+- Uses `offsetParent` check to verify clickability
+
+**Layout Shift Impact**:
+
+- Cumulative Layout Shift (CLS) caused by banner appearance
+- Measures visual stability impact
+- Lower is better (0 = no layout shift)
+
+**Viewport Coverage**:
+
+- Percentage of viewport covered by banner
+- Calculated as: `(visibleArea / viewportArea) * 100`
+- Accounts for partial visibility (banners extending beyond viewport)
+
+**Network Impact**:
+
+- Total size of banner-related network requests
+- Number of network requests
+- Download time for banner resources
+
+### Secondary Metrics (Tracked for Reference)
+
+**Banner Render Time**:
+
+- Technical render time (when element appears in DOM)
+- Not used for scoring
+- Available for technical analysis
+
+**Banner Hydration Time**:
+
+- Time from render to interactive: `interactiveTime - renderTime`
+- Measures JavaScript execution and event binding time
+- Useful for understanding banner implementation performance
+
+## Scoring Methodology
+
+### How Scores Are Calculated
+
+Our scoring system evaluates multiple categories:
+
+**Performance Score** (40% weight):
+
+- First Contentful Paint (FCP)
+- Largest Contentful Paint (LCP)
+- Cumulative Layout Shift (CLS)
+- Time to Interactive (TTI)
+- Total Blocking Time (TBT)
+- Time to First Byte (TTFB)
+
+**Bundle Strategy Score** (25% weight):
+
+- Bundle type (Bundled vs IIFE)
+- Third-party dependencies
+- Bundler type
+- TypeScript usage
+
+**Network Impact Score** (20% weight):
+
+- Total bundle size
+- Third-party size
+- Network requests count
+- Script load time
+
+**Transparency Score** (10% weight):
+
+- Open-source status
+- Company information
+- Tech stack transparency
+
+**User Experience Score** (5% weight):
+
+- Layout stability (CLS)
+- Banner render time (uses visibility time)
+- Viewport coverage
+
+### Banner Visibility Time Scoring
+
+Banner visibility time is scored within the User Experience category:
+
+- **≤ 25ms**: Excellent (35 points)
+- **≤ 50ms**: Very Good (25 points)
+- **≤ 100ms**: Good (15 points)
+- **≤ 200ms**: Fair (10 points)
+- **> 200ms**: Poor (5 points)
+
+**Note**: Scoring uses `bannerVisibilityTime` (opacity-based), not `bannerRenderTime` (technical). This ensures scores reflect actual user experience.
+
+## Reproducibility
+
+### Configuration Requirements
+
+Each benchmark requires a `config.json` file with:
+
+- Cookie banner selectors (CSS selectors to detect the banner)
+- Service hosts (domains used by the cookie service)
+- Tech stack information
+- Iteration count
+
+### Running Benchmarks
+
+```bash
+# Run benchmarks
+pnpm benchmark
+
+# View results
+pnpm results
+```
+
+### What Affects Results
+
+**Consistent Factors**:
+
+- Browser engine (Chromium)
+- Viewport size (1280x720)
+- Detection selectors
+- Measurement methodology
+
+**Variable Factors**:
+
+- Server performance (if testing SSR)
+- System load
+- Network conditions (currently localhost)
+
+**Best Practices**:
+
+- Run multiple iterations (default: 20)
+- Ensure consistent server state
+- Clear browser cache between runs
+- Use same environment for all comparisons
+
+## Limitations
+
+### Current Limitations
+
+1. **Network Conditions**:
+ - No throttling means results reflect optimal conditions
+ - Real-world performance will vary
+ - Future: Network throttling support
+
+2. **Measurement Window**:
+ - Some metrics collected over 1-second window
+ - CLS may need longer observation periods
+ - Future: Configurable observation windows
+
+3. **Banner Detection**:
+ - Requires accurate CSS selectors
+ - May miss dynamically loaded banners
+ - Detection timeout: 10 seconds
+
+4. **Animation Timing**:
+ - Opacity threshold may not account for all animation types
+ - Transform-based animations not measured
+ - Future: Enhanced animation detection
+
+### Future Improvements
+
+- Network throttling support
+- More granular animation detection
+- Configurable observation windows
+- Enhanced banner detection algorithms
+- Real-world performance simulation
+
+## Transparency Statement
+
+We believe in transparent benchmarking:
+
+- **Open Methodology**: This document explains exactly how we measure
+- **Open Source**: Benchmark code is available for review
+- **Reproducible**: Anyone can run the same benchmarks
+- **Honest Metrics**: We measure user-perceived visibility, not just technical render time
+- **Clear Limitations**: We document what we measure and what we don't
+
+## Industry Standards Alignment
+
+Our methodology aligns with:
+
+- **Core Web Vitals**: User-centric performance metrics
+- **Lighthouse**: Measurement approaches and thresholds
+- **WebPageTest**: Performance testing practices
+- **W3C Performance Timeline**: Standard timing APIs
+
+We measure what matters to users, not just what's technically possible.
+
+## Questions or Feedback
+
+If you have questions about our methodology or suggestions for improvement, please open an issue on our GitHub repository.
+
+---
+
+**Last Updated**: 2026-02-16
+**Version**: 2.0
diff --git a/README.md b/README.md
index a4e888f..2cc2864 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,8 @@ A benchmarking tool for measuring the performance impact of cookie consent solut
This tool measures various performance metrics when loading web applications with different cookie consent solutions. It helps developers understand the performance implications of their cookie consent implementation choices.
+For detailed information about how benchmarks are measured, see [METHODOLOGY.md](./METHODOLOGY.md).
+
## Metrics Measured
### Core Web Vitals
@@ -17,7 +19,11 @@ This tool measures various performance metrics when loading web applications wit
### Additional Metrics
- **Total Time**: Complete page load time
- **Script Load Time**: Time taken to load and execute JavaScript
-- **Banner Render Time**: Time taken to render the cookie consent banner
+- **DOM presence time**: Technical render time (when element appears in DOM); collected every run
+- **User-visible time**: When banner is visible to users (opacity > 0.5); **used for scoring**; collected every run
+- **Banner Interactive Time**: Time until banner buttons become clickable
+
+Both DOM presence and user-visible timings are collected in every run and shown in results. Scoring uses **user-visible time** by default. See [METHODOLOGY.md](./METHODOLOGY.md) for details.
### Resource Size Metrics
- **Total Size**: Combined size of all resources
@@ -41,7 +47,8 @@ This tool measures various performance metrics when loading web applications wit
2. **Performance Metrics**:
- Metrics are collected over a 1-second window after page load
- Some metrics (like CLS) may need longer observation periods
- - Network conditions are not simulated
+ - Network conditions are not simulated (benchmarks run on localhost)
+ - See [METHODOLOGY.md](./METHODOLOGY.md) for detailed limitations and measurement approach
## Usage
@@ -93,7 +100,7 @@ Each benchmark implementation requires a `config.json` file with the following s
- `frameworks`: Array of frameworks used
- `bundler`: Build tool used (e.g., webpack, vite)
- `packageManager`: Package manager used (e.g., npm, pnpm)
- - `bundleType`: Output format (esm, iffe, cjs)
+ - `bundleType`: Output format (esm, cjs, iife, bundled)
- `typescript`: Whether TypeScript is used
- **source**: Project source information
diff --git a/benchmarks/baseline/config.json b/benchmarks/baseline/config.json
index 271d784..c2d88ef 100644
--- a/benchmarks/baseline/config.json
+++ b/benchmarks/baseline/config.json
@@ -26,5 +26,19 @@
"includes": {
"components": ["javascript"]
},
- "tags": ["baseline"]
+ "tags": ["baseline"],
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 1,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
+ },
+ "internationalization": {
+ "detection": "none",
+ "stringLoading": "none"
+ }
}
diff --git a/benchmarks/baseline/next-env.d.ts b/benchmarks/baseline/next-env.d.ts
index 1b3be08..9edff1c 100644
--- a/benchmarks/baseline/next-env.d.ts
+++ b/benchmarks/baseline/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/baseline/package.json b/benchmarks/baseline/package.json
index 973edfd..640cb87 100644
--- a/benchmarks/baseline/package.json
+++ b/benchmarks/baseline/package.json
@@ -3,25 +3,26 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
- "build": "next build ",
+ "build": "next build",
"dev": "next dev --port 3000",
"fmt": "biome format . --write",
"lint": "biome lint .",
"start": "next start"
},
"dependencies": {
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
}
}
diff --git a/benchmarks/with-c15t-nextjs/app/favicon.ico b/benchmarks/c15t-nextjs/app/favicon.ico
similarity index 100%
rename from benchmarks/with-c15t-nextjs/app/favicon.ico
rename to benchmarks/c15t-nextjs/app/favicon.ico
diff --git a/benchmarks/with-c15t-nextjs/app/layout.tsx b/benchmarks/c15t-nextjs/app/layout.tsx
similarity index 100%
rename from benchmarks/with-c15t-nextjs/app/layout.tsx
rename to benchmarks/c15t-nextjs/app/layout.tsx
diff --git a/benchmarks/with-c15t-nextjs/app/page.tsx b/benchmarks/c15t-nextjs/app/page.tsx
similarity index 100%
rename from benchmarks/with-c15t-nextjs/app/page.tsx
rename to benchmarks/c15t-nextjs/app/page.tsx
diff --git a/benchmarks/with-c15t-nextjs/config.json b/benchmarks/c15t-nextjs/config.json
similarity index 82%
rename from benchmarks/with-c15t-nextjs/config.json
rename to benchmarks/c15t-nextjs/config.json
index 9aabb6b..cfc941e 100644
--- a/benchmarks/with-c15t-nextjs/config.json
+++ b/benchmarks/c15t-nextjs/config.json
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
- "name": "with-c15t-nextjs",
+ "name": "c15t-nextjs",
"iterations": 20,
"cookieBanner": {
"selectors": [
@@ -44,5 +44,15 @@
"website": "https://c15t.com",
"avatar": "https://zxlypdluowixfd7j.public.blob.vercel-storage.com/c15t-icon-z6gQxO0ogxWgY51dluWPhNfAoPyELT.png"
},
- "tags": ["c15t"]
+ "tags": ["c15t"],
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 16,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
+ }
}
diff --git a/benchmarks/with-c15t-nextjs/next-env.d.ts b/benchmarks/c15t-nextjs/next-env.d.ts
similarity index 100%
rename from benchmarks/with-c15t-nextjs/next-env.d.ts
rename to benchmarks/c15t-nextjs/next-env.d.ts
diff --git a/benchmarks/c15t-nextjs/next.config.ts b/benchmarks/c15t-nextjs/next.config.ts
new file mode 100644
index 0000000..dd8b328
--- /dev/null
+++ b/benchmarks/c15t-nextjs/next.config.ts
@@ -0,0 +1,15 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ images: {
+ localPatterns: [
+ {
+ pathname: "/**",
+ search: "",
+ },
+ ],
+ minimumCacheTTL: 60,
+ },
+};
+
+export default nextConfig;
diff --git a/benchmarks/with-cookie-control/package.json b/benchmarks/c15t-nextjs/package.json
similarity index 51%
rename from benchmarks/with-cookie-control/package.json
rename to benchmarks/c15t-nextjs/package.json
index d85d95f..9a60163 100644
--- a/benchmarks/with-cookie-control/package.json
+++ b/benchmarks/c15t-nextjs/package.json
@@ -1,8 +1,8 @@
{
- "name": "with-cookie-control",
+ "name": "c15t-nextjs",
+ "version": "0.1.0",
"private": true,
"scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
"build": "next build",
"dev": "next dev --port 3001",
"fmt": "biome format . --write",
@@ -10,17 +10,20 @@
"start": "next start"
},
"dependencies": {
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
+ "@c15t/nextjs": "1.8.3",
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
}
}
diff --git a/benchmarks/with-c15t-nextjs/tsconfig.json b/benchmarks/c15t-nextjs/tsconfig.json
similarity index 100%
rename from benchmarks/with-c15t-nextjs/tsconfig.json
rename to benchmarks/c15t-nextjs/tsconfig.json
diff --git a/benchmarks/with-c15t-react/app/favicon.ico b/benchmarks/c15t-react/app/favicon.ico
similarity index 100%
rename from benchmarks/with-c15t-react/app/favicon.ico
rename to benchmarks/c15t-react/app/favicon.ico
diff --git a/benchmarks/with-c15t-react/app/layout.tsx b/benchmarks/c15t-react/app/layout.tsx
similarity index 100%
rename from benchmarks/with-c15t-react/app/layout.tsx
rename to benchmarks/c15t-react/app/layout.tsx
diff --git a/benchmarks/with-c15t-react/app/page.tsx b/benchmarks/c15t-react/app/page.tsx
similarity index 100%
rename from benchmarks/with-c15t-react/app/page.tsx
rename to benchmarks/c15t-react/app/page.tsx
diff --git a/benchmarks/with-c15t-react/config.json b/benchmarks/c15t-react/config.json
similarity index 74%
rename from benchmarks/with-c15t-react/config.json
rename to benchmarks/c15t-react/config.json
index b174fc7..27ced14 100644
--- a/benchmarks/with-c15t-react/config.json
+++ b/benchmarks/c15t-react/config.json
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
- "name": "with-c15t-react",
+ "name": "c15t-react",
"iterations": 20,
"cookieBanner": {
"selectors": [
@@ -10,7 +10,11 @@
".consent-banner",
"[data-consent-banner]"
],
- "serviceHosts": ["c15t.com", "consent.io"],
+ "serviceHosts": [
+ "c15t.com",
+ "consent.io",
+ "consent-io-europe-benchmarks.c15t.dev"
+ ],
"waitForVisibility": true,
"measureViewportCoverage": true,
"expectedLayoutShift": false,
@@ -28,7 +32,7 @@
"github": "github.com/c15t/c15t",
"isOpenSource": true,
"license": "GPL-3.0-only",
- "npm": "@c15t/nextjs",
+ "npm": "@c15t/react@1.7.1",
"website": "https://www.c15t.com"
},
"includes": {
@@ -44,5 +48,15 @@
"website": "https://c15t.com",
"avatar": "https://zxlypdluowixfd7j.public.blob.vercel-storage.com/c15t-icon-z6gQxO0ogxWgY51dluWPhNfAoPyELT.png"
},
- "tags": ["c15t"]
+ "tags": ["c15t"],
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 16,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
+ }
}
diff --git a/benchmarks/with-cookie-control/next-env.d.ts b/benchmarks/c15t-react/next-env.d.ts
similarity index 85%
rename from benchmarks/with-cookie-control/next-env.d.ts
rename to benchmarks/c15t-react/next-env.d.ts
index 1b3be08..9edff1c 100644
--- a/benchmarks/with-cookie-control/next-env.d.ts
+++ b/benchmarks/c15t-react/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-c15t-nextjs/next.config.ts b/benchmarks/c15t-react/next.config.ts
similarity index 100%
rename from benchmarks/with-c15t-nextjs/next.config.ts
rename to benchmarks/c15t-react/next.config.ts
diff --git a/benchmarks/c15t-react/package.json b/benchmarks/c15t-react/package.json
new file mode 100644
index 0000000..9052679
--- /dev/null
+++ b/benchmarks/c15t-react/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "c15t-react",
+ "private": true,
+ "scripts": {
+ "build": "next build",
+ "dev": "next dev --port 3003",
+ "fmt": "biome format . --write",
+ "lint": "biome lint .",
+ "start": "next start --port 3003"
+ },
+ "dependencies": {
+ "@c15t/react": "1.7.1",
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
+ },
+ "devDependencies": {
+ "@cookiebench/benchmark-schema": "workspace:*",
+ "@cookiebench/ts-config": "workspace:*",
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ }
+}
diff --git a/benchmarks/with-c15t-react/tsconfig.json b/benchmarks/c15t-react/tsconfig.json
similarity index 100%
rename from benchmarks/with-c15t-react/tsconfig.json
rename to benchmarks/c15t-react/tsconfig.json
diff --git a/benchmarks/with-cookie-control/app/favicon.ico b/benchmarks/cookie-control/app/favicon.ico
similarity index 100%
rename from benchmarks/with-cookie-control/app/favicon.ico
rename to benchmarks/cookie-control/app/favicon.ico
diff --git a/benchmarks/with-cookie-control/app/layout.tsx b/benchmarks/cookie-control/app/layout.tsx
similarity index 83%
rename from benchmarks/with-cookie-control/app/layout.tsx
rename to benchmarks/cookie-control/app/layout.tsx
index 505cff4..beb311a 100644
--- a/benchmarks/with-cookie-control/app/layout.tsx
+++ b/benchmarks/cookie-control/app/layout.tsx
@@ -1,8 +1,8 @@
-import type { Metadata } from 'next';
-import type { ReactNode } from 'react';
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
export const metadata: Metadata = {
- title: 'benchmark',
+ title: "benchmark",
};
export default function RootLayout({
@@ -19,7 +19,7 @@ export default function RootLayout({
type="text/javascript"
/>
{children}
diff --git a/benchmarks/with-cookie-control/app/page.tsx b/benchmarks/cookie-control/app/page.tsx
similarity index 100%
rename from benchmarks/with-cookie-control/app/page.tsx
rename to benchmarks/cookie-control/app/page.tsx
diff --git a/benchmarks/cookie-control/config.json b/benchmarks/cookie-control/config.json
new file mode 100644
index 0000000..6798d1e
--- /dev/null
+++ b/benchmarks/cookie-control/config.json
@@ -0,0 +1,50 @@
+{
+ "$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
+ "name": "cookie-control",
+ "id": "cookie-control",
+ "iterations": 20,
+ "cookieBanner": {
+ "selectors": [".ccc-module--slideout"],
+ "serviceHosts": ["civiccomputing.com"],
+ "waitForVisibility": true,
+ "measureViewportCoverage": true,
+ "expectedLayoutShift": true,
+ "serviceName": "Cookie Control"
+ },
+ "internationalization": {
+ "detection": "none",
+ "stringLoading": "none"
+ },
+ "techStack": {
+ "bundler": "unknown",
+ "bundleType": "iife",
+ "frameworks": [],
+ "languages": ["javascript"],
+ "packageManager": "unknown",
+ "typescript": false
+ },
+ "source": {
+ "isOpenSource": false,
+ "license": "proprietary",
+ "website": "https://www.civicuk.com/cookie-control/"
+ },
+ "company": {
+ "name": "Cookie Control",
+ "website": "https://www.civicuk.com/cookie-control/",
+ "avatar": "https://www.civicuk.com/cookie-control/images/favicon.png"
+ },
+ "includes": {
+ "backend": ["proprietary"],
+ "components": ["javascript"]
+ },
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 16,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
+ }
+}
diff --git a/benchmarks/with-didomi/next-env.d.ts b/benchmarks/cookie-control/next-env.d.ts
similarity index 85%
rename from benchmarks/with-didomi/next-env.d.ts
rename to benchmarks/cookie-control/next-env.d.ts
index 1b3be08..9edff1c 100644
--- a/benchmarks/with-didomi/next-env.d.ts
+++ b/benchmarks/cookie-control/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-c15t-react/next.config.ts b/benchmarks/cookie-control/next.config.ts
similarity index 100%
rename from benchmarks/with-c15t-react/next.config.ts
rename to benchmarks/cookie-control/next.config.ts
diff --git a/benchmarks/with-enzuzo/package.json b/benchmarks/cookie-control/package.json
similarity index 52%
rename from benchmarks/with-enzuzo/package.json
rename to benchmarks/cookie-control/package.json
index 6dfbb46..ba6e68a 100644
--- a/benchmarks/with-enzuzo/package.json
+++ b/benchmarks/cookie-control/package.json
@@ -1,8 +1,7 @@
{
- "name": "with-enzuzo",
+ "name": "cookie-control",
"private": true,
"scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
"build": "next build",
"dev": "next dev --port 3001",
"fmt": "biome format . --write",
@@ -10,17 +9,19 @@
"start": "next start"
},
"dependencies": {
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
}
}
diff --git a/benchmarks/with-cookie-yes/tsconfig.json b/benchmarks/cookie-control/tsconfig.json
similarity index 100%
rename from benchmarks/with-cookie-yes/tsconfig.json
rename to benchmarks/cookie-control/tsconfig.json
diff --git a/benchmarks/with-cookie-yes/app/favicon.ico b/benchmarks/cookie-yes/app/favicon.ico
similarity index 100%
rename from benchmarks/with-cookie-yes/app/favicon.ico
rename to benchmarks/cookie-yes/app/favicon.ico
diff --git a/benchmarks/with-cookie-yes/app/layout.tsx b/benchmarks/cookie-yes/app/layout.tsx
similarity index 100%
rename from benchmarks/with-cookie-yes/app/layout.tsx
rename to benchmarks/cookie-yes/app/layout.tsx
diff --git a/benchmarks/with-cookie-yes/app/page.tsx b/benchmarks/cookie-yes/app/page.tsx
similarity index 100%
rename from benchmarks/with-cookie-yes/app/page.tsx
rename to benchmarks/cookie-yes/app/page.tsx
diff --git a/benchmarks/with-cookie-yes/config.json b/benchmarks/cookie-yes/config.json
similarity index 76%
rename from benchmarks/with-cookie-yes/config.json
rename to benchmarks/cookie-yes/config.json
index b084c19..a4f231f 100644
--- a/benchmarks/with-cookie-yes/config.json
+++ b/benchmarks/cookie-yes/config.json
@@ -1,11 +1,11 @@
{
"$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
- "name": "with-cookie-yes",
+ "name": "cookie-yes",
"id": "cookie-yes-banner",
"iterations": 20,
"remote": {
"enabled": true,
- "url": "https://benchmarks-with-cookie-yes.vercel.app"
+ "url": "https://benchmarks-cookie-yes.vercel.app"
},
"cookieBanner": {
"selectors": [".cky-consent-container"],
@@ -21,7 +21,7 @@
},
"techStack": {
"bundler": "unknown",
- "bundleType": "iffe",
+ "bundleType": "iife",
"frameworks": [],
"languages": ["javascript"],
"packageManager": "unknown",
@@ -40,5 +40,15 @@
"includes": {
"backend": ["proprietary"],
"components": ["javascript"]
+ },
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 16,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
}
}
diff --git a/benchmarks/with-cookie-yes/next-env.d.ts b/benchmarks/cookie-yes/next-env.d.ts
similarity index 85%
rename from benchmarks/with-cookie-yes/next-env.d.ts
rename to benchmarks/cookie-yes/next-env.d.ts
index 1b3be08..9edff1c 100644
--- a/benchmarks/with-cookie-yes/next-env.d.ts
+++ b/benchmarks/cookie-yes/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/cookie-yes/next.config.ts b/benchmarks/cookie-yes/next.config.ts
new file mode 100644
index 0000000..b99d587
--- /dev/null
+++ b/benchmarks/cookie-yes/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ typedRoutes: true,
+};
+
+export default nextConfig;
diff --git a/benchmarks/with-iubenda/package.json b/benchmarks/cookie-yes/package.json
similarity index 52%
rename from benchmarks/with-iubenda/package.json
rename to benchmarks/cookie-yes/package.json
index f0659d2..cc4a290 100644
--- a/benchmarks/with-iubenda/package.json
+++ b/benchmarks/cookie-yes/package.json
@@ -1,8 +1,7 @@
{
- "name": "with-iubenda",
+ "name": "cookie-yes",
"private": true,
"scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
"build": "next build",
"dev": "next dev --port 3001",
"fmt": "biome format . --write",
@@ -10,17 +9,19 @@
"start": "next start"
},
"dependencies": {
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
}
}
diff --git a/benchmarks/with-didomi/tsconfig.json b/benchmarks/cookie-yes/tsconfig.json
similarity index 100%
rename from benchmarks/with-didomi/tsconfig.json
rename to benchmarks/cookie-yes/tsconfig.json
diff --git a/benchmarks/didomi/app/didomi-client.tsx b/benchmarks/didomi/app/didomi-client.tsx
new file mode 100644
index 0000000..e1b59bc
--- /dev/null
+++ b/benchmarks/didomi/app/didomi-client.tsx
@@ -0,0 +1,84 @@
+/** biome-ignore-all lint/suspicious/noConsole: its okay to show it working */
+"use client";
+
+import { DidomiSDK, type IDidomiObject } from "@didomi/react";
+import { useCallback, useState } from "react";
+
+const DEBUG_DIDOMI =
+ process.env.NEXT_PUBLIC_DEBUG_DIDOMI === "true" ||
+ process.env.DEBUG_DIDOMI === "true";
+const TOKEN_VISIBLE_CHARS = 4;
+
+function redactToken(token: string): string {
+ if (!token) {
+ return "****";
+ }
+ const last4 = token.slice(-TOKEN_VISIBLE_CHARS);
+ return `****${last4}`;
+}
+
+export function DidomiClient() {
+ const [didomiObject, setDidomiObject] = useState(null);
+
+ const onDidomiReady = useCallback((didomi: IDidomiObject) => {
+ setDidomiObject(didomi);
+ if (!DEBUG_DIDOMI) {
+ return;
+ }
+
+ console.log("Didomi ready", {
+ consentRequired: didomi.isConsentRequired(),
+ vendor1Consent: didomi.getUserConsentStatusForVendor(1)
+ ? "granted"
+ : "denied",
+ vendor1CookiesConsent: didomi.getUserConsentStatus("cookies", 1)
+ ? "granted"
+ : "denied",
+ });
+ }, []);
+
+ const onConsentChanged = useCallback(
+ (cwtToken: string) => {
+ if (!(didomiObject && DEBUG_DIDOMI)) {
+ return;
+ }
+
+ console.log("Didomi consent changed", {
+ cwtToken: redactToken(cwtToken),
+ consentRequired: didomiObject.isConsentRequired(),
+ vendor1Consent: didomiObject.getUserConsentStatusForVendor(1)
+ ? "granted"
+ : "denied",
+ vendor1CookiesConsent: didomiObject.getUserConsentStatus("cookies", 1)
+ ? "granted"
+ : "denied",
+ });
+ },
+ [didomiObject]
+ );
+
+ const handleNoticeHidden = useCallback(() => {
+ if (DEBUG_DIDOMI) {
+ console.log("Didomi notice hidden");
+ }
+ }, []);
+
+ const handleNoticeShown = useCallback(() => {
+ if (DEBUG_DIDOMI) {
+ console.log("Didomi notice shown");
+ }
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/benchmarks/with-didomi/app/favicon.ico b/benchmarks/didomi/app/favicon.ico
similarity index 100%
rename from benchmarks/with-didomi/app/favicon.ico
rename to benchmarks/didomi/app/favicon.ico
diff --git a/benchmarks/didomi/app/layout.tsx b/benchmarks/didomi/app/layout.tsx
new file mode 100644
index 0000000..dfec271
--- /dev/null
+++ b/benchmarks/didomi/app/layout.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+import { DidomiClient } from "./didomi-client";
+
+export const metadata: Metadata = {
+ title: "benchmark",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: ReactNode;
+}>) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/benchmarks/with-didomi/app/page.tsx b/benchmarks/didomi/app/page.tsx
similarity index 100%
rename from benchmarks/with-didomi/app/page.tsx
rename to benchmarks/didomi/app/page.tsx
diff --git a/benchmarks/with-didomi/config.json b/benchmarks/didomi/config.json
similarity index 83%
rename from benchmarks/with-didomi/config.json
rename to benchmarks/didomi/config.json
index c007ba3..68266e4 100644
--- a/benchmarks/with-didomi/config.json
+++ b/benchmarks/didomi/config.json
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
- "name": "with-didomi",
+ "name": "didomi",
"iterations": 20,
"cookieBanner": {
"selectors": [
@@ -45,5 +45,15 @@
"includes": {
"backend": ["proprietary"],
"components": ["javascript", "react"]
+ },
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 16,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
}
}
diff --git a/benchmarks/with-c15t-react/next-env.d.ts b/benchmarks/didomi/next-env.d.ts
similarity index 85%
rename from benchmarks/with-c15t-react/next-env.d.ts
rename to benchmarks/didomi/next-env.d.ts
index 1b3be08..9edff1c 100644
--- a/benchmarks/with-c15t-react/next-env.d.ts
+++ b/benchmarks/didomi/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-cookie-yes/next.config.ts b/benchmarks/didomi/next.config.ts
similarity index 100%
rename from benchmarks/with-cookie-yes/next.config.ts
rename to benchmarks/didomi/next.config.ts
diff --git a/benchmarks/with-didomi/package.json b/benchmarks/didomi/package.json
similarity index 56%
rename from benchmarks/with-didomi/package.json
rename to benchmarks/didomi/package.json
index a5873b8..23ed359 100644
--- a/benchmarks/with-didomi/package.json
+++ b/benchmarks/didomi/package.json
@@ -1,8 +1,7 @@
{
- "name": "with-dodomi",
+ "name": "didomi",
"private": true,
"scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
"build": "next build",
"dev": "next dev",
"fmt": "biome format . --write",
@@ -11,16 +10,19 @@
},
"dependencies": {
"@didomi/react": "^1.8.8",
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
}
}
diff --git a/benchmarks/with-iubenda/tsconfig.json b/benchmarks/didomi/tsconfig.json
similarity index 100%
rename from benchmarks/with-iubenda/tsconfig.json
rename to benchmarks/didomi/tsconfig.json
diff --git a/benchmarks/with-enzuzo/app/favicon.ico b/benchmarks/enzuzo/app/favicon.ico
similarity index 100%
rename from benchmarks/with-enzuzo/app/favicon.ico
rename to benchmarks/enzuzo/app/favicon.ico
diff --git a/benchmarks/enzuzo/app/layout.tsx b/benchmarks/enzuzo/app/layout.tsx
new file mode 100644
index 0000000..dc03277
--- /dev/null
+++ b/benchmarks/enzuzo/app/layout.tsx
@@ -0,0 +1,25 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+
+export const metadata: Metadata = {
+ title: "benchmark",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: ReactNode;
+}>) {
+ return (
+
+
+ {/* Cookie Control Script */}
+
+
+ {children}
+
+ );
+}
diff --git a/benchmarks/with-enzuzo/app/page.tsx b/benchmarks/enzuzo/app/page.tsx
similarity index 100%
rename from benchmarks/with-enzuzo/app/page.tsx
rename to benchmarks/enzuzo/app/page.tsx
diff --git a/benchmarks/enzuzo/config.json b/benchmarks/enzuzo/config.json
new file mode 100644
index 0000000..f24f89f
--- /dev/null
+++ b/benchmarks/enzuzo/config.json
@@ -0,0 +1,50 @@
+{
+ "$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
+ "name": "enzuzo",
+ "id": "enzuzo",
+ "iterations": 20,
+ "cookieBanner": {
+ "selectors": [".enzuzo-cookiebanner-container"],
+ "serviceHosts": ["app.enzuzo.com"],
+ "waitForVisibility": true,
+ "measureViewportCoverage": true,
+ "expectedLayoutShift": true,
+ "serviceName": "Enzuzo"
+ },
+ "internationalization": {
+ "detection": "none",
+ "stringLoading": "none"
+ },
+ "techStack": {
+ "bundler": "unknown",
+ "bundleType": "iife",
+ "frameworks": [],
+ "languages": ["javascript"],
+ "packageManager": "unknown",
+ "typescript": false
+ },
+ "source": {
+ "isOpenSource": false,
+ "license": "proprietary",
+ "website": "https://www.enzuzo.com/"
+ },
+ "company": {
+ "name": "Enzuzo",
+ "website": "https://www.enzuzo.com/",
+ "avatar": "https://pbs.twimg.com/profile_images/1519664904562786305/h7G0tW79_400x400.jpg"
+ },
+ "includes": {
+ "backend": ["proprietary"],
+ "components": ["javascript"]
+ },
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 16,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
+ }
+}
diff --git a/benchmarks/enzuzo/next-env.d.ts b/benchmarks/enzuzo/next-env.d.ts
new file mode 100644
index 0000000..9edff1c
--- /dev/null
+++ b/benchmarks/enzuzo/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-didomi/next.config.ts b/benchmarks/enzuzo/next.config.ts
similarity index 100%
rename from benchmarks/with-didomi/next.config.ts
rename to benchmarks/enzuzo/next.config.ts
diff --git a/benchmarks/enzuzo/package.json b/benchmarks/enzuzo/package.json
new file mode 100644
index 0000000..ffbd6a5
--- /dev/null
+++ b/benchmarks/enzuzo/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "enzuzo",
+ "private": true,
+ "scripts": {
+ "build": "next build",
+ "dev": "next dev --port 3002",
+ "fmt": "biome format . --write",
+ "lint": "biome lint .",
+ "start": "next start"
+ },
+ "dependencies": {
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
+ },
+ "devDependencies": {
+ "@cookiebench/benchmark-schema": "workspace:*",
+ "@cookiebench/ts-config": "workspace:*",
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ }
+}
diff --git a/benchmarks/with-ketch/tsconfig.json b/benchmarks/enzuzo/tsconfig.json
similarity index 100%
rename from benchmarks/with-ketch/tsconfig.json
rename to benchmarks/enzuzo/tsconfig.json
diff --git a/benchmarks/with-iubenda/app/favicon.ico b/benchmarks/iubenda/app/favicon.ico
similarity index 100%
rename from benchmarks/with-iubenda/app/favicon.ico
rename to benchmarks/iubenda/app/favicon.ico
diff --git a/benchmarks/with-iubenda/app/layout.tsx b/benchmarks/iubenda/app/layout.tsx
similarity index 96%
rename from benchmarks/with-iubenda/app/layout.tsx
rename to benchmarks/iubenda/app/layout.tsx
index 66fa923..3953c20 100644
--- a/benchmarks/with-iubenda/app/layout.tsx
+++ b/benchmarks/iubenda/app/layout.tsx
@@ -15,6 +15,7 @@ export default function RootLayout({
{/* Iubenda Cookie Banner + Configuration */}
{children}
diff --git a/benchmarks/with-iubenda/app/page.tsx b/benchmarks/iubenda/app/page.tsx
similarity index 100%
rename from benchmarks/with-iubenda/app/page.tsx
rename to benchmarks/iubenda/app/page.tsx
diff --git a/benchmarks/with-iubenda/config.json b/benchmarks/iubenda/config.json
similarity index 79%
rename from benchmarks/with-iubenda/config.json
rename to benchmarks/iubenda/config.json
index 8ee60b6..30f3548 100644
--- a/benchmarks/with-iubenda/config.json
+++ b/benchmarks/iubenda/config.json
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
- "name": "with-iubenda",
+ "name": "iubenda",
"id": "iubenda-cs-banner",
"iterations": 20,
"cookieBanner": {
@@ -22,7 +22,7 @@
},
"techStack": {
"bundler": "unknown",
- "bundleType": "iffe",
+ "bundleType": "iife",
"frameworks": [],
"languages": ["javascript"],
"packageManager": "unknown",
@@ -41,5 +41,15 @@
"includes": {
"backend": ["proprietary"],
"components": ["javascript"]
+ },
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 16,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
}
}
diff --git a/benchmarks/iubenda/next-env.d.ts b/benchmarks/iubenda/next-env.d.ts
new file mode 100644
index 0000000..9edff1c
--- /dev/null
+++ b/benchmarks/iubenda/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-iubenda/next.config.ts b/benchmarks/iubenda/next.config.ts
similarity index 100%
rename from benchmarks/with-iubenda/next.config.ts
rename to benchmarks/iubenda/next.config.ts
diff --git a/benchmarks/with-cookie-yes/package.json b/benchmarks/iubenda/package.json
similarity index 51%
rename from benchmarks/with-cookie-yes/package.json
rename to benchmarks/iubenda/package.json
index a0f35e6..46933ac 100644
--- a/benchmarks/with-cookie-yes/package.json
+++ b/benchmarks/iubenda/package.json
@@ -1,8 +1,7 @@
{
- "name": "with-cookie-yes",
+ "name": "iubenda",
"private": true,
"scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
"build": "next build",
"dev": "next dev --port 3001",
"fmt": "biome format . --write",
@@ -10,17 +9,19 @@
"start": "next start"
},
"dependencies": {
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
}
}
diff --git a/benchmarks/with-onetrust/tsconfig.json b/benchmarks/iubenda/tsconfig.json
similarity index 100%
rename from benchmarks/with-onetrust/tsconfig.json
rename to benchmarks/iubenda/tsconfig.json
diff --git a/benchmarks/with-ketch/app/favicon.ico b/benchmarks/ketch/app/favicon.ico
similarity index 100%
rename from benchmarks/with-ketch/app/favicon.ico
rename to benchmarks/ketch/app/favicon.ico
diff --git a/benchmarks/with-ketch/app/layout.tsx b/benchmarks/ketch/app/layout.tsx
similarity index 100%
rename from benchmarks/with-ketch/app/layout.tsx
rename to benchmarks/ketch/app/layout.tsx
diff --git a/benchmarks/with-ketch/app/page.tsx b/benchmarks/ketch/app/page.tsx
similarity index 100%
rename from benchmarks/with-ketch/app/page.tsx
rename to benchmarks/ketch/app/page.tsx
diff --git a/benchmarks/with-ketch/config.json b/benchmarks/ketch/config.json
similarity index 78%
rename from benchmarks/with-ketch/config.json
rename to benchmarks/ketch/config.json
index a34a64a..929848c 100644
--- a/benchmarks/with-ketch/config.json
+++ b/benchmarks/ketch/config.json
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
- "name": "with-ketch",
+ "name": "ketch",
"iterations": 20,
"cookieBanner": {
"selectors": ["#ketch-modal"],
@@ -16,7 +16,7 @@
},
"techStack": {
"bundler": "unknown",
- "bundleType": "iffe",
+ "bundleType": "iife",
"frameworks": [],
"languages": ["javascript"],
"packageManager": "unknown",
@@ -35,5 +35,15 @@
"includes": {
"backend": ["proprietary"],
"components": ["javascript"]
+ },
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 16,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
}
}
diff --git a/benchmarks/ketch/next-env.d.ts b/benchmarks/ketch/next-env.d.ts
new file mode 100644
index 0000000..9edff1c
--- /dev/null
+++ b/benchmarks/ketch/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-ketch/next.config.ts b/benchmarks/ketch/next.config.ts
similarity index 100%
rename from benchmarks/with-ketch/next.config.ts
rename to benchmarks/ketch/next.config.ts
diff --git a/benchmarks/with-ketch/package.json b/benchmarks/ketch/package.json
similarity index 54%
rename from benchmarks/with-ketch/package.json
rename to benchmarks/ketch/package.json
index eaf2e4f..0981b59 100644
--- a/benchmarks/with-ketch/package.json
+++ b/benchmarks/ketch/package.json
@@ -1,8 +1,7 @@
{
- "name": "with-ketch",
+ "name": "ketch",
"private": true,
"scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
"build": "next build",
"dev": "next dev",
"fmt": "biome format . --write",
@@ -10,16 +9,19 @@
"start": "next start"
},
"dependencies": {
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
}
}
diff --git a/benchmarks/with-osano/tsconfig.json b/benchmarks/ketch/tsconfig.json
similarity index 100%
rename from benchmarks/with-osano/tsconfig.json
rename to benchmarks/ketch/tsconfig.json
diff --git a/benchmarks/with-onetrust/app/favicon.ico b/benchmarks/onetrust/app/favicon.ico
similarity index 100%
rename from benchmarks/with-onetrust/app/favicon.ico
rename to benchmarks/onetrust/app/favicon.ico
diff --git a/benchmarks/with-onetrust/app/layout.tsx b/benchmarks/onetrust/app/layout.tsx
similarity index 81%
rename from benchmarks/with-onetrust/app/layout.tsx
rename to benchmarks/onetrust/app/layout.tsx
index 9361364..9ea3d73 100644
--- a/benchmarks/with-onetrust/app/layout.tsx
+++ b/benchmarks/onetrust/app/layout.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
-import { ReactNode } from "react";
+import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "benchmark",
@@ -14,12 +14,13 @@ export default function RootLayout({
diff --git a/benchmarks/with-onetrust/app/page.tsx b/benchmarks/onetrust/app/page.tsx
similarity index 100%
rename from benchmarks/with-onetrust/app/page.tsx
rename to benchmarks/onetrust/app/page.tsx
diff --git a/benchmarks/with-onetrust/config.json b/benchmarks/onetrust/config.json
similarity index 80%
rename from benchmarks/with-onetrust/config.json
rename to benchmarks/onetrust/config.json
index 86db5fd..0a4c7c2 100644
--- a/benchmarks/with-onetrust/config.json
+++ b/benchmarks/onetrust/config.json
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
- "name": "with-onetrust",
+ "name": "onetrust",
"id": "onetrust-banner-sdk",
"iterations": 20,
"cookieBanner": {
@@ -19,7 +19,7 @@
},
"techStack": {
"bundler": "unknown",
- "bundleType": "iffe",
+ "bundleType": "iife",
"frameworks": [],
"languages": ["javascript"],
"packageManager": "unknown",
@@ -42,5 +42,15 @@
"includes": {
"backend": "proprietary",
"components": ["javascript"]
+ },
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 16,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
}
}
diff --git a/benchmarks/onetrust/next-env.d.ts b/benchmarks/onetrust/next-env.d.ts
new file mode 100644
index 0000000..9edff1c
--- /dev/null
+++ b/benchmarks/onetrust/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-onetrust/next.config.ts b/benchmarks/onetrust/next.config.ts
similarity index 100%
rename from benchmarks/with-onetrust/next.config.ts
rename to benchmarks/onetrust/next.config.ts
diff --git a/benchmarks/with-osano/package.json b/benchmarks/onetrust/package.json
similarity index 52%
rename from benchmarks/with-osano/package.json
rename to benchmarks/onetrust/package.json
index 4189226..745b597 100644
--- a/benchmarks/with-osano/package.json
+++ b/benchmarks/onetrust/package.json
@@ -1,8 +1,7 @@
{
- "name": "with-osano",
+ "name": "onetrust",
"private": true,
"scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
"build": "next build",
"dev": "next dev --port 3006",
"fmt": "biome format . --write",
@@ -10,17 +9,19 @@
"start": "next start"
},
"dependencies": {
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
}
}
diff --git a/benchmarks/with-usercentrics/tsconfig.json b/benchmarks/onetrust/tsconfig.json
similarity index 100%
rename from benchmarks/with-usercentrics/tsconfig.json
rename to benchmarks/onetrust/tsconfig.json
diff --git a/benchmarks/with-osano/app/favicon.ico b/benchmarks/osano/app/favicon.ico
similarity index 100%
rename from benchmarks/with-osano/app/favicon.ico
rename to benchmarks/osano/app/favicon.ico
diff --git a/benchmarks/with-osano/app/layout.tsx b/benchmarks/osano/app/layout.tsx
similarity index 70%
rename from benchmarks/with-osano/app/layout.tsx
rename to benchmarks/osano/app/layout.tsx
index deb9cc6..f0f4c07 100644
--- a/benchmarks/with-osano/app/layout.tsx
+++ b/benchmarks/osano/app/layout.tsx
@@ -13,7 +13,10 @@ export default function RootLayout({
return (
-
+
{children}
diff --git a/benchmarks/with-osano/app/page.tsx b/benchmarks/osano/app/page.tsx
similarity index 100%
rename from benchmarks/with-osano/app/page.tsx
rename to benchmarks/osano/app/page.tsx
diff --git a/benchmarks/with-osano/config.json b/benchmarks/osano/config.json
similarity index 75%
rename from benchmarks/with-osano/config.json
rename to benchmarks/osano/config.json
index fb25654..0f7ea23 100644
--- a/benchmarks/with-osano/config.json
+++ b/benchmarks/osano/config.json
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
- "name": "with-osano",
+ "name": "osano",
"id": "osano-banner-sdk",
"iterations": 20,
"cookieBanner": {
@@ -13,7 +13,7 @@
},
"techStack": {
"bundler": "unknown",
- "bundleType": "iffe",
+ "bundleType": "iife",
"frameworks": [],
"languages": ["javascript"],
"packageManager": "unknown",
@@ -30,11 +30,21 @@
},
"company": {
"name": "Osano",
- "website": "https://Osano.com",
+ "website": "https://www.osano.com",
"avatar": "https://pbs.twimg.com/profile_images/1678452109229043712/Y6oQn0Sq_400x400.jpg"
},
"includes": {
"backend": "proprietary",
"components": ["javascript"]
+ },
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 16,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
}
}
diff --git a/benchmarks/osano/next-env.d.ts b/benchmarks/osano/next-env.d.ts
new file mode 100644
index 0000000..9edff1c
--- /dev/null
+++ b/benchmarks/osano/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-osano/next.config.ts b/benchmarks/osano/next.config.ts
similarity index 100%
rename from benchmarks/with-osano/next.config.ts
rename to benchmarks/osano/next.config.ts
diff --git a/benchmarks/osano/package.json b/benchmarks/osano/package.json
new file mode 100644
index 0000000..1a9b762
--- /dev/null
+++ b/benchmarks/osano/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "osano",
+ "private": true,
+ "scripts": {
+ "build": "next build",
+ "dev": "next dev --port 3007",
+ "fmt": "biome format . --write",
+ "lint": "biome lint .",
+ "start": "next start"
+ },
+ "dependencies": {
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
+ },
+ "devDependencies": {
+ "@cookiebench/benchmark-schema": "workspace:*",
+ "@cookiebench/ts-config": "workspace:*",
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ }
+}
diff --git a/benchmarks/osano/tsconfig.json b/benchmarks/osano/tsconfig.json
new file mode 100644
index 0000000..787e3f6
--- /dev/null
+++ b/benchmarks/osano/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "@cookiebench/ts-config/nextjs.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules"]
+}
diff --git a/benchmarks/with-usercentrics/app/favicon.ico b/benchmarks/usercentrics/app/favicon.ico
similarity index 100%
rename from benchmarks/with-usercentrics/app/favicon.ico
rename to benchmarks/usercentrics/app/favicon.ico
diff --git a/benchmarks/with-usercentrics/app/layout.tsx b/benchmarks/usercentrics/app/layout.tsx
similarity index 100%
rename from benchmarks/with-usercentrics/app/layout.tsx
rename to benchmarks/usercentrics/app/layout.tsx
diff --git a/benchmarks/with-usercentrics/app/page.tsx b/benchmarks/usercentrics/app/page.tsx
similarity index 100%
rename from benchmarks/with-usercentrics/app/page.tsx
rename to benchmarks/usercentrics/app/page.tsx
diff --git a/benchmarks/with-usercentrics/config.json b/benchmarks/usercentrics/config.json
similarity index 78%
rename from benchmarks/with-usercentrics/config.json
rename to benchmarks/usercentrics/config.json
index d899486..1274775 100644
--- a/benchmarks/with-usercentrics/config.json
+++ b/benchmarks/usercentrics/config.json
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
- "name": "with-usercentrics",
+ "name": "usercentrics",
"id": "usercentrics-banner",
"iterations": 20,
"cookieBanner": {
@@ -17,7 +17,7 @@
},
"techStack": {
"bundler": "unknown",
- "bundleType": "iffe",
+ "bundleType": "iife",
"frameworks": [],
"languages": ["javascript"],
"packageManager": "unknown",
@@ -36,5 +36,15 @@
"includes": {
"backend": ["proprietary"],
"components": ["javascript"]
+ },
+ "runProfile": {
+ "cacheMode": "cold",
+ "networkProfile": "none",
+ "cpuSlowdownMultiplier": 1
+ },
+ "measurement": {
+ "minSuccessfulIterations": 16,
+ "maxFailureRate": 0.2,
+ "stabilityThresholdCv": 15
}
}
diff --git a/benchmarks/usercentrics/next-env.d.ts b/benchmarks/usercentrics/next-env.d.ts
new file mode 100644
index 0000000..9edff1c
--- /dev/null
+++ b/benchmarks/usercentrics/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-usercentrics/next.config.ts b/benchmarks/usercentrics/next.config.ts
similarity index 100%
rename from benchmarks/with-usercentrics/next.config.ts
rename to benchmarks/usercentrics/next.config.ts
diff --git a/benchmarks/with-usercentrics/package.json b/benchmarks/usercentrics/package.json
similarity index 51%
rename from benchmarks/with-usercentrics/package.json
rename to benchmarks/usercentrics/package.json
index eb22797..bfee769 100644
--- a/benchmarks/with-usercentrics/package.json
+++ b/benchmarks/usercentrics/package.json
@@ -1,8 +1,7 @@
{
- "name": "with-usercentrics",
+ "name": "usercentrics",
"private": true,
"scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
"build": "next build",
"dev": "next dev --port 3001",
"fmt": "biome format . --write",
@@ -10,17 +9,19 @@
"start": "next start"
},
"dependencies": {
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
}
}
diff --git a/benchmarks/usercentrics/tsconfig.json b/benchmarks/usercentrics/tsconfig.json
new file mode 100644
index 0000000..787e3f6
--- /dev/null
+++ b/benchmarks/usercentrics/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "@cookiebench/ts-config/nextjs.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules"]
+}
diff --git a/benchmarks/with-c15t-nextjs/package.json b/benchmarks/with-c15t-nextjs/package.json
deleted file mode 100644
index 075fd2c..0000000
--- a/benchmarks/with-c15t-nextjs/package.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "name": "with-c15t-nextjs",
- "version": "0.1.0",
- "private": true,
- "scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
- "build": "next build",
- "dev": "next dev --port 3001",
- "fmt": "biome format . --write",
- "lint": "biome lint .",
- "start": "next start"
- },
- "dependencies": {
- "@c15t/nextjs": "1.8.1",
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
- },
- "devDependencies": {
- "@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
- "@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
- }
-}
diff --git a/benchmarks/with-c15t-react/package.json b/benchmarks/with-c15t-react/package.json
deleted file mode 100644
index a66b2ef..0000000
--- a/benchmarks/with-c15t-react/package.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "name": "with-c15t-react",
- "private": true,
- "scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
- "build": "next build",
- "dev": "next dev --port 3003",
- "fmt": "biome format . --write",
- "lint": "biome lint .",
- "start": "next start --port 3003"
- },
- "dependencies": {
- "@c15t/react": "1.8.1",
- "@c15t/translations": "^1.8.0",
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
- },
- "devDependencies": {
- "@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
- "@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
- }
-}
diff --git a/benchmarks/with-cookie-control/config.json b/benchmarks/with-cookie-control/config.json
deleted file mode 100644
index 502dd42..0000000
--- a/benchmarks/with-cookie-control/config.json
+++ /dev/null
@@ -1,40 +0,0 @@
-{
- "$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
- "name": "with-cookie-control",
- "id": "cookie-control",
- "iterations": 20,
- "cookieBanner": {
- "selectors": [".ccc-module--slideout"],
- "serviceHosts": ["civiccomputing.com"],
- "waitForVisibility": true,
- "measureViewportCoverage": true,
- "expectedLayoutShift": true,
- "serviceName": "Cookie Control"
- },
- "internationalization": {
- "detection": "none",
- "stringLoading": "none"
- },
- "techStack": {
- "bundler": "unknown",
- "bundleType": "iffe",
- "frameworks": [],
- "languages": ["javascript"],
- "packageManager": "unknown",
- "typescript": false
- },
- "source": {
- "isOpenSource": false,
- "license": "proprietary",
- "website": "https://www.civicuk.com/cookie-control/"
- },
- "company": {
- "name": "Cookie Control",
- "website": "https://www.civicuk.com/cookie-control/",
- "avatar": "https://www.civicuk.com/cookie-control/images/favicon.png"
- },
- "includes": {
- "backend": ["proprietary"],
- "components": ["javascript"]
- }
-}
diff --git a/benchmarks/with-cookie-control/next.config.ts b/benchmarks/with-cookie-control/next.config.ts
deleted file mode 100644
index a67a28b..0000000
--- a/benchmarks/with-cookie-control/next.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { NextConfig } from 'next';
-
-const nextConfig: NextConfig = {
- /* config options here */
-};
-
-export default nextConfig;
diff --git a/benchmarks/with-cookie-control/tsconfig.json b/benchmarks/with-cookie-control/tsconfig.json
deleted file mode 100644
index 99f37b5..0000000
--- a/benchmarks/with-cookie-control/tsconfig.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "extends": "@cookiebench/ts-config/nextjs.json",
- "compilerOptions": {
- "baseUrl": ".",
- "paths": {
- "@/*": ["./*"]
- }
- },
- "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
- "exclude": ["node_modules"]
-}
diff --git a/benchmarks/with-didomi/app/layout.tsx b/benchmarks/with-didomi/app/layout.tsx
deleted file mode 100644
index 05047ed..0000000
--- a/benchmarks/with-didomi/app/layout.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-"use client";
-
-import { DidomiSDK, type IDidomiObject } from "@didomi/react";
-import { type ReactNode, useCallback, useState } from "react";
-
-export default function RootLayout({
- children,
-}: Readonly<{
- children: ReactNode;
-}>) {
- const [didomiObject, setDidomiObject] = useState(null);
-
- const onDidomiReady = useCallback((didomi: IDidomiObject) => {
- setDidomiObject(didomi);
- console.log(
- "Didomi Ready - Is consent required?:",
- didomi.isConsentRequired()
- );
- console.log(
- "Didomi Ready - Consent for vendor IAB 1:",
- didomi.getUserConsentStatusForVendor(1)
- );
- console.log(
- "Didomi Ready - Consent for vendor IAB 1 and cookies:",
- didomi.getUserConsentStatus("cookies", 1)
- );
- }, []);
-
- const onConsentChanged = useCallback(
- (cwtToken: string) => {
- if (!didomiObject) return;
- console.log("Didomi Consent Changed - cwtToken:", cwtToken);
- console.log(
- "Didomi Consent Changed - Is consent required?:",
- didomiObject.isConsentRequired()
- );
- console.log(
- "Didomi Consent Changed - Consent for vendor IAB 1:",
- didomiObject.getUserConsentStatusForVendor(1)
- );
- console.log(
- "Didomi Consent Changed - Consent for vendor IAB 1 and cookies:",
- didomiObject.getUserConsentStatus("cookies", 1)
- );
- },
- [didomiObject]
- );
-
- return (
-
-
- console.log("Didomi Notice Shown")}
- onNoticeHidden={() => console.log("Didomi Notice Hidden")}
- />
- {children}
-
-
- );
-}
diff --git a/benchmarks/with-enzuzo/app/layout.tsx b/benchmarks/with-enzuzo/app/layout.tsx
deleted file mode 100644
index 84fc0f6..0000000
--- a/benchmarks/with-enzuzo/app/layout.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import type { Metadata } from 'next';
-import type { ReactNode } from 'react';
-
-export const metadata: Metadata = {
- title: 'benchmark',
-};
-
-export default function RootLayout({
- children,
-}: Readonly<{
- children: ReactNode;
-}>) {
- return (
-
-
- {/* Cookie Control Script */}
-
- {/* */}
-
- {children}
-
- );
-}
diff --git a/benchmarks/with-enzuzo/config.json b/benchmarks/with-enzuzo/config.json
deleted file mode 100644
index 3595384..0000000
--- a/benchmarks/with-enzuzo/config.json
+++ /dev/null
@@ -1,40 +0,0 @@
-{
- "$schema": "./node_modules/@cookiebench/benchmark-schema/schema.json",
- "name": "with-enzuzo",
- "id": "enzuzo",
- "iterations": 20,
- "cookieBanner": {
- "selectors": [".enzuzo-cookiebanner-container"],
- "serviceHosts": ["app.enzuzo.com"],
- "waitForVisibility": true,
- "measureViewportCoverage": true,
- "expectedLayoutShift": true,
- "serviceName": "Enzuzo"
- },
- "internationalization": {
- "detection": "none",
- "stringLoading": "none"
- },
- "techStack": {
- "bundler": "unknown",
- "bundleType": "iffe",
- "frameworks": [],
- "languages": ["javascript"],
- "packageManager": "unknown",
- "typescript": false
- },
- "source": {
- "isOpenSource": false,
- "license": "proprietary",
- "website": "https://www.enzuzo.com/"
- },
- "company": {
- "name": "Enzuzo",
- "website": "https://www.enzuzo.com/",
- "avatar": "https://pbs.twimg.com/profile_images/1519664904562786305/h7G0tW79_400x400.jpg"
- },
- "includes": {
- "backend": ["proprietary"],
- "components": ["javascript"]
- }
-}
diff --git a/benchmarks/with-enzuzo/next-env.d.ts b/benchmarks/with-enzuzo/next-env.d.ts
deleted file mode 100644
index 1b3be08..0000000
--- a/benchmarks/with-enzuzo/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-enzuzo/next.config.ts b/benchmarks/with-enzuzo/next.config.ts
deleted file mode 100644
index a67a28b..0000000
--- a/benchmarks/with-enzuzo/next.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { NextConfig } from 'next';
-
-const nextConfig: NextConfig = {
- /* config options here */
-};
-
-export default nextConfig;
diff --git a/benchmarks/with-enzuzo/tsconfig.json b/benchmarks/with-enzuzo/tsconfig.json
deleted file mode 100644
index 99f37b5..0000000
--- a/benchmarks/with-enzuzo/tsconfig.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "extends": "@cookiebench/ts-config/nextjs.json",
- "compilerOptions": {
- "baseUrl": ".",
- "paths": {
- "@/*": ["./*"]
- }
- },
- "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
- "exclude": ["node_modules"]
-}
diff --git a/benchmarks/with-iubenda/next-env.d.ts b/benchmarks/with-iubenda/next-env.d.ts
deleted file mode 100644
index 1b3be08..0000000
--- a/benchmarks/with-iubenda/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-ketch/next-env.d.ts b/benchmarks/with-ketch/next-env.d.ts
deleted file mode 100644
index 1b3be08..0000000
--- a/benchmarks/with-ketch/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-onetrust/next-env.d.ts b/benchmarks/with-onetrust/next-env.d.ts
deleted file mode 100644
index 1b3be08..0000000
--- a/benchmarks/with-onetrust/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-onetrust/package.json b/benchmarks/with-onetrust/package.json
deleted file mode 100644
index d8297b7..0000000
--- a/benchmarks/with-onetrust/package.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "name": "with-onetrust",
- "private": true,
- "scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
- "build": "next build",
- "dev": "next dev --port 3006",
- "fmt": "biome format . --write",
- "lint": "biome lint .",
- "start": "next start"
- },
- "dependencies": {
- "next": "16.0.7",
- "react": "^19.2.1",
- "react-dom": "^19.2.1"
- },
- "devDependencies": {
- "@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
- "@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.10.1",
- "@types/react": "^19.2.7",
- "@types/react-dom": "^19.2.3",
- "typescript": "^5.9.3"
- }
-}
diff --git a/benchmarks/with-osano/next-env.d.ts b/benchmarks/with-osano/next-env.d.ts
deleted file mode 100644
index 1b3be08..0000000
--- a/benchmarks/with-osano/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/benchmarks/with-usercentrics/next-env.d.ts b/benchmarks/with-usercentrics/next-env.d.ts
deleted file mode 100644
index 1b3be08..0000000
--- a/benchmarks/with-usercentrics/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/package.json b/package.json
index a764c97..c7f4403 100644
--- a/package.json
+++ b/package.json
@@ -1,31 +1,31 @@
{
- "name": "cookiebench",
- "private": true,
- "scripts": {
- "benchmark": "turbo run benchmark",
- "build": "turbo run build",
- "check-types": "turbo run check-types",
- "db": "pnpm exec benchmark-cli db",
- "dev": "turbo run dev --filter=benchmarks",
- "fmt": "turbo fmt",
- "lint": "turbo run lint",
- "results": "pnpm exec benchmark-cli results"
- },
- "devDependencies": {
- "@biomejs/biome": "2.3.8",
- "@c15t/translations": "^1.8.0",
- "@cookiebench/cli": "workspace:*",
- "@playwright/test": "^1.57.0",
- "cli-table3": "^0.6.5",
- "drizzle-kit": "^0.31.8",
- "p-limit": "^7.2.0",
- "pretty-ms": "^9.3.0",
- "turbo": "^2.6.3",
- "typescript": "5.9.3",
- "ultracite": "^6.3.9"
- },
- "packageManager": "pnpm@9.0.0",
- "engines": {
- "node": ">=18"
- }
-}
\ No newline at end of file
+ "name": "cookiebench",
+ "private": true,
+ "scripts": {
+ "benchmark": "pnpm turbo run build --filter=./packages/cookiebench-cli... && pnpm --filter ./packages/cookiebench-cli run start benchmark",
+ "build": "turbo run build",
+ "check-types": "turbo run check-types",
+ "db": "pnpm turbo run build --filter=./packages/cookiebench-cli... && pnpm --filter ./packages/cookiebench-cli run start db",
+ "dev": "turbo run dev --filter=benchmarks",
+ "fmt": "turbo fmt",
+ "lint": "turbo run lint",
+ "results": "pnpm turbo run build --filter=./packages/cookiebench-cli... && pnpm --filter ./packages/cookiebench-cli run start results"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "2.3.2",
+ "@consentio/benchmark": "workspace:*",
+ "@consentio/runner": "workspace:*",
+ "@playwright/test": "^1.58.2",
+ "cli-table3": "^0.6.5",
+ "cookiebench": "workspace:*",
+ "drizzle-kit": "^0.31.9",
+ "pretty-ms": "^9.3.0",
+ "turbo": "^2.8.9",
+ "typescript": "5.9.3",
+ "ultracite": "6.0.5"
+ },
+ "packageManager": "pnpm@10.29.3",
+ "engines": {
+ "node": ">=20.9.0"
+ }
+}
diff --git a/packages/benchmark-schema/package.json b/packages/benchmark-schema/package.json
index a4c6153..004cc72 100644
--- a/packages/benchmark-schema/package.json
+++ b/packages/benchmark-schema/package.json
@@ -6,9 +6,10 @@
"main": "schema.json",
"files": [
"schema.json",
- "base.json"
+ "base.json",
+ "results.schema.json"
],
"publishConfig": {
"access": "public"
}
-}
\ No newline at end of file
+}
diff --git a/packages/benchmark-schema/results.schema.json b/packages/benchmark-schema/results.schema.json
new file mode 100644
index 0000000..468d12f
--- /dev/null
+++ b/packages/benchmark-schema/results.schema.json
@@ -0,0 +1,109 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "CookieBench Results Schema v2",
+ "description": "Schema for results.json emitted by cookiebench",
+ "type": "object",
+ "required": ["schemaVersion", "app", "results", "metadata"],
+ "additionalProperties": true,
+ "properties": {
+ "schemaVersion": {
+ "type": "integer",
+ "const": 2
+ },
+ "app": {
+ "type": "string"
+ },
+ "results": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "duration",
+ "size",
+ "timing",
+ "resources",
+ "cookieBanner",
+ "thirdParty"
+ ],
+ "properties": {
+ "duration": {
+ "type": "number"
+ },
+ "size": {
+ "type": "object"
+ },
+ "timing": {
+ "type": "object"
+ },
+ "resources": {
+ "type": "object"
+ },
+ "cookieBanner": {
+ "type": "object"
+ },
+ "thirdParty": {
+ "type": "object"
+ }
+ }
+ }
+ },
+ "scores": {
+ "type": "object"
+ },
+ "metadata": {
+ "type": "object",
+ "required": [
+ "generatedAtUtc",
+ "runStartedAtUtc",
+ "runCompletedAtUtc",
+ "iterationsRequested",
+ "iterationsSuccessful",
+ "runProfile",
+ "measurement",
+ "quality",
+ "statistics",
+ "environment",
+ "baselineRole"
+ ],
+ "properties": {
+ "generatedAtUtc": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "runStartedAtUtc": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "runCompletedAtUtc": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "iterationsRequested": {
+ "type": "integer"
+ },
+ "iterationsSuccessful": {
+ "type": "integer"
+ },
+ "runProfile": {
+ "type": "object"
+ },
+ "measurement": {
+ "type": "object"
+ },
+ "quality": {
+ "type": "object"
+ },
+ "statistics": {
+ "type": "object"
+ },
+ "environment": {
+ "type": "object"
+ },
+ "baselineRole": {
+ "type": "string",
+ "enum": ["reference", "candidate"]
+ }
+ }
+ }
+ }
+}
diff --git a/packages/benchmark-schema/schema.json b/packages/benchmark-schema/schema.json
index d3cf071..cee89d2 100644
--- a/packages/benchmark-schema/schema.json
+++ b/packages/benchmark-schema/schema.json
@@ -6,9 +6,15 @@
"required": [
"name",
"iterations",
+ "runProfile",
+ "measurement",
"cookieBanner",
- "techStack"
+ "techStack",
+ "source",
+ "includes",
+ "internationalization"
],
+ "additionalProperties": false,
"properties": {
"name": {
"type": "string",
@@ -20,9 +26,64 @@
"default": 20,
"description": "Number of test iterations to run"
},
+ "runProfile": {
+ "type": "object",
+ "required": ["cacheMode", "networkProfile", "cpuSlowdownMultiplier"],
+ "additionalProperties": false,
+ "properties": {
+ "cacheMode": {
+ "type": "string",
+ "enum": ["cold", "warm", "mixed"],
+ "description": "Cache strategy used for benchmark iterations"
+ },
+ "networkProfile": {
+ "type": "string",
+ "enum": ["none", "slow4g", "fast3g"],
+ "description": "Network throttling profile"
+ },
+ "cpuSlowdownMultiplier": {
+ "type": "number",
+ "minimum": 1,
+ "description": "CPU slowdown rate where 1 means no throttling"
+ }
+ }
+ },
+ "measurement": {
+ "type": "object",
+ "required": [
+ "minSuccessfulIterations",
+ "maxFailureRate",
+ "stabilityThresholdCv"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "minSuccessfulIterations": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Minimum successful iterations required for a valid run"
+ },
+ "maxFailureRate": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "description": "Maximum allowed failed iteration ratio"
+ },
+ "stabilityThresholdCv": {
+ "type": "number",
+ "minimum": 0,
+ "description": "Maximum coefficient of variation before metrics are unstable"
+ },
+ "networkMonitorMode": {
+ "type": "string",
+ "enum": ["passive", "route-debug"],
+ "description": "Network monitoring mode; route-debug enables request interception for diagnostics"
+ }
+ }
+ },
"remote": {
"type": "object",
"description": "Configuration for remote benchmarking",
+ "additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
@@ -53,10 +114,8 @@
},
"cookieBanner": {
"type": "object",
- "required": [
- "selectors",
- "serviceHosts"
- ],
+ "required": ["selectors", "serviceHosts"],
+ "additionalProperties": false,
"properties": {
"selectors": {
"type": "array",
@@ -95,28 +154,17 @@
},
"internationalization": {
"type": "object",
- "required": [
- "detection",
- "stringLoading"
- ],
+ "required": ["detection", "stringLoading"],
+ "additionalProperties": false,
"properties": {
"detection": {
"type": "string",
- "enum": [
- "browser",
- "ip",
- "manual",
- "none"
- ],
+ "enum": ["browser", "ip", "manual", "none"],
"description": "Method used to determine the banner language"
},
"stringLoading": {
"type": "string",
- "enum": [
- "bundled",
- "server",
- "none"
- ],
+ "enum": ["bundled", "server", "none"],
"description": "How the banner loads its translation strings"
}
}
@@ -130,6 +178,7 @@
"languages",
"packageManager"
],
+ "additionalProperties": false,
"properties": {
"bundler": {
"type": "string"
@@ -138,21 +187,13 @@
"oneOf": [
{
"type": "string",
- "enum": [
- "esm",
- "cjs",
- "iffe",
- "bundled"
- ]
+ "enum": ["esm", "cjs", "iife", "bundled"]
},
{
"type": "array",
"items": {
"type": "string",
- "enum": [
- "esm",
- "cjs"
- ]
+ "enum": ["esm", "cjs", "iife", "bundled"]
}
}
]
@@ -167,10 +208,7 @@
"type": "array",
"items": {
"type": "string",
- "enum": [
- "typescript",
- "javascript"
- ]
+ "enum": ["typescript", "javascript"]
}
},
"packageManager": {
@@ -183,9 +221,19 @@
},
"source": {
"type": "object",
+ "required": ["isOpenSource", "license"],
+ "additionalProperties": false,
"properties": {
"github": {
- "type": "string"
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "boolean",
+ "const": false
+ }
+ ]
},
"isOpenSource": {
"oneOf": [
@@ -194,9 +242,7 @@
},
{
"type": "string",
- "enum": [
- "partially"
- ]
+ "enum": ["partially"]
}
]
},
@@ -204,7 +250,15 @@
"type": "string"
},
"npm": {
- "type": "string"
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "boolean",
+ "const": false
+ }
+ ]
},
"website": {
"type": "string",
@@ -214,6 +268,8 @@
},
"includes": {
"type": "object",
+ "required": ["components"],
+ "additionalProperties": false,
"properties": {
"backend": {
"oneOf": [
@@ -225,6 +281,10 @@
"items": {
"type": "string"
}
+ },
+ {
+ "type": "boolean",
+ "const": false
}
]
},
@@ -238,6 +298,7 @@
},
"company": {
"type": "object",
+ "additionalProperties": false,
"properties": {
"name": {
"type": "string"
@@ -259,4 +320,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/packages/benchmark/README.md b/packages/benchmark/README.md
new file mode 100644
index 0000000..80bf2c0
--- /dev/null
+++ b/packages/benchmark/README.md
@@ -0,0 +1,113 @@
+# @consentio/benchmark
+
+Core benchmark measurement logic for cookie banner performance testing.
+
+## Overview
+
+This package provides the core functionality for detecting and measuring cookie banner performance impact. It includes collectors for cookie banners, network monitoring, and resource timing.
+
+## Features
+
+- **Cookie Banner Detection**: Automatically detects cookie banners using configurable selectors
+- **Network Monitoring**: Tracks network requests and calculates size/timing metrics
+- **Resource Collection**: Collects detailed resource timing data from the browser
+- **Bundle Strategy Detection**: Identifies bundling approaches (iife, ESM, CJS, bundled)
+- **Performance Metrics**: Measures layout shift, render time, and viewport coverage
+
+## Installation
+
+```bash
+pnpm add @consentio/benchmark
+```
+
+## Usage
+
+```typescript
+import {
+ CookieBannerCollector,
+ NetworkMonitor,
+ ResourceTimingCollector,
+ determineBundleStrategy,
+ BENCHMARK_CONSTANTS,
+} from '@consentio/benchmark';
+import { chromium } from '@playwright/test';
+
+// Create config
+const config = {
+ name: 'my-app',
+ iterations: 5,
+ cookieBanner: {
+ selectors: ['.cookie-banner', '#cookie-consent'],
+ serviceHosts: ['cookiecdn.com'],
+ serviceName: 'CookieService',
+ waitForVisibility: true,
+ measureViewportCoverage: true,
+ expectedLayoutShift: true,
+ },
+ techStack: {
+ bundleType: 'esm',
+ // ...
+ },
+ // ...
+};
+
+// Initialize collectors
+const cookieBannerCollector = new CookieBannerCollector(config);
+const networkMonitor = new NetworkMonitor(config);
+const resourceCollector = new ResourceTimingCollector();
+
+// Use with Playwright
+const browser = await chromium.launch();
+const page = await browser.newPage();
+
+// Setup detection and monitoring
+await cookieBannerCollector.setupDetection(page);
+await networkMonitor.setupMonitoring(page);
+
+// Navigate to page
+await page.goto('https://example.com');
+
+// Collect metrics
+const bannerData = await cookieBannerCollector.collectMetrics(page);
+const resourceData = await resourceCollector.collect(page);
+const networkRequests = networkMonitor.getNetworkRequests();
+
+await browser.close();
+```
+
+## API
+
+### CookieBannerCollector
+
+- `constructor(config: Config)`: Create a new collector
+- `initializeMetrics()`: Initialize cookie banner metrics tracking
+- `setupDetection(page: Page)`: Set up browser-side detection script
+- `collectMetrics(page: Page)`: Collect metrics from the page
+
+### NetworkMonitor
+
+- `constructor(config: Config)`: Create a new monitor
+- `setupMonitoring(page: Page)`: Set up network request interception
+- `getNetworkRequests()`: Get collected network requests
+- `getMetrics()`: Get network metrics
+- `calculateNetworkImpact()`: Calculate network impact metrics
+- `reset()`: Reset collected data
+
+### ResourceTimingCollector
+
+- `collect(page: Page)`: Collect detailed resource timing data
+
+### Utilities
+
+- `determineBundleStrategy(config: Config)`: Determine bundle strategy from config
+- `BENCHMARK_CONSTANTS`: Constants for detection intervals, timeouts, etc.
+- `BUNDLE_TYPES`: Bundle type constants (iife, ESM, CJS, BUNDLED)
+
+## Types
+
+See the [types file](./src/types.ts) for complete type definitions.
+
+## License
+
+MIT
+
diff --git a/packages/benchmark/package.json b/packages/benchmark/package.json
new file mode 100644
index 0000000..b53fe49
--- /dev/null
+++ b/packages/benchmark/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@consentio/benchmark",
+ "version": "0.0.1",
+ "type": "module",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "scripts": {
+ "build": "rslib build",
+ "check-types": "tsc --noEmit",
+ "dev": "rslib build --watch",
+ "fmt": "biome format . --write",
+ "lint": "biome lint ."
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "dependencies": {
+ "@c15t/logger": "^1.0.1",
+ "@consentio/shared": "workspace:*",
+ "@playwright/test": "^1.58.2",
+ "perfume.js": "^9.4.0",
+ "playwright-performance-metrics": "^1.2.5"
+ },
+ "devDependencies": {
+ "@rsdoctor/rspack-plugin": "^1.5.2",
+ "@rslib/core": "^0.16.1",
+ "@types/node": "catalog:",
+ "typescript": "catalog:"
+ }
+}
diff --git a/packages/benchmark/rslib.config.ts b/packages/benchmark/rslib.config.ts
new file mode 100644
index 0000000..434432c
--- /dev/null
+++ b/packages/benchmark/rslib.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "@rslib/core";
+
+export default defineConfig({
+ lib: [
+ {
+ format: "esm",
+ syntax: "es2021",
+ dts: true,
+ },
+ ],
+ output: {
+ target: "node",
+ },
+});
diff --git a/packages/benchmark/src/bundle-strategy.ts b/packages/benchmark/src/bundle-strategy.ts
new file mode 100644
index 0000000..50db75a
--- /dev/null
+++ b/packages/benchmark/src/bundle-strategy.ts
@@ -0,0 +1,32 @@
+import { BUNDLE_TYPES } from "./constants";
+import type { BundleStrategy, Config } from "./types";
+
+export function determineBundleStrategy(config: Config): BundleStrategy {
+ const bundleType = config.techStack?.bundleType;
+ const rawBundleType = bundleType as string | string[] | undefined;
+ const legacyIifeType = "iife";
+
+ const isIIFE =
+ rawBundleType === BUNDLE_TYPES.IIFE ||
+ rawBundleType === legacyIifeType ||
+ (Array.isArray(rawBundleType) &&
+ (rawBundleType.includes(BUNDLE_TYPES.IIFE) ||
+ rawBundleType.includes(legacyIifeType)));
+
+ const isModuleBundleType =
+ rawBundleType === BUNDLE_TYPES.ESM ||
+ rawBundleType === BUNDLE_TYPES.CJS ||
+ rawBundleType === BUNDLE_TYPES.BUNDLED;
+
+ const isArrayWithModules =
+ Array.isArray(rawBundleType) &&
+ (rawBundleType.includes(BUNDLE_TYPES.ESM) ||
+ rawBundleType.includes(BUNDLE_TYPES.CJS) ||
+ rawBundleType.includes(BUNDLE_TYPES.BUNDLED) ||
+ rawBundleType.includes(BUNDLE_TYPES.IIFE) ||
+ rawBundleType.includes(legacyIifeType));
+
+ const isBundled = !isIIFE && (isModuleBundleType || isArrayWithModules);
+
+ return { isBundled, isIIFE, bundleType };
+}
diff --git a/packages/benchmark/src/constants.ts b/packages/benchmark/src/constants.ts
new file mode 100644
index 0000000..8c95385
--- /dev/null
+++ b/packages/benchmark/src/constants.ts
@@ -0,0 +1,81 @@
+import {
+ KILOBYTE,
+ ONE_SECOND,
+ PERCENTAGE_MULTIPLIER,
+ TTI_BUFFER_MS,
+} from "@consentio/shared";
+
+/**
+ * Benchmark constants used throughout the measurement system.
+ *
+ * These constants define timing windows, thresholds, and intervals for
+ * cookie banner detection and metrics collection. All values are measured
+ * in milliseconds unless otherwise specified.
+ *
+ * @see METHODOLOGY.md for detailed explanation of measurement approach
+ */
+export const BENCHMARK_CONSTANTS = {
+ /**
+ * Wait 1 second between detection attempts when banner is not found immediately.
+ * Prevents excessive polling while ensuring we catch dynamically loaded banners.
+ */
+ DETECTION_INTERVAL: ONE_SECOND, // Wait 1 second between detection attempts
+ /**
+ * Maximum time to wait for banner detection before giving up.
+ * Increased to 15 seconds to accommodate longer waits for async-loaded banners.
+ */
+ MAX_DETECTION_TIME: 15_000, // Increased to 15 seconds to accommodate longer waits
+ /**
+ * Initial delay before starting banner detection.
+ * Allows page to start loading before we begin checking for banner.
+ */
+ INITIAL_DETECTION_DELAY: 500, // Wait 500ms before starting
+ /**
+ * Buffer time added to Time to Interactive calculations.
+ * Ensures page is truly interactive before recording TTI.
+ */
+ TTI_BUFFER: TTI_BUFFER_MS,
+ /**
+ * Timeout for collecting performance metrics from Perfume.js.
+ * Some metrics may take time to be reported by the browser.
+ */
+ METRICS_TIMEOUT: 10_000,
+ /**
+ * Retry timeout for metrics collection failures.
+ * Allows retry attempts if initial collection fails.
+ */
+ METRICS_RETRY_TIMEOUT: 5000,
+ /**
+ * Conversion factor: bytes to kilobytes.
+ * Used for displaying resource sizes in KB.
+ */
+ BYTES_TO_KB: KILOBYTE, // Convert bytes to kilobytes
+ /**
+ * Wait time for Perfume.js metrics to be collected.
+ * Perfume.js reports metrics asynchronously, so we wait before collection.
+ */
+ PERFUME_METRICS_WAIT: ONE_SECOND, // Wait 1 second for Perfume.js metrics to be collected
+ /**
+ * Polling interval for banner visibility detection.
+ * Checks every 100ms for banner appearance/visibility changes.
+ * Lower values = more accurate but more CPU usage.
+ */
+ BANNER_POLL_INTERVAL: 100, // Poll for banner visibility every 100ms
+ /**
+ * Timeout for banner detection polling.
+ * Stops checking for banner after 10 seconds to prevent infinite loops.
+ */
+ BANNER_DETECTION_TIMEOUT: 10_000, // Stop checking for banner after 10 seconds
+ /**
+ * Multiplier for converting decimal ratios to percentages.
+ * Used for viewport coverage calculations (0.125 -> 12.5%).
+ */
+ PERCENTAGE_MULTIPLIER, // Convert decimal to percentage
+} as const;
+
+export const BUNDLE_TYPES = {
+ IIFE: "iife",
+ ESM: "esm",
+ CJS: "cjs",
+ BUNDLED: "bundled",
+} as const;
diff --git a/packages/benchmark/src/cookie-banner-collector.ts b/packages/benchmark/src/cookie-banner-collector.ts
new file mode 100644
index 0000000..6749eed
--- /dev/null
+++ b/packages/benchmark/src/cookie-banner-collector.ts
@@ -0,0 +1,381 @@
+import type { Logger } from "@c15t/logger";
+import type { Page } from "@playwright/test";
+import { determineBundleStrategy } from "./bundle-strategy";
+import { BENCHMARK_CONSTANTS } from "./constants";
+import type {
+ Config,
+ CookieBannerData,
+ CookieBannerMetrics,
+ LayoutShiftEntry,
+ WindowWithCookieMetrics,
+} from "./types";
+
+/**
+ * Opacity threshold for determining when banner is actually visible to users.
+ *
+ * This threshold (0.5 = 50% opacity) accounts for CSS transitions and animations.
+ * A banner that renders instantly but fades in slowly will only be considered
+ * "visible" once it reaches 50% opacity, ensuring scores reflect actual user
+ * experience rather than just technical render time.
+ *
+ * @see METHODOLOGY.md for detailed explanation of render time vs visibility time
+ */
+const OPACITY_VISIBILITY_THRESHOLD = 0.5;
+
+export class CookieBannerCollector {
+ private readonly config: Config;
+ private readonly logger: Logger;
+
+ constructor(config: Config, logger: Logger) {
+ this.config = config;
+ this.logger = logger;
+ }
+
+ /**
+ * Initialize cookie banner metrics tracking
+ */
+ initializeMetrics(): CookieBannerMetrics {
+ const { isBundled, isIIFE } = determineBundleStrategy(this.config);
+
+ let bundleStrategy = "Unknown";
+ if (isBundled) {
+ bundleStrategy = "Bundled";
+ } else if (isIIFE) {
+ bundleStrategy = "IIFE";
+ }
+
+ this.logger.debug(
+ `Bundle strategy detected from config: ${bundleStrategy}`,
+ {
+ bundleType: this.config.techStack?.bundleType,
+ isBundled,
+ isIIFE,
+ }
+ );
+
+ return {
+ detectionStartTime: 0,
+ bannerRenderTime: 0,
+ bannerInteractiveTime: 0,
+ bannerScriptLoadTime: 0,
+ bannerLayoutShiftImpact: 0,
+ bannerNetworkRequests: 0,
+ bannerBundleSize: 0,
+ bannerMainThreadBlockingTime: 0,
+ isBundled,
+ isIIFE,
+ bannerDetected: false,
+ bannerSelector: null,
+ };
+ }
+
+ /**
+ * Set up cookie banner detection script in the browser.
+ *
+ * This method injects a detection script that runs before page load to track
+ * banner appearance metrics. It measures both technical render time (when element
+ * appears in DOM) and user-perceived visibility time (when opacity > threshold).
+ *
+ * Metrics tracked:
+ * - bannerFirstSeen: Technical render time (element in DOM)
+ * - bannerVisibleTime: User-perceived visibility (opacity > 0.5)
+ * - bannerInteractive: When buttons become clickable
+ *
+ * @see METHODOLOGY.md for detailed measurement approach
+ */
+ async setupDetection(page: Page): Promise {
+ const selectors = this.config.cookieBanner?.selectors || [];
+
+ await page.addInitScript(
+ (config: {
+ bannerSelectors: string[];
+ pollInterval: number;
+ detectionTimeout: number;
+ opacityThreshold: number;
+ }) => {
+ const {
+ bannerSelectors,
+ pollInterval,
+ detectionTimeout,
+ opacityThreshold,
+ } = config;
+ // Store initial performance baseline
+ // pageLoadStart = 0 means all times are relative to navigation start
+ // performance.now() already returns time since navigation (timeOrigin)
+ (window as unknown as WindowWithCookieMetrics).__cookieBannerMetrics = {
+ pageLoadStart: 0,
+ bannerDetectionStart: 0,
+ bannerFirstSeen: 0,
+ bannerVisibleTime: 0, // Track when banner is actually visible (opacity > 0.5)
+ bannerInteractive: 0,
+ layoutShiftsBefore: 0,
+ layoutShiftsAfter: 0,
+ detected: false,
+ selector: null,
+ };
+
+ // Monitor for layout shifts specifically
+ let cumulativeLayoutShift = 0;
+ if ("PerformanceObserver" in window) {
+ // Type guard to safely check if entry is a LayoutShiftEntry
+ const isLayoutShiftEntry = (
+ entry: PerformanceEntry
+ ): entry is LayoutShiftEntry =>
+ entry.entryType === "layout-shift" &&
+ "value" in entry &&
+ typeof entry.value === "number" &&
+ "hadRecentInput" in entry &&
+ typeof entry.hadRecentInput === "boolean";
+
+ const clsObserver = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ if (isLayoutShiftEntry(entry) && !entry.hadRecentInput) {
+ cumulativeLayoutShift += entry.value;
+ (
+ window as unknown as WindowWithCookieMetrics
+ ).__cookieBannerMetrics.layoutShiftsAfter =
+ cumulativeLayoutShift;
+ }
+ }
+ });
+ clsObserver.observe({ type: "layout-shift", buffered: true });
+ // Store observer reference for cleanup
+ (
+ window as unknown as WindowWithCookieMetrics
+ ).__cookieBannerMetrics.clsObserver = clsObserver;
+ }
+
+ // Cookie banner detection logic
+ const detectCookieBanner = () => {
+ const metrics = (window as unknown as WindowWithCookieMetrics)
+ .__cookieBannerMetrics;
+ // performance.now() returns time since navigation start (timeOrigin)
+ metrics.bannerDetectionStart = performance.now();
+
+ for (const selector of bannerSelectors) {
+ try {
+ const element = document.querySelector(selector);
+ if (element) {
+ const rect = element.getBoundingClientRect();
+ const computedStyle = window.getComputedStyle(element);
+ const opacity = Number.parseFloat(computedStyle.opacity);
+
+ /**
+ * Technical render check: Element exists in DOM with dimensions.
+ * This determines when the banner element first appears, regardless
+ * of visibility. Used for bannerRenderTime metric.
+ */
+ const isRendered =
+ rect.width > 0 &&
+ rect.height > 0 &&
+ computedStyle.visibility !== "hidden" &&
+ computedStyle.display !== "none";
+
+ /**
+ * User-perceived visibility check: Element is rendered AND visible.
+ * Uses opacity threshold to account for CSS animations. A banner that
+ * renders instantly but fades in slowly will only be considered "visible"
+ * once opacity > 0.5. This ensures scores reflect actual user experience.
+ * Used for bannerVisibilityTime metric (primary scoring metric).
+ */
+ const isVisible = isRendered && opacity > opacityThreshold;
+
+ if (isRendered) {
+ const bannerMetrics = (
+ window as unknown as WindowWithCookieMetrics
+ ).__cookieBannerMetrics;
+ // performance.now() returns time since navigation start (timeOrigin)
+ const now = performance.now();
+
+ /**
+ * Track technical render time: When banner element first appears in DOM.
+ * This is measured from navigationStart and represents when the element
+ * has dimensions and is not hidden. Used for bannerRenderTime metric.
+ */
+ if (!bannerMetrics.detected) {
+ bannerMetrics.detected = true;
+ bannerMetrics.selector = selector;
+ bannerMetrics.bannerFirstSeen = now;
+ bannerMetrics.layoutShiftsBefore = cumulativeLayoutShift;
+ }
+
+ /**
+ * Track user-perceived visibility time: When banner becomes visible to users.
+ * Only recorded when opacity > threshold (0.5), accounting for CSS animations.
+ * This is the primary metric used for scoring (bannerVisibilityTime).
+ * Only update if not set yet or if this measurement is earlier.
+ */
+ if (
+ isVisible &&
+ (bannerMetrics.bannerVisibleTime === 0 ||
+ now < bannerMetrics.bannerVisibleTime)
+ ) {
+ bannerMetrics.bannerVisibleTime = now;
+ }
+
+ // Check if banner is interactive
+ const buttons = element.querySelectorAll(
+ 'button, a, [role="button"], [onclick]'
+ );
+ if (buttons.length > 0) {
+ // Test if buttons are actually clickable
+ const firstButton = buttons[0] as HTMLElement;
+ if (firstButton.offsetParent !== null) {
+ // Element is visible and clickable
+ bannerMetrics.bannerInteractive = now;
+ }
+ }
+
+ return true;
+ }
+ }
+ } catch (_error) {
+ // Ignore selector errors and continue checking other selectors
+ }
+ }
+ return false;
+ };
+
+ // Start detection immediately - try right away, then poll if needed
+ // This ensures we catch banners that appear instantly (like in offline/bundled mode)
+ const startDetection = () => {
+ // Try immediate detection first (catches instant renders)
+ if (!detectCookieBanner()) {
+ // If not found immediately, start polling
+ const interval = setInterval(() => {
+ if (detectCookieBanner()) {
+ clearInterval(interval);
+ }
+ }, pollInterval);
+
+ // Stop checking after timeout
+ setTimeout(() => clearInterval(interval), detectionTimeout);
+ }
+ };
+
+ // Always start immediately to catch banners rendered very early.
+ startDetection();
+ if (document.readyState === "loading") {
+ // Run one more detection pass once DOM is fully parsed.
+ document.addEventListener("DOMContentLoaded", startDetection, {
+ once: true,
+ });
+ }
+ },
+ {
+ bannerSelectors: selectors,
+ pollInterval: BENCHMARK_CONSTANTS.BANNER_POLL_INTERVAL,
+ detectionTimeout: BENCHMARK_CONSTANTS.BANNER_DETECTION_TIMEOUT,
+ opacityThreshold: OPACITY_VISIBILITY_THRESHOLD,
+ }
+ );
+ }
+
+ /**
+ * Collect cookie banner specific metrics from the browser.
+ *
+ * Returns both technical metrics (bannerRenderTime) and user-perceived metrics
+ * (bannerVisibilityTime). The visibilityTime metric is used for scoring as it
+ * reflects actual user experience including CSS animations.
+ *
+ * All timing metrics are relative to navigationStart (includes TTFB).
+ *
+ * @returns CookieBannerData with render time, visibility time, interactive time,
+ * layout shift impact, and viewport coverage
+ * @see METHODOLOGY.md for detailed explanation of metrics
+ */
+ async collectMetrics(page: Page): Promise {
+ return await page.evaluate(
+ (config: { percentageMultiplier: number }) => {
+ const { percentageMultiplier } = config;
+ const metrics = (window as unknown as WindowWithCookieMetrics)
+ .__cookieBannerMetrics;
+ if (!metrics) {
+ return null;
+ }
+
+ // Read final layout shift value before disconnecting observer
+ const layoutShiftImpact =
+ metrics.layoutShiftsAfter - metrics.layoutShiftsBefore;
+
+ // Clean up PerformanceObserver to prevent memory leaks
+ if (metrics.clsObserver) {
+ metrics.clsObserver.disconnect();
+ metrics.clsObserver = undefined;
+ }
+
+ const domPresenceTime =
+ metrics.detected && metrics.bannerFirstSeen > 0
+ ? metrics.bannerFirstSeen - metrics.pageLoadStart
+ : 0;
+ const userVisibleTime = (() => {
+ if (metrics.detected && metrics.bannerVisibleTime > 0) {
+ return metrics.bannerVisibleTime - metrics.pageLoadStart;
+ }
+ if (metrics.detected && metrics.bannerFirstSeen > 0) {
+ return metrics.bannerFirstSeen - metrics.pageLoadStart;
+ }
+ return 0;
+ })();
+
+ return {
+ detected: metrics.detected,
+ selector: metrics.selector,
+ bannerRenderTime: domPresenceTime,
+ bannerVisibilityTime: userVisibleTime,
+ domPresenceTime,
+ userVisibleTime,
+ bannerInteractiveTime:
+ metrics.detected && metrics.bannerInteractive > 0
+ ? metrics.bannerInteractive - metrics.pageLoadStart
+ : 0,
+ bannerHydrationTime:
+ metrics.detected && metrics.bannerInteractive > 0
+ ? metrics.bannerInteractive - metrics.bannerFirstSeen
+ : 0,
+ layoutShiftImpact,
+ viewportCoverage: metrics.detected
+ ? (() => {
+ if (!metrics.selector) {
+ return 0;
+ }
+ const element = document.querySelector(metrics.selector);
+ if (element) {
+ const rect = element.getBoundingClientRect();
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ // Calculate the intersection of element rect with viewport
+ // Only count the visible portion of the banner
+ const visibleLeft = Math.max(0, rect.left);
+ const visibleTop = Math.max(0, rect.top);
+ const visibleRight = Math.min(
+ viewportWidth,
+ rect.left + rect.width
+ );
+ const visibleBottom = Math.min(
+ viewportHeight,
+ rect.top + rect.height
+ );
+
+ // Calculate visible area (intersection with viewport)
+ const visibleWidth = Math.max(0, visibleRight - visibleLeft);
+ const visibleHeight = Math.max(0, visibleBottom - visibleTop);
+ const visibleArea = visibleWidth * visibleHeight;
+ const viewportArea = viewportWidth * viewportHeight;
+
+ if (viewportArea === 0) {
+ return 0;
+ }
+
+ return (visibleArea / viewportArea) * percentageMultiplier;
+ }
+ return 0;
+ })()
+ : 0,
+ };
+ },
+ { percentageMultiplier: BENCHMARK_CONSTANTS.PERCENTAGE_MULTIPLIER }
+ );
+ }
+}
diff --git a/packages/benchmark/src/index.ts b/packages/benchmark/src/index.ts
new file mode 100644
index 0000000..ae05759
--- /dev/null
+++ b/packages/benchmark/src/index.ts
@@ -0,0 +1,31 @@
+/** biome-ignore-all lint/performance/noBarrelFile: this is a barrel file */
+
+// Utilities
+export { determineBundleStrategy } from "./bundle-strategy";
+export { BENCHMARK_CONSTANTS, BUNDLE_TYPES } from "./constants";
+export { CookieBannerCollector } from "./cookie-banner-collector";
+export { NetworkMonitor } from "./network-monitor";
+export { PerfumeCollector } from "./perfume-collector";
+export { ResourceTimingCollector } from "./resource-timing-collector";
+
+// Types
+export type {
+ BundleType,
+ BundleStrategy,
+ CacheMode,
+ Config,
+ CookieBannerConfig,
+ CookieBannerData,
+ CookieBannerMetrics,
+ CoreWebVitals,
+ LayoutShiftEntry,
+ MeasurementConfig,
+ NetworkMetrics,
+ NetworkProfile,
+ NetworkRequest,
+ PerfumeMetrics,
+ ResourceTimingData,
+ RunProfile,
+ WindowWithCookieMetrics,
+ WindowWithPerfumeMetrics,
+} from "./types";
diff --git a/packages/benchmark/src/network-monitor.ts b/packages/benchmark/src/network-monitor.ts
new file mode 100644
index 0000000..1266416
--- /dev/null
+++ b/packages/benchmark/src/network-monitor.ts
@@ -0,0 +1,262 @@
+import type { Logger } from "@c15t/logger";
+import type { Page, Request, Response, Route } from "@playwright/test";
+import type { Config, NetworkMetrics, NetworkRequest } from "./types";
+
+const CONTENT_RANGE_TOTAL_BYTES_PATTERN = /\/(\d+)$/;
+
+type PendingRequest = {
+ url: string;
+ startTime: number;
+ resourceType: string;
+ isScript: boolean;
+ isThirdParty: boolean;
+};
+
+export class NetworkMonitor {
+ private readonly config: Config;
+ private readonly logger: Logger;
+ private networkRequests: NetworkRequest[] = [];
+ private metrics: NetworkMetrics = {
+ bannerNetworkRequests: 0,
+ bannerBundleSize: 0,
+ };
+ private readonly pending = new Map();
+ private detach: Array<() => void> = [];
+
+ constructor(config: Config, logger: Logger) {
+ this.config = config;
+ this.logger = logger;
+ }
+
+ /**
+ * Set up passive network event monitoring.
+ * This avoids request interception side effects and records transfer sizes in bytes.
+ */
+ async setupMonitoring(page: Page, targetUrl?: string): Promise {
+ this.teardownListeners();
+ const monitorMode = this.config.measurement.networkMonitorMode || "passive";
+
+ const firstPartyUrl = this.config.url || targetUrl || page.url();
+ let firstPartyHostname = "";
+ try {
+ firstPartyHostname = new URL(firstPartyUrl).hostname;
+ } catch {
+ firstPartyHostname = "";
+ }
+
+ const handleRequest = (request: Request) => {
+ const url = request.url();
+ const resourceType = request.resourceType();
+ let requestHostname = "";
+ try {
+ requestHostname = new URL(url).hostname;
+ } catch {
+ requestHostname = "";
+ }
+
+ const isScript = resourceType === "script";
+ const isThirdParty =
+ Boolean(requestHostname) &&
+ Boolean(firstPartyHostname) &&
+ requestHostname !== firstPartyHostname;
+
+ this.pending.set(request, {
+ url,
+ startTime: Date.now(),
+ resourceType,
+ isScript,
+ isThirdParty,
+ });
+ };
+
+ const commitRequest = async (
+ request: Request,
+ response?: Response | null
+ ) => {
+ const pendingRequest = this.pending.get(request);
+ if (!pendingRequest) {
+ return;
+ }
+ this.pending.delete(request);
+
+ let size = 0;
+ try {
+ const requestSizes = await request.sizes();
+ const responseBodySize = Number.isFinite(requestSizes.responseBodySize)
+ ? requestSizes.responseBodySize
+ : 0;
+ const responseHeadersSize = Number.isFinite(
+ requestSizes.responseHeadersSize
+ )
+ ? requestSizes.responseHeadersSize
+ : 0;
+ const protocolTransferSize = responseBodySize + responseHeadersSize;
+ if (protocolTransferSize > 0) {
+ size = protocolTransferSize;
+ } else if (responseBodySize > 0) {
+ size = responseBodySize;
+ }
+ } catch {
+ // Some requests may not expose protocol sizes; fall back to headers.
+ }
+
+ if (response) {
+ const headers = response.headers();
+ const contentLength = headers["content-length"];
+ if (size === 0 && contentLength) {
+ const parsedContentLength = Number.parseInt(contentLength, 10);
+ if (Number.isFinite(parsedContentLength) && parsedContentLength > 0) {
+ size = parsedContentLength;
+ }
+ }
+ if (size === 0) {
+ const contentRange = headers["content-range"];
+ if (contentRange) {
+ const match = contentRange.match(CONTENT_RANGE_TOTAL_BYTES_PATTERN);
+ if (match) {
+ const parsedContentRange = Number.parseInt(match[1], 10);
+ if (
+ Number.isFinite(parsedContentRange) &&
+ parsedContentRange > 0
+ ) {
+ size = parsedContentRange;
+ }
+ }
+ }
+ }
+ }
+
+ if (size === 0 && response) {
+ try {
+ const body = await response.body();
+ if (body.byteLength > 0) {
+ size = body.byteLength;
+ }
+ } catch {
+ // Body access can fail for some opaque/streamed responses.
+ }
+ }
+
+ const duration = Math.max(0, Date.now() - pendingRequest.startTime);
+
+ this.networkRequests.push({
+ url: pendingRequest.url,
+ size,
+ duration,
+ startTime: pendingRequest.startTime,
+ isScript: pendingRequest.isScript,
+ isThirdParty: pendingRequest.isThirdParty,
+ });
+
+ if (pendingRequest.isThirdParty) {
+ this.metrics.bannerNetworkRequests += 1;
+ this.metrics.bannerBundleSize += size;
+ this.logger.debug(
+ `Third-party request detected: ${pendingRequest.url} [${pendingRequest.resourceType}] (${size} bytes)`
+ );
+ }
+ };
+
+ const handleRequestFinished = async (request: Request) => {
+ try {
+ await commitRequest(request, await request.response());
+ } catch (error) {
+ this.logger.warn(
+ `Failed to commit finished request ${request.url()}: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
+ };
+
+ const handleRequestFailed = async (request: Request) => {
+ try {
+ await commitRequest(request, await request.response());
+ } catch (error) {
+ this.logger.warn(
+ `Failed to commit failed request ${request.url()}: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ }
+ };
+
+ if (monitorMode === "route-debug") {
+ const handleRoute = async (route: Route) => {
+ const request = route.request();
+ this.logger.debug(
+ `[route-debug] ${request.method()} ${request.resourceType()} ${request.url()}`
+ );
+ await route.continue();
+ };
+ await page.route("**/*", handleRoute);
+ this.detach.push(() => {
+ page.unroute("**/*", handleRoute).catch(() => {
+ // Ignore teardown-time unroute errors.
+ });
+ });
+ }
+
+ page.on("request", handleRequest);
+ page.on("requestfinished", handleRequestFinished);
+ page.on("requestfailed", handleRequestFailed);
+
+ this.detach.push(() => page.off("request", handleRequest));
+ this.detach.push(() => page.off("requestfinished", handleRequestFinished));
+ this.detach.push(() => page.off("requestfailed", handleRequestFailed));
+ }
+
+ private teardownListeners(): void {
+ for (const detach of this.detach) {
+ detach();
+ }
+ this.detach = [];
+ }
+
+ getNetworkRequests(): NetworkRequest[] {
+ return [...this.networkRequests];
+ }
+
+ getMetrics(): NetworkMetrics {
+ return { ...this.metrics };
+ }
+
+ calculateNetworkImpact(): {
+ totalImpact: number;
+ totalDownloadTime: number;
+ thirdPartyImpact: number;
+ scriptImpact: number;
+ } {
+ const totalImpact = this.networkRequests.reduce(
+ (acc, req) => acc + req.size,
+ 0
+ );
+ const totalDownloadTime = this.networkRequests.reduce(
+ (acc, req) => acc + req.duration,
+ 0
+ );
+ const thirdPartyImpact = this.networkRequests
+ .filter((req) => req.isThirdParty)
+ .reduce((acc, req) => acc + req.size, 0);
+ const scriptImpact = this.networkRequests
+ .filter((req) => req.isScript)
+ .reduce((acc, req) => acc + req.size, 0);
+
+ return {
+ totalImpact,
+ totalDownloadTime,
+ thirdPartyImpact,
+ scriptImpact,
+ };
+ }
+
+ reset(): void {
+ this.networkRequests = [];
+ this.metrics = {
+ bannerNetworkRequests: 0,
+ bannerBundleSize: 0,
+ };
+ this.pending.clear();
+ this.teardownListeners();
+ }
+}
diff --git a/packages/benchmark/src/perfume-collector.ts b/packages/benchmark/src/perfume-collector.ts
new file mode 100644
index 0000000..90056af
--- /dev/null
+++ b/packages/benchmark/src/perfume-collector.ts
@@ -0,0 +1,272 @@
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import type { Logger } from "@c15t/logger";
+import type { Page } from "@playwright/test";
+import { BENCHMARK_CONSTANTS } from "./constants";
+import type { PerfumeMetrics, WindowWithPerfumeMetrics } from "./types";
+
+export class PerfumeCollector {
+ private readonly logger: Logger;
+
+ constructor(logger: Logger) {
+ this.logger = logger;
+ }
+ /**
+ * Setup Perfume.js in the browser to collect performance metrics
+ */
+ async setupPerfume(page: Page): Promise {
+ // Load Perfume.js UMD bundle from node_modules
+ const perfumeScript = this.loadPerfumeScript();
+
+ await page.addInitScript((perfumeScriptCode: string) => {
+ // Initialize storage object
+ const win = window as WindowWithPerfumeMetrics;
+ win.__perfumeMetrics = {};
+
+ if (!perfumeScriptCode) {
+ // Perfume.js failed to load, continue without it
+ return;
+ }
+
+ try {
+ // Execute Perfume.js UMD bundle using script injection
+ // This creates the Perfume constructor on window
+ const script = document.createElement("script");
+ script.textContent = perfumeScriptCode;
+ document.head.appendChild(script);
+ document.head.removeChild(script);
+
+ // Initialize Perfume with analytics tracker
+ // @ts-expect-error - Perfume is loaded from UMD bundle
+ new window.Perfume({
+ analyticsTracker: ({
+ metricName,
+ data,
+ rating,
+ attribution,
+ navigatorInformation,
+ }: {
+ metricName: string;
+ data: number;
+ rating: string;
+ attribution?: unknown;
+ navigatorInformation?: {
+ deviceMemory?: number;
+ hardwareConcurrency?: number;
+ isLowEndDevice?: boolean;
+ isLowEndExperience?: boolean;
+ serviceWorkerStatus?: string;
+ };
+ }) => {
+ const metricsWin = window as WindowWithPerfumeMetrics;
+ const metrics = metricsWin.__perfumeMetrics;
+
+ // Store metric with all available data
+ if (metrics) {
+ metrics[metricName] = {
+ value: data,
+ rating,
+ attribution,
+ navigatorInformation,
+ };
+ }
+ },
+ });
+ } catch (error) {
+ // biome-ignore lint/suspicious/noConsole: This runs in browser context via addInitScript
+ console.warn("Failed to initialize Perfume.js:", error);
+ // Perfume.js is optional, continue without it
+ }
+ }, perfumeScript);
+ }
+
+ /**
+ * Load Perfume.js UMD bundle from node_modules
+ */
+ private loadPerfumeScript(): string {
+ // Try multiple possible paths for perfume.js in a monorepo setup
+ const currentDir = fileURLToPath(new URL(".", import.meta.url));
+ const possiblePaths = [
+ // Package-level node_modules (most common in monorepos)
+ join(
+ currentDir,
+ "..",
+ "..",
+ "node_modules",
+ "perfume.js",
+ "dist",
+ "perfume.umd.min.js"
+ ),
+ // Root node_modules (alternative location)
+ join(
+ currentDir,
+ "..",
+ "..",
+ "..",
+ "..",
+ "node_modules",
+ "perfume.js",
+ "dist",
+ "perfume.umd.min.js"
+ ),
+ // Alternative root location
+ join(
+ currentDir,
+ "..",
+ "..",
+ "..",
+ "node_modules",
+ "perfume.js",
+ "dist",
+ "perfume.umd.min.js"
+ ),
+ // Also try non-minified version
+ join(
+ currentDir,
+ "..",
+ "..",
+ "node_modules",
+ "perfume.js",
+ "dist",
+ "perfume.umd.js"
+ ),
+ ];
+
+ for (const perfumePath of possiblePaths) {
+ try {
+ return readFileSync(perfumePath, "utf-8");
+ } catch {
+ // Try next path
+ }
+ }
+
+ // Try using createRequire for ES modules (works in Node.js environments)
+ try {
+ const require = createRequire(import.meta.url);
+ const perfumeModulePath = require.resolve(
+ "perfume.js/dist/perfume.umd.min.js"
+ );
+ return readFileSync(perfumeModulePath, "utf-8");
+ } catch {
+ // Final fallback
+ }
+
+ this.logger.warn(
+ "Failed to load Perfume.js from node_modules, falling back to empty script"
+ );
+ return "";
+ }
+
+ /**
+ * Collect all metrics from Perfume.js
+ */
+ async collectMetrics(page: Page): Promise {
+ try {
+ // Wait until Perfume metrics are available instead of relying on a fixed sleep.
+ await page
+ .waitForFunction(
+ () => {
+ const win = window as WindowWithPerfumeMetrics;
+ const perfumeData = win.__perfumeMetrics;
+ return Boolean(perfumeData && Object.keys(perfumeData).length > 0);
+ },
+ { timeout: BENCHMARK_CONSTANTS.PERFUME_METRICS_WAIT }
+ )
+ .catch(() => {
+ // Continue with best-effort collection if metrics are delayed.
+ });
+
+ const rawMetrics = await page.evaluate(() => {
+ const win = window as WindowWithPerfumeMetrics;
+ const perfumeData = win.__perfumeMetrics;
+ return perfumeData || {};
+ });
+
+ this.logger.debug("Raw Perfume metrics:", rawMetrics);
+
+ // Get navigation timing separately
+ const navigationTiming = await page.evaluate(() => {
+ const navigation = performance.getEntriesByType("navigation")[0] as
+ | PerformanceNavigationTiming
+ | undefined;
+
+ if (!navigation) {
+ return null;
+ }
+
+ return {
+ timeToFirstByte: navigation.responseStart,
+ domInteractive: navigation.domInteractive,
+ domContentLoadedEventStart: navigation.domContentLoadedEventStart,
+ domContentLoadedEventEnd: navigation.domContentLoadedEventEnd,
+ domComplete: navigation.domComplete,
+ loadEventStart: navigation.loadEventStart,
+ loadEventEnd: navigation.loadEventEnd,
+ };
+ });
+
+ // Get network information
+ const networkInformation = await page.evaluate(() => {
+ try {
+ const nav = typeof navigator !== "undefined" ? navigator : null;
+ if (!nav) {
+ return;
+ }
+
+ // Access experimental network information API with vendor prefixes
+ // biome-ignore lint/suspicious/noExplicitAny: Experimental API requires dynamic access
+ const navAny = nav as any;
+ const connection =
+ navAny.connection ||
+ navAny.mozConnection ||
+ navAny.webkitConnection;
+
+ if (connection) {
+ return {
+ effectiveType: connection.effectiveType || "unknown",
+ downlink: connection.downlink || 0,
+ rtt: connection.rtt || 0,
+ saveData: Boolean(connection.saveData),
+ };
+ }
+
+ return;
+ } catch {
+ return;
+ }
+ });
+
+ // Convert raw metrics to PerfumeMetrics format
+ const defaultNavigationTiming = {
+ timeToFirstByte: 0,
+ domInteractive: 0,
+ domContentLoadedEventStart: 0,
+ domContentLoadedEventEnd: 0,
+ domComplete: 0,
+ loadEventStart: 0,
+ loadEventEnd: 0,
+ };
+
+ const metrics: PerfumeMetrics = {
+ firstPaint: rawMetrics.fp?.value ?? 0,
+ firstContentfulPaint: rawMetrics.FCP?.value ?? 0,
+ largestContentfulPaint: rawMetrics.LCP?.value ?? 0,
+ cumulativeLayoutShift: rawMetrics.CLS?.value ?? 0,
+ totalBlockingTime: rawMetrics.TBT?.value ?? 0,
+ firstInputDelay: rawMetrics.FID?.value ?? null,
+ interactionToNextPaint: rawMetrics.INP?.value ?? null,
+ timeToFirstByte:
+ rawMetrics.TTFB?.value ?? navigationTiming?.timeToFirstByte ?? 0,
+ navigationTiming: navigationTiming || defaultNavigationTiming,
+ networkInformation,
+ };
+
+ return metrics;
+ } catch (error) {
+ this.logger.error("Failed to collect Perfume metrics:", error);
+ return null;
+ }
+ }
+}
diff --git a/packages/benchmark/src/resource-timing-collector.ts b/packages/benchmark/src/resource-timing-collector.ts
new file mode 100644
index 0000000..acb3868
--- /dev/null
+++ b/packages/benchmark/src/resource-timing-collector.ts
@@ -0,0 +1,347 @@
+import type { Logger } from "@c15t/logger";
+import type { Page } from "@playwright/test";
+import type { Config } from "./types";
+import type { ResourceTimingData } from "./types";
+
+export class ResourceTimingCollector {
+ private readonly logger: Logger;
+ private readonly config: Config;
+
+ constructor(logger: Logger, config: Config) {
+ this.logger = logger;
+ this.config = config;
+ }
+
+ /**
+ * Collect detailed resource timing data from the browser.
+ * Sizes are emitted in bytes.
+ */
+ async collect(page: Page): Promise {
+ this.logger.debug("Collecting resource timing data...");
+
+ return await page.evaluate((cookieServiceHosts: string[]) => {
+ const HOST_LABEL_COUNT_TWO = 2;
+ const HOST_LABEL_COUNT_THREE = 3;
+ const IPV4_PART_COUNT = 4;
+ const IPV4_PART_MAX_LENGTH = 3;
+ const MAX_IPV4_PART_VALUE = 255;
+
+ const perfEntries = performance.getEntriesByType(
+ "navigation"
+ )[0] as PerformanceNavigationTiming;
+ const resourceEntries = performance.getEntriesByType(
+ "resource"
+ ) as PerformanceResourceTiming[];
+
+ const SECOND_LEVEL_PUBLIC_SUFFIXES = new Set([
+ "co.uk",
+ "org.uk",
+ "gov.uk",
+ "ac.uk",
+ "net.uk",
+ "co.jp",
+ "or.jp",
+ "go.jp",
+ "ne.jp",
+ "co.kr",
+ "co.in",
+ "co.id",
+ "co.nz",
+ "com.au",
+ "net.au",
+ "org.au",
+ "com.br",
+ "com.mx",
+ "com.tr",
+ "com.cn",
+ "com.hk",
+ "com.sg",
+ "com.tw",
+ "com.sa",
+ "com.ar",
+ "com.pl",
+ "com.ua",
+ "com.ph",
+ "com.my",
+ "com.vn",
+ ]);
+
+ const normalizeHost = (host: string): string => {
+ const trimmed = host.trim().toLowerCase();
+ if (!trimmed) {
+ return "";
+ }
+ try {
+ const parsed = trimmed.includes("://")
+ ? new URL(trimmed).hostname
+ : trimmed;
+ const withoutDot = parsed.endsWith(".")
+ ? parsed.slice(0, -1)
+ : parsed;
+ return withoutDot.startsWith("www.")
+ ? withoutDot.slice("www.".length)
+ : withoutDot;
+ } catch {
+ return "";
+ }
+ };
+
+ const isIpv4Address = (host: string): boolean => {
+ const parts = host.split(".");
+ if (parts.length !== IPV4_PART_COUNT) {
+ return false;
+ }
+ return parts.every((part) => {
+ if (!part || part.length > IPV4_PART_MAX_LENGTH) {
+ return false;
+ }
+ const codePoints = [...part];
+ if (codePoints.some((char) => char < "0" || char > "9")) {
+ return false;
+ }
+ const value = Number(part);
+ return (
+ Number.isInteger(value) &&
+ value >= 0 &&
+ value <= MAX_IPV4_PART_VALUE
+ );
+ });
+ };
+
+ const getRegistrableDomain = (host: string): string => {
+ const normalized = normalizeHost(host);
+ if (!normalized) {
+ return "";
+ }
+ if (
+ normalized === "localhost" ||
+ normalized.includes(":") ||
+ isIpv4Address(normalized)
+ ) {
+ return normalized;
+ }
+
+ const labels = normalized.split(".").filter(Boolean);
+ if (labels.length <= HOST_LABEL_COUNT_TWO) {
+ return normalized;
+ }
+
+ const lastTwo = labels.slice(-HOST_LABEL_COUNT_TWO).join(".");
+ if (
+ SECOND_LEVEL_PUBLIC_SUFFIXES.has(lastTwo) &&
+ labels.length >= HOST_LABEL_COUNT_THREE
+ ) {
+ return labels.slice(-HOST_LABEL_COUNT_THREE).join(".");
+ }
+ return lastTwo;
+ };
+
+ const getHostname = (name: string): string => {
+ try {
+ return normalizeHost(new URL(name, window.location.origin).hostname);
+ } catch {
+ return "";
+ }
+ };
+
+ const normalizedServiceHosts = cookieServiceHosts
+ .map(normalizeHost)
+ .filter(Boolean)
+ .map((host) => ({
+ host,
+ registrableDomain: getRegistrableDomain(host),
+ }));
+
+ const isFirstParty = (entry: PerformanceResourceTiming) => {
+ const hostname = getHostname(entry.name);
+ const firstPartyHost = normalizeHost(window.location.hostname);
+ if (!(hostname && firstPartyHost)) {
+ return false;
+ }
+
+ if (
+ hostname === firstPartyHost ||
+ hostname.endsWith(`.${firstPartyHost}`)
+ ) {
+ return true;
+ }
+
+ const hostnameRegistrableDomain = getRegistrableDomain(hostname);
+ const firstPartyRegistrableDomain =
+ getRegistrableDomain(firstPartyHost);
+ return (
+ Boolean(hostnameRegistrableDomain) &&
+ hostnameRegistrableDomain === firstPartyRegistrableDomain
+ );
+ };
+
+ const isCookieService = (entry: PerformanceResourceTiming) => {
+ const hostname = getHostname(entry.name);
+ if (!hostname) {
+ return false;
+ }
+ const hostnameRegistrableDomain = getRegistrableDomain(hostname);
+ return normalizedServiceHosts.some(
+ (serviceHost) =>
+ hostname === serviceHost.host ||
+ hostname.endsWith(`.${serviceHost.host}`) ||
+ (Boolean(serviceHost.registrableDomain) &&
+ hostnameRegistrableDomain === serviceHost.registrableDomain)
+ );
+ };
+
+ const scriptEntries = resourceEntries.filter(
+ (entry) => entry.initiatorType === "script"
+ );
+ const isCssResource = (resourceName: string): boolean => {
+ try {
+ const url = new URL(resourceName);
+ return url.pathname.toLowerCase().endsWith(".css");
+ } catch {
+ const withoutFragment = resourceName.split("#")[0] || resourceName;
+ const withoutQuery = withoutFragment.split("?")[0] || withoutFragment;
+ return withoutQuery.toLowerCase().endsWith(".css");
+ }
+ };
+ const styleEntries = resourceEntries.filter(
+ (entry) => entry.initiatorType === "link" && isCssResource(entry.name)
+ );
+ const imageEntries = resourceEntries.filter(
+ (entry) => entry.initiatorType === "img"
+ );
+ const fontEntries = resourceEntries.filter(
+ (entry) => entry.initiatorType === "font"
+ );
+ const otherEntries = resourceEntries.filter(
+ (entry) =>
+ !["script", "link", "img", "font"].includes(entry.initiatorType)
+ );
+
+ const calculateSize = (entries: PerformanceResourceTiming[]) =>
+ entries.reduce((acc, entry) => {
+ const size = entry.transferSize || entry.encodedBodySize || 0;
+ return acc + size;
+ }, 0);
+
+ const navigationStart = perfEntries.startTime;
+ const domContentLoaded =
+ perfEntries.domContentLoadedEventEnd - navigationStart;
+ const load = perfEntries.loadEventEnd - navigationStart;
+
+ const cookieServiceEntries = resourceEntries.filter(isCookieService);
+ const cookieServiceScriptEntries = scriptEntries.filter(isCookieService);
+
+ return {
+ timing: {
+ navigationStart,
+ domContentLoaded,
+ load,
+ scripts: {
+ bundled: {
+ loadStart: 0,
+ loadEnd: scriptEntries
+ .filter((entry) => isFirstParty(entry))
+ .reduce((acc, entry) => acc + entry.duration, 0),
+ executeStart: 0,
+ executeEnd: 0,
+ },
+ thirdParty: {
+ loadStart: 0,
+ loadEnd: scriptEntries
+ .filter((entry) => !isFirstParty(entry))
+ .reduce((acc, entry) => acc + entry.duration, 0),
+ executeStart: 0,
+ executeEnd: 0,
+ },
+ },
+ },
+ size: {
+ total: calculateSize(resourceEntries),
+ bundled: calculateSize(
+ scriptEntries.filter((entry) => isFirstParty(entry))
+ ),
+ thirdParty: calculateSize(
+ scriptEntries.filter((entry) => !isFirstParty(entry))
+ ),
+ cookieServices: calculateSize(cookieServiceEntries),
+ scripts: {
+ total: calculateSize(scriptEntries),
+ initial: calculateSize(
+ scriptEntries.filter(
+ (entry) => entry.startTime < domContentLoaded
+ )
+ ),
+ dynamic: calculateSize(
+ scriptEntries.filter(
+ (entry) => entry.startTime >= domContentLoaded
+ )
+ ),
+ thirdParty: calculateSize(
+ scriptEntries.filter((entry) => !isFirstParty(entry))
+ ),
+ cookieServices: calculateSize(cookieServiceScriptEntries),
+ },
+ styles: calculateSize(styleEntries),
+ images: calculateSize(imageEntries),
+ fonts: calculateSize(fontEntries),
+ other: calculateSize(otherEntries),
+ },
+ resources: {
+ scripts: scriptEntries.map((entry) => ({
+ name: entry.name,
+ size: entry.transferSize || entry.encodedBodySize || 0,
+ duration: entry.duration,
+ startTime: entry.startTime - navigationStart,
+ isThirdParty: !isFirstParty(entry),
+ isDynamic: entry.startTime >= domContentLoaded,
+ isCookieService: isCookieService(entry),
+ dnsTime: entry.domainLookupEnd - entry.domainLookupStart,
+ connectionTime: entry.connectEnd - entry.connectStart,
+ })),
+ styles: styleEntries.map((entry) => ({
+ name: entry.name,
+ size: entry.transferSize || entry.encodedBodySize || 0,
+ duration: entry.duration,
+ startTime: entry.startTime - navigationStart,
+ isThirdParty: !isFirstParty(entry),
+ isCookieService: isCookieService(entry),
+ })),
+ images: imageEntries.map((entry) => ({
+ name: entry.name,
+ size: entry.transferSize || entry.encodedBodySize || 0,
+ duration: entry.duration,
+ startTime: entry.startTime - navigationStart,
+ isThirdParty: !isFirstParty(entry),
+ isCookieService: isCookieService(entry),
+ })),
+ fonts: fontEntries.map((entry) => ({
+ name: entry.name,
+ size: entry.transferSize || entry.encodedBodySize || 0,
+ duration: entry.duration,
+ startTime: entry.startTime - navigationStart,
+ isThirdParty: !isFirstParty(entry),
+ isCookieService: isCookieService(entry),
+ })),
+ other: otherEntries.map((entry) => ({
+ name: entry.name,
+ size: entry.transferSize || entry.encodedBodySize || 0,
+ duration: entry.duration,
+ startTime: entry.startTime - navigationStart,
+ isThirdParty: !isFirstParty(entry),
+ isCookieService: isCookieService(entry),
+ type: entry.initiatorType,
+ })),
+ },
+ language: (() => {
+ const docLang = (
+ document.documentElement.getAttribute("lang") || ""
+ ).trim();
+ return (
+ docLang || navigator.language || navigator.languages?.[0] || "en"
+ );
+ })(),
+ duration: load,
+ };
+ }, this.config.cookieBanner.serviceHosts || []);
+ }
+}
diff --git a/packages/benchmark/src/types.ts b/packages/benchmark/src/types.ts
new file mode 100644
index 0000000..13da13d
--- /dev/null
+++ b/packages/benchmark/src/types.ts
@@ -0,0 +1,305 @@
+import type { Page } from "@playwright/test";
+
+// Config types
+export type CookieBannerConfig = {
+ selectors: string[];
+ serviceHosts: string[];
+ waitForVisibility: boolean;
+ measureViewportCoverage: boolean;
+ expectedLayoutShift: boolean;
+ serviceName: string;
+};
+
+export type BundleType = "esm" | "cjs" | "iife" | "bundled";
+export type CacheMode = "cold" | "warm" | "mixed";
+export type NetworkProfile = "none" | "slow4g" | "fast3g";
+
+export type RunProfile = {
+ cacheMode: CacheMode;
+ networkProfile: NetworkProfile;
+ cpuSlowdownMultiplier: number;
+};
+
+export type MeasurementConfig = {
+ minSuccessfulIterations: number;
+ maxFailureRate: number;
+ stabilityThresholdCv: number;
+ networkMonitorMode?: "passive" | "route-debug";
+};
+
+export type Config = {
+ name: string;
+ url?: string;
+ testId?: string;
+ id?: string;
+ iterations: number;
+ baseline?: boolean;
+ custom?: (page: Page) => Promise;
+ runProfile: RunProfile;
+ measurement: MeasurementConfig;
+ remote?: {
+ enabled?: boolean;
+ url?: string;
+ headers?: Record;
+ };
+ cookieBanner: CookieBannerConfig;
+ internationalization: {
+ detection: string;
+ stringLoading: string;
+ };
+ techStack: {
+ bundler: string;
+ bundleType: BundleType | BundleType[];
+ frameworks: string[];
+ languages: string[];
+ packageManager: string;
+ typescript: boolean;
+ };
+ source: {
+ github: string | false;
+ isOpenSource: boolean | string;
+ license: string;
+ npm: string | false;
+ website?: string;
+ };
+ includes: {
+ backend: string | string[] | false;
+ components: string[];
+ };
+ company?: {
+ name: string;
+ website: string;
+ avatar: string;
+ };
+ tags?: string[];
+};
+
+// Performance API type definitions
+export interface LayoutShiftEntry extends PerformanceEntry {
+ value: number;
+ hadRecentInput: boolean;
+}
+
+// Cookie banner types
+export interface WindowWithCookieMetrics extends Window {
+ __cookieBannerMetrics: {
+ pageLoadStart: number;
+ bannerDetectionStart: number;
+ bannerFirstSeen: number;
+ bannerVisibleTime: number; // When banner is actually visible (opacity > 0.5) - for UX metrics
+ bannerInteractive: number;
+ layoutShiftsBefore: number;
+ layoutShiftsAfter: number;
+ detected: boolean;
+ selector: string | null;
+ clsObserver?: PerformanceObserver;
+ };
+}
+
+export type CookieBannerMetrics = {
+ detectionStartTime: number;
+ bannerRenderTime: number;
+ bannerInteractiveTime: number;
+ bannerScriptLoadTime: number;
+ bannerLayoutShiftImpact: number;
+ bannerNetworkRequests: number;
+ bannerBundleSize: number;
+ bannerMainThreadBlockingTime: number;
+ isBundled: boolean;
+ isIIFE: boolean;
+ bannerDetected: boolean;
+ bannerSelector: string | null;
+};
+
+export type CookieBannerData = {
+ detected: boolean;
+ selector: string | null;
+ /** DOM presence time (ms): when banner is first painted to screen (technical render). */
+ bannerRenderTime: number;
+ /** User-visible time (ms): when banner is actually visible to users (opacity > 0.5). Used for scoring. */
+ bannerVisibilityTime: number;
+ /** Explicit alias for downstream; same as bannerRenderTime. */
+ domPresenceTime?: number;
+ /** Explicit alias for downstream; same as bannerVisibilityTime. */
+ userVisibleTime?: number;
+ bannerInteractiveTime: number;
+ bannerHydrationTime: number;
+ layoutShiftImpact: number;
+ viewportCoverage: number;
+};
+
+// Network types
+export type NetworkRequest = {
+ url: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isScript: boolean;
+ isThirdParty: boolean;
+};
+
+export type NetworkMetrics = {
+ bannerNetworkRequests: number;
+ bannerBundleSize: number;
+};
+
+// Bundle strategy types
+export type BundleStrategy = {
+ isBundled: boolean;
+ isIIFE: boolean;
+ bundleType: BundleType | BundleType[] | undefined;
+};
+
+// Resource timing types
+export type ResourceTimingData = {
+ timing: {
+ navigationStart: number;
+ domContentLoaded: number;
+ load: number;
+ scripts: {
+ bundled: {
+ loadStart: number;
+ loadEnd: number;
+ executeStart: number;
+ executeEnd: number;
+ };
+ thirdParty: {
+ loadStart: number;
+ loadEnd: number;
+ executeStart: number;
+ executeEnd: number;
+ };
+ };
+ };
+ size: {
+ total: number;
+ bundled: number;
+ thirdParty: number;
+ cookieServices: number;
+ scripts: {
+ total: number;
+ initial: number;
+ dynamic: number;
+ thirdParty: number;
+ cookieServices: number;
+ };
+ styles: number;
+ images: number;
+ fonts: number;
+ other: number;
+ };
+ resources: {
+ scripts: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isDynamic: boolean;
+ isCookieService: boolean;
+ dnsTime: number;
+ connectionTime: number;
+ }>;
+ styles: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ }>;
+ images: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ }>;
+ fonts: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ }>;
+ other: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ type: string;
+ }>;
+ };
+ language: string;
+ duration: number;
+};
+
+// Core web vitals types
+export type CoreWebVitals = {
+ paint?: {
+ firstPaint?: number;
+ firstContentfulPaint?: number;
+ };
+ largestContentfulPaint?: number;
+ cumulativeLayoutShift?: number;
+ totalBlockingTime?: number;
+ domCompleteTiming?: number;
+ pageloadTiming?: number;
+ totalBytes?: number;
+};
+
+// Perfume.js metrics types
+export type PerfumeMetrics = {
+ // Core Web Vitals (replacing playwright-performance-metrics)
+ firstPaint: number;
+ firstContentfulPaint: number;
+ largestContentfulPaint: number;
+ cumulativeLayoutShift: number;
+ totalBlockingTime: number;
+
+ // Enhanced metrics (new)
+ firstInputDelay: number | null;
+ interactionToNextPaint: number | null;
+ timeToFirstByte: number;
+
+ // Detailed navigation timing
+ navigationTiming: {
+ timeToFirstByte: number;
+ domInteractive: number;
+ domContentLoadedEventStart: number;
+ domContentLoadedEventEnd: number;
+ domComplete: number;
+ loadEventStart: number;
+ loadEventEnd: number;
+ };
+
+ // Network information (optional)
+ networkInformation?: {
+ effectiveType: string;
+ downlink: number;
+ rtt: number;
+ saveData: boolean;
+ };
+};
+
+export interface WindowWithPerfumeMetrics extends Window {
+ __perfumeMetrics?: Record<
+ string,
+ {
+ value: number;
+ rating: string;
+ attribution?: unknown;
+ navigatorInformation?: {
+ deviceMemory?: number;
+ hardwareConcurrency?: number;
+ isLowEndDevice?: boolean;
+ isLowEndExperience?: boolean;
+ serviceWorkerStatus?: string;
+ };
+ }
+ >;
+}
diff --git a/packages/benchmark/tsconfig.json b/packages/benchmark/tsconfig.json
new file mode 100644
index 0000000..fde9b9d
--- /dev/null
+++ b/packages/benchmark/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/cli/base.json b/packages/cli/base.json
deleted file mode 100644
index 0756a8c..0000000
--- a/packages/cli/base.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "$schema": "https://json.schemastore.org/tsconfig",
- "compilerOptions": {
- "declaration": true,
- "declarationMap": true,
- "esModuleInterop": true,
- "incremental": false,
- "isolatedModules": true,
- "lib": ["es2022", "DOM", "DOM.Iterable"],
- "module": "NodeNext",
- "moduleDetection": "force",
- "moduleResolution": "NodeNext",
- "noUncheckedIndexedAccess": true,
- "resolveJsonModule": true,
- "skipLibCheck": true,
- "strict": true,
- "target": "ES2022"
- }
-}
diff --git a/packages/cli/package.json b/packages/cli/package.json
deleted file mode 100644
index 38f70f7..0000000
--- a/packages/cli/package.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "name": "@cookiebench/cli",
- "version": "0.0.0",
- "private": true,
- "type": "module",
- "exports": "./dist/index.mjs",
- "main": "./dist/index.mjs",
- "module": "dist/index.mjs",
- "bin": {
- "benchmark-cli": "dist/index.mjs"
- },
- "scripts": {
- "build": "rslib build",
- "check-types": "tsc --noEmit",
- "dev": "rslib build --watch",
- "format": "biome format . --write",
- "lint": "biome lint .",
- "start": "node ./dist/index.mjs"
- },
- "dependencies": {
- "@clack/prompts": "1.0.0-alpha.6",
- "@playwright/test": "^1.57.0",
- "cli-table3": "^0.6.5",
- "dotenv": "^17.2.3",
- "package-manager-detector": "^1.6.0",
- "picocolors": "^1.1.1"
- },
- "devDependencies": {
- "@rsdoctor/rspack-plugin": "^1.3.12",
- "@rslib/core": "^0.18.3",
- "@types/node": "^24.10.1",
- "playwright-performance-metrics": "^1.2.4",
- "typescript": "^5.9.3"
- }
-}
\ No newline at end of file
diff --git a/packages/cli/rslib.config.ts b/packages/cli/rslib.config.ts
deleted file mode 100644
index 3fbccaf..0000000
--- a/packages/cli/rslib.config.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { defineConfig } from '@rslib/core';
-
-export default defineConfig({
- source: {
- entry: {
- index: './src/index.ts',
- },
- exclude: ['figlet'],
- },
- lib: [
- {
- bundle: true,
- dts: true,
- format: 'esm',
- },
- ],
- output: {
- target: 'node',
- cleanDistPath: true,
- filename: {
- js: '[name].mjs',
- },
- },
-});
diff --git a/packages/cli/src/commands/benchmark/benchmark-runner.ts b/packages/cli/src/commands/benchmark/benchmark-runner.ts
deleted file mode 100644
index a1f2568..0000000
--- a/packages/cli/src/commands/benchmark/benchmark-runner.ts
+++ /dev/null
@@ -1,452 +0,0 @@
-import { chromium, type Page } from "@playwright/test";
-import { readConfig } from "../../utils";
-import { buildAndServeNextApp, cleanupServer } from "../../lib/server";
-import { PerformanceMetricsCollector } from "playwright-performance-metrics";
-import { writeFile } from "node:fs/promises";
-import { join } from "node:path";
-import type { ServerInfo } from "../../types";
-
-import type {
- Config,
- BenchmarkResult,
- BenchmarkDetails,
- CookieBannerMetrics,
-} from "./types";
-import { BENCHMARK_CONSTANTS } from "./constants";
-import { determineBundleStrategy } from "./bundle-strategy";
-import { NetworkMonitor } from "./network-monitor";
-import { CookieBannerDetector } from "./cookie-banner-detector";
-import { ResourceCollector } from "./resource-collector";
-import { MetricsCalculator } from "./metrics-calculator";
-import { calculateScores, printScores } from "../../utils/scoring";
-import type { RawBenchmarkDetail } from "../results";
-
-async function runBenchmark(
- page: Page,
- url: string,
- config: Config
-): Promise {
- console.log(`🔍 [DEBUG] Starting cookie banner benchmark for: ${url}`);
- console.log(
- "🔍 [DEBUG] Cookie banner selectors:",
- config.cookieBanner?.selectors || []
- );
- console.log(
- "🔍 [DEBUG] Bundle type from config:",
- config.techStack?.bundleType
- );
-
- // Set custom headers if provided for remote benchmarking
- if (config.remote?.enabled && config.remote.headers) {
- console.log("🔍 [DEBUG] Setting custom headers:", config.remote.headers);
- await page.setExtraHTTPHeaders(config.remote.headers);
- }
-
- // Initialize components
- const collector = new PerformanceMetricsCollector();
- const networkMonitor = new NetworkMonitor();
- const bannerDetector = new CookieBannerDetector();
- const resourceCollector = new ResourceCollector();
- const metricsCalculator = new MetricsCalculator();
-
- // Determine bundle strategy
- const bundleStrategy = determineBundleStrategy(config);
- console.log(
- `🔍 [BUNDLE-STRATEGY] Detected from config: ${
- bundleStrategy.isBundled
- ? "Bundled"
- : bundleStrategy.isIIFE
- ? "IIFE"
- : "Unknown"
- }`,
- {
- bundleType: bundleStrategy.bundleType,
- isBundled: bundleStrategy.isBundled,
- isIIFE: bundleStrategy.isIIFE,
- }
- );
-
- // Initialize cookie banner metrics
- const cookieBannerMetrics: CookieBannerMetrics = {
- detectionStartTime: 0,
- bannerRenderTime: 0,
- bannerInteractiveTime: 0,
- bannerScriptLoadTime: 0,
- bannerLayoutShiftImpact: 0,
- bannerNetworkRequests: 0,
- bannerBundleSize: 0,
- bannerMainThreadBlockingTime: 0,
- isBundled: bundleStrategy.isBundled,
- isIIFE: bundleStrategy.isIIFE,
- bannerDetected: false,
- bannerSelector: null,
- };
-
- // Enable console log capture
- page.on('console', (msg) => {
- const text = msg.text();
- if (text.includes('🔍')) {
- console.log(`[BROWSER] ${text}`);
- }
- });
-
- // Setup monitoring
- await networkMonitor.setupRequestMonitoring(page, cookieBannerMetrics);
- await bannerDetector.setupDetection(page, config);
-
- console.log(`🔍 [DEBUG] Navigating to: ${url}`);
- await page.goto(url, { waitUntil: "networkidle" });
-
- // Collect core web vitals
- console.log("🔍 [DEBUG] Collecting core web vitals...");
- const coreWebVitals = await collector.collectMetrics(page, {
- timeout: BENCHMARK_CONSTANTS.METRICS_TIMEOUT,
- retryTimeout: BENCHMARK_CONSTANTS.METRICS_RETRY_TIMEOUT,
- });
-
- console.log("🔍 [DEBUG] Core web vitals collected:", {
- fcp: coreWebVitals.paint?.firstContentfulPaint,
- lcp: coreWebVitals.largestContentfulPaint,
- cls: coreWebVitals.cumulativeLayoutShift,
- tbt: coreWebVitals.totalBlockingTime,
- domComplete: coreWebVitals.domCompleteTiming,
- pageLoad: coreWebVitals.pageloadTiming,
- totalBytes: coreWebVitals.totalBytes,
- });
-
- // Collect cookie banner data
- const cookieBannerData = await bannerDetector.collectBannerData(page);
- console.log("🔍 [DEBUG] Cookie banner metrics:", cookieBannerData);
-
- // Collect resource timing
- console.log("🔍 [DEBUG] Collecting resource timing data...");
- const resourceMetrics = await resourceCollector.collectResourceTiming(page);
-
- // Calculate TTI
- const tti = metricsCalculator.calculateTTI(coreWebVitals, cookieBannerData);
-
- // Get network impact data
- const networkRequests = networkMonitor.getNetworkRequests();
-
- // Merge all metrics
- const finalMetrics = metricsCalculator.mergeBenchmarkMetrics(
- resourceMetrics,
- coreWebVitals,
- cookieBannerData,
- cookieBannerMetrics,
- networkRequests,
- config,
- tti
- );
-
- // Log final results
- metricsCalculator.logFinalResults(
- finalMetrics,
- cookieBannerMetrics,
- bundleStrategy.bundleType
- );
-
- // Cleanup
- await collector.cleanup();
- networkMonitor.reset();
-
- return finalMetrics;
-}
-
-async function runBenchmarks(
- serverUrl: string,
- config: Config
-): Promise {
- const browser = await chromium.launch({
- headless: true, // Keep headless mode for stability
- });
- const page = await browser.newPage();
- const results: BenchmarkDetails[] = [];
-
- try {
- for (let i = 0; i < config.iterations; i++) {
- console.log(
- `[Benchmark] Running iteration ${i + 1}/${config.iterations}...`
- );
- const result = await runBenchmark(page, serverUrl, config);
- results.push(result);
- }
- } finally {
- await browser.close();
- }
-
- // Create app data for transparency scoring
- const appData = {
- name: config.name,
- baseline: config.baseline || false,
- company: config.company ? JSON.stringify(config.company) : null,
- techStack: JSON.stringify(config.techStack),
- source: config.source ? JSON.stringify(config.source) : null,
- tags: config.tags ? JSON.stringify(config.tags) : null,
- };
-
- // Calculate scores
- const scores = calculateScores(
- {
- fcp: results.reduce((acc, curr) => acc + curr.timing.firstContentfulPaint, 0) / results.length,
- lcp: results.reduce((acc, curr) => acc + curr.timing.largestContentfulPaint, 0) / results.length,
- cls: results.reduce((acc, curr) => acc + curr.timing.cumulativeLayoutShift, 0) / results.length,
- tbt: results.reduce((acc, curr) => acc + curr.timing.mainThreadBlocking.total, 0) / results.length,
- tti: results.reduce((acc, curr) => acc + curr.timing.timeToInteractive, 0) / results.length,
- },
- {
- totalSize: results.reduce((acc, curr) => acc + curr.size.total, 0) / results.length,
- jsSize: results.reduce((acc, curr) => acc + curr.size.scripts.total, 0) / results.length,
- cssSize: results.reduce((acc, curr) => acc + curr.size.styles, 0) / results.length,
- imageSize: results.reduce((acc, curr) => acc + curr.size.images, 0) / results.length,
- fontSize: results.reduce((acc, curr) => acc + curr.size.fonts, 0) / results.length,
- otherSize: results.reduce((acc, curr) => acc + curr.size.other, 0) / results.length,
- },
- {
- totalRequests: results.reduce((acc, curr) =>
- acc + (curr.resources.scripts.length + curr.resources.styles.length +
- curr.resources.images.length + curr.resources.fonts.length +
- curr.resources.other.length), 0) / results.length,
- thirdPartyRequests: results.reduce((acc, curr) =>
- acc + curr.resources.scripts.filter(s => s.isThirdParty).length, 0) / results.length,
- thirdPartySize: results.reduce((acc, curr) => acc + curr.size.thirdParty, 0) / results.length,
- thirdPartyDomains: 5, // Default value
- },
- {
- cookieBannerDetected: (() => {
- // Require consistent detection across ALL iterations for true positive
- const allDetected = results.every(r => r.cookieBanner.detected);
- if (!allDetected) {
- console.log("⚠️ [SCORING] Banner detection inconsistent or failed - marking as not detected");
- }
- return allDetected;
- })(),
- cookieBannerTiming: (() => {
- // If no banners detected across any iteration, heavily penalize
- const detectionSuccess = results.some(r => r.cookieBanner.detected);
- if (!detectionSuccess) {
- console.log("⚠️ [SCORING] No banner detected in any iteration - applying penalty");
- return null; // This signals failed detection for scoring
- }
-
- // Check if any results have null timing (undetected banners)
- const timingValues = results.map(r => r.cookieBanner.visibilityTime);
- const hasNullValues = timingValues.some(t => t === null || t === 0);
-
- // If we have mixed results (some detected, some not), still penalize
- if (hasNullValues) {
- console.log("⚠️ [SCORING] Inconsistent banner detection - applying penalty");
- return null;
- }
-
- // Only return actual timing if all iterations successfully detected banner
- const validTimings = timingValues.filter((t): t is number => t !== null && t > 0);
- return validTimings.length === results.length && validTimings.length > 0
- ? validTimings.reduce((acc, curr) => acc + curr, 0) / validTimings.length
- : null;
- })(),
- cookieBannerCoverage: (() => {
- // Only calculate coverage if banner was consistently detected
- const detectionSuccess = results.every(r => r.cookieBanner.detected);
- if (!detectionSuccess) {
- console.log("⚠️ [SCORING] Inconsistent detection - setting coverage to 0");
- return 0; // No coverage score if detection failed
- }
- return results.reduce((acc, curr) => acc + curr.cookieBanner.viewportCoverage, 0) / results.length / 100;
- })(),
- },
- {
- domSize: 1500, // Default value
- mainThreadBlocking: results.reduce((acc, curr) => acc + curr.timing.mainThreadBlocking.total, 0) / results.length,
- layoutShifts: results.reduce((acc, curr) => acc + curr.timing.cumulativeLayoutShift, 0) / results.length,
- },
- config.baseline || false,
- appData
- );
-
- return {
- name: config.name,
- baseline: config.baseline || false,
- techStack: config.techStack,
- source: config.source,
- includes: config.includes,
- company: config.company,
- tags: config.tags,
- details: results,
- average: {
- firstContentfulPaint:
- results.reduce(
- (acc, curr) => acc + curr.timing.firstContentfulPaint,
- 0
- ) / results.length,
- largestContentfulPaint:
- results.reduce(
- (acc, curr) => acc + curr.timing.largestContentfulPaint,
- 0
- ) / results.length,
- timeToInteractive:
- results.reduce((acc, curr) => acc + curr.timing.timeToInteractive, 0) /
- results.length,
- totalBlockingTime:
- results.reduce(
- (acc, curr) => acc + curr.timing.mainThreadBlocking.total,
- 0
- ) / results.length,
- speedIndex: 0, // Default value
- timeToFirstByte: 0, // Default value
- firstInputDelay: 0, // Default value
- cumulativeLayoutShift:
- results.reduce(
- (acc, curr) => acc + curr.timing.cumulativeLayoutShift,
- 0
- ) / results.length,
- domSize: 0, // Default value
- totalRequests:
- results.reduce(
- (acc, curr) =>
- acc +
- (curr.resources.scripts.length +
- curr.resources.styles.length +
- curr.resources.images.length +
- curr.resources.fonts.length +
- curr.resources.other.length),
- 0
- ) / results.length,
- totalSize:
- results.reduce((acc, curr) => acc + curr.size.total, 0) /
- results.length,
- jsSize:
- results.reduce((acc, curr) => acc + curr.size.scripts.total, 0) /
- results.length,
- cssSize:
- results.reduce((acc, curr) => acc + curr.size.styles, 0) / results.length,
- imageSize:
- results.reduce((acc, curr) => acc + curr.size.images, 0) / results.length,
- fontSize:
- results.reduce((acc, curr) => acc + curr.size.fonts, 0) / results.length,
- otherSize:
- results.reduce((acc, curr) => acc + curr.size.other, 0) / results.length,
- thirdPartyRequests:
- results.reduce(
- (acc, curr) => acc + curr.resources.scripts.filter(s => s.isThirdParty).length,
- 0
- ) / results.length,
- thirdPartySize:
- results.reduce((acc, curr) => acc + curr.size.thirdParty, 0) / results.length,
- thirdPartyDomains: 0, // Default value
- thirdPartyCookies: 0, // Default value
- thirdPartyLocalStorage: 0, // Default value
- thirdPartySessionStorage: 0, // Default value
- thirdPartyIndexedDB: 0, // Default value
- thirdPartyCache: 0, // Default value
- thirdPartyServiceWorkers: 0, // Default value
- thirdPartyWebWorkers: 0, // Default value
- thirdPartyWebSockets: 0, // Default value
- thirdPartyBeacons: 0, // Default value
- thirdPartyFetch: 0, // Default value
- thirdPartyXHR: 0, // Default value
- thirdPartyScripts: 0, // Default value
- thirdPartyStyles: 0, // Default value
- thirdPartyImages: 0, // Default value
- thirdPartyFonts: 0, // Default value
- thirdPartyMedia: 0, // Default value
- thirdPartyOther: 0, // Default value
- thirdPartyTiming: {
- total: 0,
- blocking: 0,
- dns: 0,
- connect: 0,
- ssl: 0,
- send: 0,
- wait: 0,
- receive: 0,
- },
- cookieBannerTiming: {
- firstPaint: 0,
- firstContentfulPaint:
- results.reduce(
- (acc, curr) => acc + curr.timing.firstContentfulPaint,
- 0
- ) / results.length,
- domContentLoaded:
- results.reduce((acc, curr) => acc + curr.timing.domContentLoaded, 0) /
- results.length,
- load:
- results.reduce((acc, curr) => acc + curr.timing.load, 0) /
- results.length,
- },
- },
- scores,
- };
-}
-
-export async function benchmarkCommand(appPath?: string): Promise {
- try {
- const config = await readConfig(appPath);
- if (!config) {
- throw new Error("Failed to read config.json");
- }
-
- let serverInfo: ServerInfo | null = null;
- let benchmarkUrl: string;
-
- // Check if remote benchmarking is enabled
- if (config.remote?.enabled && config.remote.url) {
- console.log(`🌐 Running remote benchmark against: ${config.remote.url}`);
- benchmarkUrl = config.remote.url;
- } else {
- console.log("🏗️ Building and serving app locally...");
- serverInfo = await buildAndServeNextApp(appPath);
- benchmarkUrl = serverInfo.url;
- }
-
- const cwd = appPath || process.cwd();
-
- try {
- const result = await runBenchmarks(benchmarkUrl, config);
-
- // Format results for results.json
- const resultsData = {
- app: config.name,
- techStack: config.techStack,
- source: config.source,
- includes: config.includes,
- internationalization: config.internationalization,
- company: config.company,
- tags: config.tags,
- results: result.details,
- scores: result.scores,
- metadata: {
- timestamp: new Date().toISOString(),
- iterations: config.iterations,
- languages: config.techStack.languages,
- isRemote: config.remote?.enabled || false,
- url: config.remote?.enabled ? config.remote.url : undefined,
- },
- };
-
- // Write results to file
- const outputPath = join(cwd, "results.json");
- await writeFile(outputPath, JSON.stringify(resultsData, null, 2));
- console.log(`✅ Benchmark results saved to ${outputPath}`);
-
- // Print scores if available
- if (result.scores) {
- console.log("📊 Benchmark Scores:");
- printScores(result.scores);
- }
- } finally {
- // Only cleanup server if we started one
- if (serverInfo) {
- await cleanupServer(serverInfo);
- }
- }
- } catch (error: unknown) {
- if (error instanceof Error) {
- console.error(`Error running benchmark: ${error.message}`);
- } else {
- console.error("An unknown error occurred during benchmark");
- }
- process.exit(1);
- }
-}
diff --git a/packages/cli/src/commands/benchmark/bundle-strategy.ts b/packages/cli/src/commands/benchmark/bundle-strategy.ts
deleted file mode 100644
index 9da257b..0000000
--- a/packages/cli/src/commands/benchmark/bundle-strategy.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { Config } from "../../types";
-import type { BundleStrategy } from "./types";
-import { BUNDLE_TYPES } from "./constants";
-
-export function determineBundleStrategy(config: Config): BundleStrategy {
- const bundleType = config.techStack?.bundleType;
-
- const isIIFE =
- bundleType === BUNDLE_TYPES.IIFE ||
- (Array.isArray(bundleType) && bundleType.includes(BUNDLE_TYPES.IIFE));
-
- const isBundled =
- !isIIFE &&
- (bundleType === BUNDLE_TYPES.BUNDLED ||
- (Array.isArray(bundleType) &&
- (bundleType.includes(BUNDLE_TYPES.ESM) ||
- bundleType.includes(BUNDLE_TYPES.CJS))) ||
- bundleType === BUNDLE_TYPES.ESM ||
- bundleType === BUNDLE_TYPES.CJS);
-
- return { isBundled, isIIFE, bundleType };
-}
diff --git a/packages/cli/src/commands/benchmark/constants.ts b/packages/cli/src/commands/benchmark/constants.ts
deleted file mode 100644
index 5cf8bc0..0000000
--- a/packages/cli/src/commands/benchmark/constants.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export const BENCHMARK_CONSTANTS = {
- DETECTION_INTERVAL: 1000, // Wait 1 second between detection attempts
- MAX_DETECTION_TIME: 15000, // Increased to 15 seconds to accommodate longer waits
- INITIAL_DETECTION_DELAY: 500, // Wait 500ms before starting
- TTI_BUFFER: 1000,
- METRICS_TIMEOUT: 10000,
- METRICS_RETRY_TIMEOUT: 5000,
-} as const;
-
-export const BUNDLE_TYPES = {
- IIFE: "iffe",
- ESM: "esm",
- CJS: "cjs",
- BUNDLED: "bundled",
-} as const;
diff --git a/packages/cli/src/commands/benchmark/cookie-banner-detector.ts b/packages/cli/src/commands/benchmark/cookie-banner-detector.ts
deleted file mode 100644
index bb04e2a..0000000
--- a/packages/cli/src/commands/benchmark/cookie-banner-detector.ts
+++ /dev/null
@@ -1,293 +0,0 @@
-import type { Page } from "@playwright/test";
-import type { Config } from "../../types";
-import type {
- WindowWithCookieMetrics,
- CookieBannerData,
- LayoutShiftEntry,
-} from "./types";
-import { BENCHMARK_CONSTANTS } from "./constants";
-
-export class CookieBannerDetector {
- async setupDetection(page: Page, config: Config): Promise {
- await page.addInitScript(
- (params: {
- selectors: string[];
- constants: typeof BENCHMARK_CONSTANTS;
- }) => {
- const { selectors, constants } = params;
- console.log("🔍 [BROWSER] Setting up cookie banner detection...");
-
-
- // Store initial performance baseline
- (window as unknown as WindowWithCookieMetrics).__cookieBannerMetrics = {
- pageLoadStart: performance.now(),
- bannerDetectionStart: 0,
- bannerFirstSeen: 0,
- bannerInteractive: 0,
- layoutShiftsBefore: 0,
- layoutShiftsAfter: 0,
- detected: false,
- selector: null,
- };
-
- // Monitor for layout shifts specifically
- let cumulativeLayoutShift = 0;
- if ("PerformanceObserver" in window) {
- const clsObserver = new PerformanceObserver((list) => {
- for (const entry of list.getEntries()) {
- const layoutShiftEntry = entry as LayoutShiftEntry;
- if (!layoutShiftEntry.hadRecentInput) {
- cumulativeLayoutShift += layoutShiftEntry.value;
- (
- window as unknown as WindowWithCookieMetrics
- ).__cookieBannerMetrics.layoutShiftsAfter =
- cumulativeLayoutShift;
- }
- }
- });
- clsObserver.observe({ type: "layout-shift", buffered: true });
- }
-
- // Cookie banner detection logic
- const detectCookieBanner = (): boolean => {
- (
- window as unknown as WindowWithCookieMetrics
- ).__cookieBannerMetrics.bannerDetectionStart = performance.now();
-
- console.log("🔍 [BANNER] Starting detection check...");
-
- for (const selector of selectors) {
- try {
- const element = document.querySelector(selector);
- console.log(`🔍 [BANNER] Checking selector "${selector}":`, element ? "found" : "not found");
-
- if (element) {
- // First check if element is immediately visible
- let isVisible = false;
- let recheckAttempts = 0;
- const maxRecheckAttempts = 10; // 10 attempts * 100ms = 1 second max
-
- // Function to check visibility
- const checkElementVisibility = () => {
- const rect = element.getBoundingClientRect();
- const computedStyle = window.getComputedStyle(element);
- const hasContent = rect.width > 100 && rect.height > 0;
- const hasContainer = rect.width > 200 && computedStyle.display !== "none" && computedStyle.visibility !== "hidden";
- return hasContent || hasContainer;
- };
- // Check visibility immediately
- isVisible = checkElementVisibility();
-
- // If element exists but not visible, wait and recheck up to 1 second
- while (!isVisible && recheckAttempts < maxRecheckAttempts) {
- const rect = element.getBoundingClientRect();
- console.log(`🔍 [BANNER] Element "${selector}" found but not visible yet (${rect.width}x${rect.height}), waiting 100ms (attempt ${recheckAttempts + 1}/${maxRecheckAttempts})`);
-
- // Wait 100ms synchronously (not ideal but necessary for this detection pattern)
- const start = performance.now();
- while (performance.now() - start < 100) {
- // Busy wait for 100ms
- }
-
- recheckAttempts++;
- isVisible = checkElementVisibility();
- }
-
- const rect = element.getBoundingClientRect();
- console.log(`🔍 [BANNER] Final visibility check for "${selector}":`, {
- width: rect.width,
- height: rect.height,
- isVisible,
- recheckAttempts
- });
-
- if (isVisible) {
- const metrics = (window as unknown as WindowWithCookieMetrics)
- .__cookieBannerMetrics;
- metrics.detected = true;
- metrics.selector = selector;
- metrics.bannerFirstSeen = performance.now();
- metrics.layoutShiftsBefore = cumulativeLayoutShift;
-
- console.log("🔍 [BANNER] Cookie banner detected:", selector);
- console.log(
- "🔍 [BANNER] Banner render time:",
- metrics.bannerFirstSeen - metrics.pageLoadStart,
- "ms"
- );
-
- // Check if banner is interactive
- const buttons = element.querySelectorAll(
- 'button, a, [role="button"], [onclick]'
- );
- if (buttons.length > 0) {
- // Test if buttons are actually clickable
- const firstButton = buttons[0] as HTMLElement;
- if (firstButton.offsetParent !== null) {
- // Element is visible and clickable
- metrics.bannerInteractive = performance.now();
- console.log(
- "🔍 [BANNER] Banner interactive time:",
- metrics.bannerInteractive - metrics.pageLoadStart,
- "ms"
- );
- }
- }
-
- return true;
- }
- }
- } catch (error) {
- console.warn(
- "🔍 [BANNER] Error checking selector:",
- selector,
- error
- );
- }
- }
-
- console.log("🔍 [BANNER] No visible banner found in this check");
- return false;
- };
-
- // Enhanced detection for async-loaded banners
- const startDetection = (): void => {
- let detectionInterval: ReturnType;
- let isDetected = false;
- let attemptCount = 0;
-
- const runDetection = () => {
- attemptCount++;
- console.log(`🔍 [DETECTION] Attempt ${attemptCount} - Looking for cookie banner...`);
-
- if (!isDetected && detectCookieBanner()) {
- isDetected = true;
- console.log(`🔍 [DETECTION] Banner found on attempt ${attemptCount}!`);
- if (detectionInterval) clearInterval(detectionInterval);
- if (mutationObserver) mutationObserver.disconnect();
- } else if (!isDetected) {
- console.log(`🔍 [DETECTION] Attempt ${attemptCount} - No banner found, will retry in ${constants.DETECTION_INTERVAL}ms`);
- }
- };
-
- // Initial detection attempt
- setTimeout(() => {
- runDetection();
-
- if (!isDetected) {
- // Keep checking for dynamically loaded banners
- detectionInterval = setInterval(runDetection, constants.DETECTION_INTERVAL);
-
- // Stop checking after max detection time
- setTimeout(() => {
- if (!isDetected) {
- console.log(`🔍 [DETECTION] Giving up after ${constants.MAX_DETECTION_TIME}ms and ${attemptCount} attempts`);
- }
- if (detectionInterval) clearInterval(detectionInterval);
- if (mutationObserver) mutationObserver.disconnect();
- }, constants.MAX_DETECTION_TIME);
- }
- }, constants.INITIAL_DETECTION_DELAY);
-
- // Enhanced: Watch for DOM changes for async-loaded content
- let mutationObserver: MutationObserver | null = null;
- if ('MutationObserver' in window) {
- mutationObserver = new MutationObserver((mutations) => {
- if (isDetected) return;
-
- console.log("🔍 [MUTATION] DOM mutations detected:", mutations.length);
-
- for (const mutation of mutations) {
- if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
- console.log("🔍 [MUTATION] Nodes added:", mutation.addedNodes.length);
-
- // Check if any added nodes might be our banner
- for (const node of mutation.addedNodes) {
- if (node.nodeType === Node.ELEMENT_NODE) {
- const element = node as Element;
- console.log("🔍 [MUTATION] Added element:", element.tagName, element.className, element.id);
-
- // Check if this element or its children match our selectors
- for (const selector of selectors) {
- if (element.matches?.(selector)) {
- console.log("🔍 [MUTATION] Found matching element for selector:", selector);
- setTimeout(runDetection, 50); // Small delay to ensure rendering
- return;
- }
- if (element.querySelector?.(selector)) {
- console.log("🔍 [MUTATION] Found child matching selector:", selector);
- setTimeout(runDetection, 50);
- return;
- }
- }
- }
- }
- }
- }
- });
-
- mutationObserver.observe(document.body, {
- childList: true,
- subtree: true,
- attributes: false
- });
- }
-
- };
-
- if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", startDetection);
- } else {
- startDetection();
- }
- },
- {
- selectors: config.cookieBanner?.selectors || [],
- constants: BENCHMARK_CONSTANTS,
- }
- );
- }
-
- async collectBannerData(page: Page): Promise {
- return page.evaluate(() => {
- const metrics = (window as unknown as WindowWithCookieMetrics)
- .__cookieBannerMetrics;
-
- if (!metrics) {
- return null;
- }
-
- return {
- detected: metrics.detected,
- selector: metrics.selector,
- bannerRenderTime: metrics.bannerFirstSeen - metrics.pageLoadStart,
- bannerInteractiveTime:
- metrics.bannerInteractive - metrics.pageLoadStart,
- bannerHydrationTime:
- metrics.bannerInteractive > 0
- ? metrics.bannerInteractive - metrics.bannerFirstSeen
- : 0,
- layoutShiftImpact:
- metrics.layoutShiftsAfter - metrics.layoutShiftsBefore,
- viewportCoverage: metrics.detected
- ? (() => {
- if (!metrics.selector) {
- return 0;
- }
-
- const element = document.querySelector(metrics.selector);
- if (element) {
- const rect = element.getBoundingClientRect();
- return (
- ((rect.width * rect.height) /
- (window.innerWidth * window.innerHeight)) *
- 100
- );
- }
- return 0;
- })()
- : 0,
- };
- });
- }
-}
diff --git a/packages/cli/src/commands/benchmark/index.ts b/packages/cli/src/commands/benchmark/index.ts
deleted file mode 100644
index 1cc5025..0000000
--- a/packages/cli/src/commands/benchmark/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { benchmarkCommand } from "./benchmark-runner";
-export * from "./types";
-export * from "./constants";
diff --git a/packages/cli/src/commands/benchmark/metrics-calculator.ts b/packages/cli/src/commands/benchmark/metrics-calculator.ts
deleted file mode 100644
index 2af5b88..0000000
--- a/packages/cli/src/commands/benchmark/metrics-calculator.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import type {
- BenchmarkDetails,
- CookieBannerData,
- CookieBannerMetrics,
- ResourceTimingData,
- NetworkRequest,
- Config,
-} from "./types";
-import { BENCHMARK_CONSTANTS } from "./constants";
-
-interface CoreWebVitals {
- paint?: {
- firstPaint?: number;
- firstContentfulPaint?: number;
- };
- largestContentfulPaint?: number;
- cumulativeLayoutShift?: number;
- totalBlockingTime?: number;
- domCompleteTiming?: number;
-}
-
-export class MetricsCalculator {
- calculateTTI(
- coreWebVitals: CoreWebVitals,
- cookieBannerData: CookieBannerData | null
- ): number {
- return (
- Math.max(
- coreWebVitals.paint?.firstContentfulPaint || 0,
- coreWebVitals.domCompleteTiming || 0,
- cookieBannerData?.bannerInteractiveTime || 0
- ) + BENCHMARK_CONSTANTS.TTI_BUFFER
- );
- }
-
- mergeBenchmarkMetrics(
- resourceMetrics: ResourceTimingData,
- coreWebVitals: CoreWebVitals,
- cookieBannerData: CookieBannerData | null,
- cookieBannerMetrics: CookieBannerMetrics,
- networkRequests: NetworkRequest[],
- config: Config,
- tti: number
- ): BenchmarkDetails {
- return {
- duration: resourceMetrics.duration,
- size: resourceMetrics.size,
- timing: {
- navigationStart: resourceMetrics.timing.navigationStart,
- domContentLoaded: resourceMetrics.timing.domContentLoaded,
- load: resourceMetrics.timing.load,
- firstPaint: coreWebVitals.paint?.firstPaint || 0,
- firstContentfulPaint: coreWebVitals.paint?.firstContentfulPaint || 0,
- largestContentfulPaint: coreWebVitals.largestContentfulPaint || 0,
- timeToInteractive: tti,
- cumulativeLayoutShift: coreWebVitals.cumulativeLayoutShift || 0,
- cookieBanner: {
- renderStart: cookieBannerData?.bannerRenderTime || 0,
- renderEnd: cookieBannerData?.bannerInteractiveTime || 0,
- interactionStart: cookieBannerData?.bannerInteractiveTime || 0,
- interactionEnd: cookieBannerData?.bannerInteractiveTime || 0,
- layoutShift: cookieBannerData?.layoutShiftImpact || 0,
- detected: cookieBannerData?.detected || false,
- selector: cookieBannerData?.selector || null,
- serviceName: config.cookieBanner?.serviceName || "unknown",
- visibilityTime: cookieBannerData?.bannerRenderTime ?? null,
- viewportCoverage: cookieBannerData?.viewportCoverage || 0,
- },
- thirdParty: {
- dnsLookupTime: 0,
- connectionTime: 0,
- downloadTime: networkRequests.reduce(
- (acc, req) => acc + req.duration,
- 0
- ),
- totalImpact: networkRequests.reduce((acc, req) => acc + req.size, 0),
- cookieServices: {
- hosts: config.cookieBanner?.serviceHosts || [],
- totalSize: cookieBannerMetrics.bannerBundleSize,
- resourceCount: cookieBannerMetrics.bannerNetworkRequests,
- dnsLookupTime: 0,
- connectionTime: 0,
- downloadTime: networkRequests.reduce(
- (acc, req) => acc + req.duration,
- 0
- ),
- },
- },
- mainThreadBlocking: {
- total: coreWebVitals.totalBlockingTime || 0,
- cookieBannerEstimate:
- cookieBannerMetrics.bannerMainThreadBlockingTime,
- percentageFromCookies:
- (coreWebVitals.totalBlockingTime || 0) > 0
- ? (cookieBannerMetrics.bannerMainThreadBlockingTime /
- (coreWebVitals.totalBlockingTime || 1)) *
- 100
- : 0,
- },
- scripts: resourceMetrics.timing.scripts,
- },
- resources: resourceMetrics.resources,
- language: resourceMetrics.language,
- cookieBanner: {
- detected: cookieBannerData?.detected || false,
- selector: cookieBannerData?.selector || null,
- serviceName: config.cookieBanner?.serviceName || "unknown",
- visibilityTime: cookieBannerData?.bannerRenderTime ?? null,
- viewportCoverage: cookieBannerData?.viewportCoverage || 0,
- },
- thirdParty: {
- cookieServices: {
- hosts: config.cookieBanner?.serviceHosts || [],
- totalSize: cookieBannerMetrics.bannerBundleSize,
- resourceCount: cookieBannerMetrics.bannerNetworkRequests,
- dnsLookupTime: 0,
- connectionTime: 0,
- downloadTime: networkRequests.reduce(
- (acc, req) => acc + req.duration,
- 0
- ),
- },
- totalImpact: networkRequests.reduce((acc, req) => acc + req.size, 0),
- },
- };
- }
-
- logFinalResults(
- finalMetrics: BenchmarkDetails,
- cookieBannerMetrics: CookieBannerMetrics,
- bundleType: string | string[] | undefined
- ): void {
- console.log("🔍 [DEBUG] Final cookie banner benchmark results:", {
- fcp: finalMetrics.timing.firstContentfulPaint,
- lcp: finalMetrics.timing.largestContentfulPaint,
- cls: finalMetrics.timing.cumulativeLayoutShift,
- tti: finalMetrics.timing.timeToInteractive,
- tbt: finalMetrics.timing.mainThreadBlocking.total,
- bannerDetected: finalMetrics.cookieBanner.detected,
- bannerRenderTime:
- finalMetrics.timing.cookieBanner.renderEnd -
- finalMetrics.timing.cookieBanner.renderStart,
- bannerLayoutShift: finalMetrics.timing.cookieBanner.layoutShift,
- bannerNetworkImpact: finalMetrics.thirdParty.totalImpact,
- bundleStrategy: cookieBannerMetrics.isBundled
- ? "Bundled"
- : cookieBannerMetrics.isIIFE
- ? "IIFE"
- : "Unknown",
- isBundled: cookieBannerMetrics.isBundled,
- isIIFE: cookieBannerMetrics.isIIFE,
- configBundleType: bundleType,
- });
- }
-}
diff --git a/packages/cli/src/commands/benchmark/network-monitor.ts b/packages/cli/src/commands/benchmark/network-monitor.ts
deleted file mode 100644
index afd77ed..0000000
--- a/packages/cli/src/commands/benchmark/network-monitor.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import type { Page } from "@playwright/test";
-import type { NetworkRequest, CookieBannerMetrics } from "./types";
-
-export class NetworkMonitor {
- private networkRequests: NetworkRequest[] = [];
-
- async setupRequestMonitoring(
- page: Page,
- cookieBannerMetrics: CookieBannerMetrics
- ): Promise {
- await page.route("**/*", async (route) => {
- const request = route.request();
- const url = request.url();
-
- try {
- const response = await route.fetch();
- const headers = response.headers();
-
- // Add timing-allow-origin header for all responses
- headers["timing-allow-origin"] = "*";
-
- const isScript = request.resourceType() === "script";
- const isThirdParty = !url.includes(new URL(url).hostname);
-
- if (isScript) {
- const contentLength = response.headers()["content-length"];
- const size = contentLength ? +contentLength || 0 : 0;
-
- this.networkRequests.push({
- url,
- size: size / 1024, // Convert to KB
- duration: 0, // Will be calculated later
- startTime: Date.now(),
- isScript,
- isThirdParty,
- });
-
- if (isThirdParty) {
- cookieBannerMetrics.bannerNetworkRequests++;
- cookieBannerMetrics.bannerBundleSize += size / 1024;
- console.log(
- `🌐 [THIRD-PARTY-SCRIPT] Detected: ${url} (${(
- size / 1024
- ).toFixed(2)}KB)`
- );
- }
- }
-
- await route.fulfill({ response, headers });
- } catch {
- // If we can't modify the response, just continue with the original request
- await route.continue();
- }
- });
- }
-
- getNetworkRequests(): NetworkRequest[] {
- return this.networkRequests;
- }
-
- calculateNetworkImpact(): {
- totalDownloadTime: number;
- totalSize: number;
- thirdPartyCount: number;
- } {
- return {
- totalDownloadTime: this.networkRequests.reduce(
- (acc, req) => acc + req.duration,
- 0
- ),
- totalSize: this.networkRequests.reduce((acc, req) => acc + req.size, 0),
- thirdPartyCount: this.networkRequests.filter((req) => req.isThirdParty)
- .length,
- };
- }
-
- reset(): void {
- this.networkRequests = [];
- }
-}
diff --git a/packages/cli/src/commands/benchmark/resource-collector.ts b/packages/cli/src/commands/benchmark/resource-collector.ts
deleted file mode 100644
index 3954ea6..0000000
--- a/packages/cli/src/commands/benchmark/resource-collector.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-import type { Page } from "@playwright/test";
-import type { ResourceTimingData } from "./types";
-
-export class ResourceCollector {
- async collectResourceTiming(page: Page): Promise {
- try {
- return await page.evaluate(() => {
- console.log("🔍 [BROWSER] Starting resource collection...");
-
- const perfEntries = performance.getEntriesByType(
- "navigation"
- )[0] as PerformanceNavigationTiming;
- const resourceEntries = performance.getEntriesByType(
- "resource"
- ) as PerformanceResourceTiming[];
-
- console.log("🔍 [BROWSER] Navigation timing:", {
- navigationStart: perfEntries.startTime,
- domContentLoaded:
- perfEntries.domContentLoadedEventEnd - perfEntries.startTime,
- loadComplete: perfEntries.loadEventEnd - perfEntries.startTime,
- domInteractive: perfEntries.domInteractive - perfEntries.startTime,
- });
-
- console.log("🔍 [BROWSER] Found", resourceEntries.length, "resources");
-
- // Categorize resources
- const scriptEntries = resourceEntries.filter(
- (entry) => entry.initiatorType === "script"
- );
- const styleEntries = resourceEntries.filter(
- (entry) => entry.initiatorType === "link" && entry.name.endsWith(".css")
- );
- const imageEntries = resourceEntries.filter(
- (entry) => entry.initiatorType === "img"
- );
- const fontEntries = resourceEntries.filter(
- (entry) => entry.initiatorType === "font"
- );
- const otherEntries = resourceEntries.filter(
- (entry) =>
- !["script", "link", "img", "font"].includes(entry.initiatorType)
- );
-
- console.log("🔍 [BROWSER] Resource breakdown:", {
- scripts: scriptEntries.length,
- styles: styleEntries.length,
- images: imageEntries.length,
- fonts: fontEntries.length,
- other: otherEntries.length,
- });
-
- // Calculate sizes
- const calculateSize = (entries: PerformanceResourceTiming[]): number => {
- const total =
- entries.reduce((acc, entry) => {
- const size = entry.transferSize || entry.encodedBodySize || 0;
- return acc + size;
- }, 0) / 1024;
- return total;
- };
-
- const navigationStart = perfEntries.startTime;
- const domContentLoaded =
- perfEntries.domContentLoadedEventEnd - navigationStart;
- const load = perfEntries.loadEventEnd - navigationStart;
-
- console.log("🔍 [BROWSER] Calculated timings:", {
- navigationStart,
- domContentLoaded,
- load,
- });
-
- return {
- timing: {
- navigationStart,
- domContentLoaded,
- load,
- scripts: {
- bundled: {
- loadStart: 0,
- loadEnd: scriptEntries.reduce(
- (acc, entry) => acc + entry.duration,
- 0
- ),
- executeStart: 0,
- executeEnd: 0,
- },
- thirdParty: {
- loadStart: 0,
- loadEnd: scriptEntries.reduce(
- (acc, entry) => acc + entry.duration,
- 0
- ),
- executeStart: 0,
- executeEnd: 0,
- },
- },
- },
- size: {
- total: calculateSize(resourceEntries),
- bundled: calculateSize(
- scriptEntries.filter((e) =>
- e.name.includes(window.location.hostname)
- )
- ),
- thirdParty: calculateSize(
- scriptEntries.filter(
- (e) => !e.name.includes(window.location.hostname)
- )
- ),
- cookieServices: 0, // Will be calculated later
- scripts: {
- total: calculateSize(scriptEntries),
- initial: calculateSize(
- scriptEntries.filter((e) => e.startTime < domContentLoaded)
- ),
- dynamic: calculateSize(
- scriptEntries.filter((e) => e.startTime >= domContentLoaded)
- ),
- thirdParty: calculateSize(
- scriptEntries.filter(
- (e) => !e.name.includes(window.location.hostname)
- )
- ),
- cookieServices: 0, // Will be calculated later
- },
- styles: calculateSize(styleEntries),
- images: calculateSize(imageEntries),
- fonts: calculateSize(fontEntries),
- other: calculateSize(otherEntries),
- },
- resources: {
- scripts: scriptEntries.map((entry) => ({
- name: entry.name,
- size: entry.transferSize ? entry.transferSize / 1024 : 0,
- duration: entry.duration,
- startTime: entry.startTime - navigationStart,
- isThirdParty: !entry.name.includes(window.location.hostname),
- isDynamic: entry.startTime >= domContentLoaded,
- isCookieService: false,
- dnsTime: entry.domainLookupEnd - entry.domainLookupStart,
- connectionTime: entry.connectEnd - entry.connectStart,
- })),
- styles: styleEntries.map((entry) => ({
- name: entry.name,
- size: entry.transferSize ? entry.transferSize / 1024 : 0,
- duration: entry.duration,
- startTime: entry.startTime - navigationStart,
- isThirdParty: !entry.name.includes(window.location.hostname),
- isCookieService: false,
- })),
- images: imageEntries.map((entry) => ({
- name: entry.name,
- size: entry.transferSize ? entry.transferSize / 1024 : 0,
- duration: entry.duration,
- startTime: entry.startTime - navigationStart,
- isThirdParty: !entry.name.includes(window.location.hostname),
- isCookieService: false,
- })),
- fonts: fontEntries.map((entry) => ({
- name: entry.name,
- size: entry.transferSize ? entry.transferSize / 1024 : 0,
- duration: entry.duration,
- startTime: entry.startTime - navigationStart,
- isThirdParty: !entry.name.includes(window.location.hostname),
- isCookieService: false,
- })),
- other: otherEntries.map((entry) => ({
- name: entry.name,
- size: entry.transferSize ? entry.transferSize / 1024 : 0,
- duration: entry.duration,
- startTime: entry.startTime - navigationStart,
- isThirdParty: !entry.name.includes(window.location.hostname),
- isCookieService: false,
- type: entry.initiatorType,
- })),
- },
- language: "en",
- duration: load,
- };
- });
- } catch (error) {
- console.error("🔍 [BROWSER] Error collecting resource timing:", error);
- // Return default values if resource collection fails
- return {
- timing: {
- navigationStart: 0,
- domContentLoaded: 0,
- load: 0,
- scripts: {
- bundled: { loadStart: 0, loadEnd: 0, executeStart: 0, executeEnd: 0 },
- thirdParty: { loadStart: 0, loadEnd: 0, executeStart: 0, executeEnd: 0 },
- },
- },
- size: {
- total: 0,
- bundled: 0,
- thirdParty: 0,
- cookieServices: 0,
- scripts: { total: 0, initial: 0, dynamic: 0, thirdParty: 0, cookieServices: 0 },
- styles: 0,
- images: 0,
- fonts: 0,
- other: 0,
- },
- resources: {
- scripts: [],
- styles: [],
- images: [],
- fonts: [],
- other: [],
- },
- language: "en",
- duration: 0,
- };
- }
- }
-}
diff --git a/packages/cli/src/commands/benchmark/types.ts b/packages/cli/src/commands/benchmark/types.ts
deleted file mode 100644
index ff9c2cf..0000000
--- a/packages/cli/src/commands/benchmark/types.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-export type {
- Config,
- BenchmarkResult,
- BenchmarkDetails,
- LayoutShiftEntry,
-} from "../../types";
-
-export interface WindowWithCookieMetrics extends Window {
- __cookieBannerMetrics: {
- pageLoadStart: number;
- bannerDetectionStart: number;
- bannerFirstSeen: number;
- bannerInteractive: number;
- layoutShiftsBefore: number;
- layoutShiftsAfter: number;
- detected: boolean;
- selector: string | null;
- };
-}
-
-export interface NetworkRequest {
- url: string;
- size: number;
- duration: number;
- startTime: number;
- isScript: boolean;
- isThirdParty: boolean;
-}
-
-export interface CookieBannerMetrics {
- detectionStartTime: number;
- bannerRenderTime: number;
- bannerInteractiveTime: number;
- bannerScriptLoadTime: number;
- bannerLayoutShiftImpact: number;
- bannerNetworkRequests: number;
- bannerBundleSize: number;
- bannerMainThreadBlockingTime: number;
- isBundled: boolean;
- isIIFE: boolean;
- bannerDetected: boolean;
- bannerSelector: string | null;
-}
-
-export interface CookieBannerData {
- detected: boolean;
- selector: string | null;
- bannerRenderTime: number | null;
- bannerInteractiveTime: number | null;
- bannerHydrationTime: number;
- layoutShiftImpact: number;
- viewportCoverage: number;
-}
-
-export interface BundleStrategy {
- isBundled: boolean;
- isIIFE: boolean;
- bundleType: string | string[] | undefined;
-}
-
-export interface ResourceTimingData {
- timing: {
- navigationStart: number;
- domContentLoaded: number;
- load: number;
- scripts: {
- bundled: {
- loadStart: number;
- loadEnd: number;
- executeStart: number;
- executeEnd: number;
- };
- thirdParty: {
- loadStart: number;
- loadEnd: number;
- executeStart: number;
- executeEnd: number;
- };
- };
- };
- size: {
- total: number;
- bundled: number;
- thirdParty: number;
- cookieServices: number;
- scripts: {
- total: number;
- initial: number;
- dynamic: number;
- thirdParty: number;
- cookieServices: number;
- };
- styles: number;
- images: number;
- fonts: number;
- other: number;
- };
- resources: {
- scripts: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isDynamic: boolean;
- isCookieService: boolean;
- dnsTime: number;
- connectionTime: number;
- }>;
- styles: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- }>;
- images: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- }>;
- fonts: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- }>;
- other: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- type: string;
- }>;
- };
- language: string;
- duration: number;
-}
diff --git a/packages/cli/src/commands/db.ts b/packages/cli/src/commands/db.ts
deleted file mode 100644
index 4e861ee..0000000
--- a/packages/cli/src/commands/db.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-import { setTimeout } from 'node:timers/promises';
-import { execSync } from 'node:child_process';
-import { join, dirname } from 'node:path';
-import { existsSync } from 'node:fs';
-import * as p from '@clack/prompts';
-import color from 'picocolors';
-
-const DB_PACKAGE_PATH = join(process.cwd(), 'packages', 'db');
-const DRIZZLE_CONFIG_PATH = join(DB_PACKAGE_PATH, 'drizzle.config.ts');
-
-function ensureDbPackage() {
- if (!existsSync(DB_PACKAGE_PATH)) {
- p.log.error('Database package not found. Make sure you are running this from the project root.');
- process.exit(1);
- }
-
- if (!existsSync(DRIZZLE_CONFIG_PATH)) {
- p.log.error('Drizzle config not found. Make sure drizzle.config.ts exists in packages/db/');
- process.exit(1);
- }
-}
-
-function runDrizzleCommand(command: string): void {
- try {
- p.log.step(`Running: ${color.cyan(`drizzle-kit ${command}`)}`);
- execSync(`cd ${DB_PACKAGE_PATH} && pnpm drizzle-kit ${command}`, {
- stdio: 'inherit',
- encoding: 'utf-8'
- });
- } catch (error) {
- p.log.error(`Failed to run drizzle-kit ${command}`);
- if (error instanceof Error) {
- p.log.error(error.message);
- }
- process.exit(1);
- }
-}
-
-export async function dbCommand(subcommand?: string) {
- console.clear();
- await setTimeout(1000);
-
- p.intro(`${color.bgBlue(color.white(' database '))} ${color.dim('v0.1.0')}`);
-
- ensureDbPackage();
-
- let selectedCommand = subcommand;
-
- if (!selectedCommand) {
- const command = await p.select({
- message: 'What would you like to do?',
- options: [
- {
- value: 'push',
- label: 'Push schema changes',
- hint: 'Push schema directly to database (good for development)'
- },
- {
- value: 'generate',
- label: 'Generate migrations',
- hint: 'Generate SQL migration files from schema changes'
- },
- {
- value: 'migrate',
- label: 'Run migrations',
- hint: 'Apply migration files to the database'
- },
- {
- value: 'studio',
- label: 'Open database studio',
- hint: 'Browse and edit your database with Drizzle Studio'
- },
- {
- value: 'status',
- label: 'Check migration status',
- hint: 'See which migrations have been applied'
- }
- ]
- });
-
- if (p.isCancel(command)) {
- p.cancel('Operation cancelled.');
- return;
- }
-
- selectedCommand = command;
- }
-
- switch (selectedCommand) {
- case 'push':
- await pushCommand();
- break;
- case 'generate':
- await generateCommand();
- break;
- case 'migrate':
- await migrateCommand();
- break;
- case 'studio':
- await studioCommand();
- break;
- case 'status':
- await statusCommand();
- break;
- default:
- p.log.error(`Unknown subcommand: ${selectedCommand}`);
- p.log.info('Available commands: push, generate, migrate, studio, status');
- process.exit(1);
- }
-}
-
-async function pushCommand() {
- p.log.step('Pushing schema changes to database...');
- p.log.info('This will apply schema changes directly to your database.');
- p.log.warn('This is recommended for development only!');
-
- const confirm = await p.confirm({
- message: 'Are you sure you want to push schema changes?',
- initialValue: false
- });
-
- if (p.isCancel(confirm) || !confirm) {
- p.cancel('Push cancelled.');
- return;
- }
-
- runDrizzleCommand('push');
- p.log.success('Schema pushed successfully!');
- p.outro('Database is now up to date with your schema.');
-}
-
-async function generateCommand() {
- p.log.step('Generating migration files...');
- p.log.info('This will create SQL migration files based on schema changes.');
-
- runDrizzleCommand('generate');
- p.log.success('Migration files generated!');
- p.log.info('Review the generated files in packages/db/drizzle/ before applying them.');
- p.outro(`Run ${color.cyan('cli db migrate')} to apply the migrations.`);
-}
-
-async function migrateCommand() {
- p.log.step('Running migrations...');
- p.log.info('This will apply pending migration files to your database.');
-
- const confirm = await p.confirm({
- message: 'Are you sure you want to run migrations?',
- initialValue: true
- });
-
- if (p.isCancel(confirm) || !confirm) {
- p.cancel('Migration cancelled.');
- return;
- }
-
- try {
- runDrizzleCommand('migrate');
- p.log.success('Migrations completed successfully!');
- p.outro('Database is now up to date.');
- } catch (error) {
- p.log.error('Migration failed!');
- if (error instanceof Error) {
- p.log.error(error.message);
- }
- process.exit(1);
- }
-}
-
-async function studioCommand() {
- p.log.step('Opening Drizzle Studio...');
- p.log.info('This will start a web interface to browse and edit your database.');
- p.log.info('Press Ctrl+C to stop the studio when you\'re done.');
-
- try {
- runDrizzleCommand('studio');
- } catch (error) {
- // Studio command might be interrupted by Ctrl+C, which is normal
- p.log.info('Studio closed.');
- }
-}
-
-// Same project root finding logic as in db package
-function findProjectRoot(): string {
- let currentDir = process.cwd();
-
- while (currentDir !== dirname(currentDir)) {
- if (existsSync(join(currentDir, 'pnpm-workspace.yaml')) ||
- existsSync(join(currentDir, 'package.json'))) {
- if (existsSync(join(currentDir, 'packages'))) {
- return currentDir;
- }
- }
- currentDir = dirname(currentDir);
- }
-
- return process.cwd();
-}
-
-async function statusCommand() {
- p.log.step('Checking migration status...');
-
- try {
- // Check if database exists at project root
- const projectRoot = findProjectRoot();
- const dbPath = join(projectRoot, 'benchmarks.db');
- if (!existsSync(dbPath)) {
- p.log.warn('Database file does not exist yet.');
- p.log.info(`Run ${color.cyan('cli db push')} or ${color.cyan('cli db migrate')} to create it.`);
- return;
- }
-
- // Check migrations folder
- const migrationsPath = join(DB_PACKAGE_PATH, 'drizzle');
- if (!existsSync(migrationsPath)) {
- p.log.warn('No migrations found.');
- p.log.info(`Run ${color.cyan('cli db generate')} to create migration files.`);
- return;
- }
-
- // List migration files
- const { readdir } = await import('node:fs/promises');
- const migrationFiles = await readdir(migrationsPath, { withFileTypes: true });
- const migrations = migrationFiles
- .filter(dirent => dirent.isDirectory())
- .map(dirent => dirent.name)
- .sort();
-
- if (migrations.length === 0) {
- p.log.info('No migration files found.');
- } else {
- p.log.info(`Found ${migrations.length} migration(s):`);
- for (const migration of migrations) {
- p.log.info(` - ${migration}`);
- }
- }
-
- p.log.success('Status check complete.');
- } catch (error) {
- p.log.error('Failed to check status.');
- if (error instanceof Error) {
- p.log.error(error.message);
- }
- }
-}
\ No newline at end of file
diff --git a/packages/cli/src/commands/results.ts b/packages/cli/src/commands/results.ts
deleted file mode 100644
index 39054bc..0000000
--- a/packages/cli/src/commands/results.ts
+++ /dev/null
@@ -1,949 +0,0 @@
-import { setTimeout } from "node:timers/promises";
-import * as p from "@clack/prompts";
-import color from "picocolors";
-import { readFile, readdir } from "node:fs/promises";
-import { join } from "node:path";
-import Table from "cli-table3";
-import prettyMilliseconds from "pretty-ms";
-import { config } from "dotenv";
-import { calculateScores, printScores } from "../utils/scoring";
-import type { BenchmarkScores } from "../types";
-import type { Config } from "../types";
-
-// Load environment variables from .env files
-config({ path: ".env" });
-config({ path: ".env.local" });
-config({ path: "www/.env.local" }); // Also check www directory
-
-// Function to save benchmark result via oRPC endpoint
-async function saveBenchmarkResult(result: BenchmarkResult): Promise {
- const apiUrl = process.env.API_URL || "http://localhost:3000";
- const endpoint = `${apiUrl}/api/orpc/benchmarks/save`;
-
- try {
- p.log.info(`Attempting to save ${result.name} to ${endpoint}`);
-
- const response = await fetch(endpoint, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(result),
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`);
- }
-
- const responseData = await response.json();
- p.log.success(
- `Saved benchmark result for ${result.name} (App ID: ${responseData.appId})`
- );
- } catch (error) {
- if (error instanceof Error) {
- p.log.error(`Failed to save benchmark result for ${result.name}: ${error.message}`);
- if (error.message.includes('fetch failed')) {
- p.log.error(`Connection failed. Is the server running on ${apiUrl}?`);
- }
- } else {
- p.log.error(`Failed to save benchmark result for ${result.name}: Unknown error`);
- }
- throw error;
- }
-}
-
-// Benchmark result type (matching the oRPC contract)
-interface BenchmarkResult {
- name: string;
- baseline: boolean;
- cookieBannerConfig: unknown;
- techStack: unknown;
- internationalization: unknown;
- source: unknown;
- includes: string[];
- company?: unknown;
- tags: string[];
- details: unknown[];
- average: {
- fcp: number;
- lcp: number;
- cls: number;
- tbt: number;
- tti: number;
- scriptLoadTime: number;
- totalSize: number;
- scriptSize: number;
- resourceCount: number;
- scriptCount: number;
- time: number;
- thirdPartySize: number;
- cookieServiceSize: number;
- bannerVisibilityTime: number;
- viewportCoverage: number;
- thirdPartyImpact: number;
- mainThreadBlocking: number;
- cookieBannerBlocking: number;
- };
- scores?: {
- totalScore: number;
- grade: 'Excellent' | 'Good' | 'Fair' | 'Poor' | 'Critical';
- categoryScores: {
- performance: number;
- bundleStrategy: number;
- networkImpact: number;
- transparency: number;
- userExperience: number;
- };
- categories: Array<{
- name: string;
- score: number;
- maxScore: number;
- weight: number;
- details: Array<{
- metric: string;
- value: string | number;
- score: number;
- maxScore: number;
- reason: string;
- }>;
- status: 'excellent' | 'good' | 'fair' | 'poor';
- }>;
- insights: string[];
- recommendations: string[];
- };
-}
-
-// Raw benchmark data structure from JSON files
-export interface RawBenchmarkDetail {
- duration: number;
- size: {
- total: number;
- bundled: number;
- thirdParty: number;
- cookieServices: number;
- scripts: {
- total: number;
- initial: number;
- dynamic: number;
- thirdParty: number;
- cookieServices: number;
- };
- styles: number;
- images: number;
- fonts: number;
- other: number;
- };
- timing: {
- navigationStart: number;
- domContentLoaded: number;
- load: number;
- firstPaint: number;
- firstContentfulPaint: number;
- largestContentfulPaint: number;
- timeToInteractive: number;
- cumulativeLayoutShift: number;
- cookieBanner: {
- renderStart: number;
- renderEnd: number;
- interactionStart: number;
- interactionEnd: number;
- layoutShift: number;
- detected: boolean;
- selector: string | null;
- serviceName: string;
- visibilityTime: number;
- viewportCoverage: number;
- };
- thirdParty: {
- dnsLookupTime: number;
- connectionTime: number;
- downloadTime: number;
- totalImpact: number;
- cookieServices: {
- hosts: string[];
- totalSize: number;
- resourceCount: number;
- dnsLookupTime: number;
- connectionTime: number;
- downloadTime: number;
- };
- };
- mainThreadBlocking: {
- total: number;
- cookieBannerEstimate: number;
- percentageFromCookies: number;
- };
- scripts: {
- bundled: {
- loadStart: number;
- loadEnd: number;
- executeStart: number;
- executeEnd: number;
- };
- thirdParty: {
- loadStart: number;
- loadEnd: number;
- executeStart: number;
- executeEnd: number;
- };
- };
- };
- resources: {
- scripts: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isDynamic: boolean;
- isCookieService: boolean;
- dnsTime?: number;
- connectionTime?: number;
- }>;
- styles: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- }>;
- images: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- }>;
- fonts: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- }>;
- other: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- type: string;
- }>;
- };
- language: string;
-}
-
-export interface BenchmarkOutput {
- app: string;
- results: RawBenchmarkDetail[];
- scores?: {
- totalScore: number;
- grade: 'Excellent' | 'Good' | 'Fair' | 'Poor' | 'Critical';
- categoryScores: {
- performance: number;
- bundleStrategy: number;
- networkImpact: number;
- transparency: number;
- userExperience: number;
- };
- categories: Array<{
- name: string;
- score: number;
- maxScore: number;
- weight: number;
- details: Array<{
- name: string;
- score: number;
- maxScore: number;
- reason: string;
- }>;
- status: 'good' | 'warning' | 'critical';
- }>;
- insights: string[];
- recommendations: string[];
- };
- metadata: {
- timestamp: string;
- iterations: number;
- language: string;
- };
-}
-
-async function findResultsFiles(dir: string): Promise {
- const files: string[] = [];
- const entries = await readdir(dir, { withFileTypes: true });
-
- for (const entry of entries) {
- const fullPath = join(dir, entry.name);
- if (entry.isDirectory()) {
- files.push(...(await findResultsFiles(fullPath)));
- } else if (entry.name === "results.json") {
- files.push(fullPath);
- }
- }
-
- return files;
-}
-
-async function loadConfigForApp(appName: string): Promise {
- const configPath = join("benchmarks", appName, "config.json");
-
- try {
- const configContent = await readFile(configPath, "utf-8");
- const config = JSON.parse(configContent);
-
- return {
- name: config.name || appName,
- iterations: config.iterations || 0,
- techStack: config.techStack || {
- languages: [],
- frameworks: [],
- bundler: "unknown",
- bundleType: "unknown",
- packageManager: "unknown",
- typescript: false,
- },
- source: config.source || {
- license: "unknown",
- isOpenSource: false,
- github: false,
- npm: false,
- },
- includes: config.includes || { backend: [], components: [] },
- company: config.company || undefined,
- tags: config.tags || [],
- cookieBanner: config.cookieBanner || {
- serviceName: "Unknown",
- selectors: [],
- serviceHosts: [],
- waitForVisibility: false,
- measureViewportCoverage: false,
- expectedLayoutShift: false,
- },
- internationalization: config.internationalization || {
- detection: "none",
- stringLoading: "bundled",
- },
- };
- } catch (error) {
- p.log.warn(
- `Could not load config for ${appName}: ${
- error instanceof Error ? error.message : "Unknown error"
- }`
- );
- return {
- name: appName,
- iterations: 0,
- techStack: {
- languages: [],
- frameworks: [],
- bundler: "unknown",
- bundleType: "unknown",
- packageManager: "unknown",
- typescript: false,
- },
- source: {
- license: "unknown",
- isOpenSource: false,
- github: false,
- npm: false,
- },
- includes: {
- backend: [],
- components: [],
- },
- company: undefined,
- tags: [],
- cookieBanner: {
- serviceName: "Unknown",
- selectors: [],
- serviceHosts: [],
- waitForVisibility: false,
- measureViewportCoverage: false,
- expectedLayoutShift: false,
- },
- internationalization: {
- detection: "none",
- stringLoading: "bundled",
- },
- };
- }
-}
-
-// Helper function to safely access optional nested properties
-function safeGet(obj: unknown, path: string, defaultValue: T): T {
- try {
- const result = path.split(".").reduce((current, key) => {
- if (current && typeof current === "object" && key in current) {
- return (current as Record)[key];
- }
- return undefined;
- }, obj);
- return result !== undefined && result !== null
- ? (result as T)
- : defaultValue;
- } catch {
- return defaultValue;
- }
-}
-
-async function aggregateResults(resultsDir: string) {
- const resultsFiles = await findResultsFiles(resultsDir);
- const results: Record = {};
-
- p.log.info(`Found ${resultsFiles.length} results files:`);
- for (const file of resultsFiles) {
- p.log.info(` - ${file}`);
- }
-
- for (const file of resultsFiles) {
- try {
- const content = await readFile(file, "utf-8");
- const data: BenchmarkOutput = JSON.parse(content);
-
- if (!data.app || !data.results) {
- p.log.warn(
- `Skipping invalid results file: ${file} (missing app or results)`
- );
- continue;
- }
-
- // Log the actual app name from the file
- p.log.info(`Processing ${file} with app name: "${data.app}"`);
-
- if (results[data.app]) {
- p.log.warn(
- `Duplicate app name "${data.app}" found in ${file}. Previous results will be overwritten.`
- );
- }
-
- results[data.app] = data.results;
- p.log.success(
- `Loaded results for ${data.app} (${data.results.length} iterations)`
- );
- } catch (error) {
- p.log.error(
- `Failed to process ${file}: ${
- error instanceof Error ? error.message : "Unknown error"
- }`
- );
- if (error instanceof Error && error.stack) {
- p.log.error(`Stack trace: ${error.stack}`);
- }
- }
- }
-
- // Log final results summary
- p.log.info("Final results summary:");
- for (const [app, appResults] of Object.entries(results)) {
- p.log.info(` - ${app}: ${appResults.length} iterations`);
- }
-
- return results;
-}
-
-function formatTime(ms: number): string {
- return prettyMilliseconds(ms, {
- secondsDecimalDigits: 2,
- keepDecimalsOnWholeSeconds: true,
- compact: true,
- });
-}
-
-function printResults(results: Record) {
- // Calculate baseline averages
- const baseline = results.baseline;
- const baselineAvgTime = baseline
- ? baseline.reduce((a, b) => a + b.duration, 0) / baseline.length
- : 1;
- const baselineAvgSize = baseline
- ? baseline.reduce((a, b) => a + b.size.total, 0) / baseline.length
- : 1;
- const baselineAvgFCP = baseline
- ? baseline.reduce((a, b) => a + b.timing.firstContentfulPaint, 0) /
- baseline.length
- : 1;
- const baselineAvgLCP = baseline
- ? baseline.reduce((a, b) => a + b.timing.largestContentfulPaint, 0) /
- baseline.length
- : 1;
- const baselineAvgCLS = baseline
- ? baseline.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) /
- baseline.length
- : 1;
- const baselineAvgTTI = baseline
- ? baseline.reduce((a, b) => a + b.timing.timeToInteractive, 0) /
- baseline.length
- : 1;
- const baselineAvgBannerRender = baseline
- ? baseline.reduce(
- (a, b) =>
- a + b.timing.cookieBanner.visibilityTime,
- 0
- ) / baseline.length
- : 1;
- const baselineAvgScriptLoad = baseline
- ? baseline.reduce(
- (a, b) =>
- a +
- (b.timing.scripts.thirdParty.loadEnd -
- b.timing.scripts.thirdParty.loadStart),
- 0
- ) / baseline.length
- : 1;
-
- // Prepare and sort results by avg time
- const sorted = Object.entries(results)
- .map(([app, arr]) => {
- const avgTime = arr.reduce((a, b) => a + b.duration, 0) / arr.length;
- const avgSize = arr.reduce((a, b) => a + b.size.total, 0) / arr.length;
- const avgFCP =
- arr.reduce((a, b) => a + b.timing.firstContentfulPaint, 0) / arr.length;
- const avgLCP =
- arr.reduce((a, b) => a + b.timing.largestContentfulPaint, 0) /
- arr.length;
- const avgCLS =
- arr.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) /
- arr.length;
- const avgTTI =
- arr.reduce((a, b) => a + b.timing.timeToInteractive, 0) / arr.length;
- const avgBannerRender =
- arr.reduce(
- (a, b) =>
- a + b.timing.cookieBanner.visibilityTime,
- 0
- ) / arr.length;
- const avgScriptLoad =
- arr.reduce(
- (a, b) =>
- a +
- (b.timing.scripts.thirdParty.loadEnd -
- b.timing.scripts.thirdParty.loadStart),
- 0
- ) / arr.length;
-
- // Calculate average sizes for each resource type
- const avgScriptsTotal =
- arr.reduce((a, b) => a + b.size.scripts.total, 0) / arr.length;
- const avgScriptsInitial =
- arr.reduce((a, b) => a + b.size.scripts.initial, 0) / arr.length;
- const avgScriptsDynamic =
- arr.reduce((a, b) => a + b.size.scripts.dynamic, 0) / arr.length;
- const avgStyles = arr.reduce((a, b) => a + b.size.styles, 0) / arr.length;
- const avgImages = arr.reduce((a, b) => a + b.size.images, 0) / arr.length;
- const avgFonts = arr.reduce((a, b) => a + b.size.fonts, 0) / arr.length;
- const avgOther = arr.reduce((a, b) => a + b.size.other, 0) / arr.length;
-
- return {
- app,
- avgTime,
- avgSize,
- avgFCP,
- avgLCP,
- avgCLS,
- avgTTI,
- avgBannerRender,
- avgScriptLoad,
- avgScriptsTotal,
- avgScriptsInitial,
- avgScriptsDynamic,
- avgStyles,
- avgImages,
- avgFonts,
- avgOther,
- timeDelta: ((avgTime - baselineAvgTime) / baselineAvgTime) * 100,
- sizeDelta: ((avgSize - baselineAvgSize) / baselineAvgSize) * 100,
- fcpDelta: ((avgFCP - baselineAvgFCP) / baselineAvgFCP) * 100,
- lcpDelta: ((avgLCP - baselineAvgLCP) / baselineAvgLCP) * 100,
- clsDelta: ((avgCLS - baselineAvgCLS) / baselineAvgCLS) * 100,
- ttiDelta: ((avgTTI - baselineAvgTTI) / baselineAvgTTI) * 100,
- bannerRenderDelta:
- ((avgBannerRender - baselineAvgBannerRender) /
- baselineAvgBannerRender) *
- 100,
- scriptLoadDelta:
- ((avgScriptLoad - baselineAvgScriptLoad) / baselineAvgScriptLoad) *
- 100,
- };
- })
- .sort((a, b) => a.avgTime - b.avgTime);
-
- // Setup cli-table3
- const table = new Table({
- head: [
- "App",
- "Total Time",
- "ΔTime",
- "FCP",
- "ΔFCP",
- "LCP",
- "ΔLCP",
- "CLS",
- "ΔCLS",
- "TTI",
- "ΔTTI",
- "Banner",
- "ΔBanner",
- "Script",
- "ΔScript",
- "Total Size",
- "ΔSize",
- "Scripts",
- "Styles",
- "Images",
- "Fonts",
- "Other",
- ],
- colWidths: [
- 15, 10, 8, 10, 8, 10, 8, 8, 8, 10, 8, 10, 8, 10, 8, 10, 8, 10, 10, 10, 10,
- 10,
- ],
- style: { head: ["cyan"], border: ["grey"] },
- });
-
- // Add rows
- for (const r of sorted) {
- const timeDeltaStr =
- r.app === "baseline"
- ? "-"
- : `${r.timeDelta > 0 ? "+" : ""}${r.timeDelta.toFixed(1)}%`;
- const sizeDeltaStr =
- r.app === "baseline"
- ? "-"
- : `${r.sizeDelta > 0 ? "+" : ""}${r.sizeDelta.toFixed(1)}%`;
- const fcpDeltaStr =
- r.app === "baseline"
- ? "-"
- : `${r.fcpDelta > 0 ? "+" : ""}${r.fcpDelta.toFixed(1)}%`;
- const lcpDeltaStr =
- r.app === "baseline"
- ? "-"
- : `${r.lcpDelta > 0 ? "+" : ""}${r.lcpDelta.toFixed(1)}%`;
- const clsDeltaStr =
- r.app === "baseline"
- ? "-"
- : `${r.clsDelta > 0 ? "+" : ""}${r.clsDelta.toFixed(1)}%`;
- const ttiDeltaStr =
- r.app === "baseline"
- ? "-"
- : `${r.ttiDelta > 0 ? "+" : ""}${r.ttiDelta.toFixed(1)}%`;
- const bannerDeltaStr =
- r.app === "baseline"
- ? "-"
- : `${r.bannerRenderDelta > 0 ? "+" : ""}${r.bannerRenderDelta.toFixed(
- 1
- )}%`;
- const scriptDeltaStr =
- r.app === "baseline"
- ? "-"
- : `${r.scriptLoadDelta > 0 ? "+" : ""}${r.scriptLoadDelta.toFixed(1)}%`;
-
- table.push([
- r.app,
- formatTime(r.avgTime),
- timeDeltaStr,
- formatTime(r.avgFCP),
- fcpDeltaStr,
- formatTime(r.avgLCP),
- lcpDeltaStr,
- r.avgCLS.toFixed(3),
- clsDeltaStr,
- formatTime(r.avgTTI),
- ttiDeltaStr,
- formatTime(r.avgBannerRender),
- bannerDeltaStr,
- formatTime(r.avgScriptLoad),
- scriptDeltaStr,
- `${r.avgSize.toFixed(2)}KB`,
- sizeDeltaStr,
- `${r.avgScriptsTotal.toFixed(2)}KB`,
- `${r.avgStyles.toFixed(2)}KB`,
- `${r.avgImages.toFixed(2)}KB`,
- `${r.avgFonts.toFixed(2)}KB`,
- `${r.avgOther.toFixed(2)}KB`,
- ]);
- }
-
- // Print the table to console
- console.log(table.toString());
-
- // Log a summary to console
- p.log.info("Summary:");
- for (const r of sorted.slice(0, 5)) {
- // Show top 5 results
- const deltaColor = r.timeDelta > 0 ? color.red : color.green;
- const deltaStr =
- r.app === "baseline"
- ? ""
- : ` (${deltaColor(r.timeDelta > 0 ? "+" : "")}${deltaColor(
- r.timeDelta.toFixed(1)
- )}${deltaColor("%")})`;
- p.log.info(` ${r.app}: ${formatTime(r.avgTime)}${deltaStr}`);
- }
-}
-
-// Function to transform BenchmarkScores to match oRPC contract
-function transformScoresToContract(scores: BenchmarkScores): BenchmarkResult['scores'] {
- return {
- totalScore: scores.totalScore,
- grade: scores.grade,
- categoryScores: scores.categoryScores,
- categories: scores.categories.map(category => ({
- name: category.name,
- score: category.score,
- maxScore: category.maxScore,
- weight: category.weight,
- details: category.details.map(detail => ({
- metric: detail.name,
- value: detail.score,
- score: detail.score,
- maxScore: detail.maxScore,
- reason: detail.reason,
- })),
- status: mapStatusToContract(category.status),
- })),
- insights: scores.insights,
- recommendations: scores.recommendations,
- };
-}
-
-// Function to map status values from CLI format to contract format
-function mapStatusToContract(status: 'excellent' | 'good' | 'fair' | 'poor'): 'excellent' | 'good' | 'fair' | 'poor' {
- // Now that both CLI and contract use the same format, just return as is
- return status;
-}
-
-export async function resultsCommand() {
- console.clear();
- await setTimeout(1000);
-
- p.intro(`${color.bgCyan(color.black(" results "))}`);
-
- // Check database configuration
- const databaseUrl =
- process.env.DATABASE_URL || process.env.TURSO_DATABASE_URL;
- const authToken =
- process.env.DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN;
-
- if (
- databaseUrl?.startsWith("libsql://") ||
- databaseUrl?.startsWith("wss://")
- ) {
- p.log.info(
- `🌐 Using Turso remote database: ${color.cyan(
- `${databaseUrl.split("@")[0]}@***`
- )}`
- );
- if (!authToken) {
- p.log.warn("⚠️ No auth token found. Database operations may fail.");
- }
- } else if (databaseUrl?.startsWith("file:")) {
- p.log.info(`📁 Using file database: ${color.cyan(databaseUrl)}`);
- } else if (process.env.VERCEL || process.env.NODE_ENV === "production") {
- p.log.warn("⚠️ Using in-memory database. Data will not persist!");
- } else {
- p.log.info(
- `📁 Using local SQLite database: ${color.cyan("benchmarks.db")}`
- );
- }
-
- const resultsDir = "benchmarks";
- p.log.step("Aggregating results...");
- const results = await aggregateResults(resultsDir);
-
- if (Object.keys(results).length === 0) {
- p.log.error("No benchmark results found!");
- return;
- }
-
- p.log.info(
- `Found results for ${Object.keys(results).length} apps: ${Object.keys(
- results
- ).join(", ")}`
- );
-
- const appConfigs: Record = {};
- for (const appName of Object.keys(results)) {
- appConfigs[appName] = await loadConfigForApp(appName);
- }
-
- // Calculate scores for each app
- const scores: Record = {};
- for (const [appName, appResults] of Object.entries(results)) {
- const config = appConfigs[appName];
-
- // Create app data for transparency scoring
- const appData = {
- name: appName,
- baseline: appName === "baseline",
- company: config.company ? JSON.stringify(config.company) : null,
- techStack: JSON.stringify(config.techStack),
- source: config.source ? JSON.stringify(config.source) : null,
- tags: config.tags ? JSON.stringify(config.tags) : null,
- };
-
- scores[appName] = calculateScores(
- {
- fcp: appResults.reduce((a, b) => a + b.timing.firstContentfulPaint, 0) / appResults.length,
- lcp: appResults.reduce((a, b) => a + b.timing.largestContentfulPaint, 0) / appResults.length,
- cls: appResults.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) / appResults.length,
- tbt: appResults.reduce((a, b) => a + b.timing.mainThreadBlocking.total, 0) / appResults.length,
- tti: appResults.reduce((a, b) => a + b.timing.timeToInteractive, 0) / appResults.length,
- },
- {
- totalSize: appResults.reduce((a, b) => a + b.size.total, 0) / appResults.length,
- jsSize: appResults.reduce((a, b) => a + b.size.scripts.total, 0) / appResults.length,
- cssSize: appResults.reduce((a, b) => a + b.size.styles, 0) / appResults.length,
- imageSize: appResults.reduce((a, b) => a + b.size.images, 0) / appResults.length,
- fontSize: appResults.reduce((a, b) => a + b.size.fonts, 0) / appResults.length,
- otherSize: appResults.reduce((a, b) => a + b.size.other, 0) / appResults.length,
- },
- {
- totalRequests: appResults.reduce((a, b) =>
- a + b.resources.scripts.length + b.resources.styles.length +
- b.resources.images.length + b.resources.fonts.length +
- b.resources.other.length, 0) / appResults.length,
- thirdPartyRequests: appResults.reduce((a, b) =>
- a + b.resources.scripts.filter(s => s.isThirdParty).length, 0) / appResults.length,
- thirdPartySize: appResults.reduce((a, b) => a + b.size.thirdParty, 0) / appResults.length,
- thirdPartyDomains: 5, // Default value
- },
- {
- cookieBannerDetected: appResults.some(r => r.timing.cookieBanner.detected),
- cookieBannerTiming: appResults.reduce((a, b) => a + b.timing.cookieBanner.visibilityTime, 0) / appResults.length,
- cookieBannerCoverage: appResults.reduce((a, b) => a + b.timing.cookieBanner.viewportCoverage, 0) / appResults.length / 100,
- },
- {
- domSize: 1500, // Default value
- mainThreadBlocking: appResults.reduce((a, b) => a + b.timing.mainThreadBlocking.total, 0) / appResults.length,
- layoutShifts: appResults.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) / appResults.length,
- },
- appName === "baseline",
- appData
- );
- }
-
- // Print scores
- console.log("\n📊 Benchmark Scores:");
- for (const [appName, appScores] of Object.entries(scores)) {
- console.log(`\n${appName}:`);
- printScores(appScores);
- }
-
- // Save results to database
- p.log.step("Saving results to database...");
- let savedCount = 0;
- let errorCount = 0;
-
- for (const [appName, appResults] of Object.entries(results)) {
- try {
- // Load config data for this app
- const config = appConfigs[appName];
-
- // Convert raw benchmark data to BenchmarkResult format
- const benchmarkResult: BenchmarkResult = {
- name: appName,
- baseline: appName === "baseline",
- cookieBannerConfig: {
- selectors: config.cookieBanner.selectors,
- serviceHosts: config.cookieBanner.serviceHosts,
- waitForVisibility: config.cookieBanner.waitForVisibility,
- measureViewportCoverage: config.cookieBanner.measureViewportCoverage,
- expectedLayoutShift: config.cookieBanner.expectedLayoutShift,
- serviceName: config.cookieBanner.serviceName,
- },
- techStack: config.techStack,
- internationalization: config.internationalization,
- source: config.source,
- includes: Object.values(config.includes || {})
- .flat()
- .filter((v): v is string => typeof v === "string"),
- company: config.company,
- tags: config.tags || [],
- details: appResults,
- average: {
- fcp: appResults.reduce((a, b) => a + b.timing.firstContentfulPaint, 0) /
- appResults.length,
- lcp: appResults.reduce((a, b) => a + b.timing.largestContentfulPaint, 0) /
- appResults.length,
- cls: appResults.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) /
- appResults.length,
- tbt: appResults.reduce((a, b) => a + b.timing.mainThreadBlocking.total, 0) /
- appResults.length,
- tti: appResults.reduce((a, b) => a + b.timing.timeToInteractive, 0) /
- appResults.length,
- scriptLoadTime: 0,
- totalSize: appResults.reduce((a, b) => a + b.size.total, 0) /
- appResults.length,
- scriptSize: 0,
- resourceCount: appResults.reduce((a, b) => a + b.resources.scripts.length, 0) /
- appResults.length,
- scriptCount: appResults.reduce((a, b) => a + b.resources.scripts.length, 0) /
- appResults.length,
- time: appResults.reduce((a, b) => a + b.duration, 0) / appResults.length,
- thirdPartySize: appResults.reduce((a, b) => a + b.size.thirdParty, 0) /
- appResults.length,
- cookieServiceSize: appResults.reduce((a, b) => a + b.size.cookieServices, 0) /
- appResults.length,
- bannerVisibilityTime: appResults.reduce((a, b) => a + b.timing.cookieBanner.visibilityTime, 0) /
- appResults.length,
- viewportCoverage: appResults.reduce((a, b) => a + b.timing.cookieBanner.viewportCoverage, 0) /
- appResults.length,
- thirdPartyImpact: appResults.reduce((a, b) => a + b.timing.thirdParty.totalImpact, 0) /
- appResults.length,
- mainThreadBlocking: appResults.reduce((a, b) => a + b.timing.mainThreadBlocking.total, 0) /
- appResults.length,
- cookieBannerBlocking: appResults.reduce((a, b) => a + b.timing.mainThreadBlocking.cookieBannerEstimate, 0) /
- appResults.length,
- },
- scores: transformScoresToContract(scores[appName]),
- };
-
- await saveBenchmarkResult(benchmarkResult);
- p.log.success(`Saved results for ${appName}`);
- savedCount++;
- } catch (error) {
- p.log.error(
- `Failed to save results for ${appName}: ${
- error instanceof Error ? error.message : "Unknown error"
- }`
- );
- errorCount++;
- }
- }
-
- // Print results table
- printResults(results);
-
- // Summary of database operations
- if (savedCount > 0) {
- p.log.success(`Successfully saved ${savedCount} app(s) to database.`);
- }
- if (errorCount > 0) {
- p.log.warn(`Failed to save ${errorCount} app(s) to database.`);
- }
-
- if (
- databaseUrl?.startsWith("libsql://") ||
- databaseUrl?.startsWith("wss://")
- ) {
- p.log.info(
- `Results have been saved to Turso database: ${color.cyan(
- `${databaseUrl.split("@")[0]}@***`
- )}`
- );
- } else {
- p.log.info(
- `Results have been saved to local database: ${color.cyan(
- "benchmarks.db"
- )} (in project root)`
- );
- }
-
- p.outro("Results aggregated and saved to database successfully!");
-}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
deleted file mode 100644
index 29b5808..0000000
--- a/packages/cli/src/index.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { setTimeout } from "node:timers/promises";
-import * as p from "@clack/prompts";
-import color from "picocolors";
-import { benchmarkCommand } from "./commands/benchmark";
-import { resultsCommand } from "./commands/results";
-import { dbCommand } from "./commands/db";
-
-function onCancel() {
- p.cancel("Operation cancelled.");
- process.exit(0);
-}
-
-async function main() {
- console.clear();
- await setTimeout(1000);
-
- // Check for command line arguments
- const args = process.argv.slice(2);
- const command = args[0];
-
- // If no command specified, show the prompt
- if (command) {
- // Direct command execution
- switch (command) {
- case "benchmark":
- await benchmarkCommand();
- break;
- case "results":
- await resultsCommand();
- break;
- case "db":
- await dbCommand(args[1]);
- break;
- default:
- console.error(`Unknown command: ${command}`);
- console.log("Available commands: benchmark, results, db");
- process.exit(1);
- }
- } else {
- p.intro(`${color.bgCyan(color.black(" c15t "))}`);
-
- const selectedCommand = await p.select({
- message: "What would you like to do?",
- options: [
- {
- value: "benchmark",
- label: "Run a benchmark",
- hint: "Run a performance benchmark on a URL",
- },
- {
- value: "results",
- label: "Results",
- hint: "Combine and display benchmark results",
- },
- {
- value: "db",
- label: "Database",
- hint: "Manage database schema and migrations",
- },
- ],
- });
-
- if (p.isCancel(selectedCommand)) {
- return onCancel();
- }
-
- // biome-ignore lint/style/useDefaultSwitchClause:
- switch (selectedCommand) {
- case "benchmark":
- await benchmarkCommand();
- break;
- case "results":
- await resultsCommand();
- break;
- case "db":
- await dbCommand();
- break;
- }
- }
-}
-
-main().catch(console.error);
diff --git a/packages/cli/src/lib/README.md b/packages/cli/src/lib/README.md
deleted file mode 100644
index ffa3f45..0000000
--- a/packages/cli/src/lib/README.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# Benchmark Library Structure
-
-This directory contains the refactored benchmark modules, organized by functionality for better maintainability and testability.
-
-## Architecture
-
-The benchmark system has been broken down into focused, single-responsibility modules:
-
-### Core Runner
-
-- **`benchmark-runner.ts`** - Main orchestrator that coordinates all collectors and aggregators
-
-### Collectors (`/collectors`)
-
-- **`cookie-banner-collector.ts`** - Detects and measures cookie banner performance impact
-- **`network-monitor.ts`** - Monitors network requests and calculates size/timing metrics
-- **`resource-timing-collector.ts`** - Collects detailed resource timing data from the browser
-
-### Metrics (`/metrics`)
-
-- **`performance-aggregator.ts`** - Aggregates all collected metrics into final benchmark results
-
-## Benefits of Refactoring
-
-1. **Single Responsibility**: Each module has one clear purpose
-2. **Better Testability**: Individual components can be tested in isolation
-3. **Improved Maintainability**: Changes to one area don't affect others
-4. **Cleaner Code**: Removed ~500 lines from a single 700+ line file
-5. **Type Safety**: Better TypeScript interfaces and type definitions
-6. **Reusability**: Modules can be used independently or in different combinations
-
-## Usage
-
-```typescript
-import { BenchmarkRunner } from "./benchmark-runner";
-
-const runner = new BenchmarkRunner(config);
-const results = await runner.runBenchmarks(serverUrl);
-```
-
-## Module Dependencies
-
-```
-benchmark-runner.ts
-├── collectors/
-│ ├── cookie-banner-collector.ts
-│ ├── network-monitor.ts
-│ └── resource-timing-collector.ts
-└── metrics/
- └── performance-aggregator.ts
-```
-
-Each collector is independent and can be used separately if needed. The performance aggregator combines all metrics into the final benchmark result format.
diff --git a/packages/cli/src/lib/benchmark-runner.ts b/packages/cli/src/lib/benchmark-runner.ts
deleted file mode 100644
index d311fee..0000000
--- a/packages/cli/src/lib/benchmark-runner.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import { chromium, type Page } from "@playwright/test";
-import { PerformanceMetricsCollector } from "playwright-performance-metrics";
-import type { Config, BenchmarkResult, BenchmarkDetails } from "../types";
-import { CookieBannerCollector } from "./collectors/cookie-banner-collector";
-import { NetworkMonitor } from "./collectors/network-monitor";
-import { ResourceTimingCollector } from "./collectors/resource-timing-collector";
-import {
- PerformanceAggregator,
- type CoreWebVitals,
-} from "./metrics/performance-aggregator";
-
-export class BenchmarkRunner {
- private config: Config;
- private cookieBannerCollector: CookieBannerCollector;
- private networkMonitor: NetworkMonitor;
- private resourceTimingCollector: ResourceTimingCollector;
- private performanceAggregator: PerformanceAggregator;
-
- constructor(config: Config) {
- this.config = config;
- this.cookieBannerCollector = new CookieBannerCollector(config);
- this.networkMonitor = new NetworkMonitor(config);
- this.resourceTimingCollector = new ResourceTimingCollector();
- this.performanceAggregator = new PerformanceAggregator();
- }
-
- /**
- * Run a single benchmark iteration
- */
- async runSingleBenchmark(page: Page, url: string): Promise {
- console.log(`🔍 [DEBUG] Starting cookie banner benchmark for: ${url}`);
- console.log(
- "🔍 [DEBUG] Cookie banner selectors:",
- this.config.cookieBanner?.selectors || []
- );
- console.log(
- "🔍 [DEBUG] Bundle type from config:",
- this.config.techStack?.bundleType
- );
-
- // Initialize collectors
- const collector = new PerformanceMetricsCollector();
- const cookieBannerMetrics = this.cookieBannerCollector.initializeMetrics();
-
- // Setup monitoring and detection
- await this.networkMonitor.setupMonitoring(page);
- await this.cookieBannerCollector.setupDetection(page);
-
- // Navigate to the page
- console.log(`🔍 [DEBUG] Navigating to: ${url}`);
- await page.goto(url, { waitUntil: "networkidle" });
-
- // Wait for the specified element
- await this.waitForElement(page);
-
- // Wait for network to be idle
- console.log("🔍 [DEBUG] Waiting for network idle...");
- await page.waitForLoadState("networkidle");
-
- // Collect core web vitals
- console.log("🔍 [DEBUG] Collecting core web vitals...");
- const coreWebVitals = await this.collectCoreWebVitals(collector, page);
-
- // Collect cookie banner specific metrics
- const cookieBannerData = await this.cookieBannerCollector.collectMetrics(
- page
- );
- console.log("🔍 [DEBUG] Cookie banner metrics:", cookieBannerData);
-
- // Collect detailed resource timing data
- const resourceMetrics = await this.resourceTimingCollector.collect(page);
-
- // Get network metrics
- const networkRequests = this.networkMonitor.getNetworkRequests();
- const networkMetrics = this.networkMonitor.getMetrics();
-
- // Aggregate all metrics
- const finalMetrics = this.performanceAggregator.aggregateMetrics(
- coreWebVitals,
- cookieBannerData,
- cookieBannerMetrics,
- networkRequests,
- networkMetrics,
- resourceMetrics,
- this.config
- );
-
- // Log results
- this.performanceAggregator.logResults(
- finalMetrics,
- cookieBannerMetrics,
- this.config
- );
-
- // Cleanup
- await collector.cleanup();
- this.networkMonitor.reset();
-
- return finalMetrics;
- }
-
- /**
- * Run multiple benchmark iterations
- */
- async runBenchmarks(serverUrl: string): Promise {
- const browser = await chromium.launch({
- headless: true, // Keep headless mode for stability
- args: ["--remote-debugging-port=9222"],
- });
- const results: BenchmarkDetails[] = [];
-
- try {
- for (let i = 0; i < this.config.iterations; i++) {
- console.log(
- `[Benchmark] Running iteration ${i + 1}/${this.config.iterations}...`
- );
-
- const context = await browser.newContext();
- const page = await context.newPage();
-
- const result = await this.runSingleBenchmark(
- page,
- // Add a timestamp to the URL to avoid caching
- `${serverUrl}?t=${Date.now()}`
- );
- results.push(result);
-
- await context.close();
- }
- } finally {
- await browser.close();
- }
-
- const averages = this.performanceAggregator.calculateAverages(results);
-
- return {
- name: this.config.name,
- baseline: this.config.baseline || false,
- techStack: this.config.techStack,
- source: this.config.source,
- includes: this.config.includes,
- company: this.config.company,
- tags: this.config.tags,
- details: results,
- average: averages,
- };
- }
-
- /**
- * Wait for the specified element based on config
- */
- private async waitForElement(page: Page): Promise {
- if (this.config.testId) {
- console.log(`🔍 [DEBUG] Waiting for testId: ${this.config.testId}`);
- await page.waitForSelector(`[data-testid="${this.config.testId}"]`);
- } else if (this.config.id) {
- console.log(`🔍 [DEBUG] Waiting for id: ${this.config.id}`);
- await page.waitForSelector(`#${this.config.id}`);
- } else if (this.config.custom) {
- console.log("🔍 [DEBUG] Running custom wait function");
- await this.config.custom(page);
- }
- }
-
- /**
- * Collect core web vitals using playwright-performance-metrics
- */
- private async collectCoreWebVitals(
- collector: PerformanceMetricsCollector,
- page: Page
- ): Promise {
- const coreWebVitals = await collector.collectMetrics(page, {
- timeout: 10000,
- retryTimeout: 5000,
- });
-
- console.log("🔍 [DEBUG] Core web vitals collected:", {
- fcp: coreWebVitals.paint?.firstContentfulPaint,
- lcp: coreWebVitals.largestContentfulPaint,
- cls: coreWebVitals.cumulativeLayoutShift,
- tbt: coreWebVitals.totalBlockingTime,
- domComplete: coreWebVitals.domCompleteTiming,
- pageLoad: coreWebVitals.pageloadTiming,
- totalBytes: coreWebVitals.totalBytes,
- });
-
- return coreWebVitals;
- }
-}
diff --git a/packages/cli/src/lib/collectors/cookie-banner-collector.ts b/packages/cli/src/lib/collectors/cookie-banner-collector.ts
deleted file mode 100644
index 8f92c0b..0000000
--- a/packages/cli/src/lib/collectors/cookie-banner-collector.ts
+++ /dev/null
@@ -1,279 +0,0 @@
-import type { Page } from "@playwright/test";
-import type { Config, LayoutShiftEntry } from "../../types";
-
-interface WindowWithCookieMetrics extends Window {
- __cookieBannerMetrics: {
- pageLoadStart: number;
- bannerDetectionStart: number;
- bannerFirstSeen: number;
- bannerInteractive: number;
- layoutShiftsBefore: number;
- layoutShiftsAfter: number;
- detected: boolean;
- selector: string | null;
- };
-}
-
-export interface CookieBannerMetrics {
- detectionStartTime: number;
- bannerRenderTime: number;
- bannerInteractiveTime: number;
- bannerScriptLoadTime: number;
- bannerLayoutShiftImpact: number;
- bannerNetworkRequests: number;
- bannerBundleSize: number;
- bannerMainThreadBlockingTime: number;
- isBundled: boolean;
- isIIFE: boolean;
- bannerDetected: boolean;
- bannerSelector: string | null;
-}
-
-export interface CookieBannerData {
- detected: boolean;
- selector: string | null;
- bannerRenderTime: number;
- bannerInteractiveTime: number;
- bannerHydrationTime: number;
- layoutShiftImpact: number;
- viewportCoverage: number;
-}
-
-export class CookieBannerCollector {
- private config: Config;
-
- constructor(config: Config) {
- this.config = config;
- }
-
- /**
- * Determines bundle strategy from config
- */
- getBundleStrategy(): { isBundled: boolean; isIIFE: boolean } {
- const bundleType = this.config.techStack?.bundleType;
- const isIIFE =
- bundleType === "iffe" ||
- (Array.isArray(bundleType) && bundleType.includes("iffe"));
- const isBundled =
- !isIIFE &&
- (bundleType === "bundled" ||
- (Array.isArray(bundleType) &&
- (bundleType.includes("esm") || bundleType.includes("cjs"))) ||
- bundleType === "esm" ||
- bundleType === "cjs");
-
- console.log(
- `🔍 [BUNDLE-STRATEGY] Detected from config: ${
- isBundled ? "Bundled" : isIIFE ? "IIFE" : "Unknown"
- }`,
- {
- bundleType,
- isBundled,
- isIIFE,
- }
- );
-
- return { isBundled, isIIFE };
- }
-
- /**
- * Initialize cookie banner metrics tracking
- */
- initializeMetrics(): CookieBannerMetrics {
- const { isBundled, isIIFE } = this.getBundleStrategy();
-
- return {
- detectionStartTime: 0,
- bannerRenderTime: 0,
- bannerInteractiveTime: 0,
- bannerScriptLoadTime: 0,
- bannerLayoutShiftImpact: 0,
- bannerNetworkRequests: 0,
- bannerBundleSize: 0,
- bannerMainThreadBlockingTime: 0,
- isBundled,
- isIIFE,
- bannerDetected: false,
- bannerSelector: null,
- };
- }
-
- /**
- * Set up cookie banner detection script in the browser
- */
- async setupDetection(page: Page): Promise {
- const selectors = this.config.cookieBanner?.selectors || [];
-
- await page.addInitScript((selectors: string[]) => {
- console.log("🔍 [BROWSER] Setting up cookie banner detection...");
-
- // Store initial performance baseline
- (window as unknown as WindowWithCookieMetrics).__cookieBannerMetrics = {
- pageLoadStart: performance.now(),
- bannerDetectionStart: 0,
- bannerFirstSeen: 0,
- bannerInteractive: 0,
- layoutShiftsBefore: 0,
- layoutShiftsAfter: 0,
- detected: false,
- selector: null,
- };
-
- // Monitor for layout shifts specifically
- let cumulativeLayoutShift = 0;
- if ("PerformanceObserver" in window) {
- const clsObserver = new PerformanceObserver((list) => {
- for (const entry of list.getEntries()) {
- const layoutShiftEntry = entry as LayoutShiftEntry;
- if (!layoutShiftEntry.hadRecentInput) {
- cumulativeLayoutShift += layoutShiftEntry.value;
- (
- window as unknown as WindowWithCookieMetrics
- ).__cookieBannerMetrics.layoutShiftsAfter = cumulativeLayoutShift;
- }
- }
- });
- clsObserver.observe({ type: "layout-shift", buffered: true });
- }
-
- // Cookie banner detection logic
- const detectCookieBanner = () => {
- (
- window as unknown as WindowWithCookieMetrics
- ).__cookieBannerMetrics.bannerDetectionStart = performance.now();
-
- for (const selector of selectors) {
- try {
- const element = document.querySelector(selector);
- if (element) {
- const rect = element.getBoundingClientRect();
- const isVisible =
- rect.width > 0 &&
- rect.height > 0 &&
- window.getComputedStyle(element).visibility !== "hidden" &&
- window.getComputedStyle(element).display !== "none";
-
- if (isVisible) {
- const metrics = (window as unknown as WindowWithCookieMetrics)
- .__cookieBannerMetrics;
- metrics.detected = true;
- metrics.selector = selector;
- metrics.bannerFirstSeen = performance.now();
- metrics.layoutShiftsBefore = cumulativeLayoutShift;
-
- console.log("🔍 [BANNER] Cookie banner detected:", selector);
- console.log(
- "🔍 [BANNER] Banner render time:",
- metrics.bannerFirstSeen - metrics.pageLoadStart,
- "ms"
- );
-
- // Check if banner is interactive
- const buttons = element.querySelectorAll(
- 'button, a, [role="button"], [onclick]'
- );
- if (buttons.length > 0) {
- // Test if buttons are actually clickable
- const firstButton = buttons[0] as HTMLElement;
- if (firstButton.offsetParent !== null) {
- // Element is visible and clickable
- metrics.bannerInteractive = performance.now();
- console.log(
- "🔍 [BANNER] Banner interactive time:",
- metrics.bannerInteractive - metrics.pageLoadStart,
- "ms"
- );
- }
- }
-
- return true;
- }
- }
- } catch (error) {
- console.warn(
- "🔍 [BANNER] Error checking selector:",
- selector,
- error
- );
- }
- }
- return false;
- };
-
- // Start detection after DOM is ready
- if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", () => {
- setTimeout(() => {
- if (!detectCookieBanner()) {
- // Keep checking for dynamically loaded banners
- const interval = setInterval(() => {
- if (detectCookieBanner()) {
- clearInterval(interval);
- }
- }, 100);
-
- // Stop checking after 10 seconds
- setTimeout(() => clearInterval(interval), 10000);
- }
- }, 100); // Small delay to allow for initial render
- });
- } else {
- setTimeout(() => {
- if (!detectCookieBanner()) {
- const interval = setInterval(() => {
- if (detectCookieBanner()) {
- clearInterval(interval);
- }
- }, 100);
-
- setTimeout(() => clearInterval(interval), 10000);
- }
- }, 100);
- }
- }, selectors);
- }
-
- /**
- * Collect cookie banner specific metrics from the browser
- */
- async collectMetrics(page: Page): Promise {
- return page.evaluate(() => {
- const metrics = (window as unknown as WindowWithCookieMetrics)
- .__cookieBannerMetrics;
- if (!metrics) {
- return null;
- }
-
- return {
- detected: metrics.detected,
- selector: metrics.selector,
- bannerRenderTime: metrics.bannerFirstSeen - metrics.pageLoadStart,
- bannerInteractiveTime:
- metrics.bannerInteractive - metrics.pageLoadStart,
- bannerHydrationTime:
- metrics.bannerInteractive > 0
- ? metrics.bannerInteractive - metrics.bannerFirstSeen
- : 0,
- layoutShiftImpact:
- metrics.layoutShiftsAfter - metrics.layoutShiftsBefore,
- viewportCoverage: metrics.detected
- ? (() => {
- if (!metrics.selector) {
- return 0;
- }
- const element = document.querySelector(metrics.selector);
- if (element) {
- const rect = element.getBoundingClientRect();
- return (
- ((rect.width * rect.height) /
- (window.innerWidth * window.innerHeight)) *
- 100
- );
- }
- return 0;
- })()
- : 0,
- };
- });
- }
-}
diff --git a/packages/cli/src/lib/collectors/index.ts b/packages/cli/src/lib/collectors/index.ts
deleted file mode 100644
index 0eb6d64..0000000
--- a/packages/cli/src/lib/collectors/index.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-export {
- CookieBannerCollector,
- type CookieBannerMetrics,
- type CookieBannerData,
-} from "./cookie-banner-collector";
-export {
- NetworkMonitor,
- type NetworkRequest,
- type NetworkMetrics,
-} from "./network-monitor";
-export {
- ResourceTimingCollector,
- type ResourceTimingData,
-} from "./resource-timing-collector";
diff --git a/packages/cli/src/lib/collectors/network-monitor.ts b/packages/cli/src/lib/collectors/network-monitor.ts
deleted file mode 100644
index 8be8c34..0000000
--- a/packages/cli/src/lib/collectors/network-monitor.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import type { Page, Route } from "@playwright/test";
-import type { Config } from "../../types";
-
-export interface NetworkRequest {
- url: string;
- size: number;
- duration: number;
- startTime: number;
- isScript: boolean;
- isThirdParty: boolean;
-}
-
-export interface NetworkMetrics {
- bannerNetworkRequests: number;
- bannerBundleSize: number;
-}
-
-export class NetworkMonitor {
- private config: Config;
- private networkRequests: NetworkRequest[] = [];
- private metrics: NetworkMetrics = {
- bannerNetworkRequests: 0,
- bannerBundleSize: 0,
- };
-
- constructor(config: Config) {
- this.config = config;
- }
-
- /**
- * Set up network request monitoring
- */
- async setupMonitoring(page: Page): Promise {
- await page.route("**/*", async (route: Route) => {
- const request = route.request();
- const url = request.url();
-
- try {
- const response = await route.fetch();
- const headers = response.headers();
-
- // Add timing-allow-origin header for all responses
- headers["timing-allow-origin"] = "*";
-
- const isScript = request.resourceType() === "script";
- const isThirdParty = !url.includes(new URL(url).hostname);
-
- if (isScript) {
- const contentLength = response.headers()["content-length"];
- const size = contentLength ? +contentLength || 0 : 0;
-
- this.networkRequests.push({
- url,
- size: size / 1024, // Convert to KB
- duration: 0, // Will be calculated later
- startTime: Date.now(),
- isScript,
- isThirdParty,
- });
-
- if (isThirdParty) {
- this.metrics.bannerNetworkRequests++;
- this.metrics.bannerBundleSize += size / 1024;
- console.log(
- `🌐 [THIRD-PARTY-SCRIPT] Detected: ${url} (${(
- size / 1024
- ).toFixed(2)}KB)`
- );
- }
- }
-
- await route.fulfill({ response, headers });
- } catch {
- // If we can't modify the response, just continue with the original request
- await route.continue();
- }
- });
- }
-
- /**
- * Get collected network requests
- */
- getNetworkRequests(): NetworkRequest[] {
- return this.networkRequests;
- }
-
- /**
- * Get network metrics
- */
- getMetrics(): NetworkMetrics {
- return this.metrics;
- }
-
- /**
- * Calculate network impact metrics
- */
- calculateNetworkImpact(): {
- totalImpact: number;
- totalDownloadTime: number;
- thirdPartyImpact: number;
- scriptImpact: number;
- } {
- const totalImpact = this.networkRequests.reduce(
- (acc, req) => acc + req.size,
- 0
- );
- const totalDownloadTime = this.networkRequests.reduce(
- (acc, req) => acc + req.duration,
- 0
- );
- const thirdPartyImpact = this.networkRequests
- .filter((req) => req.isThirdParty)
- .reduce((acc, req) => acc + req.size, 0);
- const scriptImpact = this.networkRequests
- .filter((req) => req.isScript)
- .reduce((acc, req) => acc + req.size, 0);
-
- return {
- totalImpact,
- totalDownloadTime,
- thirdPartyImpact,
- scriptImpact,
- };
- }
-
- /**
- * Reset collected data
- */
- reset(): void {
- this.networkRequests = [];
- this.metrics = {
- bannerNetworkRequests: 0,
- bannerBundleSize: 0,
- };
- }
-}
diff --git a/packages/cli/src/lib/collectors/resource-timing-collector.ts b/packages/cli/src/lib/collectors/resource-timing-collector.ts
deleted file mode 100644
index 26b09f3..0000000
--- a/packages/cli/src/lib/collectors/resource-timing-collector.ts
+++ /dev/null
@@ -1,274 +0,0 @@
-import type { Page } from "@playwright/test";
-
-export interface ResourceTimingData {
- timing: {
- navigationStart: number;
- domContentLoaded: number;
- load: number;
- scripts: {
- bundled: {
- loadStart: number;
- loadEnd: number;
- executeStart: number;
- executeEnd: number;
- };
- thirdParty: {
- loadStart: number;
- loadEnd: number;
- executeStart: number;
- executeEnd: number;
- };
- };
- };
- size: {
- total: number;
- bundled: number;
- thirdParty: number;
- cookieServices: number;
- scripts: {
- total: number;
- initial: number;
- dynamic: number;
- thirdParty: number;
- cookieServices: number;
- };
- styles: number;
- images: number;
- fonts: number;
- other: number;
- };
- resources: {
- scripts: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isDynamic: boolean;
- isCookieService: boolean;
- dnsTime: number;
- connectionTime: number;
- }>;
- styles: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- }>;
- images: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- }>;
- fonts: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- }>;
- other: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isCookieService: boolean;
- type: string;
- }>;
- };
- language: string;
- duration: number;
-}
-
-export class ResourceTimingCollector {
- /**
- * Collect detailed resource timing data from the browser
- */
- async collect(page: Page): Promise {
- console.log("🔍 [DEBUG] Collecting resource timing data...");
-
- return page.evaluate(() => {
- console.log("🔍 [BROWSER] Starting resource collection...");
-
- const perfEntries = performance.getEntriesByType(
- "navigation"
- )[0] as PerformanceNavigationTiming;
- const resourceEntries = performance.getEntriesByType(
- "resource"
- ) as PerformanceResourceTiming[];
-
- console.log("🔍 [BROWSER] Navigation timing:", {
- navigationStart: perfEntries.startTime,
- domContentLoaded:
- perfEntries.domContentLoadedEventEnd - perfEntries.startTime,
- loadComplete: perfEntries.loadEventEnd - perfEntries.startTime,
- domInteractive: perfEntries.domInteractive - perfEntries.startTime,
- });
-
- console.log("🔍 [BROWSER] Found", resourceEntries.length, "resources");
-
- // Categorize resources
- const scriptEntries = resourceEntries.filter(
- (entry) => entry.initiatorType === "script"
- );
- const styleEntries = resourceEntries.filter(
- (entry) => entry.initiatorType === "link" && entry.name.endsWith(".css")
- );
- const imageEntries = resourceEntries.filter(
- (entry) => entry.initiatorType === "img"
- );
- const fontEntries = resourceEntries.filter(
- (entry) => entry.initiatorType === "font"
- );
- const otherEntries = resourceEntries.filter(
- (entry) =>
- !["script", "link", "img", "font"].includes(entry.initiatorType)
- );
-
- console.log("🔍 [BROWSER] Resource breakdown:", {
- scripts: scriptEntries.length,
- styles: styleEntries.length,
- images: imageEntries.length,
- fonts: fontEntries.length,
- other: otherEntries.length,
- });
-
- // Calculate sizes
- const calculateSize = (entries: PerformanceResourceTiming[]) => {
- const total =
- entries.reduce((acc, entry) => {
- const size = entry.transferSize || entry.encodedBodySize || 0;
- return acc + size;
- }, 0) / 1024;
- return total;
- };
-
- const navigationStart = perfEntries.startTime;
- const domContentLoaded =
- perfEntries.domContentLoadedEventEnd - navigationStart;
- const load = perfEntries.loadEventEnd - navigationStart;
-
- console.log("🔍 [BROWSER] Calculated timings:", {
- navigationStart,
- domContentLoaded,
- load,
- });
-
- return {
- timing: {
- navigationStart,
- domContentLoaded,
- load,
- scripts: {
- bundled: {
- loadStart: 0,
- loadEnd: scriptEntries.reduce(
- (acc, entry) => acc + entry.duration,
- 0
- ),
- executeStart: 0,
- executeEnd: 0,
- },
- thirdParty: {
- loadStart: 0,
- loadEnd: scriptEntries.reduce(
- (acc, entry) => acc + entry.duration,
- 0
- ),
- executeStart: 0,
- executeEnd: 0,
- },
- },
- },
- size: {
- total: calculateSize(resourceEntries),
- bundled: calculateSize(
- scriptEntries.filter((e) =>
- e.name.includes(window.location.hostname)
- )
- ),
- thirdParty: calculateSize(
- scriptEntries.filter(
- (e) => !e.name.includes(window.location.hostname)
- )
- ),
- cookieServices: 0, // Will be calculated later
- scripts: {
- total: calculateSize(scriptEntries),
- initial: calculateSize(
- scriptEntries.filter((e) => e.startTime < domContentLoaded)
- ),
- dynamic: calculateSize(
- scriptEntries.filter((e) => e.startTime >= domContentLoaded)
- ),
- thirdParty: calculateSize(
- scriptEntries.filter(
- (e) => !e.name.includes(window.location.hostname)
- )
- ),
- cookieServices: 0, // Will be calculated later
- },
- styles: calculateSize(styleEntries),
- images: calculateSize(imageEntries),
- fonts: calculateSize(fontEntries),
- other: calculateSize(otherEntries),
- },
- resources: {
- scripts: scriptEntries.map((entry) => ({
- name: entry.name,
- size: entry.transferSize ? entry.transferSize / 1024 : 0,
- duration: entry.duration,
- startTime: entry.startTime - navigationStart,
- isThirdParty: !entry.name.includes(window.location.hostname),
- isDynamic: entry.startTime >= domContentLoaded,
- isCookieService: false,
- dnsTime: entry.domainLookupEnd - entry.domainLookupStart,
- connectionTime: entry.connectEnd - entry.connectStart,
- })),
- styles: styleEntries.map((entry) => ({
- name: entry.name,
- size: entry.transferSize ? entry.transferSize / 1024 : 0,
- duration: entry.duration,
- startTime: entry.startTime - navigationStart,
- isThirdParty: !entry.name.includes(window.location.hostname),
- isCookieService: false,
- })),
- images: imageEntries.map((entry) => ({
- name: entry.name,
- size: entry.transferSize ? entry.transferSize / 1024 : 0,
- duration: entry.duration,
- startTime: entry.startTime - navigationStart,
- isThirdParty: !entry.name.includes(window.location.hostname),
- isCookieService: false,
- })),
- fonts: fontEntries.map((entry) => ({
- name: entry.name,
- size: entry.transferSize ? entry.transferSize / 1024 : 0,
- duration: entry.duration,
- startTime: entry.startTime - navigationStart,
- isThirdParty: !entry.name.includes(window.location.hostname),
- isCookieService: false,
- })),
- other: otherEntries.map((entry) => ({
- name: entry.name,
- size: entry.transferSize ? entry.transferSize / 1024 : 0,
- duration: entry.duration,
- startTime: entry.startTime - navigationStart,
- isThirdParty: !entry.name.includes(window.location.hostname),
- isCookieService: false,
- type: entry.initiatorType,
- })),
- },
- language: "en",
- duration: load,
- };
- });
- }
-}
diff --git a/packages/cli/src/lib/metrics/index.ts b/packages/cli/src/lib/metrics/index.ts
deleted file mode 100644
index a6b3319..0000000
--- a/packages/cli/src/lib/metrics/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export {
- PerformanceAggregator,
- type CoreWebVitals,
-} from "./performance-aggregator";
diff --git a/packages/cli/src/lib/metrics/performance-aggregator.ts b/packages/cli/src/lib/metrics/performance-aggregator.ts
deleted file mode 100644
index 38b6a81..0000000
--- a/packages/cli/src/lib/metrics/performance-aggregator.ts
+++ /dev/null
@@ -1,286 +0,0 @@
-import type { BenchmarkDetails, BenchmarkResult, Config } from "../../types";
-import type {
- CookieBannerData,
- CookieBannerMetrics,
-} from "../collectors/cookie-banner-collector";
-import type {
- NetworkRequest,
- NetworkMetrics,
-} from "../collectors/network-monitor";
-import type { ResourceTimingData } from "../collectors/resource-timing-collector";
-
-export interface CoreWebVitals {
- paint?: {
- firstPaint?: number;
- firstContentfulPaint?: number;
- };
- largestContentfulPaint?: number;
- cumulativeLayoutShift?: number;
- totalBlockingTime?: number;
- domCompleteTiming?: number;
- pageloadTiming?: number;
- totalBytes?: number;
-}
-
-export class PerformanceAggregator {
- /**
- * Calculate Time to Interactive based on core web vitals and cookie banner interaction
- */
- calculateTTI(
- coreWebVitals: CoreWebVitals,
- cookieBannerData: CookieBannerData | null
- ): number {
- return (
- Math.max(
- coreWebVitals.paint?.firstContentfulPaint || 0,
- coreWebVitals.domCompleteTiming || 0,
- cookieBannerData?.bannerInteractiveTime || 0
- ) + 1000
- ); // Add buffer for true interactivity
- }
-
- /**
- * Merge all collected metrics into final benchmark details
- */
- aggregateMetrics(
- coreWebVitals: CoreWebVitals,
- cookieBannerData: CookieBannerData | null,
- cookieBannerMetrics: CookieBannerMetrics,
- networkRequests: NetworkRequest[],
- networkMetrics: NetworkMetrics,
- resourceMetrics: ResourceTimingData,
- config: Config
- ): BenchmarkDetails {
- const tti = this.calculateTTI(coreWebVitals, cookieBannerData);
-
- const networkImpact = this.calculateNetworkImpact(networkRequests);
-
- return {
- duration: resourceMetrics.duration,
- size: resourceMetrics.size,
- timing: {
- navigationStart: resourceMetrics.timing.navigationStart,
- domContentLoaded: resourceMetrics.timing.domContentLoaded,
- load: resourceMetrics.timing.load,
- firstPaint: coreWebVitals.paint?.firstPaint || 0,
- firstContentfulPaint: coreWebVitals.paint?.firstContentfulPaint || 0,
- largestContentfulPaint: coreWebVitals.largestContentfulPaint || 0,
- timeToInteractive: tti,
- cumulativeLayoutShift: coreWebVitals.cumulativeLayoutShift || 0,
- cookieBanner: {
- renderStart: cookieBannerData?.bannerRenderTime || 0,
- renderEnd: cookieBannerData?.bannerInteractiveTime || 0,
- interactionStart: cookieBannerData?.bannerInteractiveTime || 0,
- interactionEnd: cookieBannerData?.bannerInteractiveTime || 0,
- layoutShift: cookieBannerData?.layoutShiftImpact || 0,
- detected: cookieBannerData?.detected || false,
- selector: cookieBannerData?.selector || null,
- serviceName: config.cookieBanner?.serviceName || "unknown",
- visibilityTime: cookieBannerData?.bannerInteractiveTime || 0,
- viewportCoverage: cookieBannerData?.viewportCoverage || 0,
- },
- thirdParty: {
- dnsLookupTime: 0,
- connectionTime: 0,
- downloadTime: networkImpact.totalDownloadTime,
- totalImpact: networkImpact.totalImpact,
- cookieServices: {
- hosts: config.cookieBanner?.serviceHosts || [],
- totalSize: networkMetrics.bannerBundleSize,
- resourceCount: networkMetrics.bannerNetworkRequests,
- dnsLookupTime: 0,
- connectionTime: 0,
- downloadTime: networkImpact.totalDownloadTime,
- },
- },
- mainThreadBlocking: {
- total: coreWebVitals.totalBlockingTime || 0,
- cookieBannerEstimate:
- cookieBannerMetrics.bannerMainThreadBlockingTime,
- percentageFromCookies:
- (coreWebVitals.totalBlockingTime || 0) > 0
- ? (cookieBannerMetrics.bannerMainThreadBlockingTime /
- (coreWebVitals.totalBlockingTime || 1)) *
- 100
- : 0,
- },
- scripts: resourceMetrics.timing.scripts,
- },
- resources: resourceMetrics.resources,
- language: resourceMetrics.language,
- cookieBanner: {
- detected: cookieBannerData?.detected || false,
- selector: cookieBannerData?.selector || null,
- serviceName: config.cookieBanner?.serviceName || "unknown",
- visibilityTime: cookieBannerData?.bannerInteractiveTime || 0,
- viewportCoverage: cookieBannerData?.viewportCoverage || 0,
- },
- thirdParty: {
- cookieServices: {
- hosts: config.cookieBanner?.serviceHosts || [],
- totalSize: networkMetrics.bannerBundleSize,
- resourceCount: networkMetrics.bannerNetworkRequests,
- dnsLookupTime: 0,
- connectionTime: 0,
- downloadTime: networkImpact.totalDownloadTime,
- },
- totalImpact: networkImpact.totalImpact,
- },
- };
- }
-
- /**
- * Calculate network impact metrics
- */
- private calculateNetworkImpact(networkRequests: NetworkRequest[]): {
- totalImpact: number;
- totalDownloadTime: number;
- } {
- const totalImpact = networkRequests.reduce((acc, req) => acc + req.size, 0);
- const totalDownloadTime = networkRequests.reduce(
- (acc, req) => acc + req.duration,
- 0
- );
-
- return { totalImpact, totalDownloadTime };
- }
-
- /**
- * Calculate average metrics from multiple benchmark results
- */
- calculateAverages(results: BenchmarkDetails[]): BenchmarkResult["average"] {
- if (results.length === 0) {
- throw new Error("Cannot calculate averages from empty results array");
- }
-
- return {
- firstContentfulPaint:
- results.reduce(
- (acc, curr) => acc + curr.timing.firstContentfulPaint,
- 0
- ) / results.length,
- largestContentfulPaint:
- results.reduce(
- (acc, curr) => acc + curr.timing.largestContentfulPaint,
- 0
- ) / results.length,
- timeToInteractive:
- results.reduce((acc, curr) => acc + curr.timing.timeToInteractive, 0) /
- results.length,
- totalBlockingTime:
- results.reduce(
- (acc, curr) => acc + curr.timing.mainThreadBlocking.total,
- 0
- ) / results.length,
- speedIndex: 0, // Default value
- timeToFirstByte: 0, // Default value
- firstInputDelay: 0, // Default value
- cumulativeLayoutShift:
- results.reduce(
- (acc, curr) => acc + curr.timing.cumulativeLayoutShift,
- 0
- ) / results.length,
- domSize: 0, // Default value
- totalRequests:
- results.reduce(
- (acc, curr) =>
- acc +
- (curr.resources.scripts.length +
- curr.resources.styles.length +
- curr.resources.images.length +
- curr.resources.fonts.length +
- curr.resources.other.length),
- 0
- ) / results.length,
- totalSize:
- results.reduce((acc, curr) => acc + curr.size.total, 0) /
- results.length,
- jsSize:
- results.reduce((acc, curr) => acc + curr.size.scripts.total, 0) /
- results.length,
- cssSize:
- results.reduce((acc, curr) => acc + curr.size.styles, 0) / results.length,
- imageSize:
- results.reduce((acc, curr) => acc + curr.size.images, 0) / results.length,
- fontSize:
- results.reduce((acc, curr) => acc + curr.size.fonts, 0) / results.length,
- otherSize:
- results.reduce((acc, curr) => acc + curr.size.other, 0) / results.length,
- thirdPartyRequests: 0, // Default value
- thirdPartySize: 0, // Default value
- thirdPartyDomains: 0, // Default value
- thirdPartyCookies: 0, // Default value
- thirdPartyLocalStorage: 0, // Default value
- thirdPartySessionStorage: 0, // Default value
- thirdPartyIndexedDB: 0, // Default value
- thirdPartyCache: 0, // Default value
- thirdPartyServiceWorkers: 0, // Default value
- thirdPartyWebWorkers: 0, // Default value
- thirdPartyWebSockets: 0, // Default value
- thirdPartyBeacons: 0, // Default value
- thirdPartyFetch: 0, // Default value
- thirdPartyXHR: 0, // Default value
- thirdPartyScripts: 0, // Default value
- thirdPartyStyles: 0, // Default value
- thirdPartyImages: 0, // Default value
- thirdPartyFonts: 0, // Default value
- thirdPartyMedia: 0, // Default value
- thirdPartyOther: 0, // Default value
- thirdPartyTiming: {
- total: 0,
- blocking: 0,
- dns: 0,
- connect: 0,
- ssl: 0,
- send: 0,
- wait: 0,
- receive: 0,
- },
- cookieBannerTiming: {
- firstPaint: 0,
- firstContentfulPaint:
- results.reduce(
- (acc, curr) => acc + curr.timing.firstContentfulPaint,
- 0
- ) / results.length,
- domContentLoaded:
- results.reduce((acc, curr) => acc + curr.timing.domContentLoaded, 0) /
- results.length,
- load:
- results.reduce((acc, curr) => acc + curr.timing.load, 0) /
- results.length,
- },
- };
- }
-
- /**
- * Log comprehensive benchmark results
- */
- logResults(
- finalMetrics: BenchmarkDetails,
- cookieBannerMetrics: CookieBannerMetrics,
- config: Config
- ): void {
- console.log("🔍 [DEBUG] Final cookie banner benchmark results:", {
- fcp: finalMetrics.timing.firstContentfulPaint,
- lcp: finalMetrics.timing.largestContentfulPaint,
- cls: finalMetrics.timing.cumulativeLayoutShift,
- tti: finalMetrics.timing.timeToInteractive,
- tbt: finalMetrics.timing.mainThreadBlocking.total,
- bannerDetected: finalMetrics.cookieBanner.detected,
- bannerRenderTime:
- finalMetrics.timing.cookieBanner.renderEnd -
- finalMetrics.timing.cookieBanner.renderStart,
- bannerLayoutShift: finalMetrics.timing.cookieBanner.layoutShift,
- bannerNetworkImpact: finalMetrics.thirdParty.totalImpact,
- bundleStrategy: cookieBannerMetrics.isBundled
- ? "Bundled"
- : cookieBannerMetrics.isIIFE
- ? "IIFE"
- : "Unknown",
- isBundled: cookieBannerMetrics.isBundled,
- isIIFE: cookieBannerMetrics.isIIFE,
- configBundleType: config.techStack?.bundleType,
- });
- }
-}
diff --git a/packages/cli/src/lib/performance-enhanced.ts b/packages/cli/src/lib/performance-enhanced.ts
deleted file mode 100644
index e2e9e71..0000000
--- a/packages/cli/src/lib/performance-enhanced.ts
+++ /dev/null
@@ -1,259 +0,0 @@
-import type { Page } from "@playwright/test";
-import {
- PerformanceMetricsCollector,
- DefaultNetworkPresets,
-} from "playwright-performance-metrics";
-import type { CookieBannerMetrics } from "./performance";
-
-export interface EnhancedPerformanceOptions {
- networkCondition?: keyof typeof DefaultNetworkPresets | "none";
- timeout?: number;
- initialDelay?: number;
- retryTimeout?: number;
-}
-
-export class EnhancedPerformanceCollector {
- private collector: PerformanceMetricsCollector;
-
- constructor() {
- this.collector = new PerformanceMetricsCollector();
- }
-
- async initialize(
- page: Page,
- options: EnhancedPerformanceOptions = {}
- ): Promise {
- console.log(
- "🔍 [ENHANCED-PERF] Initializing enhanced performance collector..."
- );
-
- // Set up network conditions if specified
- if (options.networkCondition && options.networkCondition !== "none") {
- const networkPreset = DefaultNetworkPresets[options.networkCondition];
- if (networkPreset) {
- console.log(
- `🔍 [ENHANCED-PERF] Applying network preset: ${options.networkCondition}`
- );
- await this.collector.initialize(page, networkPreset);
- } else {
- console.warn(
- `🔍 [ENHANCED-PERF] Unknown network preset: ${options.networkCondition}`
- );
- }
- }
- }
-
- async collectMetrics(
- page: Page,
- cookieBannerSelectors: string[] = [],
- options: EnhancedPerformanceOptions = {}
- ): Promise {
- console.log("🔍 [ENHANCED-PERF] Collecting performance metrics...");
-
- try {
- // Use the robust playwright-performance-metrics library
- const metrics = await this.collector.collectMetrics(page, {
- timeout: options.timeout || 10000,
- retryTimeout: options.retryTimeout || 5000,
- ...options,
- });
-
- console.log("🔍 [ENHANCED-PERF] Raw metrics collected:", {
- fcp: metrics.paint?.firstContentfulPaint,
- lcp: metrics.largestContentfulPaint,
- cls: metrics.cumulativeLayoutShift,
- tbt: metrics.totalBlockingTime,
- domComplete: metrics.domCompleteTiming,
- pageLoad: metrics.pageloadTiming,
- totalBytes: metrics.totalBytes,
- });
-
- // Detect cookie banners using our existing logic
- const bannerMetrics = await this.detectCookieBanner(
- page,
- cookieBannerSelectors
- );
-
- // Convert to our CookieBannerMetrics format
- const enhancedMetrics: CookieBannerMetrics = {
- // Core Web Vitals from the library
- fcp: metrics.paint?.firstContentfulPaint || 0,
- lcp: metrics.largestContentfulPaint || 0,
- cls: metrics.cumulativeLayoutShift || 0,
- tti: this.calculateTTI(metrics),
- tbt: metrics.totalBlockingTime || 0,
-
- // Cookie Banner Specific
- bannerDetected: bannerMetrics.detected,
- bannerSelector: bannerMetrics.selector,
- bannerFirstPaint: bannerMetrics.firstPaint,
- bannerLargestContentfulPaint: bannerMetrics.lcp,
- bannerTimeToInteractive: bannerMetrics.timeToInteractive,
- bannerHydrationTime: bannerMetrics.hydrationTime,
- bannerLayoutShift: bannerMetrics.layoutShift,
- bannerMainThreadBlocking: bannerMetrics.mainThreadBlocking,
- bannerNetworkImpact: bannerMetrics.networkImpact,
- bannerVisibilityTime: bannerMetrics.visibilityTime,
- bannerViewportCoverage: bannerMetrics.viewportCoverage,
- };
-
- console.log("🔍 [ENHANCED-PERF] Final enhanced metrics:", {
- fcp: enhancedMetrics.fcp,
- lcp: enhancedMetrics.lcp,
- cls: enhancedMetrics.cls,
- tti: enhancedMetrics.tti,
- tbt: enhancedMetrics.tbt,
- bannerDetected: enhancedMetrics.bannerDetected,
- });
-
- return enhancedMetrics;
- } catch (error) {
- console.error("🔍 [ENHANCED-PERF] Error collecting metrics:", error);
- // Fallback to basic metrics
- return this.getDefaultMetrics();
- }
- }
-
- private async detectCookieBanner(
- page: Page,
- selectors: string[]
- ): Promise<{
- detected: boolean;
- selector: string | null;
- firstPaint: number;
- lcp: number;
- timeToInteractive: number;
- hydrationTime: number;
- layoutShift: number;
- mainThreadBlocking: number;
- networkImpact: number;
- visibilityTime: number;
- viewportCoverage: number;
- }> {
- if (selectors.length === 0) {
- return {
- detected: false,
- selector: null,
- firstPaint: 0,
- lcp: 0,
- timeToInteractive: 0,
- hydrationTime: 0,
- layoutShift: 0,
- mainThreadBlocking: 0,
- networkImpact: 0,
- visibilityTime: 0,
- viewportCoverage: 0,
- };
- }
-
- return page.evaluate((selectors: string[]) => {
- for (const selector of selectors) {
- try {
- const element = document.querySelector(selector);
- if (element) {
- const rect = element.getBoundingClientRect();
- const isVisible =
- rect.width > 0 &&
- rect.height > 0 &&
- window.getComputedStyle(element).visibility !== "hidden" &&
- window.getComputedStyle(element).display !== "none";
-
- if (isVisible) {
- const viewportCoverage =
- ((rect.width * rect.height) /
- (window.innerWidth * window.innerHeight)) *
- 100;
- const now = performance.now();
-
- // Check if banner is interactive
- const buttons = element.querySelectorAll(
- 'button, a, [role="button"], [onclick]'
- );
- const timeToInteractive = buttons.length > 0 ? now : 0;
-
- return {
- detected: true,
- selector: selector,
- firstPaint: now,
- lcp: now,
- timeToInteractive: timeToInteractive,
- hydrationTime:
- timeToInteractive > 0 ? timeToInteractive - now : 0,
- layoutShift: 0, // Would need more complex measurement
- mainThreadBlocking: 0, // Would need more complex measurement
- networkImpact: 0, // Would need more complex measurement
- visibilityTime: 0, // Would need time tracking
- viewportCoverage: viewportCoverage,
- };
- }
- }
- } catch (error) {
- console.warn("🔍 [BANNER] Error checking selector:", selector, error);
- }
- }
-
- return {
- detected: false,
- selector: null,
- firstPaint: 0,
- lcp: 0,
- timeToInteractive: 0,
- hydrationTime: 0,
- layoutShift: 0,
- mainThreadBlocking: 0,
- networkImpact: 0,
- visibilityTime: 0,
- viewportCoverage: 0,
- };
- }, selectors);
- }
-
- private calculateTTI(metrics: {
- paint?: { firstContentfulPaint?: number };
- domCompleteTiming?: number | null;
- }): number {
- // TTI calculation based on the available metrics
- // This is a simplified approach - the library might provide better TTI measurement in the future
- const baseTime = Math.max(
- metrics.paint?.firstContentfulPaint || 0,
- metrics.domCompleteTiming || 0
- );
-
- // Add some buffer time for interactivity (simplified approach)
- return baseTime + 2000; // Add 2 seconds as a buffer
- }
-
- private getDefaultMetrics(): CookieBannerMetrics {
- return {
- fcp: 0,
- lcp: 0,
- cls: 0,
- tti: 0,
- tbt: 0,
- bannerDetected: false,
- bannerSelector: null,
- bannerFirstPaint: 0,
- bannerLargestContentfulPaint: 0,
- bannerTimeToInteractive: 0,
- bannerHydrationTime: 0,
- bannerLayoutShift: 0,
- bannerMainThreadBlocking: 0,
- bannerNetworkImpact: 0,
- bannerVisibilityTime: 0,
- bannerViewportCoverage: 0,
- };
- }
-
- async cleanup(): Promise {
- await this.collector.cleanup();
- }
-}
-
-// Network presets for easy access
-export const NetworkPresets = {
- REGULAR_4G: "REGULAR_4G" as const,
- SLOW_3G: "SLOW_3G" as const,
- FAST_3G: "FAST_3G" as const,
- FAST_WIFI: "FAST_WIFI" as const,
- NONE: "none" as const,
-};
diff --git a/packages/cli/src/lib/performance.ts b/packages/cli/src/lib/performance.ts
deleted file mode 100644
index 5735558..0000000
--- a/packages/cli/src/lib/performance.ts
+++ /dev/null
@@ -1,438 +0,0 @@
-import type { Page } from "@playwright/test";
-import type { LayoutShiftEntry } from "../types";
-
-interface WebVitalsMetrics {
- fcp: number;
- lcp: number;
- cls: number;
- tti: number;
- tbt: number;
- bannerDetected: boolean;
- bannerSelector: string | null;
- bannerVisibilityTime: number;
- bannerViewportCoverage: number;
- bannerFirstPaint: number;
- bannerLargestContentfulPaint: number;
- bannerTimeToInteractive: number;
- bannerHydrationTime: number;
- bannerLayoutShift: number;
- bannerMainThreadBlocking: number;
- bannerNetworkImpact: number;
- measurementComplete: boolean;
-}
-
-interface WindowWithWebVitals extends Window {
- __webVitalsMetrics: WebVitalsMetrics;
-}
-
-export interface CookieBannerMetrics {
- // Core Web Vitals
- fcp: number; // First Contentful Paint
- lcp: number; // Largest Contentful Paint
- cls: number; // Cumulative Layout Shift
- tti: number; // Time to Interactive
- tbt: number; // Total Blocking Time
-
- // Cookie Banner Specific Timings
- bannerFirstPaint: number; // When banner first appears
- bannerLargestContentfulPaint: number; // When banner's main content renders
- bannerTimeToInteractive: number; // When banner buttons become clickable
- bannerHydrationTime: number; // Time from first paint to interactive (hydration)
-
- // Cookie Banner Impact Measurements
- bannerLayoutShift: number; // Layout shifts caused by banner
- bannerMainThreadBlocking: number; // Main thread blocking from banner scripts
- bannerNetworkImpact: number; // Network requests caused by banner
-
- // Cookie Banner Detection
- bannerDetected: boolean;
- bannerSelector: string | null;
- bannerVisibilityTime: number; // How long banner was visible
- bannerViewportCoverage: number; // Percentage of viewport covered
-}
-
-export async function collectCookieBannerMetrics(
- page: Page,
- cookieBannerSelectors: string[],
- serviceHosts: string[]
-): Promise {
- console.log(
- "🔍 [PERFORMANCE] Setting up Web Vitals measurement before navigation..."
- );
-
- // Set up performance measurement BEFORE navigation
- await page.addInitScript((selectors: string[]) => {
- console.log(
- "🔍 [BROWSER] Setting up performance observers before page load..."
- );
-
- // Initialize global storage for metrics
- const webVitalsMetrics = {
- fcp: 0,
- lcp: 0,
- cls: 0,
- tti: 0,
- tbt: 0,
- bannerDetected: false,
- bannerSelector: null as string | null,
- bannerVisibilityTime: 0,
- bannerViewportCoverage: 0,
- bannerFirstPaint: 0,
- bannerLargestContentfulPaint: 0,
- bannerTimeToInteractive: 0,
- bannerHydrationTime: 0,
- bannerLayoutShift: 0,
- bannerMainThreadBlocking: 0,
- bannerNetworkImpact: 0,
- measurementComplete: false
- };
-
- (window as unknown as WindowWithWebVitals).__webVitalsMetrics =
- webVitalsMetrics;
-
- // Set up observers for paint timing
- try {
- // First Contentful Paint
- const fcpObserver = new PerformanceObserver((list) => {
- for (const entry of list.getEntries()) {
- if (entry.name === "first-contentful-paint") {
- webVitalsMetrics.fcp = entry.startTime;
- console.log("🔍 [BROWSER] FCP captured:", webVitalsMetrics.fcp);
- fcpObserver.disconnect();
- }
- }
- });
- fcpObserver.observe({ type: "paint", buffered: true });
-
- // Largest Contentful Paint
- const lcpObserver = new PerformanceObserver((list) => {
- const entries = list.getEntries();
- const lastEntry =
- entries.length > 0 ? entries[entries.length - 1] : null;
- if (lastEntry) {
- webVitalsMetrics.lcp = lastEntry.startTime;
- console.log("🔍 [BROWSER] LCP captured:", webVitalsMetrics.lcp);
- }
- });
- lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
-
- // Cumulative Layout Shift
- let clsValue = 0;
- const clsObserver = new PerformanceObserver((list) => {
- for (const entry of list.getEntries()) {
- const layoutShiftEntry = entry as LayoutShiftEntry;
- if (!layoutShiftEntry.hadRecentInput) {
- clsValue += layoutShiftEntry.value;
- }
- }
- webVitalsMetrics.cls = clsValue;
- if (clsValue > 0) {
- console.log("🔍 [BROWSER] CLS updated:", webVitalsMetrics.cls);
- }
- });
- clsObserver.observe({ type: "layout-shift", buffered: true });
-
- // Total Blocking Time
- let tbtValue = 0;
- const longTaskObserver = new PerformanceObserver((list) => {
- for (const entry of list.getEntries()) {
- if (entry.duration > 50) {
- tbtValue += entry.duration - 50;
- }
- }
- webVitalsMetrics.tbt = tbtValue;
- if (tbtValue > 0) {
- console.log("🔍 [BROWSER] TBT updated:", webVitalsMetrics.tbt);
- }
- });
- longTaskObserver.observe({ type: "longtask", buffered: true });
-
- console.log("🔍 [BROWSER] Performance observers set up successfully");
- } catch (error) {
- console.warn(
- "🔍 [BROWSER] Error setting up performance observers:",
- error
- );
- }
-
- // Cookie banner detection setup
- const detectCookieBanner = () => {
- for (const selector of selectors) {
- try {
- const element = document.querySelector(selector);
- if (element) {
- const rect = element.getBoundingClientRect();
- const isVisible =
- rect.width > 0 &&
- rect.height > 0 &&
- window.getComputedStyle(element).visibility !== "hidden" &&
- window.getComputedStyle(element).display !== "none";
-
- if (isVisible) {
- webVitalsMetrics.bannerDetected = true;
- webVitalsMetrics.bannerSelector = selector;
- webVitalsMetrics.bannerFirstPaint = performance.now();
- webVitalsMetrics.bannerViewportCoverage =
- ((rect.width * rect.height) /
- (window.innerWidth * window.innerHeight)) *
- 100;
-
- console.log("🔍 [BANNER] Cookie banner detected:", selector);
-
- // Check if banner is interactive
- const buttons = element.querySelectorAll(
- 'button, a, [role="button"], [onclick]'
- );
- if (buttons.length > 0) {
- webVitalsMetrics.bannerTimeToInteractive = performance.now();
- webVitalsMetrics.bannerHydrationTime =
- webVitalsMetrics.bannerTimeToInteractive -
- webVitalsMetrics.bannerFirstPaint;
- }
-
- return true;
- }
- }
- } catch (error) {
- console.warn("🔍 [BANNER] Error checking selector:", selector, error);
- }
- }
- return false;
- };
-
- // Set up banner detection after DOM is ready
- if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", () => {
- // Check immediately and then periodically
- if (!detectCookieBanner()) {
- const interval = setInterval(() => {
- if (detectCookieBanner()) {
- clearInterval(interval);
- }
- }, 100);
-
- // Stop checking after 10 seconds
- setTimeout(() => clearInterval(interval), 10000);
- }
- });
- } else {
- // DOM is already ready
- if (!detectCookieBanner()) {
- const interval = setInterval(() => {
- if (detectCookieBanner()) {
- clearInterval(interval);
- }
- }, 100);
-
- // Stop checking after 10 seconds
- setTimeout(() => clearInterval(interval), 10000);
- }
- }
- }, cookieBannerSelectors);
-
- return {
- fcp: 0,
- lcp: 0,
- cls: 0,
- tti: 0,
- tbt: 0,
- bannerDetected: false,
- bannerSelector: null,
- bannerVisibilityTime: 0,
- bannerViewportCoverage: 0,
- bannerFirstPaint: 0,
- bannerLargestContentfulPaint: 0,
- bannerTimeToInteractive: 0,
- bannerHydrationTime: 0,
- bannerLayoutShift: 0,
- bannerMainThreadBlocking: 0,
- bannerNetworkImpact: 0
- };
-}
-
-export async function collectWebVitalsAfterLoad(
- page: Page
-): Promise {
- console.log("🔍 [PERFORMANCE] Collecting final Web Vitals...");
-
- // Wait for page to be ready
- await page.waitForLoadState("networkidle");
-
- // Get final metrics from the browser
- const webVitals = await page.evaluate(() => {
- console.log("🔍 [BROWSER] Collecting final Web Vitals...");
-
- const storedMetrics = (window as unknown as WindowWithWebVitals)
- .__webVitalsMetrics;
- if (!storedMetrics) {
- console.error("🔍 [BROWSER] No stored metrics found!");
- return null;
- }
-
- // Get additional paint entries that might have been missed
- const paintEntries = performance.getEntriesByType("paint");
- console.log("🔍 [BROWSER] Paint entries found:", paintEntries.length);
- for (const entry of paintEntries) {
- console.log(
- `🔍 [BROWSER] Paint entry: ${entry.name} = ${entry.startTime}ms`
- );
- if (entry.name === "first-contentful-paint" && storedMetrics.fcp === 0) {
- storedMetrics.fcp = entry.startTime;
- console.log("🔍 [BROWSER] FCP from fallback:", storedMetrics.fcp);
- }
- }
-
- // Get LCP entries that might have been missed
- const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
- console.log("🔍 [BROWSER] LCP entries found:", lcpEntries.length);
- if (lcpEntries.length > 0 && storedMetrics.lcp === 0) {
- const lastLCP = lcpEntries[lcpEntries.length - 1];
- storedMetrics.lcp = lastLCP.startTime;
- console.log("🔍 [BROWSER] LCP from fallback:", storedMetrics.lcp);
- }
-
- console.log("🔍 [BROWSER] Final stored metrics:", {
- fcp: storedMetrics.fcp,
- lcp: storedMetrics.lcp,
- cls: storedMetrics.cls,
- tbt: storedMetrics.tbt,
- bannerDetected: storedMetrics.bannerDetected
- });
-
- return storedMetrics;
- });
-
- if (!webVitals) {
- console.warn(
- "🔍 [PERFORMANCE] No web vitals data available, returning defaults"
- );
- return {
- fcp: 0,
- lcp: 0,
- cls: 0,
- tti: 0,
- tbt: 0,
- bannerDetected: false,
- bannerSelector: null,
- bannerVisibilityTime: 0,
- bannerViewportCoverage: 0,
- bannerFirstPaint: 0,
- bannerLargestContentfulPaint: 0,
- bannerTimeToInteractive: 0,
- bannerHydrationTime: 0,
- bannerLayoutShift: 0,
- bannerMainThreadBlocking: 0,
- bannerNetworkImpact: 0
- };
- }
-
- // Calculate TTI (simplified approach)
- const navigationTiming = await page.evaluate(() => {
- const navigation = performance.getEntriesByType(
- "navigation"
- )[0] as PerformanceNavigationTiming;
- return {
- domContentLoaded:
- navigation.domContentLoadedEventEnd - navigation.fetchStart,
- loadComplete: navigation.loadEventEnd - navigation.fetchStart
- };
- });
-
- // Calculate TTI as a simple estimation
- const tti =
- Math.max(
- webVitals.fcp || 0,
- navigationTiming.domContentLoaded,
- webVitals.bannerTimeToInteractive || 0
- ) + 5000; // Add 5 seconds for page to become truly interactive
-
- const finalMetrics: CookieBannerMetrics = {
- fcp: webVitals.fcp,
- lcp: webVitals.lcp,
- cls: webVitals.cls,
- tti: tti,
- tbt: webVitals.tbt,
- bannerDetected: webVitals.bannerDetected,
- bannerSelector: webVitals.bannerSelector,
- bannerVisibilityTime: webVitals.bannerVisibilityTime,
- bannerViewportCoverage: webVitals.bannerViewportCoverage,
- bannerFirstPaint: webVitals.bannerFirstPaint,
- bannerLargestContentfulPaint:
- webVitals.bannerLargestContentfulPaint || webVitals.bannerFirstPaint,
- bannerTimeToInteractive: webVitals.bannerTimeToInteractive,
- bannerHydrationTime: webVitals.bannerHydrationTime,
- bannerLayoutShift: webVitals.bannerLayoutShift,
- bannerMainThreadBlocking: webVitals.bannerMainThreadBlocking,
- bannerNetworkImpact: webVitals.bannerNetworkImpact
- };
-
- console.log("🔍 [PERFORMANCE] Final metrics collected:", {
- fcp: finalMetrics.fcp,
- lcp: finalMetrics.lcp,
- cls: finalMetrics.cls,
- tti: finalMetrics.tti,
- tbt: finalMetrics.tbt,
- bannerDetected: finalMetrics.bannerDetected
- });
-
- return finalMetrics;
-}
-
-// Legacy function for backwards compatibility
-export async function collectPerformanceMetrics(
- page: Page,
- cookieBannerSelectors: string[] = []
-): Promise<{
- fcp: number;
- lcp: number;
- cls: number;
- tbt: number;
- tti: number;
-}> {
- const metrics = await collectCookieBannerMetrics(
- page,
- cookieBannerSelectors,
- []
- );
- return {
- fcp: metrics.fcp,
- lcp: metrics.lcp,
- cls: metrics.cls,
- tbt: metrics.tbt,
- tti: metrics.tti
- };
-}
-
-export async function collectResourceTiming(page: Page): Promise<{
- scriptLoadTime: number;
- totalSize: number;
- scriptSize: number;
- resourceCount: number;
- scriptCount: number;
-}> {
- return page.evaluate(() => {
- const resources = performance.getEntriesByType(
- "resource"
- ) as PerformanceResourceTiming[];
- const scripts = resources.filter((r) => r.initiatorType === "script");
-
- const totalSize = resources.reduce(
- (sum, r) => sum + (r.transferSize || 0),
- 0
- );
- const scriptSize = scripts.reduce(
- (sum, r) => sum + (r.transferSize || 0),
- 0
- );
- const scriptLoadTime =
- scripts.reduce((sum, r) => sum + r.duration, 0) / (scripts.length || 1);
-
- return {
- scriptLoadTime,
- totalSize,
- scriptSize,
- resourceCount: resources.length,
- scriptCount: scripts.length
- };
- });
-}
diff --git a/packages/cli/src/lib/server.ts b/packages/cli/src/lib/server.ts
deleted file mode 100644
index 7a9d4ed..0000000
--- a/packages/cli/src/lib/server.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { spawn } from "node:child_process";
-import { getPackageManager } from "../utils";
-import type { ServerInfo } from "../types";
-
-export async function buildAndServeNextApp(
- appPath?: string
-): Promise {
- const pm = await getPackageManager();
- const cwd = appPath || process.cwd();
-
- // Build the app
- console.log("[Build] Building Next.js app...");
- const buildProcess = spawn(pm.command, [...pm.args, "build"], {
- cwd,
- stdio: "inherit",
- });
-
- await new Promise((resolve, reject) => {
- buildProcess.on("close", (code) => {
- if (code === 0) {
- resolve();
- } else {
- reject(new Error(`Build failed with code ${code}`));
- }
- });
- });
-
- // Start the server
- console.log("[Build] Starting Next.js server...");
- const port = Math.floor(Math.random() * (9000 - 3000 + 1)) + 3000;
- console.log("command", [
- ...pm.args,
- "start",
- "--",
- "--port",
- port.toString(),
- ]);
- const serverProcess = spawn(
- pm.command,
- [...pm.args, "start", "--", "--port", port.toString()],
- {
- cwd,
- stdio: ["inherit", "pipe", "inherit"],
- }
- );
-
- // Wait for server to be ready
- const url = `http://localhost:${port}`;
- let retries = 0;
- const maxRetries = 30;
-
- while (retries < maxRetries) {
- try {
- const response = await fetch(url);
- if (response.ok) {
- console.log("[Build] Server is ready!");
- return { serverProcess, url };
- }
- } catch {
- // Ignore error and retry
- }
-
- await new Promise((resolve) => setTimeout(resolve, 1000));
- retries++;
- }
-
- throw new Error("Server failed to start");
-}
-
-export function cleanupServer(serverInfo: ServerInfo): void {
- if (serverInfo.serverProcess) {
- serverInfo.serverProcess.kill();
- }
-}
diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts
deleted file mode 100644
index 26d1967..0000000
--- a/packages/cli/src/types/index.ts
+++ /dev/null
@@ -1,369 +0,0 @@
-import type { Page } from "@playwright/test";
-import type { ChildProcess } from "node:child_process";
-
-export interface Config {
- name: string;
- url?: string;
- testId?: string;
- id?: string;
- iterations: number;
- baseline?: boolean;
- custom?: (page: Page) => Promise;
- remote?: {
- enabled?: boolean;
- url?: string;
- headers?: Record;
- };
- cookieBanner: CookieBannerConfig;
- internationalization: {
- detection: string;
- stringLoading: string;
- };
- techStack: {
- bundler: string;
- bundleType: string | string[];
- frameworks: string[];
- languages: string[];
- packageManager: string;
- typescript: boolean;
- };
- source: {
- github: string | false;
- isOpenSource: boolean | string;
- license: string;
- npm: string | false;
- website?: string;
- };
- includes: {
- backend: string | string[] | false;
- components: string[];
- };
- company?: {
- name: string;
- website: string;
- avatar: string;
- };
- tags?: string[];
-}
-
-export interface BenchmarkDetails {
- duration: number;
- size: {
- total: number;
- bundled: number;
- thirdParty: number;
- scripts: {
- total: number;
- initial: number;
- dynamic: number;
- };
- styles: number;
- images: number;
- fonts: number;
- other: number;
- };
- timing: {
- navigationStart: number;
- domContentLoaded: number;
- load: number;
- firstPaint: number;
- firstContentfulPaint: number;
- largestContentfulPaint: number;
- timeToInteractive: number;
- cumulativeLayoutShift: number;
- cookieBanner: {
- renderStart: number;
- renderEnd: number;
- interactionStart: number;
- interactionEnd: number;
- layoutShift: number;
- detected: boolean;
- selector: string | null;
- serviceName: string;
- visibilityTime: number | null;
- viewportCoverage: number;
- };
- thirdParty: {
- dnsLookupTime: number;
- connectionTime: number;
- downloadTime: number;
- totalImpact: number;
- cookieServices: {
- hosts: string[];
- totalSize: number;
- resourceCount: number;
- dnsLookupTime: number;
- connectionTime: number;
- downloadTime: number;
- };
- };
- mainThreadBlocking: {
- total: number;
- cookieBannerEstimate: number;
- percentageFromCookies: number;
- };
- scripts: {
- bundled: {
- loadStart: number;
- loadEnd: number;
- executeStart: number;
- executeEnd: number;
- };
- thirdParty: {
- loadStart: number;
- loadEnd: number;
- executeStart: number;
- executeEnd: number;
- };
- };
- };
- language: string;
- resources: {
- scripts: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- isDynamic: boolean;
- }>;
- styles: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- }>;
- images: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- }>;
- fonts: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- }>;
- other: Array<{
- name: string;
- size: number;
- duration: number;
- startTime: number;
- isThirdParty: boolean;
- type: string;
- }>;
- };
- cookieBanner: EnhancedCookieBannerTiming;
- thirdParty: ThirdPartyMetrics;
-}
-
-export interface BenchmarkResult {
- name: string;
- baseline: boolean;
- techStack: Config["techStack"];
- source: Config["source"];
- includes: Config["includes"];
- company?: Config["company"];
- tags?: string[];
- details: BenchmarkDetails[];
- average: {
- firstContentfulPaint: number;
- largestContentfulPaint: number;
- timeToInteractive: number;
- totalBlockingTime: number;
- speedIndex: number;
- timeToFirstByte: number;
- firstInputDelay: number;
- cumulativeLayoutShift: number;
- domSize: number;
- totalRequests: number;
- totalSize: number;
- jsSize: number;
- cssSize: number;
- imageSize: number;
- fontSize: number;
- otherSize: number;
- thirdPartyRequests: number;
- thirdPartySize: number;
- thirdPartyDomains: number;
- thirdPartyCookies: number;
- thirdPartyLocalStorage: number;
- thirdPartySessionStorage: number;
- thirdPartyIndexedDB: number;
- thirdPartyCache: number;
- thirdPartyServiceWorkers: number;
- thirdPartyWebWorkers: number;
- thirdPartyWebSockets: number;
- thirdPartyBeacons: number;
- thirdPartyFetch: number;
- thirdPartyXHR: number;
- thirdPartyScripts: number;
- thirdPartyStyles: number;
- thirdPartyImages: number;
- thirdPartyFonts: number;
- thirdPartyMedia: number;
- thirdPartyOther: number;
- thirdPartyTiming: {
- total: number;
- blocking: number;
- dns: number;
- connect: number;
- ssl: number;
- send: number;
- wait: number;
- receive: number;
- };
- cookieBannerTiming: {
- firstPaint: number;
- firstContentfulPaint: number;
- domContentLoaded: number;
- load: number;
- };
- };
- scores?: {
- totalScore: number;
- grade: "Excellent" | "Good" | "Fair" | "Poor" | "Critical";
- categoryScores: {
- performance: number;
- bundleStrategy: number;
- networkImpact: number;
- transparency: number;
- userExperience: number;
- };
- categories: Array<{
- name: string;
- score: number;
- maxScore: number;
- weight: number;
- details: Array<{
- name: string;
- score: number;
- maxScore: number;
- weight: number;
- status: "excellent" | "good" | "fair" | "poor";
- reason: string;
- }>;
- status: "excellent" | "good" | "fair" | "poor";
- reason: string;
- }>;
- insights: string[];
- recommendations: string[];
- };
-}
-
-export interface ServerInfo {
- serverProcess: ChildProcess;
- url: string;
-}
-
-// Performance API type definitions
-export interface LayoutShiftEntry extends PerformanceEntry {
- value: number;
- hadRecentInput: boolean;
-}
-
-export interface ResourceTimingEntry extends PerformanceEntry {
- initiatorType: string;
- transferSize: number;
-}
-
-export interface LargestContentfulPaintEntry extends PerformanceEntry {
- startTime: number;
-}
-
-export interface FirstContentfulPaintEntry extends PerformanceEntry {
- startTime: number;
-}
-
-export interface LongTaskEntry extends PerformanceEntry {
- duration: number;
-}
-
-export interface PerformanceMetrics {
- fcp: number;
- lcp: number;
- cls: number;
- tbt: number;
- tti: number;
-}
-
-interface CookieBannerConfig {
- selectors: string[];
- serviceHosts: string[];
- waitForVisibility: boolean;
- measureViewportCoverage: boolean;
- expectedLayoutShift: boolean;
- serviceName: string;
-}
-
-interface EnhancedCookieBannerTiming {
- detected: boolean;
- selector: string | null;
- serviceName: string;
- visibilityTime: number | null;
- viewportCoverage: number;
-}
-
-interface ThirdPartyMetrics {
- cookieServices: {
- hosts: string[];
- totalSize: number;
- resourceCount: number;
- dnsLookupTime: number;
- connectionTime: number;
- downloadTime: number;
- };
- totalImpact: number;
-}
-
-declare global {
- interface Window {
- CLS?: number;
- TBT?: number;
- TTI?: number;
- __scriptTiming?: {
- bundled: {
- executeStart?: number;
- executeEnd?: number;
- };
- thirdParty: {
- executeStart?: number;
- executeEnd?: number;
- };
- };
- }
-}
-
-export interface BenchmarkScores {
- totalScore: number;
- grade: 'Excellent' | 'Good' | 'Fair' | 'Poor' | 'Critical';
- categoryScores: {
- performance: number;
- bundleStrategy: number;
- networkImpact: number;
- transparency: number;
- userExperience: number;
- };
- categories: Array<{
- name: string;
- score: number;
- maxScore: number;
- weight: number;
- details: Array<{
- name: string;
- score: number;
- maxScore: number;
- weight: number;
- status: 'excellent' | 'good' | 'fair' | 'poor';
- reason: string;
- }>;
- status: 'excellent' | 'good' | 'fair' | 'poor';
- reason: string;
- }>;
- insights: string[];
- recommendations: string[];
-}
diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts
deleted file mode 100644
index 372ea35..0000000
--- a/packages/cli/src/utils/index.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { readFileSync } from "node:fs";
-import { join } from "node:path";
-import type { Config } from "../types";
-
-export function readConfig(configName = "config"): Config | null {
- try {
- const configPath = join(process.cwd(), "config.json");
- const configContent = readFileSync(configPath, "utf-8");
- return JSON.parse(configContent) as Config;
- } catch (error) {
- console.error(`Failed to read ${configName}/config.json:`, error);
- return null;
- }
-}
-
-export function formatTime(ms: number): string {
- if (ms < 1000) {
- return `${ms.toFixed(0)}ms`;
- }
- return `${(ms / 1000).toFixed(2)}s`;
-}
-
-export async function getPackageManager(): Promise<{
- command: string;
- args: string[];
-}> {
- try {
- const { execSync } = await import("node:child_process");
- const output = execSync("npm -v", { encoding: "utf-8" });
- if (output) {
- return { command: "npm", args: ["run"] };
- }
- } catch {
- try {
- const { execSync } = await import("node:child_process");
- const output = execSync("yarn -v", { encoding: "utf-8" });
- if (output) {
- return { command: "yarn", args: [] };
- }
- } catch {
- try {
- const { execSync } = await import("node:child_process");
- const output = execSync("pnpm -v", { encoding: "utf-8" });
- if (output) {
- return { command: "pnpm", args: [] };
- }
- } catch {
- // Default to npm if no package manager is found
- return { command: "npm", args: ["run"] };
- }
- }
- }
- // Default to npm if no package manager is found
- return { command: "npm", args: ["run"] };
-}
diff --git a/packages/cli/src/utils/scoring.ts b/packages/cli/src/utils/scoring.ts
deleted file mode 100644
index 9b4552c..0000000
--- a/packages/cli/src/utils/scoring.ts
+++ /dev/null
@@ -1,1192 +0,0 @@
-import type { RawBenchmarkDetail } from "../commands/results";
-import type { Config } from "../types";
-import Table from "cli-table3";
-import color from "picocolors";
-import { determineBundleStrategy } from "../commands/benchmark/bundle-strategy";
-import type { BenchmarkScores } from '../types';
-
-// Type definitions for better type safety
-interface AppData {
- id?: number;
- name: string;
- baseline: boolean;
- company: string | null;
- techStack: string;
- source: string | null;
- tags: string | null;
-}
-
-interface MetricsData {
- fcp: number;
- lcp: number;
- cls: number;
- tti: number;
- tbt: number;
- totalSize: number;
- thirdPartySize: number;
- bannerVisibilityTime: number;
- viewportCoverage: number;
- resourceCount?: number;
- scriptLoadTime?: number;
- isBundled?: boolean;
- isIIFE?: boolean;
-}
-
-interface ResourceData {
- size: number;
- isThirdParty: boolean;
-}
-
-interface BenchmarkData {
- bannerRenderTime?: number;
- bannerInteractionTime?: number;
- layoutShift?: number;
-}
-
-interface TechStackData {
- languages: string[];
- frameworks: string[];
- bundler: string;
- bundleType: string;
- packageManager: string;
- typescript: boolean;
-}
-
-interface CompanyData {
- name?: string;
- avatar?: string;
-}
-
-interface SourceData {
- license?: string;
- github?: string;
- repository?: string;
- openSource?: boolean;
- type?: string;
-}
-
-interface CategoryScores {
- performance: number;
- bundleStrategy: number;
- networkImpact: number;
- transparency: number;
- userExperience: number;
-}
-
-interface ScoreDetail {
- metric: string;
- value: string | number;
- score: number;
- maxScore: number;
- reason: string;
-}
-
-interface ScoreWeights {
- performance: number;
- bundleStrategy: number;
- networkImpact: number;
- transparency: number;
- userExperience: number;
-}
-
-// Default scoring weights
-export const DEFAULT_SCORE_WEIGHTS: ScoreWeights = {
- performance: 0.4, // 40% - Core Web Vitals and performance metrics
- bundleStrategy: 0.25, // 25% - First-party vs third-party, bundling approach
- networkImpact: 0.2, // 20% - Network requests, bundle size, third-party impact
- transparency: 0.1, // 10% - Open source, company info, tech stack disclosure
- userExperience: 0.05, // 5% - Layout stability, interaction responsiveness
-};
-
-// Helper function to format time values
-function formatTime(ms: number): string {
- if (ms < 1000) {
- return `${Math.round(ms)}ms`;
- }
- return `${(ms / 1000).toFixed(2)}s`;
-}
-
-// Helper function to format byte values
-function formatBytes(bytes: number): string {
- if (bytes === 0) {
- return '0 bytes';
- }
- const k = 1024;
- const sizes = ['bytes', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
-}
-
-// Helper function to determine if a solution is open source
-function isOpenSourceSolution(
- app: AppData,
- sourceInfo: SourceData | null,
- tags: string
-): boolean {
- // Check source information for open source indicators
- if (sourceInfo) {
- // Check for open source license
- const license = sourceInfo.license?.toLowerCase() || '';
- const openSourceLicenses = [
- 'mit',
- 'apache',
- 'gpl',
- 'bsd',
- 'lgpl',
- 'mpl',
- 'isc',
- 'unlicense',
- 'cc0',
- 'wtfpl',
- 'zlib',
- 'artistic',
- 'epl',
- 'cddl',
- ];
-
- if (openSourceLicenses.some((lic) => license.includes(lic))) {
- return true;
- }
-
- // Check GitHub repository
- if (sourceInfo.github || sourceInfo.repository?.includes('github.com')) {
- return true;
- }
-
- // Check if explicitly marked as open source
- if (sourceInfo.openSource === true || sourceInfo.type === 'open-source') {
- return true;
- }
- }
-
- // Check tags for open source indicators
- const lowerTags = tags.toLowerCase();
- if (
- lowerTags.includes('open source') ||
- lowerTags.includes('opensource') ||
- lowerTags.includes('oss') ||
- lowerTags.includes('free') ||
- lowerTags.includes('community')
- ) {
- return true;
- }
-
- // Check app name for known open source solutions
- const appName = app.name.toLowerCase();
- const knownOpenSource = [
- 'c15t',
- 'cookieconsent',
- 'klaro',
- 'tarteaucitron',
- 'osano',
- 'react-cookie-consent',
- 'vanilla-cookieconsent',
- 'baseline',
- ];
-
- if (knownOpenSource.some((name) => appName.includes(name))) {
- return true;
- }
-
- return false;
-}
-
-// Helper function to parse tech stack
-function parseTechStack(techStackJson: string): TechStackData {
- try {
- const techStack = JSON.parse(techStackJson);
- return {
- languages: techStack.languages || [],
- frameworks: techStack.frameworks || [],
- bundler: techStack.bundler || 'unknown',
- bundleType: techStack.bundleType || 'unknown',
- packageManager: techStack.packageManager || 'unknown',
- typescript: techStack.typescript || false,
- };
- } catch {
- return {
- languages: [],
- frameworks: [],
- bundler: 'unknown',
- bundleType: 'unknown',
- packageManager: 'unknown',
- typescript: false,
- };
- }
-}
-
-// Helper function to parse company info
-function parseCompany(companyJson: string | null): CompanyData | null {
- if (!companyJson) {
- return null;
- }
- try {
- return JSON.parse(companyJson);
- } catch {
- return null;
- }
-}
-
-// Helper function to parse source info
-function parseSource(sourceJson: string | null): SourceData | null {
- if (!sourceJson) {
- return null;
- }
- try {
- return JSON.parse(sourceJson);
- } catch {
- return null;
- }
-}
-
-// Calculate performance score (out of 100) with more sensitive thresholds
-function calculatePerformanceScore(metrics: MetricsData): {
- score: number;
- maxScore: number;
- details: ScoreDetail[];
-} {
- const details: ScoreDetail[] = [];
- let totalScore = 0;
- const maxScore = 100;
-
- // Ensure all metrics are finite numbers
- const fcp = Number.isFinite(metrics.fcp) ? metrics.fcp : 0;
- const lcp = Number.isFinite(metrics.lcp) ? metrics.lcp : 0;
- const cls = Number.isFinite(metrics.cls) ? metrics.cls : 0;
- const tti = Number.isFinite(metrics.tti) ? metrics.tti : 0;
- const tbt = Number.isFinite(metrics.tbt) ? metrics.tbt : 0;
-
- // FCP Score (20 points) - More sensitive for fast sites
- const fcpScore = fcp <= 50 ? 20 : fcp <= 100 ? 18 : fcp <= 200 ? 15 : fcp <= 500 ? 10 : 5;
- totalScore += fcpScore;
- details.push({
- metric: 'First Contentful Paint',
- value: formatTime(fcp),
- score: fcpScore,
- maxScore: 20,
- reason: fcp <= 50 ? 'Excellent' : fcp <= 100 ? 'Very Good' : fcp <= 200 ? 'Good' : fcp <= 500 ? 'Fair' : 'Poor',
- });
-
- // LCP Score (25 points) - More sensitive for banner rendering
- const lcpScore = lcp <= 100 ? 25 : lcp <= 300 ? 20 : lcp <= 500 ? 15 : lcp <= 1000 ? 10 : 5;
- totalScore += lcpScore;
- details.push({
- metric: 'Largest Contentful Paint',
- value: formatTime(lcp),
- score: lcpScore,
- maxScore: 25,
- reason: lcp <= 100 ? 'Excellent' : lcp <= 300 ? 'Very Good' : lcp <= 500 ? 'Good' : lcp <= 1000 ? 'Fair' : 'Poor',
- });
-
- // CLS Score (20 points)
- const clsScore = cls <= 0.01 ? 20 : cls <= 0.05 ? 15 : cls <= 0.1 ? 10 : cls <= 0.25 ? 5 : 0;
- totalScore += clsScore;
- details.push({
- metric: 'Cumulative Layout Shift',
- value: cls.toFixed(3),
- score: clsScore,
- maxScore: 20,
- reason: cls <= 0.01 ? 'Excellent' : cls <= 0.05 ? 'Very Good' : cls <= 0.1 ? 'Good' : cls <= 0.25 ? 'Fair' : 'Poor',
- });
-
- // TTI Score (20 points) - Cookie banners should be interactive quickly
- const ttiScore = tti <= 1000 ? 20 : tti <= 1500 ? 15 : tti <= 2000 ? 10 : tti <= 3000 ? 5 : 0;
- totalScore += ttiScore;
- details.push({
- metric: 'Time to Interactive',
- value: formatTime(tti),
- score: ttiScore,
- maxScore: 20,
- reason: tti <= 1000 ? 'Excellent' : tti <= 1500 ? 'Very Good' : tti <= 2000 ? 'Good' : tti <= 3000 ? 'Fair' : 'Poor',
- });
-
- // TBT Score (15 points) - Main thread blocking
- const tbtScore = tbt <= 50 ? 15 : tbt <= 200 ? 10 : tbt <= 500 ? 5 : 0;
- totalScore += tbtScore;
- details.push({
- metric: 'Total Blocking Time',
- value: formatTime(tbt),
- score: tbtScore,
- maxScore: 15,
- reason: tbt <= 50 ? 'Excellent' : tbt <= 200 ? 'Good' : tbt <= 500 ? 'Fair' : 'Poor',
- });
-
- return { score: totalScore, maxScore, details };
-}
-
-// Calculate bundle strategy score (out of 100)
-function calculateBundleScore(
- metrics: MetricsData,
- techStack: TechStackData,
- resourceData: ResourceData[]
-): {
- score: number;
- maxScore: number;
- details: ScoreDetail[];
-} {
- const details: ScoreDetail[] = [];
- let totalScore = 0;
- const maxScore = 100;
-
- // Bundle strategy (40 points)
- const bundleScore = metrics.isBundled ? 40 : metrics.isIIFE ? 20 : 10;
- totalScore += bundleScore;
- details.push({
- metric: 'Bundle Strategy',
- value: metrics.isBundled ? 'Bundled' : metrics.isIIFE ? 'IIFE' : 'Unknown',
- score: bundleScore,
- maxScore: 40,
- reason: metrics.isBundled
- ? 'First-party bundled'
- : metrics.isIIFE
- ? 'External script'
- : 'Unknown strategy',
- });
-
- // Third-party dependency ratio (30 points)
- const thirdPartyResources = resourceData.filter((r) => r.isThirdParty);
- const thirdPartyRatio =
- thirdPartyResources.length / Math.max(resourceData.length, 1);
- const thirdPartyScore =
- thirdPartyRatio <= 0.1 ? 30 : thirdPartyRatio <= 0.3 ? 20 : thirdPartyRatio <= 0.5 ? 10 : 0;
- totalScore += thirdPartyScore;
- details.push({
- metric: 'Third-party Dependencies',
- value: `${thirdPartyResources.length}/${resourceData.length}`,
- score: thirdPartyScore,
- maxScore: 30,
- reason:
- thirdPartyRatio <= 0.1
- ? 'Minimal third-party'
- : thirdPartyRatio <= 0.3
- ? 'Low third-party'
- : thirdPartyRatio <= 0.5
- ? 'Moderate third-party'
- : 'Heavy third-party',
- });
-
- // Modern bundler (20 points)
- const modernBundlers = [
- 'webpack',
- 'vite',
- 'rollup',
- 'esbuild',
- 'turbopack',
- 'rspack',
- 'rslib',
- 'nextjs',
- ];
- const bundlerScore =
- techStack && modernBundlers.includes(techStack.bundler.toLowerCase())
- ? 20
- : 10;
- totalScore += bundlerScore;
- details.push({
- metric: 'Bundler',
- value: techStack?.bundler || 'Unknown',
- score: bundlerScore,
- maxScore: 20,
- reason:
- techStack && modernBundlers.includes(techStack.bundler.toLowerCase())
- ? 'Modern bundler'
- : 'Legacy/unknown bundler',
- });
-
- // TypeScript usage (10 points)
- const tsScore = techStack?.typescript ? 10 : 0;
- totalScore += tsScore;
- details.push({
- metric: 'TypeScript',
- value: techStack?.typescript ? 'Yes' : 'No',
- score: tsScore,
- maxScore: 10,
- reason: techStack?.typescript ? 'Type safety' : 'No type safety',
- });
-
- return { score: totalScore, maxScore, details };
-}
-
-// Calculate network impact score (out of 100)
-function calculateNetworkScore(
- metrics: MetricsData,
- resourceData: ResourceData[]
-): {
- score: number;
- maxScore: number;
- details: ScoreDetail[];
-} {
- const details: ScoreDetail[] = [];
- let totalScore = 0;
- const maxScore = 100;
-
- // Ensure metrics are finite numbers
- const totalSize = Number.isFinite(metrics.totalSize) ? metrics.totalSize : 0;
- const thirdPartySize = Number.isFinite(metrics.thirdPartySize) ? metrics.thirdPartySize : 0;
- const resourceCount = Number.isFinite(metrics.resourceCount)
- ? metrics.resourceCount || 0
- : 0;
- const scriptLoadTime = Number.isFinite(metrics.scriptLoadTime)
- ? metrics.scriptLoadTime || 0
- : 0;
-
- // Total size impact (35 points) - More sensitive for lightweight solutions
- const totalSizeKB = totalSize / 1024;
- const sizeScore =
- totalSizeKB <= 50 ? 35 : totalSizeKB <= 100 ? 25 : totalSizeKB <= 200 ? 15 : totalSizeKB <= 500 ? 10 : 5;
- totalScore += sizeScore;
- details.push({
- metric: 'Total Bundle Size',
- value: formatBytes(totalSize),
- score: sizeScore,
- maxScore: 35,
- reason:
- totalSizeKB <= 50
- ? 'Ultra lightweight'
- : totalSizeKB <= 100
- ? 'Lightweight'
- : totalSizeKB <= 200
- ? 'Moderate'
- : totalSizeKB <= 500
- ? 'Heavy'
- : 'Very heavy',
- });
-
- // Third-party size impact (25 points)
- const thirdPartySizeKB = thirdPartySize / 1024;
- const thirdPartyNetworkScore =
- thirdPartySizeKB === 0 ? 25 : thirdPartySizeKB <= 50 ? 15 : thirdPartySizeKB <= 100 ? 10 : 5;
- totalScore += thirdPartyNetworkScore;
- details.push({
- metric: 'Third-party Size',
- value: formatBytes(thirdPartySize),
- score: thirdPartyNetworkScore,
- maxScore: 25,
- reason:
- thirdPartySizeKB === 0
- ? 'Zero third-party'
- : thirdPartySizeKB <= 50
- ? 'Minimal third-party'
- : thirdPartySizeKB <= 100
- ? 'Moderate third-party'
- : 'Heavy third-party',
- });
-
- // Network requests (25 points)
- const requestScore =
- resourceCount <= 3 ? 25 : resourceCount <= 5 ? 20 : resourceCount <= 10 ? 15 : resourceCount <= 15 ? 10 : 5;
- totalScore += requestScore;
- details.push({
- metric: 'Network Requests',
- value: resourceCount.toString(),
- score: requestScore,
- maxScore: 25,
- reason:
- resourceCount <= 3
- ? 'Minimal requests'
- : resourceCount <= 5
- ? 'Low requests'
- : resourceCount <= 10
- ? 'Moderate requests'
- : resourceCount <= 15
- ? 'Many requests'
- : 'Too many requests',
- });
-
- // Script load time (15 points)
- const scriptScore =
- scriptLoadTime <= 50 ? 15 : scriptLoadTime <= 100 ? 10 : scriptLoadTime <= 200 ? 5 : 0;
- totalScore += scriptScore;
- details.push({
- metric: 'Script Load Time',
- value: formatTime(scriptLoadTime),
- score: scriptScore,
- maxScore: 15,
- reason:
- scriptLoadTime <= 50
- ? 'Very fast loading'
- : scriptLoadTime <= 100
- ? 'Fast loading'
- : scriptLoadTime <= 200
- ? 'Moderate loading'
- : 'Slow loading',
- });
-
- return { score: totalScore, maxScore, details };
-}
-
-// Calculate transparency score (out of 100)
-function calculateTransparencyScore(
- isOpenSource: boolean,
- company: CompanyData | null,
- techStack: TechStackData
-): {
- score: number;
- maxScore: number;
- details: ScoreDetail[];
-} {
- const details: ScoreDetail[] = [];
- let totalScore = 0;
- const maxScore = 100;
-
- // Open source bonus (60 points)
- const openSourceScore = isOpenSource ? 60 : 0;
- totalScore += openSourceScore;
- details.push({
- metric: 'Open Source',
- value: isOpenSource ? 'Yes' : 'No',
- score: openSourceScore,
- maxScore: 60,
- reason: isOpenSource ? 'Transparent & auditable' : 'Proprietary solution',
- });
-
- // Company transparency (25 points)
- const companyScore = company ? 25 : 10;
- totalScore += companyScore;
- details.push({
- metric: 'Company Info',
- value: company ? 'Available' : 'Limited',
- score: companyScore,
- maxScore: 25,
- reason: company ? 'Clear attribution' : 'Limited transparency',
- });
-
- // Tech stack disclosure (15 points)
- const techScore = techStack && techStack.bundler !== 'unknown' ? 15 : 5;
- totalScore += techScore;
- details.push({
- metric: 'Tech Stack',
- value: techStack ? 'Disclosed' : 'Unknown',
- score: techScore,
- maxScore: 15,
- reason: techStack && techStack.bundler !== 'unknown' ? 'Technical transparency' : 'Limited tech info',
- });
-
- return { score: totalScore, maxScore, details };
-}
-
-// Calculate user experience score (out of 100)
-function calculateUXScore(
- metrics: MetricsData,
- benchmarkData: BenchmarkData
-): {
- score: number;
- maxScore: number;
- details: ScoreDetail[];
-} {
- const details: ScoreDetail[] = [];
- let totalScore = 0;
- const maxScore = 100;
-
- // Layout stability (40 points)
- const cls = Number.isFinite(metrics.cls) ? metrics.cls : 0;
- const clsScore = cls <= 0.01 ? 40 : cls <= 0.05 ? 30 : cls <= 0.1 ? 20 : cls <= 0.25 ? 10 : 0;
- totalScore += clsScore;
- details.push({
- metric: 'Layout Stability',
- value: cls.toFixed(3),
- score: clsScore,
- maxScore: 40,
- reason:
- cls <= 0.01
- ? 'No layout shifts'
- : cls <= 0.05
- ? 'Minimal shifts'
- : cls <= 0.1
- ? 'Minor shifts'
- : cls <= 0.25
- ? 'Some shifts'
- : 'Significant shifts',
- });
-
- // Banner render time (35 points)
- const renderTime = Number.isFinite(benchmarkData.bannerRenderTime)
- ? benchmarkData.bannerRenderTime || 0
- : metrics.bannerVisibilityTime || 0;
- const renderScore = renderTime <= 25 ? 35 : renderTime <= 50 ? 25 : renderTime <= 100 ? 15 : renderTime <= 200 ? 10 : 5;
- totalScore += renderScore;
- details.push({
- metric: 'Banner Render Time',
- value: formatTime(renderTime),
- score: renderScore,
- maxScore: 35,
- reason:
- renderTime <= 25
- ? 'Instant render'
- : renderTime <= 50
- ? 'Very fast render'
- : renderTime <= 100
- ? 'Fast render'
- : renderTime <= 200
- ? 'Moderate render'
- : 'Slow render',
- });
-
- // Viewport coverage impact (25 points)
- const coverage = Number.isFinite(metrics.viewportCoverage) ? metrics.viewportCoverage : 0;
- const coverageScore = coverage <= 10 ? 25 : coverage <= 20 ? 20 : coverage <= 30 ? 15 : coverage <= 50 ? 10 : 5;
- totalScore += coverageScore;
- details.push({
- metric: 'Viewport Coverage',
- value: `${coverage.toFixed(1)}%`,
- score: coverageScore,
- maxScore: 25,
- reason:
- coverage <= 10
- ? 'Minimal intrusion'
- : coverage <= 20
- ? 'Low intrusion'
- : coverage <= 30
- ? 'Moderate intrusion'
- : coverage <= 50
- ? 'High intrusion'
- : 'Very intrusive',
- });
-
- return { score: totalScore, maxScore, details };
-}
-
-// Generate insights based on scores
-function generateInsights(
- categoryScores: CategoryScores,
- metrics: MetricsData,
- resourceData: ResourceData[],
- isOpenSource: boolean
-): string[] {
- const insights: string[] = [];
-
- // Performance insights
- if (categoryScores.performance >= 90) {
- insights.push(
- 'Outstanding performance metrics across all Core Web Vitals.'
- );
- } else if (categoryScores.performance < 60) {
- insights.push(
- 'Performance optimization needed - focus on reducing load times and layout shifts.'
- );
- }
-
- // Bundle strategy insights
- if (metrics.isBundled) {
- insights.push(
- 'Excellent bundle strategy - first-party bundling reduces network overhead and improves reliability.'
- );
- } else if (metrics.isIIFE) {
- insights.push(
- 'Consider bundling strategy to reduce third-party dependencies and improve performance.'
- );
- }
-
- // Open source insights
- if (!isOpenSource) {
- insights.push(
- 'Consider open source alternatives for better transparency and community support.'
- );
- } else {
- insights.push(
- 'Open source solution provides transparency and community-driven development.'
- );
- }
-
- // Network insights
- const thirdPartyResources = resourceData.filter((r) => r.isThirdParty);
- if (thirdPartyResources.length === 0) {
- insights.push(
- 'Zero third-party dependencies minimize privacy concerns and improve reliability.'
- );
- } else if (thirdPartyResources.length > 5) {
- insights.push(
- 'High number of third-party requests may impact performance and privacy.'
- );
- }
-
- return insights;
-}
-
-// Generate recommendations based on scores
-function generateRecommendations(
- categoryScores: CategoryScores,
- metrics: MetricsData,
- resourceData: ResourceData[]
-): string[] {
- const recommendations: string[] = [];
-
- // Performance recommendations
- if (categoryScores.performance < 80) {
- if (metrics.fcp > 100) {
- recommendations.push(
- 'Optimize First Contentful Paint by reducing render-blocking resources.'
- );
- }
- if (metrics.lcp > 300) {
- recommendations.push(
- 'Improve Largest Contentful Paint by optimizing critical resource loading.'
- );
- }
- if (metrics.cls > 0.05) {
- recommendations.push(
- 'Reduce Cumulative Layout Shift by reserving space for dynamic content.'
- );
- }
- if (metrics.tbt > 50) {
- recommendations.push(
- 'Reduce Total Blocking Time by optimizing JavaScript execution.'
- );
- }
- }
-
- // Bundle strategy recommendations
- if (categoryScores.bundleStrategy < 70) {
- if (!metrics.isBundled) {
- recommendations.push(
- 'Consider bundling cookie consent code with your main application bundle.'
- );
- }
- const thirdPartyRatio =
- resourceData.filter((r) => r.isThirdParty).length /
- Math.max(resourceData.length, 1);
- if (thirdPartyRatio > 0.3) {
- recommendations.push(
- 'Reduce third-party dependencies to improve reliability and performance.'
- );
- }
- }
-
- // Network recommendations
- if (categoryScores.networkImpact < 70) {
- if (metrics.totalSize > 100 * 1024) {
- recommendations.push(
- 'Reduce bundle size through code splitting and tree shaking.'
- );
- }
- if (metrics.thirdPartySize > 0) {
- recommendations.push(
- 'Eliminate or reduce third-party resources for better performance.'
- );
- }
- }
-
- return recommendations;
-}
-
-// Get score grade based on total score
-function getScoreGrade(score: number): BenchmarkScores['grade'] {
- if (score >= 90) return 'Excellent';
- if (score >= 80) return 'Good';
- if (score >= 70) return 'Fair';
- if (score >= 60) return 'Poor';
- return 'Critical';
-}
-
-// Get category status based on score percentage
-function getCategoryStatus(
- score: number,
- maxScore: number
-): 'excellent' | 'good' | 'fair' | 'poor' {
- const percentage = (score / maxScore) * 100;
- if (percentage >= 90) return 'excellent';
- if (percentage >= 75) return 'good';
- if (percentage >= 60) return 'fair';
- return 'poor';
-}
-
-// Main scoring function with CLI-compatible interface
-export function calculateScores(
- metrics: {
- fcp: number;
- lcp: number;
- cls: number;
- tbt: number;
- tti: number;
- },
- bundleMetrics: {
- totalSize: number;
- jsSize: number;
- cssSize: number;
- imageSize: number;
- fontSize: number;
- otherSize: number;
- },
- networkMetrics: {
- totalRequests: number;
- thirdPartyRequests: number;
- thirdPartySize: number;
- thirdPartyDomains: number;
- },
- transparencyMetrics: {
- cookieBannerDetected: boolean;
- cookieBannerTiming: number | null;
- cookieBannerCoverage: number;
- },
- userExperienceMetrics: {
- domSize: number;
- mainThreadBlocking: number;
- layoutShifts: number;
- },
- isBaseline = false,
- appData?: AppData
-): BenchmarkScores {
- if (isBaseline) {
- return {
- totalScore: 100,
- grade: 'Excellent',
- categoryScores: {
- performance: 100,
- bundleStrategy: 100,
- networkImpact: 100,
- transparency: 100,
- userExperience: 100,
- },
- categories: [
- {
- name: 'Performance',
- score: 100,
- maxScore: 100,
- weight: 1,
- details: [
- {
- name: 'Core Web Vitals',
- score: 100,
- maxScore: 100,
- weight: 1,
- status: 'good',
- reason: 'Baseline measurement',
- },
- ],
- status: 'good',
- reason: 'Baseline measurement',
- },
- {
- name: 'Bundle Strategy',
- score: 100,
- maxScore: 100,
- weight: 1,
- details: [
- {
- name: 'Bundle Size',
- score: 100,
- maxScore: 100,
- weight: 1,
- status: 'good',
- reason: 'Baseline measurement',
- },
- ],
- status: 'good',
- reason: 'Baseline measurement',
- },
- {
- name: 'Network Impact',
- score: 100,
- maxScore: 100,
- weight: 1,
- details: [
- {
- name: 'Network Requests',
- score: 100,
- maxScore: 100,
- weight: 1,
- status: 'good',
- reason: 'Baseline measurement',
- },
- ],
- status: 'good',
- reason: 'Baseline measurement',
- },
- {
- name: 'Transparency',
- score: 100,
- maxScore: 100,
- weight: 1,
- details: [
- {
- name: 'Cookie Banner',
- score: 100,
- maxScore: 100,
- weight: 1,
- status: 'good',
- reason: 'Baseline measurement',
- },
- ],
- status: 'good',
- reason: 'Baseline measurement',
- },
- {
- name: 'User Experience',
- score: 100,
- maxScore: 100,
- weight: 1,
- details: [
- {
- name: 'User Experience',
- score: 100,
- maxScore: 100,
- weight: 1,
- status: 'good',
- reason: 'Baseline measurement',
- },
- ],
- status: 'good',
- reason: 'Baseline measurement',
- },
- ],
- insights: [],
- recommendations: [],
- };
- }
-
- // Create app data structure
- const app: AppData = appData || {
- name: 'unknown',
- baseline: false,
- company: null,
- techStack: '{}',
- source: null,
- tags: null,
- };
-
- // Create metrics data
- const metricsData: MetricsData = {
- fcp: metrics.fcp,
- lcp: metrics.lcp,
- cls: metrics.cls,
- tti: metrics.tti,
- tbt: metrics.tbt,
- totalSize: bundleMetrics.totalSize,
- thirdPartySize: networkMetrics.thirdPartySize,
- bannerVisibilityTime: transparencyMetrics.cookieBannerTiming || 0,
- viewportCoverage: transparencyMetrics.cookieBannerCoverage * 100,
- resourceCount: networkMetrics.totalRequests,
- scriptLoadTime: 0, // TODO: Calculate from timing data
- isBundled: networkMetrics.thirdPartyRequests === 0,
- isIIFE: networkMetrics.thirdPartyRequests > 0,
- };
-
- // Create mock resource data
- const resourceData: ResourceData[] = [
- { size: bundleMetrics.jsSize, isThirdParty: false },
- { size: bundleMetrics.cssSize, isThirdParty: false },
- ...Array(networkMetrics.thirdPartyRequests).fill({ size: networkMetrics.thirdPartySize / Math.max(networkMetrics.thirdPartyRequests, 1), isThirdParty: true }),
- ];
-
- // Create benchmark data
- const benchmarkData: BenchmarkData = {
- bannerRenderTime: transparencyMetrics.cookieBannerTiming || 0,
- bannerInteractionTime: userExperienceMetrics.mainThreadBlocking,
- layoutShift: userExperienceMetrics.layoutShifts,
- };
-
- // Parse app data
- const techStack = parseTechStack(app.techStack);
- const company = parseCompany(app.company);
- const sourceInfo = parseSource(app.source);
- const tags = app.tags || '';
- const isOpenSource = isOpenSourceSolution(app, sourceInfo, tags);
-
- // Calculate individual category scores
- const performanceScore = calculatePerformanceScore(metricsData);
- const bundleScore = calculateBundleScore(metricsData, techStack, resourceData);
- const networkScore = calculateNetworkScore(metricsData, resourceData);
- const transparencyScore = calculateTransparencyScore(
- isOpenSource,
- company,
- techStack
- );
- const uxScore = calculateUXScore(metricsData, benchmarkData);
-
- // Calculate weighted total score using more balanced weights
- const weights = DEFAULT_SCORE_WEIGHTS;
- const totalScore = Math.round(
- (performanceScore.score / performanceScore.maxScore) *
- 100 *
- weights.performance +
- (bundleScore.score / bundleScore.maxScore) *
- 100 *
- weights.bundleStrategy +
- (networkScore.score / networkScore.maxScore) *
- 100 *
- weights.networkImpact +
- (transparencyScore.score / transparencyScore.maxScore) *
- 100 *
- weights.transparency +
- (uxScore.score / uxScore.maxScore) * 100 * weights.userExperience
- );
-
- // Create category scores
- const categoryScores = {
- performance: Math.round((performanceScore.score / performanceScore.maxScore) * 100),
- bundleStrategy: Math.round((bundleScore.score / bundleScore.maxScore) * 100),
- networkImpact: Math.round((networkScore.score / networkScore.maxScore) * 100),
- transparency: Math.round((transparencyScore.score / transparencyScore.maxScore) * 100),
- userExperience: Math.round((uxScore.score / uxScore.maxScore) * 100),
- };
-
- // Create score categories
- const categories = [
- {
- name: 'Performance',
- score: performanceScore.score,
- maxScore: performanceScore.maxScore,
- weight: weights.performance,
- details: performanceScore.details.map(d => ({
- name: d.metric,
- score: d.score,
- maxScore: d.maxScore,
- weight: 1,
- status: getCategoryStatus(d.score, d.maxScore),
- reason: d.reason,
- })),
- status: getCategoryStatus(performanceScore.score, performanceScore.maxScore),
- reason: `Performance score: ${performanceScore.score}/${performanceScore.maxScore}`,
- },
- {
- name: 'Bundle Strategy',
- score: bundleScore.score,
- maxScore: bundleScore.maxScore,
- weight: weights.bundleStrategy,
- details: bundleScore.details.map(d => ({
- name: d.metric,
- score: d.score,
- maxScore: d.maxScore,
- weight: 1,
- status: getCategoryStatus(d.score, d.maxScore),
- reason: d.reason,
- })),
- status: getCategoryStatus(bundleScore.score, bundleScore.maxScore),
- reason: `Bundle strategy score: ${bundleScore.score}/${bundleScore.maxScore}`,
- },
- {
- name: 'Network Impact',
- score: networkScore.score,
- maxScore: networkScore.maxScore,
- weight: weights.networkImpact,
- details: networkScore.details.map(d => ({
- name: d.metric,
- score: d.score,
- maxScore: d.maxScore,
- weight: 1,
- status: getCategoryStatus(d.score, d.maxScore),
- reason: d.reason,
- })),
- status: getCategoryStatus(networkScore.score, networkScore.maxScore),
- reason: `Network impact score: ${networkScore.score}/${networkScore.maxScore}`,
- },
- {
- name: 'Transparency',
- score: transparencyScore.score,
- maxScore: transparencyScore.maxScore,
- weight: weights.transparency,
- details: transparencyScore.details.map(d => ({
- name: d.metric,
- score: d.score,
- maxScore: d.maxScore,
- weight: 1,
- status: getCategoryStatus(d.score, d.maxScore),
- reason: d.reason,
- })),
- status: getCategoryStatus(transparencyScore.score, transparencyScore.maxScore),
- reason: `Transparency score: ${transparencyScore.score}/${transparencyScore.maxScore}`,
- },
- {
- name: 'User Experience',
- score: uxScore.score,
- maxScore: uxScore.maxScore,
- weight: weights.userExperience,
- details: uxScore.details.map(d => ({
- name: d.metric,
- score: d.score,
- maxScore: d.maxScore,
- weight: 1,
- status: getCategoryStatus(d.score, d.maxScore),
- reason: d.reason,
- })),
- status: getCategoryStatus(uxScore.score, uxScore.maxScore),
- reason: `User experience score: ${uxScore.score}/${uxScore.maxScore}`,
- },
- ];
-
- // Generate insights and recommendations
- const insights = generateInsights(
- categoryScores,
- metricsData,
- resourceData,
- isOpenSource
- );
- const recommendations = generateRecommendations(
- categoryScores,
- metricsData,
- resourceData
- );
-
- return {
- totalScore,
- grade: getScoreGrade(totalScore),
- categoryScores,
- categories,
- insights,
- recommendations,
- };
-}
-
-// Function to print scores in a table format
-export function printScores(scores: BenchmarkScores): void {
- // Create a table for overall scores
- const overallTable = new Table({
- head: ['Category', 'Score', 'Status'],
- style: { head: ['cyan'] },
- });
-
- // Add overall score
- overallTable.push([
- 'Overall',
- `${scores.totalScore}/100`,
- scores.grade,
- ]);
-
- // Add category scores
- for (const category of scores.categories) {
- overallTable.push([
- category.name,
- `${category.score}/100`,
- category.status,
- ]);
- }
-
- console.log('\nOverall Scores:');
- console.log(overallTable.toString());
-
- // Create a table for detailed scores
- const detailsTable = new Table({
- head: ['Category', 'Metric', 'Score', 'Reason'],
- style: { head: ['cyan'] },
- });
-
- // Add detailed scores
- for (const category of scores.categories) {
- for (const detail of category.details) {
- detailsTable.push([
- category.name,
- detail.name,
- `${detail.score}/100`,
- detail.reason,
- ]);
- }
- }
-
- console.log('\nDetailed Scores:');
- console.log(detailsTable.toString());
-
- // Print insights
- if (scores.insights.length > 0) {
- console.log('\nInsights:');
- for (const insight of scores.insights) {
- console.log(`• ${insight}`);
- }
- }
-
- // Print recommendations
- if (scores.recommendations.length > 0) {
- console.log('\nRecommendations:');
- for (const recommendation of scores.recommendations) {
- console.log(`• ${recommendation}`);
- }
- }
-}
diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
deleted file mode 100644
index a655867..0000000
--- a/packages/cli/tsconfig.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2020",
- "module": "ESNext",
- "moduleResolution": "bundler",
- "esModuleInterop": true,
- "strict": true,
- "skipLibCheck": true,
- "outDir": "dist",
- "rootDir": "src"
- },
- "include": ["src/**/*"],
- "exclude": ["node_modules", "dist"]
-}
\ No newline at end of file
diff --git a/packages/cookiebench-cli/README.md b/packages/cookiebench-cli/README.md
new file mode 100644
index 0000000..fefad30
--- /dev/null
+++ b/packages/cookiebench-cli/README.md
@@ -0,0 +1,399 @@
+# cookiebench
+
+Command-line interface for cookie banner performance benchmarking.
+
+## Overview
+
+This CLI tool provides an interactive interface for running cookie banner benchmarks, viewing results, and managing the benchmark database.
+
+## Installation
+
+```bash
+pnpm add -g cookiebench
+```
+
+Or use in a workspace:
+
+```bash
+pnpm add cookiebench
+```
+
+## Usage
+
+### Interactive Mode
+
+Run without arguments for interactive prompts:
+
+```bash
+cookiebench
+```
+
+### Direct Commands
+
+Run specific commands directly:
+
+```bash
+# Run a benchmark
+cookiebench benchmark
+
+# View detailed benchmark results
+cookiebench results
+
+# View scores from existing results (available via CLI only)
+cookiebench scores
+
+# Sync results to database (admin only)
+cookiebench save
+
+# Manage database (admin only)
+cookiebench db push
+```
+
+## Commands
+
+### benchmark
+
+Run performance benchmarks on configured applications.
+
+```bash
+# Interactive mode - select multiple benchmarks to run
+cookiebench benchmark
+
+# Run a specific benchmark
+cookiebench benchmark benchmarks/c15t-nextjs
+```
+
+**Interactive Multi-Select Mode:**
+
+When run without arguments, the command will:
+1. Scan the `benchmarks/` directory for available benchmarks
+2. Present a multi-select interface (use space to toggle selections)
+3. Show iteration counts from each config file
+4. Ask for iterations override (or press Enter to use config values)
+5. Ask if you want to show the results panel after completion
+6. Run selected benchmarks sequentially
+7. Show a summary of completed benchmarks
+8. Automatically display results for the benchmarks you just ran (if enabled, skips selection menu)
+
+**Single Benchmark Mode:**
+
+When a specific path is provided:
+1. Read `config.json` from the specified path
+2. Build and serve the Next.js app (or use remote URL if configured)
+3. Run benchmarks for the specified number of iterations
+4. Calculate performance scores
+5. Save results to `results.json`
+6. Display scores
+
+**Features:**
+- ✅ Multi-select with space bar toggle
+- ✅ View default iterations from config files
+- ✅ Override iterations for all benchmarks or use individual config values
+- ✅ Sequential execution with progress indicators
+- ✅ Automatic results panel for completed benchmarks (no extra selection needed)
+- ✅ Comprehensive metrics: scores, insights, Core Web Vitals, resource breakdown, network waterfall
+- ✅ Error handling - continues to next benchmark on failure
+- ✅ Summary report at the end
+
+**Example:**
+
+```text
+? Select benchmarks to run:
+ ◼ baseline
+ ◼ c15t-nextjs
+ ◼ cookieyes
+
+● info Config iterations: baseline: 5, c15t-nextjs: 3, cookieyes: 5
+
+? Number of iterations (press Enter to use config values):
+› Default: 5
+
+ [Just press Enter to use config values, or type a number to override all]
+
+? Show results panel after completion? › Yes
+
+```
+
+### results
+
+View comprehensive benchmark results with detailed metrics and analysis.
+
+```bash
+# Interactive mode - select which benchmarks to view (all selected by default)
+cookiebench results
+
+# View results for a specific app
+cookiebench results c15t-nextjs
+
+# View all results
+cookiebench results __all__
+```
+
+**Interactive Multi-Select Mode:**
+
+When run without arguments, you can:
+1. Select which benchmarks to view using space bar (all selected by default)
+2. Press Enter to view detailed results for selected benchmarks
+3. View baseline comparisons and deltas
+
+**Features:**
+
+Displays a detailed panel for each selected benchmark with:
+
+1. **🎯 Overall Score** - Color-coded score (0-100) with grade (Excellent/Good/Fair/Poor/Critical)
+ - 🟢 Green (90+) = Excellent
+ - 🟡 Yellow (70-89) = Good/Fair
+ - 🔴 Red (<70) = Poor/Critical
+
+2. **💡 Key Insights** - Auto-generated bullet points highlighting strengths and areas for improvement
+
+3. **🍪 Cookie Banner Impact** - Banner visibility, viewport coverage, network impact, bundle strategy
+
+4. **⚡ Core Web Vitals** - FCP, LCP, TTI, CLS with performance ratings
+
+5. **📦 Resource Breakdown** - Detailed size analysis by type (JS, CSS, Images, Fonts, Other)
+
+6. **📊 Performance Impact Summary** - Loading strategy, render performance, network overhead, layout stability
+
+7. **🌐 Network Chart** - ASCII waterfall visualization showing resource loading timeline with color-coded bars
+
+8. **📋 Resource Details** - Sortable table with resource names, types, sources, sizes, and durations
+
+**Notes:**
+- Interactive multi-select UI - choose which benchmarks to view
+- All benchmarks selected by default for quick viewing
+- Aggregates results from all `results.json` files in benchmark directories
+- Calculates scores on-demand from raw metrics
+- Baseline comparison with delta values
+- No database writes (read-only)
+- Automatically shown after running benchmarks (if enabled)
+
+**Example:**
+
+```text
+? Select benchmarks to view (use space to toggle, all selected by default):
+ ◼ baseline (benchmarks/baseline)
+ ◼ c15t-nextjs (benchmarks/c15t-nextjs)
+ ◼ cookieyes (benchmarks/cookieyes)
+
+● info Viewing results for: baseline, c15t-nextjs, cookieyes
+
+```
+
+### scores
+
+**Available via direct CLI only** - View calculated scores from existing benchmark results.
+
+```bash
+# Interactive: choose which app to view
+cookiebench scores
+
+# View scores for a specific app
+cookiebench scores c15t-nextjs
+
+# View scores for all apps
+cookiebench scores __all__
+```
+
+Features:
+- Reads existing `results.json` files from benchmark directories
+- Uses pre-calculated scores if available, or calculates them on-demand
+- Displays detailed score breakdowns by category
+- Shows insights and recommendations
+- Much faster than re-running full benchmarks
+
+**Note:** This command is not shown in the interactive menu. Use the `results` command instead for a comprehensive view with all metrics, or access `scores` directly via CLI for score-only output.
+
+### save
+
+**🔒 Admin Only** - Sync benchmark results to database.
+
+```bash
+# Interactive: choose which benchmarks to save
+cookiebench save
+
+# Save a specific benchmark
+cookiebench save c15t-nextjs
+
+# Save all benchmarks (can also select in interactive mode)
+cookiebench save __all__
+```
+
+**Requirements:**
+- `CONSENT_ADMIN=true` environment variable must be set
+- `API_URL` for the database endpoint
+- `DATABASE_URL` and `DATABASE_AUTH_TOKEN` for database access
+
+Features:
+- Multi-select interface for choosing which benchmarks to sync
+- Sends results to API endpoint (oRPC)
+- Persists to Turso/SQLite database
+- Confirmation prompt before syncing
+- Shows success/failure count
+
+**Note:** This command is hidden from the menu and unavailable unless you have admin access. Contact the Consent.io team for access credentials if needed.
+
+### db
+
+**🔒 Admin Only** - Manage the benchmark database.
+
+```bash
+# Push database schema
+cookiebench db push
+
+# View database status
+cookiebench db
+```
+
+## Configuration
+
+Create a `config.json` file in your project:
+
+```json
+{
+ "name": "my-app-cookieyes",
+ "iterations": 5,
+ "baseline": false,
+ "cookieBanner": {
+ "serviceName": "CookieYes",
+ "selectors": [
+ "#cookieyes-banner",
+ ".cky-consent-container"
+ ],
+ "serviceHosts": [
+ "cdn-cookieyes.com"
+ ],
+ "waitForVisibility": true,
+ "measureViewportCoverage": true,
+ "expectedLayoutShift": true
+ },
+ "techStack": {
+ "frameworks": ["react", "nextjs"],
+ "languages": ["typescript"],
+ "bundler": "webpack",
+ "bundleType": "esm",
+ "packageManager": "pnpm",
+ "typescript": true
+ },
+ "source": {
+ "license": "MIT",
+ "isOpenSource": true,
+ "github": "https://github.com/org/repo",
+ "npm": false
+ },
+ "includes": {
+ "backend": false,
+ "components": ["button", "banner"]
+ },
+ "company": {
+ "name": "Company Name",
+ "website": "https://company.com",
+ "avatar": "https://company.com/logo.png"
+ },
+ "tags": ["cookie-banner", "consent-management"]
+}
+```
+
+### Remote Benchmarking
+
+To benchmark a remote URL instead of building locally:
+
+```json
+{
+ "name": "production-app",
+ "remote": {
+ "enabled": true,
+ "url": "https://production.example.com",
+ "headers": {
+ "Authorization": "Bearer token"
+ }
+ }
+}
+```
+
+## Environment Variables
+
+```bash
+# Logging level (optional, default: info)
+LOG_LEVEL=debug # error | warn | info | debug
+# Note: Use LOG_LEVEL=debug to see detailed processing information
+
+# Admin access (required for save and db commands)
+CONSENT_ADMIN=true
+
+# Database configuration (required for save command)
+DATABASE_URL=libsql://your-turso-db.turso.io
+DATABASE_AUTH_TOKEN=your-auth-token
+
+# API endpoint for saving results (required for save command)
+API_URL=http://localhost:3000
+```
+
+### Admin Commands
+
+Some commands require admin access and are only available when `CONSENT_ADMIN=true` is set:
+
+- `save` - Sync benchmark results to database (admin only)
+- `db` - Manage database schema and migrations (admin only)
+
+**For Consent.io team members:**
+```bash
+# Enable admin mode
+export CONSENT_ADMIN=true
+
+# Now admin commands are available
+cookiebench save
+cookiebench db push
+```
+
+**For public users:**
+The `benchmark`, `results`, and `scores` commands work without admin access. Admin commands will be hidden from the menu and show an error if attempted.
+
+## Output
+
+### results.json
+
+Each benchmark creates a `results.json` file with:
+- Raw performance metrics for each iteration
+- Calculated scores (performance, bundle strategy, network impact, transparency, UX)
+- Metadata (timestamp, iterations, configuration)
+
+### Console Output
+
+The CLI displays:
+- Progress indicators for each iteration
+- Performance metrics (FCP, LCP, CLS, TTI, TBT)
+- Cookie banner detection results
+- Comprehensive scores with grades (Excellent/Good/Fair/Poor/Critical)
+- Insights and recommendations
+
+## Scoring
+
+The tool calculates scores across five categories:
+
+1. **Performance** (30%): FCP, LCP, CLS, TTI, TBT
+2. **Bundle Strategy** (25%): Bundle size, loading approach, execution time
+3. **Network Impact** (20%): Third-party requests, cookie service overhead
+4. **Transparency** (15%): Open-source status, documentation, licensing
+5. **User Experience** (10%): Banner timing, coverage, layout shifts
+
+## Development
+
+```bash
+# Build the CLI
+pnpm build
+
+# Run in development
+pnpm dev
+
+# Type checking
+pnpm check-types
+
+# Linting
+pnpm lint
+```
+
+## License
+
+MIT
+
diff --git a/packages/cookiebench-cli/package.json b/packages/cookiebench-cli/package.json
new file mode 100644
index 0000000..02ce715
--- /dev/null
+++ b/packages/cookiebench-cli/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "cookiebench",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "exports": "./dist/index.mjs",
+ "main": "./dist/index.mjs",
+ "module": "dist/index.mjs",
+ "bin": {
+ "cookiebench": "dist/index.mjs"
+ },
+ "scripts": {
+ "build": "rslib build",
+ "check-types": "tsc --noEmit",
+ "dev": "rslib build --watch",
+ "fmt": "biome format . --write",
+ "lint": "biome lint .",
+ "prestart": "pnpm -w turbo run build --filter=./packages/cookiebench-cli...",
+ "start": "node ./dist/index.mjs"
+ },
+ "dependencies": {
+ "@c15t/logger": "^1.0.1",
+ "@clack/prompts": "^1.0.1",
+ "@consentio/benchmark": "workspace:*",
+ "@consentio/runner": "workspace:*",
+ "@consentio/shared": "workspace:*",
+ "cli-table3": "^0.6.5",
+ "dotenv": "^17.3.1",
+ "figlet": "^1.10.0",
+ "picocolors": "^1.1.1",
+ "pretty-ms": "^9.3.0"
+ },
+ "devDependencies": {
+ "@rsdoctor/rspack-plugin": "^1.5.2",
+ "@rslib/core": "^0.16.1",
+ "@types/figlet": "^1.7.0",
+ "@types/node": "catalog:",
+ "typescript": "catalog:"
+ }
+}
diff --git a/packages/cookiebench-cli/rslib.config.ts b/packages/cookiebench-cli/rslib.config.ts
new file mode 100644
index 0000000..40e72d0
--- /dev/null
+++ b/packages/cookiebench-cli/rslib.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from "@rslib/core";
+
+export default defineConfig({
+ source: {
+ entry: {
+ index: "./src/index.ts",
+ },
+ exclude: ["figlet"],
+ },
+ lib: [
+ {
+ bundle: true,
+ dts: false,
+ format: "esm",
+ },
+ ],
+ output: {
+ target: "node",
+ cleanDistPath: true,
+ filename: {
+ js: "[name].mjs",
+ },
+ },
+});
diff --git a/packages/cookiebench-cli/src/commands/benchmark.ts b/packages/cookiebench-cli/src/commands/benchmark.ts
new file mode 100644
index 0000000..a03b970
--- /dev/null
+++ b/packages/cookiebench-cli/src/commands/benchmark.ts
@@ -0,0 +1,715 @@
+import { execSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdir, readdir, writeFile } from "node:fs/promises";
+import { createRequire } from "node:module";
+import { cpus } from "node:os";
+import { join } from "node:path";
+import { setTimeout } from "node:timers/promises";
+import {
+ cancel,
+ confirm,
+ intro,
+ isCancel,
+ multiselect,
+ outro,
+ text,
+} from "@clack/prompts";
+import {
+ type BenchmarkResult,
+ BenchmarkRunner,
+ buildAndServeNextApp,
+ cleanupServer,
+ type Config,
+ type ServerInfo,
+} from "@consentio/runner";
+import color from "picocolors";
+import {
+ ConfigValidationError,
+ DEFAULT_DOM_SIZE,
+ DEFAULT_ITERATIONS,
+ findProjectRoot,
+ formatConfigIssues,
+ HALF_SECOND,
+ loadValidatedConfigSync,
+ PERCENTAGE_DIVISOR,
+ resolveBenchmarkPath,
+ SEPARATOR_WIDTH,
+} from "../utils";
+import type { CliLogger } from "../utils/logger";
+import { calculateScores, printScores } from "../utils/scoring";
+
+type TraceMode = "off" | "on-failure" | "all";
+
+const require = createRequire(import.meta.url);
+
+type BenchmarkCommandOptions = {
+ traceMode?: TraceMode;
+ profile?: Config["runProfile"]["networkProfile"];
+ cacheMode?: Config["runProfile"]["cacheMode"];
+};
+
+/**
+ * Calculate average from array
+ */
+function calculateAverage(values: number[]): number {
+ if (values.length === 0) {
+ return 0;
+ }
+ return values.reduce((acc, curr) => acc + curr, 0) / values.length;
+}
+
+/**
+ * Calculate timing metrics from benchmark results
+ */
+function calculateTimingMetrics(details: BenchmarkResult["details"]) {
+ const validTtfb = details
+ .map((d) => d.timing.timeToFirstByte)
+ .filter((value): value is number => value !== null && value > 0);
+ const validInp = details
+ .map((d) => d.timing.interactionToNextPaint)
+ .filter((value): value is number => value !== null && value > 0);
+
+ return {
+ fcp: calculateAverage(details.map((d) => d.timing.firstContentfulPaint)),
+ lcp: calculateAverage(details.map((d) => d.timing.largestContentfulPaint)),
+ cls: calculateAverage(details.map((d) => d.timing.cumulativeLayoutShift)),
+ tbt: calculateAverage(
+ details.map((d) => d.timing.mainThreadBlocking.total)
+ ),
+ tti: calculateAverage(details.map((d) => d.timing.timeToInteractive)),
+ timeToFirstByte:
+ validTtfb.length > 0 ? calculateAverage(validTtfb) : undefined,
+ interactionToNextPaint:
+ validInp.length > 0 ? calculateAverage(validInp) : undefined,
+ };
+}
+
+/**
+ * Calculate size metrics from benchmark results
+ */
+function calculateSizeMetrics(details: BenchmarkResult["details"]) {
+ return {
+ totalSize: calculateAverage(details.map((d) => d.size.total)),
+ jsSize: calculateAverage(details.map((d) => d.size.scripts.total)),
+ cssSize: calculateAverage(details.map((d) => d.size.styles)),
+ imageSize: calculateAverage(details.map((d) => d.size.images)),
+ fontSize: calculateAverage(details.map((d) => d.size.fonts)),
+ otherSize: calculateAverage(details.map((d) => d.size.other)),
+ };
+}
+
+/**
+ * Calculate network metrics from benchmark results
+ */
+function calculateNetworkMetrics(details: BenchmarkResult["details"]) {
+ const totalRequests = calculateAverage(
+ details.map(
+ (d) =>
+ d.resources.scripts.length +
+ d.resources.styles.length +
+ d.resources.images.length +
+ d.resources.fonts.length +
+ d.resources.other.length
+ )
+ );
+
+ const thirdPartyRequests = calculateAverage(
+ details.map(
+ (d) =>
+ d.resources.scripts.filter((r) => r.isThirdParty).length +
+ d.resources.styles.filter((r) => r.isThirdParty).length +
+ d.resources.images.filter((r) => r.isThirdParty).length +
+ d.resources.fonts.filter((r) => r.isThirdParty).length +
+ d.resources.other.filter((r) => r.isThirdParty).length
+ )
+ );
+
+ const thirdPartySize = calculateAverage(
+ details.map((d) => d.size.thirdParty)
+ );
+
+ const thirdPartyDomains = calculateAverage(
+ details.map((d) => {
+ const domains = new Set();
+ for (const resource of [
+ ...d.resources.scripts,
+ ...d.resources.styles,
+ ...d.resources.images,
+ ...d.resources.fonts,
+ ...d.resources.other,
+ ]) {
+ if (resource.isThirdParty && resource.name) {
+ try {
+ domains.add(new URL(resource.name).hostname);
+ } catch {
+ // Skip invalid URLs
+ }
+ }
+ }
+ return domains.size;
+ })
+ );
+
+ const scriptLoadTime = calculateAverage(
+ details.map(
+ (d) =>
+ d.timing.scripts.bundled.loadEnd + d.timing.scripts.thirdParty.loadEnd
+ )
+ );
+
+ return {
+ totalRequests,
+ thirdPartyRequests,
+ thirdPartySize,
+ thirdPartyDomains,
+ scriptLoadTime,
+ };
+}
+
+/**
+ * Calculate cookie banner metrics from benchmark results.
+ */
+function calculateCookieBannerMetrics(
+ details: BenchmarkResult["details"],
+ logger: CliLogger
+) {
+ const allDetected = details.every((r) => r.cookieBanner.detected);
+ if (!allDetected) {
+ logger.warn(
+ "⚠️ [SCORING] Banner detection inconsistent or failed - marking as not detected"
+ );
+ }
+
+ const detectionSuccess = details.some((r) => r.cookieBanner.detected);
+ let cookieBannerVisibleTimeMs: number | null = null;
+
+ if (detectionSuccess) {
+ const timingValues = details.map(
+ (r) => r.cookieBanner.userVisibleTime ?? r.cookieBanner.visibilityTime
+ );
+ const hasNullValues = timingValues.some((t) => t === null || t === 0);
+
+ if (hasNullValues) {
+ logger.warn(
+ "⚠️ [SCORING] Inconsistent banner detection - applying penalty"
+ );
+ } else {
+ const validTimings = timingValues.filter(
+ (t): t is number => t !== null && t > 0
+ );
+ if (validTimings.length === details.length && validTimings.length > 0) {
+ cookieBannerVisibleTimeMs = calculateAverage(validTimings);
+ }
+ }
+ } else {
+ logger.warn(
+ "⚠️ [SCORING] No banner detected in any iteration - applying penalty"
+ );
+ }
+
+ let cookieBannerCoverage = 0;
+ if (allDetected) {
+ cookieBannerCoverage =
+ calculateAverage(details.map((d) => d.cookieBanner.viewportCoverage)) /
+ PERCENTAGE_DIVISOR;
+ } else {
+ logger.warn("⚠️ [SCORING] Inconsistent detection - setting coverage to 0");
+ }
+
+ return {
+ cookieBannerDetected: allDetected,
+ cookieBannerVisibleTimeMs,
+ cookieBannerCoverage,
+ };
+}
+
+/**
+ * Calculate performance metrics from benchmark results
+ */
+function calculatePerformanceMetrics(details: BenchmarkResult["details"]) {
+ return {
+ domSize: calculateAverage(
+ details.map((d) => d.dom?.size ?? DEFAULT_DOM_SIZE)
+ ),
+ mainThreadBlocking: calculateAverage(
+ details.map((d) => d.timing.mainThreadBlocking.total)
+ ),
+ layoutShifts: calculateAverage(
+ details.map((d) => d.timing.cumulativeLayoutShift)
+ ),
+ };
+}
+
+function loadConfig(logger: CliLogger, configPath: string): Config {
+ try {
+ return loadValidatedConfigSync(configPath);
+ } catch (error) {
+ if (error instanceof ConfigValidationError) {
+ logger.error(error.message);
+ logger.error(formatConfigIssues(error.issues));
+ throw error;
+ }
+ throw error;
+ }
+}
+
+function getGitContext(projectRoot: string): {
+ sha: string | null;
+ dirty: boolean;
+} {
+ try {
+ const sha = execSync("git rev-parse HEAD", {
+ cwd: projectRoot,
+ encoding: "utf-8",
+ }).trim();
+ const dirty =
+ execSync("git status --porcelain", {
+ cwd: projectRoot,
+ encoding: "utf-8",
+ }).trim().length > 0;
+
+ return { sha, dirty };
+ } catch {
+ return { sha: null, dirty: false };
+ }
+}
+
+function detectPlaywrightVersion(): string {
+ try {
+ const pkg = require("@playwright/test/package.json") as {
+ version?: string;
+ };
+ return pkg.version ?? "unknown";
+ } catch {
+ return "unknown";
+ }
+}
+
+function resolveTraceMode(traceMode?: TraceMode): TraceMode {
+ if (!traceMode) {
+ return "on-failure";
+ }
+ if (
+ traceMode === "off" ||
+ traceMode === "on-failure" ||
+ traceMode === "all"
+ ) {
+ return traceMode;
+ }
+ throw new Error(`Invalid trace mode: ${traceMode}`);
+}
+
+/**
+ * Find all benchmark directories
+ */
+async function findBenchmarkDirs(
+ logger: CliLogger,
+ projectRoot: string
+): Promise {
+ const benchmarksDir = join(projectRoot, "benchmarks");
+ try {
+ const entries = await readdir(benchmarksDir, { withFileTypes: true });
+ const dirs = entries
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
+ .map((entry) => entry.name);
+ return dirs;
+ } catch (error) {
+ logger.debug("Failed to read benchmarks directory:", error);
+ return [];
+ }
+}
+
+/**
+ * Run a single benchmark for a specific app
+ */
+async function runSingleBenchmark(options: {
+ logger: CliLogger;
+ appPath: string;
+ showScores?: boolean;
+ iterationsOverride?: number;
+ commandOptions?: BenchmarkCommandOptions;
+}): Promise {
+ const {
+ logger,
+ appPath,
+ showScores = true,
+ iterationsOverride,
+ commandOptions,
+ } = options;
+ const configPath = appPath ? join(appPath, "config.json") : undefined;
+ if (!configPath) {
+ logger.error("Missing benchmark config path");
+ return false;
+ }
+
+ const loadedConfig = loadConfig(logger, configPath);
+ const config = structuredClone(loadedConfig);
+
+ if (iterationsOverride !== undefined && iterationsOverride > 0) {
+ const originalIterations = config.iterations;
+ const originalMinimumSuccessfulIterations =
+ config.measurement.minSuccessfulIterations;
+
+ config.iterations = iterationsOverride;
+
+ const successRatio =
+ originalIterations > 0
+ ? originalMinimumSuccessfulIterations / originalIterations
+ : 1;
+ const scaledMinimumSuccessfulIterations = Math.ceil(
+ iterationsOverride * successRatio
+ );
+ config.measurement.minSuccessfulIterations = Math.max(
+ 1,
+ Math.min(iterationsOverride, scaledMinimumSuccessfulIterations)
+ );
+
+ logger.debug(
+ `Adjusted measurement.minSuccessfulIterations from ${originalMinimumSuccessfulIterations} to ${config.measurement.minSuccessfulIterations} for ${iterationsOverride} iteration(s)`
+ );
+ }
+
+ if (commandOptions?.profile) {
+ config.runProfile.networkProfile = commandOptions.profile;
+ }
+ if (commandOptions?.cacheMode) {
+ config.runProfile.cacheMode = commandOptions.cacheMode;
+ }
+
+ const traceMode = resolveTraceMode(commandOptions?.traceMode);
+ const runStartedAtUtc = new Date().toISOString();
+
+ try {
+ let serverInfo: ServerInfo | null = null;
+ let benchmarkUrl: string;
+
+ if (config.remote?.enabled && config.remote.url) {
+ logger.info(`🌐 Running remote benchmark against: ${config.remote.url}`);
+ benchmarkUrl = config.remote.url;
+ } else {
+ logger.info("🏗️ Building and serving app locally...");
+ serverInfo = await buildAndServeNextApp(logger, appPath);
+ benchmarkUrl = serverInfo.url;
+ }
+
+ const cwd = appPath || process.cwd();
+ let tracesDir: string | undefined;
+
+ if (traceMode !== "off") {
+ tracesDir = join(cwd, "traces");
+ try {
+ await mkdir(tracesDir, { recursive: true });
+ } catch (error: unknown) {
+ const code =
+ error && typeof error === "object" && "code" in error
+ ? (error as { code?: string }).code
+ : undefined;
+ if (code !== "EEXIST") {
+ throw error;
+ }
+ }
+ logger.info(`📊 Tracing mode: ${traceMode} (${tracesDir})`);
+ } else {
+ logger.info("📊 Tracing mode: off");
+ }
+
+ try {
+ const runner = new BenchmarkRunner(config, logger, {
+ traceMode,
+ traceDir: tracesDir,
+ });
+ const result = await runner.runBenchmarks(benchmarkUrl);
+
+ if (!result.details || result.details.length === 0) {
+ logger.error("No successful benchmark iterations");
+ return false;
+ }
+
+ const appData = {
+ name: config.name,
+ baseline: config.baseline ?? false,
+ company: config.company ? JSON.stringify(config.company) : null,
+ techStack: JSON.stringify(config.techStack),
+ source: config.source ? JSON.stringify(config.source) : null,
+ tags: config.tags ? JSON.stringify(config.tags) : null,
+ };
+
+ const timingMetrics = calculateTimingMetrics(result.details);
+ const sizeMetrics = calculateSizeMetrics(result.details);
+ const networkMetrics = calculateNetworkMetrics(result.details);
+ const cookieBannerMetrics = calculateCookieBannerMetrics(
+ result.details,
+ logger
+ );
+ const performanceMetrics = calculatePerformanceMetrics(result.details);
+
+ const scores = calculateScores(
+ timingMetrics,
+ sizeMetrics,
+ networkMetrics,
+ cookieBannerMetrics,
+ performanceMetrics,
+ config.baseline ?? false,
+ appData,
+ result.details[0]?.timing.networkInformation
+ );
+
+ const projectRoot = findProjectRoot();
+ const gitContext = getGitContext(projectRoot);
+ const cpuInfo = cpus();
+ const configHash = createHash("sha256")
+ .update(JSON.stringify(config))
+ .digest("hex");
+ const runCompletedAtUtc = new Date().toISOString();
+
+ const resultsData = {
+ $schema:
+ "./node_modules/@cookiebench/benchmark-schema/results.schema.json",
+ schemaVersion: 2,
+ app: config.name,
+ techStack: config.techStack,
+ source: config.source,
+ includes: config.includes,
+ internationalization: config.internationalization,
+ company: config.company,
+ tags: config.tags,
+ results: result.details,
+ scores,
+ metadata: {
+ generatedAtUtc: runCompletedAtUtc,
+ runStartedAtUtc,
+ runCompletedAtUtc,
+ iterationsRequested: config.iterations,
+ iterationsSuccessful: result.details.length,
+ isRemote: config.remote?.enabled ?? false,
+ url: config.remote?.enabled ? config.remote.url : undefined,
+ traceMode,
+ runProfile: config.runProfile,
+ measurement: config.measurement,
+ quality: result.quality,
+ statistics: result.statistics,
+ environment: {
+ nodeVersion: process.version,
+ platform: process.platform,
+ arch: process.arch,
+ cpuModel: cpuInfo[0]?.model ?? "unknown",
+ cpuCores: cpuInfo.length,
+ playwrightVersion: detectPlaywrightVersion(),
+ chromiumVersion: result.environment.chromiumVersion,
+ gitSha: gitContext.sha,
+ gitDirty: gitContext.dirty,
+ configHash,
+ },
+ baselineRole: config.baseline ? "reference" : "candidate",
+ },
+ };
+
+ const outputPath = join(cwd, "results.json");
+ await writeFile(outputPath, JSON.stringify(resultsData, null, 2));
+ logger.success(`Benchmark results saved to ${outputPath}`);
+
+ if (showScores && scores) {
+ logger.info("📊 Benchmark Scores:");
+ printScores(scores);
+ }
+
+ return true;
+ } finally {
+ if (serverInfo) {
+ cleanupServer(serverInfo);
+ }
+ }
+ } catch (error: unknown) {
+ if (error instanceof Error) {
+ logger.error(`Error running benchmark: ${error.message}`);
+ } else {
+ logger.error("An unknown error occurred during benchmark");
+ }
+ return false;
+ }
+}
+
+/**
+ * Main benchmark command with multi-select support
+ */
+export async function benchmarkCommand(
+ logger: CliLogger,
+ appPath?: string,
+ options?: BenchmarkCommandOptions
+): Promise {
+ const projectRoot = findProjectRoot();
+
+ if (appPath) {
+ const resolvedAppPath = resolveBenchmarkPath(projectRoot, appPath);
+ const success = await runSingleBenchmark({
+ logger,
+ appPath: resolvedAppPath,
+ showScores: true,
+ commandOptions: options,
+ });
+ if (!success) {
+ throw new Error(`Benchmark failed for ${appPath}`);
+ }
+ return;
+ }
+
+ logger.clear();
+ await setTimeout(HALF_SECOND);
+
+ intro(`${color.bgMagenta(color.white(" benchmark "))}`);
+
+ const availableBenchmarks = await findBenchmarkDirs(logger, projectRoot);
+
+ if (availableBenchmarks.length === 0) {
+ logger.error("No benchmarks found in the benchmarks/ directory");
+ logger.info(
+ "Create benchmark directories with config.json files to get started"
+ );
+ throw new Error("No benchmarks found");
+ }
+
+ logger.info(
+ `Found ${availableBenchmarks.length} benchmark(s): ${color.cyan(availableBenchmarks.join(", "))}`
+ );
+
+ const selectedBenchmarks = await multiselect({
+ message: "Select benchmarks to run (use space to toggle):",
+ options: availableBenchmarks.map((name) => ({
+ value: name,
+ label: name,
+ hint: join("benchmarks", name),
+ })),
+ required: true,
+ });
+
+ if (isCancel(selectedBenchmarks)) {
+ cancel("Operation cancelled");
+ return;
+ }
+
+ if (!Array.isArray(selectedBenchmarks) || selectedBenchmarks.length === 0) {
+ logger.warn("No benchmarks selected");
+ return;
+ }
+
+ const benchmarkConfigs = new Map();
+ for (const benchmarkName of selectedBenchmarks) {
+ const benchmarkPath = join(projectRoot, "benchmarks", benchmarkName);
+ const configPath = join(benchmarkPath, "config.json");
+ const config = loadConfig(logger, configPath);
+ benchmarkConfigs.set(benchmarkName, config.iterations);
+ }
+
+ const defaultIterations =
+ benchmarkConfigs.size > 0
+ ? Array.from(benchmarkConfigs.values())[0]
+ : DEFAULT_ITERATIONS;
+
+ const iterationsList = Array.from(selectedBenchmarks)
+ .map((name) => {
+ const iterations = benchmarkConfigs.get(name) ?? "?";
+ return `${name}: ${iterations}`;
+ })
+ .join(", ");
+
+ logger.info(`Config iterations: ${color.dim(iterationsList)}`);
+
+ const iterationsInput = await text({
+ message: "Number of iterations (press Enter to use config values):",
+ placeholder: `Default: ${defaultIterations}`,
+ defaultValue: "",
+ validate: (value) => {
+ if (!value || value === "") {
+ return;
+ }
+ const num = Number.parseInt(value, 10);
+ if (Number.isNaN(num) || num < 1) {
+ return "Please enter a valid number greater than 0";
+ }
+ },
+ });
+
+ if (isCancel(iterationsInput)) {
+ cancel("Operation cancelled");
+ return;
+ }
+
+ const iterationsOverride =
+ iterationsInput === "" ? undefined : Number.parseInt(iterationsInput, 10);
+
+ if (iterationsOverride !== undefined) {
+ logger.info(
+ `Using ${color.bold(color.cyan(String(iterationsOverride)))} iterations for all benchmarks`
+ );
+ } else {
+ logger.info("Using iteration counts from each benchmark config");
+ }
+
+ const showResults = await confirm({
+ message: "Show results panel after completion?",
+ initialValue: true,
+ });
+
+ if (isCancel(showResults)) {
+ cancel("Operation cancelled");
+ return;
+ }
+
+ const results: Array<{ name: string; success: boolean }> = [];
+
+ for (let i = 0; i < selectedBenchmarks.length; i += 1) {
+ const benchmarkName = selectedBenchmarks[i];
+ const benchmarkPath = join(projectRoot, "benchmarks", benchmarkName);
+
+ logger.info(
+ `\n${color.bold(color.cyan(`[${i + 1}/${selectedBenchmarks.length}]`))} Running benchmark: ${color.bold(benchmarkName)}`
+ );
+
+ const success = await runSingleBenchmark({
+ logger,
+ appPath: benchmarkPath,
+ showScores: false,
+ iterationsOverride,
+ commandOptions: options,
+ });
+
+ results.push({ name: benchmarkName, success });
+
+ if (!success) {
+ logger.error(
+ `Failed to complete benchmark for ${benchmarkName}, continuing...`
+ );
+ }
+
+ if (i < selectedBenchmarks.length - 1) {
+ logger.message(`\n${"─".repeat(SEPARATOR_WIDTH)}\n`);
+ }
+ }
+
+ logger.message("\n");
+ outro(
+ `${color.bold("Summary:")} ${results.filter((r) => r.success).length}/${results.length} benchmarks completed successfully`
+ );
+
+ const failed = results.filter((r) => !r.success);
+ if (failed.length > 0) {
+ logger.warn(`Failed benchmarks: ${failed.map((r) => r.name).join(", ")}`);
+ }
+
+ if (showResults === true && results.some((r) => r.success)) {
+ logger.message(`\n${"═".repeat(SEPARATOR_WIDTH)}\n`);
+ logger.info("Loading results panel...\n");
+
+ const successfulBenchmarks = results
+ .filter((r) => r.success)
+ .map((r) => r.name);
+
+ try {
+ const { resultsCommand } = await import("./results.js");
+ await resultsCommand(logger, successfulBenchmarks);
+ } catch (error) {
+ logger.error("Failed to load results panel");
+ logger.debug(error instanceof Error ? error.message : String(error));
+ }
+ }
+}
diff --git a/packages/cookiebench-cli/src/commands/db.ts b/packages/cookiebench-cli/src/commands/db.ts
new file mode 100644
index 0000000..17e8091
--- /dev/null
+++ b/packages/cookiebench-cli/src/commands/db.ts
@@ -0,0 +1,295 @@
+///
+import { execFile } from "node:child_process";
+import { existsSync } from "node:fs";
+import { readdir } from "node:fs/promises";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import { cancel, confirm, intro, isCancel, select } from "@clack/prompts";
+import color from "picocolors";
+import { isAdminUser } from "../utils/auth";
+import type { CliLogger } from "../utils/logger";
+import { findProjectRoot } from "../utils/project-root";
+
+const DB_PACKAGE_RELATIVE_PATH = join("packages", "db");
+const execFileAsync = promisify(execFile);
+
+function ensureDbPackage(logger: CliLogger, projectRoot: string) {
+ const dbPackagePath = join(projectRoot, DB_PACKAGE_RELATIVE_PATH);
+ const drizzleConfigPath = join(dbPackagePath, "drizzle.config.ts");
+
+ if (!existsSync(dbPackagePath)) {
+ logger.error(
+ "Database package not found. Make sure you are running this from the project root."
+ );
+ process.exit(1);
+ }
+
+ if (!existsSync(drizzleConfigPath)) {
+ logger.error(
+ "Drizzle config not found. Make sure drizzle.config.ts exists in packages/db/"
+ );
+ process.exit(1);
+ }
+}
+
+async function runDrizzleCommand(
+ logger: CliLogger,
+ projectRoot: string,
+ command: string
+): Promise {
+ const dbPackagePath = join(projectRoot, DB_PACKAGE_RELATIVE_PATH);
+
+ try {
+ logger.step(`Running: ${color.cyan(`drizzle-kit ${command}`)}`);
+ await execFileAsync("pnpm", ["drizzle-kit", command], {
+ cwd: dbPackagePath,
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ throw error;
+ }
+ throw new Error(`Failed to run drizzle-kit ${command}`);
+ }
+}
+
+export async function dbCommand(logger: CliLogger, subcommand?: string) {
+ // Double-check admin access (safeguard)
+ if (!isAdminUser()) {
+ logger.error("This command requires admin access");
+ process.exit(1);
+ }
+
+ logger.clear();
+
+ intro(`${color.bgBlue(color.white(" database "))} ${color.dim("v0.1.0")}`);
+
+ const projectRoot = findProjectRoot();
+ ensureDbPackage(logger, projectRoot);
+
+ let selectedCommand = subcommand;
+
+ if (!selectedCommand) {
+ const command = await select({
+ message: "What would you like to do?",
+ options: [
+ {
+ value: "push",
+ label: "Push schema changes",
+ hint: "Push schema directly to database (good for development)",
+ },
+ {
+ value: "generate",
+ label: "Generate migrations",
+ hint: "Generate SQL migration files from schema changes",
+ },
+ {
+ value: "migrate",
+ label: "Run migrations",
+ hint: "Apply migration files to the database",
+ },
+ {
+ value: "studio",
+ label: "Open database studio",
+ hint: "Browse and edit your database with Drizzle Studio",
+ },
+ {
+ value: "status",
+ label: "Check migration status",
+ hint: "See which migrations have been applied",
+ },
+ ],
+ });
+
+ if (isCancel(command)) {
+ cancel("Operation cancelled.");
+ return;
+ }
+
+ selectedCommand = command;
+ }
+
+ switch (selectedCommand) {
+ case "push":
+ await pushCommand(logger, projectRoot);
+ break;
+ case "generate":
+ await generateCommand(logger, projectRoot);
+ break;
+ case "migrate":
+ await migrateCommand(logger, projectRoot);
+ break;
+ case "studio":
+ await studioCommand(logger, projectRoot);
+ break;
+ case "status":
+ await statusCommand(logger, projectRoot);
+ break;
+ default:
+ logger.error(`Unknown subcommand: ${selectedCommand}`);
+ logger.info(
+ "Available commands: push, generate, migrate, studio, status"
+ );
+ process.exit(1);
+ }
+}
+
+async function pushCommand(logger: CliLogger, projectRoot: string) {
+ logger.step("Pushing schema changes to database...");
+ logger.info("This will apply schema changes directly to your database.");
+ logger.warn("This is recommended for development only!");
+
+ const confirmCommand = await confirm({
+ message: "Are you sure you want to push schema changes?",
+ initialValue: false,
+ });
+
+ if (isCancel(confirmCommand) || !confirmCommand) {
+ cancel("Push cancelled.");
+ return;
+ }
+
+ try {
+ await runDrizzleCommand(logger, projectRoot, "push");
+ logger.success("Schema pushed successfully!");
+ logger.outro("Database is now up to date with your schema.");
+ } catch (error) {
+ logger.error("Push failed!");
+ logger.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+ }
+}
+
+async function generateCommand(
+ logger: CliLogger,
+ projectRoot: string
+): Promise {
+ logger.step("Generating migration files...");
+ logger.info("This will create SQL migration files based on schema changes.");
+
+ try {
+ await runDrizzleCommand(logger, projectRoot, "generate");
+ logger.success("Migration files generated!");
+ logger.info(
+ "Review the generated files in packages/db/drizzle/ before applying them."
+ );
+ logger.outro(
+ `Run ${color.cyan("cli db migrate")} to apply the migrations.`
+ );
+ } catch (error) {
+ logger.error("Migration generation failed!");
+ logger.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+ }
+}
+
+async function migrateCommand(logger: CliLogger, projectRoot: string) {
+ logger.step("Running migrations...");
+ logger.info("This will apply pending migration files to your database.");
+
+ const confirmCommand = await confirm({
+ message: "Are you sure you want to run migrations?",
+ initialValue: true,
+ });
+
+ if (isCancel(confirmCommand) || !confirmCommand) {
+ cancel("Migration cancelled.");
+ return;
+ }
+
+ try {
+ await runDrizzleCommand(logger, projectRoot, "migrate");
+ logger.success("Migrations completed successfully!");
+ logger.outro("Database is now up to date.");
+ } catch (error) {
+ logger.error("Migration failed!");
+ if (error instanceof Error) {
+ logger.error(error.message);
+ }
+ process.exit(1);
+ }
+}
+
+async function studioCommand(
+ logger: CliLogger,
+ projectRoot: string
+): Promise {
+ logger.step("Opening Drizzle Studio...");
+ logger.info(
+ "This will start a web interface to browse and edit your database."
+ );
+ logger.info("Press Ctrl+C to stop the studio when you're done.");
+
+ try {
+ await runDrizzleCommand(logger, projectRoot, "studio");
+ } catch (error) {
+ const signal =
+ error && typeof error === "object" && "signal" in error
+ ? String((error as { signal?: string }).signal)
+ : undefined;
+ const name =
+ error && typeof error === "object" && "name" in error
+ ? String((error as { name?: string }).name)
+ : undefined;
+ if (signal === "SIGINT" || name === "AbortError") {
+ logger.info("Studio closed.");
+ return;
+ }
+ logger.error("Failed to run Drizzle Studio.");
+ logger.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+ }
+}
+
+async function statusCommand(logger: CliLogger, projectRoot: string) {
+ logger.step("Checking migration status...");
+
+ try {
+ const dbPath = join(projectRoot, "benchmarks.db");
+ if (!existsSync(dbPath)) {
+ logger.warn("Database file does not exist yet.");
+ logger.info(
+ `Run ${color.cyan("cli db push")} or ${color.cyan("cli db migrate")} to create it.`
+ );
+ return;
+ }
+
+ // Check migrations folder
+ const migrationsPath = join(
+ projectRoot,
+ DB_PACKAGE_RELATIVE_PATH,
+ "drizzle"
+ );
+ if (!existsSync(migrationsPath)) {
+ logger.warn("No migrations found.");
+ logger.info(
+ `Run ${color.cyan("cli db generate")} to create migration files.`
+ );
+ return;
+ }
+
+ // List migration files
+ const migrationFiles = await readdir(migrationsPath, {
+ withFileTypes: true,
+ });
+ const migrations = migrationFiles
+ .filter((dirent) => dirent.isDirectory())
+ .map((dirent) => dirent.name)
+ .sort();
+
+ if (migrations.length === 0) {
+ logger.info("No migration files found.");
+ } else {
+ logger.info(`Found ${migrations.length} migration(s):`);
+ for (const migration of migrations) {
+ logger.info(` - ${migration}`);
+ }
+ }
+
+ logger.success("Status check complete.");
+ } catch (error) {
+ logger.error("Failed to check status.");
+ if (error instanceof Error) {
+ logger.error(error.message);
+ }
+ }
+}
diff --git a/packages/cookiebench-cli/src/commands/migrate-results.ts b/packages/cookiebench-cli/src/commands/migrate-results.ts
new file mode 100644
index 0000000..13ac182
--- /dev/null
+++ b/packages/cookiebench-cli/src/commands/migrate-results.ts
@@ -0,0 +1,341 @@
+import {
+ copyFile,
+ readdir,
+ readFile,
+ rename,
+ writeFile,
+} from "node:fs/promises";
+import { join } from "node:path";
+import color from "picocolors";
+import { findProjectRoot } from "../utils/project-root";
+import type { CliLogger } from "../utils/logger";
+
+type GenericRecord = Record;
+
+const BYTES_PER_KB = 1024;
+const SIZE_HEURISTIC_LOW_THRESHOLD_BYTES = 100_000;
+const MAJORITY_THRESHOLD = 0.5;
+const SIZE_HEURISTIC_MAX_KB = 200_000;
+const SIZE_HEURISTIC_MEAN_KB = 50_000;
+
+async function findResultsFiles(dir: string): Promise {
+ const files: string[] = [];
+ const entries = await readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...(await findResultsFiles(fullPath)));
+ } else if (entry.name === "results.json") {
+ files.push(fullPath);
+ }
+ }
+ return files;
+}
+
+function scaleNumeric(value: unknown, multiplier: number): number {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return 0;
+ }
+ return value * multiplier;
+}
+
+function migrateDetail(
+ detail: GenericRecord,
+ multiplier: number
+): GenericRecord {
+ const migrated = JSON.parse(JSON.stringify(detail)) as GenericRecord;
+ const size = migrated.size as GenericRecord | undefined;
+ if (size) {
+ size.total = scaleNumeric(size.total, multiplier);
+ size.bundled = scaleNumeric(size.bundled, multiplier);
+ size.thirdParty = scaleNumeric(size.thirdParty, multiplier);
+ size.cookieServices = scaleNumeric(size.cookieServices, multiplier);
+
+ const scripts = size.scripts as GenericRecord | undefined;
+ if (scripts) {
+ scripts.total = scaleNumeric(scripts.total, multiplier);
+ scripts.initial = scaleNumeric(scripts.initial, multiplier);
+ scripts.dynamic = scaleNumeric(scripts.dynamic, multiplier);
+ scripts.thirdParty = scaleNumeric(scripts.thirdParty, multiplier);
+ scripts.cookieServices = scaleNumeric(scripts.cookieServices, multiplier);
+ }
+
+ size.styles = scaleNumeric(size.styles, multiplier);
+ size.images = scaleNumeric(size.images, multiplier);
+ size.fonts = scaleNumeric(size.fonts, multiplier);
+ size.other = scaleNumeric(size.other, multiplier);
+ }
+
+ const resources = migrated.resources as GenericRecord | undefined;
+ if (resources) {
+ for (const key of [
+ "scripts",
+ "styles",
+ "images",
+ "fonts",
+ "other",
+ ] as const) {
+ const list = resources[key] as GenericRecord[] | undefined;
+ if (!Array.isArray(list)) {
+ continue;
+ }
+ for (const item of list) {
+ item.size = scaleNumeric(item.size, multiplier);
+ }
+ }
+ }
+
+ const timing = migrated.timing as GenericRecord | undefined;
+ if (timing) {
+ const thirdParty = timing.thirdParty as GenericRecord | undefined;
+ if (thirdParty) {
+ thirdParty.totalImpact = scaleNumeric(thirdParty.totalImpact, multiplier);
+ const cookieServices = thirdParty.cookieServices as
+ | GenericRecord
+ | undefined;
+ if (cookieServices) {
+ cookieServices.totalSize = scaleNumeric(
+ cookieServices.totalSize,
+ multiplier
+ );
+ }
+ }
+ }
+
+ const thirdPartyTopLevel = migrated.thirdParty as GenericRecord | undefined;
+ if (thirdPartyTopLevel) {
+ thirdPartyTopLevel.totalImpact = scaleNumeric(
+ thirdPartyTopLevel.totalImpact,
+ multiplier
+ );
+ const cookieServices = thirdPartyTopLevel.cookieServices as
+ | GenericRecord
+ | undefined;
+ if (cookieServices) {
+ cookieServices.totalSize = scaleNumeric(
+ cookieServices.totalSize,
+ multiplier
+ );
+ }
+ }
+
+ return migrated;
+}
+
+function migrateV1ToV2(
+ rawData: GenericRecord,
+ logger: CliLogger
+): GenericRecord {
+ const details = Array.isArray(rawData.results)
+ ? (rawData.results as GenericRecord[])
+ : [];
+
+ const totalSizeCandidates = details
+ .map((detail) => {
+ const size = detail.size as GenericRecord | undefined;
+ return typeof size?.total === "number" ? size.total : 0;
+ })
+ .filter((value) => value > 0);
+
+ const metadata = (rawData.metadata as GenericRecord | undefined) ?? {};
+ const explicitSizeUnit =
+ typeof metadata.sizeUnit === "string"
+ ? metadata.sizeUnit.toLowerCase()
+ : undefined;
+
+ let multiplier = 1;
+ if (explicitSizeUnit === "kb" || explicitSizeUnit === "kilobytes") {
+ multiplier = BYTES_PER_KB;
+ } else if (explicitSizeUnit === "bytes" || explicitSizeUnit === "b") {
+ multiplier = 1;
+ } else if (totalSizeCandidates.length > 0) {
+ const count = totalSizeCandidates.length;
+ const min = Math.min(...totalSizeCandidates);
+ const max = Math.max(...totalSizeCandidates);
+ const mean =
+ totalSizeCandidates.reduce((acc, value) => acc + value, 0) / count;
+ const lowCount = totalSizeCandidates.filter(
+ (value) => value < SIZE_HEURISTIC_LOW_THRESHOLD_BYTES
+ ).length;
+ const majorityLow = lowCount / count > MAJORITY_THRESHOLD;
+ const likelyKbData =
+ majorityLow &&
+ max < SIZE_HEURISTIC_MAX_KB &&
+ mean < SIZE_HEURISTIC_MEAN_KB;
+
+ if (likelyKbData) {
+ multiplier = BYTES_PER_KB;
+ }
+
+ logger.warn(
+ `Using size unit heuristic for migration (count=${count}, min=${Math.round(
+ min
+ )}, max=${Math.round(max)}, mean=${Math.round(mean)}). Assumed ${
+ multiplier === BYTES_PER_KB ? "KB->bytes" : "bytes"
+ }.`
+ );
+ }
+
+ const migratedDetails = details.map((detail) =>
+ migrateDetail(detail, multiplier)
+ );
+ const now = new Date().toISOString();
+ const timestamp =
+ typeof metadata.timestamp === "string" ? metadata.timestamp : now;
+ const requestedIterations =
+ typeof metadata.iterations === "number"
+ ? metadata.iterations
+ : migratedDetails.length;
+ const successfulIterations = migratedDetails.length;
+ const failedIterations = Math.max(
+ 0,
+ requestedIterations - successfulIterations
+ );
+ const failureRate =
+ requestedIterations > 0 ? failedIterations / requestedIterations : 0;
+ const baselineRole =
+ typeof rawData.app === "string" && rawData.app === "baseline"
+ ? "reference"
+ : "candidate";
+
+ return {
+ ...rawData,
+ schemaVersion: 2,
+ results: migratedDetails,
+ metadata: {
+ generatedAtUtc: timestamp,
+ runStartedAtUtc: timestamp,
+ runCompletedAtUtc: timestamp,
+ iterationsRequested: requestedIterations,
+ iterationsSuccessful: successfulIterations,
+ runProfile:
+ typeof metadata.runProfile === "object" && metadata.runProfile !== null
+ ? metadata.runProfile
+ : {
+ cacheMode: "cold",
+ networkProfile: "none",
+ cpuSlowdownMultiplier: 1,
+ },
+ measurement:
+ typeof metadata.measurement === "object" &&
+ metadata.measurement !== null
+ ? metadata.measurement
+ : {
+ minSuccessfulIterations: Math.max(1, successfulIterations),
+ maxFailureRate: 1,
+ stabilityThresholdCv: 100,
+ },
+ quality:
+ typeof metadata.quality === "object" && metadata.quality !== null
+ ? metadata.quality
+ : {
+ requestedIterations,
+ successfulIterations,
+ failedIterations,
+ failureRate,
+ minSuccessfulIterations: Math.max(1, successfulIterations),
+ maxFailureRate: 1,
+ stabilityThresholdCv: 100,
+ stable: true,
+ unstableMetrics: [],
+ },
+ statistics:
+ typeof metadata.statistics === "object" && metadata.statistics !== null
+ ? metadata.statistics
+ : {},
+ environment:
+ typeof metadata.environment === "object" &&
+ metadata.environment !== null
+ ? metadata.environment
+ : {},
+ baselineRole:
+ typeof metadata.baselineRole === "string" &&
+ (metadata.baselineRole === "reference" ||
+ metadata.baselineRole === "candidate")
+ ? metadata.baselineRole
+ : baselineRole,
+ migrationTimestampUtc: now,
+ migratedFromSchemaVersion:
+ typeof rawData.schemaVersion === "number" ? rawData.schemaVersion : 1,
+ migrationUnitScale: multiplier,
+ },
+ };
+}
+
+export async function migrateResultsCommand(
+ logger: CliLogger,
+ appName?: string
+): Promise {
+ const projectRoot = findProjectRoot();
+ const resultsDir = join(projectRoot, "benchmarks");
+ let files: string[] = [];
+
+ try {
+ files = await findResultsFiles(resultsDir);
+ } catch (error) {
+ logger.error(
+ `Failed to scan results files: ${error instanceof Error ? error.message : "Unknown error"}`
+ );
+ return;
+ }
+
+ if (files.length === 0) {
+ logger.warn("No results.json files found");
+ return;
+ }
+
+ let migratedCount = 0;
+ let skippedCount = 0;
+
+ for (const file of files) {
+ try {
+ const content = await readFile(file, "utf-8");
+ const parsed = JSON.parse(content) as GenericRecord;
+ const app =
+ typeof parsed.app === "string" && parsed.app.length > 0
+ ? parsed.app
+ : null;
+
+ if (appName && app !== appName) {
+ skippedCount += 1;
+ continue;
+ }
+
+ if (parsed.schemaVersion === 2) {
+ skippedCount += 1;
+ continue;
+ }
+
+ const migrated = migrateV1ToV2(parsed, logger);
+ const backupPath = `${file}.bak-${Date.now()}`;
+ const tempPath = `${file}.tmp-${process.pid}-${Date.now()}`;
+ await copyFile(file, backupPath);
+ try {
+ await writeFile(tempPath, JSON.stringify(migrated, null, 2));
+ await rename(tempPath, file);
+ } catch (error) {
+ try {
+ await rename(backupPath, file);
+ } catch {
+ // Best effort restore
+ }
+ throw error;
+ }
+ logger.info(`Migrated ${file}`);
+ migratedCount += 1;
+ } catch (error) {
+ logger.error(
+ `Failed to migrate ${file}: ${error instanceof Error ? error.message : "Unknown error"}`
+ );
+ }
+ }
+
+ if (migratedCount === 0) {
+ logger.warn("No files needed migration");
+ return;
+ }
+
+ logger.success(
+ `${color.bold(String(migratedCount))} results file(s) migrated to schemaVersion 2 (${skippedCount} skipped)`
+ );
+}
diff --git a/packages/cookiebench-cli/src/commands/results.ts b/packages/cookiebench-cli/src/commands/results.ts
new file mode 100644
index 0000000..5327872
--- /dev/null
+++ b/packages/cookiebench-cli/src/commands/results.ts
@@ -0,0 +1,1158 @@
+/** biome-ignore-all lint/suspicious/noConsole: console output needed for results display */
+
+import { readdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { setTimeout } from "node:timers/promises";
+
+import { cancel, intro, isCancel, multiselect } from "@clack/prompts";
+import type { Config } from "@consentio/runner";
+import { ONE_SECOND, PERCENTAGE_DIVISOR } from "@consentio/shared";
+import Table from "cli-table3";
+import color from "picocolors";
+import prettyMilliseconds from "pretty-ms";
+import type { BenchmarkScores } from "../types";
+import {
+ CLS_DECIMAL_PLACES,
+ CLS_THRESHOLD_FAIR,
+ CLS_THRESHOLD_GOOD,
+ COL_WIDTH_CHART_PADDING,
+ COL_WIDTH_DURATION,
+ COL_WIDTH_NAME,
+ COL_WIDTH_SIZE,
+ COL_WIDTH_SOURCE,
+ COL_WIDTH_TAGS,
+ COL_WIDTH_TYPE,
+ DEFAULT_DOM_SIZE,
+ MAX_FILENAME_LENGTH,
+ MIN_DURATION_THRESHOLD,
+ SCORE_THRESHOLD_FAIR,
+ SCORE_THRESHOLD_POOR,
+ TRUNCATED_FILENAME_LENGTH,
+} from "../utils/constants";
+import { findProjectRoot } from "../utils/project-root";
+import type { CliLogger } from "../utils/logger";
+import {
+ ConfigValidationError,
+ formatConfigIssues,
+ formatBytes as formatBytesShared,
+ loadValidatedConfigSync,
+} from "../utils";
+import { calculateScores } from "../utils/scoring";
+
+// Raw benchmark data structure from JSON files
+export type RawBenchmarkDetail = {
+ duration: number;
+ size: {
+ total: number;
+ bundled: number;
+ thirdParty: number;
+ cookieServices: number;
+ scripts: {
+ total: number;
+ initial: number;
+ dynamic: number;
+ thirdParty: number;
+ cookieServices: number;
+ };
+ styles: number;
+ images: number;
+ fonts: number;
+ other: number;
+ };
+ timing: {
+ navigationStart: number;
+ domContentLoaded: number;
+ load: number;
+ firstPaint: number;
+ firstContentfulPaint: number;
+ largestContentfulPaint: number;
+ timeToInteractive: number;
+ cumulativeLayoutShift: number;
+ // NEW: Perfume.js enhanced metrics
+ timeToFirstByte?: number;
+ firstInputDelay?: number | null;
+ interactionToNextPaint?: number | null;
+ navigationTiming?: {
+ timeToFirstByte: number;
+ domInteractive: number;
+ domContentLoadedEventStart: number;
+ domContentLoadedEventEnd: number;
+ domComplete: number;
+ loadEventStart: number;
+ loadEventEnd: number;
+ };
+ networkInformation?: {
+ effectiveType: string;
+ downlink: number;
+ rtt: number;
+ saveData: boolean;
+ };
+ cookieBanner: {
+ renderStart: number;
+ renderEnd: number;
+ interactionStart: number;
+ interactionEnd: number;
+ layoutShift: number;
+ detected: boolean;
+ selector: string | null;
+ serviceName: string;
+ visibilityTime: number;
+ /** DOM presence time (ms). Present when reading runner output. */
+ domPresenceTime?: number;
+ /** User-visible time (ms). Present when reading runner output; used for scoring. */
+ userVisibleTime?: number;
+ viewportCoverage: number;
+ };
+ thirdParty: {
+ dnsLookupTime: number;
+ connectionTime: number;
+ downloadTime: number;
+ totalImpact: number;
+ cookieServices: {
+ hosts: string[];
+ totalSize: number;
+ resourceCount: number;
+ dnsLookupTime: number;
+ connectionTime: number;
+ downloadTime: number;
+ };
+ };
+ mainThreadBlocking: {
+ total: number;
+ cookieBannerEstimate: number;
+ percentageFromCookies: number;
+ };
+ scripts: {
+ bundled: {
+ loadStart: number;
+ loadEnd: number;
+ executeStart: number;
+ executeEnd: number;
+ };
+ thirdParty: {
+ loadStart: number;
+ loadEnd: number;
+ executeStart: number;
+ executeEnd: number;
+ };
+ };
+ };
+ resources: {
+ scripts: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isDynamic: boolean;
+ isCookieService: boolean;
+ dnsTime?: number;
+ connectionTime?: number;
+ }>;
+ styles: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ }>;
+ images: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ }>;
+ fonts: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ }>;
+ other: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ type: string;
+ }>;
+ };
+ language: string;
+};
+
+export type BenchmarkOutput = {
+ schemaVersion?: number;
+ app: string;
+ results: RawBenchmarkDetail[];
+ scores?: BenchmarkScores;
+ metadata?: Record;
+};
+
+async function findResultsFiles(dir: string): Promise {
+ const files: string[] = [];
+ try {
+ const entries = await readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...(await findResultsFiles(fullPath)));
+ } else if (entry.name === "results.json") {
+ files.push(fullPath);
+ }
+ }
+ } catch (error) {
+ const errorCode =
+ typeof error === "object" &&
+ error !== null &&
+ "code" in error &&
+ typeof error.code === "string"
+ ? error.code
+ : "";
+ if (errorCode === "ENOENT") {
+ return files;
+ }
+ throw error;
+ }
+
+ return files;
+}
+
+function loadConfigForApp(
+ logger: CliLogger,
+ appName: string,
+ projectRoot: string
+): Config {
+ const configPath = join(projectRoot, "benchmarks", appName, "config.json");
+
+ try {
+ return loadValidatedConfigSync(configPath);
+ } catch (error) {
+ if (error instanceof ConfigValidationError) {
+ logger.error(`Invalid config for ${appName}`);
+ logger.error(formatConfigIssues(error.issues));
+ throw error;
+ }
+ logger.debug(
+ `Could not load config for ${appName}: ${
+ error instanceof Error ? error.message : "Unknown error"
+ }`
+ );
+ throw error;
+ }
+}
+
+async function aggregateResults(
+ logger: CliLogger,
+ resultsDir: string,
+ scopedApps?: Set
+) {
+ const resultsFiles = await findResultsFiles(resultsDir);
+ const results: Record = {};
+ const nonV2Files: string[] = [];
+
+ logger.debug(`Found ${resultsFiles.length} results files:`);
+ for (const file of resultsFiles) {
+ logger.debug(` - ${file}`);
+ }
+
+ for (const file of resultsFiles) {
+ try {
+ const content = await readFile(file, "utf-8");
+ const data: BenchmarkOutput = JSON.parse(content);
+ const appFromFile = typeof data.app === "string" ? data.app : null;
+
+ if (scopedApps && appFromFile && !scopedApps.has(appFromFile)) {
+ continue;
+ }
+
+ if (data.schemaVersion !== 2) {
+ if (!scopedApps || (appFromFile && scopedApps.has(appFromFile))) {
+ nonV2Files.push(file);
+ }
+ continue;
+ }
+
+ if (!(appFromFile && data.results)) {
+ logger.warn(
+ `Skipping invalid results file: ${file} (missing app or results)`
+ );
+ continue;
+ }
+
+ logger.debug(`Processing ${file} with app name: "${appFromFile}"`);
+
+ if (results[appFromFile]) {
+ logger.warn(
+ `Duplicate app name "${appFromFile}" found in ${file}. Previous results will be overwritten.`
+ );
+ }
+
+ results[appFromFile] = data.results;
+ logger.debug(
+ `Loaded results for ${appFromFile} (${data.results.length} iterations)`
+ );
+ } catch (error) {
+ logger.error(
+ `Failed to process ${file}: ${
+ error instanceof Error ? error.message : "Unknown error"
+ }`
+ );
+ if (error instanceof Error && error.stack) {
+ logger.debug(`Stack trace: ${error.stack}`);
+ }
+ }
+ }
+
+ logger.debug("Final results summary:");
+ for (const [app, appResults] of Object.entries(results)) {
+ logger.debug(` - ${app}: ${appResults.length} iterations`);
+ }
+
+ if (nonV2Files.length > 0) {
+ throw new Error(
+ `Found ${nonV2Files.length} non-v2 results files. Run "cookiebench migrate-results" first.`
+ );
+ }
+
+ return results;
+}
+
+// Minimum threshold for showing decimal milliseconds (JavaScript precision is ~0.1ms)
+const SUB_MILLISECOND_THRESHOLD = 1;
+const MILLISECOND_DECIMAL_PLACES = 3;
+
+function formatTime(ms: number): string {
+ // JavaScript timing precision is typically ~0.1ms, so we don't show microseconds
+ // For very small values, show fractional milliseconds
+ return prettyMilliseconds(ms, {
+ secondsDecimalDigits: 2,
+ keepDecimalsOnWholeSeconds: true,
+ compact: true,
+ millisecondsDecimalDigits:
+ ms < SUB_MILLISECOND_THRESHOLD ? MILLISECOND_DECIMAL_PLACES : 0,
+ });
+}
+
+function getPerformanceRating(metric: string, value: number): string {
+ const ratings: Record = {
+ fcp: { good: 1800, poor: 3000 },
+ lcp: { good: 2500, poor: 4000 },
+ cls: { good: 0.1, poor: 0.25 },
+ tti: { good: 3800, poor: 7300 },
+ tbt: { good: 200, poor: 600 },
+ };
+
+ const thresholds = ratings[metric];
+ if (!thresholds) {
+ return "N/A";
+ }
+
+ if (value <= thresholds.good) {
+ return color.green("Good");
+ }
+ if (value <= thresholds.poor) {
+ return color.yellow("Fair");
+ }
+ return color.red("Poor");
+}
+
+function printDetailedResults(
+ appName: string,
+ results: RawBenchmarkDetail[],
+ scores: BenchmarkScores,
+ baseline?: RawBenchmarkDetail[]
+) {
+ if (!results || results.length === 0) {
+ console.log(
+ `\n${color.bold(color.cyan(`━━━ ${appName.toUpperCase()} ━━━`))}`
+ );
+ console.log(color.dim("No benchmark iterations available for this app."));
+ return;
+ }
+
+ console.log(
+ `\n${color.bold(color.cyan(`━━━ ${appName.toUpperCase()} ━━━`))}`
+ );
+
+ // ━━━ Score Display ━━━
+ const score = Math.round(scores.totalScore);
+ let scoreColor = color.green;
+ let scoreBgColor = color.bgGreen;
+
+ if (score < SCORE_THRESHOLD_POOR) {
+ scoreColor = color.red;
+ scoreBgColor = color.bgRed;
+ } else if (score < SCORE_THRESHOLD_FAIR) {
+ scoreColor = color.yellow;
+ scoreBgColor = color.bgYellow;
+ }
+
+ console.log(`\n${color.bold("🎯 Overall Score")}`);
+ console.log(
+ scoreColor(` ${score}/100`) +
+ " " +
+ scoreBgColor(color.black(` ${scores.grade} `))
+ );
+
+ // ━━━ Key Insights ━━━
+ if (scores.insights && scores.insights.length > 0) {
+ console.log(`\n${color.bold("💡 Key Insights")}`);
+ for (const insight of scores.insights) {
+ console.log(`${color.blue(" •")} ${color.dim(insight)}`);
+ }
+ }
+
+ // Calculate averages (support both timing modes; scoring uses user-visible)
+ const getUserVisibleTimeMs = (r: RawBenchmarkDetail) =>
+ r.timing.cookieBanner.userVisibleTime ??
+ r.timing.cookieBanner.visibilityTime;
+ const getDomPresenceTimeMs = (r: RawBenchmarkDetail) =>
+ r.timing.cookieBanner.domPresenceTime ?? r.timing.cookieBanner.renderStart;
+ const avgBannerDomPresenceTimeMs =
+ results.reduce((a, b) => a + getDomPresenceTimeMs(b), 0) / results.length;
+ const avgBannerVisibleTimeMs =
+ results.reduce((a, b) => a + getUserVisibleTimeMs(b), 0) / results.length;
+ const avgViewportCoverage =
+ results.reduce((a, b) => a + b.timing.cookieBanner.viewportCoverage, 0) /
+ results.length;
+ const avgNetworkImpact =
+ results.reduce((a, b) => a + b.size.thirdParty, 0) / results.length;
+ const avgThirdPartyRequests =
+ results.reduce(
+ (total, result) =>
+ total +
+ result.resources.scripts.filter((resource) => resource.isThirdParty)
+ .length +
+ result.resources.styles.filter((resource) => resource.isThirdParty)
+ .length +
+ result.resources.images.filter((resource) => resource.isThirdParty)
+ .length +
+ result.resources.fonts.filter((resource) => resource.isThirdParty)
+ .length +
+ result.resources.other.filter((resource) => resource.isThirdParty)
+ .length,
+ 0
+ ) / results.length;
+ const _bannerDetected = results.some((r) => r.timing.cookieBanner.detected);
+ const isBundled = avgThirdPartyRequests === 0;
+ const formattedThirdPartyRequests = Number.isInteger(avgThirdPartyRequests)
+ ? avgThirdPartyRequests.toString()
+ : avgThirdPartyRequests.toFixed(1);
+ const networkImpactSummary = isBundled
+ ? formatBytesShared(avgNetworkImpact)
+ : `${formatBytesShared(avgNetworkImpact)} (${formattedThirdPartyRequests} req)`;
+ let networkImpactHint: string;
+ if (isBundled) {
+ networkImpactHint = "Bundled (no external requests)";
+ } else if (avgNetworkImpact > 0) {
+ networkImpactHint = "External requests";
+ } else {
+ networkImpactHint = "External requests (size unavailable)";
+ }
+ const bundleStrategyHint = isBundled
+ ? "Included in main bundle"
+ : "Loaded from external hosts";
+
+ const avgFCP =
+ results.reduce((a, b) => a + b.timing.firstContentfulPaint, 0) /
+ results.length;
+ const avgLCP =
+ results.reduce((a, b) => a + b.timing.largestContentfulPaint, 0) /
+ results.length;
+ const avgTTI =
+ results.reduce((a, b) => a + b.timing.timeToInteractive, 0) /
+ results.length;
+ const avgCLS =
+ results.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) /
+ results.length;
+ const avgTBT =
+ results.reduce((a, b) => a + b.timing.mainThreadBlocking.total, 0) /
+ results.length;
+
+ const totalSize =
+ results.reduce((a, b) => a + b.size.total, 0) / results.length;
+ const jsSize =
+ results.reduce((a, b) => a + b.size.scripts.total, 0) / results.length;
+ const cssSize =
+ results.reduce((a, b) => a + b.size.styles, 0) / results.length;
+ const imageSize =
+ results.reduce((a, b) => a + b.size.images, 0) / results.length;
+ const fontSize =
+ results.reduce((a, b) => a + b.size.fonts, 0) / results.length;
+ const otherSize =
+ results.reduce((a, b) => a + b.size.other, 0) / results.length;
+
+ const jsFiles =
+ results.reduce((a, b) => a + b.resources.scripts.length, 0) /
+ results.length;
+ const cssFiles =
+ results.reduce((a, b) => a + b.resources.styles.length, 0) / results.length;
+ const imageFiles =
+ results.reduce((a, b) => a + b.resources.images.length, 0) / results.length;
+ const fontFiles =
+ results.reduce((a, b) => a + b.resources.fonts.length, 0) / results.length;
+ const otherFiles =
+ results.reduce((a, b) => a + b.resources.other.length, 0) / results.length;
+
+ // Calculate deltas if baseline exists (user-visible is used for scoring)
+ let bannerDelta = "";
+ if (baseline && appName !== "baseline") {
+ const baselineAvgBanner =
+ baseline.reduce((a, b) => a + getUserVisibleTimeMs(b), 0) /
+ baseline.length;
+ const delta = avgBannerVisibleTimeMs - baselineAvgBanner;
+ bannerDelta = ` ${delta > 0 ? "+" : ""}${formatTime(delta)}`;
+ }
+
+ // ━━━ Cookie Banner Impact (dual timing modes) ━━━
+ console.log(`\n${color.bold("🍪 Cookie Banner Impact")}`);
+ console.log(
+ color.dim(
+ " Dual timing: DOM presence (technical) | Banner visible (used for score)"
+ )
+ );
+ const bannerTable = new Table({
+ chars: { mid: "", "left-mid": "", "mid-mid": "", "right-mid": "" },
+ style: { "padding-left": 2, "padding-right": 2, border: ["grey"] },
+ });
+
+ bannerTable.push(
+ [
+ { content: "DOM presence", colSpan: 1 },
+ { content: "Banner visible (scored)", colSpan: 1 },
+ { content: "Viewport Coverage", colSpan: 1 },
+ { content: "Network Impact", colSpan: 1 },
+ { content: "Bundle Strategy", colSpan: 1 },
+ ],
+ [
+ `${color.bold(formatTime(avgBannerDomPresenceTimeMs))}\n${color.dim("Technical render")}`,
+ `${color.bold(formatTime(avgBannerVisibleTimeMs))}\n${color.dim(bannerDelta || "baseline")}`,
+ `${color.bold(`${avgViewportCoverage.toFixed(1)}%`)}\n${color.dim("Screen real estate")}`,
+ `${color.bold(networkImpactSummary)}\n${color.dim(networkImpactHint)}`,
+ `${color.bold(isBundled ? "Bundled" : "External")}\n${color.dim(bundleStrategyHint)}`,
+ ]
+ );
+
+ console.log(bannerTable.toString());
+
+ // ━━━ Core Web Vitals ━━━
+ console.log(`\n${color.bold("⚡ Core Web Vitals")}`);
+ const vitalsTable = new Table({
+ chars: { mid: "", "left-mid": "", "mid-mid": "", "right-mid": "" },
+ style: { "padding-left": 2, "padding-right": 2, border: ["grey"] },
+ });
+
+ vitalsTable.push(
+ [
+ { content: "First Contentful Paint", colSpan: 1 },
+ { content: "Largest Contentful Paint", colSpan: 1 },
+ { content: "Time to Interactive", colSpan: 1 },
+ { content: "Cumulative Layout Shift", colSpan: 1 },
+ ],
+ [
+ `${color.bold(formatTime(avgFCP))}\n${getPerformanceRating("fcp", avgFCP)}`,
+ `${color.bold(formatTime(avgLCP))}\n${getPerformanceRating("lcp", avgLCP)}`,
+ `${color.bold(formatTime(avgTTI))}\n${getPerformanceRating("tti", avgTTI)}`,
+ `${color.bold(avgCLS.toFixed(CLS_DECIMAL_PLACES))}\n${getPerformanceRating("cls", avgCLS)}`,
+ ]
+ );
+
+ console.log(vitalsTable.toString());
+
+ // ━━━ Resource Breakdown ━━━
+ console.log(`\n${color.bold("📦 Resource Breakdown")}`);
+
+ const totalFiles = jsFiles + cssFiles + imageFiles + fontFiles + otherFiles;
+ const jsPercentage =
+ totalSize > 0 ? (jsSize / totalSize) * PERCENTAGE_DIVISOR : 0;
+ const cssPercentage =
+ totalSize > 0 ? (cssSize / totalSize) * PERCENTAGE_DIVISOR : 0;
+ const imagePercentage =
+ totalSize > 0 ? (imageSize / totalSize) * PERCENTAGE_DIVISOR : 0;
+ const fontPercentage =
+ totalSize > 0 ? (fontSize / totalSize) * PERCENTAGE_DIVISOR : 0;
+ const otherPercentage =
+ totalSize > 0 ? (otherSize / totalSize) * PERCENTAGE_DIVISOR : 0;
+
+ const resourceTable = new Table({
+ chars: { mid: "", "left-mid": "", "mid-mid": "", "right-mid": "" },
+ style: { "padding-left": 2, "padding-right": 2, border: ["grey"] },
+ });
+
+ resourceTable.push(
+ [
+ { content: "Type", colSpan: 1 },
+ { content: "Size", colSpan: 1 },
+ { content: "Files", colSpan: 1 },
+ { content: "% of Total", colSpan: 1 },
+ ],
+ [
+ color.cyan("JavaScript"),
+ formatBytesShared(jsSize),
+ Math.round(jsFiles).toString(),
+ `${jsPercentage.toFixed(1)}%`,
+ ],
+ [
+ color.cyan("CSS"),
+ formatBytesShared(cssSize),
+ Math.round(cssFiles).toString(),
+ `${cssPercentage.toFixed(1)}%`,
+ ],
+ [
+ color.cyan("Images"),
+ formatBytesShared(imageSize),
+ Math.round(imageFiles).toString(),
+ `${imagePercentage.toFixed(1)}%`,
+ ],
+ [
+ color.cyan("Fonts"),
+ formatBytesShared(fontSize),
+ Math.round(fontFiles).toString(),
+ `${fontPercentage.toFixed(1)}%`,
+ ],
+ [
+ color.cyan("Other"),
+ formatBytesShared(otherSize),
+ Math.round(otherFiles).toString(),
+ `${otherPercentage.toFixed(1)}%`,
+ ],
+ [
+ color.bold("Total"),
+ color.bold(formatBytesShared(totalSize)),
+ color.bold(Math.round(totalFiles).toString()),
+ color.bold("100%"),
+ ]
+ );
+
+ console.log(resourceTable.toString());
+
+ // ━━━ Performance Impact Summary ━━━
+ console.log(`\n${color.bold("📊 Performance Impact Summary")}`);
+ const summaryTable = new Table({
+ chars: { mid: "", "left-mid": "", "mid-mid": "", "right-mid": "" },
+ style: { "padding-left": 2, "padding-right": 2, border: ["grey"] },
+ });
+
+ let layoutStability = "Poor";
+ if (avgCLS === 0) {
+ layoutStability = "Perfect";
+ } else if (avgCLS < CLS_THRESHOLD_GOOD) {
+ layoutStability = "Good";
+ } else if (avgCLS < CLS_THRESHOLD_FAIR) {
+ layoutStability = "Fair";
+ }
+
+ summaryTable.push(
+ ["Loading Strategy", color.bold(isBundled ? "Bundled" : "External")],
+ ["Render Performance", color.bold(formatTime(avgBannerVisibleTimeMs))],
+ ["Network Overhead", color.bold(networkImpactSummary)],
+ ["Main Thread Impact", color.bold(formatTime(avgTBT))],
+ ["Layout Stability", color.bold(layoutStability)],
+ ["User Disruption", color.bold(`${avgViewportCoverage.toFixed(1)}%`)]
+ );
+
+ console.log(summaryTable.toString());
+
+ // ━━━ Network Chart (Waterfall) ━━━
+ console.log(`\n${color.bold("🌐 Network Chart")}`);
+
+ // Get first iteration's resources for waterfall
+ const firstResult = results[0];
+ if (firstResult?.resources) {
+ const allResources = [
+ ...firstResult.resources.scripts.map((r) => ({ ...r, type: "script" })),
+ ...firstResult.resources.styles.map((r) => ({ ...r, type: "style" })),
+ ...firstResult.resources.images.map((r) => ({ ...r, type: "image" })),
+ ...firstResult.resources.fonts.map((r) => ({ ...r, type: "font" })),
+ ...firstResult.resources.other.map((r) => ({ ...r, type: "other" })),
+ ].sort((a, b) => a.startTime - b.startTime);
+
+ // Take top 10 resources for waterfall
+ const topResources = allResources.slice(0, 10);
+
+ if (topResources.length > 0) {
+ const maxEndTime = Math.max(
+ ...topResources.map((r) => r.startTime + r.duration)
+ );
+ const chartWidth = 60; // Width of the waterfall bars
+
+ const waterfallTable = new Table({
+ chars: { mid: "", "left-mid": "", "mid-mid": "", "right-mid": "" },
+ colWidths: [COL_WIDTH_NAME, chartWidth + COL_WIDTH_CHART_PADDING],
+ style: { "padding-left": 1, "padding-right": 1, border: ["grey"] },
+ wordWrap: true,
+ });
+
+ waterfallTable.push([
+ color.dim("Resource"),
+ color.dim(
+ "Timeline (0ms ───────────────────────────► " +
+ formatTime(maxEndTime) +
+ ")"
+ ),
+ ]);
+
+ for (const resource of topResources) {
+ const fileName = resource.name.split("/").pop() || resource.name;
+ const shortName =
+ fileName.length > MAX_FILENAME_LENGTH
+ ? `${fileName.substring(0, TRUNCATED_FILENAME_LENGTH)}...`
+ : fileName;
+
+ const startPos = Math.floor(
+ (resource.startTime / maxEndTime) * chartWidth
+ );
+ const barLength = Math.max(
+ 1,
+ Math.floor((resource.duration / maxEndTime) * chartWidth)
+ );
+
+ const emptyBefore = " ".repeat(startPos);
+ const bar = "█".repeat(barLength);
+ const durationLabel =
+ resource.duration > maxEndTime * MIN_DURATION_THRESHOLD
+ ? formatTime(resource.duration)
+ : "";
+
+ let barColor = color.blue;
+ if (resource.isThirdParty) {
+ barColor = color.yellow;
+ }
+ if (resource.isCookieService) {
+ barColor = color.red;
+ }
+
+ waterfallTable.push([
+ color.dim(shortName),
+ `${emptyBefore + barColor(bar)} ${color.dim(durationLabel)}`,
+ ]);
+ }
+
+ console.log(waterfallTable.toString());
+ }
+ }
+
+ // ━━━ Resource Details ━━━
+ console.log(`\n${color.bold("📋 Resource Details")}`);
+
+ // Aggregate resource data across all results
+ const aggregatedResources: Array<{
+ name: string;
+ type: string;
+ source: string;
+ size: number;
+ duration: number;
+ tags: string[];
+ }> = [];
+
+ // Use first result for resource list (assuming resources are consistent)
+ const sampleResult = results[0];
+ if (sampleResult?.resources) {
+ const allSampleResources = [
+ ...sampleResult.resources.scripts.map((r) => ({
+ ...r,
+ type: "JavaScript",
+ })),
+ ...sampleResult.resources.styles.map((r) => ({ ...r, type: "CSS" })),
+ ...sampleResult.resources.images.map((r) => ({ ...r, type: "Image" })),
+ ...sampleResult.resources.fonts.map((r) => ({ ...r, type: "Font" })),
+ ...sampleResult.resources.other.map((r) => ({ ...r, type: "Other" })),
+ ];
+
+ // Calculate averages for each resource
+ for (const sampleResource of allSampleResources) {
+ const resourceName = sampleResource.name;
+
+ // Find this resource in all results and average the values
+ const avgSize =
+ results.reduce((sum, result) => {
+ const allResources = [
+ ...result.resources.scripts,
+ ...result.resources.styles,
+ ...result.resources.images,
+ ...result.resources.fonts,
+ ...result.resources.other,
+ ];
+ const found = allResources.find((r) => r.name === resourceName);
+ return sum + (found ? found.size : 0);
+ }, 0) / results.length;
+
+ const avgDuration =
+ results.reduce((sum, result) => {
+ const allResources = [
+ ...result.resources.scripts,
+ ...result.resources.styles,
+ ...result.resources.images,
+ ...result.resources.fonts,
+ ...result.resources.other,
+ ];
+ const found = allResources.find((r) => r.name === resourceName);
+ return sum + (found ? found.duration : 0);
+ }, 0) / results.length;
+
+ let source = "Bundled";
+ if (sampleResource.isThirdParty) {
+ source = sampleResource.isCookieService
+ ? "Cookie Service"
+ : "Third-Party";
+ }
+
+ const tags: string[] = [];
+ if (!sampleResource.isThirdParty) {
+ tags.push("bundled");
+ }
+ if (sampleResource.isThirdParty) {
+ tags.push("third-party");
+ }
+ if (sampleResource.isCookieService) {
+ tags.push("cookie-service");
+ }
+ if ("isDynamic" in sampleResource && sampleResource.isDynamic) {
+ tags.push("dynamic");
+ }
+
+ // Add core/other categorization for bundled scripts
+ if (
+ !sampleResource.isThirdParty &&
+ sampleResource.type === "JavaScript"
+ ) {
+ tags.push("core");
+ }
+
+ aggregatedResources.push({
+ name: resourceName,
+ type: sampleResource.type,
+ source,
+ size: avgSize,
+ duration: avgDuration,
+ tags,
+ });
+ }
+ }
+
+ // Sort by size (descending) and take top 10
+ const topResources = aggregatedResources
+ .sort((a, b) => b.size - a.size)
+ .slice(0, 10);
+
+ if (topResources.length > 0) {
+ const detailsTable = new Table({
+ head: ["Resource Name", "Type", "Source", "Size", "Duration", "Tags"],
+ colWidths: [
+ COL_WIDTH_NAME,
+ COL_WIDTH_TYPE,
+ COL_WIDTH_SOURCE,
+ COL_WIDTH_SIZE,
+ COL_WIDTH_DURATION,
+ COL_WIDTH_TAGS,
+ ],
+ style: { head: ["cyan"], border: ["grey"] },
+ wordWrap: true,
+ });
+
+ for (const resource of topResources) {
+ const fileName = resource.name.split("/").pop() || resource.name;
+ const shortName =
+ fileName.length > MAX_FILENAME_LENGTH
+ ? `${fileName.substring(0, TRUNCATED_FILENAME_LENGTH)}...`
+ : fileName;
+
+ let sourceColor = color.green;
+ if (resource.source === "Third-Party") {
+ sourceColor = color.yellow;
+ }
+ if (resource.source === "Cookie Service") {
+ sourceColor = color.red;
+ }
+
+ detailsTable.push([
+ shortName,
+ resource.type,
+ sourceColor(resource.source),
+ formatBytesShared(resource.size),
+ color.blue(formatTime(resource.duration)),
+ resource.tags.join(", "),
+ ]);
+ }
+
+ console.log(detailsTable.toString());
+ }
+}
+
+export async function resultsCommand(
+ logger: CliLogger,
+ appName?: string | string[]
+) {
+ logger.clear();
+ await setTimeout(ONE_SECOND);
+
+ intro(
+ `${color.bgCyan(color.black(" results "))} ${color.dim("Compare benchmarks")}`
+ );
+
+ const projectRoot = findProjectRoot();
+ const resultsDir = join(projectRoot, "benchmarks");
+ let scopedApps: Set | undefined;
+ if (Array.isArray(appName)) {
+ scopedApps = new Set(appName);
+ } else if (appName && appName !== "__all__") {
+ scopedApps = new Set([appName]);
+ }
+ const results = await aggregateResults(logger, resultsDir, scopedApps);
+
+ if (Object.keys(results).length === 0) {
+ logger.error("No benchmark results found!");
+ return;
+ }
+
+ logger.debug(
+ `Found results for ${Object.keys(results).length} apps: ${Object.keys(
+ results
+ ).join(", ")}`
+ );
+
+ // If a specific app is requested, filter to that
+ let selectedApps: string[];
+
+ if (Array.isArray(appName)) {
+ // Array of app names passed (e.g., from benchmark command)
+ // Filter to only valid apps
+ selectedApps = appName.filter((name) => results[name]);
+ if (selectedApps.length === 0) {
+ logger.error("No valid results found for the specified benchmarks");
+ logger.info(`Available apps: ${Object.keys(results).join(", ")}`);
+ return;
+ }
+ } else if (appName && appName !== "__all__") {
+ // Direct command with specific app
+ if (!results[appName]) {
+ logger.error(`No results found for "${appName}"`);
+ logger.info(`Available apps: ${Object.keys(results).join(", ")}`);
+ return;
+ }
+ selectedApps = [appName];
+ } else if (appName === "__all__") {
+ // Show all results
+ selectedApps = Object.keys(results);
+ } else {
+ // Interactive mode - let user select which apps to view
+ const availableApps = Object.keys(results).sort((a, b) => {
+ if (a === "baseline") {
+ return -1;
+ }
+ if (b === "baseline") {
+ return 1;
+ }
+ return a.localeCompare(b);
+ });
+
+ const selected = await multiselect({
+ message:
+ "Select benchmarks to view (use space to toggle, all selected by default):",
+ options: availableApps.map((name) => ({
+ value: name,
+ label: name,
+ hint: `benchmarks/${name}`,
+ })),
+ initialValues: availableApps, // All selected by default
+ required: true,
+ });
+
+ if (isCancel(selected)) {
+ cancel("Operation cancelled");
+ return;
+ }
+
+ if (!Array.isArray(selected) || selected.length === 0) {
+ logger.warn("No benchmarks selected");
+ return;
+ }
+
+ selectedApps = selected;
+ }
+
+ logger.debug(`Viewing results for: ${selectedApps.join(", ")}`);
+
+ // Load configs for each app
+ const appConfigs: Record = {};
+ for (const name of Object.keys(results)) {
+ appConfigs[name] = loadConfigForApp(logger, name, projectRoot);
+ }
+
+ // Calculate scores for each app
+ const scores: Record = {};
+ for (const [name, appResults] of Object.entries(results)) {
+ const config = appConfigs[name];
+
+ // Create app data for transparency scoring
+ const appData = {
+ name,
+ baseline: name === "baseline",
+ company: config.company ? JSON.stringify(config.company) : null,
+ techStack: JSON.stringify(config.techStack),
+ source: config.source ? JSON.stringify(config.source) : null,
+ tags: config.tags ? JSON.stringify(config.tags) : null,
+ };
+
+ scores[name] = calculateScores(
+ {
+ fcp:
+ appResults.reduce((a, b) => a + b.timing.firstContentfulPaint, 0) /
+ appResults.length,
+ lcp:
+ appResults.reduce((a, b) => a + b.timing.largestContentfulPaint, 0) /
+ appResults.length,
+ cls:
+ appResults.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) /
+ appResults.length,
+ tbt:
+ appResults.reduce(
+ (a, b) => a + b.timing.mainThreadBlocking.total,
+ 0
+ ) / appResults.length,
+ tti:
+ appResults.reduce((a, b) => a + b.timing.timeToInteractive, 0) /
+ appResults.length,
+ timeToFirstByte:
+ appResults.reduce((a, b) => a + (b.timing.timeToFirstByte || 0), 0) /
+ appResults.length,
+ interactionToNextPaint: (() => {
+ const validValues = appResults
+ .map((result) => result.timing.interactionToNextPaint)
+ .filter(
+ (inp): inp is number =>
+ inp !== null && inp !== undefined && Number.isFinite(inp)
+ );
+ return validValues.length > 0
+ ? validValues.reduce((a, b) => a + b, 0) / validValues.length
+ : null;
+ })(),
+ },
+ {
+ totalSize:
+ appResults.reduce((a, b) => a + b.size.total, 0) / appResults.length,
+ jsSize:
+ appResults.reduce((a, b) => a + b.size.scripts.total, 0) /
+ appResults.length,
+ cssSize:
+ appResults.reduce((a, b) => a + b.size.styles, 0) / appResults.length,
+ imageSize:
+ appResults.reduce((a, b) => a + b.size.images, 0) / appResults.length,
+ fontSize:
+ appResults.reduce((a, b) => a + b.size.fonts, 0) / appResults.length,
+ otherSize:
+ appResults.reduce((a, b) => a + b.size.other, 0) / appResults.length,
+ },
+ {
+ totalRequests:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ b.resources.scripts.length +
+ b.resources.styles.length +
+ b.resources.images.length +
+ b.resources.fonts.length +
+ b.resources.other.length,
+ 0
+ ) / appResults.length,
+ thirdPartyRequests:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ b.resources.scripts.filter((s) => s.isThirdParty).length +
+ b.resources.styles.filter((s) => s.isThirdParty).length +
+ b.resources.images.filter((s) => s.isThirdParty).length +
+ b.resources.fonts.filter((s) => s.isThirdParty).length +
+ b.resources.other.filter((s) => s.isThirdParty).length,
+ 0
+ ) / appResults.length,
+ thirdPartySize:
+ appResults.reduce((a, b) => a + b.size.thirdParty, 0) /
+ appResults.length,
+ thirdPartyDomains:
+ appResults.reduce((sum, appResult) => {
+ const thirdPartyHosts = new Set();
+ const allThirdPartyResources = [
+ ...appResult.resources.scripts.filter((r) => r.isThirdParty),
+ ...appResult.resources.styles.filter((r) => r.isThirdParty),
+ ...appResult.resources.images.filter((r) => r.isThirdParty),
+ ...appResult.resources.fonts.filter((r) => r.isThirdParty),
+ ...appResult.resources.other.filter((r) => r.isThirdParty),
+ ];
+ for (const resource of allThirdPartyResources) {
+ try {
+ thirdPartyHosts.add(new URL(resource.name).hostname);
+ } catch {
+ // Skip invalid resource URLs
+ }
+ }
+ return sum + thirdPartyHosts.size;
+ }, 0) / appResults.length,
+ scriptLoadTime:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ b.timing.scripts.bundled.loadEnd +
+ b.timing.scripts.thirdParty.loadEnd,
+ 0
+ ) / appResults.length,
+ },
+ {
+ cookieBannerDetected: appResults.some(
+ (r) => r.timing.cookieBanner.detected
+ ),
+ cookieBannerVisibleTimeMs:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ (b.timing.cookieBanner.userVisibleTime ??
+ b.timing.cookieBanner.visibilityTime),
+ 0
+ ) / appResults.length,
+ cookieBannerCoverage:
+ appResults.reduce(
+ (a, b) => a + b.timing.cookieBanner.viewportCoverage,
+ 0
+ ) /
+ appResults.length /
+ PERCENTAGE_DIVISOR,
+ },
+ {
+ domSize: DEFAULT_DOM_SIZE,
+ mainThreadBlocking:
+ appResults.reduce(
+ (a, b) => a + b.timing.mainThreadBlocking.total,
+ 0
+ ) / appResults.length,
+ layoutShifts:
+ appResults.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) /
+ appResults.length,
+ },
+ name === "baseline",
+ appData,
+ appResults[0]?.timing.networkInformation
+ );
+ }
+
+ // Print detailed results for selected apps only
+ const baselineResults = results.baseline;
+ const sortedApps = selectedApps.sort((a, b) => {
+ if (a === "baseline") {
+ return -1;
+ }
+ if (b === "baseline") {
+ return 1;
+ }
+ return a.localeCompare(b);
+ });
+
+ for (const name of sortedApps) {
+ printDetailedResults(name, results[name], scores[name], baselineResults);
+ }
+}
diff --git a/packages/cookiebench-cli/src/commands/save.ts b/packages/cookiebench-cli/src/commands/save.ts
new file mode 100644
index 0000000..88bb1bf
--- /dev/null
+++ b/packages/cookiebench-cli/src/commands/save.ts
@@ -0,0 +1,671 @@
+import { readdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { setTimeout as sleep } from "node:timers/promises";
+
+import { cancel, confirm, intro, isCancel, multiselect } from "@clack/prompts";
+import type { Config } from "@consentio/runner";
+import { HALF_SECOND, PERCENTAGE_DIVISOR } from "@consentio/shared";
+import { config as loadDotenv } from "dotenv";
+import color from "picocolors";
+import type { BenchmarkScores } from "../types";
+import {
+ ConfigValidationError,
+ formatConfigIssues,
+ loadValidatedConfigSync,
+} from "../utils";
+import { isAdminUser } from "../utils/auth";
+import { findProjectRoot } from "../utils/project-root";
+import type { CliLogger } from "../utils/logger";
+import { calculateScores } from "../utils/scoring";
+import type { RawBenchmarkDetail } from "./results";
+
+// Load environment variables from .env files
+loadDotenv({ path: ".env" });
+loadDotenv({ path: ".env.local" });
+loadDotenv({ path: "www/.env.local" });
+
+type BenchmarkOutput = {
+ schemaVersion?: number;
+ app: string;
+ results: RawBenchmarkDetail[];
+ scores?: BenchmarkScores;
+ metadata?: Record;
+};
+
+// Benchmark result type (matching the oRPC contract)
+type BenchmarkResult = {
+ name: string;
+ baseline: boolean;
+ cookieBannerConfig: unknown;
+ techStack: unknown;
+ internationalization: unknown;
+ source: unknown;
+ includes: string[];
+ company?: string;
+ tags: string[];
+ details: RawBenchmarkDetail[];
+ average: {
+ fcp: number;
+ lcp: number;
+ cls: number;
+ tbt: number;
+ tti: number;
+ scriptLoadTime: number;
+ totalSize: number;
+ scriptSize: number;
+ resourceCount: number;
+ scriptCount: number;
+ time: number;
+ thirdPartySize: number;
+ cookieServiceSize: number;
+ bannerVisibilityTime: number;
+ viewportCoverage: number;
+ thirdPartyImpact: number;
+ mainThreadBlocking: number;
+ cookieBannerBlocking: number;
+ };
+ scores?: {
+ totalScore: number;
+ grade: "Excellent" | "Good" | "Fair" | "Poor" | "Critical";
+ indexes?: {
+ performanceIndex: number;
+ governanceIndex: number;
+ combinedIndex: number;
+ };
+ categoryScores: {
+ performance: number;
+ bundleStrategy: number;
+ networkImpact: number;
+ transparency: number;
+ userExperience: number;
+ };
+ categories: Array<{
+ name: string;
+ score: number;
+ maxScore: number;
+ weight: number;
+ details: Array<{
+ metric: string;
+ value: string | number;
+ score: number;
+ maxScore: number;
+ reason: string;
+ }>;
+ status: "excellent" | "good" | "fair" | "poor" | "critical";
+ }>;
+ insights: string[];
+ recommendations: string[];
+ };
+};
+
+const SAVE_REQUEST_TIMEOUT_MS = 15_000;
+
+async function saveBenchmarkResult(
+ logger: CliLogger,
+ result: BenchmarkResult
+): Promise {
+ const apiUrl = process.env.API_URL || "http://localhost:3000";
+ const endpoint = `${apiUrl}/api/orpc/benchmarks/save`;
+ const controller = new AbortController();
+ const timeoutId = globalThis.setTimeout(
+ () => controller.abort(),
+ SAVE_REQUEST_TIMEOUT_MS
+ );
+
+ try {
+ logger.debug(`Attempting to save ${result.name} to ${endpoint}`);
+
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ signal: controller.signal,
+ body: JSON.stringify(result),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(
+ `HTTP error! status: ${response.status}, body: ${errorText}`
+ );
+ }
+
+ const responseData = await response.json();
+ logger.success(`Saved ${result.name} (App ID: ${responseData.appId})`);
+ } catch (error) {
+ if (error instanceof Error) {
+ const wasAborted =
+ error.name === "AbortError" ||
+ error.message.toLowerCase().includes("abort");
+ if (wasAborted) {
+ logger.error(
+ `Request timed out after 15s while saving ${result.name} to ${apiUrl}`
+ );
+ }
+ logger.error(`Failed to save ${result.name}: ${error.message}`);
+ if (error.message.includes("fetch failed")) {
+ logger.error(`Connection failed. Is the server running on ${apiUrl}?`);
+ }
+ } else {
+ logger.error(`Failed to save ${result.name}: Unknown error`);
+ }
+ throw error;
+ } finally {
+ globalThis.clearTimeout(timeoutId);
+ }
+}
+
+async function findResultsFiles(dir: string): Promise {
+ const files: string[] = [];
+ try {
+ const entries = await readdir(dir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...(await findResultsFiles(fullPath)));
+ } else if (entry.name === "results.json") {
+ files.push(fullPath);
+ }
+ }
+ } catch {
+ // Silently fail if directory doesn't exist
+ }
+
+ return files;
+}
+
+function loadConfigForApp(
+ logger: CliLogger,
+ appName: string,
+ projectRoot: string
+): Config | null {
+ const configPath = join(projectRoot, "benchmarks", appName, "config.json");
+
+ try {
+ return loadValidatedConfigSync(configPath);
+ } catch (error) {
+ if (error instanceof ConfigValidationError) {
+ logger.error(`Invalid config for ${appName}`);
+ logger.error(formatConfigIssues(error.issues));
+ return null;
+ }
+ logger.debug(`Could not load config for ${appName}:`, error);
+ return null;
+ }
+}
+
+function transformScoresToContract(
+ scores: BenchmarkScores
+): BenchmarkResult["scores"] {
+ return {
+ totalScore: scores.totalScore,
+ grade: scores.grade,
+ indexes: scores.indexes,
+ categoryScores: scores.categoryScores,
+ categories: scores.categories.map((category) => ({
+ name: category.name,
+ score: category.score,
+ maxScore: category.maxScore,
+ weight: category.weight,
+ details: category.details.map((detail) => ({
+ metric: detail.name,
+ value: detail.value ?? detail.score,
+ score: detail.score,
+ maxScore: detail.maxScore,
+ reason: detail.reason,
+ })),
+ status: category.status,
+ })),
+ insights: scores.insights,
+ recommendations: scores.recommendations,
+ };
+}
+
+export async function saveCommand(
+ logger: CliLogger,
+ appName?: string
+): Promise {
+ const projectRoot = findProjectRoot();
+
+ // Double-check admin access (safeguard)
+ if (!isAdminUser()) {
+ logger.error("This command requires admin access");
+ process.exit(1);
+ }
+
+ logger.clear();
+ await sleep(HALF_SECOND);
+
+ intro(
+ `${color.bgBlue(color.white(" save "))} ${color.dim("Sync results to database")}`
+ );
+
+ // Check database configuration
+ const databaseUrl =
+ process.env.DATABASE_URL || process.env.TURSO_DATABASE_URL;
+ const authToken =
+ process.env.DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN;
+ const apiUrl = process.env.API_URL || "http://localhost:3000";
+
+ logger.info(`API endpoint: ${color.cyan(apiUrl)}`);
+
+ if (
+ databaseUrl?.startsWith("libsql://") ||
+ databaseUrl?.startsWith("wss://")
+ ) {
+ logger.info(
+ `Database: ${color.cyan(`Turso (${databaseUrl.split("@")[0]}@***)`)}`
+ );
+ if (!authToken) {
+ logger.warn("⚠️ No auth token found. Database operations may fail.");
+ }
+ } else if (databaseUrl?.startsWith("file:")) {
+ logger.info(`Database: ${color.cyan(`Local (${databaseUrl})`)}`);
+ } else {
+ logger.info(`Database: ${color.cyan("Local SQLite (benchmarks.db)")}`);
+ }
+
+ const resultsDir = join(projectRoot, "benchmarks");
+ const resultsFiles = await findResultsFiles(resultsDir);
+
+ if (resultsFiles.length === 0) {
+ logger.error("No benchmark results found!");
+ logger.info(
+ `Run ${color.cyan("cookiebench benchmark")} first to generate results.`
+ );
+ return;
+ }
+
+ logger.info(`Found ${resultsFiles.length} results file(s)`);
+
+ // Load all results
+ const allResults: Record = {};
+ const nonV2Files: string[] = [];
+ for (const file of resultsFiles) {
+ try {
+ const content = await readFile(file, "utf-8");
+ const data: BenchmarkOutput = JSON.parse(content);
+
+ if (data.schemaVersion !== 2) {
+ nonV2Files.push(file);
+ continue;
+ }
+
+ if (data.app && data.results) {
+ allResults[data.app] = data;
+ }
+ } catch (error) {
+ logger.debug(`Failed to load ${file}:`, error);
+ }
+ }
+
+ if (nonV2Files.length > 0) {
+ logger.error(
+ `Found ${nonV2Files.length} non-v2 results files. Run ${color.cyan("cookiebench migrate-results")} first.`
+ );
+ return;
+ }
+
+ if (Object.keys(allResults).length === 0) {
+ logger.error("No valid benchmark results found!");
+ return;
+ }
+
+ // If specific app requested, save only that one
+ if (appName) {
+ const result = allResults[appName];
+ if (!result) {
+ logger.error(`No results found for app: ${appName}`);
+ logger.info(`Available apps: ${Object.keys(allResults).join(", ")}`);
+ return;
+ }
+
+ await saveAppToDatabase(logger, appName, result, projectRoot);
+ logger.outro("Done!");
+ return;
+ }
+
+ // Otherwise, show interactive selection
+ const appOptions = Object.keys(allResults).map((name) => ({
+ value: name,
+ label: name,
+ hint: `${allResults[name].results.length} iterations`,
+ }));
+
+ appOptions.push({
+ value: "__all__",
+ label: "Save all apps",
+ hint: "Sync all benchmark results to database",
+ });
+
+ const selectedApps = await multiselect({
+ message: "Select benchmarks to save to database:",
+ options: appOptions,
+ required: true,
+ });
+
+ if (isCancel(selectedApps)) {
+ cancel("Operation cancelled");
+ return;
+ }
+
+ if (!Array.isArray(selectedApps) || selectedApps.length === 0) {
+ logger.warn("No benchmarks selected");
+ return;
+ }
+
+ // Confirm before saving
+ const appsToSave = selectedApps.includes("__all__")
+ ? Object.keys(allResults)
+ : (selectedApps as string[]);
+
+ const confirmBeforeSave = await confirm({
+ message: `Save ${appsToSave.length} benchmark(s) to ${apiUrl}?`,
+ initialValue: true,
+ });
+
+ if (isCancel(confirmBeforeSave) || !confirmBeforeSave) {
+ cancel("Operation cancelled");
+ return;
+ }
+
+ // Save selected apps
+ let savedCount = 0;
+ let errorCount = 0;
+
+ for (const name of appsToSave) {
+ try {
+ await saveAppToDatabase(logger, name, allResults[name], projectRoot);
+ savedCount += 1;
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error(`Failed to save ${name}: ${error.message}`);
+ } else {
+ logger.error(`Failed to save ${name}: Unknown error`);
+ }
+ errorCount += 1;
+ }
+ }
+
+ // Summary
+ logger.message("");
+ if (savedCount > 0) {
+ logger.success(`Successfully saved ${savedCount} app(s)`);
+ }
+ if (errorCount > 0) {
+ logger.warn(`Failed to save ${errorCount} app(s)`);
+ }
+
+ logger.outro(
+ `Saved ${savedCount}/${appsToSave.length} benchmarks to database`
+ );
+}
+
+async function saveAppToDatabase(
+ logger: CliLogger,
+ appName: string,
+ result: BenchmarkOutput,
+ projectRoot: string
+): Promise {
+ const appConfig = loadConfigForApp(logger, appName, projectRoot);
+ if (!appConfig) {
+ throw new Error(
+ `Cannot save ${appName}: benchmark config validation failed`
+ );
+ }
+ const appResults = result.results;
+ if (appResults.length === 0) {
+ logger.warn(`Skipping ${appName}: no benchmark iterations found.`);
+ return;
+ }
+
+ // Calculate scores if not already in results
+ let scores = result.scores;
+ if (!scores) {
+ const appData = {
+ name: appName,
+ baseline: appName === "baseline",
+ company: appConfig.company ? JSON.stringify(appConfig.company) : null,
+ techStack: JSON.stringify(appConfig.techStack),
+ source: appConfig.source ? JSON.stringify(appConfig.source) : null,
+ tags: appConfig.tags ? JSON.stringify(appConfig.tags) : null,
+ };
+
+ scores = calculateScores(
+ {
+ fcp:
+ appResults.reduce((a, b) => a + b.timing.firstContentfulPaint, 0) /
+ appResults.length,
+ lcp:
+ appResults.reduce((a, b) => a + b.timing.largestContentfulPaint, 0) /
+ appResults.length,
+ cls:
+ appResults.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) /
+ appResults.length,
+ tbt:
+ appResults.reduce(
+ (a, b) => a + b.timing.mainThreadBlocking.total,
+ 0
+ ) / appResults.length,
+ tti:
+ appResults.reduce((a, b) => a + b.timing.timeToInteractive, 0) /
+ appResults.length,
+ timeToFirstByte:
+ appResults.reduce((a, b) => a + (b.timing.timeToFirstByte || 0), 0) /
+ appResults.length,
+ interactionToNextPaint: (() => {
+ const validValues = appResults
+ .map((resultItem) => resultItem.timing.interactionToNextPaint)
+ .filter(
+ (inp): inp is number =>
+ inp !== null && inp !== undefined && Number.isFinite(inp)
+ );
+ return validValues.length > 0
+ ? validValues.reduce((a, b) => a + b, 0) / validValues.length
+ : null;
+ })(),
+ },
+ {
+ totalSize:
+ appResults.reduce((a, b) => a + b.size.total, 0) / appResults.length,
+ jsSize:
+ appResults.reduce((a, b) => a + b.size.scripts.total, 0) /
+ appResults.length,
+ cssSize:
+ appResults.reduce((a, b) => a + b.size.styles, 0) / appResults.length,
+ imageSize:
+ appResults.reduce((a, b) => a + b.size.images, 0) / appResults.length,
+ fontSize:
+ appResults.reduce((a, b) => a + b.size.fonts, 0) / appResults.length,
+ otherSize:
+ appResults.reduce((a, b) => a + b.size.other, 0) / appResults.length,
+ },
+ {
+ totalRequests:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ b.resources.scripts.length +
+ b.resources.styles.length +
+ b.resources.images.length +
+ b.resources.fonts.length +
+ b.resources.other.length,
+ 0
+ ) / appResults.length,
+ thirdPartyRequests:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ b.resources.scripts.filter((s) => s.isThirdParty).length +
+ b.resources.styles.filter((s) => s.isThirdParty).length +
+ b.resources.images.filter((s) => s.isThirdParty).length +
+ b.resources.fonts.filter((s) => s.isThirdParty).length +
+ b.resources.other.filter((s) => s.isThirdParty).length,
+ 0
+ ) / appResults.length,
+ thirdPartySize:
+ appResults.reduce((a, b) => a + b.size.thirdParty, 0) /
+ appResults.length,
+ thirdPartyDomains:
+ appResults.reduce((sum, appResult) => {
+ const thirdPartyHosts = new Set();
+ const allThirdPartyResources = [
+ ...appResult.resources.scripts.filter((r) => r.isThirdParty),
+ ...appResult.resources.styles.filter((r) => r.isThirdParty),
+ ...appResult.resources.images.filter((r) => r.isThirdParty),
+ ...appResult.resources.fonts.filter((r) => r.isThirdParty),
+ ...appResult.resources.other.filter((r) => r.isThirdParty),
+ ];
+ for (const resource of allThirdPartyResources) {
+ try {
+ thirdPartyHosts.add(new URL(resource.name).hostname);
+ } catch {
+ // Ignore invalid URLs
+ }
+ }
+ return sum + thirdPartyHosts.size;
+ }, 0) / appResults.length,
+ scriptLoadTime:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ b.timing.scripts.bundled.loadEnd +
+ b.timing.scripts.thirdParty.loadEnd,
+ 0
+ ) / appResults.length,
+ },
+ {
+ cookieBannerDetected: appResults.some(
+ (r) => r.timing.cookieBanner.detected
+ ),
+ cookieBannerVisibleTimeMs:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ (b.timing.cookieBanner.userVisibleTime ??
+ b.timing.cookieBanner.visibilityTime),
+ 0
+ ) / appResults.length,
+ cookieBannerCoverage:
+ appResults.reduce(
+ (a, b) => a + b.timing.cookieBanner.viewportCoverage,
+ 0
+ ) /
+ appResults.length /
+ PERCENTAGE_DIVISOR,
+ },
+ {
+ domSize: 1500,
+ mainThreadBlocking:
+ appResults.reduce(
+ (a, b) => a + b.timing.mainThreadBlocking.total,
+ 0
+ ) / appResults.length,
+ layoutShifts:
+ appResults.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) /
+ appResults.length,
+ },
+ appName === "baseline",
+ appData,
+ appResults[0]?.timing.networkInformation
+ );
+ }
+
+ // Convert to API format
+ const benchmarkResult: BenchmarkResult = {
+ name: appName,
+ baseline: appName === "baseline",
+ cookieBannerConfig: appConfig.cookieBanner,
+ techStack: appConfig.techStack,
+ internationalization: appConfig.internationalization,
+ source: appConfig.source,
+ includes: appConfig.includes
+ ? Object.values(appConfig.includes)
+ .flat()
+ .filter((v): v is string => typeof v === "string")
+ : [],
+ company: appConfig.company ? JSON.stringify(appConfig.company) : undefined,
+ tags: appConfig.tags || [],
+ details: appResults,
+ average: {
+ fcp:
+ appResults.reduce((a, b) => a + b.timing.firstContentfulPaint, 0) /
+ appResults.length,
+ lcp:
+ appResults.reduce((a, b) => a + b.timing.largestContentfulPaint, 0) /
+ appResults.length,
+ cls:
+ appResults.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) /
+ appResults.length,
+ tbt:
+ appResults.reduce((a, b) => a + b.timing.mainThreadBlocking.total, 0) /
+ appResults.length,
+ tti:
+ appResults.reduce((a, b) => a + b.timing.timeToInteractive, 0) /
+ appResults.length,
+ scriptLoadTime:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ b.timing.scripts.bundled.loadEnd +
+ b.timing.scripts.thirdParty.loadEnd,
+ 0
+ ) / appResults.length,
+ totalSize:
+ appResults.reduce((a, b) => a + b.size.total, 0) / appResults.length,
+ scriptSize:
+ appResults.reduce((a, b) => a + b.size.scripts.total, 0) /
+ appResults.length,
+ resourceCount:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ b.resources.scripts.length +
+ b.resources.styles.length +
+ b.resources.images.length +
+ b.resources.fonts.length +
+ b.resources.other.length,
+ 0
+ ) / appResults.length,
+ scriptCount:
+ appResults.reduce((a, b) => a + b.resources.scripts.length, 0) /
+ appResults.length,
+ time: appResults.reduce((a, b) => a + b.duration, 0) / appResults.length,
+ thirdPartySize:
+ appResults.reduce((a, b) => a + b.size.thirdParty, 0) /
+ appResults.length,
+ cookieServiceSize:
+ appResults.reduce((a, b) => a + b.size.cookieServices, 0) /
+ appResults.length,
+ bannerVisibilityTime:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ (b.timing.cookieBanner.userVisibleTime ??
+ b.timing.cookieBanner.visibilityTime),
+ 0
+ ) / appResults.length,
+ viewportCoverage:
+ appResults.reduce(
+ (a, b) => a + b.timing.cookieBanner.viewportCoverage,
+ 0
+ ) / appResults.length,
+ thirdPartyImpact:
+ appResults.reduce((a, b) => a + b.timing.thirdParty.totalImpact, 0) /
+ appResults.length,
+ mainThreadBlocking:
+ appResults.reduce((a, b) => a + b.timing.mainThreadBlocking.total, 0) /
+ appResults.length,
+ cookieBannerBlocking:
+ appResults.reduce(
+ (a, b) => a + b.timing.mainThreadBlocking.cookieBannerEstimate,
+ 0
+ ) / appResults.length,
+ },
+ scores: scores ? transformScoresToContract(scores) : undefined,
+ };
+
+ await saveBenchmarkResult(logger, benchmarkResult);
+}
diff --git a/packages/cookiebench-cli/src/commands/scores.ts b/packages/cookiebench-cli/src/commands/scores.ts
new file mode 100644
index 0000000..12155de
--- /dev/null
+++ b/packages/cookiebench-cli/src/commands/scores.ts
@@ -0,0 +1,371 @@
+import { readdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { setTimeout } from "node:timers/promises";
+import { intro, isCancel, select } from "@clack/prompts";
+import type { Config } from "@consentio/runner";
+import { HALF_SECOND, PERCENTAGE_DIVISOR } from "@consentio/shared";
+import color from "picocolors";
+import type { BenchmarkScores } from "../types";
+import { DEFAULT_DOM_SIZE } from "../utils/constants";
+import { findProjectRoot } from "../utils/project-root";
+import type { CliLogger } from "../utils/logger";
+import {
+ ConfigValidationError,
+ formatConfigIssues,
+ loadValidatedConfigSync,
+} from "../utils";
+import { calculateScores, printScores } from "../utils/scoring";
+import type { RawBenchmarkDetail } from "./results";
+
+type BenchmarkOutput = {
+ schemaVersion?: number;
+ app: string;
+ results: RawBenchmarkDetail[];
+ scores?: BenchmarkScores;
+ metadata?: Record;
+};
+
+async function findResultsFiles(dir: string): Promise {
+ const files: string[] = [];
+ try {
+ const entries = await readdir(dir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...(await findResultsFiles(fullPath)));
+ } else if (entry.name === "results.json") {
+ files.push(fullPath);
+ }
+ }
+ } catch {
+ // Directory doesn't exist or can't be read
+ }
+
+ return files;
+}
+
+function loadConfigForApp(
+ logger: CliLogger,
+ appName: string,
+ projectRoot: string
+): Config | null {
+ const configPath = join(projectRoot, "benchmarks", appName, "config.json");
+
+ try {
+ return loadValidatedConfigSync(configPath);
+ } catch (error) {
+ if (error instanceof ConfigValidationError) {
+ logger.error(`Invalid config for ${appName}`);
+ logger.error(formatConfigIssues(error.issues));
+ return null;
+ }
+ logger.debug(`Could not load config for ${appName}:`, error);
+ return null;
+ }
+}
+
+export async function scoresCommand(logger: CliLogger, appName?: string) {
+ logger.clear();
+ await setTimeout(HALF_SECOND);
+
+ intro(`${color.bgCyan(color.black(" scores "))}`);
+
+ const projectRoot = findProjectRoot();
+ const resultsDir = join(projectRoot, "benchmarks");
+ const resultsFiles = await findResultsFiles(resultsDir);
+
+ if (resultsFiles.length === 0) {
+ logger.error("No benchmark results found!");
+ logger.info(
+ `Run ${color.cyan("cookiebench benchmark")} first to generate results.`
+ );
+ return;
+ }
+
+ logger.debug(`Found ${resultsFiles.length} results files`);
+
+ // Load all results
+ const allResults: Record = {};
+ const nonV2Files: string[] = [];
+ for (const file of resultsFiles) {
+ try {
+ const content = await readFile(file, "utf-8");
+ const data: BenchmarkOutput = JSON.parse(content);
+
+ if (data.schemaVersion !== 2) {
+ nonV2Files.push(file);
+ continue;
+ }
+
+ if (data.app && data.results) {
+ allResults[data.app] = data;
+ }
+ } catch (error) {
+ logger.debug(`Failed to load ${file}:`, error);
+ }
+ }
+
+ if (nonV2Files.length > 0) {
+ throw new Error(
+ `Found ${nonV2Files.length} non-v2 results files. Run "cookiebench migrate-results" first.`
+ );
+ }
+
+ if (Object.keys(allResults).length === 0) {
+ logger.error("No valid benchmark results found!");
+ return;
+ }
+
+ // If specific app requested, show only that one
+ if (appName) {
+ const result = allResults[appName];
+ if (!result) {
+ logger.error(`No results found for app: ${appName}`);
+ logger.info(`Available apps: ${Object.keys(allResults).join(", ")}`);
+ return;
+ }
+
+ await displayAppScores(logger, appName, result);
+ return;
+ }
+
+ // Otherwise, show interactive selection
+ const appOptions = Object.keys(allResults).map((name) => ({
+ value: name,
+ label: name,
+ hint: `${allResults[name].results.length} iterations`,
+ }));
+
+ appOptions.push({
+ value: "__all__",
+ label: "Show all apps",
+ hint: "Display scores for all benchmarks",
+ });
+
+ const selectedApp = await select({
+ message: "Which benchmark scores would you like to view?",
+ options: appOptions,
+ });
+
+ if (isCancel(selectedApp)) {
+ logger.info("Operation cancelled");
+ return;
+ }
+
+ if (selectedApp === "__all__") {
+ // Show all apps
+ for (const [name, result] of Object.entries(allResults)) {
+ await displayAppScores(logger, name, result);
+ logger.message(""); // Add spacing between apps
+ }
+ } else {
+ await displayAppScores(
+ logger,
+ selectedApp as string,
+ allResults[selectedApp as string]
+ );
+ }
+
+ logger.outro("Done!");
+}
+
+function displayAppScores(
+ logger: CliLogger,
+ appName: string,
+ result: BenchmarkOutput
+) {
+ logger.info(`\n${color.bold(color.cyan(`📊 ${appName}`))}`);
+
+ // Show metadata if available
+ if (result.metadata) {
+ logger.debug(`Metadata available for ${appName}`);
+ }
+
+ // If scores are already calculated and stored, use them
+ if (result.scores) {
+ logger.debug("Using pre-calculated scores from results file");
+ printScores(result.scores);
+ return;
+ }
+
+ // Otherwise, calculate scores from raw results
+ logger.debug("Calculating scores from raw benchmark data");
+
+ const appResults = result.results;
+ if (appResults.length === 0) {
+ logger.warn(
+ `No benchmark runs found for ${appName}, skipping score output.`
+ );
+ return;
+ }
+ const projectRoot = findProjectRoot();
+ const config = loadConfigForApp(logger, appName, projectRoot);
+
+ if (!config) {
+ throw new Error(
+ `Cannot score ${appName} because benchmark config validation failed`
+ );
+ }
+
+ // Create app data for transparency scoring
+ const appData = {
+ name: appName,
+ baseline: appName === "baseline",
+ company: config?.company ? JSON.stringify(config.company) : null,
+ techStack: config?.techStack ? JSON.stringify(config.techStack) : "{}",
+ source: config?.source ? JSON.stringify(config.source) : null,
+ tags: config?.tags ? JSON.stringify(config.tags) : null,
+ };
+
+ const scores = calculateScores(
+ {
+ fcp:
+ appResults.reduce((a, b) => a + b.timing.firstContentfulPaint, 0) /
+ appResults.length,
+ lcp:
+ appResults.reduce((a, b) => a + b.timing.largestContentfulPaint, 0) /
+ appResults.length,
+ cls:
+ appResults.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) /
+ appResults.length,
+ tbt:
+ appResults.reduce((a, b) => a + b.timing.mainThreadBlocking.total, 0) /
+ appResults.length,
+ tti:
+ appResults.reduce((a, b) => a + b.timing.timeToInteractive, 0) /
+ appResults.length,
+ timeToFirstByte:
+ appResults.reduce((a, b) => a + (b.timing.timeToFirstByte || 0), 0) /
+ appResults.length,
+ interactionToNextPaint: (() => {
+ const validValues = appResults
+ .map((r) => r.timing.interactionToNextPaint)
+ .filter(
+ (inp): inp is number =>
+ inp !== null && inp !== undefined && Number.isFinite(inp)
+ );
+ return validValues.length > 0
+ ? validValues.reduce((a, b) => a + b, 0) / validValues.length
+ : null;
+ })(),
+ },
+ {
+ totalSize:
+ appResults.reduce((a, b) => a + b.size.total, 0) / appResults.length,
+ jsSize:
+ appResults.reduce((a, b) => a + b.size.scripts.total, 0) /
+ appResults.length,
+ cssSize:
+ appResults.reduce((a, b) => a + b.size.styles, 0) / appResults.length,
+ imageSize:
+ appResults.reduce((a, b) => a + b.size.images, 0) / appResults.length,
+ fontSize:
+ appResults.reduce((a, b) => a + b.size.fonts, 0) / appResults.length,
+ otherSize:
+ appResults.reduce((a, b) => a + b.size.other, 0) / appResults.length,
+ },
+ {
+ totalRequests:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ b.resources.scripts.length +
+ b.resources.styles.length +
+ b.resources.images.length +
+ b.resources.fonts.length +
+ b.resources.other.length,
+ 0
+ ) / appResults.length,
+ thirdPartyRequests:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ b.resources.scripts.filter((s) => s.isThirdParty).length +
+ b.resources.styles.filter((s) => s.isThirdParty).length +
+ b.resources.images.filter((s) => s.isThirdParty).length +
+ b.resources.fonts.filter((s) => s.isThirdParty).length +
+ b.resources.other.filter((s) => s.isThirdParty).length,
+ 0
+ ) / appResults.length,
+ thirdPartySize:
+ appResults.reduce((a, b) => a + b.size.thirdParty, 0) /
+ appResults.length,
+ thirdPartyDomains: (() => {
+ // Calculate unique third-party domains from resources
+ const thirdPartyHosts = new Set();
+ for (const appResult of appResults) {
+ // Check all resource types for third-party resources
+ const allThirdPartyResources = [
+ ...appResult.resources.scripts.filter((r) => r.isThirdParty),
+ ...appResult.resources.styles.filter((r) => r.isThirdParty),
+ ...appResult.resources.images.filter((r) => r.isThirdParty),
+ ...appResult.resources.fonts.filter((r) => r.isThirdParty),
+ ...appResult.resources.other.filter((r) => r.isThirdParty),
+ ];
+ for (const resource of allThirdPartyResources) {
+ try {
+ const url = new URL(resource.name);
+ thirdPartyHosts.add(url.hostname);
+ } catch {
+ // Invalid URL, skip
+ }
+ }
+ }
+ return thirdPartyHosts.size;
+ })(),
+ scriptLoadTime:
+ appResults.reduce(
+ (a, b) =>
+ a +
+ b.timing.scripts.bundled.loadEnd +
+ b.timing.scripts.thirdParty.loadEnd,
+ 0
+ ) / appResults.length,
+ },
+ {
+ cookieBannerDetected: appResults.some(
+ (r) => r.timing.cookieBanner.detected
+ ),
+ cookieBannerVisibleTimeMs: (() => {
+ const validTimes = appResults
+ .map(
+ (iteration) =>
+ iteration.timing.cookieBanner.userVisibleTime ??
+ iteration.timing.cookieBanner.visibilityTime
+ )
+ .filter(
+ (time): time is number =>
+ typeof time === "number" && Number.isFinite(time)
+ );
+ if (validTimes.length === 0) {
+ return 0;
+ }
+ return (
+ validTimes.reduce((sum, time) => sum + time, 0) / validTimes.length
+ );
+ })(),
+ cookieBannerCoverage:
+ appResults.reduce(
+ (a, b) => a + b.timing.cookieBanner.viewportCoverage,
+ 0
+ ) /
+ appResults.length /
+ PERCENTAGE_DIVISOR,
+ },
+ {
+ domSize: DEFAULT_DOM_SIZE,
+ mainThreadBlocking:
+ appResults.reduce((a, b) => a + b.timing.mainThreadBlocking.total, 0) /
+ appResults.length,
+ layoutShifts:
+ appResults.reduce((a, b) => a + b.timing.cumulativeLayoutShift, 0) /
+ appResults.length,
+ },
+ appName === "baseline",
+ appData,
+ appResults[0]?.timing.networkInformation
+ );
+
+ printScores(scores);
+}
diff --git a/packages/cookiebench-cli/src/components/intro.ts b/packages/cookiebench-cli/src/components/intro.ts
new file mode 100644
index 0000000..e9b6dbd
--- /dev/null
+++ b/packages/cookiebench-cli/src/components/intro.ts
@@ -0,0 +1,107 @@
+/** biome-ignore-all lint/style/noMagicNumbers: its okay for the figlet word */
+import figlet from "figlet";
+import color from "picocolors";
+import type { CliLogger } from "../utils/logger";
+
+/**
+ * Displays the CLI introduction sequence with figlet art
+ * @param logger - The CLI logger instance
+ * @param version - The CLI version string
+ */
+export async function displayIntro(
+ logger: CliLogger,
+ version?: string
+): Promise {
+ // Generate and display Figlet text (async)
+ let figletText = "cookiebench"; // Default
+ try {
+ figletText = await new Promise((resolve) => {
+ figlet.text(
+ "cookiebench",
+ {
+ font: "Slant",
+ horizontalLayout: "default",
+ verticalLayout: "default",
+ width: 80,
+ whitespaceBreak: true,
+ },
+ (err, data) => {
+ if (err) {
+ logger.debug("Failed to generate figlet text");
+ resolve("cookiebench");
+ } else {
+ resolve(data || "cookiebench");
+ }
+ }
+ );
+ });
+ } catch (error) {
+ logger.debug("Error generating figlet text", error);
+ }
+
+ // Display the figlet text with cyan/teal gradient
+ const canUseAnsiColors =
+ Boolean(color.isColorSupported) &&
+ !process.env.NO_COLOR &&
+ process.stdout.isTTY;
+
+ const customColor = {
+ cyan10: (text: string) =>
+ canUseAnsiColors ? `\x1b[38;2;10;80;90m${text}\x1b[0m` : text,
+ cyan20: (text: string) =>
+ canUseAnsiColors ? `\x1b[38;2;15;100;110m${text}\x1b[0m` : text,
+ cyan30: (text: string) =>
+ canUseAnsiColors ? `\x1b[38;2;20;120;130m${text}\x1b[0m` : text,
+ cyan40: (text: string) =>
+ canUseAnsiColors ? `\x1b[38;2;25;150;170m${text}\x1b[0m` : text,
+ cyan50: (text: string) =>
+ canUseAnsiColors ? `\x1b[38;2;30;170;190m${text}\x1b[0m` : text,
+ cyan75: (text: string) =>
+ canUseAnsiColors ? `\x1b[38;2;34;211;230m${text}\x1b[0m` : text,
+ cyan90: (text: string) =>
+ canUseAnsiColors ? `\x1b[38;2;45;225;245m${text}\x1b[0m` : text,
+ cyan100: (text: string) =>
+ canUseAnsiColors ? `\x1b[38;2;65;235;255m${text}\x1b[0m` : text,
+ };
+
+ const lines = figletText.split("\n");
+ const gradientDenominator = Math.max(1, lines.length - 1);
+ const coloredLines = lines.map((line, index) => {
+ // Calculate the position in the gradient based on line index
+ const position = index / gradientDenominator;
+
+ if (position < 0.1) {
+ return customColor.cyan10(line);
+ }
+ if (position < 0.2) {
+ return customColor.cyan20(line);
+ }
+ if (position < 0.3) {
+ return customColor.cyan30(line);
+ }
+ if (position < 0.4) {
+ return customColor.cyan40(line);
+ }
+ if (position < 0.5) {
+ return customColor.cyan50(line);
+ }
+ if (position < 0.65) {
+ return customColor.cyan75(line);
+ }
+ if (position < 0.8) {
+ return customColor.cyan90(line);
+ }
+ return customColor.cyan100(line);
+ });
+
+ // Join all colored lines and send as a single message
+ logger.message(coloredLines.join("\n"));
+
+ // Display version if provided
+ if (version) {
+ logger.message(color.dim(`v${version}`));
+ }
+
+ // Spacing before next step
+ logger.message("");
+}
diff --git a/packages/cookiebench-cli/src/index.ts b/packages/cookiebench-cli/src/index.ts
new file mode 100644
index 0000000..1259751
--- /dev/null
+++ b/packages/cookiebench-cli/src/index.ts
@@ -0,0 +1,233 @@
+#!/usr/bin/env node
+import { setTimeout } from "node:timers/promises";
+import { cancel, isCancel, select } from "@clack/prompts";
+import { HALF_SECOND } from "@consentio/shared";
+import { benchmarkCommand } from "./commands/benchmark";
+import { dbCommand } from "./commands/db";
+import { migrateResultsCommand } from "./commands/migrate-results";
+import { resultsCommand } from "./commands/results";
+import { saveCommand } from "./commands/save";
+import { scoresCommand } from "./commands/scores";
+import { displayIntro } from "./components/intro";
+import { isAdminUser } from "./utils/auth";
+import { type CliLogger, createCliLogger } from "./utils/logger";
+
+// Get log level from env or default to info
+const logLevel =
+ (process.env.LOG_LEVEL as "error" | "warn" | "info" | "debug") || "info";
+const logger: CliLogger = createCliLogger(logLevel);
+
+// Check admin access for restricted commands
+const isAdmin = isAdminUser();
+
+function onCancel() {
+ cancel("Operation cancelled.");
+ process.exit(0);
+}
+
+function parseBenchmarkArgs(args: string[]): {
+ appPath?: string;
+ traceMode?: "off" | "on-failure" | "all";
+ profile?: "none" | "slow4g" | "fast3g";
+ cacheMode?: "cold" | "warm" | "mixed";
+} {
+ const parsed: {
+ appPath?: string;
+ traceMode?: "off" | "on-failure" | "all";
+ profile?: "none" | "slow4g" | "fast3g";
+ cacheMode?: "cold" | "warm" | "mixed";
+ } = {};
+
+ let index = 0;
+ while (index < args.length) {
+ const token = args[index];
+ if (!(token.startsWith("--") || parsed.appPath)) {
+ parsed.appPath = token;
+ index += 1;
+ continue;
+ }
+
+ if (token === "--trace") {
+ const value = args[index + 1];
+ if (value === "off" || value === "on-failure" || value === "all") {
+ parsed.traceMode = value;
+ index += 2;
+ continue;
+ }
+ throw new Error(
+ "Invalid --trace value. Expected one of: off, on-failure, all"
+ );
+ }
+
+ if (token === "--profile") {
+ const value = args[index + 1];
+ if (value === "none" || value === "slow4g" || value === "fast3g") {
+ parsed.profile = value;
+ index += 2;
+ continue;
+ }
+ throw new Error(
+ "Invalid --profile value. Expected one of: none, slow4g, fast3g"
+ );
+ }
+
+ if (token === "--cache-mode") {
+ const value = args[index + 1];
+ if (value === "cold" || value === "warm" || value === "mixed") {
+ parsed.cacheMode = value;
+ index += 2;
+ continue;
+ }
+ throw new Error(
+ "Invalid --cache-mode value. Expected one of: cold, warm, mixed"
+ );
+ }
+
+ throw new Error(`Unknown benchmark option: ${token}`);
+ }
+
+ return parsed;
+}
+
+async function main() {
+ logger.clear();
+ await setTimeout(HALF_SECOND);
+
+ // Check for command line arguments
+ const rawArgs = process.argv.slice(2);
+ const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
+ const command = args[0];
+
+ // Show intro for interactive mode
+ if (!command) {
+ await displayIntro(logger);
+ }
+
+ // If no command specified, show the prompt
+ if (command) {
+ // Direct command execution
+ switch (command) {
+ case "benchmark": {
+ const parsed = parseBenchmarkArgs(args.slice(1));
+ await benchmarkCommand(logger, parsed.appPath, {
+ traceMode: parsed.traceMode,
+ profile: parsed.profile,
+ cacheMode: parsed.cacheMode,
+ });
+ break;
+ }
+ case "results":
+ await resultsCommand(logger, args[1]);
+ break;
+ case "scores":
+ await scoresCommand(logger, args[1]);
+ break;
+ case "migrate-results":
+ await migrateResultsCommand(logger, args[1]);
+ break;
+ case "save":
+ if (!isAdmin) {
+ logger.error("This command requires admin access");
+ process.exit(1);
+ }
+ await saveCommand(logger, args[1]);
+ break;
+ case "db":
+ if (!isAdmin) {
+ logger.error("This command requires admin access");
+ process.exit(1);
+ }
+ await dbCommand(logger, args[1]);
+ break;
+ default: {
+ logger.error(`Unknown command: ${command}`);
+ const availableCommands = [
+ "benchmark",
+ "results",
+ "scores",
+ "migrate-results",
+ ];
+ if (isAdmin) {
+ availableCommands.push("save", "db");
+ }
+ logger.info(`Available commands: ${availableCommands.join(", ")}`);
+ process.exit(1);
+ }
+ }
+ process.exit(0);
+ } else {
+ // Build options based on admin access
+ const options = [
+ {
+ value: "benchmark",
+ label: "Run a benchmark",
+ hint: "Run a performance benchmark on a URL",
+ },
+ {
+ value: "results",
+ label: "Results",
+ hint: "View detailed benchmark results",
+ },
+ {
+ value: "scores",
+ label: "Scores",
+ hint: "View score-focused benchmark output",
+ },
+ {
+ value: "migrate-results",
+ label: "Migrate Results",
+ hint: "Upgrade results.json files to schemaVersion 2",
+ },
+ ];
+
+ // Add admin-only commands
+ if (isAdmin) {
+ options.push({
+ value: "save",
+ label: "Save to database",
+ hint: "🔒 Admin: Sync benchmark results to database",
+ });
+ options.push({
+ value: "db",
+ label: "Database",
+ hint: "🔒 Admin: Manage database schema and migrations",
+ });
+ }
+
+ const selectedCommand = await select({
+ message: "What would you like to do?",
+ options,
+ });
+
+ if (isCancel(selectedCommand)) {
+ return onCancel();
+ }
+
+ // biome-ignore lint/style/useDefaultSwitchClause: this is a CLI tool
+ switch (selectedCommand) {
+ case "benchmark":
+ await benchmarkCommand(logger);
+ break;
+ case "results":
+ await resultsCommand(logger);
+ break;
+ case "scores":
+ await scoresCommand(logger);
+ break;
+ case "migrate-results":
+ await migrateResultsCommand(logger);
+ break;
+ case "save":
+ await saveCommand(logger);
+ break;
+ case "db":
+ await dbCommand(logger);
+ break;
+ }
+ }
+}
+
+main().catch((error) => {
+ logger.error("Fatal error:", error);
+ process.exit(1);
+});
diff --git a/packages/cookiebench-cli/src/types/index.ts b/packages/cookiebench-cli/src/types/index.ts
new file mode 100644
index 0000000..ee66fc4
--- /dev/null
+++ b/packages/cookiebench-cli/src/types/index.ts
@@ -0,0 +1,48 @@
+// Re-export types from runner package
+export type {
+ BenchmarkDetails,
+ BenchmarkResult,
+ Config,
+ ServerInfo,
+} from "@consentio/runner";
+
+export type ScoreLevel = "excellent" | "good" | "fair" | "poor" | "critical";
+export type ScoreStatus = ScoreLevel;
+export type ScoreGrade = Capitalize;
+
+// CLI-specific scoring types
+export type BenchmarkScores = {
+ totalScore: number;
+ grade: ScoreGrade;
+ indexes: {
+ performanceIndex: number;
+ governanceIndex: number;
+ combinedIndex: number;
+ };
+ categoryScores: {
+ performance: number;
+ bundleStrategy: number;
+ networkImpact: number;
+ transparency: number;
+ userExperience: number;
+ };
+ categories: Array<{
+ name: string;
+ score: number;
+ maxScore: number;
+ weight: number;
+ details: Array<{
+ name: string;
+ value?: string | number;
+ score: number;
+ maxScore: number;
+ weight: number;
+ status: ScoreStatus;
+ reason: string;
+ }>;
+ status: ScoreStatus;
+ reason: string;
+ }>;
+ insights: string[];
+ recommendations: string[];
+};
diff --git a/packages/cookiebench-cli/src/utils/auth.ts b/packages/cookiebench-cli/src/utils/auth.ts
new file mode 100644
index 0000000..ac23516
--- /dev/null
+++ b/packages/cookiebench-cli/src/utils/auth.ts
@@ -0,0 +1,11 @@
+/**
+ * Check if the user has admin access for database operations
+ * This is gated by an admin flag to prevent unauthorized database writes
+ */
+export function isAdminUser(): boolean {
+ // Check for admin flag in environment
+ const adminFlag = (process.env.CONSENT_ADMIN || "").trim().toLowerCase();
+
+ // Accept 'true', '1', 'yes' as valid values
+ return adminFlag === "true" || adminFlag === "1" || adminFlag === "yes";
+}
diff --git a/packages/cookiebench-cli/src/utils/config-validation.ts b/packages/cookiebench-cli/src/utils/config-validation.ts
new file mode 100644
index 0000000..d3ed219
--- /dev/null
+++ b/packages/cookiebench-cli/src/utils/config-validation.ts
@@ -0,0 +1,600 @@
+import { readFileSync } from "node:fs";
+import type { Config } from "../types";
+
+const ROOT_KEYS = new Set([
+ "$schema",
+ "name",
+ "url",
+ "testId",
+ "id",
+ "iterations",
+ "baseline",
+ "remote",
+ "runProfile",
+ "measurement",
+ "cookieBanner",
+ "internationalization",
+ "techStack",
+ "source",
+ "includes",
+ "company",
+ "tags",
+]);
+
+const BUNDLE_TYPES = new Set(["esm", "cjs", "iife", "bundled"]);
+const CACHE_MODES = new Set(["cold", "warm", "mixed"]);
+const NETWORK_PROFILES = new Set(["none", "slow4g", "fast3g"]);
+const I18N_DETECTION = new Set(["browser", "ip", "manual", "none"]);
+const I18N_STRING_LOADING = new Set(["bundled", "server", "none"]);
+const LANGUAGES = new Set(["typescript", "javascript"]);
+
+type ValidationIssue = {
+ path: string;
+ message: string;
+};
+
+export class ConfigValidationError extends Error {
+ readonly issues: ValidationIssue[];
+
+ constructor(message: string, issues: ValidationIssue[]) {
+ super(message);
+ this.name = "ConfigValidationError";
+ this.issues = issues;
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function assertNoUnknownKeys(
+ issues: ValidationIssue[],
+ path: string,
+ value: Record,
+ allowed: Set
+): void {
+ for (const key of Object.keys(value)) {
+ if (!allowed.has(key)) {
+ issues.push({
+ path: `${path}.${key}`,
+ message: "Unknown property",
+ });
+ }
+ }
+}
+
+function assertString(
+ issues: ValidationIssue[],
+ path: string,
+ value: unknown
+): value is string {
+ if (typeof value !== "string" || value.trim().length === 0) {
+ issues.push({ path, message: "Expected non-empty string" });
+ return false;
+ }
+ return true;
+}
+
+function assertBoolean(
+ issues: ValidationIssue[],
+ path: string,
+ value: unknown
+): value is boolean {
+ if (typeof value !== "boolean") {
+ issues.push({ path, message: "Expected boolean" });
+ return false;
+ }
+ return true;
+}
+
+function assertNumber(
+ issues: ValidationIssue[],
+ path: string,
+ value: unknown,
+ options?: { validator?: (num: number) => boolean; hint?: string }
+): value is number {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ issues.push({ path, message: "Expected number" });
+ return false;
+ }
+ if (options?.validator && !options.validator(value)) {
+ issues.push({
+ path,
+ message: options.hint ?? "Invalid numeric value",
+ });
+ return false;
+ }
+ return true;
+}
+
+function assertStringArray(
+ issues: ValidationIssue[],
+ path: string,
+ value: unknown,
+ allowedValues?: Set
+): value is string[] {
+ if (!Array.isArray(value)) {
+ issues.push({ path, message: "Expected array" });
+ return false;
+ }
+
+ for (const [index, item] of value.entries()) {
+ if (typeof item !== "string" || item.trim().length === 0) {
+ issues.push({
+ path: `${path}[${index}]`,
+ message: "Expected non-empty string",
+ });
+ continue;
+ }
+ if (allowedValues && !allowedValues.has(item)) {
+ issues.push({
+ path: `${path}[${index}]`,
+ message: `Unsupported value "${item}"`,
+ });
+ }
+ }
+ return true;
+}
+
+function normalizeBundleType(
+ bundleType: Config["techStack"]["bundleType"]
+): Config["techStack"]["bundleType"] {
+ if (Array.isArray(bundleType)) {
+ return bundleType.map((value) => (value === "iife" ? "iife" : value));
+ }
+ return bundleType === "iife" ? "iife" : bundleType;
+}
+
+export function validateBenchmarkConfig(config: unknown): {
+ config: Config | null;
+ issues: ValidationIssue[];
+} {
+ const issues: ValidationIssue[] = [];
+
+ if (!isRecord(config)) {
+ return {
+ config: null,
+ issues: [{ path: "config", message: "Expected object" }],
+ };
+ }
+
+ assertNoUnknownKeys(issues, "config", config, ROOT_KEYS);
+
+ assertString(issues, "name", config.name);
+ assertNumber(issues, "iterations", config.iterations, {
+ validator: (value) => Number.isInteger(value) && value >= 1,
+ hint: "Expected integer >= 1",
+ });
+
+ if (config.url !== undefined) {
+ assertString(issues, "url", config.url);
+ }
+ if (config.testId !== undefined) {
+ assertString(issues, "testId", config.testId);
+ }
+ if (config.id !== undefined) {
+ assertString(issues, "id", config.id);
+ }
+ if (config.baseline !== undefined) {
+ assertBoolean(issues, "baseline", config.baseline);
+ }
+
+ // runProfile
+ if (isRecord(config.runProfile)) {
+ const runProfile = config.runProfile;
+ assertNoUnknownKeys(
+ issues,
+ "runProfile",
+ runProfile,
+ new Set(["cacheMode", "networkProfile", "cpuSlowdownMultiplier"])
+ );
+
+ if (
+ assertString(issues, "runProfile.cacheMode", runProfile.cacheMode) &&
+ !CACHE_MODES.has(runProfile.cacheMode)
+ ) {
+ issues.push({
+ path: "runProfile.cacheMode",
+ message: `Unsupported value "${runProfile.cacheMode}"`,
+ });
+ }
+ if (
+ assertString(
+ issues,
+ "runProfile.networkProfile",
+ runProfile.networkProfile
+ ) &&
+ !NETWORK_PROFILES.has(runProfile.networkProfile)
+ ) {
+ issues.push({
+ path: "runProfile.networkProfile",
+ message: `Unsupported value "${runProfile.networkProfile}"`,
+ });
+ }
+ assertNumber(
+ issues,
+ "runProfile.cpuSlowdownMultiplier",
+ runProfile.cpuSlowdownMultiplier,
+ { validator: (value) => value >= 1, hint: "Expected number >= 1" }
+ );
+ } else {
+ issues.push({
+ path: "runProfile",
+ message:
+ "Missing required object (expected cacheMode, networkProfile, cpuSlowdownMultiplier)",
+ });
+ }
+
+ // measurement
+ if (isRecord(config.measurement)) {
+ const measurement = config.measurement;
+ assertNoUnknownKeys(
+ issues,
+ "measurement",
+ measurement,
+ new Set([
+ "minSuccessfulIterations",
+ "maxFailureRate",
+ "stabilityThresholdCv",
+ ])
+ );
+
+ assertNumber(
+ issues,
+ "measurement.minSuccessfulIterations",
+ measurement.minSuccessfulIterations,
+ {
+ validator: (value) => Number.isInteger(value) && value >= 1,
+ hint: "Expected integer >= 1",
+ }
+ );
+ assertNumber(
+ issues,
+ "measurement.maxFailureRate",
+ measurement.maxFailureRate,
+ {
+ validator: (value) => value >= 0 && value <= 1,
+ hint: "Expected number between 0 and 1",
+ }
+ );
+ assertNumber(
+ issues,
+ "measurement.stabilityThresholdCv",
+ measurement.stabilityThresholdCv,
+ { validator: (value) => value >= 0, hint: "Expected number >= 0" }
+ );
+ } else {
+ issues.push({
+ path: "measurement",
+ message:
+ "Missing required object (expected minSuccessfulIterations, maxFailureRate, stabilityThresholdCv)",
+ });
+ }
+
+ // remote
+ if (config.remote !== undefined) {
+ if (isRecord(config.remote)) {
+ const remote = config.remote;
+ assertNoUnknownKeys(
+ issues,
+ "remote",
+ remote,
+ new Set(["enabled", "url", "headers"])
+ );
+
+ let enabled = false;
+ if (remote.enabled !== undefined) {
+ const isValidEnabled = assertBoolean(
+ issues,
+ "remote.enabled",
+ remote.enabled
+ );
+ enabled = Boolean(remote.enabled && isValidEnabled);
+ }
+
+ if (enabled) {
+ assertString(issues, "remote.url", remote.url);
+ } else if (remote.url !== undefined) {
+ assertString(issues, "remote.url", remote.url);
+ }
+
+ if (remote.headers !== undefined) {
+ if (isRecord(remote.headers)) {
+ for (const [header, headerValue] of Object.entries(remote.headers)) {
+ if (typeof headerValue !== "string") {
+ issues.push({
+ path: `remote.headers.${header}`,
+ message: "Expected string header value",
+ });
+ }
+ }
+ } else {
+ issues.push({
+ path: "remote.headers",
+ message: "Expected object map of string:string",
+ });
+ }
+ }
+ } else {
+ issues.push({ path: "remote", message: "Expected object" });
+ }
+ }
+
+ // cookieBanner
+ if (isRecord(config.cookieBanner)) {
+ const cookieBanner = config.cookieBanner;
+ assertNoUnknownKeys(
+ issues,
+ "cookieBanner",
+ cookieBanner,
+ new Set([
+ "selectors",
+ "serviceHosts",
+ "waitForVisibility",
+ "measureViewportCoverage",
+ "expectedLayoutShift",
+ "serviceName",
+ ])
+ );
+ assertStringArray(issues, "cookieBanner.selectors", cookieBanner.selectors);
+ assertStringArray(
+ issues,
+ "cookieBanner.serviceHosts",
+ cookieBanner.serviceHosts
+ );
+ assertBoolean(
+ issues,
+ "cookieBanner.waitForVisibility",
+ cookieBanner.waitForVisibility
+ );
+ assertBoolean(
+ issues,
+ "cookieBanner.measureViewportCoverage",
+ cookieBanner.measureViewportCoverage
+ );
+ assertBoolean(
+ issues,
+ "cookieBanner.expectedLayoutShift",
+ cookieBanner.expectedLayoutShift
+ );
+ assertString(issues, "cookieBanner.serviceName", cookieBanner.serviceName);
+ } else {
+ issues.push({ path: "cookieBanner", message: "Missing required object" });
+ }
+
+ // internationalization
+ if (isRecord(config.internationalization)) {
+ const internationalization = config.internationalization;
+ assertNoUnknownKeys(
+ issues,
+ "internationalization",
+ internationalization,
+ new Set(["detection", "stringLoading"])
+ );
+
+ if (
+ assertString(
+ issues,
+ "internationalization.detection",
+ internationalization.detection
+ ) &&
+ !I18N_DETECTION.has(internationalization.detection)
+ ) {
+ issues.push({
+ path: "internationalization.detection",
+ message: `Unsupported value "${internationalization.detection}"`,
+ });
+ }
+
+ if (
+ assertString(
+ issues,
+ "internationalization.stringLoading",
+ internationalization.stringLoading
+ ) &&
+ !I18N_STRING_LOADING.has(internationalization.stringLoading)
+ ) {
+ issues.push({
+ path: "internationalization.stringLoading",
+ message: `Unsupported value "${internationalization.stringLoading}"`,
+ });
+ }
+ } else {
+ issues.push({
+ path: "internationalization",
+ message: "Missing required object",
+ });
+ }
+
+ // techStack
+ if (isRecord(config.techStack)) {
+ const techStack = config.techStack;
+ assertNoUnknownKeys(
+ issues,
+ "techStack",
+ techStack,
+ new Set([
+ "bundler",
+ "bundleType",
+ "frameworks",
+ "languages",
+ "packageManager",
+ "typescript",
+ ])
+ );
+ assertString(issues, "techStack.bundler", techStack.bundler);
+
+ if (typeof techStack.bundleType === "string") {
+ if (!BUNDLE_TYPES.has(techStack.bundleType)) {
+ issues.push({
+ path: "techStack.bundleType",
+ message: `Unsupported value "${techStack.bundleType}"`,
+ });
+ }
+ } else if (Array.isArray(techStack.bundleType)) {
+ const normalizedBundleTypes = (techStack.bundleType as unknown[]).map(
+ (value) => (value === "iife" ? "iife" : value)
+ );
+ assertStringArray(
+ issues,
+ "techStack.bundleType",
+ normalizedBundleTypes,
+ BUNDLE_TYPES
+ );
+ } else {
+ issues.push({
+ path: "techStack.bundleType",
+ message: "Expected string or string[]",
+ });
+ }
+
+ assertStringArray(
+ issues,
+ "techStack.frameworks",
+ techStack.frameworks as unknown[]
+ );
+ assertStringArray(
+ issues,
+ "techStack.languages",
+ techStack.languages as unknown[],
+ LANGUAGES
+ );
+ assertString(issues, "techStack.packageManager", techStack.packageManager);
+ assertBoolean(issues, "techStack.typescript", techStack.typescript);
+ } else {
+ issues.push({ path: "techStack", message: "Missing required object" });
+ }
+
+ // source
+ if (isRecord(config.source)) {
+ const source = config.source;
+ assertNoUnknownKeys(
+ issues,
+ "source",
+ source,
+ new Set(["github", "isOpenSource", "license", "npm", "website"])
+ );
+
+ if (source.github !== undefined && source.github !== false) {
+ assertString(issues, "source.github", source.github);
+ }
+ if (
+ typeof source.isOpenSource !== "boolean" &&
+ source.isOpenSource !== "partially"
+ ) {
+ issues.push({
+ path: "source.isOpenSource",
+ message: 'Expected boolean or "partially"',
+ });
+ }
+ assertString(issues, "source.license", source.license);
+ if (source.npm !== undefined && source.npm !== false) {
+ assertString(issues, "source.npm", source.npm);
+ }
+ if (source.website !== undefined) {
+ assertString(issues, "source.website", source.website);
+ }
+ } else {
+ issues.push({ path: "source", message: "Missing required object" });
+ }
+
+ // includes
+ if (isRecord(config.includes)) {
+ const includes = config.includes;
+ assertNoUnknownKeys(
+ issues,
+ "includes",
+ includes,
+ new Set(["backend", "components"])
+ );
+ if (
+ includes.backend !== undefined &&
+ includes.backend !== false &&
+ typeof includes.backend !== "string"
+ ) {
+ assertStringArray(
+ issues,
+ "includes.backend",
+ includes.backend as unknown[]
+ );
+ }
+ assertStringArray(
+ issues,
+ "includes.components",
+ includes.components as unknown[]
+ );
+ } else {
+ issues.push({ path: "includes", message: "Missing required object" });
+ }
+
+ if (config.company !== undefined) {
+ if (isRecord(config.company)) {
+ const company = config.company;
+ assertNoUnknownKeys(
+ issues,
+ "company",
+ company,
+ new Set(["name", "website", "avatar"])
+ );
+ assertString(issues, "company.name", company.name);
+ assertString(issues, "company.website", company.website);
+ assertString(issues, "company.avatar", company.avatar);
+ } else {
+ issues.push({ path: "company", message: "Expected object" });
+ }
+ }
+
+ if (config.tags !== undefined) {
+ assertStringArray(issues, "tags", config.tags as unknown[]);
+ }
+
+ if (issues.length > 0) {
+ return { config: null, issues };
+ }
+
+ const normalized = {
+ ...config,
+ techStack: {
+ ...(config.techStack as Config["techStack"]),
+ bundleType: normalizeBundleType(
+ (config.techStack as Config["techStack"]).bundleType
+ ),
+ },
+ } as Config;
+
+ return { config: normalized, issues };
+}
+
+export function formatConfigIssues(issues: ValidationIssue[]): string {
+ return issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n");
+}
+
+export function loadValidatedConfigSync(configPath: string): Config {
+ let parsed: unknown;
+ try {
+ const fileContent = readFileSync(configPath, "utf-8");
+ parsed = JSON.parse(fileContent) as unknown;
+ } catch (error) {
+ throw new ConfigValidationError(
+ `Failed to read config file at ${configPath}: ${
+ error instanceof Error ? error.message : "Unknown error"
+ }`,
+ [
+ {
+ path: configPath,
+ message: "Could not read or parse JSON",
+ },
+ ]
+ );
+ }
+
+ const result = validateBenchmarkConfig(parsed);
+ if (!result.config) {
+ throw new ConfigValidationError(
+ `Invalid benchmark config at ${configPath}`,
+ result.issues
+ );
+ }
+
+ return result.config;
+}
diff --git a/packages/cookiebench-cli/src/utils/constants.ts b/packages/cookiebench-cli/src/utils/constants.ts
new file mode 100644
index 0000000..4784dfd
--- /dev/null
+++ b/packages/cookiebench-cli/src/utils/constants.ts
@@ -0,0 +1,31 @@
+// CLI-specific constants
+export const DEFAULT_ITERATIONS = 5;
+export const DEFAULT_DOM_SIZE = 1500;
+export const DEFAULT_THIRD_PARTY_DOMAINS = 5;
+export const SEPARATOR_WIDTH = 80;
+export const LINE_LENGTH = 80;
+export const CLS_DECIMAL_PLACES = 3;
+
+// Score thresholds
+export const SCORE_THRESHOLD_POOR = 70;
+export const SCORE_THRESHOLD_FAIR = 90;
+
+// CLS thresholds (Core Web Vitals)
+export const CLS_THRESHOLD_GOOD = 0.1;
+export const CLS_THRESHOLD_FAIR = 0.25;
+
+// Table column widths
+export const COL_WIDTH_NAME = 25;
+export const COL_WIDTH_TYPE = 12;
+export const COL_WIDTH_SOURCE = 15;
+export const COL_WIDTH_SIZE = 10;
+export const COL_WIDTH_DURATION = 10;
+export const COL_WIDTH_TAGS = 20;
+export const COL_WIDTH_CHART_PADDING = 10;
+
+// Resource name display
+export const MAX_FILENAME_LENGTH = 23;
+export const TRUNCATED_FILENAME_LENGTH = 20;
+
+// Waterfall chart
+export const MIN_DURATION_THRESHOLD = 0.1;
diff --git a/packages/cookiebench-cli/src/utils/index.ts b/packages/cookiebench-cli/src/utils/index.ts
new file mode 100644
index 0000000..04431bf
--- /dev/null
+++ b/packages/cookiebench-cli/src/utils/index.ts
@@ -0,0 +1,30 @@
+/** biome-ignore-all lint/performance/noBarrelFile: this is a barrel file */
+import { readConfig as readConfigShared } from "@consentio/shared";
+import type { Config } from "../types";
+
+// Re-export shared constants
+// Re-export shared utilities
+export {
+ formatBytes,
+ formatTime,
+ getPackageManager,
+ HALF_SECOND,
+ KILOBYTE,
+ ONE_SECOND,
+ PERCENTAGE_DIVISOR,
+ PERCENTAGE_MULTIPLIER,
+} from "@consentio/shared";
+// Re-export local constants
+
+export * from "./constants";
+export {
+ ConfigValidationError,
+ formatConfigIssues,
+ loadValidatedConfigSync,
+ validateBenchmarkConfig,
+} from "./config-validation";
+export { findProjectRoot, resolveBenchmarkPath } from "./project-root";
+
+export function readConfig(configPath?: string): Config | null {
+ return readConfigShared(configPath);
+}
diff --git a/packages/cookiebench-cli/src/utils/logger.ts b/packages/cookiebench-cli/src/utils/logger.ts
new file mode 100644
index 0000000..9c83c7d
--- /dev/null
+++ b/packages/cookiebench-cli/src/utils/logger.ts
@@ -0,0 +1,162 @@
+import { createLogger, type Logger } from "@c15t/logger";
+import { log, note, outro } from "@clack/prompts";
+import { inspect } from "node:util";
+import color from "picocolors";
+
+// Define standard log levels
+export type LogLevel = "error" | "warn" | "info" | "debug";
+export const validLogLevels: LogLevel[] = ["error", "warn", "info", "debug"];
+export type CliLogger = Logger & CliExtensions;
+
+// Define CLI-specific extension levels with their method signatures
+export type CliExtensions = {
+ message: (message: string, ...args: unknown[]) => void;
+ note: (message: string, title?: string) => void;
+ outro: (message: string) => void;
+ step: (message: string) => void;
+ clear: () => void;
+};
+
+const formatArgs = (args: unknown[]): string => {
+ if (args.length === 0) {
+ return "";
+ }
+ return `\n${args
+ .map((arg) => {
+ try {
+ return ` - ${JSON.stringify(arg, null, 2)}`;
+ } catch {
+ // Fallback to util.inspect for circular references or other serialization errors
+ return ` - ${inspect(arg, { depth: null })}`;
+ }
+ })
+ .join("\n")}`;
+};
+
+/**
+ * Formats a log message with appropriate styling based on log level
+ *
+ * @param logLevel - The log level to format for
+ * @param message - The message to format
+ * @param args - Additional arguments to format
+ * @returns The formatted message string
+ */
+export const formatLogMessage = (
+ logLevel: LogLevel | string,
+ message: unknown,
+ args: unknown[] = []
+): string => {
+ const messageStr = typeof message === "string" ? message : String(message);
+ const formattedArgs = formatArgs(args);
+
+ switch (logLevel) {
+ case "error": {
+ return `${color.bgRed(color.black(" error "))} ${messageStr}${formattedArgs}`;
+ }
+ case "warn": {
+ return `${color.bgYellow(color.black(" warning "))} ${messageStr}${formattedArgs}`;
+ }
+ case "info": {
+ return `${color.bgCyan(color.black(" info "))} ${messageStr}${formattedArgs}`;
+ }
+ case "debug": {
+ return `${color.bgBlack(color.white(" debug "))} ${messageStr}${formattedArgs}`;
+ }
+ case "success": {
+ return `${color.bgGreen(color.white(" success "))} ${messageStr}${formattedArgs}`;
+ }
+ default: {
+ // Handle unexpected levels
+ const levelStr = String(logLevel);
+ return `[${levelStr.toUpperCase()}] ${messageStr}${formattedArgs}`;
+ }
+ }
+};
+
+/**
+ * Logs a message with the appropriate clack prompt styling
+ * Can be used before logger initialization
+ *
+ * @param logLevel - The log level to use
+ * @param message - The message to log
+ * @param args - Additional arguments to include
+ */
+export const logMessage = (
+ logLevel: LogLevel | "success" | string,
+ message: unknown,
+ ...args: unknown[]
+): void => {
+ const formattedMessage = formatLogMessage(logLevel, message, args);
+
+ switch (logLevel) {
+ case "error":
+ log.error(formattedMessage);
+ break;
+ case "warn":
+ log.warn(formattedMessage);
+ break;
+ case "info":
+ case "debug":
+ log.info(formattedMessage);
+ break;
+ case "success":
+ log.success(formattedMessage);
+ break;
+ default:
+ log.message(formattedMessage);
+ }
+};
+
+// This function creates a logger instance based on the provided level
+// It includes the custom log handler for clack integration.
+export const createCliLogger = (level: LogLevel = "info"): CliLogger => {
+ // Create the base logger with standard levels
+ const baseLogger = createLogger({
+ level,
+ appName: "cookiebench",
+ log: (
+ logLevel: LogLevel | "success",
+ message: string,
+ ...args: unknown[]
+ ) => {
+ // Level filtering is primarily handled by the createLogger factory's level setting.
+ // This function now just focuses on routing output.
+ logMessage(logLevel, message, ...args);
+ },
+ });
+
+ // Extend the logger with CLI-specific methods
+ const extendedLogger = baseLogger as CliLogger;
+
+ // Add message method (plain text without prefix)
+ extendedLogger.message = (message: string) => {
+ log.message(message);
+ };
+
+ // Add note method (creates a note box)
+ extendedLogger.note = (message: string, title?: string) => {
+ const messageStr = typeof message === "string" ? message : String(message);
+ note(messageStr, title);
+ };
+
+ // Add step method
+ extendedLogger.step = (message: string) => {
+ log.step(message);
+ };
+
+ // Add outro method (uses plain message)
+ extendedLogger.outro = (message: string) => {
+ outro(message);
+ };
+
+ // Add clear method
+ extendedLogger.clear = () => {
+ // biome-ignore lint/suspicious/noConsole: this is a CLI tool
+ console.clear();
+ };
+
+ return extendedLogger;
+};
+
+// Export a default logger instance
+export const logger = createCliLogger();
diff --git a/packages/cookiebench-cli/src/utils/project-root.ts b/packages/cookiebench-cli/src/utils/project-root.ts
new file mode 100644
index 0000000..7acc6c3
--- /dev/null
+++ b/packages/cookiebench-cli/src/utils/project-root.ts
@@ -0,0 +1,45 @@
+import { existsSync } from "node:fs";
+import { dirname, isAbsolute, join } from "node:path";
+
+export function findProjectRoot(startDir = process.cwd()): string {
+ let currentDir = startDir;
+
+ while (currentDir !== dirname(currentDir)) {
+ const hasWorkspaceFile = existsSync(
+ join(currentDir, "pnpm-workspace.yaml")
+ );
+ const hasBenchmarksDir = existsSync(join(currentDir, "benchmarks"));
+ const hasPackagesDir = existsSync(join(currentDir, "packages"));
+
+ if (hasWorkspaceFile && hasBenchmarksDir && hasPackagesDir) {
+ return currentDir;
+ }
+
+ currentDir = dirname(currentDir);
+ }
+
+ return startDir;
+}
+
+export function resolveBenchmarkPath(
+ projectRoot: string,
+ appPath: string
+): string {
+ if (isAbsolute(appPath)) {
+ return appPath;
+ }
+
+ const directPath = join(projectRoot, appPath);
+ if (existsSync(join(directPath, "config.json"))) {
+ return directPath;
+ }
+
+ const fallbackPath = join(projectRoot, "benchmarks", appPath);
+ if (existsSync(join(fallbackPath, "config.json"))) {
+ return fallbackPath;
+ }
+
+ throw new Error(
+ `Could not resolve benchmark path for "${appPath}". Expected config.json in ${directPath} or ${fallbackPath}.`
+ );
+}
diff --git a/packages/cookiebench-cli/src/utils/scoring.ts b/packages/cookiebench-cli/src/utils/scoring.ts
new file mode 100644
index 0000000..4c9f0a2
--- /dev/null
+++ b/packages/cookiebench-cli/src/utils/scoring.ts
@@ -0,0 +1,1405 @@
+/** biome-ignore-all lint/style/noMagicNumbers: scoring requires many numeric thresholds */
+/** biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: scoring logic is complex */
+/** biome-ignore-all lint/style/noNestedTernary: scoring logic uses ternary expressions */
+import Table from "cli-table3";
+import type { BenchmarkScores } from "../types";
+import { formatBytes, formatTime } from "./index";
+
+// Type definitions for better type safety
+type AppData = {
+ id?: number;
+ name: string;
+ baseline: boolean;
+ company: string | null;
+ techStack: string;
+ source: string | null;
+ tags: string | null;
+};
+
+type MetricsData = {
+ fcp: number;
+ lcp: number;
+ cls: number;
+ tti: number;
+ tbt: number;
+ totalSize: number;
+ thirdPartySize: number;
+ thirdPartyRequestCount?: number;
+ bannerVisibilityTime: number;
+ viewportCoverage: number;
+ resourceCount?: number;
+ scriptLoadTime?: number;
+ isBundled?: boolean;
+ isIIFE?: boolean;
+ // NEW: Perfume.js enhanced metrics
+ timeToFirstByte?: number;
+ interactionToNextPaint?: number | null;
+ networkInformation?: {
+ effectiveType: string;
+ downlink: number;
+ rtt: number;
+ saveData: boolean;
+ };
+};
+
+type ResourceData = {
+ size: number;
+ isThirdParty: boolean;
+};
+
+type BenchmarkData = {
+ bannerRenderTime?: number;
+ bannerInteractionTime?: number;
+ layoutShift?: number;
+};
+
+export type TechStackData = {
+ languages: string[];
+ frameworks: string[];
+ bundler: string;
+ bundleType: string;
+ packageManager: string;
+ typescript: boolean;
+};
+
+type CompanyData = {
+ name: string;
+ avatar: string;
+};
+
+type SourceData = {
+ license?: string;
+ github?: string;
+ repository?: string;
+ openSource?: boolean;
+ type?: string;
+};
+
+type CategoryScores = {
+ performance: number;
+ bundleStrategy: number;
+ networkImpact: number;
+ transparency: number;
+ userExperience: number;
+};
+
+type ScoreDetail = {
+ metric: string;
+ value: string | number;
+ score: number;
+ maxScore: number;
+ reason: string;
+};
+
+type ScoreWeights = {
+ performance: number;
+ bundleStrategy: number;
+ networkImpact: number;
+ transparency: number;
+ userExperience: number;
+};
+
+// Default scoring weights
+export const DEFAULT_SCORE_WEIGHTS: ScoreWeights = {
+ performance: 0.4, // 40% - Core Web Vitals and performance metrics
+ bundleStrategy: 0.25, // 25% - First-party vs third-party, bundling approach
+ networkImpact: 0.2, // 20% - Network requests, bundle size, third-party impact
+ transparency: 0.1, // 10% - Open source, company info, tech stack disclosure
+ userExperience: 0.05, // 5% - Layout stability, interaction responsiveness
+};
+
+// Helper function to determine if a solution is open source
+function isOpenSourceSolution(
+ app: AppData,
+ sourceInfo: SourceData | null,
+ tags: string
+): boolean {
+ // Check source information for open source indicators
+ if (sourceInfo) {
+ // Check for open source license
+ const license = sourceInfo.license?.toLowerCase() || "";
+ const openSourceLicenses = [
+ "mit",
+ "apache",
+ "gpl",
+ "bsd",
+ "lgpl",
+ "mpl",
+ "isc",
+ "unlicense",
+ "cc0",
+ "wtfpl",
+ "zlib",
+ "artistic",
+ "epl",
+ "cddl",
+ ];
+
+ if (openSourceLicenses.some((lic) => license.includes(lic))) {
+ return true;
+ }
+
+ // Check GitHub repository
+ if (sourceInfo.github || sourceInfo.repository?.includes("github.com")) {
+ return true;
+ }
+
+ // Check if explicitly marked as open source
+ if (sourceInfo.openSource === true || sourceInfo.type === "open-source") {
+ return true;
+ }
+ }
+
+ // Check tags for open source indicators
+ const lowerTags = tags.toLowerCase();
+ if (
+ lowerTags.includes("open source") ||
+ lowerTags.includes("opensource") ||
+ lowerTags.includes("oss")
+ ) {
+ return true;
+ }
+
+ // Check app name for known open source solutions
+ const appName = app.name.toLowerCase();
+ const knownOpenSource = [
+ "c15t",
+ "cookieconsent",
+ "klaro",
+ "tarteaucitron",
+ "osano",
+ "react-cookie-consent",
+ "vanilla-cookieconsent",
+ "baseline",
+ ];
+
+ if (knownOpenSource.some((name) => appName.includes(name))) {
+ return true;
+ }
+
+ return false;
+}
+
+// Helper function to parse tech stack
+function parseTechStack(techStackJson: string): TechStackData {
+ try {
+ const techStack = JSON.parse(techStackJson);
+ return {
+ languages: techStack.languages || [],
+ frameworks: techStack.frameworks || [],
+ bundler: techStack.bundler || "unknown",
+ bundleType: techStack.bundleType || "unknown",
+ packageManager: techStack.packageManager || "unknown",
+ typescript: techStack.typescript ?? false,
+ };
+ } catch {
+ return {
+ languages: [],
+ frameworks: [],
+ bundler: "unknown",
+ bundleType: "unknown",
+ packageManager: "unknown",
+ typescript: false,
+ };
+ }
+}
+
+// Helper function to parse company info
+function parseCompany(companyJson: string | null): CompanyData | null {
+ if (!companyJson) {
+ return null;
+ }
+ try {
+ return JSON.parse(companyJson);
+ } catch {
+ return null;
+ }
+}
+
+// Helper function to parse source info
+function parseSource(sourceJson: string | null): SourceData | null {
+ if (!sourceJson) {
+ return null;
+ }
+ try {
+ return JSON.parse(sourceJson);
+ } catch {
+ return null;
+ }
+}
+
+// Calculate performance score (out of 100) with more sensitive thresholds
+function calculatePerformanceScore(metrics: MetricsData): {
+ score: number;
+ maxScore: number;
+ details: ScoreDetail[];
+} {
+ const details: ScoreDetail[] = [];
+ let totalScore = 0;
+ const maxScore = 100;
+
+ // Ensure all metrics are finite numbers
+ const fcp = Number.isFinite(metrics.fcp) ? metrics.fcp : 0;
+ const lcp = Number.isFinite(metrics.lcp) ? metrics.lcp : 0;
+ const cls = Number.isFinite(metrics.cls) ? metrics.cls : 0;
+ const tti = Number.isFinite(metrics.tti) ? metrics.tti : 0;
+ const tbt = Number.isFinite(metrics.tbt) ? metrics.tbt : 0;
+
+ // FCP Score (15 points) - More sensitive for fast sites
+ const fcpScore =
+ fcp <= 50 ? 15 : fcp <= 100 ? 13 : fcp <= 200 ? 10 : fcp <= 500 ? 7 : 3;
+ totalScore += fcpScore;
+ details.push({
+ metric: "First Contentful Paint",
+ value: formatTime(fcp),
+ score: fcpScore,
+ maxScore: 15,
+ reason:
+ fcp <= 50
+ ? "Excellent"
+ : fcp <= 100
+ ? "Very Good"
+ : fcp <= 200
+ ? "Good"
+ : fcp <= 500
+ ? "Fair"
+ : "Poor",
+ });
+
+ // LCP Score (20 points) - More sensitive for banner rendering
+ const lcpScore =
+ lcp <= 100 ? 20 : lcp <= 300 ? 16 : lcp <= 500 ? 12 : lcp <= 1000 ? 8 : 4;
+ totalScore += lcpScore;
+ details.push({
+ metric: "Largest Contentful Paint",
+ value: formatTime(lcp),
+ score: lcpScore,
+ maxScore: 20,
+ reason:
+ lcp <= 100
+ ? "Excellent"
+ : lcp <= 300
+ ? "Very Good"
+ : lcp <= 500
+ ? "Good"
+ : lcp <= 1000
+ ? "Fair"
+ : "Poor",
+ });
+
+ // CLS Score (20 points)
+ const clsScore =
+ cls <= 0.01 ? 20 : cls <= 0.05 ? 15 : cls <= 0.1 ? 10 : cls <= 0.25 ? 5 : 0;
+ totalScore += clsScore;
+ details.push({
+ metric: "Cumulative Layout Shift",
+ value: cls.toFixed(3),
+ score: clsScore,
+ maxScore: 20,
+ reason:
+ cls <= 0.01
+ ? "Excellent"
+ : cls <= 0.05
+ ? "Very Good"
+ : cls <= 0.1
+ ? "Good"
+ : cls <= 0.25
+ ? "Fair"
+ : "Poor",
+ });
+
+ // TTI Score (15 points) - Cookie banners should be interactive quickly
+ const ttiScore =
+ tti <= 1000 ? 15 : tti <= 1500 ? 12 : tti <= 2000 ? 8 : tti <= 3000 ? 4 : 0;
+ totalScore += ttiScore;
+ details.push({
+ metric: "Time to Interactive",
+ value: formatTime(tti),
+ score: ttiScore,
+ maxScore: 15,
+ reason:
+ tti <= 1000
+ ? "Excellent"
+ : tti <= 1500
+ ? "Very Good"
+ : tti <= 2000
+ ? "Good"
+ : tti <= 3000
+ ? "Fair"
+ : "Poor",
+ });
+
+ // TBT Score (10 points) - Main thread blocking
+ const tbtScore = tbt <= 50 ? 10 : tbt <= 200 ? 7 : tbt <= 500 ? 3 : 0;
+ totalScore += tbtScore;
+ details.push({
+ metric: "Total Blocking Time",
+ value: formatTime(tbt),
+ score: tbtScore,
+ maxScore: 10,
+ reason:
+ tbt <= 50
+ ? "Excellent"
+ : tbt <= 200
+ ? "Good"
+ : tbt <= 500
+ ? "Fair"
+ : "Poor",
+ });
+
+ // TTFB Score (10 points) - Server response time
+ const ttfb =
+ Number.isFinite(metrics.timeToFirstByte) &&
+ metrics.timeToFirstByte !== undefined
+ ? metrics.timeToFirstByte
+ : 0;
+ const ttfbScore =
+ ttfb <= 100 ? 10 : ttfb <= 200 ? 8 : ttfb <= 400 ? 5 : ttfb <= 600 ? 3 : 0;
+ totalScore += ttfbScore;
+ details.push({
+ metric: "Time to First Byte",
+ value: formatTime(ttfb),
+ score: ttfbScore,
+ maxScore: 10,
+ reason:
+ ttfb <= 100
+ ? "Excellent"
+ : ttfb <= 200
+ ? "Good"
+ : ttfb <= 400
+ ? "Fair"
+ : ttfb <= 600
+ ? "Poor"
+ : "Very Poor",
+ });
+
+ // INP Score (10 points) - Interaction responsiveness (replaces/complements FID)
+ const inp =
+ Number.isFinite(metrics.interactionToNextPaint) &&
+ metrics.interactionToNextPaint !== undefined &&
+ metrics.interactionToNextPaint !== null
+ ? metrics.interactionToNextPaint
+ : null;
+ const inpScore =
+ inp === null ? 5 : inp <= 200 ? 10 : inp <= 500 ? 7 : inp <= 1000 ? 4 : 0;
+ totalScore += inpScore;
+ details.push({
+ metric: "Interaction to Next Paint",
+ value: inp === null ? "N/A" : formatTime(inp),
+ score: inpScore,
+ maxScore: 10,
+ reason:
+ inp === null
+ ? "No interactions detected"
+ : inp <= 200
+ ? "Excellent"
+ : inp <= 500
+ ? "Good"
+ : inp <= 1000
+ ? "Fair"
+ : "Poor",
+ });
+
+ return { score: totalScore, maxScore, details };
+}
+
+// Calculate bundle strategy score (out of 100)
+function calculateBundleScore(
+ metrics: MetricsData,
+ techStack: TechStackData,
+ resourceData: ResourceData[]
+): {
+ score: number;
+ maxScore: number;
+ details: ScoreDetail[];
+} {
+ const details: ScoreDetail[] = [];
+ let totalScore = 0;
+ const maxScore = 100;
+
+ // Bundle strategy (40 points)
+ const bundleScore = metrics.isBundled ? 40 : metrics.isIIFE ? 20 : 10;
+ totalScore += bundleScore;
+ details.push({
+ metric: "Bundle Strategy",
+ value: metrics.isBundled ? "Bundled" : metrics.isIIFE ? "IIFE" : "Unknown",
+ score: bundleScore,
+ maxScore: 40,
+ reason: metrics.isBundled
+ ? "First-party bundled"
+ : metrics.isIIFE
+ ? "External script"
+ : "Unknown strategy",
+ });
+
+ // Third-party dependency ratio (30 points)
+ const thirdPartyResources = resourceData.filter((r) => r.isThirdParty);
+ const thirdPartyRatio =
+ thirdPartyResources.length / Math.max(resourceData.length, 1);
+ const thirdPartyScore =
+ thirdPartyRatio <= 0.1
+ ? 30
+ : thirdPartyRatio <= 0.3
+ ? 20
+ : thirdPartyRatio <= 0.5
+ ? 10
+ : 0;
+ totalScore += thirdPartyScore;
+ details.push({
+ metric: "Third-party Dependencies",
+ value: `${thirdPartyResources.length}/${resourceData.length}`,
+ score: thirdPartyScore,
+ maxScore: 30,
+ reason:
+ thirdPartyRatio <= 0.1
+ ? "Minimal third-party"
+ : thirdPartyRatio <= 0.3
+ ? "Low third-party"
+ : thirdPartyRatio <= 0.5
+ ? "Moderate third-party"
+ : "Heavy third-party",
+ });
+
+ // Modern bundler (20 points)
+ const modernBundlers = [
+ "webpack",
+ "vite",
+ "rollup",
+ "esbuild",
+ "turbopack",
+ "rspack",
+ "rslib",
+ "nextjs",
+ ];
+ const bundlerScore =
+ techStack && modernBundlers.includes(techStack.bundler.toLowerCase())
+ ? 20
+ : 10;
+ totalScore += bundlerScore;
+ details.push({
+ metric: "Bundler",
+ value: techStack?.bundler || "Unknown",
+ score: bundlerScore,
+ maxScore: 20,
+ reason:
+ techStack && modernBundlers.includes(techStack.bundler.toLowerCase())
+ ? "Modern bundler"
+ : "Legacy/unknown bundler",
+ });
+
+ // TypeScript usage (10 points)
+ const tsScore = techStack?.typescript ? 10 : 0;
+ totalScore += tsScore;
+ details.push({
+ metric: "TypeScript",
+ value: techStack?.typescript ? "Yes" : "No",
+ score: tsScore,
+ maxScore: 10,
+ reason: techStack?.typescript ? "Type safety" : "No type safety",
+ });
+
+ return { score: totalScore, maxScore, details };
+}
+
+// Calculate network impact score (out of 100)
+function calculateNetworkScore(
+ metrics: MetricsData,
+ // Reserved for future resource-based scoring.
+ _resourceData: ResourceData[]
+): {
+ score: number;
+ maxScore: number;
+ details: ScoreDetail[];
+} {
+ const details: ScoreDetail[] = [];
+ let totalScore = 0;
+ const maxScore = 100;
+
+ // Ensure metrics are finite numbers
+ const totalSize = Number.isFinite(metrics.totalSize) ? metrics.totalSize : 0;
+ const thirdPartySize = Number.isFinite(metrics.thirdPartySize)
+ ? metrics.thirdPartySize
+ : 0;
+ const thirdPartyRequestCount = Number.isFinite(metrics.thirdPartyRequestCount)
+ ? metrics.thirdPartyRequestCount || 0
+ : 0;
+ const resourceCount = Number.isFinite(metrics.resourceCount)
+ ? metrics.resourceCount || 0
+ : 0;
+ const scriptLoadTime = Number.isFinite(metrics.scriptLoadTime)
+ ? metrics.scriptLoadTime || 0
+ : 0;
+
+ // Total size impact (35 points) - More sensitive for lightweight solutions
+ const totalSizeKB = totalSize / 1024;
+ const sizeScore =
+ totalSizeKB <= 50
+ ? 35
+ : totalSizeKB <= 100
+ ? 25
+ : totalSizeKB <= 200
+ ? 15
+ : totalSizeKB <= 500
+ ? 10
+ : 5;
+ totalScore += sizeScore;
+ details.push({
+ metric: "Total Bundle Size",
+ value: formatBytes(totalSize),
+ score: sizeScore,
+ maxScore: 35,
+ reason:
+ totalSizeKB <= 50
+ ? "Ultra lightweight"
+ : totalSizeKB <= 100
+ ? "Lightweight"
+ : totalSizeKB <= 200
+ ? "Moderate"
+ : totalSizeKB <= 500
+ ? "Heavy"
+ : "Very heavy",
+ });
+
+ // Third-party size impact (25 points)
+ const thirdPartySizeKB = thirdPartySize / 1024;
+ const formattedThirdPartyRequestCount = Number.isInteger(
+ thirdPartyRequestCount
+ )
+ ? thirdPartyRequestCount.toString()
+ : thirdPartyRequestCount.toFixed(1);
+
+ let thirdPartyNetworkScore = 25;
+ let thirdPartyReason = "Zero third-party";
+ if (thirdPartyRequestCount > 0) {
+ if (thirdPartySizeKB > 0) {
+ thirdPartyNetworkScore =
+ thirdPartySizeKB <= 50 ? 15 : thirdPartySizeKB <= 100 ? 10 : 5;
+ thirdPartyReason =
+ thirdPartySizeKB <= 50
+ ? "Minimal third-party"
+ : thirdPartySizeKB <= 100
+ ? "Moderate third-party"
+ : "Heavy third-party";
+ } else {
+ // Size can be unavailable for cross-origin responses without Timing-Allow-Origin.
+ thirdPartyNetworkScore =
+ thirdPartyRequestCount <= 2 ? 15 : thirdPartyRequestCount <= 5 ? 10 : 5;
+ thirdPartyReason =
+ thirdPartyRequestCount <= 2
+ ? "Third-party traffic detected (size unavailable)"
+ : thirdPartyRequestCount <= 5
+ ? "Multiple third-party requests (size unavailable)"
+ : "Heavy third-party traffic (size unavailable)";
+ }
+ } else if (thirdPartySizeKB > 0) {
+ thirdPartyNetworkScore =
+ thirdPartySizeKB <= 50 ? 15 : thirdPartySizeKB <= 100 ? 10 : 5;
+ thirdPartyReason =
+ thirdPartySizeKB <= 50
+ ? "Minimal third-party"
+ : thirdPartySizeKB <= 100
+ ? "Moderate third-party"
+ : "Heavy third-party";
+ }
+ totalScore += thirdPartyNetworkScore;
+ details.push({
+ metric: "Third-party Size",
+ value:
+ thirdPartyRequestCount > 0
+ ? `${formatBytes(thirdPartySize)} (${formattedThirdPartyRequestCount} req)`
+ : formatBytes(thirdPartySize),
+ score: thirdPartyNetworkScore,
+ maxScore: 25,
+ reason: thirdPartyReason,
+ });
+
+ // Network requests (25 points)
+ const requestScore =
+ resourceCount <= 3
+ ? 25
+ : resourceCount <= 5
+ ? 20
+ : resourceCount <= 10
+ ? 15
+ : resourceCount <= 15
+ ? 10
+ : 5;
+ totalScore += requestScore;
+ details.push({
+ metric: "Network Requests",
+ value: resourceCount.toString(),
+ score: requestScore,
+ maxScore: 25,
+ reason:
+ resourceCount <= 3
+ ? "Minimal requests"
+ : resourceCount <= 5
+ ? "Low requests"
+ : resourceCount <= 10
+ ? "Moderate requests"
+ : resourceCount <= 15
+ ? "Many requests"
+ : "Too many requests",
+ });
+
+ // Script load time (15 points)
+ const scriptScore =
+ scriptLoadTime <= 50
+ ? 15
+ : scriptLoadTime <= 100
+ ? 10
+ : scriptLoadTime <= 200
+ ? 5
+ : 0;
+ totalScore += scriptScore;
+ details.push({
+ metric: "Script Load Time",
+ value: formatTime(scriptLoadTime),
+ score: scriptScore,
+ maxScore: 15,
+ reason:
+ scriptLoadTime <= 50
+ ? "Very fast loading"
+ : scriptLoadTime <= 100
+ ? "Fast loading"
+ : scriptLoadTime <= 200
+ ? "Moderate loading"
+ : "Slow loading",
+ });
+
+ return { score: totalScore, maxScore, details };
+}
+
+// Calculate transparency score (out of 100)
+function calculateTransparencyScore(
+ isOpenSource: boolean,
+ company: CompanyData | null,
+ techStack: TechStackData
+): {
+ score: number;
+ maxScore: number;
+ details: ScoreDetail[];
+} {
+ const details: ScoreDetail[] = [];
+ let totalScore = 0;
+ const maxScore = 100;
+
+ // Open source bonus (60 points)
+ const openSourceScore = isOpenSource ? 60 : 0;
+ totalScore += openSourceScore;
+ details.push({
+ metric: "Open Source",
+ value: isOpenSource ? "Yes" : "No",
+ score: openSourceScore,
+ maxScore: 60,
+ reason: isOpenSource ? "Transparent & auditable" : "Proprietary solution",
+ });
+
+ // Company transparency (25 points)
+ const companyScore = company ? 25 : 10;
+ totalScore += companyScore;
+ details.push({
+ metric: "Company Info",
+ value: company ? "Available" : "Limited",
+ score: companyScore,
+ maxScore: 25,
+ reason: company ? "Clear attribution" : "Limited transparency",
+ });
+
+ // Tech stack disclosure (15 points)
+ const techScore = techStack && techStack.bundler !== "unknown" ? 15 : 5;
+ totalScore += techScore;
+ details.push({
+ metric: "Tech Stack",
+ value: techStack ? "Disclosed" : "Unknown",
+ score: techScore,
+ maxScore: 15,
+ reason:
+ techStack && techStack.bundler !== "unknown"
+ ? "Technical transparency"
+ : "Limited tech info",
+ });
+
+ return { score: totalScore, maxScore, details };
+}
+
+// Calculate user experience score (out of 100)
+function calculateUXScore(
+ metrics: MetricsData,
+ benchmarkData: BenchmarkData
+): {
+ score: number;
+ maxScore: number;
+ details: ScoreDetail[];
+} {
+ const details: ScoreDetail[] = [];
+ let totalScore = 0;
+ const maxScore = 100;
+
+ // Layout stability (40 points)
+ const cls = Number.isFinite(metrics.cls) ? metrics.cls : 0;
+ const clsScore =
+ cls <= 0.01
+ ? 40
+ : cls <= 0.05
+ ? 30
+ : cls <= 0.1
+ ? 20
+ : cls <= 0.25
+ ? 10
+ : 0;
+ totalScore += clsScore;
+ details.push({
+ metric: "Layout Stability",
+ value: cls.toFixed(3),
+ score: clsScore,
+ maxScore: 40,
+ reason:
+ cls <= 0.01
+ ? "No layout shifts"
+ : cls <= 0.05
+ ? "Minimal shifts"
+ : cls <= 0.1
+ ? "Minor shifts"
+ : cls <= 0.25
+ ? "Some shifts"
+ : "Significant shifts",
+ });
+
+ // Banner visible time (35 points) — scored from user-visible time only, not DOM presence
+ const bannerVisibleTimeMs = Number.isFinite(benchmarkData.bannerRenderTime)
+ ? benchmarkData.bannerRenderTime || 0
+ : metrics.bannerVisibilityTime || 0;
+ const renderScore =
+ bannerVisibleTimeMs <= 25
+ ? 35
+ : bannerVisibleTimeMs <= 50
+ ? 25
+ : bannerVisibleTimeMs <= 100
+ ? 15
+ : bannerVisibleTimeMs <= 200
+ ? 10
+ : 5;
+ totalScore += renderScore;
+ details.push({
+ metric: "Banner visible time",
+ value: formatTime(bannerVisibleTimeMs),
+ score: renderScore,
+ maxScore: 35,
+ reason:
+ bannerVisibleTimeMs <= 25
+ ? "Instant visibility"
+ : bannerVisibleTimeMs <= 50
+ ? "Very fast visibility"
+ : bannerVisibleTimeMs <= 100
+ ? "Fast visibility"
+ : bannerVisibleTimeMs <= 200
+ ? "Moderate visibility"
+ : "Slow visibility",
+ });
+
+ // Viewport coverage impact (25 points)
+ const coverage = Number.isFinite(metrics.viewportCoverage)
+ ? metrics.viewportCoverage
+ : 0;
+ const coverageScore =
+ coverage <= 10
+ ? 25
+ : coverage <= 20
+ ? 20
+ : coverage <= 30
+ ? 15
+ : coverage <= 50
+ ? 10
+ : 5;
+ totalScore += coverageScore;
+ details.push({
+ metric: "Viewport Coverage",
+ value: `${coverage.toFixed(1)}%`,
+ score: coverageScore,
+ maxScore: 25,
+ reason:
+ coverage <= 10
+ ? "Minimal intrusion"
+ : coverage <= 20
+ ? "Low intrusion"
+ : coverage <= 30
+ ? "Moderate intrusion"
+ : coverage <= 50
+ ? "High intrusion"
+ : "Very intrusive",
+ });
+
+ return { score: totalScore, maxScore, details };
+}
+
+// Generate insights based on scores
+function generateInsights(
+ categoryScores: CategoryScores,
+ metrics: MetricsData,
+ resourceData: ResourceData[],
+ isOpenSource: boolean
+): string[] {
+ const insights: string[] = [];
+
+ // Performance insights
+ if (categoryScores.performance >= 90) {
+ insights.push(
+ "Outstanding performance metrics across all Core Web Vitals."
+ );
+ } else if (categoryScores.performance < 60) {
+ insights.push(
+ "Performance optimization needed - focus on reducing load times and layout shifts."
+ );
+ }
+
+ // Bundle strategy insights
+ if (metrics.isBundled) {
+ insights.push(
+ "Excellent bundle strategy - first-party bundling reduces network overhead and improves reliability."
+ );
+ } else if (metrics.isIIFE) {
+ insights.push(
+ "Consider bundling strategy to reduce third-party dependencies and improve performance."
+ );
+ }
+
+ // Open source insights
+ if (isOpenSource) {
+ insights.push(
+ "Open source solution provides transparency and community-driven development."
+ );
+ } else {
+ insights.push(
+ "Consider open source alternatives for better transparency and community support."
+ );
+ }
+
+ // Network insights
+ const thirdPartyResources = resourceData.filter((r) => r.isThirdParty);
+ if (thirdPartyResources.length === 0) {
+ insights.push(
+ "Zero third-party dependencies minimize privacy concerns and improve reliability."
+ );
+ } else if (thirdPartyResources.length > 5) {
+ insights.push(
+ "High number of third-party requests may impact performance and privacy."
+ );
+ }
+
+ // Network quality context (from Perfume.js)
+ if (metrics.networkInformation) {
+ const netInfo = metrics.networkInformation;
+ insights.push(
+ `Tested on ${netInfo.effectiveType} connection (${netInfo.downlink} Mbps, ${netInfo.rtt}ms RTT).`
+ );
+ }
+
+ // TTFB insights
+ if (metrics.timeToFirstByte) {
+ if (metrics.timeToFirstByte <= 100) {
+ insights.push(
+ "Excellent server response time (TTFB) ensures fast initial loading."
+ );
+ } else if (metrics.timeToFirstByte > 600) {
+ insights.push(
+ "Server response time (TTFB) is slow - consider CDN or server optimization."
+ );
+ }
+ }
+
+ // INP insights (if interactions detected)
+ if (
+ metrics.interactionToNextPaint !== null &&
+ metrics.interactionToNextPaint !== undefined &&
+ metrics.interactionToNextPaint > 500
+ ) {
+ insights.push(
+ "Interaction responsiveness (INP) needs improvement for better user experience."
+ );
+ }
+
+ return insights;
+}
+
+// Generate recommendations based on scores
+function generateRecommendations(
+ categoryScores: CategoryScores,
+ metrics: MetricsData,
+ resourceData: ResourceData[]
+): string[] {
+ const recommendations: string[] = [];
+
+ // Performance recommendations
+ if (categoryScores.performance < 80) {
+ if (metrics.fcp > 100) {
+ recommendations.push(
+ "Optimize First Contentful Paint by reducing render-blocking resources."
+ );
+ }
+ if (metrics.lcp > 300) {
+ recommendations.push(
+ "Improve Largest Contentful Paint by optimizing critical resource loading."
+ );
+ }
+ if (metrics.cls > 0.05) {
+ recommendations.push(
+ "Reduce Cumulative Layout Shift by reserving space for dynamic content."
+ );
+ }
+ if (metrics.tbt > 50) {
+ recommendations.push(
+ "Reduce Total Blocking Time by optimizing JavaScript execution."
+ );
+ }
+ }
+
+ // TTFB and INP recommendations
+ if (metrics.timeToFirstByte && metrics.timeToFirstByte > 200) {
+ recommendations.push(
+ "Improve Time to First Byte (TTFB) through server optimization, CDN usage, or caching strategies."
+ );
+ }
+ if (metrics.interactionToNextPaint && metrics.interactionToNextPaint > 200) {
+ recommendations.push(
+ "Optimize Interaction to Next Paint (INP) by reducing JavaScript execution time and improving event handler performance."
+ );
+ }
+
+ // Bundle strategy recommendations
+ if (categoryScores.bundleStrategy < 70) {
+ if (!metrics.isBundled) {
+ recommendations.push(
+ "Consider bundling cookie consent code with your main application bundle."
+ );
+ }
+ const thirdPartyRatio =
+ resourceData.filter((r) => r.isThirdParty).length /
+ Math.max(resourceData.length, 1);
+ if (thirdPartyRatio > 0.3) {
+ recommendations.push(
+ "Reduce third-party dependencies to improve reliability and performance."
+ );
+ }
+ }
+
+ // Network recommendations
+ if (categoryScores.networkImpact < 70) {
+ if (metrics.totalSize > 100 * 1024) {
+ recommendations.push(
+ "Reduce bundle size through code splitting and tree shaking."
+ );
+ }
+ if (
+ metrics.thirdPartySize > 0 ||
+ (metrics.thirdPartyRequestCount ?? 0) > 0
+ ) {
+ recommendations.push(
+ "Eliminate or reduce third-party resources for better performance."
+ );
+ }
+ }
+
+ return recommendations;
+}
+
+// Get score grade based on total score
+function getScoreGrade(score: number): BenchmarkScores["grade"] {
+ if (score >= 90) {
+ return "Excellent";
+ }
+ if (score >= 80) {
+ return "Good";
+ }
+ if (score >= 70) {
+ return "Fair";
+ }
+ if (score >= 60) {
+ return "Poor";
+ }
+ return "Critical";
+}
+
+// Get category status based on score percentage
+function getCategoryStatus(
+ score: number,
+ maxScore: number
+): "excellent" | "good" | "fair" | "poor" | "critical" {
+ const percentage = (score / maxScore) * 100;
+ if (percentage >= 90) {
+ return "excellent";
+ }
+ if (percentage >= 75) {
+ return "good";
+ }
+ if (percentage >= 60) {
+ return "fair";
+ }
+ if (percentage >= 40) {
+ return "poor";
+ }
+ return "critical";
+}
+
+// Main scoring function with CLI-compatible interface
+// biome-ignore lint/nursery/useMaxParams: legacy API with multiple metric groups
+export function calculateScores(
+ metrics: {
+ fcp: number;
+ lcp: number;
+ cls: number;
+ tbt: number;
+ tti: number;
+ timeToFirstByte?: number;
+ interactionToNextPaint?: number | null;
+ },
+ bundleMetrics: {
+ totalSize: number;
+ jsSize: number;
+ cssSize: number;
+ imageSize: number;
+ fontSize: number;
+ otherSize: number;
+ },
+ networkMetrics: {
+ totalRequests: number;
+ thirdPartyRequests: number;
+ thirdPartySize: number;
+ thirdPartyDomains: number;
+ scriptLoadTime?: number;
+ },
+ /** Must use user-visible time (opacity > 0.5), not DOM presence time, for scoring. */
+ transparencyMetrics: {
+ cookieBannerDetected: boolean;
+ /** User-visible time (ms). Do not pass DOM presence time here. */
+ cookieBannerVisibleTimeMs: number | null;
+ cookieBannerCoverage: number;
+ },
+ userExperienceMetrics: {
+ domSize: number;
+ mainThreadBlocking: number;
+ layoutShifts: number;
+ },
+ isBaseline = false,
+ appData?: AppData,
+ networkInformation?: {
+ effectiveType: string;
+ downlink: number;
+ rtt: number;
+ saveData: boolean;
+ }
+): BenchmarkScores {
+ const normalizedThirdPartyRequests = Math.max(
+ 0,
+ Math.round(networkMetrics.thirdPartyRequests)
+ );
+
+ // Create app data structure
+ const app: AppData = appData || {
+ name: "unknown",
+ baseline: isBaseline,
+ company: null,
+ techStack: "{}",
+ source: null,
+ tags: null,
+ };
+
+ // Create metrics data (scoring uses user-visible time only; cookieBannerVisibleTimeMs must be user-visible)
+ const metricsData: MetricsData = {
+ fcp: metrics.fcp,
+ lcp: metrics.lcp,
+ cls: metrics.cls,
+ tti: metrics.tti,
+ tbt: metrics.tbt,
+ totalSize: bundleMetrics.totalSize,
+ thirdPartySize: networkMetrics.thirdPartySize,
+ thirdPartyRequestCount: normalizedThirdPartyRequests,
+ bannerVisibilityTime: transparencyMetrics.cookieBannerVisibleTimeMs || 0,
+ viewportCoverage: transparencyMetrics.cookieBannerCoverage * 100,
+ resourceCount: networkMetrics.totalRequests,
+ scriptLoadTime: networkMetrics.scriptLoadTime || 0,
+ isBundled: normalizedThirdPartyRequests === 0,
+ isIIFE: normalizedThirdPartyRequests > 0,
+ // NEW: Add Perfume.js metrics
+ timeToFirstByte: metrics.timeToFirstByte || 0,
+ interactionToNextPaint: metrics.interactionToNextPaint,
+ networkInformation,
+ };
+
+ // Create mock resource data
+ const resourceData: ResourceData[] = [
+ { size: bundleMetrics.jsSize, isThirdParty: false },
+ { size: bundleMetrics.cssSize, isThirdParty: false },
+ ...Array.from({ length: normalizedThirdPartyRequests }, () => ({
+ size:
+ networkMetrics.thirdPartySize /
+ Math.max(normalizedThirdPartyRequests, 1),
+ isThirdParty: true,
+ })),
+ ];
+
+ // Create benchmark data (bannerRenderTime slot holds user-visible time for scoring; do not use DOM presence)
+ const benchmarkData: BenchmarkData = {
+ bannerRenderTime: transparencyMetrics.cookieBannerVisibleTimeMs || 0,
+ bannerInteractionTime: userExperienceMetrics.mainThreadBlocking,
+ layoutShift: userExperienceMetrics.layoutShifts,
+ };
+
+ // Parse app data
+ const techStack = parseTechStack(app.techStack);
+ const company = parseCompany(app.company);
+ const sourceInfo = parseSource(app.source);
+ const tags = app.tags || "";
+ const isOpenSource = isOpenSourceSolution(app, sourceInfo, tags);
+
+ // Calculate individual category scores
+ const performanceScore = calculatePerformanceScore(metricsData);
+ const bundleScore = calculateBundleScore(
+ metricsData,
+ techStack,
+ resourceData
+ );
+ const networkScore = calculateNetworkScore(metricsData, resourceData);
+ const transparencyScore = calculateTransparencyScore(
+ isOpenSource,
+ company,
+ techStack
+ );
+ const uxScore = calculateUXScore(metricsData, benchmarkData);
+
+ // Calculate category percentages first
+ const weights = DEFAULT_SCORE_WEIGHTS;
+ const categoryScores = {
+ performance: Math.round(
+ (performanceScore.score / performanceScore.maxScore) * 100
+ ),
+ bundleStrategy: Math.round(
+ (bundleScore.score / bundleScore.maxScore) * 100
+ ),
+ networkImpact: Math.round(
+ (networkScore.score / networkScore.maxScore) * 100
+ ),
+ transparency: Math.round(
+ (transparencyScore.score / transparencyScore.maxScore) * 100
+ ),
+ userExperience: Math.round((uxScore.score / uxScore.maxScore) * 100),
+ };
+
+ // Objective performance index excludes governance/transparency scoring.
+ const objectiveWeightTotal =
+ weights.performance +
+ weights.bundleStrategy +
+ weights.networkImpact +
+ weights.userExperience;
+
+ const performanceIndex = Math.round(
+ (categoryScores.performance * weights.performance +
+ categoryScores.bundleStrategy * weights.bundleStrategy +
+ categoryScores.networkImpact * weights.networkImpact +
+ categoryScores.userExperience * weights.userExperience) /
+ objectiveWeightTotal
+ );
+ const governanceIndex = categoryScores.transparency;
+ const combinedIndex = Math.round(
+ performanceIndex * (1 - weights.transparency) +
+ governanceIndex * weights.transparency
+ );
+
+ // totalScore is now the objective performance index so transparency cannot mask regressions.
+ const totalScore = performanceIndex;
+
+ // Create score categories
+ const categories = [
+ {
+ name: "Performance",
+ score: performanceScore.score,
+ maxScore: performanceScore.maxScore,
+ weight: weights.performance,
+ details: performanceScore.details.map((d) => ({
+ name: d.metric,
+ score: d.score,
+ maxScore: d.maxScore,
+ weight: 1,
+ status: getCategoryStatus(d.score, d.maxScore),
+ reason: d.reason,
+ })),
+ status: getCategoryStatus(
+ performanceScore.score,
+ performanceScore.maxScore
+ ),
+ reason: `Performance score: ${performanceScore.score}/${performanceScore.maxScore}`,
+ },
+ {
+ name: "Bundle Strategy",
+ score: bundleScore.score,
+ maxScore: bundleScore.maxScore,
+ weight: weights.bundleStrategy,
+ details: bundleScore.details.map((d) => ({
+ name: d.metric,
+ score: d.score,
+ maxScore: d.maxScore,
+ weight: 1,
+ status: getCategoryStatus(d.score, d.maxScore),
+ reason: d.reason,
+ })),
+ status: getCategoryStatus(bundleScore.score, bundleScore.maxScore),
+ reason: `Bundle strategy score: ${bundleScore.score}/${bundleScore.maxScore}`,
+ },
+ {
+ name: "Network Impact",
+ score: networkScore.score,
+ maxScore: networkScore.maxScore,
+ weight: weights.networkImpact,
+ details: networkScore.details.map((d) => ({
+ name: d.metric,
+ score: d.score,
+ maxScore: d.maxScore,
+ weight: 1,
+ status: getCategoryStatus(d.score, d.maxScore),
+ reason: d.reason,
+ })),
+ status: getCategoryStatus(networkScore.score, networkScore.maxScore),
+ reason: `Network impact score: ${networkScore.score}/${networkScore.maxScore}`,
+ },
+ {
+ name: "Transparency",
+ score: transparencyScore.score,
+ maxScore: transparencyScore.maxScore,
+ weight: weights.transparency,
+ details: transparencyScore.details.map((d) => ({
+ name: d.metric,
+ score: d.score,
+ maxScore: d.maxScore,
+ weight: 1,
+ status: getCategoryStatus(d.score, d.maxScore),
+ reason: d.reason,
+ })),
+ status: getCategoryStatus(
+ transparencyScore.score,
+ transparencyScore.maxScore
+ ),
+ reason: `Transparency score: ${transparencyScore.score}/${transparencyScore.maxScore}`,
+ },
+ {
+ name: "User Experience",
+ score: uxScore.score,
+ maxScore: uxScore.maxScore,
+ weight: weights.userExperience,
+ details: uxScore.details.map((d) => ({
+ name: d.metric,
+ score: d.score,
+ maxScore: d.maxScore,
+ weight: 1,
+ status: getCategoryStatus(d.score, d.maxScore),
+ reason: d.reason,
+ })),
+ status: getCategoryStatus(uxScore.score, uxScore.maxScore),
+ reason: `User experience score: ${uxScore.score}/${uxScore.maxScore}`,
+ },
+ ];
+
+ // Generate insights and recommendations
+ const insights = generateInsights(
+ categoryScores,
+ metricsData,
+ resourceData,
+ isOpenSource
+ );
+ const recommendations = generateRecommendations(
+ categoryScores,
+ metricsData,
+ resourceData
+ );
+
+ return {
+ totalScore,
+ grade: getScoreGrade(totalScore),
+ indexes: {
+ performanceIndex,
+ governanceIndex,
+ combinedIndex,
+ },
+ categoryScores,
+ categories,
+ insights,
+ recommendations,
+ };
+}
+
+// Function to print scores in a table format
+// Note: This outputs directly to console as it's user-facing display output, not logging
+export function printScores(scores: BenchmarkScores): void {
+ // Create a table for overall scores
+ const overallTable = new Table({
+ head: ["Category", "Score", "Status"],
+ style: { head: ["cyan"] },
+ });
+
+ // Add overall score
+ overallTable.push(["Overall", `${scores.totalScore}/100`, scores.grade]);
+ overallTable.push([
+ "Performance Index",
+ `${scores.indexes.performanceIndex}/100`,
+ "objective",
+ ]);
+ overallTable.push([
+ "Governance Index",
+ `${scores.indexes.governanceIndex}/100`,
+ "governance",
+ ]);
+ overallTable.push([
+ "Combined Index",
+ `${scores.indexes.combinedIndex}/100`,
+ "optional",
+ ]);
+
+ // Add category scores
+ for (const category of scores.categories) {
+ overallTable.push([
+ category.name,
+ `${category.score}/${category.maxScore}`,
+ category.status,
+ ]);
+ }
+
+ // Create a table for detailed scores
+ const detailsTable = new Table({
+ head: ["Category", "Metric", "Score", "Reason"],
+ style: { head: ["cyan"] },
+ });
+
+ // Add detailed scores
+ for (const category of scores.categories) {
+ for (const detail of category.details) {
+ detailsTable.push([
+ category.name,
+ detail.name,
+ `${detail.score}/${detail.maxScore}`,
+ detail.reason,
+ ]);
+ }
+ }
+
+ // biome-ignore lint/suspicious/noConsole: CLI output table
+ console.log(overallTable.toString());
+ // biome-ignore lint/suspicious/noConsole: CLI output table
+ console.log(detailsTable.toString());
+
+ if (scores.insights.length > 0) {
+ // biome-ignore lint/suspicious/noConsole: CLI output section
+ console.log("\nInsights:");
+ for (const insight of scores.insights) {
+ // biome-ignore lint/suspicious/noConsole: CLI output list
+ console.log(`- ${insight}`);
+ }
+ }
+
+ if (scores.recommendations.length > 0) {
+ // biome-ignore lint/suspicious/noConsole: CLI output section
+ console.log("\nRecommendations:");
+ for (const recommendation of scores.recommendations) {
+ // biome-ignore lint/suspicious/noConsole: CLI output list
+ console.log(`- ${recommendation}`);
+ }
+ }
+}
diff --git a/packages/cookiebench-cli/tsconfig.base.json b/packages/cookiebench-cli/tsconfig.base.json
new file mode 100644
index 0000000..551c764
--- /dev/null
+++ b/packages/cookiebench-cli/tsconfig.base.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "esModuleInterop": true,
+ "incremental": false,
+ "isolatedModules": true,
+ "lib": ["es2022"],
+ "module": "NodeNext",
+ "moduleDetection": "force",
+ "moduleResolution": "NodeNext",
+ "noUncheckedIndexedAccess": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2022"
+ }
+}
diff --git a/packages/cookiebench-cli/tsconfig.json b/packages/cookiebench-cli/tsconfig.json
new file mode 100644
index 0000000..d983382
--- /dev/null
+++ b/packages/cookiebench-cli/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "types": ["node"],
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/runner/README.md b/packages/runner/README.md
new file mode 100644
index 0000000..1775766
--- /dev/null
+++ b/packages/runner/README.md
@@ -0,0 +1,151 @@
+# @consentio/runner
+
+Benchmark orchestration for running cookie banner performance tests.
+
+## Overview
+
+This package orchestrates benchmark execution, managing browser instances, running iterations, aggregating results, and serving Next.js applications for testing.
+
+## Features
+
+- **Benchmark Orchestration**: Run multiple benchmark iterations with automated browser management
+- **Next.js Server Management**: Build and serve Next.js apps for local testing
+- **Performance Aggregation**: Calculate averages and aggregate metrics across iterations
+- **Config Loading**: Load and validate benchmark configurations
+- **Remote & Local Testing**: Support for both remote URLs and local development
+
+## Installation
+
+```bash
+pnpm add @consentio/runner @consentio/benchmark
+```
+
+## Usage
+
+### Basic Usage
+
+```typescript
+import { BenchmarkRunner, readConfig } from '@consentio/runner';
+
+// Load config
+const config = readConfig('./config.json');
+
+// Create runner
+const runner = new BenchmarkRunner(config);
+
+// Run benchmarks
+const results = await runner.runBenchmarks('http://localhost:3000');
+
+console.log('Benchmark complete:', results);
+```
+
+### With Server Management
+
+```typescript
+import {
+ BenchmarkRunner,
+ buildAndServeNextApp,
+ cleanupServer,
+ readConfig,
+} from '@consentio/runner';
+import { createLogger } from '@c15t/logger';
+
+const config = readConfig();
+const logger = createLogger({ level: 'info' });
+const serverInfo = await buildAndServeNextApp(logger, './my-next-app');
+
+try {
+ const runner = new BenchmarkRunner(config);
+ const results = await runner.runBenchmarks(serverInfo.url);
+
+ console.log('Results:', results);
+} finally {
+ cleanupServer(serverInfo);
+}
+```
+
+### Remote Benchmarking
+
+```typescript
+import { BenchmarkRunner } from '@consentio/runner';
+
+const config = {
+ name: 'production-test',
+ iterations: 5,
+ remote: {
+ enabled: true,
+ url: 'https://production.example.com',
+ headers: {
+ 'Authorization': 'Bearer token',
+ },
+ },
+ // ... other config
+};
+
+const runner = new BenchmarkRunner(config);
+const results = await runner.runBenchmarks(config.remote.url);
+```
+
+## API
+
+### BenchmarkRunner
+
+- `constructor(config: Config)`: Create a new benchmark runner
+- `runBenchmarks(serverUrl: string)`: Run multiple benchmark iterations
+- `runSingleBenchmark(page: Page, url: string)`: Run a single benchmark iteration
+
+### Server Management
+
+- `buildAndServeNextApp(logger: Logger, appPath?: string)`: Build and serve a Next.js app
+- `cleanupServer(serverInfo: ServerInfo)`: Stop the server process
+
+### Utilities
+
+- `readConfig(configPath?: string)`: Read and parse config.json
+- `formatTime(ms: number)`: Format milliseconds to human-readable string
+- `getPackageManager()`: Detect package manager (npm/yarn/pnpm)
+
+### PerformanceAggregator
+
+- `calculateTTI(coreWebVitals, cookieBannerData)`: Calculate Time to Interactive
+- `aggregateMetrics(...)`: Merge all collected metrics into final benchmark details
+- `calculateAverages(results)`: Calculate average metrics from multiple runs
+- `logResults(...)`: Log comprehensive benchmark results
+
+## Configuration
+
+```json
+{
+ "name": "my-app",
+ "iterations": 5,
+ "baseline": false,
+ "remote": {
+ "enabled": false,
+ "url": "https://example.com"
+ },
+ "cookieBanner": {
+ "selectors": [".cookie-banner"],
+ "serviceHosts": ["cookiecdn.com"],
+ "serviceName": "CookieService",
+ "waitForVisibility": true,
+ "measureViewportCoverage": true,
+ "expectedLayoutShift": true
+ },
+ "techStack": {
+ "bundler": "webpack",
+ "bundleType": "esm",
+ "frameworks": ["react", "nextjs"],
+ "languages": ["typescript"],
+ "packageManager": "pnpm",
+ "typescript": true
+ }
+}
+```
+
+## Types
+
+See the [types file](./src/types.ts) for complete type definitions.
+
+## License
+
+MIT
diff --git a/packages/runner/package.json b/packages/runner/package.json
new file mode 100644
index 0000000..455b968
--- /dev/null
+++ b/packages/runner/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@consentio/runner",
+ "version": "0.0.1",
+ "type": "module",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "scripts": {
+ "build": "rslib build",
+ "check-types": "tsc --noEmit",
+ "dev": "rslib build --watch",
+ "fmt": "biome format . --write",
+ "lint": "biome lint ."
+ },
+ "dependencies": {
+ "@c15t/logger": "1.0.0",
+ "@consentio/benchmark": "workspace:*",
+ "@consentio/shared": "workspace:*",
+ "@playwright/test": "^1.58.2",
+ "playwright-performance-metrics": "^1.2.5"
+ },
+ "devDependencies": {
+ "@rsdoctor/rspack-plugin": "^1.5.2",
+ "@rslib/core": "^0.16.1",
+ "@types/node": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ }
+}
diff --git a/packages/runner/rslib.config.ts b/packages/runner/rslib.config.ts
new file mode 100644
index 0000000..434432c
--- /dev/null
+++ b/packages/runner/rslib.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "@rslib/core";
+
+export default defineConfig({
+ lib: [
+ {
+ format: "esm",
+ syntax: "es2021",
+ dts: true,
+ },
+ ],
+ output: {
+ target: "node",
+ },
+});
diff --git a/packages/runner/src/benchmark-runner.ts b/packages/runner/src/benchmark-runner.ts
new file mode 100644
index 0000000..49b2b20
--- /dev/null
+++ b/packages/runner/src/benchmark-runner.ts
@@ -0,0 +1,763 @@
+///
+import { execFile } from "node:child_process";
+import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
+import { resolve, sep } from "node:path";
+import { promisify } from "node:util";
+import type { Logger } from "@c15t/logger";
+import type { Config } from "@consentio/benchmark";
+import {
+ BENCHMARK_CONSTANTS,
+ CookieBannerCollector,
+ NetworkMonitor,
+ PerfumeCollector,
+ ResourceTimingCollector,
+} from "@consentio/benchmark";
+import {
+ chromium,
+ type Browser,
+ type BrowserContext,
+ type BrowserContextOptions,
+ type Page,
+} from "@playwright/test";
+import { PerformanceMetricsCollector } from "playwright-performance-metrics";
+import { PerformanceAggregator } from "./performance-aggregator";
+import type {
+ BenchmarkDetails,
+ BenchmarkQuality,
+ BenchmarkResult,
+ BenchmarkStatistics,
+} from "./types";
+
+const execFileAsync = promisify(execFile);
+
+const WARMUP_ITERATIONS = 1;
+const MAX_RETRIES = 2;
+const ITERATION_TIMEOUT_MS = 120_000;
+const CLEANUP_DELAY_MS = 500;
+const NAVIGATION_TIMEOUT_MS = 60_000;
+const RETRY_DELAY_MULTIPLIER = 2;
+const MILLISECONDS_TO_SECONDS = 1000;
+const FCP_LCP_MIN_STDDEV_MS = 8;
+const FCP_LCP_MIN_P95_P50_SPREAD_MS = 15;
+const TTI_MIN_STDDEV_MS = 30;
+const TTI_MIN_P95_P50_SPREAD_MS = 60;
+const TBT_MIN_STDDEV_MS = 15;
+const TBT_MIN_P95_P50_SPREAD_MS = 25;
+const BANNER_VISIBLE_MIN_P95_P50_SPREAD_MS = 250;
+const BANNER_VISIBLE_MIN_RELATIVE_SPREAD = 0.2;
+const CLS_MIN_STDDEV = 0.005;
+const CLS_MIN_P95_P50_SPREAD = 0.01;
+const PERCENT_FACTOR = 100;
+
+type TraceMode = "off" | "on-failure" | "all";
+
+const BYTES_PER_KIB = 1024;
+const BITS_PER_BYTE = 8;
+const SLOW4G_LATENCY_MS = 60;
+const SLOW4G_DOWNLOAD_MBPS = 10;
+const SLOW4G_UPLOAD_MBPS = 3;
+const FAST3G_LATENCY_MS = 120;
+const FAST3G_DOWNLOAD_MBPS = 1.6;
+const FAST3G_UPLOAD_KBPS = 750;
+
+const NETWORK_PROFILES: Record<
+ Config["runProfile"]["networkProfile"],
+ {
+ latency: number;
+ downloadThroughput: number;
+ uploadThroughput: number;
+ } | null
+> = {
+ none: null,
+ slow4g: {
+ latency: SLOW4G_LATENCY_MS,
+ downloadThroughput:
+ (SLOW4G_DOWNLOAD_MBPS * BYTES_PER_KIB * BYTES_PER_KIB) / BITS_PER_BYTE,
+ uploadThroughput:
+ (SLOW4G_UPLOAD_MBPS * BYTES_PER_KIB * BYTES_PER_KIB) / BITS_PER_BYTE,
+ },
+ fast3g: {
+ latency: FAST3G_LATENCY_MS,
+ downloadThroughput:
+ (FAST3G_DOWNLOAD_MBPS * BYTES_PER_KIB * BYTES_PER_KIB) / BITS_PER_BYTE,
+ uploadThroughput: (FAST3G_UPLOAD_KBPS * BYTES_PER_KIB) / BITS_PER_BYTE,
+ },
+};
+
+export class BenchmarkRunner {
+ private readonly config: Config;
+ private readonly logger: Logger;
+ private readonly cookieBannerCollector: CookieBannerCollector;
+ private readonly networkMonitor: NetworkMonitor;
+ private readonly resourceTimingCollector: ResourceTimingCollector;
+ private readonly perfumeCollector: PerfumeCollector;
+ private readonly performanceAggregator: PerformanceAggregator;
+ private readonly traceMode: TraceMode;
+ private readonly traceDir?: string;
+ private warmContext: BrowserContext | null = null;
+ private warnedMissingManualGc = false;
+
+ constructor(
+ config: Config,
+ logger: Logger,
+ options?: { traceMode?: TraceMode; traceDir?: string }
+ ) {
+ this.config = config;
+ this.logger = logger;
+ this.cookieBannerCollector = new CookieBannerCollector(config, logger);
+ this.networkMonitor = new NetworkMonitor(config, logger);
+ this.resourceTimingCollector = new ResourceTimingCollector(logger, config);
+ this.perfumeCollector = new PerfumeCollector(logger);
+ this.performanceAggregator = new PerformanceAggregator(logger);
+ this.traceMode = options?.traceMode ?? "on-failure";
+ this.traceDir = options?.traceDir;
+ this.validateConfig();
+ }
+
+ private validateConfig(): void {
+ if (!this.config.iterations || this.config.iterations < 1) {
+ throw new Error(
+ `Invalid iterations: ${this.config.iterations}. Must be at least 1.`
+ );
+ }
+
+ if (!this.config.runProfile) {
+ throw new Error("Missing required runProfile configuration");
+ }
+ if (!this.config.measurement) {
+ throw new Error("Missing required measurement configuration");
+ }
+
+ const hasSelectors =
+ this.config.cookieBanner?.selectors &&
+ this.config.cookieBanner.selectors.length > 0;
+ if (!hasSelectors) {
+ this.logger.warn(
+ "No cookie banner selectors configured. Banner detection may fail."
+ );
+ }
+
+ const hasWaitCondition =
+ this.config.testId || this.config.id || this.config.custom;
+
+ if (!hasWaitCondition) {
+ if (hasSelectors) {
+ this.logger.debug(
+ "No explicit wait condition, will use first cookie banner selector as fallback"
+ );
+ } else {
+ this.logger.warn(
+ "No wait condition configured (testId, id, or custom) and no cookie banner selectors found. Benchmarks may not wait for page readiness."
+ );
+ }
+ }
+ }
+
+ private getContextOptions(): BrowserContextOptions {
+ if (!this.config.remote?.enabled) {
+ return {};
+ }
+
+ const extraHTTPHeaders = this.config.remote.headers;
+ if (extraHTTPHeaders && Object.keys(extraHTTPHeaders).length > 0) {
+ return { extraHTTPHeaders };
+ }
+ return {};
+ }
+
+ private shouldUseWarmCache(iterationIndex: number): boolean {
+ const cacheMode = this.config.runProfile.cacheMode;
+ if (cacheMode === "warm") {
+ return iterationIndex > 0;
+ }
+ if (cacheMode === "mixed") {
+ return iterationIndex % 2 === 1;
+ }
+ return false;
+ }
+
+ private withCacheBuster(url: string, label: string): string {
+ try {
+ const parsed = new URL(url);
+ parsed.searchParams.set("cb", `${Date.now()}-${label}`);
+ return parsed.toString();
+ } catch {
+ return `${url}${url.includes("?") ? "&" : "?"}cb=${Date.now()}-${label}`;
+ }
+ }
+
+ private getIterationUrl(
+ baseUrl: string,
+ iterationIndex: number,
+ isWarmup: boolean,
+ useWarmCache: boolean
+ ): string {
+ const cacheMode = this.config.runProfile.cacheMode;
+ const shouldBustCache =
+ cacheMode === "cold" || (!useWarmCache && cacheMode === "mixed");
+ if (!shouldBustCache) {
+ return baseUrl;
+ }
+ const label = isWarmup
+ ? `warmup-${iterationIndex}`
+ : `iter-${iterationIndex}`;
+ return this.withCacheBuster(baseUrl, label);
+ }
+
+ private async applyRunProfile(page: Page): Promise {
+ const cdpSession = await page.context().newCDPSession(page);
+ const networkProfile =
+ NETWORK_PROFILES[this.config.runProfile.networkProfile];
+ const cpuSlowdown = this.config.runProfile.cpuSlowdownMultiplier;
+
+ if (networkProfile) {
+ await cdpSession.send("Network.enable");
+ await cdpSession.send("Network.emulateNetworkConditions", {
+ offline: false,
+ latency: networkProfile.latency,
+ downloadThroughput: networkProfile.downloadThroughput,
+ uploadThroughput: networkProfile.uploadThroughput,
+ });
+ }
+
+ if (cpuSlowdown > 1) {
+ await cdpSession.send("Emulation.setCPUThrottlingRate", {
+ rate: cpuSlowdown,
+ });
+ }
+ }
+
+ async runSingleBenchmark(
+ page: Page,
+ url: string,
+ isWarmup = false
+ ): Promise {
+ if (isWarmup) {
+ this.logger.debug(`Starting warmup benchmark for: ${url}`);
+ } else {
+ this.logger.debug(`Starting cookie banner benchmark for: ${url}`);
+ }
+ this.logger.debug(
+ "Cookie banner selectors:",
+ this.config.cookieBanner?.selectors || []
+ );
+ this.logger.debug(
+ "Bundle type from config:",
+ this.config.techStack?.bundleType
+ );
+
+ const collector = new PerformanceMetricsCollector();
+ const cookieBannerMetrics = this.cookieBannerCollector.initializeMetrics();
+
+ await this.applyRunProfile(page);
+ await this.networkMonitor.setupMonitoring(page, url);
+ await this.cookieBannerCollector.setupDetection(page);
+ await this.perfumeCollector.setupPerfume(page);
+
+ this.logger.debug(`Navigating to: ${url}`);
+ try {
+ await page.goto(url, {
+ waitUntil: "networkidle",
+ timeout: NAVIGATION_TIMEOUT_MS,
+ });
+ } catch (error) {
+ throw new Error(
+ `Navigation timeout or failed: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+
+ await this.waitForElement(page);
+ await page.waitForLoadState("networkidle");
+
+ const coreWebVitals = await collector.collectMetrics(page, {
+ timeout: BENCHMARK_CONSTANTS.METRICS_TIMEOUT,
+ retryTimeout: BENCHMARK_CONSTANTS.METRICS_RETRY_TIMEOUT,
+ });
+
+ const perfumeMetrics = await this.perfumeCollector.collectMetrics(page);
+ const cookieBannerData =
+ await this.cookieBannerCollector.collectMetrics(page);
+ const resourceMetrics = await this.resourceTimingCollector.collect(page);
+ const networkRequests = this.networkMonitor.getNetworkRequests();
+ const networkMetrics = this.networkMonitor.getMetrics();
+
+ const finalMetrics = this.performanceAggregator.aggregateMetrics({
+ coreWebVitals,
+ cookieBannerData,
+ cookieBannerMetrics,
+ networkRequests,
+ networkMetrics,
+ resourceMetrics,
+ config: this.config,
+ perfumeMetrics,
+ });
+
+ this.performanceAggregator.logResults(
+ finalMetrics,
+ cookieBannerMetrics,
+ this.config
+ );
+
+ await collector.cleanup();
+ this.networkMonitor.reset();
+
+ return finalMetrics;
+ }
+
+ private async persistTrace(
+ context: BrowserContext,
+ iterationNumber: number,
+ suffix: string
+ ): Promise {
+ const sanitizeComponent = (value: string): string =>
+ value.replace(/[^\w.-]/g, "_");
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const safeSuffix = sanitizeComponent(suffix);
+ const safeConfigName = sanitizeComponent(this.config.name);
+ const baseDir = resolve(this.traceDir || process.cwd());
+ if (baseDir.includes("\0")) {
+ throw new Error("Invalid trace directory path");
+ }
+ const traceZipPath = this.traceDir
+ ? resolve(baseDir, `Trace-${timestamp}-${safeSuffix}.zip`)
+ : resolve(
+ baseDir,
+ `trace-${safeConfigName}-iteration-${iterationNumber}-${safeSuffix}.zip`
+ );
+ const traceJsonPath = this.traceDir
+ ? resolve(baseDir, `Trace-${timestamp}-${safeSuffix}.json`)
+ : resolve(
+ baseDir,
+ `trace-${safeConfigName}-iteration-${iterationNumber}-${safeSuffix}.json`
+ );
+ const ensureWithinBase = (pathValue: string): void => {
+ if (!pathValue.startsWith(`${baseDir}${sep}`)) {
+ throw new Error(`Refusing to write trace outside ${baseDir}`);
+ }
+ };
+ ensureWithinBase(traceZipPath);
+ ensureWithinBase(traceJsonPath);
+ await context.tracing.stop({ path: traceZipPath });
+
+ try {
+ const tempDir = baseDir;
+ await execFileAsync("unzip", [
+ "-o",
+ traceZipPath,
+ "-d",
+ tempDir,
+ "trace.trace",
+ ]);
+
+ const traceFilePath = resolve(tempDir, "trace.trace");
+ ensureWithinBase(traceFilePath);
+ const traceContent = readFileSync(traceFilePath, "utf-8");
+ writeFileSync(traceJsonPath, traceContent, "utf-8");
+ try {
+ unlinkSync(traceFilePath);
+ } catch {
+ // Ignore cleanup failures
+ }
+ try {
+ unlinkSync(traceZipPath);
+ } catch {
+ // Ignore cleanup failures
+ }
+ this.logger.info(`📊 Trace saved to: ${traceJsonPath}`);
+ } catch {
+ this.logger.warn(
+ `Failed to extract trace JSON, keeping ZIP file: ${traceZipPath}`
+ );
+ this.logger.info(`📊 Trace saved to: ${traceZipPath}`);
+ }
+ }
+
+ private async runSingleBenchmarkWithRetry(options: {
+ browser: Browser;
+ url: string;
+ isWarmup: boolean;
+ iterationNumber: number;
+ useWarmCache: boolean;
+ }): Promise {
+ const { browser, url, isWarmup, iterationNumber, useWarmCache } = options;
+ let lastError: Error | null = null;
+
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
+ let context: BrowserContext;
+ let ownsContext = false;
+
+ if (useWarmCache) {
+ if (!this.warmContext) {
+ this.warmContext = await browser.newContext(this.getContextOptions());
+ }
+ context = this.warmContext;
+ } else {
+ context = await browser.newContext(this.getContextOptions());
+ ownsContext = true;
+ }
+
+ const page = await context.newPage();
+ const shouldTrace = this.traceMode !== "off" && !isWarmup;
+ if (shouldTrace) {
+ await context.tracing.start({
+ screenshots: true,
+ snapshots: true,
+ });
+ }
+
+ try {
+ if (attempt > 0) {
+ this.logger.warn(
+ `Retrying iteration (attempt ${attempt + 1}/${MAX_RETRIES + 1})...`
+ );
+ }
+
+ const result = await Promise.race([
+ this.runSingleBenchmark(page, url, isWarmup),
+ new Promise((_, reject) =>
+ setTimeout(
+ () => reject(new Error("Iteration timeout")),
+ ITERATION_TIMEOUT_MS
+ )
+ ),
+ ]);
+
+ if (shouldTrace) {
+ if (this.traceMode === "all") {
+ await this.persistTrace(context, iterationNumber, "success");
+ } else {
+ await context.tracing.stop();
+ }
+ }
+
+ await page.close();
+ if (ownsContext) {
+ await context.close();
+ }
+ return result;
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error(String(error));
+ this.logger.debug(
+ `Iteration attempt ${attempt + 1} failed:`,
+ lastError.message
+ );
+
+ if (
+ shouldTrace &&
+ (this.traceMode === "on-failure" || this.traceMode === "all")
+ ) {
+ await this.persistTrace(
+ context,
+ iterationNumber,
+ `failed-attempt-${attempt + 1}`
+ );
+ } else if (shouldTrace) {
+ await context.tracing.stop();
+ }
+
+ await page.close();
+ if (ownsContext) {
+ await context.close();
+ }
+
+ if (attempt < MAX_RETRIES) {
+ const retryDelay = CLEANUP_DELAY_MS * RETRY_DELAY_MULTIPLIER;
+ await new Promise((done) => setTimeout(done, retryDelay));
+ }
+ }
+ }
+
+ throw new Error(
+ `Failed to complete benchmark after ${MAX_RETRIES + 1} attempts: ${lastError?.message}`
+ );
+ }
+
+ private async cleanupBetweenIterations(): Promise {
+ await new Promise((done) => setTimeout(done, CLEANUP_DELAY_MS));
+
+ if (typeof global.gc === "function") {
+ global.gc();
+ return;
+ }
+ if (!this.warnedMissingManualGc) {
+ this.warnedMissingManualGc = true;
+ this.logger.warn(
+ "Manual GC unavailable. Start Node with --expose-gc to enable explicit garbage collection between iterations."
+ );
+ }
+ }
+
+ private buildQualitySummary(
+ results: BenchmarkDetails[],
+ statistics: BenchmarkStatistics
+ ): BenchmarkQuality {
+ const requestedIterations = this.config.iterations;
+ const successfulIterations = results.length;
+ const failedIterations = requestedIterations - successfulIterations;
+ const failureRate =
+ requestedIterations > 0 ? failedIterations / requestedIterations : 0;
+ const minSuccessfulIterations =
+ this.config.measurement.minSuccessfulIterations;
+ const maxFailureRate = this.config.measurement.maxFailureRate;
+ const stabilityThresholdCv = this.config.measurement.stabilityThresholdCv;
+ const isMetricUnstable = (
+ stats: BenchmarkStatistics[keyof BenchmarkStatistics],
+ options: {
+ minStddev: number;
+ minP95P50Spread: number;
+ minRelativeSpread?: number;
+ useStddev?: boolean;
+ }
+ ): boolean => {
+ const spread = Math.max(0, stats.p95 - stats.p50);
+ const relativeSpread =
+ stats.p50 > 0 ? spread / stats.p50 : Number.POSITIVE_INFINITY;
+ const stddevUnstable =
+ options.useStddev === false ? false : stats.stddev >= options.minStddev;
+ const spreadUnstable =
+ spread >= options.minP95P50Spread &&
+ (options.minRelativeSpread === undefined ||
+ relativeSpread >= options.minRelativeSpread);
+
+ return (
+ stats.cv > stabilityThresholdCv && (stddevUnstable || spreadUnstable)
+ );
+ };
+
+ const cvMetrics: Array<{ metric: string; unstable: boolean }> = [
+ {
+ metric: "fcp",
+ unstable: isMetricUnstable(statistics.fcp, {
+ minStddev: FCP_LCP_MIN_STDDEV_MS,
+ minP95P50Spread: FCP_LCP_MIN_P95_P50_SPREAD_MS,
+ }),
+ },
+ {
+ metric: "lcp",
+ unstable: isMetricUnstable(statistics.lcp, {
+ minStddev: FCP_LCP_MIN_STDDEV_MS,
+ minP95P50Spread: FCP_LCP_MIN_P95_P50_SPREAD_MS,
+ }),
+ },
+ {
+ metric: "tti",
+ unstable: isMetricUnstable(statistics.tti, {
+ minStddev: TTI_MIN_STDDEV_MS,
+ minP95P50Spread: TTI_MIN_P95_P50_SPREAD_MS,
+ }),
+ },
+ {
+ metric: "tbt",
+ unstable: isMetricUnstable(statistics.tbt, {
+ minStddev: TBT_MIN_STDDEV_MS,
+ minP95P50Spread: TBT_MIN_P95_P50_SPREAD_MS,
+ }),
+ },
+ {
+ metric: "cls",
+ unstable: isMetricUnstable(statistics.cls, {
+ minStddev: CLS_MIN_STDDEV,
+ minP95P50Spread: CLS_MIN_P95_P50_SPREAD,
+ }),
+ },
+ {
+ metric: "bannerVisibleTime",
+ unstable: isMetricUnstable(statistics.bannerVisibleTime, {
+ minStddev: 0,
+ minP95P50Spread: BANNER_VISIBLE_MIN_P95_P50_SPREAD_MS,
+ minRelativeSpread: BANNER_VISIBLE_MIN_RELATIVE_SPREAD,
+ useStddev: false,
+ }),
+ },
+ ];
+ const unstableMetrics = cvMetrics
+ .filter(({ unstable }) => unstable)
+ .map(({ metric }) => metric);
+
+ return {
+ requestedIterations,
+ successfulIterations,
+ failedIterations,
+ failureRate,
+ minSuccessfulIterations,
+ maxFailureRate,
+ stabilityThresholdCv,
+ stable: unstableMetrics.length === 0,
+ unstableMetrics,
+ };
+ }
+
+ async runBenchmarks(serverUrl: string): Promise {
+ const browser = await chromium.launch({
+ headless: true,
+ args: ["--remote-debugging-port=9222"],
+ });
+ const chromiumVersion = browser.version();
+ const results: BenchmarkDetails[] = [];
+ const startTime = Date.now();
+
+ try {
+ if (WARMUP_ITERATIONS > 0) {
+ this.logger.info(
+ `Running ${WARMUP_ITERATIONS} warmup iteration(s) to stabilize environment...`
+ );
+ const warmupContext = await browser.newContext(
+ this.getContextOptions()
+ );
+ const warmupPage = await warmupContext.newPage();
+
+ for (let i = 0; i < WARMUP_ITERATIONS; i += 1) {
+ const warmupUrl = this.getIterationUrl(serverUrl, i, true, false);
+ try {
+ await this.runSingleBenchmark(warmupPage, warmupUrl, true);
+ this.logger.debug(`Warmup iteration ${i + 1} completed`);
+ } catch (error) {
+ this.logger.debug(
+ `Warmup iteration ${i + 1} failed (non-critical):`,
+ error instanceof Error ? error.message : String(error)
+ );
+ }
+ await this.cleanupBetweenIterations();
+ }
+
+ await warmupContext.close();
+ this.logger.info("Warmup complete. Starting actual benchmarks...");
+ }
+
+ for (let i = 0; i < this.config.iterations; i += 1) {
+ const iterationStartTime = Date.now();
+ const elapsedTimeSeconds = Math.round(
+ (Date.now() - startTime) / MILLISECONDS_TO_SECONDS
+ );
+ const avgTimePerIteration = i > 0 ? elapsedTimeSeconds / i : 0;
+ const remainingIterations = this.config.iterations - i - 1;
+ const estimatedRemaining = avgTimePerIteration * remainingIterations;
+ const useWarmCache = this.shouldUseWarmCache(i);
+ const iterationUrl = this.getIterationUrl(
+ serverUrl,
+ i,
+ false,
+ useWarmCache
+ );
+
+ this.logger.info(
+ `Running iteration ${i + 1}/${this.config.iterations}${estimatedRemaining > 0 ? ` (est. ${Math.round(estimatedRemaining)}s remaining)` : ""}...`
+ );
+ this.logger.debug(
+ `Cache mode ${this.config.runProfile.cacheMode}; warm cache ${useWarmCache ? "enabled" : "disabled"}`
+ );
+
+ try {
+ const result = await this.runSingleBenchmarkWithRetry({
+ browser,
+ url: iterationUrl,
+ isWarmup: false,
+ iterationNumber: i + 1,
+ useWarmCache,
+ });
+ results.push(result);
+
+ const iterationDurationSeconds = Math.round(
+ (Date.now() - iterationStartTime) / MILLISECONDS_TO_SECONDS
+ );
+ this.logger.debug(
+ `Iteration ${i + 1} completed in ${iterationDurationSeconds}s`
+ );
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ this.logger.error(
+ `Failed to complete iteration ${i + 1}: ${errorMessage}`
+ );
+ // Continue so quality gates can evaluate failure rate.
+ } finally {
+ await this.cleanupBetweenIterations();
+ }
+ }
+
+ if (results.length === 0) {
+ throw new Error(
+ "All benchmark iterations failed. Check logs for details."
+ );
+ }
+ } finally {
+ if (this.warmContext) {
+ await this.warmContext.close();
+ this.warmContext = null;
+ }
+ await browser.close();
+ }
+
+ const totalTimeSeconds = Math.round(
+ (Date.now() - startTime) / MILLISECONDS_TO_SECONDS
+ );
+ this.logger.info(
+ `Benchmark completed in ${totalTimeSeconds}s (${results.length} successful iterations)`
+ );
+
+ const averages = this.performanceAggregator.calculateAverages(results);
+ const statistics =
+ this.performanceAggregator.getStatisticalSummary(results);
+ const quality = this.buildQualitySummary(results, statistics);
+
+ if (quality.successfulIterations < quality.minSuccessfulIterations) {
+ throw new Error(
+ `Run failed quality gate: only ${quality.successfulIterations}/${quality.requestedIterations} iterations succeeded (minimum ${quality.minSuccessfulIterations})`
+ );
+ }
+
+ if (quality.failureRate > quality.maxFailureRate) {
+ throw new Error(
+ `Run failed quality gate: failure rate ${(quality.failureRate * PERCENT_FACTOR).toFixed(1)}% exceeds ${(quality.maxFailureRate * PERCENT_FACTOR).toFixed(1)}%`
+ );
+ }
+
+ if (!quality.stable) {
+ this.logger.warn(
+ `Run is unstable for metrics: ${quality.unstableMetrics.join(", ")}`
+ );
+ }
+
+ if (results.length > 1) {
+ this.performanceAggregator.logStatisticalSummary(results);
+ }
+
+ return {
+ name: this.config.name,
+ baseline: this.config.baseline ?? false,
+ techStack: this.config.techStack,
+ source: this.config.source,
+ includes: this.config.includes,
+ company: this.config.company,
+ tags: this.config.tags,
+ details: results,
+ average: averages,
+ statistics,
+ quality,
+ environment: {
+ chromiumVersion,
+ },
+ };
+ }
+
+ private async waitForElement(page: Page): Promise {
+ if (this.config.testId) {
+ this.logger.debug(`Waiting for testId: ${this.config.testId}`);
+ await page.waitForSelector(`[data-testid="${this.config.testId}"]`);
+ } else if (this.config.id) {
+ this.logger.debug(`Waiting for id: ${this.config.id}`);
+ await page.waitForSelector(`#${this.config.id}`);
+ } else if (this.config.custom) {
+ this.logger.debug("Running custom wait function");
+ await this.config.custom(page);
+ } else {
+ const firstSelector = this.config.cookieBanner?.selectors?.[0];
+ if (firstSelector) {
+ this.logger.debug(
+ `No explicit wait condition found, using first cookie banner selector: ${firstSelector}`
+ );
+ await page.waitForSelector(firstSelector);
+ }
+ }
+ }
+}
diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts
new file mode 100644
index 0000000..21cd6c8
--- /dev/null
+++ b/packages/runner/src/index.ts
@@ -0,0 +1,34 @@
+// Main runner
+// biome-ignore lint/performance/noBarrelFile: this is a barrel file
+export { BenchmarkRunner } from "./benchmark-runner";
+// Performance aggregation
+export { PerformanceAggregator } from "./performance-aggregator";
+// Server management
+export { buildAndServeNextApp, cleanupServer } from "./server";
+// Types
+export type {
+ BenchmarkDetails,
+ BenchmarkEnvironment,
+ BenchmarkQuality,
+ BenchmarkResult,
+ BenchmarkStatistics,
+ BundleStrategy,
+ Config,
+ CookieBannerConfig,
+ CookieBannerData,
+ CookieBannerMetrics,
+ CoreWebVitals,
+ NetworkMetrics,
+ NetworkRequest,
+ ResourceTimingData,
+ ServerInfo,
+} from "./types";
+// Statistics utilities
+export {
+ calculateCoefficientOfVariation,
+ calculateStatistics,
+ calculateTrimmedMean,
+ isStable,
+} from "./statistics";
+// Utilities
+export { formatTime, getPackageManager, readConfig } from "./utils";
diff --git a/packages/runner/src/performance-aggregator.ts b/packages/runner/src/performance-aggregator.ts
new file mode 100644
index 0000000..71f1d05
--- /dev/null
+++ b/packages/runner/src/performance-aggregator.ts
@@ -0,0 +1,581 @@
+import type { Logger } from "@c15t/logger";
+import type {
+ Config,
+ CookieBannerData,
+ CookieBannerMetrics,
+ CoreWebVitals,
+ NetworkMetrics,
+ NetworkRequest,
+ PerfumeMetrics,
+ ResourceTimingData,
+} from "@consentio/benchmark";
+import { PERCENTAGE_MULTIPLIER, TTI_BUFFER_MS } from "@consentio/shared";
+import { calculateStatistics, calculateTrimmedMean } from "./statistics";
+import type {
+ BenchmarkDetails,
+ BenchmarkResult,
+ BenchmarkStatistics,
+} from "./types";
+
+const VARIABILITY_WARNING_THRESHOLD = 20;
+const STABILITY_THRESHOLD = 15;
+const TRIM_PERCENT = 10;
+const FCP_LCP_MIN_STDDEV_MS = 8;
+const FCP_LCP_MIN_P95_P50_SPREAD_MS = 15;
+const TTI_MIN_STDDEV_MS = 30;
+const TTI_MIN_P95_P50_SPREAD_MS = 60;
+
+type AggregateMetricsParams = {
+ coreWebVitals: CoreWebVitals;
+ cookieBannerData: CookieBannerData | null;
+ cookieBannerMetrics: CookieBannerMetrics;
+ networkRequests: NetworkRequest[];
+ networkMetrics: NetworkMetrics;
+ resourceMetrics: ResourceTimingData;
+ config: Config;
+ perfumeMetrics: PerfumeMetrics | null;
+};
+
+export class PerformanceAggregator {
+ private readonly logger: Logger;
+
+ constructor(logger: Logger) {
+ this.logger = logger;
+ }
+
+ calculateTTI(
+ coreWebVitals: CoreWebVitals,
+ cookieBannerData: CookieBannerData | null
+ ): number {
+ return (
+ Math.max(
+ coreWebVitals.paint?.firstContentfulPaint || 0,
+ coreWebVitals.domCompleteTiming || 0,
+ cookieBannerData?.bannerInteractiveTime || 0
+ ) + TTI_BUFFER_MS
+ );
+ }
+
+ private buildCookieBannerTiming(
+ cookieBannerData: CookieBannerData | null,
+ config: Config
+ ) {
+ const domPresenceTime = cookieBannerData?.bannerRenderTime || 0;
+ const userVisibleTime =
+ cookieBannerData?.bannerVisibilityTime ||
+ cookieBannerData?.bannerInteractiveTime ||
+ 0;
+ return {
+ renderStart: domPresenceTime,
+ renderEnd: cookieBannerData?.bannerInteractiveTime || 0,
+ interactionStart: cookieBannerData?.bannerInteractiveTime || 0,
+ interactionEnd: cookieBannerData?.bannerInteractiveTime || 0,
+ layoutShift: cookieBannerData?.layoutShiftImpact || 0,
+ detected: cookieBannerData?.detected ?? false,
+ selector: cookieBannerData?.selector ?? null,
+ serviceName: config.cookieBanner?.serviceName ?? "unknown",
+ visibilityTime: userVisibleTime,
+ domPresenceTime,
+ userVisibleTime,
+ viewportCoverage: cookieBannerData?.viewportCoverage || 0,
+ };
+ }
+
+ private buildThirdPartyMetrics(
+ networkImpact: {
+ totalImpact: number;
+ totalDownloadTime: number;
+ thirdPartyImpact: number;
+ thirdPartyDownloadTime: number;
+ },
+ resourceMetrics: ResourceTimingData,
+ config: Config
+ ) {
+ const cookieServiceResources = [
+ ...resourceMetrics.resources.scripts,
+ ...resourceMetrics.resources.styles,
+ ...resourceMetrics.resources.images,
+ ...resourceMetrics.resources.fonts,
+ ...resourceMetrics.resources.other,
+ ].filter((resource) => resource.isCookieService);
+
+ const cookieServicesTotalSizeFromResourceTiming =
+ cookieServiceResources.reduce((acc, resource) => acc + resource.size, 0);
+ const cookieServicesDownloadTimeFromResourceTiming =
+ cookieServiceResources.reduce(
+ (acc, resource) => acc + resource.duration,
+ 0
+ );
+
+ const cookieServicesTotalSize =
+ cookieServicesTotalSizeFromResourceTiming > 0
+ ? cookieServicesTotalSizeFromResourceTiming
+ : networkImpact.thirdPartyImpact;
+ const cookieServicesDownloadTime =
+ cookieServicesDownloadTimeFromResourceTiming > 0
+ ? cookieServicesDownloadTimeFromResourceTiming
+ : networkImpact.thirdPartyDownloadTime;
+ const totalImpact =
+ networkImpact.totalImpact > 0
+ ? networkImpact.totalImpact
+ : cookieServicesTotalSize;
+ const totalDownloadTime =
+ networkImpact.totalDownloadTime > 0
+ ? networkImpact.totalDownloadTime
+ : cookieServicesDownloadTime;
+
+ return {
+ dnsLookupTime: 0,
+ connectionTime: 0,
+ downloadTime: totalDownloadTime,
+ totalImpact,
+ cookieServices: {
+ hosts: config.cookieBanner?.serviceHosts || [],
+ totalSize: cookieServicesTotalSize,
+ resourceCount: cookieServiceResources.length,
+ dnsLookupTime: 0,
+ connectionTime: 0,
+ downloadTime: cookieServicesDownloadTime,
+ },
+ };
+ }
+
+ private buildMainThreadBlockingMetrics(
+ coreWebVitals: CoreWebVitals,
+ cookieBannerMetrics: CookieBannerMetrics
+ ) {
+ const totalBlockingTime = coreWebVitals.totalBlockingTime || 0;
+ const cookieBannerEstimate =
+ cookieBannerMetrics.bannerMainThreadBlockingTime;
+
+ const percentageFromCookies =
+ totalBlockingTime > 0
+ ? (cookieBannerEstimate / totalBlockingTime) * PERCENTAGE_MULTIPLIER
+ : 0;
+
+ return {
+ total: totalBlockingTime,
+ cookieBannerEstimate,
+ percentageFromCookies,
+ };
+ }
+
+ aggregateMetrics(params: AggregateMetricsParams): BenchmarkDetails {
+ const {
+ coreWebVitals,
+ cookieBannerData,
+ cookieBannerMetrics,
+ networkRequests,
+ networkMetrics,
+ resourceMetrics,
+ config,
+ perfumeMetrics,
+ } = params;
+
+ const tti = this.calculateTTI(coreWebVitals, cookieBannerData);
+ const networkImpact = this.calculateNetworkImpact(networkRequests);
+ const thirdPartyMetrics = this.buildThirdPartyMetrics(
+ networkImpact,
+ resourceMetrics,
+ config
+ );
+ let resolvedThirdPartySize: number;
+ if (resourceMetrics.size.thirdParty > 0) {
+ resolvedThirdPartySize = resourceMetrics.size.thirdParty;
+ } else if (networkImpact.thirdPartyImpact > 0) {
+ resolvedThirdPartySize = networkImpact.thirdPartyImpact;
+ } else {
+ resolvedThirdPartySize = thirdPartyMetrics.cookieServices.totalSize;
+ }
+ const resolvedThirdPartyScriptSize =
+ resourceMetrics.size.scripts.thirdParty > 0
+ ? resourceMetrics.size.scripts.thirdParty
+ : resolvedThirdPartySize;
+
+ return {
+ duration: resourceMetrics.duration,
+ size: {
+ ...resourceMetrics.size,
+ thirdParty: resolvedThirdPartySize,
+ cookieServices:
+ resourceMetrics.size.cookieServices ||
+ thirdPartyMetrics.cookieServices.totalSize,
+ scripts: {
+ ...resourceMetrics.size.scripts,
+ thirdParty: resolvedThirdPartyScriptSize,
+ cookieServices:
+ resourceMetrics.size.scripts.cookieServices ||
+ resourceMetrics.resources.scripts
+ .filter((resource) => resource.isCookieService)
+ .reduce((acc, resource) => acc + resource.size, 0),
+ },
+ },
+ timing: {
+ navigationStart: resourceMetrics.timing.navigationStart,
+ domContentLoaded: resourceMetrics.timing.domContentLoaded,
+ load: resourceMetrics.timing.load,
+ firstPaint: coreWebVitals.paint?.firstPaint || 0,
+ firstContentfulPaint: coreWebVitals.paint?.firstContentfulPaint || 0,
+ largestContentfulPaint: coreWebVitals.largestContentfulPaint || 0,
+ timeToInteractive: tti,
+ cumulativeLayoutShift: coreWebVitals.cumulativeLayoutShift || 0,
+ timeToFirstByte: perfumeMetrics?.timeToFirstByte ?? null,
+ firstInputDelay: perfumeMetrics?.firstInputDelay ?? null,
+ interactionToNextPaint: perfumeMetrics?.interactionToNextPaint ?? null,
+ navigationTiming: perfumeMetrics?.navigationTiming ?? {
+ timeToFirstByte: 0,
+ domInteractive: 0,
+ domContentLoadedEventStart: 0,
+ domContentLoadedEventEnd: 0,
+ domComplete: 0,
+ loadEventStart: 0,
+ loadEventEnd: 0,
+ },
+ networkInformation: perfumeMetrics?.networkInformation ?? undefined,
+ cookieBanner: this.buildCookieBannerTiming(cookieBannerData, config),
+ thirdParty: thirdPartyMetrics,
+ mainThreadBlocking: this.buildMainThreadBlockingMetrics(
+ coreWebVitals,
+ cookieBannerMetrics
+ ),
+ scripts: resourceMetrics.timing.scripts,
+ },
+ resources: resourceMetrics.resources,
+ language: resourceMetrics.language,
+ cookieBanner: {
+ detected: cookieBannerData?.detected ?? false,
+ selector: cookieBannerData?.selector ?? null,
+ serviceName: config.cookieBanner?.serviceName ?? "unknown",
+ visibilityTime:
+ cookieBannerData?.bannerVisibilityTime ||
+ cookieBannerData?.bannerInteractiveTime ||
+ 0,
+ domPresenceTime: cookieBannerData?.bannerRenderTime || 0,
+ userVisibleTime:
+ cookieBannerData?.bannerVisibilityTime ||
+ cookieBannerData?.bannerInteractiveTime ||
+ 0,
+ viewportCoverage: cookieBannerData?.viewportCoverage || 0,
+ },
+ thirdParty: {
+ cookieServices: {
+ hosts: config.cookieBanner?.serviceHosts || [],
+ totalSize: thirdPartyMetrics.cookieServices.totalSize,
+ resourceCount: thirdPartyMetrics.cookieServices.resourceCount,
+ dnsLookupTime: 0,
+ connectionTime: 0,
+ downloadTime: thirdPartyMetrics.cookieServices.downloadTime,
+ },
+ totalImpact:
+ networkImpact.thirdPartyImpact ||
+ networkMetrics.bannerBundleSize ||
+ resolvedThirdPartySize ||
+ 0,
+ },
+ };
+ }
+
+ calculateNetworkImpact(networkRequests: NetworkRequest[]): {
+ totalImpact: number;
+ totalDownloadTime: number;
+ thirdPartyImpact: number;
+ thirdPartyDownloadTime: number;
+ } {
+ const totalImpact = networkRequests.reduce((acc, req) => acc + req.size, 0);
+ const totalDownloadTime = networkRequests.reduce(
+ (acc, req) => acc + req.duration,
+ 0
+ );
+ const thirdPartyImpact = networkRequests
+ .filter((request) => request.isThirdParty)
+ .reduce((acc, request) => acc + request.size, 0);
+ const thirdPartyDownloadTime = networkRequests
+ .filter((request) => request.isThirdParty)
+ .reduce((acc, request) => acc + request.duration, 0);
+
+ return {
+ totalImpact,
+ totalDownloadTime,
+ thirdPartyImpact,
+ thirdPartyDownloadTime,
+ };
+ }
+
+ hasMeaningfulVariability(
+ values: number[],
+ cvThreshold: number,
+ minStddev: number,
+ minP95P50Spread: number
+ ): { unstable: boolean; cv: number } {
+ const stats = calculateStatistics(values);
+ const absoluteSpread = Math.max(0, stats.p95 - stats.p50);
+ const unstable =
+ stats.cv > cvThreshold &&
+ (stats.stddev >= minStddev || absoluteSpread >= minP95P50Spread);
+ return { unstable, cv: stats.cv };
+ }
+
+ private calculateAverageThirdPartyDomainCount(
+ results: BenchmarkDetails[]
+ ): number {
+ const domainCounts = results.map((result) => {
+ const hosts = new Set();
+ for (const resource of [
+ ...result.resources.scripts,
+ ...result.resources.styles,
+ ...result.resources.images,
+ ...result.resources.fonts,
+ ...result.resources.other,
+ ]) {
+ if (!resource.isThirdParty) {
+ continue;
+ }
+ try {
+ hosts.add(new URL(resource.name).hostname);
+ } catch {
+ // Ignore malformed URLs from resource timing
+ }
+ }
+ return hosts.size;
+ });
+ return calculateTrimmedMean(domainCounts, TRIM_PERCENT);
+ }
+
+ calculateAverages(results: BenchmarkDetails[]): BenchmarkResult["average"] {
+ if (results.length === 0) {
+ throw new Error("Cannot calculate averages from empty results array");
+ }
+
+ const fcpValues = results.map((r) => r.timing.firstContentfulPaint);
+ const lcpValues = results.map((r) => r.timing.largestContentfulPaint);
+ const ttiValues = results.map((r) => r.timing.timeToInteractive);
+ const tbtValues = results.map((r) => r.timing.mainThreadBlocking.total);
+ const clsValues = results.map((r) => r.timing.cumulativeLayoutShift);
+ const ttfbValues = results
+ .map((r) => r.timing.timeToFirstByte)
+ .filter((value): value is number => value !== null && value > 0);
+ const inpValues = results
+ .map((r) => r.timing.interactionToNextPaint)
+ .filter((value): value is number => value !== null && value > 0);
+ const totalSizeValues = results.map((r) => r.size.total);
+ const jsSizeValues = results.map((r) => r.size.scripts.total);
+ const cssSizeValues = results.map((r) => r.size.styles);
+ const imageSizeValues = results.map((r) => r.size.images);
+ const fontSizeValues = results.map((r) => r.size.fonts);
+ const otherSizeValues = results.map((r) => r.size.other);
+ const totalRequestsValues = results.map(
+ (r) =>
+ r.resources.scripts.length +
+ r.resources.styles.length +
+ r.resources.images.length +
+ r.resources.fonts.length +
+ r.resources.other.length
+ );
+ const thirdPartyRequestValues = results.map(
+ (r) =>
+ r.resources.scripts.filter((resource) => resource.isThirdParty).length +
+ r.resources.styles.filter((resource) => resource.isThirdParty).length +
+ r.resources.images.filter((resource) => resource.isThirdParty).length +
+ r.resources.fonts.filter((resource) => resource.isThirdParty).length +
+ r.resources.other.filter((resource) => resource.isThirdParty).length
+ );
+ const thirdPartySizeValues = results.map((r) => r.size.thirdParty);
+ const bannerVisibleValues = results.map(
+ (r) =>
+ r.cookieBanner.userVisibleTime ?? r.cookieBanner.visibilityTime ?? 0
+ );
+ const bannerDomValues = results.map((r) => r.cookieBanner.domPresenceTime);
+ const bannerCoverageValues = results.map(
+ (r) => r.cookieBanner.viewportCoverage
+ );
+ const scriptLoadValues = results.map(
+ (r) =>
+ r.timing.scripts.bundled.loadEnd + r.timing.scripts.thirdParty.loadEnd
+ );
+
+ const fcpVariability = this.hasMeaningfulVariability(
+ fcpValues,
+ VARIABILITY_WARNING_THRESHOLD,
+ FCP_LCP_MIN_STDDEV_MS,
+ FCP_LCP_MIN_P95_P50_SPREAD_MS
+ );
+ if (fcpVariability.unstable) {
+ this.logger.warn(
+ `First Contentful Paint shows high variability (CV: ${fcpVariability.cv.toFixed(1)}%)`
+ );
+ }
+ const lcpVariability = this.hasMeaningfulVariability(
+ lcpValues,
+ VARIABILITY_WARNING_THRESHOLD,
+ FCP_LCP_MIN_STDDEV_MS,
+ FCP_LCP_MIN_P95_P50_SPREAD_MS
+ );
+ if (lcpVariability.unstable) {
+ this.logger.warn(
+ `Largest Contentful Paint shows high variability (CV: ${lcpVariability.cv.toFixed(1)}%)`
+ );
+ }
+ const ttiVariability = this.hasMeaningfulVariability(
+ ttiValues,
+ VARIABILITY_WARNING_THRESHOLD,
+ TTI_MIN_STDDEV_MS,
+ TTI_MIN_P95_P50_SPREAD_MS
+ );
+ if (ttiVariability.unstable) {
+ this.logger.warn(
+ `Time to Interactive shows high variability (CV: ${ttiVariability.cv.toFixed(1)}%)`
+ );
+ }
+
+ return {
+ firstContentfulPaint: calculateTrimmedMean(fcpValues, TRIM_PERCENT),
+ largestContentfulPaint: calculateTrimmedMean(lcpValues, TRIM_PERCENT),
+ timeToInteractive: calculateTrimmedMean(ttiValues, TRIM_PERCENT),
+ totalBlockingTime: calculateTrimmedMean(tbtValues, TRIM_PERCENT),
+ timeToFirstByte:
+ ttfbValues.length > 0
+ ? calculateTrimmedMean(ttfbValues, TRIM_PERCENT)
+ : 0,
+ interactionToNextPaint:
+ inpValues.length > 0
+ ? calculateTrimmedMean(inpValues, TRIM_PERCENT)
+ : 0,
+ cumulativeLayoutShift: calculateTrimmedMean(clsValues, TRIM_PERCENT),
+ totalRequests: calculateTrimmedMean(totalRequestsValues, TRIM_PERCENT),
+ totalSize: calculateTrimmedMean(totalSizeValues, TRIM_PERCENT),
+ jsSize: calculateTrimmedMean(jsSizeValues, TRIM_PERCENT),
+ cssSize: calculateTrimmedMean(cssSizeValues, TRIM_PERCENT),
+ imageSize: calculateTrimmedMean(imageSizeValues, TRIM_PERCENT),
+ fontSize: calculateTrimmedMean(fontSizeValues, TRIM_PERCENT),
+ otherSize: calculateTrimmedMean(otherSizeValues, TRIM_PERCENT),
+ thirdPartyRequests: calculateTrimmedMean(
+ thirdPartyRequestValues,
+ TRIM_PERCENT
+ ),
+ thirdPartySize: calculateTrimmedMean(thirdPartySizeValues, TRIM_PERCENT),
+ thirdPartyDomains: this.calculateAverageThirdPartyDomainCount(results),
+ cookieBannerVisibleTime: calculateTrimmedMean(
+ bannerVisibleValues,
+ TRIM_PERCENT
+ ),
+ cookieBannerDomPresenceTime: calculateTrimmedMean(
+ bannerDomValues,
+ TRIM_PERCENT
+ ),
+ cookieBannerCoverage: calculateTrimmedMean(
+ bannerCoverageValues,
+ TRIM_PERCENT
+ ),
+ scriptLoadTime: calculateTrimmedMean(scriptLoadValues, TRIM_PERCENT),
+ };
+ }
+
+ getStatisticalSummary(results: BenchmarkDetails[]): BenchmarkStatistics {
+ const fcpValues = results.map((r) => r.timing.firstContentfulPaint);
+ const lcpValues = results.map((r) => r.timing.largestContentfulPaint);
+ const ttiValues = results.map((r) => r.timing.timeToInteractive);
+ const tbtValues = results.map((r) => r.timing.mainThreadBlocking.total);
+ const clsValues = results.map((r) => r.timing.cumulativeLayoutShift);
+ const ttfbValues = results
+ .map((r) => r.timing.timeToFirstByte)
+ .filter((value): value is number => value !== null && value > 0);
+ const bannerVisibleValues = results
+ .map(
+ (r) => r.cookieBanner.userVisibleTime ?? r.cookieBanner.visibilityTime
+ )
+ .filter((value): value is number => value !== null && value > 0);
+
+ return {
+ fcp: calculateStatistics(fcpValues),
+ lcp: calculateStatistics(lcpValues),
+ tti: calculateStatistics(ttiValues),
+ tbt: calculateStatistics(tbtValues),
+ cls: calculateStatistics(clsValues),
+ ttfb: calculateStatistics(ttfbValues),
+ bannerVisibleTime: calculateStatistics(bannerVisibleValues),
+ };
+ }
+
+ logResults(
+ finalMetrics: BenchmarkDetails,
+ cookieBannerMetrics: CookieBannerMetrics,
+ config: Config
+ ): void {
+ let bundleStrategy = "Unknown";
+ if (cookieBannerMetrics.isBundled) {
+ bundleStrategy = "Bundled";
+ } else if (cookieBannerMetrics.isIIFE) {
+ bundleStrategy = "IIFE";
+ }
+
+ this.logger.debug("Final cookie banner benchmark results:", {
+ fcp: finalMetrics.timing.firstContentfulPaint,
+ lcp: finalMetrics.timing.largestContentfulPaint,
+ cls: finalMetrics.timing.cumulativeLayoutShift,
+ tti: finalMetrics.timing.timeToInteractive,
+ tbt: finalMetrics.timing.mainThreadBlocking.total,
+ bannerDetected: finalMetrics.cookieBanner.detected,
+ bannerVisibleTime: finalMetrics.cookieBanner.userVisibleTime,
+ bannerLayoutShift: finalMetrics.timing.cookieBanner.layoutShift,
+ bannerNetworkImpact: finalMetrics.thirdParty.totalImpact,
+ bundleStrategy,
+ isBundled: cookieBannerMetrics.isBundled,
+ isIIFE: cookieBannerMetrics.isIIFE,
+ configBundleType: config.techStack?.bundleType,
+ });
+ }
+
+ logStatisticalSummary(results: BenchmarkDetails[]): void {
+ if (results.length === 0) {
+ return;
+ }
+
+ const summary = this.getStatisticalSummary(results);
+
+ this.logger.info("📊 Statistical Summary:");
+ this.logger.info(
+ ` FCP mean ${summary.fcp.mean.toFixed(0)}ms | p50 ${summary.fcp.p50.toFixed(0)}ms | p95 ${summary.fcp.p95.toFixed(0)}ms | CV ${summary.fcp.cv.toFixed(1)}%`
+ );
+ this.logger.info(
+ ` LCP mean ${summary.lcp.mean.toFixed(0)}ms | p50 ${summary.lcp.p50.toFixed(0)}ms | p95 ${summary.lcp.p95.toFixed(0)}ms | CV ${summary.lcp.cv.toFixed(1)}%`
+ );
+ this.logger.info(
+ ` TTI mean ${summary.tti.mean.toFixed(0)}ms | p50 ${summary.tti.p50.toFixed(0)}ms | p95 ${summary.tti.p95.toFixed(0)}ms | CV ${summary.tti.cv.toFixed(1)}%`
+ );
+ this.logger.info(
+ ` TBT mean ${summary.tbt.mean.toFixed(0)}ms | p50 ${summary.tbt.p50.toFixed(0)}ms | p95 ${summary.tbt.p95.toFixed(0)}ms | CV ${summary.tbt.cv.toFixed(1)}%`
+ );
+ this.logger.info(
+ ` FCP CI95 [${summary.fcp.ci95Low.toFixed(0)}, ${summary.fcp.ci95High.toFixed(0)}] ms`
+ );
+
+ const fcpStable = !this.hasMeaningfulVariability(
+ results.map((r) => r.timing.firstContentfulPaint),
+ STABILITY_THRESHOLD,
+ FCP_LCP_MIN_STDDEV_MS,
+ FCP_LCP_MIN_P95_P50_SPREAD_MS
+ ).unstable;
+ const lcpStable = !this.hasMeaningfulVariability(
+ results.map((r) => r.timing.largestContentfulPaint),
+ STABILITY_THRESHOLD,
+ FCP_LCP_MIN_STDDEV_MS,
+ FCP_LCP_MIN_P95_P50_SPREAD_MS
+ ).unstable;
+ const ttiStable = !this.hasMeaningfulVariability(
+ results.map((r) => r.timing.timeToInteractive),
+ STABILITY_THRESHOLD,
+ TTI_MIN_STDDEV_MS,
+ TTI_MIN_P95_P50_SPREAD_MS
+ ).unstable;
+
+ if (fcpStable) {
+ this.logger.info(" ✓ FCP is stable");
+ }
+ if (lcpStable) {
+ this.logger.info(" ✓ LCP is stable");
+ }
+ if (ttiStable) {
+ this.logger.info(" ✓ TTI is stable");
+ }
+ }
+}
diff --git a/packages/runner/src/server.ts b/packages/runner/src/server.ts
new file mode 100644
index 0000000..4606b7c
--- /dev/null
+++ b/packages/runner/src/server.ts
@@ -0,0 +1,128 @@
+import { spawn } from "node:child_process";
+import type { Logger } from "@c15t/logger";
+import type { ServerInfo } from "./types";
+import { getPackageManager, ONE_SECOND } from "./utils";
+
+export async function buildAndServeNextApp(
+ logger: Logger,
+ appPath?: string
+): Promise {
+ const pm = await getPackageManager();
+ const cwd = appPath || process.cwd();
+
+ // Build the app
+ logger.info("Building Next.js app...");
+ const buildProcess = spawn(pm.command, [...pm.args, "build"], {
+ cwd,
+ stdio: "inherit",
+ });
+
+ await new Promise((resolve, reject) => {
+ let settled = false;
+ const onError = (error: Error) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ reject(
+ new Error(
+ `Build process failed to start or crashed early: ${error.message}`
+ )
+ );
+ };
+ const onClose = (code: number | null) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error(`Build failed with code ${code}`));
+ }
+ };
+ buildProcess.once("error", onError);
+ buildProcess.once("close", onClose);
+ });
+
+ // Start the server
+ logger.info("Starting Next.js server...");
+ // biome-ignore lint/style/noMagicNumbers: working with random port
+ const port = Math.floor(Math.random() * (9000 - 3000 + 1)) + 3000;
+ logger.debug("Server command:", [
+ ...pm.args,
+ "start",
+ ...(pm.requiresScriptArgSeparator ? ["--"] : []),
+ "--port",
+ port.toString(),
+ ]);
+ const startArgs = [
+ ...pm.args,
+ "start",
+ ...(pm.requiresScriptArgSeparator ? ["--"] : []),
+ "--port",
+ port.toString(),
+ ];
+ const serverProcess = spawn(pm.command, startArgs, {
+ cwd,
+ stdio: ["inherit", "inherit", "inherit"],
+ });
+
+ // Wait for server to be ready
+ const url = `http://localhost:${port}`;
+ let retries = 0;
+ const maxRetries = 30;
+ const requestTimeoutMs = 5000;
+ let crashErrorMessage: string | null = null;
+ const onServerExit = (code: number | null, signal: NodeJS.Signals | null) => {
+ crashErrorMessage = `Server process exited before ready (code: ${code}, signal: ${signal})`;
+ };
+ const onServerError = (error: Error) => {
+ crashErrorMessage = `Server process failed before ready: ${error.message}`;
+ };
+ serverProcess.once("exit", onServerExit);
+ serverProcess.once("error", onServerError);
+
+ while (retries < maxRetries) {
+ if (crashErrorMessage !== null) {
+ logger.error(crashErrorMessage);
+ serverProcess.kill();
+ serverProcess.removeListener("exit", onServerExit);
+ serverProcess.removeListener("error", onServerError);
+ throw new Error(crashErrorMessage);
+ }
+
+ const controller = new AbortController();
+ const timeoutHandle = setTimeout(() => {
+ controller.abort();
+ }, requestTimeoutMs);
+ try {
+ const response = await fetch(url, { signal: controller.signal });
+ if (response.ok) {
+ clearTimeout(timeoutHandle);
+ serverProcess.removeListener("exit", onServerExit);
+ serverProcess.removeListener("error", onServerError);
+ logger.success("Server is ready!");
+ return { serverProcess, url };
+ }
+ } catch {
+ // Ignore error and retry
+ } finally {
+ clearTimeout(timeoutHandle);
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, ONE_SECOND));
+ retries += 1;
+ }
+
+ serverProcess.removeListener("exit", onServerExit);
+ serverProcess.removeListener("error", onServerError);
+ serverProcess.kill();
+ throw new Error("Server failed to start");
+}
+
+export function cleanupServer(serverInfo: ServerInfo): void {
+ if (serverInfo.serverProcess) {
+ serverInfo.serverProcess.kill();
+ }
+}
diff --git a/packages/runner/src/statistics.ts b/packages/runner/src/statistics.ts
new file mode 100644
index 0000000..38d5c3c
--- /dev/null
+++ b/packages/runner/src/statistics.ts
@@ -0,0 +1,175 @@
+// Constants for statistics calculations
+const PERCENTILE_50 = 50;
+const PERCENTILE_95 = 95;
+const PERCENTILE_99 = 99;
+const Z_SCORE_95_CI = 1.96;
+const DEFAULT_TRIM_PERCENT = 10;
+const PERCENTAGE_CONVERSION = 100;
+const STABILITY_THRESHOLD = 15;
+
+/**
+ * Calculate statistical metrics for an array of numbers (inspired by Mitata's approach)
+ */
+export function calculateStatistics(values: number[]): {
+ sampleCount: number;
+ mean: number;
+ p50: number;
+ median: number;
+ stddev: number;
+ cv: number;
+ min: number;
+ max: number;
+ p95: number;
+ p99: number;
+ ci95Low: number;
+ ci95High: number;
+} {
+ if (values.length === 0) {
+ return {
+ sampleCount: 0,
+ mean: 0,
+ p50: 0,
+ median: 0,
+ stddev: 0,
+ cv: 0,
+ min: 0,
+ max: 0,
+ p95: 0,
+ p99: 0,
+ ci95Low: 0,
+ ci95High: 0,
+ };
+ }
+
+ const sorted = [...values].sort((a, b) => a - b);
+ const sampleCount = values.length;
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
+ const variance =
+ values.length > 1
+ ? values.reduce((acc, val) => acc + (val - mean) ** 2, 0) /
+ (values.length - 1)
+ : 0;
+ const stddev = Math.sqrt(variance);
+ const median = getMedian(sorted);
+ const p50 = getPercentile(sorted, PERCENTILE_50);
+ const p95 = getPercentile(sorted, PERCENTILE_95);
+ const p99 = getPercentile(sorted, PERCENTILE_99);
+ const cv = calculateCoefficientOfVariation(values);
+ const ci95Delta =
+ sampleCount > 0 ? Z_SCORE_95_CI * (stddev / Math.sqrt(sampleCount)) : 0;
+
+ const lastIndex = sorted.length - 1;
+ const maxValue = lastIndex >= 0 ? sorted[lastIndex] : 0;
+ return {
+ sampleCount,
+ mean,
+ p50,
+ median,
+ stddev,
+ cv,
+ min: sorted[0],
+ max: maxValue,
+ p95,
+ p99,
+ ci95Low: mean - ci95Delta,
+ ci95High: mean + ci95Delta,
+ };
+}
+
+/**
+ * Calculate median from sorted array
+ */
+function getMedian(sorted: number[]): number {
+ const mid = Math.floor(sorted.length / 2);
+ if (sorted.length % 2 === 0) {
+ return (sorted[mid - 1] + sorted[mid]) / 2;
+ }
+ return sorted[mid];
+}
+
+/**
+ * Calculate percentile from sorted array
+ */
+function getPercentile(sorted: number[], percentile: number): number {
+ const index = (percentile / PERCENTAGE_CONVERSION) * (sorted.length - 1);
+ const lower = Math.floor(index);
+ const upper = Math.ceil(index);
+ const weight = index - lower;
+
+ if (lower === upper) {
+ return sorted[lower];
+ }
+
+ return sorted[lower] * (1 - weight) + sorted[upper] * weight;
+}
+
+/**
+ * Calculate robust average using trimmed mean (removes outliers)
+ */
+export function calculateTrimmedMean(
+ values: number[],
+ trimPercent = DEFAULT_TRIM_PERCENT
+): number {
+ if (values.length === 0) {
+ return 0;
+ }
+
+ if (values.length <= 2) {
+ return values.reduce((a, b) => a + b, 0) / values.length;
+ }
+
+ const sorted = [...values].sort((a, b) => a - b);
+ const normalizedTrimPercent = Math.max(
+ 0,
+ Math.min(trimPercent, PERCENTAGE_CONVERSION / 2)
+ );
+ const requestedTrimCount = Math.floor(
+ (values.length * normalizedTrimPercent) / PERCENTAGE_CONVERSION
+ );
+ const trimCount = Math.max(
+ 0,
+ Math.min(requestedTrimCount, Math.floor(values.length / 2))
+ );
+ const trimmed = sorted.slice(trimCount, values.length - trimCount);
+
+ if (trimmed.length === 0) {
+ return sorted[Math.floor(sorted.length / 2)];
+ }
+
+ return trimmed.reduce((a, b) => a + b, 0) / trimmed.length;
+}
+
+/**
+ * Calculate coefficient of variation (CV) as a measure of stability
+ */
+export function calculateCoefficientOfVariation(values: number[]): number {
+ if (values.length === 0) {
+ return 0;
+ }
+
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
+ const variance =
+ values.length > 1
+ ? values.reduce((acc, val) => acc + (val - mean) ** 2, 0) /
+ (values.length - 1)
+ : 0;
+ const stddev = Math.sqrt(variance);
+
+ if (mean === 0) {
+ // CV is undefined when mean is zero; return NaN so callers can handle this explicitly.
+ return Number.NaN;
+ }
+
+ return (stddev / mean) * PERCENTAGE_CONVERSION;
+}
+
+/**
+ * Check if values are statistically stable (low coefficient of variation)
+ */
+export function isStable(
+ values: number[],
+ threshold = STABILITY_THRESHOLD
+): boolean {
+ const cv = calculateCoefficientOfVariation(values);
+ return cv < threshold;
+}
diff --git a/packages/runner/src/types.ts b/packages/runner/src/types.ts
new file mode 100644
index 0000000..fc786fe
--- /dev/null
+++ b/packages/runner/src/types.ts
@@ -0,0 +1,323 @@
+import type { ChildProcess } from "node:child_process";
+import type { BundleType } from "@consentio/benchmark";
+
+export type {
+ BundleType,
+ BundleStrategy,
+ CacheMode,
+ Config,
+ CookieBannerConfig,
+ CookieBannerData,
+ CookieBannerMetrics,
+ CoreWebVitals,
+ MeasurementConfig,
+ NetworkMetrics,
+ NetworkProfile,
+ NetworkRequest,
+ PerfumeMetrics,
+ ResourceTimingData,
+ RunProfile,
+} from "@consentio/benchmark";
+
+export type ServerInfo = {
+ serverProcess: ChildProcess;
+ url: string;
+};
+
+export type BenchmarkDetails = {
+ duration: number;
+ size: {
+ total: number;
+ bundled: number;
+ thirdParty: number;
+ cookieServices: number;
+ scripts: {
+ total: number;
+ initial: number;
+ dynamic: number;
+ thirdParty: number;
+ cookieServices: number;
+ };
+ styles: number;
+ images: number;
+ fonts: number;
+ other: number;
+ };
+ timing: {
+ navigationStart: number;
+ domContentLoaded: number;
+ load: number;
+ firstPaint: number;
+ firstContentfulPaint: number;
+ largestContentfulPaint: number;
+ timeToInteractive: number;
+ cumulativeLayoutShift: number;
+ timeToFirstByte: number | null;
+ firstInputDelay: number | null;
+ interactionToNextPaint: number | null;
+ navigationTiming: {
+ timeToFirstByte: number;
+ domInteractive: number;
+ domContentLoadedEventStart: number;
+ domContentLoadedEventEnd: number;
+ domComplete: number;
+ loadEventStart: number;
+ loadEventEnd: number;
+ };
+ networkInformation?: {
+ effectiveType: string;
+ downlink: number;
+ rtt: number;
+ saveData: boolean;
+ };
+ cookieBanner: {
+ renderStart: number;
+ renderEnd: number;
+ interactionStart: number;
+ interactionEnd: number;
+ layoutShift: number;
+ detected: boolean;
+ selector: string | null;
+ serviceName: string;
+ visibilityTime: number | null;
+ domPresenceTime: number;
+ userVisibleTime: number;
+ viewportCoverage: number;
+ };
+ thirdParty: {
+ dnsLookupTime: number;
+ connectionTime: number;
+ downloadTime: number;
+ totalImpact: number;
+ cookieServices: {
+ hosts: string[];
+ totalSize: number;
+ resourceCount: number;
+ dnsLookupTime: number;
+ connectionTime: number;
+ downloadTime: number;
+ };
+ };
+ mainThreadBlocking: {
+ total: number;
+ cookieBannerEstimate: number;
+ percentageFromCookies: number;
+ };
+ scripts: {
+ bundled: {
+ loadStart: number;
+ loadEnd: number;
+ executeStart: number;
+ executeEnd: number;
+ };
+ thirdParty: {
+ loadStart: number;
+ loadEnd: number;
+ executeStart: number;
+ executeEnd: number;
+ };
+ };
+ };
+ language: string;
+ resources: {
+ scripts: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isDynamic: boolean;
+ isCookieService: boolean;
+ dnsTime: number;
+ connectionTime: number;
+ }>;
+ styles: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ }>;
+ images: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ }>;
+ fonts: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ }>;
+ other: Array<{
+ name: string;
+ size: number;
+ duration: number;
+ startTime: number;
+ isThirdParty: boolean;
+ isCookieService: boolean;
+ type: string;
+ }>;
+ };
+ dom?: {
+ size?: number;
+ };
+ cookieBanner: {
+ detected: boolean;
+ selector: string | null;
+ serviceName: string;
+ visibilityTime: number | null;
+ domPresenceTime: number;
+ userVisibleTime: number;
+ viewportCoverage: number;
+ };
+ thirdParty: {
+ cookieServices: {
+ hosts: string[];
+ totalSize: number;
+ resourceCount: number;
+ dnsLookupTime: number;
+ connectionTime: number;
+ downloadTime: number;
+ };
+ totalImpact: number;
+ };
+};
+
+export type MetricStatistics = {
+ sampleCount: number;
+ mean: number;
+ p50: number;
+ median: number;
+ stddev: number;
+ cv: number;
+ min: number;
+ max: number;
+ p95: number;
+ p99: number;
+ ci95Low: number;
+ ci95High: number;
+};
+
+export type BenchmarkStatistics = {
+ fcp: MetricStatistics;
+ lcp: MetricStatistics;
+ tti: MetricStatistics;
+ tbt: MetricStatistics;
+ cls: MetricStatistics;
+ ttfb: MetricStatistics;
+ bannerVisibleTime: MetricStatistics;
+};
+
+export type BenchmarkQuality = {
+ requestedIterations: number;
+ successfulIterations: number;
+ failedIterations: number;
+ failureRate: number;
+ minSuccessfulIterations: number;
+ maxFailureRate: number;
+ stabilityThresholdCv: number;
+ stable: boolean;
+ unstableMetrics: string[];
+};
+
+export type BenchmarkEnvironment = {
+ chromiumVersion: string;
+};
+
+export type BenchmarkResult = {
+ name: string;
+ baseline: boolean;
+ techStack: {
+ bundler: string;
+ bundleType: BundleType | BundleType[];
+ frameworks: string[];
+ languages: string[];
+ packageManager: string;
+ typescript: boolean;
+ };
+ source: {
+ github: string | false;
+ isOpenSource: boolean | string;
+ license: string;
+ npm: string | false;
+ website?: string;
+ };
+ includes: {
+ backend: string | string[] | false;
+ components: string[];
+ };
+ company?: {
+ name: string;
+ website: string;
+ avatar: string;
+ };
+ tags?: string[];
+ details: BenchmarkDetails[];
+ average: {
+ firstContentfulPaint: number;
+ largestContentfulPaint: number;
+ timeToInteractive: number;
+ totalBlockingTime: number;
+ timeToFirstByte: number;
+ interactionToNextPaint: number;
+ cumulativeLayoutShift: number;
+ totalRequests: number;
+ totalSize: number;
+ jsSize: number;
+ cssSize: number;
+ imageSize: number;
+ fontSize: number;
+ otherSize: number;
+ thirdPartyRequests: number;
+ thirdPartySize: number;
+ thirdPartyDomains: number;
+ cookieBannerVisibleTime: number;
+ cookieBannerDomPresenceTime: number;
+ cookieBannerCoverage: number;
+ scriptLoadTime: number;
+ };
+ statistics: BenchmarkStatistics;
+ quality: BenchmarkQuality;
+ environment: BenchmarkEnvironment;
+ scores?: {
+ totalScore: number;
+ grade: "Excellent" | "Good" | "Fair" | "Poor" | "Critical";
+ categoryScores: {
+ performance: number;
+ bundleStrategy: number;
+ networkImpact: number;
+ transparency: number;
+ userExperience: number;
+ };
+ indexes?: {
+ performanceIndex: number;
+ governanceIndex: number;
+ combinedIndex: number;
+ };
+ categories: Array<{
+ name: string;
+ score: number;
+ maxScore: number;
+ weight: number;
+ details: Array<{
+ name: string;
+ score: number;
+ maxScore: number;
+ weight: number;
+ status: "excellent" | "good" | "fair" | "poor" | "critical";
+ reason: string;
+ }>;
+ status: "excellent" | "good" | "fair" | "poor" | "critical";
+ reason: string;
+ }>;
+ insights: string[];
+ recommendations: string[];
+ };
+};
diff --git a/packages/runner/src/utils.ts b/packages/runner/src/utils.ts
new file mode 100644
index 0000000..7c96e42
--- /dev/null
+++ b/packages/runner/src/utils.ts
@@ -0,0 +1,156 @@
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import type { Config } from "@consentio/benchmark";
+
+export const ONE_SECOND = 1000;
+export function readConfig(configPath?: string): Config | null {
+ try {
+ const path = configPath || join(process.cwd(), "config.json");
+ const configContent = readFileSync(path, "utf-8");
+ return JSON.parse(configContent) as Config;
+ } catch (error) {
+ // biome-ignore lint/suspicious/noConsole: console error is needed for debugging
+ console.error("Failed to read config.json:", error);
+ return null;
+ }
+}
+
+export function formatTime(ms: number): string {
+ if (ms < ONE_SECOND) {
+ return `${ms.toFixed(0)}ms`;
+ }
+ return `${(ms / ONE_SECOND).toFixed(2)}s`;
+}
+
+export async function getPackageManager(): Promise<{
+ command: string;
+ args: string[];
+ requiresScriptArgSeparator: boolean;
+}> {
+ function parsePackageManager(packageManager: string | undefined): {
+ command: string;
+ args: string[];
+ requiresScriptArgSeparator: boolean;
+ } | null {
+ if (!packageManager) {
+ return null;
+ }
+ if (packageManager.startsWith("pnpm@")) {
+ return {
+ command: "pnpm",
+ args: [],
+ requiresScriptArgSeparator: false,
+ };
+ }
+ if (packageManager.startsWith("yarn@")) {
+ return {
+ command: "yarn",
+ args: [],
+ requiresScriptArgSeparator: false,
+ };
+ }
+ if (packageManager.startsWith("npm@")) {
+ return {
+ command: "npm",
+ args: ["run"],
+ requiresScriptArgSeparator: true,
+ };
+ }
+ return null;
+ }
+
+ let currentDir = process.cwd();
+ while (true) {
+ if (existsSync(join(currentDir, "pnpm-lock.yaml"))) {
+ return {
+ command: "pnpm",
+ args: [],
+ requiresScriptArgSeparator: false,
+ };
+ }
+ if (existsSync(join(currentDir, "yarn.lock"))) {
+ return {
+ command: "yarn",
+ args: [],
+ requiresScriptArgSeparator: false,
+ };
+ }
+ if (existsSync(join(currentDir, "package-lock.json"))) {
+ return {
+ command: "npm",
+ args: ["run"],
+ requiresScriptArgSeparator: true,
+ };
+ }
+
+ const packageJsonPath = join(currentDir, "package.json");
+ if (existsSync(packageJsonPath)) {
+ try {
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
+ packageManager?: string;
+ };
+ const detected = parsePackageManager(pkg.packageManager);
+ if (detected) {
+ return detected;
+ }
+ } catch {
+ // Ignore malformed package.json and continue fallback detection.
+ }
+ }
+
+ const parentDir = dirname(currentDir);
+ if (parentDir === currentDir) {
+ break;
+ }
+ currentDir = parentDir;
+ }
+
+ try {
+ const { execSync } = await import("node:child_process");
+ const output = execSync("pnpm -v", { encoding: "utf-8" });
+ if (output) {
+ return {
+ command: "pnpm",
+ args: [],
+ requiresScriptArgSeparator: false,
+ };
+ }
+ } catch {
+ try {
+ const { execSync } = await import("node:child_process");
+ const output = execSync("yarn -v", { encoding: "utf-8" });
+ if (output) {
+ return {
+ command: "yarn",
+ args: [],
+ requiresScriptArgSeparator: false,
+ };
+ }
+ } catch {
+ try {
+ const { execSync } = await import("node:child_process");
+ const output = execSync("npm -v", { encoding: "utf-8" });
+ if (output) {
+ return {
+ command: "npm",
+ args: ["run"],
+ requiresScriptArgSeparator: true,
+ };
+ }
+ } catch {
+ // Default to npm if no package manager is found
+ return {
+ command: "npm",
+ args: ["run"],
+ requiresScriptArgSeparator: true,
+ };
+ }
+ }
+ }
+ // Default to npm if no package manager is found
+ return {
+ command: "npm",
+ args: ["run"],
+ requiresScriptArgSeparator: true,
+ };
+}
diff --git a/packages/runner/tsconfig.json b/packages/runner/tsconfig.json
new file mode 100644
index 0000000..fde9b9d
--- /dev/null
+++ b/packages/runner/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/shared/README.md b/packages/shared/README.md
new file mode 100644
index 0000000..40da7d8
--- /dev/null
+++ b/packages/shared/README.md
@@ -0,0 +1,99 @@
+# @consentio/shared
+
+Shared utilities, constants, and helper functions used across all Consentio benchmark packages.
+
+## Purpose
+
+This package contains common functionality that is used by multiple packages in the monorepo:
+- `@consentio/benchmark` - Core benchmarking logic
+- `@consentio/runner` - Benchmark orchestration
+- `cookiebench` - CLI tool
+
+## Contents
+
+### Constants
+
+- **Time constants**: `ONE_SECOND` (1000ms), `HALF_SECOND` (500ms), `TTI_BUFFER_MS` (1000ms)
+- **Size constants**: `KILOBYTE` (1024)
+- **Percentage constants**: `PERCENTAGE_MULTIPLIER` (100), `PERCENTAGE_DIVISOR` (100)
+
+### Utilities
+
+#### Time Formatting
+- **`formatTime(ms: number): string`** - Convert milliseconds to human-readable format
+ - Returns `"150ms"` for values < 1 second
+ - Returns `"1.50s"` for values ≥ 1 second
+
+#### Byte Formatting
+- **`formatBytes(bytes: number): string`** - Format bytes to human-readable string with appropriate units
+ - Returns `"0 bytes"`, `"1.50 KB"`, `"2.00 MB"`, etc.
+
+#### Config Management
+- **`readConfig(path?: string): T | null`** - Read and parse JSON config files
+ - Defaults to `./config.json` if no path provided
+ - Returns `null` if file cannot be read
+ - Generic type parameter allows for type-safe config objects
+
+#### Package Manager Detection
+- **`getPackageManager(): Promise<{ command: string; args: string[] }>`** - Detect and return available package manager (npm/yarn/pnpm)
+
+#### Conversion Helpers
+- **`bytesToKB(bytes: number): number`** - Convert bytes to kilobytes
+- **`decimalToPercentage(decimal: number): number`** - Convert 0.75 → 75
+- **`percentageToDecimal(percentage: number): number`** - Convert 75 → 0.75
+
+## Usage
+
+```typescript
+import {
+ ONE_SECOND,
+ formatTime,
+ formatBytes,
+ readConfig,
+ bytesToKB
+} from '@consentio/shared';
+
+// Format time
+console.log(formatTime(1500)); // "1.50s"
+console.log(formatTime(150)); // "150ms"
+
+// Format bytes
+console.log(formatBytes(1536)); // "1.50 KB"
+console.log(formatBytes(2097152)); // "2.00 MB"
+
+// Read config
+const config = readConfig('./my-config.json');
+
+// Use constants
+import { setTimeout } from "node:timers/promises"; // Promise-based setTimeout (Node.js 16+)
+await setTimeout(ONE_SECOND);
+
+// Convert sizes
+const sizeInKB = bytesToKB(2048); // 2
+```
+
+## Integration Status
+
+✅ **@consentio/benchmark** - Fully integrated
+ - Uses shared constants for time and size conversions
+ - Imports directly via `@consentio/shared`
+
+✅ **@consentio/runner** - Fully integrated
+ - Re-exports shared utilities with proper Config typing
+ - Provides typed wrappers: `formatTime`, `getPackageManager`, `readConfig`
+
+✅ **cookiebench CLI** - Fully integrated
+ - Re-exports shared utilities and constants via `utils/index.ts`
+ - Provides typed `readConfig` wrapper for CLI Config type
+ - All duplicate implementations removed
+
+## Benefits
+
+- ✅ Single source of truth for shared functionality
+- ✅ No code duplication across packages
+- ✅ Easier maintenance and testing
+- ✅ Smaller bundle sizes
+- ✅ Type-safe utilities with proper TypeScript support
+- ✅ Consistent behavior across all packages
+
+
diff --git a/packages/shared/package.json b/packages/shared/package.json
new file mode 100644
index 0000000..9f6d987
--- /dev/null
+++ b/packages/shared/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@consentio/shared",
+ "version": "0.0.1",
+ "type": "module",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "scripts": {
+ "build": "rslib build",
+ "check-types": "tsc --noEmit",
+ "dev": "rslib build --watch",
+ "fmt": "biome format . --write",
+ "lint": "biome lint ."
+ },
+ "devDependencies": {
+ "@cookiebench/ts-config": "workspace:*",
+ "@rsdoctor/rspack-plugin": "^1.5.2",
+ "@rslib/core": "^0.16.1",
+ "@types/node": "catalog:",
+ "typescript": "catalog:"
+ }
+}
diff --git a/packages/shared/rslib.config.ts b/packages/shared/rslib.config.ts
new file mode 100644
index 0000000..a782543
--- /dev/null
+++ b/packages/shared/rslib.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from "@rslib/core";
+
+export default defineConfig({
+ lib: [
+ {
+ format: "esm",
+ syntax: "es2021",
+ dts: true,
+ },
+ ],
+ source: {
+ entry: {
+ index: "./src/index.ts",
+ },
+ },
+ output: {
+ target: "node",
+ },
+});
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
new file mode 100644
index 0000000..8ce70ce
--- /dev/null
+++ b/packages/shared/src/constants.ts
@@ -0,0 +1,15 @@
+// Time constants
+export const ONE_SECOND = 1000;
+export const HALF_SECOND = 500;
+
+// Size constants (bytes to kilobytes)
+export const KILOBYTE = 1024;
+
+// Percentage constants
+// Multiplier used when converting decimal ratios to percentage units.
+export const PERCENTAGE_MULTIPLIER = 100;
+// Divisor used when converting percentage units back to decimal ratios.
+export const PERCENTAGE_DIVISOR = 100;
+
+// Common thresholds
+export const TTI_BUFFER_MS = ONE_SECOND; // Buffer for true interactivity
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
new file mode 100644
index 0000000..a5d5195
--- /dev/null
+++ b/packages/shared/src/index.ts
@@ -0,0 +1,21 @@
+/** biome-ignore-all lint/performance/noBarrelFile: this is a barrel file */
+
+// Constants
+export {
+ HALF_SECOND,
+ KILOBYTE,
+ ONE_SECOND,
+ PERCENTAGE_DIVISOR,
+ PERCENTAGE_MULTIPLIER,
+ TTI_BUFFER_MS,
+} from "./constants";
+export { type BaseConfig, readConfig } from "./utils/config";
+// Utilities
+export {
+ bytesToKB,
+ decimalToPercentage,
+ formatBytes,
+ percentageToDecimal,
+} from "./utils/conversion";
+export { getPackageManager } from "./utils/package-manager";
+export { formatTime } from "./utils/time";
diff --git a/packages/shared/src/utils/config.ts b/packages/shared/src/utils/config.ts
new file mode 100644
index 0000000..60c165a
--- /dev/null
+++ b/packages/shared/src/utils/config.ts
@@ -0,0 +1,35 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+/**
+ * Generic config type - packages should define their own specific config types
+ */
+export type BaseConfig = Record;
+
+/**
+ * Read and parse a JSON config file
+ *
+ * Note: `readConfig` only applies the generic `T` type at compile time.
+ * It does not validate JSON structure at runtime. If strict runtime safety is
+ * required, validate the parsed value for the provided `configPath` before
+ * using the returned value.
+ *
+ * @param configPath - Optional path to config file, defaults to ./config.json
+ * @returns Parsed config object or null if file cannot be read
+ */
+export function readConfig(
+ configPath?: string
+): T | null {
+ try {
+ const resolvedPath = configPath || join(process.cwd(), "config.json");
+ const configContent = readFileSync(resolvedPath, "utf-8");
+ return JSON.parse(configContent) as T;
+ } catch (error) {
+ process.stderr.write(
+ `Failed to read config at ${
+ configPath || join(process.cwd(), "config.json")
+ }: ${error instanceof Error ? error.message : String(error)}\n`
+ );
+ return null;
+ }
+}
diff --git a/packages/shared/src/utils/conversion.ts b/packages/shared/src/utils/conversion.ts
new file mode 100644
index 0000000..22b5ca5
--- /dev/null
+++ b/packages/shared/src/utils/conversion.ts
@@ -0,0 +1,46 @@
+import { KILOBYTE, PERCENTAGE_MULTIPLIER } from "../constants";
+
+/**
+ * Convert bytes to kilobytes
+ * @param bytes - Size in bytes
+ * @returns Size in kilobytes
+ */
+export function bytesToKB(bytes: number): number {
+ return bytes / KILOBYTE;
+}
+
+/**
+ * Format bytes to human-readable string with appropriate units
+ * @param bytes - Size in bytes
+ * @returns Formatted string (e.g., "1.50 KB", "2.00 MB")
+ */
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) {
+ return "0 bytes";
+ }
+ const safeBytes = Math.max(0, bytes);
+ const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB"];
+ const rawIndex = Math.floor(Math.log(safeBytes) / Math.log(KILOBYTE));
+ const unitIndex = Math.max(0, Math.min(rawIndex, sizes.length - 1));
+ const normalizedValue = safeBytes / KILOBYTE ** unitIndex;
+ const safeValue = Number.isFinite(normalizedValue) ? normalizedValue : 0;
+ return `${Number.parseFloat(safeValue.toFixed(2))} ${sizes[unitIndex]}`;
+}
+
+/**
+ * Convert decimal to percentage
+ * @param decimal - Decimal value (e.g., 0.75)
+ * @returns Percentage value (e.g., 75)
+ */
+export function decimalToPercentage(decimal: number): number {
+ return decimal * PERCENTAGE_MULTIPLIER;
+}
+
+/**
+ * Convert percentage to decimal
+ * @param percentage - Percentage value (e.g., 75)
+ * @returns Decimal value (e.g., 0.75)
+ */
+export function percentageToDecimal(percentage: number): number {
+ return percentage / PERCENTAGE_MULTIPLIER;
+}
diff --git a/packages/shared/src/utils/package-manager.ts b/packages/shared/src/utils/package-manager.ts
new file mode 100644
index 0000000..d3dffab
--- /dev/null
+++ b/packages/shared/src/utils/package-manager.ts
@@ -0,0 +1,85 @@
+import { execSync } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+
+type PackageManagerInfo = {
+ command: string;
+ args: string[];
+};
+
+function fromName(name: string): PackageManagerInfo | null {
+ if (name.startsWith("pnpm")) {
+ return { command: "pnpm", args: [] };
+ }
+ if (name.startsWith("yarn")) {
+ return { command: "yarn", args: [] };
+ }
+ if (name.startsWith("npm")) {
+ return { command: "npm", args: ["run"] };
+ }
+ return null;
+}
+
+/**
+ * Detect and return the available package manager
+ * @returns Package manager command and args
+ */
+export function getPackageManager(): PackageManagerInfo {
+ const userAgent = process.env.npm_config_user_agent || "";
+ const userAgentResult = fromName(userAgent);
+ if (userAgentResult) {
+ return userAgentResult;
+ }
+
+ try {
+ const rootPackageJsonPath = join(process.cwd(), "package.json");
+ if (existsSync(rootPackageJsonPath)) {
+ const packageJson = JSON.parse(
+ readFileSync(rootPackageJsonPath, "utf-8")
+ ) as {
+ packageManager?: string;
+ };
+ if (packageJson.packageManager) {
+ const packageManagerResult = fromName(packageJson.packageManager);
+ if (packageManagerResult) {
+ return packageManagerResult;
+ }
+ }
+ }
+ } catch {
+ // Ignore parse/read errors and continue fallback detection
+ }
+
+ if (existsSync(join(process.cwd(), "pnpm-lock.yaml"))) {
+ return { command: "pnpm", args: [] };
+ }
+ if (existsSync(join(process.cwd(), "yarn.lock"))) {
+ return { command: "yarn", args: [] };
+ }
+
+ try {
+ const output = execSync("npm -v", { encoding: "utf-8" });
+ if (output) {
+ return { command: "npm", args: ["run"] };
+ }
+ } catch {
+ try {
+ const output = execSync("yarn -v", { encoding: "utf-8" });
+ if (output) {
+ return { command: "yarn", args: [] };
+ }
+ } catch {
+ try {
+ const output = execSync("pnpm -v", { encoding: "utf-8" });
+ if (output) {
+ return { command: "pnpm", args: [] };
+ }
+ } catch {
+ // Default to npm if no package manager is found
+ return { command: "npm", args: ["run"] };
+ }
+ }
+ }
+ // Fallback if all checks succeed but output is falsy (shouldn't happen in practice)
+ return { command: "npm", args: ["run"] };
+}
diff --git a/packages/shared/src/utils/time.ts b/packages/shared/src/utils/time.ts
new file mode 100644
index 0000000..b2c4064
--- /dev/null
+++ b/packages/shared/src/utils/time.ts
@@ -0,0 +1,20 @@
+import { ONE_SECOND } from "../constants";
+
+/**
+ * Format milliseconds to human-readable time string
+ * @param ms - Time in milliseconds
+ * @returns Formatted time string (e.g., "150ms" or "1.50s")
+ */
+export function formatTime(ms: number): string {
+ if (!Number.isFinite(ms)) {
+ return "N/A";
+ }
+ if (ms < 0) {
+ return "0ms";
+ }
+
+ if (ms < ONE_SECOND) {
+ return `${ms.toFixed(0)}ms`;
+ }
+ return `${(ms / ONE_SECOND).toFixed(2)}s`;
+}
diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json
new file mode 100644
index 0000000..2a2c414
--- /dev/null
+++ b/packages/shared/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1a07d0f..c85041d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,237 +4,243 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+catalogs:
+ default:
+ '@types/node':
+ specifier: 25.2.3
+ version: 25.2.3
+ '@types/react':
+ specifier: 19.2.14
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: 19.2.3
+ version: 19.2.3
+ next:
+ specifier: 16.1.6
+ version: 16.1.6
+ react:
+ specifier: 19.2.4
+ version: 19.2.4
+ react-dom:
+ specifier: 19.2.4
+ version: 19.2.4
+ typescript:
+ specifier: 5.9.3
+ version: 5.9.3
+
importers:
.:
devDependencies:
'@biomejs/biome':
- specifier: 2.3.8
- version: 2.3.8
- '@c15t/translations':
- specifier: ^1.8.0
- version: 1.8.0
- '@cookiebench/cli':
+ specifier: 2.3.2
+ version: 2.3.2
+ '@consentio/benchmark':
+ specifier: workspace:*
+ version: link:packages/benchmark
+ '@consentio/runner':
specifier: workspace:*
- version: link:packages/cli
+ version: link:packages/runner
'@playwright/test':
- specifier: ^1.57.0
- version: 1.57.0
+ specifier: ^1.58.2
+ version: 1.58.2
cli-table3:
specifier: ^0.6.5
version: 0.6.5
+ cookiebench:
+ specifier: workspace:*
+ version: link:packages/cookiebench-cli
drizzle-kit:
- specifier: ^0.31.8
- version: 0.31.8
- p-limit:
- specifier: ^7.2.0
- version: 7.2.0
+ specifier: ^0.31.9
+ version: 0.31.9
pretty-ms:
specifier: ^9.3.0
version: 9.3.0
turbo:
- specifier: ^2.6.3
- version: 2.6.3
+ specifier: ^2.8.9
+ version: 2.8.9
typescript:
specifier: 5.9.3
version: 5.9.3
ultracite:
- specifier: ^6.3.9
- version: 6.3.9(@orpc/server@1.12.2(@opentelemetry/api@1.9.0)(ws@8.18.3))(typescript@5.9.3)
+ specifier: 6.0.5
+ version: 6.0.5(@orpc/server@1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3))(typescript@5.9.3)
benchmarks/baseline:
dependencies:
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
version: link:../../packages/benchmark-schema
- '@cookiebench/cli':
- specifier: workspace:*
- version: link:../../packages/cli
'@cookiebench/ts-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
- benchmarks/with-c15t-nextjs:
+ benchmarks/c15t-nextjs:
dependencies:
'@c15t/nextjs':
- specifier: 1.8.1
- version: 1.8.1(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.5.0)(next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typeorm@0.3.28(better-sqlite3@12.5.0))(use-sync-external-store@1.6.0(react@19.2.1))(ws@8.18.3)
+ specifier: 1.8.3
+ version: 1.8.3(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.4.1)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.18.3)
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
version: link:../../packages/benchmark-schema
- '@cookiebench/cli':
- specifier: workspace:*
- version: link:../../packages/cli
'@cookiebench/ts-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
- benchmarks/with-c15t-react:
+ benchmarks/c15t-react:
dependencies:
'@c15t/react':
- specifier: 1.8.1
- version: 1.8.1(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typeorm@0.3.28(better-sqlite3@12.5.0))(use-sync-external-store@1.6.0(react@19.2.1))(ws@8.18.3)
- '@c15t/translations':
- specifier: ^1.8.0
- version: 1.8.0
+ specifier: 1.7.1
+ version: 1.7.1(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.18.3)
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
version: link:../../packages/benchmark-schema
- '@cookiebench/cli':
- specifier: workspace:*
- version: link:../../packages/cli
'@cookiebench/ts-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
- benchmarks/with-cookie-control:
+ benchmarks/cookie-control:
dependencies:
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
version: link:../../packages/benchmark-schema
- '@cookiebench/cli':
- specifier: workspace:*
- version: link:../../packages/cli
'@cookiebench/ts-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
- benchmarks/with-cookie-yes:
+ benchmarks/cookie-yes:
dependencies:
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
version: link:../../packages/benchmark-schema
- '@cookiebench/cli':
- specifier: workspace:*
- version: link:../../packages/cli
'@cookiebench/ts-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
- benchmarks/with-didomi:
+ benchmarks/didomi:
dependencies:
'@didomi/react':
specifier: ^1.8.8
- version: 1.8.8(react@19.2.1)
+ version: 1.8.8(react@19.2.4)
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
@@ -243,97 +249,91 @@ importers:
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
- benchmarks/with-enzuzo:
+ benchmarks/enzuzo:
dependencies:
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
version: link:../../packages/benchmark-schema
- '@cookiebench/cli':
- specifier: workspace:*
- version: link:../../packages/cli
'@cookiebench/ts-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
- benchmarks/with-iubenda:
+ benchmarks/iubenda:
dependencies:
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
version: link:../../packages/benchmark-schema
- '@cookiebench/cli':
- specifier: workspace:*
- version: link:../../packages/cli
'@cookiebench/ts-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
- benchmarks/with-ketch:
+ benchmarks/ketch:
dependencies:
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
@@ -342,157 +342,240 @@ importers:
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
- benchmarks/with-onetrust:
+ benchmarks/onetrust:
dependencies:
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
version: link:../../packages/benchmark-schema
- '@cookiebench/cli':
- specifier: workspace:*
- version: link:../../packages/cli
'@cookiebench/ts-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
- benchmarks/with-osano:
+ benchmarks/osano:
dependencies:
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
version: link:../../packages/benchmark-schema
- '@cookiebench/cli':
- specifier: workspace:*
- version: link:../../packages/cli
'@cookiebench/ts-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
- benchmarks/with-usercentrics:
+ benchmarks/usercentrics:
dependencies:
next:
- specifier: 16.0.7
- version: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 'catalog:'
+ version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
- specifier: ^19.2.1
- version: 19.2.1
+ specifier: 'catalog:'
+ version: 19.2.4
react-dom:
- specifier: ^19.2.1
- version: 19.2.1(react@19.2.1)
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
devDependencies:
'@cookiebench/benchmark-schema':
specifier: workspace:*
version: link:../../packages/benchmark-schema
- '@cookiebench/cli':
- specifier: workspace:*
- version: link:../../packages/cli
'@cookiebench/ts-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
'@types/react':
- specifier: ^19.2.7
- version: 19.2.7
+ specifier: 'catalog:'
+ version: 19.2.14
'@types/react-dom':
- specifier: ^19.2.3
- version: 19.2.3(@types/react@19.2.7)
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+
+ packages/benchmark:
+ dependencies:
+ '@c15t/logger':
+ specifier: ^1.0.1
+ version: 1.0.1
+ '@consentio/shared':
+ specifier: workspace:*
+ version: link:../shared
+ '@playwright/test':
+ specifier: ^1.58.2
+ version: 1.58.2
+ perfume.js:
+ specifier: ^9.4.0
+ version: 9.4.0
+ playwright-performance-metrics:
+ specifier: ^1.2.5
+ version: 1.2.5(@playwright/test@1.58.2)
+ devDependencies:
+ '@rsdoctor/rspack-plugin':
+ specifier: ^1.5.2
+ version: 1.5.2(@rsbuild/core@1.6.0-beta.1)(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rslib/core':
+ specifier: ^0.16.1
+ version: 0.16.1(typescript@5.9.3)
+ '@types/node':
+ specifier: 'catalog:'
+ version: 25.2.3
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
packages/benchmark-schema: {}
- packages/cli:
+ packages/cookiebench-cli:
dependencies:
+ '@c15t/logger':
+ specifier: ^1.0.1
+ version: 1.0.1
'@clack/prompts':
- specifier: 1.0.0-alpha.6
- version: 1.0.0-alpha.6
- '@playwright/test':
- specifier: ^1.57.0
- version: 1.57.0
+ specifier: ^1.0.1
+ version: 1.0.1
+ '@consentio/benchmark':
+ specifier: workspace:*
+ version: link:../benchmark
+ '@consentio/runner':
+ specifier: workspace:*
+ version: link:../runner
+ '@consentio/shared':
+ specifier: workspace:*
+ version: link:../shared
cli-table3:
specifier: ^0.6.5
version: 0.6.5
dotenv:
- specifier: ^17.2.3
- version: 17.2.3
- package-manager-detector:
- specifier: ^1.6.0
- version: 1.6.0
+ specifier: ^17.3.1
+ version: 17.3.1
+ figlet:
+ specifier: ^1.10.0
+ version: 1.10.0
picocolors:
specifier: ^1.1.1
version: 1.1.1
+ pretty-ms:
+ specifier: ^9.3.0
+ version: 9.3.0
devDependencies:
'@rsdoctor/rspack-plugin':
- specifier: ^1.3.12
- version: 1.3.12(@rsbuild/core@1.6.12)(@rspack/core@1.6.6(@swc/helpers@0.5.17))
+ specifier: ^1.5.2
+ version: 1.5.2(@rsbuild/core@1.7.3)(@rspack/core@1.7.6(@swc/helpers@0.5.18))
'@rslib/core':
- specifier: ^0.18.3
- version: 0.18.3(typescript@5.9.3)
+ specifier: ^0.16.1
+ version: 0.16.1(typescript@5.9.3)
+ '@types/figlet':
+ specifier: ^1.7.0
+ version: 1.7.0
'@types/node':
- specifier: ^24.10.1
- version: 24.10.1
+ specifier: 'catalog:'
+ version: 25.2.3
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+
+ packages/runner:
+ dependencies:
+ '@c15t/logger':
+ specifier: 1.0.0
+ version: 1.0.0
+ '@consentio/benchmark':
+ specifier: workspace:*
+ version: link:../benchmark
+ '@consentio/shared':
+ specifier: workspace:*
+ version: link:../shared
+ '@playwright/test':
+ specifier: ^1.58.2
+ version: 1.58.2
playwright-performance-metrics:
- specifier: ^1.2.4
- version: 1.2.4(@playwright/test@1.57.0)
+ specifier: ^1.2.5
+ version: 1.2.5(@playwright/test@1.58.2)
+ devDependencies:
+ '@rsdoctor/rspack-plugin':
+ specifier: ^1.5.2
+ version: 1.5.2(@rsbuild/core@1.7.3)(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rslib/core':
+ specifier: ^0.16.1
+ version: 0.16.1(typescript@5.9.3)
+ '@types/node':
+ specifier: 'catalog:'
+ version: 25.2.3
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+
+ packages/shared:
+ devDependencies:
+ '@cookiebench/ts-config':
+ specifier: workspace:*
+ version: link:../typescript-config
+ '@rsdoctor/rspack-plugin':
+ specifier: ^1.5.2
+ version: 1.5.2(@rsbuild/core@1.7.3)(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rslib/core':
+ specifier: ^0.16.1
+ version: 0.16.1(typescript@5.9.3)
+ '@types/node':
+ specifier: 'catalog:'
+ version: 25.2.3
typescript:
- specifier: ^5.9.3
+ specifier: 'catalog:'
version: 5.9.3
packages/typescript-config: {}
@@ -516,24 +599,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@ast-grep/napi-linux-arm64-musl@0.37.0':
resolution: {integrity: sha512-LF9sAvYy6es/OdyJDO3RwkX3I82Vkfsng1sqUBcoWC1jVb1wX5YVzHtpQox9JrEhGl+bNp7FYxB4Qba9OdA5GA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@ast-grep/napi-linux-x64-gnu@0.37.0':
resolution: {integrity: sha512-TViz5/klqre6aSmJzswEIjApnGjJzstG/SE8VDWsrftMBMYt2PTu3MeluZVwzSqDao8doT/P+6U11dU05UOgxw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@ast-grep/napi-linux-x64-musl@0.37.0':
resolution: {integrity: sha512-/BcCH33S9E3ovOAEoxYngUNXgb+JLg991sdyiNP2bSoYd30a9RHrG7CYwW6fMgua3ijQ474eV6cq9yZO1bCpXg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@ast-grep/napi-win32-arm64-msvc@0.37.0':
resolution: {integrity: sha512-TjQA4cFoIEW2bgjLkaL9yqT4XWuuLa5MCNd0VCDhGRDMNQ9+rhwi9eLOWRaap3xzT7g+nlbcEHL3AkVCD2+b3A==}
@@ -565,92 +652,111 @@ packages:
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
- '@biomejs/biome@2.3.8':
- resolution: {integrity: sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==}
+ '@biomejs/biome@2.3.2':
+ resolution: {integrity: sha512-8e9tzamuDycx7fdrcJ/F/GDZ8SYukc5ud6tDicjjFqURKYFSWMl0H0iXNXZEGmcmNUmABgGuHThPykcM41INgg==}
engines: {node: '>=14.21.3'}
hasBin: true
- '@biomejs/cli-darwin-arm64@2.3.8':
- resolution: {integrity: sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==}
+ '@biomejs/cli-darwin-arm64@2.3.2':
+ resolution: {integrity: sha512-4LECm4kc3If0JISai4c3KWQzukoUdpxy4fRzlrPcrdMSRFksR9ZoXK7JBcPuLBmd2SoT4/d7CQS33VnZpgBjew==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
- '@biomejs/cli-darwin-x64@2.3.8':
- resolution: {integrity: sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==}
+ '@biomejs/cli-darwin-x64@2.3.2':
+ resolution: {integrity: sha512-jNMnfwHT4N3wi+ypRfMTjLGnDmKYGzxVr1EYAPBcauRcDnICFXN81wD6wxJcSUrLynoyyYCdfW6vJHS/IAoTDA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
- '@biomejs/cli-linux-arm64-musl@2.3.8':
- resolution: {integrity: sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==}
+ '@biomejs/cli-linux-arm64-musl@2.3.2':
+ resolution: {integrity: sha512-2Zz4usDG1GTTPQnliIeNx6eVGGP2ry5vE/v39nT73a3cKN6t5H5XxjcEoZZh62uVZvED7hXXikclvI64vZkYqw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
- '@biomejs/cli-linux-arm64@2.3.8':
- resolution: {integrity: sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==}
+ '@biomejs/cli-linux-arm64@2.3.2':
+ resolution: {integrity: sha512-amnqvk+gWybbQleRRq8TMe0rIv7GHss8mFJEaGuEZYWg1Tw14YKOkeo8h6pf1c+d3qR+JU4iT9KXnBKGON4klw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
- '@biomejs/cli-linux-x64-musl@2.3.8':
- resolution: {integrity: sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==}
+ '@biomejs/cli-linux-x64-musl@2.3.2':
+ resolution: {integrity: sha512-gzB19MpRdTuOuLtPpFBGrV3Lq424gHyq2lFj8wfX9tvLMLdmA/R9C7k/mqBp/spcbWuHeIEKgEs3RviOPcWGBA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
+ libc: [musl]
- '@biomejs/cli-linux-x64@2.3.8':
- resolution: {integrity: sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==}
+ '@biomejs/cli-linux-x64@2.3.2':
+ resolution: {integrity: sha512-8BG/vRAhFz1pmuyd24FQPhNeueLqPtwvZk6yblABY2gzL2H8fLQAF/Z2OPIc+BPIVPld+8cSiKY/KFh6k81xfA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
- '@biomejs/cli-win32-arm64@2.3.8':
- resolution: {integrity: sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==}
+ '@biomejs/cli-win32-arm64@2.3.2':
+ resolution: {integrity: sha512-lCruqQlfWjhMlOdyf5pDHOxoNm4WoyY2vZ4YN33/nuZBRstVDuqPPjS0yBkbUlLEte11FbpW+wWSlfnZfSIZvg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
- '@biomejs/cli-win32-x64@2.3.8':
- resolution: {integrity: sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==}
+ '@biomejs/cli-win32-x64@2.3.2':
+ resolution: {integrity: sha512-6Ee9P26DTb4D8sN9nXxgbi9Dw5vSOfH98M7UlmkjKB2vtUbrRqCbZiNfryGiwnPIpd6YUoTl7rLVD2/x1CyEHQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
+ '@c15t/backend@1.7.1':
+ resolution: {integrity: sha512-oO+UM7fI93qb1KC+r02FCPeva+cUEySZ0S3MPbUWM5OG5vc/tz6JaPeAK9wEc1TTXszvv5zuNKrK1FYgYNcNdQ==}
+
'@c15t/backend@1.8.0':
resolution: {integrity: sha512-vM/jXQfWrI2XqN8h3Wq0IRWFBLz5o98vPUVAPu8Mdw9WblnBRwVRIEtmN6maMVM3yyai4yZA8fbXAi0e3PSM/Q==}
+ '@c15t/logger@1.0.0':
+ resolution: {integrity: sha512-z2RrUnvO5bbEg/qd9iD/TqdBi04vfvALf2uovDVLJiQTNfOVUb7FM/GkPH5mugiDwErlrtVhAW8hW/2VmBXbUA==}
+
'@c15t/logger@1.0.1':
resolution: {integrity: sha512-Yz+HkHjsGWPkJ7nWGqlSgF3LG9WtV72Om5NoWrW6YJHLMNasC5Z6opi9GM8B1Nm8ivZBzJpADEc4gVIBCkqamw==}
- '@c15t/nextjs@1.8.1':
- resolution: {integrity: sha512-4Y9KShd/jE2wpz6/5cNURzuBDDUcy9XX13b/oYtzzwLa5Qy3fg+O/2AhwyM2hWok83n3WygS7xWex1YUyYzroQ==}
+ '@c15t/nextjs@1.8.3':
+ resolution: {integrity: sha512-cWMW0MLUExpIqRQojN5Crk+/K4LvlUOdxltJtELhMHoVld+piDO1hzq3zN0MQArvWCoU3TdiG5Cbisphrk/hJg==}
peerDependencies:
next: ^16.0.0 || ^15.0.0 || ^14.0.0 || ^13.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
- '@c15t/react@1.8.1':
- resolution: {integrity: sha512-Slh98Rxo4gYhrcOU+rR7M8HiAB084EE8VyW6JMXC8ItXWSCqu+1O89hpikn+2i3PdgyCIbwUv+VKEVm4KcAmBg==}
+ '@c15t/react@1.7.1':
+ resolution: {integrity: sha512-Ey6SUOxhf3nEiomYhRR9Axiy4Ku47zrSIDIvJ+amhHEhCS8jyJNQBdclUAggUFzvB8xVJQfGuniU6X6IzjL39Q==}
peerDependencies:
react: ^19.0.0 || ^19.0.0-rc || ^18.0.0 || ^17.0.0 || ^16.8.0
react-dom: ^19.0.0 || ^19.0.0-rc || ^18.0.0 || ^17.0.0 || ^16.8.0
+ '@c15t/react@1.8.3':
+ resolution: {integrity: sha512-BOOtA5AeGaJo+bcPdzRXsTtH7trdpcAcUEN9Rv+72qUzcQ7melV8JycZFwgrd0hgQ0PCGJ19YXp60EBbJraIzg==}
+ peerDependencies:
+ react: ^19.0.0 || ^19.0.0-rc || ^18.0.0 || ^17.0.0 || ^16.8.0
+ react-dom: ^19.0.0 || ^19.0.0-rc || ^18.0.0 || ^17.0.0 || ^16.8.0
+
+ '@c15t/translations@1.7.0':
+ resolution: {integrity: sha512-irrJAni2Cei56wHLmGoIoPHoXk/ygXllWs4dBF9LL0D/Ns7bvcBi/CAPCnCD1hw4FnoJ9amPqYtVKQ3sT++JaQ==}
+
'@c15t/translations@1.8.0':
resolution: {integrity: sha512-iCw8iOc9lgXpIJaN/WzN+T3FMzFUgjN+UZ7tp6hKSV4RktXwr7C/z3HAOdeGYZE01BdmunrlOZGQB/N3hZoZZQ==}
'@clack/core@0.5.0':
resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==}
- '@clack/core@1.0.0-alpha.6':
- resolution: {integrity: sha512-eG5P45+oShFG17u9I1DJzLkXYB1hpUgTLi32EfsMjSHLEqJUR8BOBCVFkdbUX2g08eh/HCi6UxNGpPhaac1LAA==}
+ '@clack/core@1.0.1':
+ resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==}
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
- '@clack/prompts@1.0.0-alpha.6':
- resolution: {integrity: sha512-75NCtYOgDHVBE2nLdKPTDYOaESxO0GLAKC7INREp5VbS988Xua1u+588VaGlcvXiLc/kSwc25Cd+4PeTSpY6QQ==}
+ '@clack/prompts@1.0.1':
+ resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==}
'@colors/colors@1.5.0':
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
@@ -664,11 +770,11 @@ packages:
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
- '@emnapi/core@1.7.1':
- resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
+ '@emnapi/core@1.8.1':
+ resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
- '@emnapi/runtime@1.7.1':
- resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
+ '@emnapi/runtime@1.8.1':
+ resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
@@ -969,8 +1075,8 @@ packages:
cpu: [x64]
os: [win32]
- '@grpc/grpc-js@1.14.2':
- resolution: {integrity: sha512-QzVUtEFyu05UNx2xr0fCQmStUO17uVQhGNowtxs00IgTZT6/W2PBLfUkj30s0FKJ29VtTa3ArVNIhNP6akQhqA==}
+ '@grpc/grpc-js@1.14.3':
+ resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==}
engines: {node: '>=12.10.0'}
'@grpc/proto-loader@0.8.0':
@@ -1008,89 +1114,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
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-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
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-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
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-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
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-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.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}
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}
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}
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}
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}
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}
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}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -1115,14 +1237,6 @@ packages:
cpu: [x64]
os: [win32]
- '@isaacs/balanced-match@4.0.1':
- resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
- engines: {node: 20 || >=22}
-
- '@isaacs/brace-expansion@5.0.0':
- resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
- engines: {node: 20 || >=22}
-
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -1191,23 +1305,41 @@ packages:
cpu: [x64]
os: [win32]
- '@module-federation/error-codes@0.21.6':
- resolution: {integrity: sha512-MLJUCQ05KnoVl8xd6xs9a5g2/8U+eWmVxg7xiBMeR0+7OjdWUbHwcwgVFatRIwSZvFgKHfWEiI7wsU1q1XbTRQ==}
+ '@module-federation/error-codes@0.21.1':
+ resolution: {integrity: sha512-h1brnwR9AbwMu1P7ZoJJ9j2O2XWkuMh5p03WhXI1vNEdl3xJheSAvH8RjG8FoKRccVgMnUNDQ+vDVwevUBms/A==}
- '@module-federation/runtime-core@0.21.6':
- resolution: {integrity: sha512-5Hd1Y5qp5lU/aTiK66lidMlM/4ji2gr3EXAtJdreJzkY+bKcI5+21GRcliZ4RAkICmvdxQU5PHPL71XmNc7Lsw==}
+ '@module-federation/error-codes@0.22.0':
+ resolution: {integrity: sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==}
- '@module-federation/runtime-tools@0.21.6':
- resolution: {integrity: sha512-fnP+ZOZTFeBGiTAnxve+axGmiYn2D60h86nUISXjXClK3LUY1krUfPgf6MaD4YDJ4i51OGXZWPekeMe16pkd8Q==}
+ '@module-federation/runtime-core@0.21.1':
+ resolution: {integrity: sha512-COob5bepqDc9mKjTziXbQd4WQMCTzhc0cuXyraZhYddYcjcepzZrMpDIXG1x5p+gdg5p1vsGNWt/ZcU8cFh/pg==}
- '@module-federation/runtime@0.21.6':
- resolution: {integrity: sha512-+caXwaQqwTNh+CQqyb4mZmXq7iEemRDrTZQGD+zyeH454JAYnJ3s/3oDFizdH6245pk+NiqDyOOkHzzFQorKhQ==}
+ '@module-federation/runtime-core@0.22.0':
+ resolution: {integrity: sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==}
- '@module-federation/sdk@0.21.6':
- resolution: {integrity: sha512-x6hARETb8iqHVhEsQBysuWpznNZViUh84qV2yE7AD+g7uIzHKiYdoWqj10posbo5XKf/147qgWDzKZoKoEP2dw==}
+ '@module-federation/runtime-tools@0.21.1':
+ resolution: {integrity: sha512-uQmammw3Osg8370yiRqZwKo7eA5zkyml9pAX9x4oS9QAkEBvQpDogERlF9f7gAgcP2P3v+xLg3/bCdquD0gt8A==}
- '@module-federation/webpack-bundler-runtime@0.21.6':
- resolution: {integrity: sha512-7zIp3LrcWbhGuFDTUMLJ2FJvcwjlddqhWGxi/MW3ur1a+HaO8v5tF2nl+vElKmbG1DFLU/52l3PElVcWf/YcsQ==}
+ '@module-federation/runtime-tools@0.22.0':
+ resolution: {integrity: sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==}
+
+ '@module-federation/runtime@0.21.1':
+ resolution: {integrity: sha512-sfBrP0gEPwXPEiREVKVd0IjEWXtr3G/i7EUZVWTt4D491nNpswog/kuKFatGmhcBb+9uD5v9rxFgmIbgL9njnQ==}
+
+ '@module-federation/runtime@0.22.0':
+ resolution: {integrity: sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==}
+
+ '@module-federation/sdk@0.21.1':
+ resolution: {integrity: sha512-1cHMrmCCao3NMFM4BkA0GDt4rbYbyneHct5E4z68cu5UBUnI3L/UboP5VNM8lkYMO1nCR8M0FcLkLhK35Nt48A==}
+
+ '@module-federation/sdk@0.22.0':
+ resolution: {integrity: sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==}
+
+ '@module-federation/webpack-bundler-runtime@0.21.1':
+ resolution: {integrity: sha512-yyXX6ugTV07pMxMzAHt6/JDwblS3f1NDyUI7l44CyYgXpl2ItEEUs5aj5h/5xU1c9Px7M//KkY3qW+InW4tR/A==}
+
+ '@module-federation/webpack-bundler-runtime@0.22.0':
+ resolution: {integrity: sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==}
'@napi-rs/wasm-runtime@1.0.7':
resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==}
@@ -1215,53 +1347,57 @@ packages:
'@neon-rs/load@0.0.4':
resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
- '@next/env@16.0.7':
- resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==}
+ '@next/env@16.1.6':
+ resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
- '@next/swc-darwin-arm64@16.0.7':
- resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==}
+ '@next/swc-darwin-arm64@16.1.6':
+ resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.0.7':
- resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==}
+ '@next/swc-darwin-x64@16.1.6':
+ resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.0.7':
- resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==}
+ '@next/swc-linux-arm64-gnu@16.1.6':
+ resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
- '@next/swc-linux-arm64-musl@16.0.7':
- resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==}
+ '@next/swc-linux-arm64-musl@16.1.6':
+ resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
- '@next/swc-linux-x64-gnu@16.0.7':
- resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==}
+ '@next/swc-linux-x64-gnu@16.1.6':
+ resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
- '@next/swc-linux-x64-musl@16.0.7':
- resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==}
+ '@next/swc-linux-x64-musl@16.1.6':
+ resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
- '@next/swc-win32-arm64-msvc@16.0.7':
- resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==}
+ '@next/swc-win32-arm64-msvc@16.1.6':
+ resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.0.7':
- resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==}
+ '@next/swc-win32-x64-msvc@16.1.6':
+ resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1274,8 +1410,8 @@ packages:
resolution: {integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==}
engines: {node: '>=8.0.0'}
- '@opentelemetry/api-logs@0.208.0':
- resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
+ '@opentelemetry/api-logs@0.207.0':
+ resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.0':
@@ -1294,8 +1430,8 @@ packages:
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
- '@opentelemetry/core@2.2.0':
- resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==}
+ '@opentelemetry/core@2.5.1':
+ resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
@@ -1372,8 +1508,8 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
- '@opentelemetry/instrumentation@0.208.0':
- resolution: {integrity: sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==}
+ '@opentelemetry/instrumentation@0.207.0':
+ resolution: {integrity: sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
@@ -1414,8 +1550,8 @@ packages:
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
- '@opentelemetry/resources@2.2.0':
- resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==}
+ '@opentelemetry/resources@2.5.1':
+ resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
@@ -1444,8 +1580,8 @@ packages:
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
- '@opentelemetry/sdk-trace-base@2.2.0':
- resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==}
+ '@opentelemetry/sdk-trace-base@2.5.1':
+ resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
@@ -1456,25 +1592,16 @@ packages:
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
- '@opentelemetry/semantic-conventions@1.38.0':
- resolution: {integrity: sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==}
+ '@opentelemetry/semantic-conventions@1.39.0':
+ resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==}
engines: {node: '>=14'}
- '@orpc/client@1.12.2':
- resolution: {integrity: sha512-3MTFnWRYYjcyzhtcYpodvkaYQlqsxKd5xGv+7PPJSpjCgFg9wcp7mZmRKy7hK0sCwUlkyi7AKs1Q19aUVUFIGA==}
-
'@orpc/client@1.8.1':
resolution: {integrity: sha512-ewofUqn46yHQxnAOFLwZspQD1k3DPTUCnkqP6aFO1alUMHgLFNb03BKd1rgwUXSWaxWltKD8Db07HNxgltdk4A==}
- '@orpc/contract@1.12.2':
- resolution: {integrity: sha512-eleSbF7WgfkWz+7jl1b9t3C3DWn127694+yEdR3j6EiBjb9mzHMIeOMRTXsclIP4gWj13wD1NtXp1Qlv8m7oZw==}
-
'@orpc/contract@1.8.1':
resolution: {integrity: sha512-zw0xgRxOyR/otX+4UeGEm0VjuMH/AeVbEuWvPNRJC8xxvQpaPJFo8Nr8eaywqSUZo1DnIS+pkwsdipa7GgFl7A==}
- '@orpc/interop@1.12.2':
- resolution: {integrity: sha512-whHawJ8XZBzxngqOZKRzkI6HaFZcFSdbaK0//LmqOdSKXBeuveHF+kprCcpr8C6rH2N5i9nKMdnt0RetjPvxCg==}
-
'@orpc/interop@1.8.1':
resolution: {integrity: sha512-TA2AQREo3tKQheHPvVs5PY1PqDnNUFGO3c8eb6lrX53npOD6lMi6IPedzeEHaZCqb17bRZ9Skc9Fg1FZ30ckug==}
@@ -1487,23 +1614,12 @@ packages:
'@orpc/openapi@1.8.1':
resolution: {integrity: sha512-G1qMCjpVGxJYrU0vMdYOr2WxSiX9C6ZhvafgKN7vOGqC7O5s1D+Ph/cSUe1R9OPZZPouhw75MNjTtKYP5lOrcA==}
- '@orpc/otel@1.12.2':
- resolution: {integrity: sha512-51pE8OuncexO+ai5Ma1qZL3ldEL+msiHBN51SauuMezVXbCmVzhhHLlXD0LhvJWbE2X8kedLMmMYxI1fqiF0Mw==}
+ '@orpc/otel@1.13.5':
+ resolution: {integrity: sha512-GPqaonr74EiqBbKLpNRVe6P8qv7CKM5mSLcfbkFcpvntSdl9OoPh+5Bq4iWhoaZF+7Ijs+/Ve9YHPr7LvRtJAA==}
peerDependencies:
'@opentelemetry/api': '>=1.9.0'
'@opentelemetry/instrumentation': '>=0.203.0'
- '@orpc/server@1.12.2':
- resolution: {integrity: sha512-lgT3VR+yXsCcgzbZ2d1fXtqaf1RbgUJHMDWQ4J22LBYH1P8pi0Nk+EYi9/w3YNFIr1WuUmVu4Pm6Dg6l92oiQA==}
- peerDependencies:
- crossws: '>=0.3.4'
- ws: '>=8.18.1'
- peerDependenciesMeta:
- crossws:
- optional: true
- ws:
- optional: true
-
'@orpc/server@1.8.1':
resolution: {integrity: sha512-Aei7KJSMG9daNdubREeDYKCnmSRTnZT7V1Cx0NFLYDYqx8Bdil2J2InJghsio6AZdJUWLNq/zMm6QDu3NN2E6g==}
peerDependencies:
@@ -1515,8 +1631,8 @@ packages:
ws:
optional: true
- '@orpc/shared@1.12.2':
- resolution: {integrity: sha512-aITtDnmkofoG/GY6897AOPLnFMkLpQpM7ljzaqsG8QMbL6oovO427G/9Tr9Y3DDSyrsxA7FQ8+rwV03ZDG7gfQ==}
+ '@orpc/shared@1.13.5':
+ resolution: {integrity: sha512-yBFD9FqwazpbcOegEOZ0kAz7i9oNO110HX5AV5YAPGh+zxOY3RfZFXODQ5kBR1mr2nyo4ju+5ohYbJppZuWlcA==}
peerDependencies:
'@opentelemetry/api': '>=1.9.0'
peerDependenciesMeta:
@@ -1531,41 +1647,18 @@ packages:
'@opentelemetry/api':
optional: true
- '@orpc/standard-server-aws-lambda@1.12.2':
- resolution: {integrity: sha512-2gpM0ipl3YvE6/bJKTE6Oga4EROo2zuRiOzwY21F/3q38xyQ344Lk1smHzUv20lYZEIBDWP0FRR0OPhBQYLwNQ==}
-
'@orpc/standard-server-aws-lambda@1.8.1':
resolution: {integrity: sha512-5db2k1nhdySkYXMMXCxMuZIXDgo04rrZ6qqIEr1cvKnW5OHadcvxysfMa60WMA4Mzm3pypl2WJKGy36mdm6R8Q==}
- '@orpc/standard-server-fastify@1.12.2':
- resolution: {integrity: sha512-O1IsvytsK1j6cbuztreE5sJHL3OyvDMn1lSWKO16YoLBK47W8XRHCVBKDDV+1gk4f4Q74bSJOvNGQh1OUcNbkg==}
- peerDependencies:
- fastify: '>=5.6.1'
- peerDependenciesMeta:
- fastify:
- optional: true
-
- '@orpc/standard-server-fetch@1.12.2':
- resolution: {integrity: sha512-gjqZgD8uiW3dVaf+bo9Gj0GXTQ4B/vXkaHPmat+11AE+34UsOZJqZJzEuUi+P4qkmNOm6ZXAsb8roz6H3izPrg==}
-
'@orpc/standard-server-fetch@1.8.1':
resolution: {integrity: sha512-HZNTuLitB6X2nVPBfpbKvHRPNL7LV4J9+mQxr+Xi/hU2I6Fr2KWjNmAxcH1Fn6cOsJ09q9RFIaikUC88CQMnlA==}
- '@orpc/standard-server-node@1.12.2':
- resolution: {integrity: sha512-st9yjw3i+xFJu8YHeKcCBchMkRKyobBNqstR8yUelrLE/+rC0qKX+AwrmP6xq6gP6BQqFk+RSfIManaIruKuuQ==}
-
'@orpc/standard-server-node@1.8.1':
resolution: {integrity: sha512-+hKShhdAM2ljCbfvFjZQVhcpZjVXCgrz3ysTNKRsIR+E3aiYXffylL6RDOjLAjfvQyRy7wsPVxPjyqUHGXX4vg==}
- '@orpc/standard-server-peer@1.12.2':
- resolution: {integrity: sha512-O51FDFAHgK5uG5EhIG5PYWrkpnwBSVIKih2yYtNzLVYsU3y8EkE07UHRWixJFjUDJ7YeGy16ud3M8oNqt2bE4g==}
-
'@orpc/standard-server-peer@1.8.1':
resolution: {integrity: sha512-QDGfi/P0HKxFQCoDUOsIaIfO1KK10qoXDmd6ZfZF9DwFKrnqE6AEpMgz/TVDqLb6mFUzMtvdjrPga8uO9QdnbQ==}
- '@orpc/standard-server@1.12.2':
- resolution: {integrity: sha512-8ZNhL3CRJmoJ7uFjOZB4U7NVGdcbGIOKLRTP0i/yhi51QL5Lw+56f/hNqztrCPVZolhJWif86HjyQhuixizrVg==}
-
'@orpc/standard-server@1.8.1':
resolution: {integrity: sha512-SgVoxnmRAhrF0usZcz72F8FcneM4Rp+UYMhggwwnCEtjKtKXddg89J/to2bkpH/P5x8JMVI/uE2YXRG8CtU6Cg==}
@@ -1583,8 +1676,8 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
- '@playwright/test@1.57.0':
- resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
+ '@playwright/test@1.58.2':
+ resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
hasBin: true
@@ -1789,46 +1882,52 @@ packages:
'@types/react':
optional: true
- '@rollup/rollup-linux-x64-gnu@4.53.3':
- resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
+ '@rollup/rollup-linux-x64-gnu@4.57.1':
+ resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
+
+ '@rsbuild/core@1.6.0-beta.1':
+ resolution: {integrity: sha512-UjQnvXDW9m/hS4DP66ubGIMVjK2PzYx8tzgiinrO0kjNCr9i8KWuJSJGUWyczFMpSsXxp20LnuTxtx7kiGiYdA==}
+ engines: {node: '>=18.12.0'}
+ hasBin: true
- '@rsbuild/core@1.6.12':
- resolution: {integrity: sha512-gHwDfAMMgK2Zu9JgiekX949F262g6yYY+/ZLTIaMXHZmqYLAgsu0XOeVogpfQa045xcnwcQfpieiB9zgukJSgw==}
+ '@rsbuild/core@1.7.3':
+ resolution: {integrity: sha512-kI1oQvCXbQYxUvQPnDLdjSX4gFsbrFNpuUj6jXEJ7IcJ74Q+n4oeFj74/8tKerhxhe0L90m/ZQfzLeN5ORGA9w==}
engines: {node: '>=18.12.0'}
hasBin: true
- '@rsbuild/plugin-check-syntax@1.5.0':
- resolution: {integrity: sha512-/JutNle3wI67q/WOYcneyjUSAjZDJIycfpoJ04PWFh+qQ68Pwg4/Be1kU7GiVVklMwmEScoCd9S+gXWk+27YSg==}
+ '@rsbuild/plugin-check-syntax@1.6.1':
+ resolution: {integrity: sha512-26xtEYN0QjZYoyt0lWnvIztBWjEZJvcfw7MN4f5B4SpNggmnF7F7aNPrgkY3EccXVFx1VGQBhnCkBV//OoS07Q==}
peerDependencies:
- '@rsbuild/core': 1.x
+ '@rsbuild/core': ^1.0.0 || ^2.0.0-0
peerDependenciesMeta:
'@rsbuild/core':
optional: true
- '@rsdoctor/client@1.3.12':
- resolution: {integrity: sha512-7IsPn2JrsbuoCexKu4sEwPcrW1l/DeTnqG6oCp4QFvQ8fSMfNwG/ncigjIGyg4uPu5p5ZIBeY2G0p7c9120Z0w==}
+ '@rsdoctor/client@1.5.2':
+ resolution: {integrity: sha512-fufNlCiA4+MDj3ZW8ssEdRI9mwawdqSSYvqOOK01+NNIg3TuQNgEdnF/QVGnkxKLgVXJCtUdOKaZtUq1bwnJVQ==}
- '@rsdoctor/core@1.3.12':
- resolution: {integrity: sha512-9vsn7WWab+BQbdSrxIrLgv5jQ8l01UL9N5gCP3uLG6OUTRvKbDgn/iKtb1BobMkB5kdD2xV8iKNzb6oeTZpQtg==}
+ '@rsdoctor/core@1.5.2':
+ resolution: {integrity: sha512-pAtehxWiOhEef7+nxmeX7EDdABh9maAtmD+G/0MNC5zc/aKqzh2KD/CCrEO9SqSoVnfkTot1BQ54kLgBMFvclw==}
- '@rsdoctor/graph@1.3.12':
- resolution: {integrity: sha512-QqYm7SLybA8DW+4FVZQM5ooSfIFFBF6zTtCpyF3LO2pMFdN4OjX3Gmuaw01I9Qr5b/+gy5XiqsCFHXI0uVCbOQ==}
+ '@rsdoctor/graph@1.5.2':
+ resolution: {integrity: sha512-4Wt/Hg4Z3uSJBJQh1pm9cFeYI8lHhSFdWbR39dPOiIC/Q1/86TVdo63GBYMLYyXWRZ58S5kDL5ZjO9IyDLVgug==}
- '@rsdoctor/rspack-plugin@1.3.12':
- resolution: {integrity: sha512-LHulXsUudq+8lPt8JqsJ0/nPOdIVDqVlvEr1EhCNG9WUNFB2lQL+FzUluf1VpixjtzUM9OqeGY3M4rpSZcY/ww==}
+ '@rsdoctor/rspack-plugin@1.5.2':
+ resolution: {integrity: sha512-l097etdtvz4osG5rDJX0RBbIqLAZ+oSUXOJu11Y9aFg+4/lohrZZa4lj1R/7hxlqC0XuROir2zsbRaAuCGtxzw==}
peerDependencies:
'@rspack/core': '*'
peerDependenciesMeta:
'@rspack/core':
optional: true
- '@rsdoctor/sdk@1.3.12':
- resolution: {integrity: sha512-WOzF5CWLai2+8L3tmJZI5tdhodiHphgHbqOGD0N0RqrCFCeDx11oQkndXjXMns9kKd9Q0NhlzKjr76iE7QpjUQ==}
+ '@rsdoctor/sdk@1.5.2':
+ resolution: {integrity: sha512-VPJoSO1gPIlMDLumzGgphhIyNM8s7NPgbN/AAFwbU/uWzEwC2Gy2joLwoYkOw32UDdMD/MiuXrQoP+fnjAYjjQ==}
- '@rsdoctor/types@1.3.12':
- resolution: {integrity: sha512-/BvmO/cgqxBO1OJxGisYfNZOh2IdqORHUZRBbDIio8D88YQFt2Xo9mSBjHEEXXpPFe5Lw835tTOuCgAtQlw1EQ==}
+ '@rsdoctor/types@1.5.2':
+ resolution: {integrity: sha512-sOj7H/Dc9F3LPM/04dgJSpBhAb7DNlqcm9uipW6+ZCB/pr4dcwUDo8lHcjaEgQWeUfvHSUHjtzarp9EVpfkFsg==}
peerDependencies:
'@rspack/core': '*'
webpack: 5.x
@@ -1838,11 +1937,11 @@ packages:
webpack:
optional: true
- '@rsdoctor/utils@1.3.12':
- resolution: {integrity: sha512-vWvknXpowq2BI4jFZv0dfIg9GdLBlOqD4XW1EzMQ6ULYyPCU+b4C6o8OBn9Q5hEu/Ed0e2Ca4G3jTWYyFcsR+A==}
+ '@rsdoctor/utils@1.5.2':
+ resolution: {integrity: sha512-Zdvpp4GdJKgQYXLuILM124YU0peMDebr95k2s5zTTuB2LBkHENf8UdjJs7ijKyoaVYKqxXTBdghekXEFcdo6jQ==}
- '@rslib/core@0.18.3':
- resolution: {integrity: sha512-Jdpssyig/OIJ9EzjXi957gKUCLZhlynMAunkJnaYcriFE245quLxZAdDsxXeeKjUp0Mx/J6fVXgUQyYmbKum6w==}
+ '@rslib/core@0.16.1':
+ resolution: {integrity: sha512-KhZwWO4kcP4PPdBH2aAgg/A6FYapbDh2rhsxo8dqOOPWCjDFU3QKAYPieKg+k4IrRQlye8AWOXo2rbyF4FC01g==}
engines: {node: '>=18.12.0'}
hasBin: true
peerDependencies:
@@ -1854,60 +1953,120 @@ packages:
typescript:
optional: true
- '@rspack/binding-darwin-arm64@1.6.6':
- resolution: {integrity: sha512-vGVDP0rlWa2w/gLba/sncVfkCah0HmhdmK5vGj/7sSX0iViwQneA2xjxDHyCNSQrvfq9GJmj4Kmdq/9tGh0KuA==}
+ '@rspack/binding-darwin-arm64@1.6.0-beta.1':
+ resolution: {integrity: sha512-RXQ97iVXgvQAb/cq265z/txdHOOJ6fQQRBfnn0IfMNk7gT4W2rvsLrOqQpwtMKxYV4N/mfWnycfAVa0OOf22Gg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rspack/binding-darwin-arm64@1.7.6':
+ resolution: {integrity: sha512-NZ9AWtB1COLUX1tA9HQQvWpTy07NSFfKBU8A6ylWd5KH8AePZztpNgLLAVPTuNO4CZXYpwcoclf8jG/luJcQdQ==}
cpu: [arm64]
os: [darwin]
- '@rspack/binding-darwin-x64@1.6.6':
- resolution: {integrity: sha512-IcdEG2kOmbPPO70Zl7gDnowDjK7d7C1hWew2vU7dPltr2t1JalRIMnS051lhiur0ULkSxV3cW1zXqv0Oi8AnOg==}
+ '@rspack/binding-darwin-x64@1.6.0-beta.1':
+ resolution: {integrity: sha512-Ulb7Jyyvuf28BwPXZKSbglaSK/19b32ItWT+pgswhbFsnfhzAQQd7Jo7TUEvHNHAdVDiES8VFlrnOhOSnwEOLg==}
cpu: [x64]
os: [darwin]
- '@rspack/binding-linux-arm64-gnu@1.6.6':
- resolution: {integrity: sha512-rIguCCtlTcwoFlwheDiUgdImk27spuCRn43zGJogARpM/ZYRFKIuSwFDGUtJT2g0TSLUAHUhWAUqC36NwvrbMQ==}
+ '@rspack/binding-darwin-x64@1.7.6':
+ resolution: {integrity: sha512-J2g6xk8ZS7uc024dNTGTHxoFzFovAZIRixUG7PiciLKTMP78svbSSWrmW6N8oAsAkzYfJWwQpVgWfFNRHvYxSw==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rspack/binding-linux-arm64-gnu@1.6.0-beta.1':
+ resolution: {integrity: sha512-UyUoh5RXHTWCktqPVnqoc5rwlWyLkWqGu6ga+iyJHDxdxlrHFfwJnTSnCd4y8cRadf7CrmjHElxE61GU3WCYhw==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
- '@rspack/binding-linux-arm64-musl@1.6.6':
- resolution: {integrity: sha512-x6X6Gr0fUw6qrJGxZt3Rb6oIX+jd9pdcyp0VbtofcLaqGVQbzustYsYnuLATPOys0q4J/4kWnmEhkjLJHwkhpQ==}
+ '@rspack/binding-linux-arm64-gnu@1.7.6':
+ resolution: {integrity: sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
- '@rspack/binding-linux-x64-gnu@1.6.6':
- resolution: {integrity: sha512-gSlVdASszWHosQKn+nzYOInBijdQboUnmNMGgW9/PijVg3433IvQjzviUuJFno8CMGgrACV9yw+ZFDuK0J57VA==}
+ '@rspack/binding-linux-arm64-musl@1.6.0-beta.1':
+ resolution: {integrity: sha512-JAXVKHQieN4Ruvs7MstvsPUtRBSAROqJ0abCh4rXdV+FzncKp/ZkdfjQploDhBWtWfU8rPvIjaxeZcPfHMI5/A==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rspack/binding-linux-arm64-musl@1.7.6':
+ resolution: {integrity: sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rspack/binding-linux-x64-gnu@1.6.0-beta.1':
+ resolution: {integrity: sha512-LqAos71CJS5/V4knX9T7T68oGz0XPRZ2IJmI3jEByRlNcyZdxYeQ7Dw09JO9Y5Xj0T+0cudOeL2MxHcD3gTF/w==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
- '@rspack/binding-linux-x64-musl@1.6.6':
- resolution: {integrity: sha512-TZaqVkh7memsTK/hxkOBrbpdzbmBUMea1YnYt++7QjMgco1kWFvAQ+YhAWtIaOaEg8s6C07Lt0Zp8izM2Dja0g==}
+ '@rspack/binding-linux-x64-gnu@1.7.6':
+ resolution: {integrity: sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
- '@rspack/binding-wasm32-wasi@1.6.6':
- resolution: {integrity: sha512-W4mWdlLnYrbUaktyHOGNfATblxMTbgF7CBfDw8PhbDtjd2l8e/TnaHgIDkwITHXAOMEF/QEKfo9FtusbcQJNKw==}
+ '@rspack/binding-linux-x64-musl@1.6.0-beta.1':
+ resolution: {integrity: sha512-E4dRMzIHYaoYkgmDTFLrgnGtdspbAuVbLfaPF9AWW5YkQn52obGAgbbNb1wi1JJ5f29nTBoLauYCucEO5IGFvA==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rspack/binding-linux-x64-musl@1.7.6':
+ resolution: {integrity: sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rspack/binding-wasm32-wasi@1.6.0-beta.1':
+ resolution: {integrity: sha512-PaKEjXOkYprSFlgdgVm/P3pv2E8nAQx9WSGgPmMVIAtxo3Cyz0wwFf0f1Bp9wCw0KkIWgi+9lz8oXNkgKZilug==}
cpu: [wasm32]
- '@rspack/binding-win32-arm64-msvc@1.6.6':
- resolution: {integrity: sha512-cw5OgxqoDwjoZlk0L3vGEwcjPZsOVFYLwr2ssiC05rsTbhBwxj8coLpAJdvUvbf6C2TTmCB7iPe2sPq1KWD37g==}
+ '@rspack/binding-wasm32-wasi@1.7.6':
+ resolution: {integrity: sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg==}
+ cpu: [wasm32]
+
+ '@rspack/binding-win32-arm64-msvc@1.6.0-beta.1':
+ resolution: {integrity: sha512-HWz9Qxrjf3TKLCwiFPJaqw+STvEsBvFYZvBXZ8umIZXqtdfgQP5d91V8JRG4Gg1J6xnGC/KhZexxBuR/y64aBA==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rspack/binding-win32-arm64-msvc@1.7.6':
+ resolution: {integrity: sha512-INj7aVXjBvlZ84kEhSK4kJ484ub0i+BzgnjDWOWM1K+eFYDZjLdAsQSS3fGGXwVc3qKbPIssFfnftATDMTEJHQ==}
cpu: [arm64]
os: [win32]
- '@rspack/binding-win32-ia32-msvc@1.6.6':
- resolution: {integrity: sha512-M4ruR+VZ59iy+mPjy6FQPT27cOgeytf3wFBrt7e0suKeNLYGxrNyI9YhgpCTY++SMJsAMgRLGDHoI3ZgWulw1Q==}
+ '@rspack/binding-win32-ia32-msvc@1.6.0-beta.1':
+ resolution: {integrity: sha512-alAZHRuyPzCH3rJpEC9EBE60EZPnQjzltZ6HN8lsCidACMFTzaLBvuzZyYQah+Zm58O22ok2Eon4BpP1Coizgg==}
cpu: [ia32]
os: [win32]
- '@rspack/binding-win32-x64-msvc@1.6.6':
- resolution: {integrity: sha512-q5QTvdhPUh+CA93cQG5zWKRIHMIWPzw+ftFDEwBw52zYdvNAoLniqD8o5Mi8CT0pndhulXgR5aw0Sjd3eMah+A==}
+ '@rspack/binding-win32-ia32-msvc@1.7.6':
+ resolution: {integrity: sha512-lXGvC+z67UMcw58In12h8zCa9IyYRmuptUBMItQJzu+M278aMuD1nETyGLL7e4+OZ2lvrnnBIcjXN1hfw2yRzw==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rspack/binding-win32-x64-msvc@1.6.0-beta.1':
+ resolution: {integrity: sha512-/WBzhed0Cu0o9XQ9caGgWwzyNnnPKlENlExa2aGbRCbB14/+CwfhCyETyKlc/ID+dtlV/eHKTC9cckUNI8NpTQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rspack/binding-win32-x64-msvc@1.7.6':
+ resolution: {integrity: sha512-zeUxEc0ZaPpmaYlCeWcjSJUPuRRySiSHN23oJ2Xyw0jsQ01Qm4OScPdr0RhEOFuK/UE+ANyRtDo4zJsY52Hadw==}
cpu: [x64]
os: [win32]
- '@rspack/binding@1.6.6':
- resolution: {integrity: sha512-noiV+qhyBTVpvG2M4bnOwKk2Ynl6G47Wf7wpCjPCFr87qr3txNwTTnhkEJEU59yj+VvIhbRD2rf5+9TLoT0Wxg==}
+ '@rspack/binding@1.6.0-beta.1':
+ resolution: {integrity: sha512-r3L60ekkDLM5qoRjCMrqsgwU9SQ5e8oA/Omltu/FEEUspIVHawPvAqNZvAXnGB+FoNxM8YgdRRh12PAwXJww0A==}
+
+ '@rspack/binding@1.7.6':
+ resolution: {integrity: sha512-/NrEcfo8Gx22hLGysanrV6gHMuqZSxToSci/3M4kzEQtF5cPjfOv5pqeLK/+B6cr56ul/OmE96cCdWcXeVnFjQ==}
- '@rspack/core@1.6.6':
- resolution: {integrity: sha512-2mR+2YBydlgZ7Q0Rpd6bCC3MBnV9TS0x857K0zIhbDj4BQOqaWVy1n7fx/B3MrS8TR0QCuzKfyDAjNz+XTyJVQ==}
+ '@rspack/core@1.6.0-beta.1':
+ resolution: {integrity: sha512-2ff8XWonPPHyQ6mEWogMspg+Sul3lXZUfNQVrbYSjfNpi8CeDV0/ZtRbHHbAXiy6pz5fvBFL6X+i/ATckjTYBw==}
engines: {node: '>=18.12.0'}
peerDependencies:
'@swc/helpers': '>=0.5.1'
@@ -1915,6 +2074,19 @@ packages:
'@swc/helpers':
optional: true
+ '@rspack/core@1.7.6':
+ resolution: {integrity: sha512-Iax6UhrfZqJajA778c1d5DBFbSIqPOSrI34kpNIiNpWd8Jq7mFIa+Z60SQb5ZQDZuUxcCZikjz5BxinFjTkg7Q==}
+ engines: {node: '>=18.12.0'}
+ peerDependencies:
+ '@swc/helpers': '>=0.5.1'
+ peerDependenciesMeta:
+ '@swc/helpers':
+ optional: true
+
+ '@rspack/lite-tapable@1.0.1':
+ resolution: {integrity: sha512-VynGOEsVw2s8TAlLf/uESfrgfrq2+rcXB1muPJYBWbsm1Oa6r5qVQhjA5ggM6z/coYPrsVMgovl3Ff7Q7OCp1w==}
+ engines: {node: '>=16.0.0'}
+
'@rspack/lite-tapable@1.1.0':
resolution: {integrity: sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==}
@@ -1924,17 +2096,17 @@ packages:
'@sqltools/formatter@1.2.5':
resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==}
- '@standard-schema/spec@1.0.0':
- resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
- '@swc/helpers@0.5.17':
- resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
+ '@swc/helpers@0.5.18':
+ resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
- '@trpc/server@11.7.2':
- resolution: {integrity: sha512-AgB26PXY69sckherIhCacKLY49rxE2XP5h38vr/KMZTbLCL1p8IuIoKPjALTcugC2kbyQ7Lbqo2JDVfRSmPmfQ==}
+ '@trpc/server@11.10.0':
+ resolution: {integrity: sha512-zZjTrR6He61e5TiT7e/bQqab/jRcXBZM8Fg78Yoo8uh5pz60dzzbYuONNUCOkafv5ppXVMms4NHYfNZgzw50vg==}
peerDependencies:
typescript: '>=5.7.2'
@@ -1953,16 +2125,19 @@ packages:
'@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
- '@types/node@24.10.1':
- resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==}
+ '@types/figlet@1.7.0':
+ resolution: {integrity: sha512-KwrT7p/8Eo3Op/HBSIwGXOsTZKYiM9NpWRBJ5sVjWP/SmlS+oxxRvJht/FNAtliJvja44N3ul1yATgohnVBV0Q==}
+
+ '@types/node@25.2.3':
+ resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
'@types/react': ^19.2.0
- '@types/react@19.2.7':
- resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
+ '@types/react@19.2.14':
+ resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/tapable@2.2.7':
resolution: {integrity: sha512-D6QzACV9vNX3r8HQQNTOnpG+Bv1rko+yEA82wKs3O9CQ5+XW7HI7TED17/UE7+5dIxyxZIWTxKbsBeF6uKFCwA==}
@@ -2004,8 +2179,8 @@ packages:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
- ansis@4.2.0:
- resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
+ ansis@3.17.0:
+ resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==}
engines: {node: '>=14'}
app-root-path@3.1.0:
@@ -2029,13 +2204,13 @@ packages:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
- baseline-browser-mapping@2.9.2:
- resolution: {integrity: sha512-PxSsosKQjI38iXkmb3d0Y32efqyA0uW4s41u4IVBsLlWLhCiYNpH/AfNOVWRqCQBlD8TFJTz6OUWNd4DFJCnmw==}
+ baseline-browser-mapping@2.9.19:
+ resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true
- better-sqlite3@12.5.0:
- resolution: {integrity: sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==}
- engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
+ better-sqlite3@12.4.1:
+ resolution: {integrity: sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==}
+ engines: {node: 20.x || 22.x || 23.x || 24.x}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
@@ -2049,8 +2224,9 @@ packages:
browserslist-load-config@1.0.1:
resolution: {integrity: sha512-orLR5HAoQugQNVUXUwNd+GAAwl3H64KLIwoMFBNW0AbMSqX2Lxs4ZV2/5UoNrVQlQqF9ygychiu7Svr/99bLtg==}
- browserslist-to-es-version@1.2.0:
- resolution: {integrity: sha512-wZpJM7QUP33yPzWDzMLgTFZzb3WC6f7G13gnJ5p8PlFz3Xm9MUwArRh9jgE0y4/Sqo6CKtsNxyGpVf5zLWgAhg==}
+ browserslist-to-es-version@1.4.1:
+ resolution: {integrity: sha512-1bYCrck5Qh5HUy7P+iDuK39v757/ry5PnQo20vf4sHGeUrYKL2N2OF05U9ARSGt06TpFDQiTv9MT+eitYgWWxA==}
+ hasBin: true
browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
@@ -2066,8 +2242,11 @@ packages:
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
- c15t@1.8.1:
- resolution: {integrity: sha512-D4We2FxVN7tGf/LQsMCWQj+GrSVKX6HSC9Leyw/Ble74E0ocmVKI61+YcP6rdEpmIv6e9T9lO7NECtX0RTEEWA==}
+ c15t@1.7.1:
+ resolution: {integrity: sha512-MXQt0zsqTRbUs+9dNc9PivauJWc9232akbotS3NmUXnkMLBxYsCduSvqe560HDVVj0uhIQmYx0Yp0JbvHp/5yw==}
+
+ c15t@1.8.3:
+ resolution: {integrity: sha512-qZSlabDUNkY6Wfv/q0ah07e2ERMiX7xg/qaXSa7A80mMOa5/fXqW7nkw5IWIEgq61g+/l2Eunjm99sA+pSsJQQ==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
@@ -2081,8 +2260,8 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
- caniuse-lite@1.0.30001759:
- resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==}
+ caniuse-lite@1.0.30001770:
+ resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==}
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
@@ -2091,12 +2270,15 @@ packages:
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
- citty@0.1.6:
- resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
+ citty@0.2.1:
+ resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==}
cjs-module-lexer@1.4.3:
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==}
+ cjs-module-lexer@2.2.0:
+ resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==}
+
cli-table3@0.6.5:
resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==}
engines: {node: 10.* || >= 12.*}
@@ -2119,17 +2301,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
- commander@14.0.2:
- resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==}
+ commander@14.0.3:
+ resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
- confbox@0.2.2:
- resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
-
- consola@3.4.2:
- resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
- engines: {node: ^14.18.0 || >=16.10.0}
-
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
@@ -2142,11 +2317,14 @@ packages:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
+ core-js@3.46.0:
+ resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==}
+
core-js@3.47.0:
resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==}
- cors@2.8.5:
- resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
+ cors@2.8.6:
+ resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
cross-spawn@7.0.6:
@@ -2185,8 +2363,8 @@ packages:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
- dedent@1.7.0:
- resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==}
+ dedent@1.7.1:
+ resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==}
peerDependencies:
babel-plugin-macros: ^3.1.0
peerDependenciesMeta:
@@ -2237,12 +2415,12 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
- dotenv@17.2.3:
- resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
+ dotenv@17.3.1:
+ resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
engines: {node: '>=12'}
- drizzle-kit@0.31.8:
- resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==}
+ drizzle-kit@0.31.9:
+ resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==}
hasBin: true
drizzle-orm@0.44.7:
@@ -2344,8 +2522,8 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
- electron-to-chromium@1.5.266:
- resolution: {integrity: sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==}
+ electron-to-chromium@1.5.286:
+ resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -2360,8 +2538,8 @@ packages:
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
engines: {node: '>=10.0.0'}
- engine.io@6.6.4:
- resolution: {integrity: sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==}
+ engine.io@6.6.5:
+ resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==}
engines: {node: '>=10.2.0'}
enhanced-resolve@5.12.0:
@@ -2376,8 +2554,8 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
- envinfo@7.19.0:
- resolution: {integrity: sha512-DoSM9VyG6O3vqBf+p3Gjgr/Q52HYBBtO3v+4koAxt1MnWr+zEnxE+nke/yXS4lt2P4SYCHQ4V3f1i88LQVOpAw==}
+ envinfo@7.21.0:
+ resolution: {integrity: sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==}
engines: {node: '>=4'}
hasBin: true
@@ -2393,8 +2571,8 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
- es-toolkit@1.42.0:
- resolution: {integrity: sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==}
+ es-toolkit@1.44.0:
+ resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==}
esbuild-register@3.6.0:
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
@@ -2423,13 +2601,15 @@ packages:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
- exsolve@1.0.8:
- resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
-
fetch-blob@3.2.0:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
+ figlet@1.10.0:
+ resolution: {integrity: sha512-aktIwEZZ6Gp9AWdMXW4YCi0J2Ahuxo67fNJRUIWD81w8pQ0t9TS8FFpbl27ChlTLF06VkwjDesZSzEVzN75rzA==}
+ engines: {node: '>= 17.0.0'}
+ hasBin: true
+
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
@@ -2452,8 +2632,8 @@ packages:
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
- fs-extra@11.3.2:
- resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
+ fs-extra@11.3.3:
+ resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==}
engines: {node: '>=14.14'}
fsevents@2.3.2:
@@ -2500,20 +2680,17 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
- get-tsconfig@4.13.0:
- resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
+ get-tsconfig@4.13.6:
+ resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
- glob@13.0.0:
- resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
- engines: {node: 20 || >=22}
-
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -2545,8 +2722,8 @@ packages:
import-in-the-middle@1.15.0:
resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==}
- import-in-the-middle@2.0.0:
- resolution: {integrity: sha512-yNZhyQYqXpkT0AKq3F3KLasUSK4fHvebNH5hOsKQw2dhGSALvQ4U0BqUc5suziKvydO5u5hgN2hy1RJaho8U5A==}
+ import-in-the-middle@2.0.6:
+ resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -2612,8 +2789,8 @@ packages:
resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==}
engines: {node: '>=14.0.0'}
- kysely@0.28.8:
- resolution: {integrity: sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==}
+ kysely@0.28.11:
+ resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==}
engines: {node: '>=20.0.0'}
libsql@0.5.22:
@@ -2638,10 +2815,6 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
- lru-cache@11.2.4:
- resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
- engines: {node: 20 || >=22}
-
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -2658,10 +2831,6 @@ packages:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
- minimatch@10.1.1:
- resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
- engines: {node: 20 || >=22}
-
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -2698,8 +2867,8 @@ packages:
resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==}
engines: {node: '>=18'}
- next@16.0.7:
- resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==}
+ next@16.1.6:
+ resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -2719,8 +2888,8 @@ packages:
sass:
optional: true
- node-abi@3.85.0:
- resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==}
+ node-abi@3.87.0:
+ resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'}
node-domexception@1.0.0:
@@ -2735,9 +2904,9 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
- nypm@0.6.2:
- resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==}
- engines: {node: ^14.16.0 || >=16.10.0}
+ nypm@0.6.5:
+ resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==}
+ engines: {node: '>=18'}
hasBin: true
object-assign@4.1.1:
@@ -2750,16 +2919,9 @@ packages:
openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
- p-limit@7.2.0:
- resolution: {integrity: sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ==}
- engines: {node: '>=20'}
-
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
- package-manager-detector@1.6.0:
- resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
-
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
@@ -2778,32 +2940,29 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
- path-scurry@2.0.1:
- resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
- engines: {node: 20 || >=22}
-
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ perfume.js@9.4.0:
+ resolution: {integrity: sha512-YYxGBYm2OcDx68GhzX/N3h4RrtViAz7Whgk7dA6j1bC9NxBGIG8c+rs+K3ql/dW4KReszN8ptAC1ghUGdWNsMQ==}
+ engines: {node: '>=10.0.0'}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- pkg-types@2.3.0:
- resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
-
- playwright-core@1.57.0:
- resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
+ playwright-core@1.58.2:
+ resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
- playwright-performance-metrics@1.2.4:
- resolution: {integrity: sha512-opcX5KR3TZLn6oHkRHvoLBdp0U20Yact/gdKTCGN3uwnp3SmB5Ch8knlAJOujQpLN/WZ/JAI/hlUaesewPq7fg==}
+ playwright-performance-metrics@1.2.5:
+ resolution: {integrity: sha512-64tL3xm3TCn3J1yYhrW6pCYbx9uaJYWy4l3aD7hLK2BPLub7U1fYL0yVpI5ewLq4qMwsal8OkomShuMflbGUBA==}
engines: {node: '>=18.0.0'}
peerDependencies:
'@playwright/test': '>=1.40.0 <2.0.0'
- playwright@1.57.0:
- resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==}
+ playwright@1.58.2:
+ resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
@@ -2845,16 +3004,16 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
- react-dom@19.2.1:
- resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==}
+ react-dom@19.2.4:
+ resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies:
- react: ^19.2.1
+ react: ^19.2.4
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
- react@19.2.1:
- resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
+ react@19.2.4:
+ resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
readable-stream@3.6.2:
@@ -2884,11 +3043,11 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
- rou3@0.7.10:
- resolution: {integrity: sha512-aoFj6f7MJZ5muJ+Of79nrhs9N3oLGqi2VEMe94Zbkjb6Wupha46EuoYgpWSOZlXww3bbd8ojgXTAA2mzimX5Ww==}
+ rou3@0.7.12:
+ resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
- rsbuild-plugin-dts@0.18.3:
- resolution: {integrity: sha512-+V0D37pK0Q+5DIVGDlq+ky5H/Sb2VnTzsbtV3l0Hw9tVcXQOjEQMeWAmBgC8jBgNjnCVEWrwCqLdAvSTzIeOOw==}
+ rsbuild-plugin-dts@0.16.1:
+ resolution: {integrity: sha512-2/5ihqhc6q42gor9KsS+Kyeqv3hY3wt65tsL7gIRLjjBrlrVeo2MIyTjXcFpJ6hgLslJzm2KITpIDr9nxs30CA==}
engines: {node: '>=18.12.0'}
peerDependencies:
'@microsoft/api-extractor': ^7
@@ -2915,8 +3074,8 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
- semver@7.7.3:
- resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+ semver@7.7.4:
+ resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
@@ -2954,11 +3113,11 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
- socket.io-adapter@2.5.5:
- resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==}
+ socket.io-adapter@2.5.6:
+ resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
- socket.io-parser@4.2.4:
- resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
+ socket.io-parser@4.2.5:
+ resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==}
engines: {node: '>=10.0.0'}
socket.io@4.8.1:
@@ -3055,8 +3214,8 @@ packages:
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
engines: {node: '>= 0.4'}
- trpc-cli@0.12.1:
- resolution: {integrity: sha512-/D/mIQf3tUrS7ZKJZ1gmSPJn2psAABJfkC5Eevm55SZ4s6KwANOUNlwhAGXN9HT4VSJVfoF2jettevE9vHPQlg==}
+ trpc-cli@0.12.2:
+ resolution: {integrity: sha512-kGNCiyOimGlfcZFImbWzFF2Nn3TMnenwUdyuckiN5SEaceJbIac7+Iau3WsVHjQpoNgugFruZMDOKf8GNQNtJw==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
@@ -3086,38 +3245,38 @@ packages:
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
- turbo-darwin-64@2.6.3:
- resolution: {integrity: sha512-BlJJDc1CQ7SK5Y5qnl7AzpkvKSnpkfPmnA+HeU/sgny3oHZckPV2776ebO2M33CYDSor7+8HQwaodY++IINhYg==}
+ turbo-darwin-64@2.8.9:
+ resolution: {integrity: sha512-KnCw1ZI9KTnEAhdI9avZrnZ/z4wsM++flMA1w8s8PKOqi5daGpFV36qoPafg4S8TmYMe52JPWEoFr0L+lQ5JIw==}
cpu: [x64]
os: [darwin]
- turbo-darwin-arm64@2.6.3:
- resolution: {integrity: sha512-MwVt7rBKiOK7zdYerenfCRTypefw4kZCue35IJga9CH1+S50+KTiCkT6LBqo0hHeoH2iKuI0ldTF2a0aB72z3w==}
+ turbo-darwin-arm64@2.8.9:
+ resolution: {integrity: sha512-CbD5Y2NKJKBXTOZ7z7Cc7vGlFPZkYjApA7ri9lH4iFwKV1X7MoZswh9gyRLetXYWImVX1BqIvP8KftulJg/wIA==}
cpu: [arm64]
os: [darwin]
- turbo-linux-64@2.6.3:
- resolution: {integrity: sha512-cqpcw+dXxbnPtNnzeeSyWprjmuFVpHJqKcs7Jym5oXlu/ZcovEASUIUZVN3OGEM6Y/OTyyw0z09tOHNt5yBAVg==}
+ turbo-linux-64@2.8.9:
+ resolution: {integrity: sha512-OXC9HdCtsHvyH+5KUoH8ds+p5WU13vdif0OPbsFzZca4cUXMwKA3HWwUuCgQetk0iAE4cscXpi/t8A263n3VTg==}
cpu: [x64]
os: [linux]
- turbo-linux-arm64@2.6.3:
- resolution: {integrity: sha512-MterpZQmjXyr4uM7zOgFSFL3oRdNKeflY7nsjxJb2TklsYqiu3Z9pQ4zRVFFH8n0mLGna7MbQMZuKoWqqHb45w==}
+ turbo-linux-arm64@2.8.9:
+ resolution: {integrity: sha512-yI5n8jNXiFA6+CxnXG0gO7h5ZF1+19K8uO3/kXPQmyl37AdiA7ehKJQOvf9OPAnmkGDHcF2HSCPltabERNRmug==}
cpu: [arm64]
os: [linux]
- turbo-windows-64@2.6.3:
- resolution: {integrity: sha512-biDU70v9dLwnBdLf+daoDlNJVvqOOP8YEjqNipBHzgclbQlXbsi6Gqqelp5er81Qo3BiRgmTNx79oaZQTPb07Q==}
+ turbo-windows-64@2.8.9:
+ resolution: {integrity: sha512-/OztzeGftJAg258M/9vK2ZCkUKUzqrWXJIikiD2pm8TlqHcIYUmepDbyZSDfOiUjMy6NzrLFahpNLnY7b5vNgg==}
cpu: [x64]
os: [win32]
- turbo-windows-arm64@2.6.3:
- resolution: {integrity: sha512-dDHVKpSeukah3VsI/xMEKeTnV9V9cjlpFSUs4bmsUiLu3Yv2ENlgVEZv65wxbeE0bh0jjpmElDT+P1KaCxArQQ==}
+ turbo-windows-arm64@2.8.9:
+ resolution: {integrity: sha512-xZ2VTwVTjIqpFZKN4UBxDHCPM3oJ2J5cpRzCBSmRpJ/Pn33wpiYjs+9FB2E03svKaD04/lSSLlEUej0UYsugfg==}
cpu: [arm64]
os: [win32]
- turbo@2.6.3:
- resolution: {integrity: sha512-bf6YKUv11l5Xfcmg76PyWoy/e2vbkkxFNBGJSnfdSXQC33ZiUfutYh6IXidc5MhsnrFkWfdNNLyaRk+kHMLlwA==}
+ turbo@2.8.9:
+ resolution: {integrity: sha512-G+Mq8VVQAlpz/0HTsxiNNk/xywaHGl+dk1oiBREgOEVCCDjXInDlONWUn5srRnC9s5tdHTFD1bx1N19eR4hI+g==}
hasBin: true
type-detect@4.1.0:
@@ -3128,31 +3287,32 @@ packages:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
- type-fest@5.3.0:
- resolution: {integrity: sha512-d9CwU93nN0IA1QL+GSNDdwLAu1Ew5ZjTwupvedwg3WdfoH6pIDvYQ2hV0Uc2nKBLPq7NB5apCx57MLS5qlmO5g==}
+ type-fest@5.4.4:
+ resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==}
engines: {node: '>=20'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
- typeorm@0.3.28:
- resolution: {integrity: sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==}
+ typeorm@0.3.27:
+ resolution: {integrity: sha512-pNV1bn+1n8qEe8tUNsNdD8ejuPcMAg47u2lUGnbsajiNUr3p2Js1XLKQjBMH0yMRMDfdX8T+fIRejFmIwy9x4A==}
engines: {node: '>=16.13.0'}
hasBin: true
peerDependencies:
- '@google-cloud/spanner': ^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ '@google-cloud/spanner': ^5.18.0 || ^6.0.0 || ^7.0.0
'@sap/hana-client': ^2.14.22
better-sqlite3: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0
ioredis: ^5.0.4
mongodb: ^5.8.0 || ^6.0.0
- mssql: ^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0
+ mssql: ^9.1.1 || ^10.0.1 || ^11.0.1
mysql2: ^2.2.5 || ^3.0.1
oracledb: ^6.3.0
pg: ^8.5.1
pg-native: ^3.0.0
pg-query-stream: ^4.0.0
redis: ^3.1.1 || ^4.0.0 || ^5.0.14
+ reflect-metadata: ^0.1.14 || ^0.2.0
sql.js: ^1.4.0
sqlite3: ^5.0.3
ts-node: ^10.7.0
@@ -3196,8 +3356,8 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
- ultracite@6.3.9:
- resolution: {integrity: sha512-bqj8Eh3VWYcjHXMR3+a9xqZXbgFY3PxwB+m9LTC+W1HHs5qtOtxH4QX51MiH513jfThpdM+cnWAk5KNltVh+Kg==}
+ ultracite@6.0.5:
+ resolution: {integrity: sha512-H5OrSBQzZmjxL1qwLgi21cmWI46i9sk0iBIVurb+iGOmr6XdzPANcydpOS3h4dN/dCzDGp29GOCqVaUGHeET1g==}
hasBin: true
undici-types@7.16.0:
@@ -3207,8 +3367,8 @@ packages:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
- update-browserslist-db@1.2.2:
- resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==}
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
@@ -3233,8 +3393,11 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
- which-typed-array@1.1.19:
- resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
+ web-vitals@3.5.2:
+ resolution: {integrity: sha512-c0rhqNcHXRkY/ogGDJQxZ9Im9D19hDihbzSQJrsioex+KnFgmMzBiy57Z1EjkhX/+OjyBpclDCzz2ITtjokFmg==}
+
+ which-typed-array@1.1.20:
+ resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
engines: {node: '>= 0.4'}
which@2.0.2:
@@ -3256,18 +3419,6 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
- ws@8.17.1:
- resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
- engines: {node: '>=10.0.0'}
- peerDependencies:
- bufferutil: ^4.0.1
- utf-8-validate: '>=5.0.2'
- peerDependenciesMeta:
- bufferutil:
- optional: true
- utf-8-validate:
- optional: true
-
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
@@ -3292,15 +3443,11 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
- yocto-queue@1.2.2:
- resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
- engines: {node: '>=12.20'}
+ zod@4.3.6:
+ resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
- zod@4.1.13:
- resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==}
-
- zustand@5.0.9:
- resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
+ zustand@5.0.11:
+ resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
@@ -3366,62 +3513,119 @@ snapshots:
'@babel/helper-validator-identifier@7.28.5': {}
- '@biomejs/biome@2.3.8':
+ '@biomejs/biome@2.3.2':
optionalDependencies:
- '@biomejs/cli-darwin-arm64': 2.3.8
- '@biomejs/cli-darwin-x64': 2.3.8
- '@biomejs/cli-linux-arm64': 2.3.8
- '@biomejs/cli-linux-arm64-musl': 2.3.8
- '@biomejs/cli-linux-x64': 2.3.8
- '@biomejs/cli-linux-x64-musl': 2.3.8
- '@biomejs/cli-win32-arm64': 2.3.8
- '@biomejs/cli-win32-x64': 2.3.8
+ '@biomejs/cli-darwin-arm64': 2.3.2
+ '@biomejs/cli-darwin-x64': 2.3.2
+ '@biomejs/cli-linux-arm64': 2.3.2
+ '@biomejs/cli-linux-arm64-musl': 2.3.2
+ '@biomejs/cli-linux-x64': 2.3.2
+ '@biomejs/cli-linux-x64-musl': 2.3.2
+ '@biomejs/cli-win32-arm64': 2.3.2
+ '@biomejs/cli-win32-x64': 2.3.2
- '@biomejs/cli-darwin-arm64@2.3.8':
+ '@biomejs/cli-darwin-arm64@2.3.2':
optional: true
- '@biomejs/cli-darwin-x64@2.3.8':
+ '@biomejs/cli-darwin-x64@2.3.2':
optional: true
- '@biomejs/cli-linux-arm64-musl@2.3.8':
+ '@biomejs/cli-linux-arm64-musl@2.3.2':
optional: true
- '@biomejs/cli-linux-arm64@2.3.8':
+ '@biomejs/cli-linux-arm64@2.3.2':
optional: true
- '@biomejs/cli-linux-x64-musl@2.3.8':
+ '@biomejs/cli-linux-x64-musl@2.3.2':
optional: true
- '@biomejs/cli-linux-x64@2.3.8':
+ '@biomejs/cli-linux-x64@2.3.2':
optional: true
- '@biomejs/cli-win32-arm64@2.3.8':
+ '@biomejs/cli-win32-arm64@2.3.2':
optional: true
- '@biomejs/cli-win32-x64@2.3.8':
+ '@biomejs/cli-win32-x64@2.3.2':
optional: true
- '@c15t/backend@1.8.0(@libsql/client@0.15.15)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(typeorm@0.3.28(better-sqlite3@12.5.0))(ws@8.18.3)':
+ '@c15t/backend@1.7.1(@libsql/client@0.15.15)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(ws@8.18.3)':
+ dependencies:
+ '@c15t/logger': 1.0.0
+ '@c15t/translations': 1.7.0
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-node': 0.203.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0)
+ '@orpc/contract': 1.8.1(@opentelemetry/api@1.9.0)
+ '@orpc/openapi': 1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3)
+ '@orpc/otel': 1.13.5(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))
+ '@orpc/server': 1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3)
+ '@orpc/zod': 1.8.1(@opentelemetry/api@1.9.0)(@orpc/contract@1.8.1(@opentelemetry/api@1.9.0))(@orpc/server@1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3))(ws@8.18.3)(zod@4.3.6)
+ base-x: 5.0.1
+ defu: 6.1.4
+ drizzle-orm: 0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(kysely@0.27.6)
+ fumadb: 0.1.2(drizzle-orm@0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(kysely@0.27.6))(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))
+ kysely: 0.27.6
+ neverthrow: 8.2.0
+ superjson: 2.2.6
+ zod: 4.3.6
+ transitivePeerDependencies:
+ - '@aws-sdk/client-rds-data'
+ - '@cloudflare/workers-types'
+ - '@electric-sql/pglite'
+ - '@libsql/client'
+ - '@libsql/client-wasm'
+ - '@neondatabase/serverless'
+ - '@op-engineering/op-sqlite'
+ - '@opentelemetry/instrumentation'
+ - '@planetscale/database'
+ - '@prisma/client'
+ - '@tidbcloud/serverless'
+ - '@types/better-sqlite3'
+ - '@types/pg'
+ - '@types/sql.js'
+ - '@upstash/redis'
+ - '@vercel/postgres'
+ - '@xata.io/client'
+ - better-sqlite3
+ - bun-types
+ - convex
+ - crossws
+ - expo-sqlite
+ - gel
+ - knex
+ - mongodb
+ - mysql2
+ - pg
+ - postgres
+ - prisma
+ - sql.js
+ - sqlite3
+ - supports-color
+ - typeorm
+ - ws
+
+ '@c15t/backend@1.8.0(@libsql/client@0.15.15)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(ws@8.18.3)':
dependencies:
'@c15t/logger': 1.0.1
'@c15t/translations': 1.8.0
'@opentelemetry/api': 1.9.0
- '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-node': 0.203.0(@opentelemetry/api@1.9.0)
- '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0)
'@orpc/contract': 1.8.1(@opentelemetry/api@1.9.0)
'@orpc/openapi': 1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3)
- '@orpc/otel': 1.12.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))
+ '@orpc/otel': 1.13.5(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))
'@orpc/server': 1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3)
- '@orpc/zod': 1.8.1(@opentelemetry/api@1.9.0)(@orpc/contract@1.8.1(@opentelemetry/api@1.9.0))(@orpc/server@1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3))(ws@8.18.3)(zod@4.1.13)
+ '@orpc/zod': 1.8.1(@opentelemetry/api@1.9.0)(@orpc/contract@1.8.1(@opentelemetry/api@1.9.0))(@orpc/server@1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3))(ws@8.18.3)(zod@4.3.6)
base-x: 5.0.1
defu: 6.1.4
- drizzle-orm: 0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.27.6)
- fumadb: 0.1.2(drizzle-orm@0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.27.6))(typeorm@0.3.28(better-sqlite3@12.5.0))
+ drizzle-orm: 0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(kysely@0.27.6)
+ fumadb: 0.1.2(drizzle-orm@0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(kysely@0.27.6))(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))
kysely: 0.27.6
neverthrow: 8.2.0
superjson: 2.2.6
- zod: 4.1.13
+ zod: 4.3.6
transitivePeerDependencies:
- '@aws-sdk/client-rds-data'
- '@cloudflare/workers-types'
@@ -3458,19 +3662,76 @@ snapshots:
- typeorm
- ws
+ '@c15t/logger@1.0.0':
+ dependencies:
+ chalk: 5.6.2
+ neverthrow: 8.2.0
+ picocolors: 1.1.1
+
'@c15t/logger@1.0.1':
dependencies:
chalk: 5.6.2
neverthrow: 8.2.0
picocolors: 1.1.1
- '@c15t/nextjs@1.8.1(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.5.0)(next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typeorm@0.3.28(better-sqlite3@12.5.0))(use-sync-external-store@1.6.0(react@19.2.1))(ws@8.18.3)':
+ '@c15t/nextjs@1.8.3(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.4.1)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.18.3)':
dependencies:
- '@c15t/react': 1.8.1(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typeorm@0.3.28(better-sqlite3@12.5.0))(use-sync-external-store@1.6.0(react@19.2.1))(ws@8.18.3)
+ '@c15t/react': 1.8.3(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.18.3)
'@c15t/translations': 1.8.0
- next: 16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
- react: 19.2.1
- react-dom: 19.2.1(react@19.2.1)
+ next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ transitivePeerDependencies:
+ - '@aws-sdk/client-rds-data'
+ - '@cloudflare/workers-types'
+ - '@electric-sql/pglite'
+ - '@libsql/client'
+ - '@libsql/client-wasm'
+ - '@neondatabase/serverless'
+ - '@op-engineering/op-sqlite'
+ - '@opentelemetry/api'
+ - '@opentelemetry/instrumentation'
+ - '@planetscale/database'
+ - '@prisma/client'
+ - '@tidbcloud/serverless'
+ - '@types/better-sqlite3'
+ - '@types/pg'
+ - '@types/react'
+ - '@types/react-dom'
+ - '@types/sql.js'
+ - '@upstash/redis'
+ - '@vercel/postgres'
+ - '@xata.io/client'
+ - better-sqlite3
+ - bun-types
+ - convex
+ - crossws
+ - expo-sqlite
+ - gel
+ - immer
+ - knex
+ - mongodb
+ - mysql2
+ - pg
+ - postgres
+ - prisma
+ - sql.js
+ - sqlite3
+ - supports-color
+ - typeorm
+ - use-sync-external-store
+ - ws
+
+ '@c15t/react@1.7.1(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.18.3)':
+ dependencies:
+ '@radix-ui/react-accordion': 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-slot': 1.2.0(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-switch': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ c15t: 1.7.1(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react@19.2.14)(better-sqlite3@12.4.1)(react@19.2.4)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.18.3)
+ clsx: 2.1.1
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ zustand: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
transitivePeerDependencies:
- '@aws-sdk/client-rds-data'
- '@cloudflare/workers-types'
@@ -3512,16 +3773,16 @@ snapshots:
- use-sync-external-store
- ws
- '@c15t/react@1.8.1(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typeorm@0.3.28(better-sqlite3@12.5.0))(use-sync-external-store@1.6.0(react@19.2.1))(ws@8.18.3)':
+ '@c15t/react@1.8.3(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.18.3)':
dependencies:
- '@radix-ui/react-accordion': 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
- '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-switch': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
- c15t: 1.8.1(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react@19.2.7)(better-sqlite3@12.5.0)(react@19.2.1)(typeorm@0.3.28(better-sqlite3@12.5.0))(use-sync-external-store@1.6.0(react@19.2.1))(ws@8.18.3)
+ '@radix-ui/react-accordion': 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-slot': 1.2.0(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-switch': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ c15t: 1.8.3(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react@19.2.14)(better-sqlite3@12.4.1)(react@19.2.4)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.18.3)
clsx: 2.1.1
- react: 19.2.1
- react-dom: 19.2.1(react@19.2.1)
- zustand: 5.0.9(@types/react@19.2.7)(react@19.2.1)(use-sync-external-store@1.6.0(react@19.2.1))
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ zustand: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
transitivePeerDependencies:
- '@aws-sdk/client-rds-data'
- '@cloudflare/workers-types'
@@ -3563,6 +3824,8 @@ snapshots:
- use-sync-external-store
- ws
+ '@c15t/translations@1.7.0': {}
+
'@c15t/translations@1.8.0': {}
'@clack/core@0.5.0':
@@ -3570,7 +3833,7 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
- '@clack/core@1.0.0-alpha.6':
+ '@clack/core@1.0.1':
dependencies:
picocolors: 1.1.1
sisteransi: 1.0.5
@@ -3581,29 +3844,29 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
- '@clack/prompts@1.0.0-alpha.6':
+ '@clack/prompts@1.0.1':
dependencies:
- '@clack/core': 1.0.0-alpha.6
+ '@clack/core': 1.0.1
picocolors: 1.1.1
sisteransi: 1.0.5
'@colors/colors@1.5.0':
optional: true
- '@didomi/react@1.8.8(react@19.2.1)':
+ '@didomi/react@1.8.8(react@19.2.4)':
dependencies:
prop-types: 15.8.1
- react: 19.2.1
+ react: 19.2.4
'@drizzle-team/brocli@0.10.2': {}
- '@emnapi/core@1.7.1':
+ '@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
optional: true
- '@emnapi/runtime@1.7.1':
+ '@emnapi/runtime@1.8.1':
dependencies:
tslib: 2.8.1
optional: true
@@ -3621,7 +3884,7 @@ snapshots:
'@esbuild-kit/esm-loader@2.6.5':
dependencies:
'@esbuild-kit/core-utils': 3.3.2
- get-tsconfig: 4.13.0
+ get-tsconfig: 4.13.6
'@esbuild/aix-ppc64@0.25.12':
optional: true
@@ -3767,7 +4030,7 @@ snapshots:
'@esbuild/win32-x64@0.25.12':
optional: true
- '@grpc/grpc-js@1.14.2':
+ '@grpc/grpc-js@1.14.3':
dependencies:
'@grpc/proto-loader': 0.8.0
'@js-sdsl/ordered-map': 4.4.2
@@ -3864,7 +4127,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5':
dependencies:
- '@emnapi/runtime': 1.7.1
+ '@emnapi/runtime': 1.8.1
optional: true
'@img/sharp-win32-arm64@0.34.5':
@@ -3876,12 +4139,6 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
- '@isaacs/balanced-match@4.0.1': {}
-
- '@isaacs/brace-expansion@5.0.0':
- dependencies:
- '@isaacs/balanced-match': 4.0.1
-
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -3960,65 +4217,96 @@ snapshots:
'@libsql/win32-x64-msvc@0.5.22':
optional: true
- '@module-federation/error-codes@0.21.6': {}
+ '@module-federation/error-codes@0.21.1': {}
+
+ '@module-federation/error-codes@0.22.0':
+ optional: true
+
+ '@module-federation/runtime-core@0.21.1':
+ dependencies:
+ '@module-federation/error-codes': 0.21.1
+ '@module-federation/sdk': 0.21.1
+
+ '@module-federation/runtime-core@0.22.0':
+ dependencies:
+ '@module-federation/error-codes': 0.22.0
+ '@module-federation/sdk': 0.22.0
+ optional: true
+
+ '@module-federation/runtime-tools@0.21.1':
+ dependencies:
+ '@module-federation/runtime': 0.21.1
+ '@module-federation/webpack-bundler-runtime': 0.21.1
- '@module-federation/runtime-core@0.21.6':
+ '@module-federation/runtime-tools@0.22.0':
dependencies:
- '@module-federation/error-codes': 0.21.6
- '@module-federation/sdk': 0.21.6
+ '@module-federation/runtime': 0.22.0
+ '@module-federation/webpack-bundler-runtime': 0.22.0
+ optional: true
- '@module-federation/runtime-tools@0.21.6':
+ '@module-federation/runtime@0.21.1':
dependencies:
- '@module-federation/runtime': 0.21.6
- '@module-federation/webpack-bundler-runtime': 0.21.6
+ '@module-federation/error-codes': 0.21.1
+ '@module-federation/runtime-core': 0.21.1
+ '@module-federation/sdk': 0.21.1
- '@module-federation/runtime@0.21.6':
+ '@module-federation/runtime@0.22.0':
dependencies:
- '@module-federation/error-codes': 0.21.6
- '@module-federation/runtime-core': 0.21.6
- '@module-federation/sdk': 0.21.6
+ '@module-federation/error-codes': 0.22.0
+ '@module-federation/runtime-core': 0.22.0
+ '@module-federation/sdk': 0.22.0
+ optional: true
- '@module-federation/sdk@0.21.6': {}
+ '@module-federation/sdk@0.21.1': {}
- '@module-federation/webpack-bundler-runtime@0.21.6':
+ '@module-federation/sdk@0.22.0':
+ optional: true
+
+ '@module-federation/webpack-bundler-runtime@0.21.1':
dependencies:
- '@module-federation/runtime': 0.21.6
- '@module-federation/sdk': 0.21.6
+ '@module-federation/runtime': 0.21.1
+ '@module-federation/sdk': 0.21.1
+
+ '@module-federation/webpack-bundler-runtime@0.22.0':
+ dependencies:
+ '@module-federation/runtime': 0.22.0
+ '@module-federation/sdk': 0.22.0
+ optional: true
'@napi-rs/wasm-runtime@1.0.7':
dependencies:
- '@emnapi/core': 1.7.1
- '@emnapi/runtime': 1.7.1
+ '@emnapi/core': 1.8.1
+ '@emnapi/runtime': 1.8.1
'@tybys/wasm-util': 0.10.1
optional: true
'@neon-rs/load@0.0.4':
optional: true
- '@next/env@16.0.7': {}
+ '@next/env@16.1.6': {}
- '@next/swc-darwin-arm64@16.0.7':
+ '@next/swc-darwin-arm64@16.1.6':
optional: true
- '@next/swc-darwin-x64@16.0.7':
+ '@next/swc-darwin-x64@16.1.6':
optional: true
- '@next/swc-linux-arm64-gnu@16.0.7':
+ '@next/swc-linux-arm64-gnu@16.1.6':
optional: true
- '@next/swc-linux-arm64-musl@16.0.7':
+ '@next/swc-linux-arm64-musl@16.1.6':
optional: true
- '@next/swc-linux-x64-gnu@16.0.7':
+ '@next/swc-linux-x64-gnu@16.1.6':
optional: true
- '@next/swc-linux-x64-musl@16.0.7':
+ '@next/swc-linux-x64-musl@16.1.6':
optional: true
- '@next/swc-win32-arm64-msvc@16.0.7':
+ '@next/swc-win32-arm64-msvc@16.1.6':
optional: true
- '@next/swc-win32-x64-msvc@16.0.7':
+ '@next/swc-win32-x64-msvc@16.1.6':
optional: true
'@noble/hashes@1.8.0': {}
@@ -4027,7 +4315,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
- '@opentelemetry/api-logs@0.208.0':
+ '@opentelemetry/api-logs@0.207.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -4040,16 +4328,16 @@ snapshots:
'@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
- '@opentelemetry/semantic-conventions': 1.38.0
+ '@opentelemetry/semantic-conventions': 1.39.0
- '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)':
+ '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
- '@opentelemetry/semantic-conventions': 1.38.0
+ '@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/exporter-logs-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)':
dependencies:
- '@grpc/grpc-js': 1.14.2
+ '@grpc/grpc-js': 1.14.3
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0)
@@ -4079,7 +4367,7 @@ snapshots:
'@opentelemetry/exporter-metrics-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)':
dependencies:
- '@grpc/grpc-js': 1.14.2
+ '@grpc/grpc-js': 1.14.3
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0)
@@ -4117,7 +4405,7 @@ snapshots:
'@opentelemetry/exporter-trace-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)':
dependencies:
- '@grpc/grpc-js': 1.14.2
+ '@grpc/grpc-js': 1.14.3
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0)
@@ -4150,7 +4438,7 @@ snapshots:
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0)
- '@opentelemetry/semantic-conventions': 1.38.0
+ '@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0)':
dependencies:
@@ -4161,11 +4449,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0)':
+ '@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
- '@opentelemetry/api-logs': 0.208.0
- import-in-the-middle: 2.0.0
+ '@opentelemetry/api-logs': 0.207.0
+ import-in-the-middle: 2.0.6
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
@@ -4178,7 +4466,7 @@ snapshots:
'@opentelemetry/otlp-grpc-exporter-base@0.203.0(@opentelemetry/api@1.9.0)':
dependencies:
- '@grpc/grpc-js': 1.14.2
+ '@grpc/grpc-js': 1.14.3
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0)
@@ -4209,13 +4497,13 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
- '@opentelemetry/semantic-conventions': 1.38.0
+ '@opentelemetry/semantic-conventions': 1.39.0
- '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)':
+ '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
- '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
- '@opentelemetry/semantic-conventions': 1.38.0
+ '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0)':
dependencies:
@@ -4254,7 +4542,7 @@ snapshots:
'@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-node': 2.0.1(@opentelemetry/api@1.9.0)
- '@opentelemetry/semantic-conventions': 1.38.0
+ '@opentelemetry/semantic-conventions': 1.39.0
transitivePeerDependencies:
- supports-color
@@ -4263,14 +4551,14 @@ snapshots:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0)
- '@opentelemetry/semantic-conventions': 1.38.0
+ '@opentelemetry/semantic-conventions': 1.39.0
- '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)':
+ '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
- '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
- '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
- '@opentelemetry/semantic-conventions': 1.38.0
+ '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/sdk-trace-node@2.0.1(@opentelemetry/api@1.9.0)':
dependencies:
@@ -4279,17 +4567,7 @@ snapshots:
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0)
- '@opentelemetry/semantic-conventions@1.38.0': {}
-
- '@orpc/client@1.12.2(@opentelemetry/api@1.9.0)':
- dependencies:
- '@orpc/shared': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server-fetch': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server-peer': 1.12.2(@opentelemetry/api@1.9.0)
- transitivePeerDependencies:
- - '@opentelemetry/api'
- optional: true
+ '@opentelemetry/semantic-conventions@1.39.0': {}
'@orpc/client@1.8.1(@opentelemetry/api@1.9.0)':
dependencies:
@@ -4300,28 +4578,15 @@ snapshots:
transitivePeerDependencies:
- '@opentelemetry/api'
- '@orpc/contract@1.12.2(@opentelemetry/api@1.9.0)':
- dependencies:
- '@orpc/client': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/shared': 1.12.2(@opentelemetry/api@1.9.0)
- '@standard-schema/spec': 1.0.0
- openapi-types: 12.1.3
- transitivePeerDependencies:
- - '@opentelemetry/api'
- optional: true
-
'@orpc/contract@1.8.1(@opentelemetry/api@1.9.0)':
dependencies:
'@orpc/client': 1.8.1(@opentelemetry/api@1.9.0)
'@orpc/shared': 1.8.1(@opentelemetry/api@1.9.0)
- '@standard-schema/spec': 1.0.0
+ '@standard-schema/spec': 1.1.0
openapi-types: 12.1.3
transitivePeerDependencies:
- '@opentelemetry/api'
- '@orpc/interop@1.12.2':
- optional: true
-
'@orpc/interop@1.8.1': {}
'@orpc/json-schema@1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3)':
@@ -4354,37 +4619,17 @@ snapshots:
'@orpc/server': 1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3)
'@orpc/shared': 1.8.1(@opentelemetry/api@1.9.0)
'@orpc/standard-server': 1.8.1(@opentelemetry/api@1.9.0)
- rou3: 0.7.10
+ rou3: 0.7.12
transitivePeerDependencies:
- '@opentelemetry/api'
- crossws
- ws
- '@orpc/otel@1.12.2(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))':
+ '@orpc/otel@1.13.5(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))':
dependencies:
'@opentelemetry/api': 1.9.0
- '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
- '@orpc/shared': 1.12.2(@opentelemetry/api@1.9.0)
-
- '@orpc/server@1.12.2(@opentelemetry/api@1.9.0)(ws@8.18.3)':
- dependencies:
- '@orpc/client': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/contract': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/interop': 1.12.2
- '@orpc/shared': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server-aws-lambda': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server-fastify': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server-fetch': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server-node': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server-peer': 1.12.2(@opentelemetry/api@1.9.0)
- cookie: 1.1.1
- optionalDependencies:
- ws: 8.18.3
- transitivePeerDependencies:
- - '@opentelemetry/api'
- - fastify
- optional: true
+ '@opentelemetry/instrumentation': 0.207.0(@opentelemetry/api@1.9.0)
+ '@orpc/shared': 1.13.5(@opentelemetry/api@1.9.0)
'@orpc/server@1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3)':
dependencies:
@@ -4403,10 +4648,10 @@ snapshots:
transitivePeerDependencies:
- '@opentelemetry/api'
- '@orpc/shared@1.12.2(@opentelemetry/api@1.9.0)':
+ '@orpc/shared@1.13.5(@opentelemetry/api@1.9.0)':
dependencies:
radash: 12.1.1
- type-fest: 5.3.0
+ type-fest: 5.4.4
optionalDependencies:
'@opentelemetry/api': 1.9.0
@@ -4417,16 +4662,6 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
- '@orpc/standard-server-aws-lambda@1.12.2(@opentelemetry/api@1.9.0)':
- dependencies:
- '@orpc/shared': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server-fetch': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server-node': 1.12.2(@opentelemetry/api@1.9.0)
- transitivePeerDependencies:
- - '@opentelemetry/api'
- optional: true
-
'@orpc/standard-server-aws-lambda@1.8.1(@opentelemetry/api@1.9.0)':
dependencies:
'@orpc/shared': 1.8.1(@opentelemetry/api@1.9.0)
@@ -4436,23 +4671,6 @@ snapshots:
transitivePeerDependencies:
- '@opentelemetry/api'
- '@orpc/standard-server-fastify@1.12.2(@opentelemetry/api@1.9.0)':
- dependencies:
- '@orpc/shared': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server-node': 1.12.2(@opentelemetry/api@1.9.0)
- transitivePeerDependencies:
- - '@opentelemetry/api'
- optional: true
-
- '@orpc/standard-server-fetch@1.12.2(@opentelemetry/api@1.9.0)':
- dependencies:
- '@orpc/shared': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server': 1.12.2(@opentelemetry/api@1.9.0)
- transitivePeerDependencies:
- - '@opentelemetry/api'
- optional: true
-
'@orpc/standard-server-fetch@1.8.1(@opentelemetry/api@1.9.0)':
dependencies:
'@orpc/shared': 1.8.1(@opentelemetry/api@1.9.0)
@@ -4460,15 +4678,6 @@ snapshots:
transitivePeerDependencies:
- '@opentelemetry/api'
- '@orpc/standard-server-node@1.12.2(@opentelemetry/api@1.9.0)':
- dependencies:
- '@orpc/shared': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server-fetch': 1.12.2(@opentelemetry/api@1.9.0)
- transitivePeerDependencies:
- - '@opentelemetry/api'
- optional: true
-
'@orpc/standard-server-node@1.8.1(@opentelemetry/api@1.9.0)':
dependencies:
'@orpc/shared': 1.8.1(@opentelemetry/api@1.9.0)
@@ -4477,14 +4686,6 @@ snapshots:
transitivePeerDependencies:
- '@opentelemetry/api'
- '@orpc/standard-server-peer@1.12.2(@opentelemetry/api@1.9.0)':
- dependencies:
- '@orpc/shared': 1.12.2(@opentelemetry/api@1.9.0)
- '@orpc/standard-server': 1.12.2(@opentelemetry/api@1.9.0)
- transitivePeerDependencies:
- - '@opentelemetry/api'
- optional: true
-
'@orpc/standard-server-peer@1.8.1(@opentelemetry/api@1.9.0)':
dependencies:
'@orpc/shared': 1.8.1(@opentelemetry/api@1.9.0)
@@ -4492,20 +4693,13 @@ snapshots:
transitivePeerDependencies:
- '@opentelemetry/api'
- '@orpc/standard-server@1.12.2(@opentelemetry/api@1.9.0)':
- dependencies:
- '@orpc/shared': 1.12.2(@opentelemetry/api@1.9.0)
- transitivePeerDependencies:
- - '@opentelemetry/api'
- optional: true
-
'@orpc/standard-server@1.8.1(@opentelemetry/api@1.9.0)':
dependencies:
'@orpc/shared': 1.8.1(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- '@opentelemetry/api'
- '@orpc/zod@1.8.1(@opentelemetry/api@1.9.0)(@orpc/contract@1.8.1(@opentelemetry/api@1.9.0))(@orpc/server@1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3))(ws@8.18.3)(zod@4.1.13)':
+ '@orpc/zod@1.8.1(@opentelemetry/api@1.9.0)(@orpc/contract@1.8.1(@opentelemetry/api@1.9.0))(@orpc/server@1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3))(ws@8.18.3)(zod@4.3.6)':
dependencies:
'@orpc/contract': 1.8.1(@opentelemetry/api@1.9.0)
'@orpc/json-schema': 1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3)
@@ -4514,7 +4708,7 @@ snapshots:
'@orpc/shared': 1.8.1(@opentelemetry/api@1.9.0)
escape-string-regexp: 5.0.0
wildcard-match: 5.1.4
- zod: 4.1.13
+ zod: 4.3.6
transitivePeerDependencies:
- '@opentelemetry/api'
- crossws
@@ -4527,9 +4721,9 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
- '@playwright/test@1.57.0':
+ '@playwright/test@1.58.2':
dependencies:
- playwright: 1.57.0
+ playwright: 1.58.2
'@protobufjs/aspromise@1.1.2': {}
@@ -4556,185 +4750,204 @@ snapshots:
'@radix-ui/primitive@1.1.2': {}
- '@radix-ui/react-accordion@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
+ '@radix-ui/react-accordion@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.2
- '@radix-ui/react-collapsible': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
- '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
- '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- react: 19.2.1
- react-dom: 19.2.1(react@19.2.1)
+ '@radix-ui/react-collapsible': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
- '@types/react': 19.2.7
- '@types/react-dom': 19.2.3(@types/react@19.2.7)
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-collapsible@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
+ '@radix-ui/react-collapsible@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.2
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
- '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
- '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- react: 19.2.1
- react-dom: 19.2.1(react@19.2.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
- '@types/react': 19.2.7
- '@types/react-dom': 19.2.3(@types/react@19.2.7)
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-collection@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
+ '@radix-ui/react-collection@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
- '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.2.1)
- react: 19.2.1
- react-dom: 19.2.1(react@19.2.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-slot': 1.2.0(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
- '@types/react': 19.2.7
- '@types/react-dom': 19.2.3(@types/react@19.2.7)
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.1)':
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.1
+ react: 19.2.4
optionalDependencies:
- '@types/react': 19.2.7
+ '@types/react': 19.2.14
- '@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.2.1)':
+ '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.1
+ react: 19.2.4
optionalDependencies:
- '@types/react': 19.2.7
+ '@types/react': 19.2.14
- '@radix-ui/react-direction@1.1.1(@types/react@19.2.7)(react@19.2.1)':
+ '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.1
+ react: 19.2.4
optionalDependencies:
- '@types/react': 19.2.7
+ '@types/react': 19.2.14
- '@radix-ui/react-id@1.1.1(@types/react@19.2.7)(react@19.2.1)':
+ '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- react: 19.2.1
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
- '@types/react': 19.2.7
+ '@types/react': 19.2.14
- '@radix-ui/react-presence@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
+ '@radix-ui/react-presence@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- react: 19.2.1
- react-dom: 19.2.1(react@19.2.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
- '@types/react': 19.2.7
- '@types/react-dom': 19.2.3(@types/react@19.2.7)
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
+ '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
- '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.2.1)
- react: 19.2.1
- react-dom: 19.2.1(react@19.2.1)
+ '@radix-ui/react-slot': 1.2.0(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
- '@types/react': 19.2.7
- '@types/react-dom': 19.2.3(@types/react@19.2.7)
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-slot@1.2.0(@types/react@19.2.7)(react@19.2.1)':
+ '@radix-ui/react-slot@1.2.0(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
- react: 19.2.1
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
- '@types/react': 19.2.7
+ '@types/react': 19.2.14
- '@radix-ui/react-switch@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
+ '@radix-ui/react-switch@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.2
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
- '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- react: 19.2.1
- react-dom: 19.2.1(react@19.2.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
- '@types/react': 19.2.7
- '@types/react-dom': 19.2.3(@types/react@19.2.7)
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.2.1)':
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.1
+ react: 19.2.4
optionalDependencies:
- '@types/react': 19.2.7
+ '@types/react': 19.2.14
- '@radix-ui/react-use-controllable-state@1.1.1(@types/react@19.2.7)(react@19.2.1)':
+ '@radix-ui/react-use-controllable-state@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- react: 19.2.1
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
- '@types/react': 19.2.7
+ '@types/react': 19.2.14
- '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.2.1)':
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.1
+ react: 19.2.4
optionalDependencies:
- '@types/react': 19.2.7
+ '@types/react': 19.2.14
- '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.7)(react@19.2.1)':
+ '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- react: 19.2.1
+ react: 19.2.4
optionalDependencies:
- '@types/react': 19.2.7
+ '@types/react': 19.2.14
- '@radix-ui/react-use-size@1.1.1(@types/react@19.2.7)(react@19.2.1)':
+ '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1)
- react: 19.2.1
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.4
optionalDependencies:
- '@types/react': 19.2.7
+ '@types/react': 19.2.14
- '@rollup/rollup-linux-x64-gnu@4.53.3':
+ '@rollup/rollup-linux-x64-gnu@4.57.1':
optional: true
- '@rsbuild/core@1.6.12':
+ '@rsbuild/core@1.6.0-beta.1':
dependencies:
- '@rspack/core': 1.6.6(@swc/helpers@0.5.17)
+ '@rspack/core': 1.6.0-beta.1(@swc/helpers@0.5.18)
+ '@rspack/lite-tapable': 1.0.1
+ '@swc/helpers': 0.5.18
+ core-js: 3.46.0
+ jiti: 2.6.1
+
+ '@rsbuild/core@1.7.3':
+ dependencies:
+ '@rspack/core': 1.7.6(@swc/helpers@0.5.18)
'@rspack/lite-tapable': 1.1.0
- '@swc/helpers': 0.5.17
+ '@swc/helpers': 0.5.18
core-js: 3.47.0
jiti: 2.6.1
+ optional: true
- '@rsbuild/plugin-check-syntax@1.5.0(@rsbuild/core@1.6.12)':
+ '@rsbuild/plugin-check-syntax@1.6.1(@rsbuild/core@1.6.0-beta.1)':
dependencies:
acorn: 8.15.0
- browserslist-to-es-version: 1.2.0
+ browserslist-to-es-version: 1.4.1
htmlparser2: 10.0.0
picocolors: 1.1.1
source-map: 0.7.6
optionalDependencies:
- '@rsbuild/core': 1.6.12
+ '@rsbuild/core': 1.6.0-beta.1
- '@rsdoctor/client@1.3.12': {}
+ '@rsbuild/plugin-check-syntax@1.6.1(@rsbuild/core@1.7.3)':
+ dependencies:
+ acorn: 8.15.0
+ browserslist-to-es-version: 1.4.1
+ htmlparser2: 10.0.0
+ picocolors: 1.1.1
+ source-map: 0.7.6
+ optionalDependencies:
+ '@rsbuild/core': 1.7.3
- '@rsdoctor/core@1.3.12(@rsbuild/core@1.6.12)(@rspack/core@1.6.6(@swc/helpers@0.5.17))':
+ '@rsdoctor/client@1.5.2': {}
+
+ '@rsdoctor/core@1.5.2(@rsbuild/core@1.6.0-beta.1)(@rspack/core@1.7.6(@swc/helpers@0.5.18))':
dependencies:
- '@rsbuild/plugin-check-syntax': 1.5.0(@rsbuild/core@1.6.12)
- '@rsdoctor/graph': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
- '@rsdoctor/sdk': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
- '@rsdoctor/types': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
- '@rsdoctor/utils': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
+ '@rsbuild/plugin-check-syntax': 1.6.1(@rsbuild/core@1.6.0-beta.1)
+ '@rsdoctor/graph': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/sdk': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/types': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/utils': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
browserslist-load-config: 1.0.1
enhanced-resolve: 5.12.0
- es-toolkit: 1.42.0
+ es-toolkit: 1.44.0
filesize: 10.1.6
- fs-extra: 11.3.2
- semver: 7.7.3
+ fs-extra: 11.3.3
+ semver: 7.7.4
source-map: 0.7.6
transitivePeerDependencies:
- '@rsbuild/core'
@@ -4744,26 +4957,64 @@ snapshots:
- utf-8-validate
- webpack
- '@rsdoctor/graph@1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))':
+ '@rsdoctor/core@1.5.2(@rsbuild/core@1.7.3)(@rspack/core@1.7.6(@swc/helpers@0.5.18))':
dependencies:
- '@rsdoctor/types': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
- '@rsdoctor/utils': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
- es-toolkit: 1.42.0
+ '@rsbuild/plugin-check-syntax': 1.6.1(@rsbuild/core@1.7.3)
+ '@rsdoctor/graph': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/sdk': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/types': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/utils': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ browserslist-load-config: 1.0.1
+ enhanced-resolve: 5.12.0
+ es-toolkit: 1.44.0
+ filesize: 10.1.6
+ fs-extra: 11.3.3
+ semver: 7.7.4
+ source-map: 0.7.6
+ transitivePeerDependencies:
+ - '@rsbuild/core'
+ - '@rspack/core'
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ - webpack
+
+ '@rsdoctor/graph@1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))':
+ dependencies:
+ '@rsdoctor/types': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/utils': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ es-toolkit: 1.44.0
path-browserify: 1.0.1
source-map: 0.7.6
transitivePeerDependencies:
- '@rspack/core'
- webpack
- '@rsdoctor/rspack-plugin@1.3.12(@rsbuild/core@1.6.12)(@rspack/core@1.6.6(@swc/helpers@0.5.17))':
+ '@rsdoctor/rspack-plugin@1.5.2(@rsbuild/core@1.6.0-beta.1)(@rspack/core@1.7.6(@swc/helpers@0.5.18))':
+ dependencies:
+ '@rsdoctor/core': 1.5.2(@rsbuild/core@1.6.0-beta.1)(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/graph': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/sdk': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/types': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/utils': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ optionalDependencies:
+ '@rspack/core': 1.7.6(@swc/helpers@0.5.18)
+ transitivePeerDependencies:
+ - '@rsbuild/core'
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ - webpack
+
+ '@rsdoctor/rspack-plugin@1.5.2(@rsbuild/core@1.7.3)(@rspack/core@1.7.6(@swc/helpers@0.5.18))':
dependencies:
- '@rsdoctor/core': 1.3.12(@rsbuild/core@1.6.12)(@rspack/core@1.6.6(@swc/helpers@0.5.17))
- '@rsdoctor/graph': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
- '@rsdoctor/sdk': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
- '@rsdoctor/types': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
- '@rsdoctor/utils': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
+ '@rsdoctor/core': 1.5.2(@rsbuild/core@1.7.3)(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/graph': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/sdk': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/types': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/utils': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
optionalDependencies:
- '@rspack/core': 1.6.6(@swc/helpers@0.5.17)
+ '@rspack/core': 1.7.6(@swc/helpers@0.5.18)
transitivePeerDependencies:
- '@rsbuild/core'
- bufferutil
@@ -4771,12 +5022,12 @@ snapshots:
- utf-8-validate
- webpack
- '@rsdoctor/sdk@1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))':
+ '@rsdoctor/sdk@1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))':
dependencies:
- '@rsdoctor/client': 1.3.12
- '@rsdoctor/graph': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
- '@rsdoctor/types': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
- '@rsdoctor/utils': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
+ '@rsdoctor/client': 1.5.2
+ '@rsdoctor/graph': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/types': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
+ '@rsdoctor/utils': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
safer-buffer: 2.1.2
socket.io: 4.8.1
tapable: 2.2.3
@@ -4787,26 +5038,26 @@ snapshots:
- utf-8-validate
- webpack
- '@rsdoctor/types@1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))':
+ '@rsdoctor/types@1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))':
dependencies:
'@types/connect': 3.4.38
'@types/estree': 1.0.5
'@types/tapable': 2.2.7
source-map: 0.7.6
optionalDependencies:
- '@rspack/core': 1.6.6(@swc/helpers@0.5.17)
+ '@rspack/core': 1.7.6(@swc/helpers@0.5.18)
- '@rsdoctor/utils@1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))':
+ '@rsdoctor/utils@1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))':
dependencies:
'@babel/code-frame': 7.26.2
- '@rsdoctor/types': 1.3.12(@rspack/core@1.6.6(@swc/helpers@0.5.17))
+ '@rsdoctor/types': 1.5.2(@rspack/core@1.7.6(@swc/helpers@0.5.18))
'@types/estree': 1.0.5
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
acorn-walk: 8.3.4
deep-eql: 4.1.4
- envinfo: 7.19.0
- fs-extra: 11.3.2
+ envinfo: 7.21.0
+ fs-extra: 11.3.3
get-port: 5.1.1
json-stream-stringify: 3.0.1
lines-and-columns: 2.0.4
@@ -4817,85 +5068,143 @@ snapshots:
- '@rspack/core'
- webpack
- '@rslib/core@0.18.3(typescript@5.9.3)':
+ '@rslib/core@0.16.1(typescript@5.9.3)':
dependencies:
- '@rsbuild/core': 1.6.12
- rsbuild-plugin-dts: 0.18.3(@rsbuild/core@1.6.12)(typescript@5.9.3)
+ '@rsbuild/core': 1.6.0-beta.1
+ rsbuild-plugin-dts: 0.16.1(@rsbuild/core@1.6.0-beta.1)(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@typescript/native-preview'
- '@rspack/binding-darwin-arm64@1.6.6':
+ '@rspack/binding-darwin-arm64@1.6.0-beta.1':
+ optional: true
+
+ '@rspack/binding-darwin-arm64@1.7.6':
+ optional: true
+
+ '@rspack/binding-darwin-x64@1.6.0-beta.1':
+ optional: true
+
+ '@rspack/binding-darwin-x64@1.7.6':
+ optional: true
+
+ '@rspack/binding-linux-arm64-gnu@1.6.0-beta.1':
+ optional: true
+
+ '@rspack/binding-linux-arm64-gnu@1.7.6':
+ optional: true
+
+ '@rspack/binding-linux-arm64-musl@1.6.0-beta.1':
+ optional: true
+
+ '@rspack/binding-linux-arm64-musl@1.7.6':
optional: true
- '@rspack/binding-darwin-x64@1.6.6':
+ '@rspack/binding-linux-x64-gnu@1.6.0-beta.1':
optional: true
- '@rspack/binding-linux-arm64-gnu@1.6.6':
+ '@rspack/binding-linux-x64-gnu@1.7.6':
optional: true
- '@rspack/binding-linux-arm64-musl@1.6.6':
+ '@rspack/binding-linux-x64-musl@1.6.0-beta.1':
optional: true
- '@rspack/binding-linux-x64-gnu@1.6.6':
+ '@rspack/binding-linux-x64-musl@1.7.6':
optional: true
- '@rspack/binding-linux-x64-musl@1.6.6':
+ '@rspack/binding-wasm32-wasi@1.6.0-beta.1':
+ dependencies:
+ '@napi-rs/wasm-runtime': 1.0.7
optional: true
- '@rspack/binding-wasm32-wasi@1.6.6':
+ '@rspack/binding-wasm32-wasi@1.7.6':
dependencies:
'@napi-rs/wasm-runtime': 1.0.7
optional: true
- '@rspack/binding-win32-arm64-msvc@1.6.6':
+ '@rspack/binding-win32-arm64-msvc@1.6.0-beta.1':
+ optional: true
+
+ '@rspack/binding-win32-arm64-msvc@1.7.6':
+ optional: true
+
+ '@rspack/binding-win32-ia32-msvc@1.6.0-beta.1':
optional: true
- '@rspack/binding-win32-ia32-msvc@1.6.6':
+ '@rspack/binding-win32-ia32-msvc@1.7.6':
optional: true
- '@rspack/binding-win32-x64-msvc@1.6.6':
+ '@rspack/binding-win32-x64-msvc@1.6.0-beta.1':
optional: true
- '@rspack/binding@1.6.6':
+ '@rspack/binding-win32-x64-msvc@1.7.6':
+ optional: true
+
+ '@rspack/binding@1.6.0-beta.1':
+ optionalDependencies:
+ '@rspack/binding-darwin-arm64': 1.6.0-beta.1
+ '@rspack/binding-darwin-x64': 1.6.0-beta.1
+ '@rspack/binding-linux-arm64-gnu': 1.6.0-beta.1
+ '@rspack/binding-linux-arm64-musl': 1.6.0-beta.1
+ '@rspack/binding-linux-x64-gnu': 1.6.0-beta.1
+ '@rspack/binding-linux-x64-musl': 1.6.0-beta.1
+ '@rspack/binding-wasm32-wasi': 1.6.0-beta.1
+ '@rspack/binding-win32-arm64-msvc': 1.6.0-beta.1
+ '@rspack/binding-win32-ia32-msvc': 1.6.0-beta.1
+ '@rspack/binding-win32-x64-msvc': 1.6.0-beta.1
+
+ '@rspack/binding@1.7.6':
optionalDependencies:
- '@rspack/binding-darwin-arm64': 1.6.6
- '@rspack/binding-darwin-x64': 1.6.6
- '@rspack/binding-linux-arm64-gnu': 1.6.6
- '@rspack/binding-linux-arm64-musl': 1.6.6
- '@rspack/binding-linux-x64-gnu': 1.6.6
- '@rspack/binding-linux-x64-musl': 1.6.6
- '@rspack/binding-wasm32-wasi': 1.6.6
- '@rspack/binding-win32-arm64-msvc': 1.6.6
- '@rspack/binding-win32-ia32-msvc': 1.6.6
- '@rspack/binding-win32-x64-msvc': 1.6.6
-
- '@rspack/core@1.6.6(@swc/helpers@0.5.17)':
- dependencies:
- '@module-federation/runtime-tools': 0.21.6
- '@rspack/binding': 1.6.6
+ '@rspack/binding-darwin-arm64': 1.7.6
+ '@rspack/binding-darwin-x64': 1.7.6
+ '@rspack/binding-linux-arm64-gnu': 1.7.6
+ '@rspack/binding-linux-arm64-musl': 1.7.6
+ '@rspack/binding-linux-x64-gnu': 1.7.6
+ '@rspack/binding-linux-x64-musl': 1.7.6
+ '@rspack/binding-wasm32-wasi': 1.7.6
+ '@rspack/binding-win32-arm64-msvc': 1.7.6
+ '@rspack/binding-win32-ia32-msvc': 1.7.6
+ '@rspack/binding-win32-x64-msvc': 1.7.6
+ optional: true
+
+ '@rspack/core@1.6.0-beta.1(@swc/helpers@0.5.18)':
+ dependencies:
+ '@module-federation/runtime-tools': 0.21.1
+ '@rspack/binding': 1.6.0-beta.1
+ '@rspack/lite-tapable': 1.0.1
+ optionalDependencies:
+ '@swc/helpers': 0.5.18
+
+ '@rspack/core@1.7.6(@swc/helpers@0.5.18)':
+ dependencies:
+ '@module-federation/runtime-tools': 0.22.0
+ '@rspack/binding': 1.7.6
'@rspack/lite-tapable': 1.1.0
optionalDependencies:
- '@swc/helpers': 0.5.17
+ '@swc/helpers': 0.5.18
+ optional: true
+
+ '@rspack/lite-tapable@1.0.1': {}
- '@rspack/lite-tapable@1.1.0': {}
+ '@rspack/lite-tapable@1.1.0':
+ optional: true
'@socket.io/component-emitter@3.1.2': {}
'@sqltools/formatter@1.2.5': {}
- '@standard-schema/spec@1.0.0': {}
+ '@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
- '@swc/helpers@0.5.17':
+ '@swc/helpers@0.5.18':
dependencies:
tslib: 2.8.1
- '@trpc/server@11.7.2(typescript@5.9.3)':
+ '@trpc/server@11.10.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
@@ -4906,28 +5215,30 @@ snapshots:
'@types/better-sqlite3@7.6.13':
dependencies:
- '@types/node': 24.10.1
+ '@types/node': 25.2.3
optional: true
'@types/connect@3.4.38':
dependencies:
- '@types/node': 24.10.1
+ '@types/node': 25.2.3
'@types/cors@2.8.19':
dependencies:
- '@types/node': 24.10.1
+ '@types/node': 25.2.3
'@types/estree@1.0.5': {}
- '@types/node@24.10.1':
+ '@types/figlet@1.7.0': {}
+
+ '@types/node@25.2.3':
dependencies:
undici-types: 7.16.0
- '@types/react-dom@19.2.3(@types/react@19.2.7)':
+ '@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
- '@types/react': 19.2.7
+ '@types/react': 19.2.14
- '@types/react@19.2.7':
+ '@types/react@19.2.14':
dependencies:
csstype: 3.2.3
@@ -4937,7 +5248,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
- '@types/node': 24.10.1
+ '@types/node': 25.2.3
optional: true
accepts@1.3.8:
@@ -4965,7 +5276,7 @@ snapshots:
ansi-styles@6.2.3: {}
- ansis@4.2.0: {}
+ ansis@3.17.0: {}
app-root-path@3.1.0: {}
@@ -4981,9 +5292,9 @@ snapshots:
base64id@2.0.0: {}
- baseline-browser-mapping@2.9.2: {}
+ baseline-browser-mapping@2.9.19: {}
- better-sqlite3@12.5.0:
+ better-sqlite3@12.4.1:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
@@ -5007,17 +5318,17 @@ snapshots:
browserslist-load-config@1.0.1: {}
- browserslist-to-es-version@1.2.0:
+ browserslist-to-es-version@1.4.1:
dependencies:
browserslist: 4.28.1
browserslist@4.28.1:
dependencies:
- baseline-browser-mapping: 2.9.2
- caniuse-lite: 1.0.30001759
- electron-to-chromium: 1.5.266
+ baseline-browser-mapping: 2.9.19
+ caniuse-lite: 1.0.30001770
+ electron-to-chromium: 1.5.286
node-releases: 2.0.27
- update-browserslist-db: 1.2.2(browserslist@4.28.1)
+ update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-from@1.1.2: {}
@@ -5032,13 +5343,61 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
- c15t@1.8.1(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react@19.2.7)(better-sqlite3@12.5.0)(react@19.2.1)(typeorm@0.3.28(better-sqlite3@12.5.0))(use-sync-external-store@1.6.0(react@19.2.1))(ws@8.18.3):
+ c15t@1.7.1(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react@19.2.14)(better-sqlite3@12.4.1)(react@19.2.4)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.18.3):
dependencies:
- '@c15t/backend': 1.8.0(@libsql/client@0.15.15)(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(typeorm@0.3.28(better-sqlite3@12.5.0))(ws@8.18.3)
+ '@c15t/backend': 1.7.1(@libsql/client@0.15.15)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(ws@8.18.3)
+ '@c15t/translations': 1.7.0
+ '@orpc/client': 1.8.1(@opentelemetry/api@1.9.0)
+ '@orpc/server': 1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3)
+ zustand: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ transitivePeerDependencies:
+ - '@aws-sdk/client-rds-data'
+ - '@cloudflare/workers-types'
+ - '@electric-sql/pglite'
+ - '@libsql/client'
+ - '@libsql/client-wasm'
+ - '@neondatabase/serverless'
+ - '@op-engineering/op-sqlite'
+ - '@opentelemetry/api'
+ - '@opentelemetry/instrumentation'
+ - '@planetscale/database'
+ - '@prisma/client'
+ - '@tidbcloud/serverless'
+ - '@types/better-sqlite3'
+ - '@types/pg'
+ - '@types/react'
+ - '@types/sql.js'
+ - '@upstash/redis'
+ - '@vercel/postgres'
+ - '@xata.io/client'
+ - better-sqlite3
+ - bun-types
+ - convex
+ - crossws
+ - expo-sqlite
+ - gel
+ - immer
+ - knex
+ - mongodb
+ - mysql2
+ - pg
+ - postgres
+ - prisma
+ - react
+ - sql.js
+ - sqlite3
+ - supports-color
+ - typeorm
+ - use-sync-external-store
+ - ws
+
+ c15t@1.8.3(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(@types/react@19.2.14)(better-sqlite3@12.4.1)(react@19.2.4)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.18.3):
+ dependencies:
+ '@c15t/backend': 1.8.0(@libsql/client@0.15.15)(@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(ws@8.18.3)
'@c15t/translations': 1.8.0
'@orpc/client': 1.8.1(@opentelemetry/api@1.9.0)
'@orpc/server': 1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3)
- zustand: 5.0.9(@types/react@19.2.7)(react@19.2.1)(use-sync-external-store@1.6.0(react@19.2.1))
+ zustand: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
transitivePeerDependencies:
- '@aws-sdk/client-rds-data'
- '@cloudflare/workers-types'
@@ -5097,19 +5456,19 @@ snapshots:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
- caniuse-lite@1.0.30001759: {}
+ caniuse-lite@1.0.30001770: {}
chalk@5.6.2: {}
chownr@1.1.4:
optional: true
- citty@0.1.6:
- dependencies:
- consola: 3.4.2
+ citty@0.2.1: {}
cjs-module-lexer@1.4.3: {}
+ cjs-module-lexer@2.2.0: {}
+
cli-table3@0.6.5:
dependencies:
string-width: 4.2.3
@@ -5132,11 +5491,7 @@ snapshots:
color-name@1.1.4: {}
- commander@14.0.2: {}
-
- confbox@0.2.2: {}
-
- consola@3.4.2: {}
+ commander@14.0.3: {}
cookie@0.7.2: {}
@@ -5146,9 +5501,12 @@ snapshots:
dependencies:
is-what: 5.5.0
- core-js@3.47.0: {}
+ core-js@3.46.0: {}
- cors@2.8.5:
+ core-js@3.47.0:
+ optional: true
+
+ cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
@@ -5179,7 +5537,7 @@ snapshots:
mimic-response: 3.1.0
optional: true
- dedent@1.7.0: {}
+ dedent@1.7.1: {}
deep-eql@4.1.4:
dependencies:
@@ -5224,9 +5582,9 @@ snapshots:
dotenv@16.6.1: {}
- dotenv@17.2.3: {}
+ dotenv@17.3.1: {}
- drizzle-kit@0.31.8:
+ drizzle-kit@0.31.9:
dependencies:
'@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5
@@ -5235,12 +5593,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- drizzle-orm@0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.27.6):
+ drizzle-orm@0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(kysely@0.27.6):
optionalDependencies:
'@libsql/client': 0.15.15
'@opentelemetry/api': 1.9.0
'@types/better-sqlite3': 7.6.13
- better-sqlite3: 12.5.0
+ better-sqlite3: 12.4.1
kysely: 0.27.6
dunder-proto@1.0.1:
@@ -5251,7 +5609,7 @@ snapshots:
eastasianwidth@0.2.0: {}
- electron-to-chromium@1.5.266: {}
+ electron-to-chromium@1.5.286: {}
emoji-regex@8.0.0: {}
@@ -5264,17 +5622,17 @@ snapshots:
engine.io-parser@5.2.3: {}
- engine.io@6.6.4:
+ engine.io@6.6.5:
dependencies:
'@types/cors': 2.8.19
- '@types/node': 24.10.1
+ '@types/node': 25.2.3
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
- cors: 2.8.5
- debug: 4.3.7
+ cors: 2.8.6
+ debug: 4.4.3
engine.io-parser: 5.2.3
- ws: 8.17.1
+ ws: 8.18.3
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -5289,7 +5647,7 @@ snapshots:
entities@6.0.1: {}
- envinfo@7.19.0: {}
+ envinfo@7.21.0: {}
es-define-property@1.0.1: {}
@@ -5299,7 +5657,7 @@ snapshots:
dependencies:
es-errors: 1.3.0
- es-toolkit@1.42.0: {}
+ es-toolkit@1.44.0: {}
esbuild-register@3.6.0(esbuild@0.25.12):
dependencies:
@@ -5369,14 +5727,16 @@ snapshots:
expand-template@2.0.3:
optional: true
- exsolve@1.0.8: {}
-
fetch-blob@3.2.0:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
optional: true
+ figlet@1.10.0:
+ dependencies:
+ commander: 14.0.3
+
file-uri-to-path@1.0.0:
optional: true
@@ -5399,7 +5759,7 @@ snapshots:
fs-constants@1.0.0:
optional: true
- fs-extra@11.3.2:
+ fs-extra@11.3.3:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.0
@@ -5408,18 +5768,18 @@ snapshots:
fsevents@2.3.2:
optional: true
- fumadb@0.1.2(drizzle-orm@0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.27.6))(typeorm@0.3.28(better-sqlite3@12.5.0)):
+ fumadb@0.1.2(drizzle-orm@0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(kysely@0.27.6))(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2)):
dependencies:
'@clack/prompts': 0.11.0
'@paralleldrive/cuid2': 2.3.1
- commander: 14.0.2
- kysely: 0.28.8
- kysely-typeorm: 0.3.0(kysely@0.28.8)(typeorm@0.3.28(better-sqlite3@12.5.0))
- semver: 7.7.3
- zod: 4.1.13
+ commander: 14.0.3
+ kysely: 0.28.11
+ kysely-typeorm: 0.3.0(kysely@0.28.11)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))
+ semver: 7.7.4
+ zod: 4.3.6
optionalDependencies:
- drizzle-orm: 0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.5.0)(kysely@0.27.6)
- typeorm: 0.3.28(better-sqlite3@12.5.0)
+ drizzle-orm: 0.44.7(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(kysely@0.27.6)
+ typeorm: 0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2)
function-bind@1.1.2: {}
@@ -5445,7 +5805,7 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
- get-tsconfig@4.13.0:
+ get-tsconfig@4.13.6:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -5461,12 +5821,6 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
- glob@13.0.0:
- dependencies:
- minimatch: 10.1.1
- minipass: 7.1.2
- path-scurry: 2.0.1
-
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
@@ -5501,11 +5855,11 @@ snapshots:
cjs-module-lexer: 1.4.3
module-details-from-path: 1.0.4
- import-in-the-middle@2.0.0:
+ import-in-the-middle@2.0.6:
dependencies:
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
- cjs-module-lexer: 1.4.3
+ cjs-module-lexer: 2.2.0
module-details-from-path: 1.0.4
inherits@2.0.4: {}
@@ -5523,7 +5877,7 @@ snapshots:
is-typed-array@1.1.15:
dependencies:
- which-typed-array: 1.1.19
+ which-typed-array: 1.1.20
is-what@5.5.0: {}
@@ -5554,14 +5908,14 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
- kysely-typeorm@0.3.0(kysely@0.28.8)(typeorm@0.3.28(better-sqlite3@12.5.0)):
+ kysely-typeorm@0.3.0(kysely@0.28.11)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2)):
dependencies:
- kysely: 0.28.8
- typeorm: 0.3.28(better-sqlite3@12.5.0)
+ kysely: 0.28.11
+ typeorm: 0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2)
kysely@0.27.6: {}
- kysely@0.28.8: {}
+ kysely@0.28.11: {}
libsql@0.5.22:
dependencies:
@@ -5591,8 +5945,6 @@ snapshots:
lru-cache@10.4.3: {}
- lru-cache@11.2.4: {}
-
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
@@ -5604,10 +5956,6 @@ snapshots:
mimic-response@3.1.0:
optional: true
- minimatch@10.1.1:
- dependencies:
- '@isaacs/brace-expansion': 5.0.0
-
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
@@ -5633,36 +5981,37 @@ snapshots:
neverthrow@8.2.0:
optionalDependencies:
- '@rollup/rollup-linux-x64-gnu': 4.53.3
+ '@rollup/rollup-linux-x64-gnu': 4.57.1
- next@16.0.7(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
+ next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
- '@next/env': 16.0.7
+ '@next/env': 16.1.6
'@swc/helpers': 0.5.15
- caniuse-lite: 1.0.30001759
+ baseline-browser-mapping: 2.9.19
+ caniuse-lite: 1.0.30001770
postcss: 8.4.31
- react: 19.2.1
- react-dom: 19.2.1(react@19.2.1)
- styled-jsx: 5.1.6(react@19.2.1)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ styled-jsx: 5.1.6(react@19.2.4)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.0.7
- '@next/swc-darwin-x64': 16.0.7
- '@next/swc-linux-arm64-gnu': 16.0.7
- '@next/swc-linux-arm64-musl': 16.0.7
- '@next/swc-linux-x64-gnu': 16.0.7
- '@next/swc-linux-x64-musl': 16.0.7
- '@next/swc-win32-arm64-msvc': 16.0.7
- '@next/swc-win32-x64-msvc': 16.0.7
+ '@next/swc-darwin-arm64': 16.1.6
+ '@next/swc-darwin-x64': 16.1.6
+ '@next/swc-linux-arm64-gnu': 16.1.6
+ '@next/swc-linux-arm64-musl': 16.1.6
+ '@next/swc-linux-x64-gnu': 16.1.6
+ '@next/swc-linux-x64-musl': 16.1.6
+ '@next/swc-win32-arm64-msvc': 16.1.6
+ '@next/swc-win32-x64-msvc': 16.1.6
'@opentelemetry/api': 1.9.0
- '@playwright/test': 1.57.0
+ '@playwright/test': 1.58.2
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
- node-abi@3.85.0:
+ node-abi@3.87.0:
dependencies:
- semver: 7.7.3
+ semver: 7.7.4
optional: true
node-domexception@1.0.0:
@@ -5677,12 +6026,10 @@ snapshots:
node-releases@2.0.27: {}
- nypm@0.6.2:
+ nypm@0.6.5:
dependencies:
- citty: 0.1.6
- consola: 3.4.2
+ citty: 0.2.1
pathe: 2.0.3
- pkg-types: 2.3.0
tinyexec: 1.0.2
object-assign@4.1.1: {}
@@ -5694,14 +6041,8 @@ snapshots:
openapi-types@12.1.3: {}
- p-limit@7.2.0:
- dependencies:
- yocto-queue: 1.2.2
-
package-json-from-dist@1.0.1: {}
- package-manager-detector@1.6.0: {}
-
parse-ms@4.0.0: {}
path-browserify@1.0.1: {}
@@ -5715,30 +6056,23 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
- path-scurry@2.0.1:
- dependencies:
- lru-cache: 11.2.4
- minipass: 7.1.2
-
pathe@2.0.3: {}
- picocolors@1.1.1: {}
-
- pkg-types@2.3.0:
+ perfume.js@9.4.0:
dependencies:
- confbox: 0.2.2
- exsolve: 1.0.8
- pathe: 2.0.3
+ web-vitals: 3.5.2
+
+ picocolors@1.1.1: {}
- playwright-core@1.57.0: {}
+ playwright-core@1.58.2: {}
- playwright-performance-metrics@1.2.4(@playwright/test@1.57.0):
+ playwright-performance-metrics@1.2.5(@playwright/test@1.58.2):
dependencies:
- '@playwright/test': 1.57.0
+ '@playwright/test': 1.58.2
- playwright@1.57.0:
+ playwright@1.58.2:
dependencies:
- playwright-core: 1.57.0
+ playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
@@ -5758,7 +6092,7 @@ snapshots:
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
- node-abi: 3.85.0
+ node-abi: 3.87.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
@@ -5791,7 +6125,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
- '@types/node': 24.10.1
+ '@types/node': 25.2.3
long: 5.3.2
pump@3.0.3:
@@ -5810,14 +6144,14 @@ snapshots:
strip-json-comments: 2.0.1
optional: true
- react-dom@19.2.1(react@19.2.1):
+ react-dom@19.2.4(react@19.2.4):
dependencies:
- react: 19.2.1
+ react: 19.2.4
scheduler: 0.27.0
react-is@16.13.1: {}
- react@19.2.1: {}
+ react@19.2.4: {}
readable-stream@3.6.2:
dependencies:
@@ -5853,12 +6187,12 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
- rou3@0.7.10: {}
+ rou3@0.7.12: {}
- rsbuild-plugin-dts@0.18.3(@rsbuild/core@1.6.12)(typescript@5.9.3):
+ rsbuild-plugin-dts@0.16.1(@rsbuild/core@1.6.0-beta.1)(typescript@5.9.3):
dependencies:
'@ast-grep/napi': 0.37.0
- '@rsbuild/core': 1.6.12
+ '@rsbuild/core': 1.6.0-beta.1
optionalDependencies:
typescript: 5.9.3
@@ -5870,7 +6204,7 @@ snapshots:
scheduler@0.27.0: {}
- semver@7.7.3: {}
+ semver@7.7.4: {}
set-function-length@1.2.2:
dependencies:
@@ -5891,7 +6225,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
@@ -5939,19 +6273,19 @@ snapshots:
sisteransi@1.0.5: {}
- socket.io-adapter@2.5.5:
+ socket.io-adapter@2.5.6:
dependencies:
- debug: 4.3.7
- ws: 8.17.1
+ debug: 4.4.3
+ ws: 8.18.3
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
- socket.io-parser@4.2.4:
+ socket.io-parser@4.2.5:
dependencies:
'@socket.io/component-emitter': 3.1.2
- debug: 4.3.7
+ debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -5959,11 +6293,11 @@ snapshots:
dependencies:
accepts: 1.3.8
base64id: 2.0.0
- cors: 2.8.5
+ cors: 2.8.6
debug: 4.3.7
- engine.io: 6.6.4
- socket.io-adapter: 2.5.5
- socket.io-parser: 4.2.4
+ engine.io: 6.6.5
+ socket.io-adapter: 2.5.6
+ socket.io-parser: 4.2.5
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -6010,10 +6344,10 @@ snapshots:
strip-json-comments@2.0.1:
optional: true
- styled-jsx@5.1.6(react@19.2.1):
+ styled-jsx@5.1.6(react@19.2.4):
dependencies:
client-only: 0.0.1
- react: 19.2.1
+ react: 19.2.4
superjson@2.2.6:
dependencies:
@@ -6052,13 +6386,13 @@ snapshots:
safe-buffer: 5.2.1
typed-array-buffer: 1.0.3
- trpc-cli@0.12.1(@orpc/server@1.12.2(@opentelemetry/api@1.9.0)(ws@8.18.3))(@trpc/server@11.7.2(typescript@5.9.3))(zod@4.1.13):
+ trpc-cli@0.12.2(@orpc/server@1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3))(@trpc/server@11.10.0(typescript@5.9.3))(zod@4.3.6):
dependencies:
- commander: 14.0.2
+ commander: 14.0.3
optionalDependencies:
- '@orpc/server': 1.12.2(@opentelemetry/api@1.9.0)(ws@8.18.3)
- '@trpc/server': 11.7.2(typescript@5.9.3)
- zod: 4.1.13
+ '@orpc/server': 1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3)
+ '@trpc/server': 11.10.0(typescript@5.9.3)
+ zod: 4.3.6
tslib@2.8.1: {}
@@ -6067,38 +6401,38 @@ snapshots:
safe-buffer: 5.2.1
optional: true
- turbo-darwin-64@2.6.3:
+ turbo-darwin-64@2.8.9:
optional: true
- turbo-darwin-arm64@2.6.3:
+ turbo-darwin-arm64@2.8.9:
optional: true
- turbo-linux-64@2.6.3:
+ turbo-linux-64@2.8.9:
optional: true
- turbo-linux-arm64@2.6.3:
+ turbo-linux-arm64@2.8.9:
optional: true
- turbo-windows-64@2.6.3:
+ turbo-windows-64@2.8.9:
optional: true
- turbo-windows-arm64@2.6.3:
+ turbo-windows-arm64@2.8.9:
optional: true
- turbo@2.6.3:
+ turbo@2.8.9:
optionalDependencies:
- turbo-darwin-64: 2.6.3
- turbo-darwin-arm64: 2.6.3
- turbo-linux-64: 2.6.3
- turbo-linux-arm64: 2.6.3
- turbo-windows-64: 2.6.3
- turbo-windows-arm64: 2.6.3
+ turbo-darwin-64: 2.8.9
+ turbo-darwin-arm64: 2.8.9
+ turbo-linux-64: 2.8.9
+ turbo-linux-arm64: 2.8.9
+ turbo-windows-64: 2.8.9
+ turbo-windows-arm64: 2.8.9
type-detect@4.1.0: {}
type-fest@4.41.0: {}
- type-fest@5.3.0:
+ type-fest@5.4.4:
dependencies:
tagged-tag: 1.0.0
@@ -6108,15 +6442,15 @@ snapshots:
es-errors: 1.3.0
is-typed-array: 1.1.15
- typeorm@0.3.28(better-sqlite3@12.5.0):
+ typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2):
dependencies:
'@sqltools/formatter': 1.2.5
- ansis: 4.2.0
+ ansis: 3.17.0
app-root-path: 3.1.0
buffer: 6.0.3
dayjs: 1.11.19
debug: 4.4.3
- dedent: 1.7.0
+ dedent: 1.7.1
dotenv: 16.6.1
glob: 10.5.0
reflect-metadata: 0.2.2
@@ -6126,23 +6460,22 @@ snapshots:
uuid: 11.1.0
yargs: 17.7.2
optionalDependencies:
- better-sqlite3: 12.5.0
+ better-sqlite3: 12.4.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
typescript@5.9.3: {}
- ultracite@6.3.9(@orpc/server@1.12.2(@opentelemetry/api@1.9.0)(ws@8.18.3))(typescript@5.9.3):
+ ultracite@6.0.5(@orpc/server@1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3))(typescript@5.9.3):
dependencies:
'@clack/prompts': 0.11.0
- '@trpc/server': 11.7.2(typescript@5.9.3)
+ '@trpc/server': 11.10.0(typescript@5.9.3)
deepmerge: 4.3.1
- glob: 13.0.0
jsonc-parser: 3.3.1
- nypm: 0.6.2
- trpc-cli: 0.12.1(@orpc/server@1.12.2(@opentelemetry/api@1.9.0)(ws@8.18.3))(@trpc/server@11.7.2(typescript@5.9.3))(zod@4.1.13)
- zod: 4.1.13
+ nypm: 0.6.5
+ trpc-cli: 0.12.2(@orpc/server@1.8.1(@opentelemetry/api@1.9.0)(ws@8.18.3))(@trpc/server@11.10.0(typescript@5.9.3))(zod@4.3.6)
+ zod: 4.3.6
transitivePeerDependencies:
- '@orpc/server'
- '@valibot/to-json-schema'
@@ -6154,15 +6487,15 @@ snapshots:
universalify@2.0.1: {}
- update-browserslist-db@1.2.2(browserslist@4.28.1):
+ update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
browserslist: 4.28.1
escalade: 3.2.0
picocolors: 1.1.1
- use-sync-external-store@1.6.0(react@19.2.1):
+ use-sync-external-store@1.6.0(react@19.2.4):
dependencies:
- react: 19.2.1
+ react: 19.2.4
optional: true
util-deprecate@1.0.2:
@@ -6175,7 +6508,9 @@ snapshots:
web-streams-polyfill@3.3.3:
optional: true
- which-typed-array@1.1.19:
+ web-vitals@3.5.2: {}
+
+ which-typed-array@1.1.20:
dependencies:
available-typed-arrays: 1.0.7
call-bind: 1.0.8
@@ -6206,10 +6541,7 @@ snapshots:
wrappy@1.0.2:
optional: true
- ws@8.17.1: {}
-
- ws@8.18.3:
- optional: true
+ ws@8.18.3: {}
y18n@5.0.8: {}
@@ -6225,12 +6557,10 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
- yocto-queue@1.2.2: {}
-
- zod@4.1.13: {}
+ zod@4.3.6: {}
- zustand@5.0.9(@types/react@19.2.7)(react@19.2.1)(use-sync-external-store@1.6.0(react@19.2.1)):
+ zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
optionalDependencies:
- '@types/react': 19.2.7
- react: 19.2.1
- use-sync-external-store: 1.6.0(react@19.2.1)
+ '@types/react': 19.2.14
+ react: 19.2.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index b0e015b..4042287 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,4 +1,13 @@
packages:
- "benchmarks/*"
- "packages/*"
- - "www"
\ No newline at end of file
+ - "www"
+
+catalog:
+ "@types/node": "25.2.3"
+ "@types/react-dom": "19.2.3"
+ "@types/react": "19.2.14"
+ next: "16.1.6"
+ react-dom: "19.2.4"
+ react: "19.2.4"
+ typescript: "5.9.3"
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index cdd162f..e0b296f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
- "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "lib": [
+ "DOM",
+ "DOM.Iterable",
+ "ESNext"
+ ],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
@@ -15,6 +19,11 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
- "include": ["packages/**/*.ts", "packages/**/*.tsx"],
- "exclude": ["node_modules"]
-}
\ No newline at end of file
+ "include": [
+ "packages/**/*.ts",
+ "packages/**/*.tsx"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
\ No newline at end of file
diff --git a/turbo.json b/turbo.json
index b3c5fe4..596540f 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,39 +1,28 @@
{
- "$schema": "https://turborepo.com/schema.json",
- "ui": "tui",
- "globalEnv": [
- "TURSO_DATABASE_URL",
- "TURSO_DATABASE_AUTH_TOKEN"
- ],
- "tasks": {
- "build": {
- "dependsOn": ["^build"],
- "inputs": ["$TURBO_DEFAULT$", ".env*"],
- "outputs": [".next/**", "!.next/cache/**", "dist/**"]
- },
- "@cookiebench/cli#build": {
- "inputs": ["src/**", "package.json", "rslib.config.ts", "tsconfig.json"],
- "outputs": ["dist/**"]
- },
- "@cookiebench/www#build": {
- "inputs": ["$TURBO_DEFAULT$", ".env*"],
- "outputs": [".next/**", "!.next/cache/**"]
- },
- "lint": {
- "dependsOn": ["^lint"]
- },
- "check-types": {
- "dependsOn": ["^check-types"]
- },
- "fmt": {
- "dependsOn": ["^fmt"]
- },
- "benchmark": {
- "dependsOn": ["^build", "^benchmark"]
- },
- "dev": {
- "cache": false,
- "persistent": true
- }
- }
+ "$schema": "https://turborepo.com/schema.json",
+ "ui": "tui",
+ "globalEnv": ["TURSO_DATABASE_URL", "TURSO_DATABASE_AUTH_TOKEN"],
+ "tasks": {
+ "build": {
+ "dependsOn": ["^build"],
+ "inputs": ["$TURBO_DEFAULT$", ".env*"],
+ "outputs": [".next/**", "!.next/cache/**", "dist/**"]
+ },
+ "lint": {
+ "dependsOn": ["^lint"]
+ },
+ "check-types": {
+ "dependsOn": ["^check-types"]
+ },
+ "fmt": {
+ "dependsOn": ["^fmt"]
+ },
+ "benchmark": {
+ "dependsOn": ["^build", "^benchmark"]
+ },
+ "dev": {
+ "cache": false,
+ "persistent": true
+ }
+ }
}
|