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..c67f0c8
--- /dev/null
+++ b/METHODOLOGY.md
@@ -0,0 +1,289 @@
+# 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
+
+### Banner Render Time vs Visibility Time
+
+We track two distinct metrics for banner appearance:
+
+**Banner Render Time** (Technical Metric):
+
+- Measures when the banner element first appears in the DOM
+- Recorded when the element has dimensions (`width > 0`, `height > 0`) and is not hidden
+- Represents technical implementation performance
+- This metric is tracked for reference but not used in primary scoring
+
+**Banner Visibility Time** (User-Perceived Metric):
+
+- Measures when the banner becomes visible to users
+- Uses opacity threshold of **0.5** to account for CSS animations and transitions
+- Represents actual user experience - when users can see and interact with the banner
+- This metric is used for scoring and primary comparisons
+
+**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)
+
+**Banner Visibility Time**:
+
+- Time from `navigationStart` until banner opacity > 0.5
+- Primary metric for UX scoring
+- Accounts for CSS animations
+- Measured in milliseconds
+
+**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**: 2025-10-31
+**Version**: 2.0
diff --git a/README.md b/README.md
index a4e888f..f203c51 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
+- **Banner Render Time**: Technical render time (when element appears in DOM)
+- **Banner Visibility Time**: User-perceived visibility time (when opacity > 0.5, accounts for CSS animations)
+- **Banner Interactive Time**: Time until banner buttons become clickable
+
+See [METHODOLOGY.md](./METHODOLOGY.md) for detailed explanation of render time vs visibility time.
### 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, iife, cjs)
- `typescript`: Whether TypeScript is used
- **source**: Project source information
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 6fac9a7..5eeebf8 100644
--- a/benchmarks/baseline/package.json
+++ b/benchmarks/baseline/package.json
@@ -3,8 +3,7 @@
"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 .",
@@ -17,11 +16,13 @@
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
+ },
+ "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 97%
rename from benchmarks/with-c15t-nextjs/config.json
rename to benchmarks/c15t-nextjs/config.json
index 9aabb6b..2735964 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": [
diff --git a/benchmarks/with-c15t-react/next-env.d.ts b/benchmarks/c15t-nextjs/next-env.d.ts
similarity index 85%
rename from benchmarks/with-c15t-react/next-env.d.ts
rename to benchmarks/c15t-nextjs/next-env.d.ts
index 1b3be08..9edff1c 100644
--- a/benchmarks/with-c15t-react/next-env.d.ts
+++ b/benchmarks/c15t-nextjs/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-nextjs/next.config.ts
similarity index 100%
rename from benchmarks/with-c15t-nextjs/next.config.ts
rename to benchmarks/c15t-nextjs/next.config.ts
diff --git a/benchmarks/with-c15t-nextjs/package.json b/benchmarks/c15t-nextjs/package.json
similarity index 82%
rename from benchmarks/with-c15t-nextjs/package.json
rename to benchmarks/c15t-nextjs/package.json
index 3eb8116..95baf82 100644
--- a/benchmarks/with-c15t-nextjs/package.json
+++ b/benchmarks/c15t-nextjs/package.json
@@ -1,9 +1,8 @@
{
- "name": "with-c15t-nextjs",
+ "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",
@@ -18,11 +17,13 @@
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
+ },
+ "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 97%
rename from benchmarks/with-c15t-react/config.json
rename to benchmarks/c15t-react/config.json
index b174fc7..bf1b98c 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": [
diff --git a/benchmarks/with-c15t-nextjs/next-env.d.ts b/benchmarks/c15t-react/next-env.d.ts
similarity index 85%
rename from benchmarks/with-c15t-nextjs/next-env.d.ts
rename to benchmarks/c15t-react/next-env.d.ts
index 1b3be08..9edff1c 100644
--- a/benchmarks/with-c15t-nextjs/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-react/next.config.ts b/benchmarks/c15t-react/next.config.ts
similarity index 100%
rename from benchmarks/with-c15t-react/next.config.ts
rename to benchmarks/c15t-react/next.config.ts
diff --git a/benchmarks/with-c15t-react/package.json b/benchmarks/c15t-react/package.json
similarity index 78%
rename from benchmarks/with-c15t-react/package.json
rename to benchmarks/c15t-react/package.json
index af4354a..079c95b 100644
--- a/benchmarks/with-c15t-react/package.json
+++ b/benchmarks/c15t-react/package.json
@@ -1,8 +1,7 @@
{
- "name": "with-c15t-react",
+ "name": "c15t-react",
"private": true,
"scripts": {
- "benchmark": "pnpm exec benchmark-cli benchmark",
"build": "next build",
"dev": "next dev --port 3003",
"fmt": "biome format . --write",
@@ -11,18 +10,19 @@
},
"dependencies": {
"@c15t/react": "1.7.1",
- "@c15t/translations": "^1.7.0",
"next": "16.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
+ },
+ "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..2f13868
--- /dev/null
+++ b/benchmarks/cookie-control/config.json
@@ -0,0 +1,40 @@
+{
+ "$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"]
+ }
+}
diff --git a/benchmarks/with-cookie-control/next-env.d.ts b/benchmarks/cookie-control/next-env.d.ts
similarity index 85%
rename from benchmarks/with-cookie-control/next-env.d.ts
rename to benchmarks/cookie-control/next-env.d.ts
index 1b3be08..9edff1c 100644
--- a/benchmarks/with-cookie-control/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-cookie-yes/next.config.ts b/benchmarks/cookie-control/next.config.ts
similarity index 100%
rename from benchmarks/with-cookie-yes/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 82%
rename from benchmarks/with-enzuzo/package.json
rename to benchmarks/cookie-control/package.json
index 4bc7c5b..ff58f10 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",
@@ -16,11 +15,13 @@
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
+ },
+ "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 90%
rename from benchmarks/with-cookie-yes/config.json
rename to benchmarks/cookie-yes/config.json
index b084c19..a07b091 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",
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/with-didomi/next.config.ts b/benchmarks/cookie-yes/next.config.ts
similarity index 100%
rename from benchmarks/with-didomi/next.config.ts
rename to benchmarks/cookie-yes/next.config.ts
diff --git a/benchmarks/with-iubenda/package.json b/benchmarks/cookie-yes/package.json
similarity index 81%
rename from benchmarks/with-iubenda/package.json
rename to benchmarks/cookie-yes/package.json
index e440f8b..5b69982 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",
@@ -16,11 +15,13 @@
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
+ },
+ "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/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/with-didomi/app/layout.tsx b/benchmarks/didomi/app/layout.tsx
similarity index 93%
rename from benchmarks/with-didomi/app/layout.tsx
rename to benchmarks/didomi/app/layout.tsx
index 05047ed..dfd9a91 100644
--- a/benchmarks/with-didomi/app/layout.tsx
+++ b/benchmarks/didomi/app/layout.tsx
@@ -1,3 +1,4 @@
+/** biome-ignore-all lint/suspicious/noConsole: its okay to show it working */
"use client";
import { DidomiSDK, type IDidomiObject } from "@didomi/react";
@@ -28,7 +29,9 @@ export default function RootLayout({
const onConsentChanged = useCallback(
(cwtToken: string) => {
- if (!didomiObject) return;
+ if (!didomiObject) {
+ return;
+ }
console.log("Didomi Consent Changed - cwtToken:", cwtToken);
console.log(
"Didomi Consent Changed - Is consent required?:",
@@ -51,13 +54,13 @@ export default function RootLayout({
console.log("Didomi Notice Shown")}
onNoticeHidden={() => console.log("Didomi Notice Hidden")}
+ onNoticeShown={() => console.log("Didomi Notice Shown")}
+ onReady={onDidomiReady}
/>
{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 97%
rename from benchmarks/with-didomi/config.json
rename to benchmarks/didomi/config.json
index c007ba3..185d5f5 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": [
diff --git a/benchmarks/didomi/next-env.d.ts b/benchmarks/didomi/next-env.d.ts
new file mode 100644
index 0000000..9edff1c
--- /dev/null
+++ b/benchmarks/didomi/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/didomi/next.config.ts
similarity index 100%
rename from benchmarks/with-iubenda/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 87%
rename from benchmarks/with-didomi/package.json
rename to benchmarks/didomi/package.json
index eef43df..1ab9573 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",
@@ -22,5 +21,8 @@
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
+ },
+ "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/with-enzuzo/app/layout.tsx b/benchmarks/enzuzo/app/layout.tsx
similarity index 87%
rename from benchmarks/with-enzuzo/app/layout.tsx
rename to benchmarks/enzuzo/app/layout.tsx
index 84fc0f6..92e9a2e 100644
--- a/benchmarks/with-enzuzo/app/layout.tsx
+++ b/benchmarks/enzuzo/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({
@@ -15,7 +15,7 @@ export default function RootLayout({
{/* Cookie Control Script */}
{/*
+///
+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/enzuzo/next.config.ts
similarity index 100%
rename from benchmarks/with-ketch/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..77691d1
--- /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": "16.0.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@cookiebench/benchmark-schema": "workspace:*",
+ "@cookiebench/ts-config": "workspace:*",
+ "@types/node": "^24.9.2",
+ "@types/react": "^19.2.2",
+ "@types/react-dom": "^19.2.2",
+ "typescript": "^5.9.3"
+ },
+ "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 95%
rename from benchmarks/with-iubenda/config.json
rename to benchmarks/iubenda/config.json
index 8ee60b6..bb4c9c9 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",
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-onetrust/next.config.ts b/benchmarks/iubenda/next.config.ts
similarity index 100%
rename from benchmarks/with-onetrust/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 81%
rename from benchmarks/with-cookie-yes/package.json
rename to benchmarks/iubenda/package.json
index 387ce8a..7d427e3 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",
@@ -16,11 +15,13 @@
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
+ },
+ "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 95%
rename from benchmarks/with-ketch/config.json
rename to benchmarks/ketch/config.json
index a34a64a..029ca94 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",
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-osano/next.config.ts b/benchmarks/ketch/next.config.ts
similarity index 100%
rename from benchmarks/with-osano/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 87%
rename from benchmarks/with-ketch/package.json
rename to benchmarks/ketch/package.json
index 54326f2..cc4e2c5 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",
@@ -21,5 +20,8 @@
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
+ },
+ "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 95%
rename from benchmarks/with-onetrust/config.json
rename to benchmarks/onetrust/config.json
index 86db5fd..919f2ce 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",
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-usercentrics/next.config.ts b/benchmarks/onetrust/next.config.ts
similarity index 100%
rename from benchmarks/with-usercentrics/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 82%
rename from benchmarks/with-osano/package.json
rename to benchmarks/onetrust/package.json
index 2e18ee8..5172f03 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",
@@ -16,11 +15,13 @@
},
"devDependencies": {
"@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
"@cookiebench/ts-config": "workspace:*",
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
+ },
+ "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 100%
rename from benchmarks/with-osano/app/layout.tsx
rename to benchmarks/osano/app/layout.tsx
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 95%
rename from benchmarks/with-osano/config.json
rename to benchmarks/osano/config.json
index fb25654..45a010c 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",
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-enzuzo/next.config.ts b/benchmarks/osano/next.config.ts
similarity index 69%
rename from benchmarks/with-enzuzo/next.config.ts
rename to benchmarks/osano/next.config.ts
index a67a28b..7921f35 100644
--- a/benchmarks/with-enzuzo/next.config.ts
+++ b/benchmarks/osano/next.config.ts
@@ -1,4 +1,4 @@
-import type { NextConfig } from 'next';
+import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
diff --git a/benchmarks/osano/package.json b/benchmarks/osano/package.json
new file mode 100644
index 0000000..b3b18c0
--- /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": "16.0.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@cookiebench/benchmark-schema": "workspace:*",
+ "@cookiebench/ts-config": "workspace:*",
+ "@types/node": "^24.9.2",
+ "@types/react": "^19.2.2",
+ "@types/react-dom": "^19.2.2",
+ "typescript": "^5.9.3"
+ },
+ "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 94%
rename from benchmarks/with-usercentrics/config.json
rename to benchmarks/usercentrics/config.json
index d899486..e7e3cfd 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",
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-cookie-control/next.config.ts b/benchmarks/usercentrics/next.config.ts
similarity index 69%
rename from benchmarks/with-cookie-control/next.config.ts
rename to benchmarks/usercentrics/next.config.ts
index a67a28b..7921f35 100644
--- a/benchmarks/with-cookie-control/next.config.ts
+++ b/benchmarks/usercentrics/next.config.ts
@@ -1,4 +1,4 @@
-import type { NextConfig } from 'next';
+import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
diff --git a/benchmarks/usercentrics/package.json b/benchmarks/usercentrics/package.json
new file mode 100644
index 0000000..6dee7bc
--- /dev/null
+++ b/benchmarks/usercentrics/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "usercentrics",
+ "private": true,
+ "scripts": {
+ "build": "next build",
+ "dev": "next dev --port 3001",
+ "fmt": "biome format . --write",
+ "lint": "biome lint .",
+ "start": "next start"
+ },
+ "dependencies": {
+ "next": "16.0.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@cookiebench/benchmark-schema": "workspace:*",
+ "@cookiebench/ts-config": "workspace:*",
+ "@types/node": "^24.9.2",
+ "@types/react": "^19.2.2",
+ "@types/react-dom": "^19.2.2",
+ "typescript": "^5.9.3"
+ },
+ "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-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/package.json b/benchmarks/with-cookie-control/package.json
deleted file mode 100644
index a9e7144..0000000
--- a/benchmarks/with-cookie-control/package.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "name": "with-cookie-control",
- "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": {
- "next": "16.0.1",
- "react": "^19.2.0",
- "react-dom": "^19.2.0"
- },
- "devDependencies": {
- "@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
- "@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.9.2",
- "@types/react": "^19.2.2",
- "@types/react-dom": "^19.2.2",
- "typescript": "^5.9.3"
- }
-}
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/next-env.d.ts b/benchmarks/with-didomi/next-env.d.ts
deleted file mode 100644
index 1b3be08..0000000
--- a/benchmarks/with-didomi/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/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/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 d2c9ebe..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.1",
- "react": "^19.2.0",
- "react-dom": "^19.2.0"
- },
- "devDependencies": {
- "@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
- "@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.9.2",
- "@types/react": "^19.2.2",
- "@types/react-dom": "^19.2.2",
- "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/benchmarks/with-usercentrics/package.json b/benchmarks/with-usercentrics/package.json
deleted file mode 100644
index 8815747..0000000
--- a/benchmarks/with-usercentrics/package.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "name": "with-usercentrics",
- "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": {
- "next": "16.0.1",
- "react": "^19.2.0",
- "react-dom": "^19.2.0"
- },
- "devDependencies": {
- "@cookiebench/benchmark-schema": "workspace:*",
- "@cookiebench/cli": "workspace:*",
- "@cookiebench/ts-config": "workspace:*",
- "@types/node": "^24.9.2",
- "@types/react": "^19.2.2",
- "@types/react-dom": "^19.2.2",
- "typescript": "^5.9.3"
- }
-}
diff --git a/package.json b/package.json
index 102dcf0..f6ea402 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.2",
- "@c15t/translations": "^1.7.0",
- "@cookiebench/cli": "workspace:*",
- "@playwright/test": "^1.56.1",
- "cli-table3": "^0.6.5",
- "drizzle-kit": "^0.31.6",
- "p-limit": "^7.2.0",
- "pretty-ms": "^9.3.0",
- "turbo": "^2.5.8",
- "typescript": "5.9.3",
- "ultracite": "^6.0.5"
- },
- "packageManager": "pnpm@9.0.0",
- "engines": {
- "node": ">=18"
- }
-}
\ No newline at end of file
+ "name": "cookiebench",
+ "private": true,
+ "scripts": {
+ "benchmark": "pnpm exec cookiebench benchmark",
+ "build": "turbo run build",
+ "check-types": "turbo run check-types",
+ "db": "pnpm exec cookiebench db",
+ "dev": "turbo run dev --filter=benchmarks",
+ "fmt": "turbo fmt",
+ "lint": "turbo run lint",
+ "results": "pnpm exec cookiebench results"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "2.3.2",
+ "@consentio/benchmark": "workspace:*",
+ "@consentio/runner": "workspace:*",
+ "@playwright/test": "^1.56.1",
+ "cli-table3": "^0.6.5",
+ "cookiebench": "workspace:*",
+ "drizzle-kit": "^0.31.6",
+ "pretty-ms": "^9.3.0",
+ "turbo": "^2.5.8",
+ "typescript": "5.9.3",
+ "ultracite": "6.0.5"
+ },
+ "packageManager": "pnpm@9.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/packages/benchmark-schema/schema.json b/packages/benchmark-schema/schema.json
index d3cf071..3b93cd0 100644
--- a/packages/benchmark-schema/schema.json
+++ b/packages/benchmark-schema/schema.json
@@ -3,12 +3,7 @@
"title": "Benchmark Configuration Schema",
"description": "Schema for cookie banner benchmark configurations",
"type": "object",
- "required": [
- "name",
- "iterations",
- "cookieBanner",
- "techStack"
- ],
+ "required": ["name", "iterations", "cookieBanner", "techStack"],
"properties": {
"name": {
"type": "string",
@@ -53,10 +48,7 @@
},
"cookieBanner": {
"type": "object",
- "required": [
- "selectors",
- "serviceHosts"
- ],
+ "required": ["selectors", "serviceHosts"],
"properties": {
"selectors": {
"type": "array",
@@ -95,28 +87,16 @@
},
"internationalization": {
"type": "object",
- "required": [
- "detection",
- "stringLoading"
- ],
+ "required": ["detection", "stringLoading"],
"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"
}
}
@@ -138,21 +118,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"]
}
}
]
@@ -167,10 +139,7 @@
"type": "array",
"items": {
"type": "string",
- "enum": [
- "typescript",
- "javascript"
- ]
+ "enum": ["typescript", "javascript"]
}
},
"packageManager": {
@@ -194,9 +163,7 @@
},
{
"type": "string",
- "enum": [
- "partially"
- ]
+ "enum": ["partially"]
}
]
},
@@ -259,4 +226,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..574b95b
--- /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..79b6254
--- /dev/null
+++ b/packages/benchmark/package.json
@@ -0,0 +1,34 @@
+{
+ "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 ."
+ },
+ "dependencies": {
+ "@c15t/logger": "1.0.0",
+ "@consentio/shared": "workspace:*",
+ "@playwright/test": "^1.56.1",
+ "perfume.js": "^9.4.0",
+ "playwright-performance-metrics": "^1.2.2"
+ },
+ "devDependencies": {
+ "@rsdoctor/rspack-plugin": "^1.3.6",
+ "@rslib/core": "^0.16.1",
+ "@types/node": "^24.9.2",
+ "typescript": "^5.9.3"
+ }
+}
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..5b9449c
--- /dev/null
+++ b/packages/benchmark/src/bundle-strategy.ts
@@ -0,0 +1,25 @@
+import { BUNDLE_TYPES } from "./constants";
+import type { BundleStrategy, Config } from "./types";
+
+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 isModuleBundleType =
+ bundleType === BUNDLE_TYPES.ESM ||
+ bundleType === BUNDLE_TYPES.CJS ||
+ bundleType === BUNDLE_TYPES.BUNDLED;
+
+ const isArrayWithModules =
+ Array.isArray(bundleType) &&
+ (bundleType.includes(BUNDLE_TYPES.ESM) ||
+ bundleType.includes(BUNDLE_TYPES.CJS) ||
+ bundleType.includes(BUNDLE_TYPES.BUNDLED));
+
+ 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..e522e7c
--- /dev/null
+++ b/packages/benchmark/src/constants.ts
@@ -0,0 +1,81 @@
+import {
+ BYTES_TO_KB,
+ 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, // 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..fda21b6
--- /dev/null
+++ b/packages/benchmark/src/cookie-banner-collector.ts
@@ -0,0 +1,388 @@
+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);
+ }
+ };
+
+ // If DOM is already loaded, start immediately
+ if (document.readyState !== "loading") {
+ startDetection();
+ } else {
+ // Otherwise wait for DOMContentLoaded, but start immediately after
+ 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;
+ }
+
+ return {
+ detected: metrics.detected,
+ selector: metrics.selector,
+ /**
+ * Technical render time: When banner element first appeared in DOM.
+ * Measured from navigationStart. This is tracked for reference but
+ * not used in primary scoring.
+ */
+ bannerRenderTime:
+ metrics.detected && metrics.bannerFirstSeen > 0
+ ? metrics.bannerFirstSeen - metrics.pageLoadStart
+ : 0,
+ /**
+ * User-perceived visibility time: When banner becomes visible to users.
+ * Uses opacity threshold (0.5) to account for CSS animations. This is the
+ * primary metric used for scoring. Falls back to render time if visibility
+ * time was not recorded (e.g., banner rendered with opacity already > 0.5).
+ */
+ bannerVisibilityTime: (() => {
+ if (metrics.detected && metrics.bannerVisibleTime > 0) {
+ return metrics.bannerVisibleTime - metrics.pageLoadStart;
+ }
+ if (metrics.detected && metrics.bannerFirstSeen > 0) {
+ return metrics.bannerFirstSeen - metrics.pageLoadStart;
+ }
+ return 0; // Fallback to render time if visibility time not set
+ })(),
+ 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..2fd9634
--- /dev/null
+++ b/packages/benchmark/src/index.ts
@@ -0,0 +1,26 @@
+/** 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 {
+ BundleStrategy,
+ Config,
+ CookieBannerConfig,
+ CookieBannerData,
+ CookieBannerMetrics,
+ CoreWebVitals,
+ LayoutShiftEntry,
+ NetworkMetrics,
+ NetworkRequest,
+ PerfumeMetrics,
+ ResourceTimingData,
+ 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..4575e01
--- /dev/null
+++ b/packages/benchmark/src/network-monitor.ts
@@ -0,0 +1,135 @@
+import type { Logger } from "@c15t/logger";
+import type { Page, Route } from "@playwright/test";
+import { BENCHMARK_CONSTANTS } from "./constants";
+import type { Config, NetworkMetrics, NetworkRequest } from "./types";
+
+export class NetworkMonitor {
+ private readonly config: Config;
+ private readonly logger: Logger;
+ private networkRequests: NetworkRequest[] = [];
+ private metrics: NetworkMetrics = {
+ bannerNetworkRequests: 0,
+ bannerBundleSize: 0,
+ };
+
+ constructor(config: Config, logger: Logger) {
+ this.config = config;
+ this.logger = logger;
+ }
+
+ /**
+ * Set up network request monitoring
+ */
+ async setupMonitoring(page: Page, targetUrl?: string): Promise {
+ // Extract first-party hostname from config, provided URL, or page URL
+ const firstPartyUrl = this.config.url || targetUrl || page.url();
+ const firstPartyHostname = new URL(firstPartyUrl).hostname;
+
+ await page.route("**/*", async (route: Route) => {
+ const request = route.request();
+ const url = request.url();
+ const startTime = Date.now();
+
+ 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";
+ // Compare request hostname against first-party hostname for third-party detection
+ const requestHostname = new URL(url).hostname;
+ const isThirdParty = requestHostname !== firstPartyHostname;
+
+ if (isScript) {
+ const contentLength = response.headers()["content-length"];
+ const size = contentLength ? +contentLength || 0 : 0;
+
+ // Calculate duration from request start to response
+ const duration = Date.now() - startTime;
+
+ this.networkRequests.push({
+ url,
+ size: size / BENCHMARK_CONSTANTS.BYTES_TO_KB, // Convert to KB
+ duration,
+ startTime,
+ isScript,
+ isThirdParty,
+ });
+
+ if (isThirdParty) {
+ this.metrics.bannerNetworkRequests += 1;
+ this.metrics.bannerBundleSize +=
+ size / BENCHMARK_CONSTANTS.BYTES_TO_KB;
+ this.logger.debug(
+ `Third-party script detected: ${url} (${(size / BENCHMARK_CONSTANTS.BYTES_TO_KB).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/benchmark/src/perfume-collector.ts b/packages/benchmark/src/perfume-collector.ts
new file mode 100644
index 0000000..6fdea42
--- /dev/null
+++ b/packages/benchmark/src/perfume-collector.ts
@@ -0,0 +1,261 @@
+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 a bit for metrics to be collected
+ await page.waitForTimeout(BENCHMARK_CONSTANTS.PERFUME_METRICS_WAIT);
+
+ 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..b3f180e
--- /dev/null
+++ b/packages/benchmark/src/resource-timing-collector.ts
@@ -0,0 +1,188 @@
+import type { Logger } from "@c15t/logger";
+import type { Page } from "@playwright/test";
+import { BENCHMARK_CONSTANTS } from "./constants";
+import type { ResourceTimingData } from "./types";
+
+export class ResourceTimingCollector {
+ private readonly logger: Logger;
+
+ constructor(logger: Logger) {
+ this.logger = logger;
+ }
+ /**
+ * Collect detailed resource timing data from the browser
+ */
+ async collect(page: Page): Promise {
+ this.logger.debug("Collecting resource timing data...");
+
+ return await page.evaluate((bytesToKb: number) => {
+ const perfEntries = performance.getEntriesByType(
+ "navigation"
+ )[0] as PerformanceNavigationTiming;
+ const resourceEntries = performance.getEntriesByType(
+ "resource"
+ ) as PerformanceResourceTiming[];
+
+ // Helper to determine if a resource is first-party
+ const isFirstParty = (entry: PerformanceResourceTiming) => {
+ try {
+ return (
+ new URL(entry.name, window.location.origin).hostname ===
+ window.location.hostname
+ );
+ } catch {
+ return (
+ entry.name.startsWith(window.location.origin) ||
+ entry.name.startsWith("/")
+ );
+ }
+ };
+
+ // 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)
+ );
+
+ // Calculate sizes
+ const calculateSize = (entries: PerformanceResourceTiming[]) => {
+ const total =
+ entries.reduce((acc, entry) => {
+ const size = entry.transferSize || entry.encodedBodySize || 0;
+ return acc + size;
+ }, 0) / bytesToKb;
+ return total;
+ };
+
+ const navigationStart = perfEntries.startTime;
+ const domContentLoaded =
+ perfEntries.domContentLoadedEventEnd - navigationStart;
+ const load = perfEntries.loadEventEnd - navigationStart;
+
+ 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: 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((entry) => !isFirstParty(entry))
+ ),
+ 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.encodedBodySize || 0) / bytesToKb,
+ duration: entry.duration,
+ startTime: entry.startTime - navigationStart,
+ isThirdParty: !isFirstParty(entry),
+ 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.encodedBodySize || 0) / bytesToKb,
+ duration: entry.duration,
+ startTime: entry.startTime - navigationStart,
+ isThirdParty: !isFirstParty(entry),
+ isCookieService: false,
+ })),
+ images: imageEntries.map((entry) => ({
+ name: entry.name,
+ size:
+ (entry.transferSize || entry.encodedBodySize || 0) / bytesToKb,
+ duration: entry.duration,
+ startTime: entry.startTime - navigationStart,
+ isThirdParty: !isFirstParty(entry),
+ isCookieService: false,
+ })),
+ fonts: fontEntries.map((entry) => ({
+ name: entry.name,
+ size:
+ (entry.transferSize || entry.encodedBodySize || 0) / bytesToKb,
+ duration: entry.duration,
+ startTime: entry.startTime - navigationStart,
+ isThirdParty: !isFirstParty(entry),
+ isCookieService: false,
+ })),
+ other: otherEntries.map((entry) => ({
+ name: entry.name,
+ size:
+ (entry.transferSize || entry.encodedBodySize || 0) / bytesToKb,
+ duration: entry.duration,
+ startTime: entry.startTime - navigationStart,
+ isThirdParty: !isFirstParty(entry),
+ isCookieService: false,
+ type: entry.initiatorType,
+ })),
+ },
+ language: (() => {
+ const docLang = (
+ document.documentElement.getAttribute("lang") || ""
+ ).trim();
+ return (
+ docLang || navigator.language || navigator.languages?.[0] || "en"
+ );
+ })(),
+ duration: load,
+ };
+ }, BENCHMARK_CONSTANTS.BYTES_TO_KB);
+ }
+}
diff --git a/packages/benchmark/src/types.ts b/packages/benchmark/src/types.ts
new file mode 100644
index 0000000..acec188
--- /dev/null
+++ b/packages/benchmark/src/types.ts
@@ -0,0 +1,280 @@
+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 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[];
+};
+
+// 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;
+ bannerRenderTime: number; // Technical: when banner is painted to screen
+ bannerVisibilityTime: number; // UX: when banner is actually visible to users (opacity > 0.5)
+ 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: string | string[] | 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/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 a9953bb..0000000
--- a/packages/cli/src/lib/performance.ts
+++ /dev/null
@@ -1,437 +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.at(-1);
- 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/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..1727a94
--- /dev/null
+++ b/packages/cookiebench-cli/README.md
@@ -0,0 +1,395 @@
+# 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:**
+```
+? 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:**
+```
+? 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/base.json b/packages/cookiebench-cli/base.json
new file mode 100644
index 0000000..5117f2a
--- /dev/null
+++ b/packages/cookiebench-cli/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", "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/cookiebench-cli/package.json
similarity index 54%
rename from packages/cli/package.json
rename to packages/cookiebench-cli/package.json
index 13d6af4..8e4484c 100644
--- a/packages/cli/package.json
+++ b/packages/cookiebench-cli/package.json
@@ -1,35 +1,39 @@
{
- "name": "@cookiebench/cli",
- "version": "0.0.0",
+ "name": "cookiebench",
+ "version": "0.0.1",
"private": true,
"type": "module",
"exports": "./dist/index.mjs",
"main": "./dist/index.mjs",
"module": "dist/index.mjs",
"bin": {
- "benchmark-cli": "dist/index.mjs"
+ "cookiebench": "dist/index.mjs"
},
"scripts": {
"build": "rslib build",
"check-types": "tsc --noEmit",
"dev": "rslib build --watch",
- "format": "biome format . --write",
+ "fmt": "biome format . --write",
"lint": "biome lint .",
"start": "node ./dist/index.mjs"
},
"dependencies": {
- "@clack/prompts": "1.0.0-alpha.6",
- "@playwright/test": "^1.56.1",
- "cli-table3": "^0.6.5",
+ "@c15t/logger": "^1.0.0",
+ "@clack/prompts": "^1.0.0-alpha.0",
+ "@consentio/benchmark": "workspace:*",
+ "@consentio/runner": "workspace:*",
+ "@consentio/shared": "workspace:*",
+ "cli-table3": "^0.6.3",
"dotenv": "^17.2.3",
- "package-manager-detector": "^1.5.0",
- "picocolors": "^1.1.1"
+ "figlet": "^1.9.3",
+ "picocolors": "^1.0.0",
+ "pretty-ms": "^9.3.0"
},
"devDependencies": {
+ "@types/figlet": "^1.7.0",
"@rsdoctor/rspack-plugin": "^1.3.6",
"@rslib/core": "^0.16.1",
"@types/node": "^24.9.2",
- "playwright-performance-metrics": "^1.2.2",
"typescript": "^5.9.3"
}
-}
\ No newline at end of file
+}
diff --git a/packages/cli/rslib.config.ts b/packages/cookiebench-cli/rslib.config.ts
similarity index 53%
rename from packages/cli/rslib.config.ts
rename to packages/cookiebench-cli/rslib.config.ts
index 3fbccaf..b7b8e33 100644
--- a/packages/cli/rslib.config.ts
+++ b/packages/cookiebench-cli/rslib.config.ts
@@ -1,24 +1,24 @@
-import { defineConfig } from '@rslib/core';
+import { defineConfig } from "@rslib/core";
export default defineConfig({
source: {
entry: {
- index: './src/index.ts',
+ index: "./src/index.ts",
},
- exclude: ['figlet'],
+ exclude: ["figlet"],
},
lib: [
{
bundle: true,
dts: true,
- format: 'esm',
+ format: "esm",
},
],
output: {
- target: 'node',
+ target: "node",
cleanDistPath: true,
filename: {
- js: '[name].mjs',
+ 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..87a13fa
--- /dev/null
+++ b/packages/cookiebench-cli/src/commands/benchmark.ts
@@ -0,0 +1,584 @@
+import { mkdir, readdir, writeFile } from "node:fs/promises";
+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 ServerInfo,
+} from "@consentio/runner";
+import color from "picocolors";
+import {
+ DEFAULT_DOM_SIZE,
+ DEFAULT_ITERATIONS,
+ HALF_SECOND,
+ PERCENTAGE_DIVISOR,
+ readConfig,
+ SEPARATOR_WIDTH,
+} from "../utils";
+import type { CliLogger } from "../utils/logger";
+import { calculateScores, printScores } from "../utils/scoring";
+
+/**
+ * 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"]) {
+ 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)),
+ };
+}
+
+/**
+ * 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;
+ })
+ );
+
+ return {
+ totalRequests,
+ thirdPartyRequests,
+ thirdPartySize,
+ thirdPartyDomains,
+ };
+}
+
+/**
+ * Calculate cookie banner metrics from benchmark results.
+ *
+ * This function aggregates cookie banner metrics across all iterations and calculates
+ * the average timing for scoring. It uses bannerVisibilityTime (user-perceived visibility
+ * with opacity > 0.5) rather than bannerRenderTime (technical render time) to ensure
+ * scores reflect actual user experience.
+ *
+ * Scoring methodology:
+ * - Requires consistent detection across ALL iterations for positive score
+ * - Uses average of visibilityTime across iterations
+ * - Applies penalties for inconsistent detection
+ * - Calculates viewport coverage percentage
+ *
+ * @param details Array of benchmark details from all iterations
+ * @param logger Logger instance for warnings
+ * @returns Aggregated cookie banner metrics for scoring
+ * @see METHODOLOGY.md for detailed explanation of render time vs visibility time
+ */
+function calculateCookieBannerMetrics(
+ details: BenchmarkResult["details"],
+ logger: CliLogger
+) {
+ // Require consistent detection across ALL iterations for true positive
+ const allDetected = details.every((r) => r.cookieBanner.detected);
+ if (!allDetected) {
+ logger.warn(
+ "⚠️ [SCORING] Banner detection inconsistent or failed - marking as not detected"
+ );
+ }
+
+ // Calculate timing
+ const detectionSuccess = details.some((r) => r.cookieBanner.detected);
+ let cookieBannerTiming: number | null = null;
+
+ if (detectionSuccess) {
+ /**
+ * Use bannerVisibilityTime (opacity-based, user-perceived) for scoring.
+ * This ensures scores reflect actual user experience including CSS animations.
+ * Not using bannerRenderTime (technical render time) for scoring.
+ */
+ const timingValues = details.map((r) => 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) {
+ cookieBannerTiming = calculateAverage(validTimings);
+ }
+ }
+ } else {
+ logger.warn(
+ "⚠️ [SCORING] No banner detected in any iteration - applying penalty"
+ );
+ }
+
+ // Calculate coverage
+ 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,
+ cookieBannerTiming,
+ 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)
+ ),
+ };
+}
+
+/**
+ * Find all benchmark directories
+ */
+async function findBenchmarkDirs(logger: CliLogger): Promise {
+ const benchmarksDir = "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(
+ logger: CliLogger,
+ appPath: string,
+ showScores = true,
+ iterationsOverride?: number
+): Promise {
+ const configPath = appPath ? join(appPath, "config.json") : undefined;
+ const config = readConfig(configPath);
+ if (!config) {
+ logger.error(
+ `Failed to read config.json for ${appPath || "current directory"}`
+ );
+ return false;
+ }
+
+ // Override iterations if provided
+ if (iterationsOverride !== undefined && iterationsOverride > 0) {
+ config.iterations = iterationsOverride;
+ }
+
+ try {
+ let serverInfo: ServerInfo | null = null;
+ let benchmarkUrl: string;
+
+ // Check if remote benchmarking is enabled
+ 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();
+
+ // Create traces directory if it doesn't exist
+ const tracesDir = join(cwd, "traces");
+ try {
+ await mkdir(tracesDir, { recursive: true });
+ } catch (error: unknown) {
+ // Ignore EEXIST - directory already exists
+ if (
+ error &&
+ typeof error === "object" &&
+ "code" in error &&
+ error.code !== "EEXIST"
+ ) {
+ throw error;
+ }
+ }
+ logger.info(`📊 Tracing enabled - traces will be saved to: ${tracesDir}`);
+
+ try {
+ // Create benchmark runner and run benchmarks with trace saving enabled
+ const runner = new BenchmarkRunner(config, logger, {
+ saveTrace: true,
+ traceDir: tracesDir,
+ });
+ const result = await runner.runBenchmarks(benchmarkUrl);
+
+ if (!result.details || result.details.length === 0) {
+ logger.error("No successful benchmark iterations");
+ return false;
+ }
+
+ // 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 all metrics using helper functions
+ 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);
+
+ // Calculate scores
+ const scores = calculateScores(
+ timingMetrics,
+ sizeMetrics,
+ networkMetrics,
+ cookieBannerMetrics,
+ performanceMetrics,
+ config.baseline ?? false,
+ appData
+ );
+
+ // 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,
+ 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));
+ logger.success(`Benchmark results saved to ${outputPath}`);
+
+ // Print scores if requested
+ if (showScores && scores) {
+ logger.info("📊 Benchmark Scores:");
+ printScores(scores);
+ }
+
+ return true;
+ } finally {
+ // Only cleanup server if we started one
+ 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
+): Promise {
+ // If a specific app path is provided, run that benchmark directly
+ if (appPath) {
+ const success = await runSingleBenchmark(logger, appPath, true);
+ if (!success) {
+ throw new Error(`Benchmark failed for ${appPath}`);
+ }
+ return;
+ }
+
+ // Otherwise, show multi-select for available benchmarks
+ logger.clear();
+ await setTimeout(HALF_SECOND);
+
+ intro(`${color.bgMagenta(color.white(" benchmark "))}`);
+
+ // Find available benchmarks
+ const availableBenchmarks = await findBenchmarkDirs(logger);
+
+ 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(", "))}`
+ );
+
+ // Ask user to select benchmarks
+ 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;
+ }
+
+ // Load configs to get default iterations
+ const benchmarkConfigs = new Map();
+ for (const benchmarkName of selectedBenchmarks) {
+ const benchmarkPath = join("benchmarks", benchmarkName);
+ const configPath = join(benchmarkPath, "config.json");
+ const config = readConfig(configPath);
+ if (config) {
+ benchmarkConfigs.set(benchmarkName, config.iterations);
+ }
+ }
+
+ // Find the most common iteration count or first one
+ const defaultIterations =
+ benchmarkConfigs.size > 0
+ ? Array.from(benchmarkConfigs.values())[0]
+ : DEFAULT_ITERATIONS;
+
+ // Show iteration counts for selected benchmarks
+ const iterationsList = Array.from(selectedBenchmarks)
+ .map((name) => {
+ const iterations = benchmarkConfigs.get(name) ?? "?";
+ return `${name}: ${iterations}`;
+ })
+ .join(", ");
+
+ logger.info(`Config iterations: ${color.dim(iterationsList)}`);
+
+ // Ask for iterations override
+ const iterationsInput = await text({
+ message: "Number of iterations (press Enter to use config values):",
+ placeholder: `Default: ${defaultIterations}`,
+ defaultValue: "",
+ validate: (value) => {
+ if (!value || value === "") {
+ return; // Empty is valid (use defaults)
+ }
+ 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;
+ }
+
+ // Parse iterations - if empty string, use undefined to let each benchmark use its config
+ 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");
+ }
+
+ // Ask if user wants to see results panel after completion
+ const showResults = await confirm({
+ message: "Show results panel after completion?",
+ initialValue: true,
+ });
+
+ if (isCancel(showResults)) {
+ cancel("Operation cancelled");
+ return;
+ }
+
+ // Run selected benchmarks sequentially
+ const results: Array<{ name: string; success: boolean }> = [];
+
+ for (let i = 0; i < selectedBenchmarks.length; i += 1) {
+ const benchmarkName = selectedBenchmarks[i];
+ const benchmarkPath = join("benchmarks", benchmarkName);
+
+ logger.info(
+ `\n${color.bold(color.cyan(`[${i + 1}/${selectedBenchmarks.length}]`))} Running benchmark: ${color.bold(benchmarkName)}`
+ );
+
+ const success = await runSingleBenchmark(
+ logger,
+ benchmarkPath,
+ false, // Don't show inline scores anymore
+ iterationsOverride
+ );
+
+ results.push({ name: benchmarkName, success });
+
+ if (!success) {
+ logger.error(
+ `Failed to complete benchmark for ${benchmarkName}, continuing...`
+ );
+ }
+
+ // Add spacing between benchmarks
+ if (i < selectedBenchmarks.length - 1) {
+ logger.message(`\n${"─".repeat(SEPARATOR_WIDTH)}\n`);
+ }
+ }
+
+ // Summary
+ logger.message("\n");
+ outro(
+ `${color.bold("Summary:")} ${results.filter((r) => r.success).length}/${results.length} benchmarks completed successfully`
+ );
+
+ // Show failed benchmarks if any
+ const failed = results.filter((r) => !r.success);
+ if (failed.length > 0) {
+ logger.warn(`Failed benchmarks: ${failed.map((r) => r.name).join(", ")}`);
+ }
+
+ // Show results panel if requested
+ if (showResults === true && results.some((r) => r.success)) {
+ logger.message(`\n${"═".repeat(SEPARATOR_WIDTH)}\n`);
+ logger.info("Loading results panel...\n");
+
+ // Get successful benchmark names
+ const successfulBenchmarks = results
+ .filter((r) => r.success)
+ .map((r) => r.name);
+
+ // Dynamically import and run the results command with specific benchmarks
+ 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..ed787d2
--- /dev/null
+++ b/packages/cookiebench-cli/src/commands/db.ts
@@ -0,0 +1,270 @@
+import { execSync } from "node:child_process";
+import { existsSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { setTimeout } from "node:timers/promises";
+import { cancel, confirm, intro, isCancel, select } from "@clack/prompts";
+import { ONE_SECOND } from "@consentio/shared";
+import color from "picocolors";
+import { isAdminUser } from "../utils/auth";
+import type { CliLogger } from "../utils/logger";
+
+const DB_PACKAGE_PATH = join(process.cwd(), "packages", "db");
+const DRIZZLE_CONFIG_PATH = join(DB_PACKAGE_PATH, "drizzle.config.ts");
+
+function ensureDbPackage(logger: CliLogger) {
+ if (!existsSync(DB_PACKAGE_PATH)) {
+ logger.error(
+ "Database package not found. Make sure you are running this from the project root."
+ );
+ process.exit(1);
+ }
+
+ if (!existsSync(DRIZZLE_CONFIG_PATH)) {
+ logger.error(
+ "Drizzle config not found. Make sure drizzle.config.ts exists in packages/db/"
+ );
+ process.exit(1);
+ }
+}
+
+function runDrizzleCommand(logger: CliLogger, command: string): void {
+ try {
+ logger.step(`Running: ${color.cyan(`drizzle-kit ${command}`)}`);
+ execSync(`cd ${DB_PACKAGE_PATH} && pnpm drizzle-kit ${command}`, {
+ stdio: "inherit",
+ encoding: "utf-8",
+ });
+ } catch (error) {
+ logger.error(`Failed to run drizzle-kit ${command}`);
+ if (error instanceof Error) {
+ logger.error(error.message);
+ }
+ process.exit(1);
+ }
+}
+
+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();
+ await setTimeout(ONE_SECOND);
+
+ intro(`${color.bgBlue(color.white(" database "))} ${color.dim("v0.1.0")}`);
+
+ ensureDbPackage(logger);
+
+ 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);
+ break;
+ case "generate":
+ await generateCommand(logger);
+ break;
+ case "migrate":
+ await migrateCommand(logger);
+ break;
+ case "studio":
+ await studioCommand(logger);
+ break;
+ case "status":
+ await statusCommand(logger);
+ break;
+ default:
+ logger.error(`Unknown subcommand: ${selectedCommand}`);
+ logger.info(
+ "Available commands: push, generate, migrate, studio, status"
+ );
+ process.exit(1);
+ }
+}
+
+async function pushCommand(logger: CliLogger) {
+ 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;
+ }
+
+ runDrizzleCommand(logger, "push");
+ logger.success("Schema pushed successfully!");
+ logger.outro("Database is now up to date with your schema.");
+}
+
+function generateCommand(logger: CliLogger) {
+ logger.step("Generating migration files...");
+ logger.info("This will create SQL migration files based on schema changes.");
+
+ runDrizzleCommand(logger, "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.`);
+}
+
+async function migrateCommand(logger: CliLogger) {
+ 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 {
+ runDrizzleCommand(logger, "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);
+ }
+}
+
+function studioCommand(logger: CliLogger) {
+ 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 {
+ runDrizzleCommand(logger, "studio");
+ } catch {
+ // Studio command might be interrupted by Ctrl+C, which is normal
+ logger.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"))) &&
+ existsSync(join(currentDir, "packages"))
+ ) {
+ return currentDir;
+ }
+ currentDir = dirname(currentDir);
+ }
+
+ return process.cwd();
+}
+
+async function statusCommand(logger: CliLogger) {
+ logger.step("Checking migration status...");
+
+ try {
+ // Check if database exists at project root
+ const projectRoot = findProjectRoot();
+ 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(DB_PACKAGE_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 { 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) {
+ 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/results.ts b/packages/cookiebench-cli/src/commands/results.ts
new file mode 100644
index 0000000..e232601
--- /dev/null
+++ b/packages/cookiebench-cli/src/commands/results.ts
@@ -0,0 +1,1120 @@
+/** 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 { KILOBYTE, 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 { isAdminUser } from "../utils/auth";
+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 type { CliLogger } from "../utils/logger";
+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;
+ 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 = {
+ 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(
+ logger: CliLogger,
+ 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) {
+ logger.debug(
+ `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",
+ },
+ };
+ }
+}
+
+async function aggregateResults(logger: CliLogger, resultsDir: string) {
+ const resultsFiles = await findResultsFiles(resultsDir);
+ const results: Record = {};
+
+ 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);
+
+ if (!(data.app && data.results)) {
+ logger.warn(
+ `Skipping invalid results file: ${file} (missing app or results)`
+ );
+ continue;
+ }
+
+ logger.debug(`Processing ${file} with app name: "${data.app}"`);
+
+ if (results[data.app]) {
+ logger.warn(
+ `Duplicate app name "${data.app}" found in ${file}. Previous results will be overwritten.`
+ );
+ }
+
+ results[data.app] = data.results;
+ logger.debug(
+ `Loaded results for ${data.app} (${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`);
+ }
+
+ 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 formatBytes(bytes: number): string {
+ if (bytes === 0) {
+ return "0bytes";
+ }
+ if (bytes < KILOBYTE) {
+ return `${bytes.toFixed(0)}bytes`;
+ }
+ return `${(bytes / KILOBYTE).toFixed(0)}KB`;
+}
+
+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[]
+) {
+ 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
+ const avgBannerVisibility =
+ results.reduce((a, b) => a + b.timing.cookieBanner.visibilityTime, 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 _bannerDetected = results.some((r) => r.timing.cookieBanner.detected);
+ const isBundled = results[0]?.size.thirdParty === 0;
+
+ 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
+ let bannerDelta = "";
+ if (baseline && appName !== "baseline") {
+ const baselineAvgBanner =
+ baseline.reduce((a, b) => a + b.timing.cookieBanner.visibilityTime, 0) /
+ baseline.length;
+ const delta = avgBannerVisibility - baselineAvgBanner;
+ bannerDelta = ` ${delta > 0 ? "+" : ""}${formatTime(delta)}`;
+ }
+
+ // ━━━ Cookie Banner Impact ━━━
+ console.log(`\n${color.bold("🍪 Cookie Banner Impact")}`);
+ const bannerTable = new Table({
+ chars: { mid: "", "left-mid": "", "mid-mid": "", "right-mid": "" },
+ style: { "padding-left": 2, "padding-right": 2, border: ["grey"] },
+ });
+
+ bannerTable.push(
+ [
+ { content: "Banner Visibility", colSpan: 1 },
+ { content: "Viewport Coverage", colSpan: 1 },
+ { content: "Network Impact", colSpan: 1 },
+ { content: "Bundle Strategy", colSpan: 1 },
+ ],
+ [
+ `${color.bold(formatTime(avgBannerVisibility))}\n${color.dim(bannerDelta || "baseline")}`,
+ `${color.bold(`${avgViewportCoverage.toFixed(1)}%`)}\n${color.dim("Screen real estate")}`,
+ `${color.bold(formatBytes(avgNetworkImpact * KILOBYTE))}\n${color.dim(isBundled ? "Bundled (no network)" : "External requests")}`,
+ `${color.bold(isBundled ? "Bundled" : "External")}\n${color.dim(isBundled ? "Included in main bundle" : "Loaded from CDN")}`,
+ ]
+ );
+
+ 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"),
+ formatBytes(jsSize * KILOBYTE),
+ Math.round(jsFiles).toString(),
+ `${jsPercentage.toFixed(1)}%`,
+ ],
+ [
+ color.cyan("CSS"),
+ formatBytes(cssSize * KILOBYTE),
+ Math.round(cssFiles).toString(),
+ `${cssPercentage.toFixed(1)}%`,
+ ],
+ [
+ color.cyan("Images"),
+ formatBytes(imageSize * KILOBYTE),
+ Math.round(imageFiles).toString(),
+ `${imagePercentage.toFixed(1)}%`,
+ ],
+ [
+ color.cyan("Fonts"),
+ formatBytes(fontSize * KILOBYTE),
+ Math.round(fontFiles).toString(),
+ `${fontPercentage.toFixed(1)}%`,
+ ],
+ [
+ color.cyan("Other"),
+ formatBytes(otherSize * KILOBYTE),
+ Math.round(otherFiles).toString(),
+ `${otherPercentage.toFixed(1)}%`,
+ ],
+ [
+ color.bold("Total"),
+ color.bold(formatBytes(totalSize * KILOBYTE)),
+ 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(avgBannerVisibility))],
+ ["Network Overhead", color.bold(formatBytes(avgNetworkImpact * KILOBYTE))],
+ ["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),
+ formatBytes(resource.size * KILOBYTE),
+ 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 resultsDir = "benchmarks";
+ const results = await aggregateResults(logger, resultsDir);
+
+ 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] = await loadConfigForApp(logger, name);
+ }
+
+ // 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:
+ appResults[0]?.timing.interactionToNextPaint || 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,
+ 0
+ ) / appResults.length,
+ thirdPartySize:
+ appResults.reduce((a, b) => a + b.size.thirdParty, 0) /
+ appResults.length,
+ thirdPartyDomains: 5,
+ },
+ {
+ 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 /
+ 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);
+ }
+
+ if (isAdminUser()) {
+ logger.outro(
+ `\nDisplayed ${selectedApps.length} of ${Object.keys(results).length} benchmark(s) - Use ${color.cyan("cookiebench save")} to sync to database`
+ );
+ } else {
+ logger.outro(
+ `\nDisplayed ${selectedApps.length} of ${Object.keys(results).length} benchmark(s)`
+ );
+ }
+}
diff --git a/packages/cookiebench-cli/src/commands/save.ts b/packages/cookiebench-cli/src/commands/save.ts
new file mode 100644
index 0000000..e20b47f
--- /dev/null
+++ b/packages/cookiebench-cli/src/commands/save.ts
@@ -0,0 +1,587 @@
+import { readdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { setTimeout } 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 { isAdminUser } from "../utils/auth";
+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 = {
+ app: string;
+ results: RawBenchmarkDetail[];
+ scores?: BenchmarkScores;
+ metadata?: {
+ timestamp: string;
+ iterations: number;
+ languages?: string[];
+ };
+};
+
+// 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";
+ 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[];
+ };
+};
+
+async function saveBenchmarkResult(
+ logger: CliLogger,
+ result: BenchmarkResult
+): Promise {
+ const apiUrl = process.env.API_URL || "http://localhost:3000";
+ const endpoint = `${apiUrl}/api/orpc/benchmarks/save`;
+
+ try {
+ logger.debug(`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();
+ logger.success(`Saved ${result.name} (App ID: ${responseData.appId})`);
+ } catch (error) {
+ if (error instanceof Error) {
+ 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;
+ }
+}
+
+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;
+}
+
+async function loadConfigForApp(
+ logger: CliLogger,
+ 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) {
+ logger.debug(`Could not load config for ${appName}:`, error);
+ return null;
+ }
+}
+
+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: category.status,
+ })),
+ insights: scores.insights,
+ recommendations: scores.recommendations,
+ };
+}
+
+export async function saveCommand(
+ logger: CliLogger,
+ appName?: string
+): Promise {
+ // Double-check admin access (safeguard)
+ if (!isAdminUser()) {
+ logger.error("This command requires admin access");
+ process.exit(1);
+ }
+
+ logger.clear();
+ await setTimeout(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 = "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 = {};
+ for (const file of resultsFiles) {
+ try {
+ const content = await readFile(file, "utf-8");
+ const data: BenchmarkOutput = JSON.parse(content);
+
+ if (data.app && data.results) {
+ allResults[data.app] = data;
+ }
+ } catch (error) {
+ logger.debug(`Failed to load ${file}:`, error);
+ }
+ }
+
+ 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);
+ 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]);
+ 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
+): Promise {
+ const appConfig = await loadConfigForApp(logger, appName);
+ const appResults = result.results;
+
+ // 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: appConfig?.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:
+ appResults[0]?.timing.interactionToNextPaint || 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,
+ 0
+ ) / appResults.length,
+ thirdPartySize:
+ appResults.reduce((a, b) => a + b.size.thirdParty, 0) /
+ appResults.length,
+ thirdPartyDomains: 5,
+ },
+ {
+ 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 /
+ 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: 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: 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..5e22ac4
--- /dev/null
+++ b/packages/cookiebench-cli/src/commands/scores.ts
@@ -0,0 +1,355 @@
+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 type { CliLogger } from "../utils/logger";
+import { calculateScores, printScores } from "../utils/scoring";
+import type { RawBenchmarkDetail } from "./results";
+
+type BenchmarkOutput = {
+ app: string;
+ results: RawBenchmarkDetail[];
+ scores?: BenchmarkScores;
+ metadata?: {
+ timestamp: string;
+ iterations: number;
+ languages?: string[];
+ };
+};
+
+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;
+}
+
+async function loadConfigForApp(
+ logger: CliLogger,
+ 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) {
+ 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 resultsDir = "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 = {};
+ for (const file of resultsFiles) {
+ try {
+ const content = await readFile(file, "utf-8");
+ const data: BenchmarkOutput = JSON.parse(content);
+
+ if (data.app && data.results) {
+ allResults[data.app] = data;
+ }
+ } catch (error) {
+ logger.debug(`Failed to load ${file}:`, error);
+ }
+ }
+
+ 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!");
+}
+
+async 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(`Iterations: ${result.metadata.iterations}`);
+ logger.debug(`Timestamp: ${result.metadata.timestamp}`);
+ }
+
+ // 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;
+ const config = await loadConfigForApp(logger, appName);
+
+ if (!config) {
+ logger.warn(`Could not load config for ${appName}, using default values`);
+ }
+
+ // 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;
+ })(),
+ },
+ {
+ 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 /
+ 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..0eecdbc
--- /dev/null
+++ b/packages/cookiebench-cli/src/components/intro.ts
@@ -0,0 +1,93 @@
+/** 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 customColor = {
+ cyan10: (text: string) => `\x1b[38;2;10;80;90m${text}\x1b[0m`,
+ cyan20: (text: string) => `\x1b[38;2;15;100;110m${text}\x1b[0m`,
+ cyan30: (text: string) => `\x1b[38;2;20;120;130m${text}\x1b[0m`,
+ cyan40: (text: string) => `\x1b[38;2;25;150;170m${text}\x1b[0m`,
+ cyan50: (text: string) => `\x1b[38;2;30;170;190m${text}\x1b[0m`,
+ cyan75: (text: string) => `\x1b[38;2;34;211;230m${text}\x1b[0m`,
+ cyan90: (text: string) => `\x1b[38;2;45;225;245m${text}\x1b[0m`,
+ cyan100: (text: string) => `\x1b[38;2;65;235;255m${text}\x1b[0m`,
+ };
+
+ const lines = figletText.split("\n");
+ const coloredLines = lines.map((line, index) => {
+ // Calculate the position in the gradient based on line index
+ const position = index / (lines.length - 1);
+
+ 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..f06c9df
--- /dev/null
+++ b/packages/cookiebench-cli/src/index.ts
@@ -0,0 +1,135 @@
+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 { 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);
+}
+
+async function main() {
+ logger.clear();
+ await setTimeout(HALF_SECOND);
+
+ // Check for command line arguments
+ const args = process.argv.slice(2);
+ 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":
+ await benchmarkCommand(logger);
+ break;
+ case "results":
+ await resultsCommand(logger, args[1]);
+ break;
+ case "scores":
+ await scoresCommand(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"];
+ if (isAdmin) {
+ availableCommands.push("save", "db");
+ }
+ logger.info(`Available commands: ${availableCommands.join(", ")}`);
+ process.exit(1);
+ }
+ }
+ } 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",
+ },
+ ];
+
+ // 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 "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..a38ffb2
--- /dev/null
+++ b/packages/cookiebench-cli/src/types/index.ts
@@ -0,0 +1,38 @@
+// Re-export types from runner package
+export type {
+ BenchmarkDetails,
+ BenchmarkResult,
+ Config,
+ ServerInfo,
+} from "@consentio/runner";
+
+// CLI-specific scoring types
+export type 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/cookiebench-cli/src/utils/auth.ts b/packages/cookiebench-cli/src/utils/auth.ts
new file mode 100644
index 0000000..dbcada3
--- /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;
+
+ // Accept 'true', '1', 'yes' as valid values
+ return adminFlag === "true" || adminFlag === "1" || adminFlag === "yes";
+}
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..b7b18c6
--- /dev/null
+++ b/packages/cookiebench-cli/src/utils/index.ts
@@ -0,0 +1,23 @@
+/** 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 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..e0cb471
--- /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 = logLevel as string;
+ 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/cli/src/utils/scoring.ts b/packages/cookiebench-cli/src/utils/scoring.ts
similarity index 57%
rename from packages/cli/src/utils/scoring.ts
rename to packages/cookiebench-cli/src/utils/scoring.ts
index 9b4552c..e16c5e1 100644
--- a/packages/cli/src/utils/scoring.ts
+++ b/packages/cookiebench-cli/src/utils/scoring.ts
@@ -1,12 +1,12 @@
-import type { RawBenchmarkDetail } from "../commands/results";
-import type { Config } from "../types";
+/** 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 color from "picocolors";
-import { determineBundleStrategy } from "../commands/benchmark/bundle-strategy";
-import type { BenchmarkScores } from '../types';
+import type { BenchmarkScores } from "../types";
+import { formatBytes, formatTime } from "./index";
// Type definitions for better type safety
-interface AppData {
+type AppData = {
id?: number;
name: string;
baseline: boolean;
@@ -14,9 +14,9 @@ interface AppData {
techStack: string;
source: string | null;
tags: string | null;
-}
+};
-interface MetricsData {
+type MetricsData = {
fcp: number;
lcp: number;
cls: number;
@@ -30,64 +30,73 @@ interface MetricsData {
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;
+ };
+};
-interface ResourceData {
+type ResourceData = {
size: number;
isThirdParty: boolean;
-}
+};
-interface BenchmarkData {
+type BenchmarkData = {
bannerRenderTime?: number;
bannerInteractionTime?: number;
layoutShift?: number;
-}
+};
-interface TechStackData {
+export type TechStackData = {
languages: string[];
frameworks: string[];
bundler: string;
bundleType: string;
packageManager: string;
typescript: boolean;
-}
+};
-interface CompanyData {
- name?: string;
- avatar?: string;
-}
+type CompanyData = {
+ name: string;
+ avatar: string;
+};
-interface SourceData {
+type SourceData = {
license?: string;
github?: string;
repository?: string;
openSource?: boolean;
type?: string;
-}
+};
-interface CategoryScores {
+type CategoryScores = {
performance: number;
bundleStrategy: number;
networkImpact: number;
transparency: number;
userExperience: number;
-}
+};
-interface ScoreDetail {
+type ScoreDetail = {
metric: string;
value: string | number;
score: number;
maxScore: number;
reason: string;
-}
+};
-interface ScoreWeights {
+type ScoreWeights = {
performance: number;
bundleStrategy: number;
networkImpact: number;
transparency: number;
userExperience: number;
-}
+};
// Default scoring weights
export const DEFAULT_SCORE_WEIGHTS: ScoreWeights = {
@@ -98,25 +107,6 @@ export const DEFAULT_SCORE_WEIGHTS: ScoreWeights = {
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,
@@ -126,22 +116,22 @@ function isOpenSourceSolution(
// Check source information for open source indicators
if (sourceInfo) {
// Check for open source license
- const license = sourceInfo.license?.toLowerCase() || '';
+ const license = sourceInfo.license?.toLowerCase() || "";
const openSourceLicenses = [
- 'mit',
- 'apache',
- 'gpl',
- 'bsd',
- 'lgpl',
- 'mpl',
- 'isc',
- 'unlicense',
- 'cc0',
- 'wtfpl',
- 'zlib',
- 'artistic',
- 'epl',
- 'cddl',
+ "mit",
+ "apache",
+ "gpl",
+ "bsd",
+ "lgpl",
+ "mpl",
+ "isc",
+ "unlicense",
+ "cc0",
+ "wtfpl",
+ "zlib",
+ "artistic",
+ "epl",
+ "cddl",
];
if (openSourceLicenses.some((lic) => license.includes(lic))) {
@@ -149,12 +139,12 @@ function isOpenSourceSolution(
}
// Check GitHub repository
- if (sourceInfo.github || sourceInfo.repository?.includes('github.com')) {
+ 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') {
+ if (sourceInfo.openSource === true || sourceInfo.type === "open-source") {
return true;
}
}
@@ -162,11 +152,11 @@ function isOpenSourceSolution(
// 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')
+ lowerTags.includes("open source") ||
+ lowerTags.includes("opensource") ||
+ lowerTags.includes("oss") ||
+ lowerTags.includes("free") ||
+ lowerTags.includes("community")
) {
return true;
}
@@ -174,14 +164,14 @@ function isOpenSourceSolution(
// 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',
+ "c15t",
+ "cookieconsent",
+ "klaro",
+ "tarteaucitron",
+ "osano",
+ "react-cookie-consent",
+ "vanilla-cookieconsent",
+ "baseline",
];
if (knownOpenSource.some((name) => appName.includes(name))) {
@@ -198,18 +188,18 @@ function parseTechStack(techStackJson: string): TechStackData {
return {
languages: techStack.languages || [],
frameworks: techStack.frameworks || [],
- bundler: techStack.bundler || 'unknown',
- bundleType: techStack.bundleType || 'unknown',
- packageManager: techStack.packageManager || 'unknown',
- typescript: techStack.typescript || false,
+ bundler: techStack.bundler || "unknown",
+ bundleType: techStack.bundleType || "unknown",
+ packageManager: techStack.packageManager || "unknown",
+ typescript: techStack.typescript,
};
} catch {
return {
languages: [],
frameworks: [],
- bundler: 'unknown',
- bundleType: 'unknown',
- packageManager: 'unknown',
+ bundler: "unknown",
+ bundleType: "unknown",
+ packageManager: "unknown",
typescript: false,
};
}
@@ -256,59 +246,159 @@ function calculatePerformanceScore(metrics: MetricsData): {
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;
+ // 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',
+ 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',
+ maxScore: 15,
+ 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;
+ // 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',
+ 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',
+ 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;
+ 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',
+ 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',
+ 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;
+ // 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',
+ 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',
+ maxScore: 15,
+ 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;
+ // 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',
+ metric: "Total Blocking Time",
value: formatTime(tbt),
score: tbtScore,
- maxScore: 15,
- reason: tbt <= 50 ? 'Excellent' : tbt <= 200 ? 'Good' : tbt <= 500 ? 'Fair' : 'Poor',
+ 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 };
@@ -332,15 +422,15 @@ function calculateBundleScore(
const bundleScore = metrics.isBundled ? 40 : metrics.isIIFE ? 20 : 10;
totalScore += bundleScore;
details.push({
- metric: 'Bundle Strategy',
- value: metrics.isBundled ? 'Bundled' : metrics.isIIFE ? 'IIFE' : 'Unknown',
+ metric: "Bundle Strategy",
+ value: metrics.isBundled ? "Bundled" : metrics.isIIFE ? "IIFE" : "Unknown",
score: bundleScore,
maxScore: 40,
reason: metrics.isBundled
- ? 'First-party bundled'
+ ? "First-party bundled"
: metrics.isIIFE
- ? 'External script'
- : 'Unknown strategy',
+ ? "External script"
+ : "Unknown strategy",
});
// Third-party dependency ratio (30 points)
@@ -348,33 +438,39 @@ function calculateBundleScore(
const thirdPartyRatio =
thirdPartyResources.length / Math.max(resourceData.length, 1);
const thirdPartyScore =
- thirdPartyRatio <= 0.1 ? 30 : thirdPartyRatio <= 0.3 ? 20 : thirdPartyRatio <= 0.5 ? 10 : 0;
+ thirdPartyRatio <= 0.1
+ ? 30
+ : thirdPartyRatio <= 0.3
+ ? 20
+ : thirdPartyRatio <= 0.5
+ ? 10
+ : 0;
totalScore += thirdPartyScore;
details.push({
- metric: 'Third-party Dependencies',
+ metric: "Third-party Dependencies",
value: `${thirdPartyResources.length}/${resourceData.length}`,
score: thirdPartyScore,
maxScore: 30,
reason:
thirdPartyRatio <= 0.1
- ? 'Minimal third-party'
+ ? "Minimal third-party"
: thirdPartyRatio <= 0.3
- ? 'Low third-party'
+ ? "Low third-party"
: thirdPartyRatio <= 0.5
- ? 'Moderate third-party'
- : 'Heavy third-party',
+ ? "Moderate third-party"
+ : "Heavy third-party",
});
// Modern bundler (20 points)
const modernBundlers = [
- 'webpack',
- 'vite',
- 'rollup',
- 'esbuild',
- 'turbopack',
- 'rspack',
- 'rslib',
- 'nextjs',
+ "webpack",
+ "vite",
+ "rollup",
+ "esbuild",
+ "turbopack",
+ "rspack",
+ "rslib",
+ "nextjs",
];
const bundlerScore =
techStack && modernBundlers.includes(techStack.bundler.toLowerCase())
@@ -382,25 +478,25 @@ function calculateBundleScore(
: 10;
totalScore += bundlerScore;
details.push({
- metric: 'Bundler',
- value: techStack?.bundler || 'Unknown',
+ metric: "Bundler",
+ value: techStack?.bundler || "Unknown",
score: bundlerScore,
maxScore: 20,
reason:
techStack && modernBundlers.includes(techStack.bundler.toLowerCase())
- ? 'Modern bundler'
- : 'Legacy/unknown bundler',
+ ? "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',
+ metric: "TypeScript",
+ value: techStack?.typescript ? "Yes" : "No",
score: tsScore,
maxScore: 10,
- reason: techStack?.typescript ? 'Type safety' : 'No type safety',
+ reason: techStack?.typescript ? "Type safety" : "No type safety",
});
return { score: totalScore, maxScore, details };
@@ -409,7 +505,7 @@ function calculateBundleScore(
// Calculate network impact score (out of 100)
function calculateNetworkScore(
metrics: MetricsData,
- resourceData: ResourceData[]
+ _resourceData: ResourceData[]
): {
score: number;
maxScore: number;
@@ -421,7 +517,9 @@ function calculateNetworkScore(
// Ensure metrics are finite numbers
const totalSize = Number.isFinite(metrics.totalSize) ? metrics.totalSize : 0;
- const thirdPartySize = Number.isFinite(metrics.thirdPartySize) ? metrics.thirdPartySize : 0;
+ const thirdPartySize = Number.isFinite(metrics.thirdPartySize)
+ ? metrics.thirdPartySize
+ : 0;
const resourceCount = Number.isFinite(metrics.resourceCount)
? metrics.resourceCount || 0
: 0;
@@ -432,83 +530,111 @@ function calculateNetworkScore(
// 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;
+ totalSizeKB <= 50
+ ? 35
+ : totalSizeKB <= 100
+ ? 25
+ : totalSizeKB <= 200
+ ? 15
+ : totalSizeKB <= 500
+ ? 10
+ : 5;
totalScore += sizeScore;
details.push({
- metric: 'Total Bundle Size',
+ metric: "Total Bundle Size",
value: formatBytes(totalSize),
score: sizeScore,
maxScore: 35,
reason:
totalSizeKB <= 50
- ? 'Ultra lightweight'
+ ? "Ultra lightweight"
: totalSizeKB <= 100
- ? 'Lightweight'
+ ? "Lightweight"
: totalSizeKB <= 200
- ? 'Moderate'
+ ? "Moderate"
: totalSizeKB <= 500
- ? 'Heavy'
- : 'Very heavy',
+ ? "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;
+ thirdPartySizeKB === 0
+ ? 25
+ : thirdPartySizeKB <= 50
+ ? 15
+ : thirdPartySizeKB <= 100
+ ? 10
+ : 5;
totalScore += thirdPartyNetworkScore;
details.push({
- metric: 'Third-party Size',
+ metric: "Third-party Size",
value: formatBytes(thirdPartySize),
score: thirdPartyNetworkScore,
maxScore: 25,
reason:
thirdPartySizeKB === 0
- ? 'Zero third-party'
+ ? "Zero third-party"
: thirdPartySizeKB <= 50
- ? 'Minimal third-party'
+ ? "Minimal third-party"
: thirdPartySizeKB <= 100
- ? 'Moderate third-party'
- : 'Heavy third-party',
+ ? "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;
+ resourceCount <= 3
+ ? 25
+ : resourceCount <= 5
+ ? 20
+ : resourceCount <= 10
+ ? 15
+ : resourceCount <= 15
+ ? 10
+ : 5;
totalScore += requestScore;
details.push({
- metric: 'Network Requests',
+ metric: "Network Requests",
value: resourceCount.toString(),
score: requestScore,
maxScore: 25,
reason:
resourceCount <= 3
- ? 'Minimal requests'
+ ? "Minimal requests"
: resourceCount <= 5
- ? 'Low requests'
+ ? "Low requests"
: resourceCount <= 10
- ? 'Moderate requests'
+ ? "Moderate requests"
: resourceCount <= 15
- ? 'Many requests'
- : 'Too many requests',
+ ? "Many requests"
+ : "Too many requests",
});
// Script load time (15 points)
const scriptScore =
- scriptLoadTime <= 50 ? 15 : scriptLoadTime <= 100 ? 10 : scriptLoadTime <= 200 ? 5 : 0;
+ scriptLoadTime <= 50
+ ? 15
+ : scriptLoadTime <= 100
+ ? 10
+ : scriptLoadTime <= 200
+ ? 5
+ : 0;
totalScore += scriptScore;
details.push({
- metric: 'Script Load Time',
+ metric: "Script Load Time",
value: formatTime(scriptLoadTime),
score: scriptScore,
maxScore: 15,
reason:
scriptLoadTime <= 50
- ? 'Very fast loading'
+ ? "Very fast loading"
: scriptLoadTime <= 100
- ? 'Fast loading'
+ ? "Fast loading"
: scriptLoadTime <= 200
- ? 'Moderate loading'
- : 'Slow loading',
+ ? "Moderate loading"
+ : "Slow loading",
});
return { score: totalScore, maxScore, details };
@@ -532,33 +658,36 @@ function calculateTransparencyScore(
const openSourceScore = isOpenSource ? 60 : 0;
totalScore += openSourceScore;
details.push({
- metric: 'Open Source',
- value: isOpenSource ? 'Yes' : 'No',
+ metric: "Open Source",
+ value: isOpenSource ? "Yes" : "No",
score: openSourceScore,
maxScore: 60,
- reason: isOpenSource ? 'Transparent & auditable' : 'Proprietary solution',
+ 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',
+ metric: "Company Info",
+ value: company ? "Available" : "Limited",
score: companyScore,
maxScore: 25,
- reason: company ? 'Clear attribution' : 'Limited transparency',
+ reason: company ? "Clear attribution" : "Limited transparency",
});
// Tech stack disclosure (15 points)
- const techScore = techStack && techStack.bundler !== 'unknown' ? 15 : 5;
+ const techScore = techStack && techStack.bundler !== "unknown" ? 15 : 5;
totalScore += techScore;
details.push({
- metric: 'Tech Stack',
- value: techStack ? 'Disclosed' : 'Unknown',
+ metric: "Tech Stack",
+ value: techStack ? "Disclosed" : "Unknown",
score: techScore,
maxScore: 15,
- reason: techStack && techStack.bundler !== 'unknown' ? 'Technical transparency' : 'Limited tech info',
+ reason:
+ techStack && techStack.bundler !== "unknown"
+ ? "Technical transparency"
+ : "Limited tech info",
});
return { score: totalScore, maxScore, details };
@@ -579,67 +708,96 @@ function calculateUXScore(
// 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;
+ 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',
+ metric: "Layout Stability",
value: cls.toFixed(3),
score: clsScore,
maxScore: 40,
reason:
cls <= 0.01
- ? 'No layout shifts'
+ ? "No layout shifts"
: cls <= 0.05
- ? 'Minimal shifts'
+ ? "Minimal shifts"
: cls <= 0.1
- ? 'Minor shifts'
+ ? "Minor shifts"
: cls <= 0.25
- ? 'Some shifts'
- : 'Significant shifts',
+ ? "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;
+ const renderScore =
+ renderTime <= 25
+ ? 35
+ : renderTime <= 50
+ ? 25
+ : renderTime <= 100
+ ? 15
+ : renderTime <= 200
+ ? 10
+ : 5;
totalScore += renderScore;
details.push({
- metric: 'Banner Render Time',
+ metric: "Banner Render Time",
value: formatTime(renderTime),
score: renderScore,
maxScore: 35,
reason:
renderTime <= 25
- ? 'Instant render'
+ ? "Instant render"
: renderTime <= 50
- ? 'Very fast render'
+ ? "Very fast render"
: renderTime <= 100
- ? 'Fast render'
+ ? "Fast render"
: renderTime <= 200
- ? 'Moderate render'
- : 'Slow render',
+ ? "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;
+ 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',
+ metric: "Viewport Coverage",
value: `${coverage.toFixed(1)}%`,
score: coverageScore,
maxScore: 25,
reason:
coverage <= 10
- ? 'Minimal intrusion'
+ ? "Minimal intrusion"
: coverage <= 20
- ? 'Low intrusion'
+ ? "Low intrusion"
: coverage <= 30
- ? 'Moderate intrusion'
+ ? "Moderate intrusion"
: coverage <= 50
- ? 'High intrusion'
- : 'Very intrusive',
+ ? "High intrusion"
+ : "Very intrusive",
});
return { score: totalScore, maxScore, details };
@@ -657,33 +815,33 @@ function generateInsights(
// Performance insights
if (categoryScores.performance >= 90) {
insights.push(
- 'Outstanding performance metrics across all Core Web Vitals.'
+ "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.'
+ "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.'
+ "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.'
+ "Consider bundling strategy to reduce third-party dependencies and improve performance."
);
}
// Open source insights
- if (!isOpenSource) {
+ if (isOpenSource) {
insights.push(
- 'Consider open source alternatives for better transparency and community support.'
+ "Open source solution provides transparency and community-driven development."
);
} else {
insights.push(
- 'Open source solution provides transparency and community-driven development.'
+ "Consider open source alternatives for better transparency and community support."
);
}
@@ -691,11 +849,43 @@ function generateInsights(
const thirdPartyResources = resourceData.filter((r) => r.isThirdParty);
if (thirdPartyResources.length === 0) {
insights.push(
- 'Zero third-party dependencies minimize privacy concerns and improve reliability.'
+ "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.'
+ "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."
);
}
@@ -714,31 +904,43 @@ function generateRecommendations(
if (categoryScores.performance < 80) {
if (metrics.fcp > 100) {
recommendations.push(
- 'Optimize First Contentful Paint by reducing render-blocking resources.'
+ "Optimize First Contentful Paint by reducing render-blocking resources."
);
}
if (metrics.lcp > 300) {
recommendations.push(
- 'Improve Largest Contentful Paint by optimizing critical resource loading.'
+ "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.'
+ "Reduce Cumulative Layout Shift by reserving space for dynamic content."
);
}
if (metrics.tbt > 50) {
recommendations.push(
- 'Reduce Total Blocking Time by optimizing JavaScript execution.'
+ "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.'
+ "Consider bundling cookie consent code with your main application bundle."
);
}
const thirdPartyRatio =
@@ -746,7 +948,7 @@ function generateRecommendations(
Math.max(resourceData.length, 1);
if (thirdPartyRatio > 0.3) {
recommendations.push(
- 'Reduce third-party dependencies to improve reliability and performance.'
+ "Reduce third-party dependencies to improve reliability and performance."
);
}
}
@@ -755,12 +957,12 @@ function generateRecommendations(
if (categoryScores.networkImpact < 70) {
if (metrics.totalSize > 100 * 1024) {
recommendations.push(
- 'Reduce bundle size through code splitting and tree shaking.'
+ "Reduce bundle size through code splitting and tree shaking."
);
}
if (metrics.thirdPartySize > 0) {
recommendations.push(
- 'Eliminate or reduce third-party resources for better performance.'
+ "Eliminate or reduce third-party resources for better performance."
);
}
}
@@ -769,27 +971,42 @@ function generateRecommendations(
}
// 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';
+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' {
+): "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';
+ if (percentage >= 90) {
+ return "excellent";
+ }
+ if (percentage >= 75) {
+ return "good";
+ }
+ if (percentage >= 60) {
+ return "fair";
+ }
+ return "poor";
}
// Main scoring function with CLI-compatible interface
+// biome-ignore lint/nursery/useMaxParams: legacy API with multiple metric groups
export function calculateScores(
metrics: {
fcp: number;
@@ -797,6 +1014,8 @@ export function calculateScores(
cls: number;
tbt: number;
tti: number;
+ timeToFirstByte?: number;
+ interactionToNextPaint?: number | null;
},
bundleMetrics: {
totalSize: number;
@@ -823,12 +1042,18 @@ export function calculateScores(
layoutShifts: number;
},
isBaseline = false,
- appData?: AppData
+ appData?: AppData,
+ networkInformation?: {
+ effectiveType: string;
+ downlink: number;
+ rtt: number;
+ saveData: boolean;
+ }
): BenchmarkScores {
if (isBaseline) {
return {
totalScore: 100,
- grade: 'Excellent',
+ grade: "Excellent",
categoryScores: {
performance: 100,
bundleStrategy: 100,
@@ -838,94 +1063,94 @@ export function calculateScores(
},
categories: [
{
- name: 'Performance',
+ name: "Performance",
score: 100,
maxScore: 100,
weight: 1,
details: [
{
- name: 'Core Web Vitals',
+ name: "Core Web Vitals",
score: 100,
maxScore: 100,
weight: 1,
- status: 'good',
- reason: 'Baseline measurement',
+ status: "good",
+ reason: "Baseline measurement",
},
],
- status: 'good',
- reason: 'Baseline measurement',
+ status: "good",
+ reason: "Baseline measurement",
},
{
- name: 'Bundle Strategy',
+ name: "Bundle Strategy",
score: 100,
maxScore: 100,
weight: 1,
details: [
{
- name: 'Bundle Size',
+ name: "Bundle Size",
score: 100,
maxScore: 100,
weight: 1,
- status: 'good',
- reason: 'Baseline measurement',
+ status: "good",
+ reason: "Baseline measurement",
},
],
- status: 'good',
- reason: 'Baseline measurement',
+ status: "good",
+ reason: "Baseline measurement",
},
{
- name: 'Network Impact',
+ name: "Network Impact",
score: 100,
maxScore: 100,
weight: 1,
details: [
{
- name: 'Network Requests',
+ name: "Network Requests",
score: 100,
maxScore: 100,
weight: 1,
- status: 'good',
- reason: 'Baseline measurement',
+ status: "good",
+ reason: "Baseline measurement",
},
],
- status: 'good',
- reason: 'Baseline measurement',
+ status: "good",
+ reason: "Baseline measurement",
},
{
- name: 'Transparency',
+ name: "Transparency",
score: 100,
maxScore: 100,
weight: 1,
details: [
{
- name: 'Cookie Banner',
+ name: "Cookie Banner",
score: 100,
maxScore: 100,
weight: 1,
- status: 'good',
- reason: 'Baseline measurement',
+ status: "good",
+ reason: "Baseline measurement",
},
],
- status: 'good',
- reason: 'Baseline measurement',
+ status: "good",
+ reason: "Baseline measurement",
},
{
- name: 'User Experience',
+ name: "User Experience",
score: 100,
maxScore: 100,
weight: 1,
details: [
{
- name: 'User Experience',
+ name: "User Experience",
score: 100,
maxScore: 100,
weight: 1,
- status: 'good',
- reason: 'Baseline measurement',
+ status: "good",
+ reason: "Baseline measurement",
},
],
- status: 'good',
- reason: 'Baseline measurement',
+ status: "good",
+ reason: "Baseline measurement",
},
],
insights: [],
@@ -935,10 +1160,10 @@ export function calculateScores(
// Create app data structure
const app: AppData = appData || {
- name: 'unknown',
+ name: "unknown",
baseline: false,
company: null,
- techStack: '{}',
+ techStack: "{}",
source: null,
tags: null,
};
@@ -958,13 +1183,22 @@ export function calculateScores(
scriptLoadTime: 0, // TODO: Calculate from timing data
isBundled: networkMetrics.thirdPartyRequests === 0,
isIIFE: networkMetrics.thirdPartyRequests > 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(networkMetrics.thirdPartyRequests).fill({ size: networkMetrics.thirdPartySize / Math.max(networkMetrics.thirdPartyRequests, 1), isThirdParty: true }),
+ ...new Array(networkMetrics.thirdPartyRequests).fill({
+ size:
+ networkMetrics.thirdPartySize /
+ Math.max(networkMetrics.thirdPartyRequests, 1),
+ isThirdParty: true,
+ }),
];
// Create benchmark data
@@ -978,12 +1212,16 @@ export function calculateScores(
const techStack = parseTechStack(app.techStack);
const company = parseCompany(app.company);
const sourceInfo = parseSource(app.source);
- const tags = app.tags || '';
+ 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 bundleScore = calculateBundleScore(
+ metricsData,
+ techStack,
+ resourceData
+ );
const networkScore = calculateNetworkScore(metricsData, resourceData);
const transparencyScore = calculateTransparencyScore(
isOpenSource,
@@ -1012,21 +1250,29 @@ export function calculateScores(
// 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),
+ 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',
+ name: "Performance",
score: performanceScore.score,
maxScore: performanceScore.maxScore,
weight: weights.performance,
- details: performanceScore.details.map(d => ({
+ details: performanceScore.details.map((d) => ({
name: d.metric,
score: d.score,
maxScore: d.maxScore,
@@ -1034,15 +1280,18 @@ export function calculateScores(
status: getCategoryStatus(d.score, d.maxScore),
reason: d.reason,
})),
- status: getCategoryStatus(performanceScore.score, performanceScore.maxScore),
+ status: getCategoryStatus(
+ performanceScore.score,
+ performanceScore.maxScore
+ ),
reason: `Performance score: ${performanceScore.score}/${performanceScore.maxScore}`,
},
{
- name: 'Bundle Strategy',
+ name: "Bundle Strategy",
score: bundleScore.score,
maxScore: bundleScore.maxScore,
weight: weights.bundleStrategy,
- details: bundleScore.details.map(d => ({
+ details: bundleScore.details.map((d) => ({
name: d.metric,
score: d.score,
maxScore: d.maxScore,
@@ -1054,11 +1303,11 @@ export function calculateScores(
reason: `Bundle strategy score: ${bundleScore.score}/${bundleScore.maxScore}`,
},
{
- name: 'Network Impact',
+ name: "Network Impact",
score: networkScore.score,
maxScore: networkScore.maxScore,
weight: weights.networkImpact,
- details: networkScore.details.map(d => ({
+ details: networkScore.details.map((d) => ({
name: d.metric,
score: d.score,
maxScore: d.maxScore,
@@ -1070,11 +1319,11 @@ export function calculateScores(
reason: `Network impact score: ${networkScore.score}/${networkScore.maxScore}`,
},
{
- name: 'Transparency',
+ name: "Transparency",
score: transparencyScore.score,
maxScore: transparencyScore.maxScore,
weight: weights.transparency,
- details: transparencyScore.details.map(d => ({
+ details: transparencyScore.details.map((d) => ({
name: d.metric,
score: d.score,
maxScore: d.maxScore,
@@ -1082,15 +1331,18 @@ export function calculateScores(
status: getCategoryStatus(d.score, d.maxScore),
reason: d.reason,
})),
- status: getCategoryStatus(transparencyScore.score, transparencyScore.maxScore),
+ status: getCategoryStatus(
+ transparencyScore.score,
+ transparencyScore.maxScore
+ ),
reason: `Transparency score: ${transparencyScore.score}/${transparencyScore.maxScore}`,
},
{
- name: 'User Experience',
+ name: "User Experience",
score: uxScore.score,
maxScore: uxScore.maxScore,
weight: weights.userExperience,
- details: uxScore.details.map(d => ({
+ details: uxScore.details.map((d) => ({
name: d.metric,
score: d.score,
maxScore: d.maxScore,
@@ -1127,19 +1379,16 @@ export function calculateScores(
}
// 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'] },
+ head: ["Category", "Score", "Status"],
+ style: { head: ["cyan"] },
});
// Add overall score
- overallTable.push([
- 'Overall',
- `${scores.totalScore}/100`,
- scores.grade,
- ]);
+ overallTable.push(["Overall", `${scores.totalScore}/100`, scores.grade]);
// Add category scores
for (const category of scores.categories) {
@@ -1150,13 +1399,10 @@ export function printScores(scores: BenchmarkScores): void {
]);
}
- 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'] },
+ head: ["Category", "Metric", "Score", "Reason"],
+ style: { head: ["cyan"] },
});
// Add detailed scores
@@ -1171,22 +1417,6 @@ export function printScores(scores: BenchmarkScores): void {
}
}
- 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}`);
- }
- }
+ // Insights and recommendations are included in the scores object
+ // They can be accessed via scores.insights and scores.recommendations
}
diff --git a/packages/cookiebench-cli/tsconfig.json b/packages/cookiebench-cli/tsconfig.json
new file mode 100644
index 0000000..2a2c414
--- /dev/null
+++ b/packages/cookiebench-cli/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/packages/runner/README.md b/packages/runner/README.md
new file mode 100644
index 0000000..bfcd144
--- /dev/null
+++ b/packages/runner/README.md
@@ -0,0 +1,150 @@
+# @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';
+
+const config = readConfig();
+const serverInfo = await buildAndServeNextApp('./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(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..0a05100
--- /dev/null
+++ b/packages/runner/package.json
@@ -0,0 +1,34 @@
+{
+ "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.56.1",
+ "playwright-performance-metrics": "^1.2.2"
+ },
+ "devDependencies": {
+ "@rsdoctor/rspack-plugin": "^1.3.6",
+ "@rslib/core": "^0.16.1",
+ "@types/node": "^24.9.2",
+ "typescript": "^5.9.3"
+ }
+}
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..dee86df
--- /dev/null
+++ b/packages/runner/src/benchmark-runner.ts
@@ -0,0 +1,474 @@
+import { exec } from "node:child_process";
+import { readFileSync, unlink, writeFileSync } from "node:fs";
+import { join } 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 Page } from "@playwright/test";
+import { PerformanceMetricsCollector } from "playwright-performance-metrics";
+import { PerformanceAggregator } from "./performance-aggregator";
+import type { BenchmarkDetails, BenchmarkResult } from "./types";
+
+const execAsync = promisify(exec);
+
+// Constants
+const WARMUP_ITERATIONS = 1; // Number of warmup runs before actual benchmarking
+const MAX_RETRIES = 2; // Maximum retries for failed iterations
+const ITERATION_TIMEOUT_MS = 120_000; // 2 minutes timeout per iteration
+const CLEANUP_DELAY_MS = 500; // Delay between iterations for cleanup
+const NAVIGATION_TIMEOUT_MS = 60_000; // 60 second timeout for navigation
+const RETRY_DELAY_MULTIPLIER = 2; // Multiplier for retry delay
+const MILLISECONDS_TO_SECONDS = 1000; // Conversion factor for time calculations
+
+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 saveTrace: boolean;
+ private readonly traceDir?: string;
+
+ constructor(
+ config: Config,
+ logger: Logger,
+ options?: { saveTrace?: boolean; 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);
+ this.perfumeCollector = new PerfumeCollector(logger);
+ this.performanceAggregator = new PerformanceAggregator(logger);
+ this.saveTrace = options?.saveTrace ?? false;
+ this.traceDir = options?.traceDir;
+ this.validateConfig();
+ }
+
+ /**
+ * Validate configuration before running benchmarks
+ */
+ private validateConfig(): void {
+ if (!this.config.iterations || this.config.iterations < 1) {
+ throw new Error(
+ `Invalid iterations: ${this.config.iterations}. Must be at least 1.`
+ );
+ }
+
+ 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."
+ );
+ }
+ }
+ }
+
+ /**
+ * Run a single benchmark iteration with timeout and error handling
+ */
+ 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
+ );
+
+ // 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);
+ await this.perfumeCollector.setupPerfume(page);
+
+ // Navigate to the page with timeout
+ 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)}`
+ );
+ }
+
+ // Wait for the specified element
+ await this.waitForElement(page);
+
+ // Wait for network to be idle
+ this.logger.debug("Waiting for network idle...");
+ await page.waitForLoadState("networkidle");
+
+ // Collect core web vitals from playwright-performance-metrics (primary source)
+ this.logger.debug("Collecting core web vitals...");
+ const coreWebVitals = await collector.collectMetrics(page, {
+ timeout: BENCHMARK_CONSTANTS.METRICS_TIMEOUT,
+ retryTimeout: BENCHMARK_CONSTANTS.METRICS_RETRY_TIMEOUT,
+ });
+
+ this.logger.debug("Core web vitals collected:", {
+ fcp: coreWebVitals.paint?.firstContentfulPaint,
+ lcp: coreWebVitals.largestContentfulPaint,
+ cls: coreWebVitals.cumulativeLayoutShift,
+ tbt: coreWebVitals.totalBlockingTime,
+ });
+
+ // Collect Perfume.js metrics (supplementary - TTFB, navigation timing, network info)
+ this.logger.debug("Collecting Perfume.js supplementary metrics...");
+ const perfumeMetrics = await this.perfumeCollector.collectMetrics(page);
+ this.logger.debug("Perfume.js metrics:", perfumeMetrics);
+
+ // Collect cookie banner specific metrics
+ const cookieBannerData =
+ await this.cookieBannerCollector.collectMetrics(page);
+ this.logger.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,
+ config: this.config,
+ perfumeMetrics,
+ });
+
+ // Log results
+ this.performanceAggregator.logResults(
+ finalMetrics,
+ cookieBannerMetrics,
+ this.config
+ );
+
+ // Cleanup
+ await collector.cleanup();
+ this.networkMonitor.reset();
+
+ return finalMetrics;
+ }
+
+ /**
+ * Run a single benchmark iteration with retry logic
+ */
+ private async runSingleBenchmarkWithRetry(
+ page: Page,
+ url: string,
+ isWarmup: boolean
+ ): Promise {
+ let lastError: Error | null = null;
+
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
+ try {
+ if (attempt > 0) {
+ this.logger.warn(
+ `Retrying iteration (attempt ${attempt + 1}/${MAX_RETRIES + 1})...`
+ );
+ }
+
+ return await Promise.race([
+ this.runSingleBenchmark(page, url, isWarmup),
+ new Promise((_, reject) =>
+ setTimeout(
+ () => reject(new Error("Iteration timeout")),
+ ITERATION_TIMEOUT_MS
+ )
+ ),
+ ]);
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error(String(error));
+ this.logger.debug(
+ `Iteration attempt ${attempt + 1} failed:`,
+ lastError.message
+ );
+
+ if (attempt < MAX_RETRIES) {
+ // Wait before retry
+ const retryDelay = CLEANUP_DELAY_MS * RETRY_DELAY_MULTIPLIER;
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
+ }
+ }
+ }
+
+ throw new Error(
+ `Failed to complete benchmark after ${MAX_RETRIES + 1} attempts: ${lastError?.message}`
+ );
+ }
+
+ /**
+ * Cleanup resources between iterations
+ */
+ private async cleanupBetweenIterations(): Promise {
+ // Small delay to allow cleanup
+ await new Promise((resolve) => setTimeout(resolve, CLEANUP_DELAY_MS));
+
+ // Force garbage collection if available (Node.js with --expose-gc)
+ if (global.gc) {
+ global.gc();
+ }
+ }
+
+ /**
+ * Run multiple benchmark iterations with warmup and error handling
+ */
+ 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[] = [];
+ const startTime = Date.now();
+
+ try {
+ // Warmup runs (discarded, used to stabilize the environment)
+ if (WARMUP_ITERATIONS > 0) {
+ this.logger.info(
+ `Running ${WARMUP_ITERATIONS} warmup iteration(s) to stabilize environment...`
+ );
+ const warmupContext = await browser.newContext();
+ const warmupPage = await warmupContext.newPage();
+
+ for (let i = 0; i < WARMUP_ITERATIONS; i += 1) {
+ try {
+ await this.runSingleBenchmark(
+ warmupPage,
+ `${serverUrl}?t=${Date.now()}&warmup=true`,
+ 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...");
+ }
+
+ // Actual benchmark iterations
+ 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;
+
+ this.logger.info(
+ `Running iteration ${i + 1}/${this.config.iterations}${estimatedRemaining > 0 ? ` (est. ${Math.round(estimatedRemaining)}s remaining)` : ""}...`
+ );
+
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ try {
+ if (this.saveTrace) {
+ this.logger.info(
+ `📊 Starting trace capture for iteration ${i + 1}...`
+ );
+ await context.tracing.start({
+ screenshots: true,
+ snapshots: true,
+ });
+ }
+
+ const result = await this.runSingleBenchmarkWithRetry(
+ page,
+ // Add a timestamp to the URL to avoid caching
+ `${serverUrl}?t=${Date.now()}`,
+ false
+ );
+ results.push(result);
+
+ // Save trace if enabled (must be done before closing context)
+ if (this.saveTrace) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const traceZipPath = this.traceDir
+ ? join(this.traceDir, `Trace-${timestamp}.zip`)
+ : join(
+ process.cwd(),
+ `trace-${this.config.name}-iteration-${i + 1}.zip`
+ );
+ const traceJsonPath = this.traceDir
+ ? join(this.traceDir, `Trace-${timestamp}.json`)
+ : join(
+ process.cwd(),
+ `trace-${this.config.name}-iteration-${i + 1}.json`
+ );
+
+ // Playwright saves traces as ZIP files
+ await context.tracing.stop({ path: traceZipPath });
+
+ // Extract the trace.trace file from the ZIP and save as JSON
+ try {
+ // Extract trace.trace from the ZIP
+ const tempDir = this.traceDir || process.cwd();
+ await execAsync(
+ `unzip -o "${traceZipPath}" -d "${tempDir}" trace.trace 2>/dev/null`
+ );
+
+ // Read the extracted trace.trace file and write it as JSON
+ const traceFilePath = join(tempDir, "trace.trace");
+ const traceContent = readFileSync(traceFilePath, "utf-8");
+ writeFileSync(traceJsonPath, traceContent, "utf-8");
+ // Clean up the temporary trace.trace file
+ unlink(traceFilePath, () => {
+ // Ignore errors during cleanup
+ });
+ // Clean up the ZIP file
+ unlink(traceZipPath, () => {
+ // Ignore errors during cleanup
+ });
+ this.logger.info(`📊 Trace saved to: ${traceJsonPath}`);
+ } catch {
+ // If extraction failed, keep the ZIP file
+ this.logger.warn(
+ `Failed to extract trace JSON, keeping ZIP file: ${traceZipPath}`
+ );
+ this.logger.info(`📊 Trace saved to: ${traceZipPath}`);
+ }
+ }
+
+ 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 with remaining iterations instead of failing completely
+ } finally {
+ await context.close();
+ await this.cleanupBetweenIterations();
+ }
+ }
+
+ if (results.length === 0) {
+ throw new Error(
+ "All benchmark iterations failed. Check logs for details."
+ );
+ }
+
+ if (results.length < this.config.iterations) {
+ this.logger.warn(
+ `Only ${results.length}/${this.config.iterations} iterations completed successfully. Results may be less reliable.`
+ );
+ }
+ } finally {
+ 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);
+
+ // Log statistical summary after all iterations
+ 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,
+ };
+ }
+
+ /**
+ * Wait for the specified element based on config
+ * Falls back to first cookie banner selector if no explicit wait condition is set
+ */
+ 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 {
+ // Fallback: use first cookie banner selector if available
+ 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);
+ }
+ // If no selector found, continue without waiting (will rely on networkidle)
+ }
+ }
+}
diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts
new file mode 100644
index 0000000..367833f
--- /dev/null
+++ b/packages/runner/src/index.ts
@@ -0,0 +1,31 @@
+// 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,
+ BenchmarkResult,
+ 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..be5cbde
--- /dev/null
+++ b/packages/runner/src/performance-aggregator.ts
@@ -0,0 +1,463 @@
+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 {
+ calculateCoefficientOfVariation,
+ calculateStatistics,
+ calculateTrimmedMean,
+ isStable,
+} from "./statistics";
+import type { BenchmarkDetails, BenchmarkResult } from "./types";
+
+const VARIABILITY_WARNING_THRESHOLD = 20; // Coefficient of variation threshold for warnings
+const STABILITY_THRESHOLD = 15; // Coefficient of variation threshold for stability checks
+const TRIM_PERCENT = 10; // Percentage to trim from each end for trimmed mean
+
+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;
+ }
+ /**
+ * 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
+ ) + TTI_BUFFER_MS
+ ); // Add buffer for true interactivity
+ }
+
+ /**
+ * Build cookie banner timing metrics.
+ *
+ * Uses bannerVisibilityTime (opacity-based, user-perceived) as the primary
+ * visibility metric for scoring. This accounts for CSS animations and ensures
+ * scores reflect actual user experience. Falls back to interactiveTime if
+ * visibilityTime is not available.
+ *
+ * @param cookieBannerData Collected banner metrics from browser
+ * @param config Benchmark configuration
+ * @returns Cookie banner timing object with render, visibility, and interactive times
+ * @see METHODOLOGY.md for detailed explanation of visibility time vs render time
+ */
+ private buildCookieBannerTiming(
+ cookieBannerData: CookieBannerData | null,
+ config: Config
+ ) {
+ return {
+ 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",
+ /**
+ * Primary visibility metric: Uses bannerVisibilityTime (opacity > 0.5, user-perceived)
+ * which accounts for CSS transitions. Falls back to interactiveTime if visibilityTime
+ * not available. This metric is used for scoring to ensure results reflect actual
+ * user experience rather than just technical render time.
+ */
+ visibilityTime:
+ cookieBannerData?.bannerVisibilityTime ||
+ cookieBannerData?.bannerInteractiveTime ||
+ 0,
+ viewportCoverage: cookieBannerData?.viewportCoverage || 0,
+ };
+ }
+
+ /**
+ * Build third party metrics
+ */
+ private buildThirdPartyMetrics(
+ networkImpact: { totalImpact: number; totalDownloadTime: number },
+ networkMetrics: NetworkMetrics,
+ config: Config
+ ) {
+ return {
+ 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,
+ },
+ };
+ }
+
+ /**
+ * Build main thread blocking metrics
+ */
+ 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,
+ };
+ }
+
+ /**
+ * Merge all collected metrics into final benchmark details
+ */
+ 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);
+
+ 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,
+ timeToFirstByte: perfumeMetrics?.timeToFirstByte ?? 0,
+ 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: this.buildThirdPartyMetrics(
+ networkImpact,
+ networkMetrics,
+ config
+ ),
+ 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,
+ 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 using Mitata statistics
+ * Uses trimmed mean (10% trim) for robustness against outliers
+ * Logs stability warnings for metrics with high variability
+ */
+ calculateAverages(results: BenchmarkDetails[]): BenchmarkResult["average"] {
+ if (results.length === 0) {
+ throw new Error("Cannot calculate averages from empty results array");
+ }
+
+ // Extract metric arrays for statistical analysis
+ 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 ttfbValues = results.map((r) => r.timing.timeToFirstByte || 0);
+ const fidValues = results
+ .map((r) => r.timing.firstInputDelay || 0)
+ .filter((v) => v > 0);
+ const inpValues = results
+ .map((r) => r.timing.interactionToNextPaint || 0)
+ .filter((v) => v > 0);
+ const clsValues = results.map((r) => r.timing.cumulativeLayoutShift);
+ 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 domContentLoadedValues = results.map(
+ (r) => r.timing.domContentLoaded
+ );
+ const loadValues = results.map((r) => r.timing.load);
+
+ // Use trimmed mean for better robustness against outliers (10% trim)
+ // Log stability warnings for critical metrics
+ if (!isStable(fcpValues, VARIABILITY_WARNING_THRESHOLD)) {
+ this.logger.warn(
+ `First Contentful Paint shows high variability (CV: ${calculateCoefficientOfVariation(fcpValues).toFixed(1)}%)`
+ );
+ }
+ if (!isStable(lcpValues, VARIABILITY_WARNING_THRESHOLD)) {
+ this.logger.warn(
+ `Largest Contentful Paint shows high variability (CV: ${calculateCoefficientOfVariation(lcpValues).toFixed(1)}%)`
+ );
+ }
+ if (!isStable(ttiValues, VARIABILITY_WARNING_THRESHOLD)) {
+ this.logger.warn(
+ `Time to Interactive shows high variability (CV: ${calculateCoefficientOfVariation(ttiValues).toFixed(1)}%)`
+ );
+ }
+
+ return {
+ firstContentfulPaint: calculateTrimmedMean(fcpValues, TRIM_PERCENT),
+ largestContentfulPaint: calculateTrimmedMean(lcpValues, TRIM_PERCENT),
+ timeToInteractive: calculateTrimmedMean(ttiValues, TRIM_PERCENT),
+ totalBlockingTime: calculateTrimmedMean(tbtValues, TRIM_PERCENT),
+ speedIndex: 0, // Default value
+ timeToFirstByte: calculateTrimmedMean(ttfbValues, TRIM_PERCENT),
+ firstInputDelay:
+ fidValues.length > 0
+ ? calculateTrimmedMean(fidValues, TRIM_PERCENT)
+ : 0,
+ interactionToNextPaint:
+ inpValues.length > 0
+ ? calculateTrimmedMean(inpValues, TRIM_PERCENT)
+ : 0,
+ cumulativeLayoutShift: calculateTrimmedMean(clsValues, TRIM_PERCENT),
+ domSize: 0, // Default value
+ 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: 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: calculateTrimmedMean(fcpValues, TRIM_PERCENT),
+ domContentLoaded: calculateTrimmedMean(
+ domContentLoadedValues,
+ TRIM_PERCENT
+ ),
+ load: calculateTrimmedMean(loadValues, TRIM_PERCENT),
+ },
+ };
+ }
+
+ /**
+ * Get statistical summary for a set of benchmark results
+ */
+ getStatisticalSummary(results: BenchmarkDetails[]): {
+ fcp: ReturnType;
+ lcp: ReturnType;
+ tti: ReturnType;
+ tbt: ReturnType;
+ } {
+ 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);
+
+ return {
+ fcp: calculateStatistics(fcpValues),
+ lcp: calculateStatistics(lcpValues),
+ tti: calculateStatistics(ttiValues),
+ tbt: calculateStatistics(tbtValues),
+ };
+ }
+
+ /**
+ * Log comprehensive benchmark results with statistical information
+ */
+ 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,
+ bannerRenderTime:
+ finalMetrics.timing.cookieBanner.renderEnd -
+ finalMetrics.timing.cookieBanner.renderStart,
+ bannerLayoutShift: finalMetrics.timing.cookieBanner.layoutShift,
+ bannerNetworkImpact: finalMetrics.thirdParty.totalImpact,
+ bundleStrategy,
+ isBundled: cookieBannerMetrics.isBundled,
+ isIIFE: cookieBannerMetrics.isIIFE,
+ configBundleType: config.techStack?.bundleType,
+ });
+ }
+
+ /**
+ * Log statistical summary for multiple benchmark runs
+ */
+ logStatisticalSummary(results: BenchmarkDetails[]): void {
+ if (results.length === 0) {
+ return;
+ }
+
+ const summary = this.getStatisticalSummary(results);
+
+ this.logger.info("📊 Statistical Summary:");
+ this.logger.info(
+ ` FCP: ${summary.fcp.mean.toFixed(0)}ms (median: ${summary.fcp.median.toFixed(0)}ms, stddev: ${summary.fcp.stddev.toFixed(0)}ms)`
+ );
+ this.logger.info(
+ ` LCP: ${summary.lcp.mean.toFixed(0)}ms (median: ${summary.lcp.median.toFixed(0)}ms, stddev: ${summary.lcp.stddev.toFixed(0)}ms)`
+ );
+ this.logger.info(
+ ` TTI: ${summary.tti.mean.toFixed(0)}ms (median: ${summary.tti.median.toFixed(0)}ms, stddev: ${summary.tti.stddev.toFixed(0)}ms)`
+ );
+ this.logger.info(
+ ` TBT: ${summary.tbt.mean.toFixed(0)}ms (median: ${summary.tbt.median.toFixed(0)}ms, stddev: ${summary.tbt.stddev.toFixed(0)}ms)`
+ );
+
+ // Log stability indicators
+ const fcpValues = results.map((r) => r.timing.firstContentfulPaint);
+ const lcpValues = results.map((r) => r.timing.largestContentfulPaint);
+ const ttiValues = results.map((r) => r.timing.timeToInteractive);
+
+ if (isStable(fcpValues, STABILITY_THRESHOLD)) {
+ this.logger.info(" ✓ FCP is stable");
+ }
+ if (isStable(lcpValues, STABILITY_THRESHOLD)) {
+ this.logger.info(" ✓ LCP is stable");
+ }
+ if (isStable(ttiValues, STABILITY_THRESHOLD)) {
+ 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..d222689
--- /dev/null
+++ b/packages/runner/src/server.ts
@@ -0,0 +1,77 @@
+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) => {
+ buildProcess.on("close", (code) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error(`Build failed with code ${code}`));
+ }
+ });
+ });
+
+ // 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",
+ "--",
+ "--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) {
+ logger.success("Server is ready!");
+ return { serverProcess, url };
+ }
+ } catch {
+ // Ignore error and retry
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, ONE_SECOND));
+ retries += 1;
+ }
+
+ 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..3c68d5c
--- /dev/null
+++ b/packages/runner/src/statistics.ts
@@ -0,0 +1,138 @@
+// Constants for statistics calculations
+const PERCENTILE_95 = 95;
+const PERCENTILE_99 = 99;
+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[]): {
+ mean: number;
+ median: number;
+ stddev: number;
+ min: number;
+ max: number;
+ p95: number;
+ p99: number;
+} {
+ if (values.length === 0) {
+ return {
+ mean: 0,
+ median: 0,
+ stddev: 0,
+ min: 0,
+ max: 0,
+ p95: 0,
+ p99: 0,
+ };
+ }
+
+ const sorted = [...values].sort((a, b) => a - b);
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
+ const variance =
+ values.reduce((acc, val) => acc + (val - mean) ** 2, 0) / values.length;
+ const stddev = Math.sqrt(variance);
+ const median = getMedian(sorted);
+ const p95 = getPercentile(sorted, PERCENTILE_95);
+ const p99 = getPercentile(sorted, PERCENTILE_99);
+
+ const lastIndex = sorted.length - 1;
+ const maxValue = lastIndex >= 0 ? sorted[lastIndex] : 0;
+ return {
+ mean,
+ median,
+ stddev,
+ min: sorted[0],
+ max: maxValue,
+ p95,
+ p99,
+ };
+}
+
+/**
+ * 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 trimCount = Math.floor(
+ (values.length * trimPercent) / PERCENTAGE_CONVERSION
+ );
+ 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.reduce((acc, val) => acc + (val - mean) ** 2, 0) / values.length;
+ const stddev = Math.sqrt(variance);
+
+ if (mean === 0) {
+ return 0;
+ }
+
+ 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..30210d9
--- /dev/null
+++ b/packages/runner/src/types.ts
@@ -0,0 +1,294 @@
+import type { ChildProcess } from "node:child_process";
+
+// Re-export common types from benchmark package
+export type {
+ BundleStrategy,
+ Config,
+ CookieBannerConfig,
+ CookieBannerData,
+ CookieBannerMetrics,
+ CoreWebVitals,
+ NetworkMetrics,
+ NetworkRequest,
+ PerfumeMetrics,
+ ResourceTimingData,
+} from "@consentio/benchmark";
+
+// Server types
+export type ServerInfo = {
+ serverProcess: ChildProcess;
+ url: string;
+};
+
+// Benchmark result types
+export type 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;
+ // Enhanced metrics from Perfume.js
+ 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 | 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;
+ }>;
+ };
+ dom?: {
+ size?: number;
+ };
+ cookieBanner: EnhancedCookieBannerTiming;
+ thirdParty: ThirdPartyMetrics;
+};
+
+export type BenchmarkResult = {
+ name: string;
+ baseline: boolean;
+ 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[];
+ details: BenchmarkDetails[];
+ average: {
+ firstContentfulPaint: number;
+ largestContentfulPaint: number;
+ timeToInteractive: number;
+ totalBlockingTime: number;
+ speedIndex: number;
+ timeToFirstByte: number;
+ firstInputDelay: number;
+ interactionToNextPaint: 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[];
+ };
+};
+
+type EnhancedCookieBannerTiming = {
+ detected: boolean;
+ selector: string | null;
+ serviceName: string;
+ visibilityTime: number | null;
+ viewportCoverage: number;
+};
+
+type ThirdPartyMetrics = {
+ cookieServices: {
+ hosts: string[];
+ totalSize: number;
+ resourceCount: number;
+ dnsLookupTime: number;
+ connectionTime: number;
+ downloadTime: number;
+ };
+ totalImpact: number;
+};
diff --git a/packages/runner/src/utils.ts b/packages/runner/src/utils.ts
new file mode 100644
index 0000000..6b0f42c
--- /dev/null
+++ b/packages/runner/src/utils.ts
@@ -0,0 +1,57 @@
+import { readFileSync } from "node:fs";
+import { 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[];
+}> {
+ 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/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..7ccf2be
--- /dev/null
+++ b/packages/shared/README.md
@@ -0,0 +1,98 @@
+# @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**: `BYTES_TO_KB` (1024), `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
+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..a2163e9
--- /dev/null
+++ b/packages/shared/package.json
@@ -0,0 +1,29 @@
+{
+ "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 ."
+ },
+ "dependencies": {},
+ "devDependencies": {
+ "@cookiebench/ts-config": "workspace:*",
+ "@rsdoctor/rspack-plugin": "^1.3.6",
+ "@rslib/core": "^0.16.1",
+ "@types/node": "^24.9.2",
+ "typescript": "^5.9.3"
+ }
+}
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..fd73f95
--- /dev/null
+++ b/packages/shared/src/constants.ts
@@ -0,0 +1,14 @@
+// Time constants
+export const ONE_SECOND = 1000;
+export const HALF_SECOND = 500;
+
+// Size constants (bytes to kilobytes)
+export const BYTES_TO_KB = 1024;
+export const KILOBYTE = 1024;
+
+// Percentage constants
+export const PERCENTAGE_MULTIPLIER = 100;
+export const PERCENTAGE_DIVISOR = 100;
+
+// Common thresholds
+export const TTI_BUFFER_MS = 1000; // Buffer for true interactivity
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
new file mode 100644
index 0000000..9765f12
--- /dev/null
+++ b/packages/shared/src/index.ts
@@ -0,0 +1,22 @@
+/** biome-ignore-all lint/performance/noBarrelFile: this is a barrel file */
+
+// Constants
+export {
+ BYTES_TO_KB,
+ 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..eb038db
--- /dev/null
+++ b/packages/shared/src/utils/config.ts
@@ -0,0 +1,26 @@
+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
+ * @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 path = configPath || join(process.cwd(), "config.json");
+ const configContent = readFileSync(path, "utf-8");
+ return JSON.parse(configContent) as T;
+ } catch (error) {
+ // biome-ignore lint/suspicious/noConsole: console error is needed for debugging
+ console.error("Failed to read config.json:", error);
+ return null;
+ }
+}
diff --git a/packages/shared/src/utils/conversion.ts b/packages/shared/src/utils/conversion.ts
new file mode 100644
index 0000000..fb61666
--- /dev/null
+++ b/packages/shared/src/utils/conversion.ts
@@ -0,0 +1,43 @@
+import { BYTES_TO_KB, 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 / BYTES_TO_KB;
+}
+
+/**
+ * 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 (bytes === 0) {
+ return "0 bytes";
+ }
+ const k = KILOBYTE;
+ 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]}`;
+}
+
+/**
+ * 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..99d59ad
--- /dev/null
+++ b/packages/shared/src/utils/package-manager.ts
@@ -0,0 +1,37 @@
+/**
+ * Detect and return the available package manager
+ * @returns Package manager command and args
+ */
+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"] };
+ }
+ }
+ }
+ // 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 d9efcae..9519fd7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,24 +11,24 @@ importers:
'@biomejs/biome':
specifier: 2.3.2
version: 2.3.2
- '@c15t/translations':
- specifier: ^1.7.0
- version: 1.7.0
- '@cookiebench/cli':
+ '@consentio/benchmark':
specifier: workspace:*
- version: link:packages/cli
+ version: link:packages/benchmark
+ '@consentio/runner':
+ specifier: workspace:*
+ version: link:packages/runner
'@playwright/test':
specifier: ^1.56.1
version: 1.56.1
cli-table3:
specifier: ^0.6.5
version: 0.6.5
+ cookiebench:
+ specifier: workspace:*
+ version: link:packages/cookiebench-cli
drizzle-kit:
specifier: ^0.31.6
version: 0.31.6
- p-limit:
- specifier: ^7.2.0
- version: 7.2.0
pretty-ms:
specifier: ^9.3.0
version: 9.3.0
@@ -39,7 +39,7 @@ importers:
specifier: 5.9.3
version: 5.9.3
ultracite:
- specifier: ^6.0.5
+ 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:
@@ -57,9 +57,6 @@ importers:
'@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
@@ -76,7 +73,7 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- benchmarks/with-c15t-nextjs:
+ benchmarks/c15t-nextjs:
dependencies:
'@c15t/nextjs':
specifier: 1.7.1
@@ -94,9 +91,6 @@ importers:
'@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
@@ -113,14 +107,11 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- benchmarks/with-c15t-react:
+ benchmarks/c15t-react:
dependencies:
'@c15t/react':
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.2(@types/react@19.2.2))(@types/react@19.2.2)(better-sqlite3@12.4.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typeorm@0.3.27(better-sqlite3@12.4.1)(reflect-metadata@0.2.2))(use-sync-external-store@1.6.0(react@19.2.0))(ws@8.18.3)
- '@c15t/translations':
- specifier: ^1.7.0
- version: 1.7.0
next:
specifier: 16.0.1
version: 16.0.1(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -134,9 +125,6 @@ importers:
'@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
@@ -153,7 +141,7 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- benchmarks/with-cookie-control:
+ benchmarks/cookie-control:
dependencies:
next:
specifier: 16.0.1
@@ -168,9 +156,6 @@ importers:
'@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
@@ -187,7 +172,7 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- benchmarks/with-cookie-yes:
+ benchmarks/cookie-yes:
dependencies:
next:
specifier: 16.0.1
@@ -202,9 +187,6 @@ importers:
'@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
@@ -221,7 +203,7 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- benchmarks/with-didomi:
+ benchmarks/didomi:
dependencies:
'@didomi/react':
specifier: ^1.8.8
@@ -255,7 +237,7 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- benchmarks/with-enzuzo:
+ benchmarks/enzuzo:
dependencies:
next:
specifier: 16.0.1
@@ -270,9 +252,6 @@ importers:
'@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
@@ -289,7 +268,7 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- benchmarks/with-iubenda:
+ benchmarks/iubenda:
dependencies:
next:
specifier: 16.0.1
@@ -304,9 +283,6 @@ importers:
'@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
@@ -323,7 +299,7 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- benchmarks/with-ketch:
+ benchmarks/ketch:
dependencies:
next:
specifier: 16.0.1
@@ -354,7 +330,7 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- benchmarks/with-onetrust:
+ benchmarks/onetrust:
dependencies:
next:
specifier: 16.0.1
@@ -369,9 +345,6 @@ importers:
'@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
@@ -388,7 +361,7 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- benchmarks/with-osano:
+ benchmarks/osano:
dependencies:
next:
specifier: 16.0.1
@@ -403,9 +376,6 @@ importers:
'@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
@@ -422,7 +392,7 @@ importers:
specifier: ^5.9.3
version: 5.9.3
- benchmarks/with-usercentrics:
+ benchmarks/usercentrics:
dependencies:
next:
specifier: 16.0.1
@@ -437,9 +407,6 @@ importers:
'@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
@@ -456,28 +423,71 @@ importers:
specifier: ^5.9.3
version: 5.9.3
+ packages/benchmark:
+ dependencies:
+ '@c15t/logger':
+ specifier: 1.0.0
+ version: 1.0.0
+ '@consentio/shared':
+ specifier: workspace:*
+ version: link:../shared
+ '@playwright/test':
+ specifier: ^1.56.1
+ version: 1.56.1
+ perfume.js:
+ specifier: ^9.4.0
+ version: 9.4.0
+ playwright-performance-metrics:
+ specifier: ^1.2.2
+ version: 1.2.2(@playwright/test@1.56.1)
+ devDependencies:
+ '@rsdoctor/rspack-plugin':
+ specifier: ^1.3.6
+ version: 1.3.6(@rsbuild/core@1.6.0-beta.1)(@rspack/core@1.5.8(@swc/helpers@0.5.17))
+ '@rslib/core':
+ specifier: ^0.16.1
+ version: 0.16.1(typescript@5.9.3)
+ '@types/node':
+ specifier: ^24.9.2
+ version: 24.9.2
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
packages/benchmark-schema: {}
- packages/cli:
+ packages/cookiebench-cli:
dependencies:
+ '@c15t/logger':
+ specifier: ^1.0.0
+ version: 1.0.0
'@clack/prompts':
- specifier: 1.0.0-alpha.6
+ specifier: ^1.0.0-alpha.0
version: 1.0.0-alpha.6
- '@playwright/test':
- specifier: ^1.56.1
- version: 1.56.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
+ specifier: ^0.6.3
version: 0.6.5
dotenv:
specifier: ^17.2.3
version: 17.2.3
- package-manager-detector:
- specifier: ^1.5.0
- version: 1.5.0
+ figlet:
+ specifier: ^1.9.3
+ version: 1.9.3
picocolors:
- specifier: ^1.1.1
+ specifier: ^1.0.0
version: 1.1.1
+ pretty-ms:
+ specifier: ^9.3.0
+ version: 9.3.0
devDependencies:
'@rsdoctor/rspack-plugin':
specifier: ^1.3.6
@@ -485,12 +495,61 @@ importers:
'@rslib/core':
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.9.2
version: 24.9.2
+ typescript:
+ specifier: ^5.9.3
+ 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.56.1
+ version: 1.56.1
playwright-performance-metrics:
specifier: ^1.2.2
version: 1.2.2(@playwright/test@1.56.1)
+ devDependencies:
+ '@rsdoctor/rspack-plugin':
+ specifier: ^1.3.6
+ version: 1.3.6(@rsbuild/core@1.6.0-beta.1)(@rspack/core@1.5.8(@swc/helpers@0.5.17))
+ '@rslib/core':
+ specifier: ^0.16.1
+ version: 0.16.1(typescript@5.9.3)
+ '@types/node':
+ specifier: ^24.9.2
+ version: 24.9.2
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
+ packages/shared:
+ devDependencies:
+ '@cookiebench/ts-config':
+ specifier: workspace:*
+ version: link:../typescript-config
+ '@rsdoctor/rspack-plugin':
+ specifier: ^1.3.6
+ version: 1.3.6(@rsbuild/core@1.6.0-beta.1)(@rspack/core@1.5.8(@swc/helpers@0.5.17))
+ '@rslib/core':
+ specifier: ^0.16.1
+ version: 0.16.1(typescript@5.9.3)
+ '@types/node':
+ specifier: ^24.9.2
+ version: 24.9.2
typescript:
specifier: ^5.9.3
version: 5.9.3
@@ -1971,6 +2030,9 @@ packages:
'@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
+ '@types/figlet@1.7.0':
+ resolution: {integrity: sha512-KwrT7p/8Eo3Op/HBSIwGXOsTZKYiM9NpWRBJ5sVjWP/SmlS+oxxRvJht/FNAtliJvja44N3ul1yATgohnVBV0Q==}
+
'@types/node@24.9.2':
resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==}
@@ -2448,6 +2510,11 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
+ figlet@1.9.3:
+ resolution: {integrity: sha512-majPgOpVtrZN1iyNGbsUP6bOtZ6eaJgg5HHh0vFvm5DJhh8dc+FJpOC4GABvMZ/A7XHAJUuJujhgUY/2jPWgMA==}
+ engines: {node: '>= 17.0.0'}
+ hasBin: true
+
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
@@ -2756,16 +2823,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.5.0:
- resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==}
-
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
@@ -2787,6 +2847,10 @@ packages:
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==}
@@ -3235,6 +3299,9 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
+ web-vitals@3.5.2:
+ resolution: {integrity: sha512-c0rhqNcHXRkY/ogGDJQxZ9Im9D19hDihbzSQJrsioex+KnFgmMzBiy57Z1EjkhX/+OjyBpclDCzz2ITtjokFmg==}
+
which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
@@ -3294,10 +3361,6 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
- yocto-queue@1.2.1:
- resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
- engines: {node: '>=12.20'}
-
zod@4.1.12:
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
@@ -4899,6 +4962,8 @@ snapshots:
'@types/estree@1.0.5': {}
+ '@types/figlet@1.7.0': {}
+
'@types/node@24.9.2':
dependencies:
undici-types: 7.16.0
@@ -5357,6 +5422,10 @@ snapshots:
web-streams-polyfill: 3.3.3
optional: true
+ figlet@1.9.3:
+ dependencies:
+ commander: 14.0.2
+
file-uri-to-path@1.0.0:
optional: true
@@ -5662,14 +5731,8 @@ snapshots:
openapi-types@12.1.3: {}
- p-limit@7.2.0:
- dependencies:
- yocto-queue: 1.2.1
-
package-json-from-dist@1.0.1: {}
- package-manager-detector@1.5.0: {}
-
parse-ms@4.0.0: {}
path-browserify@1.0.1: {}
@@ -5685,6 +5748,10 @@ snapshots:
pathe@2.0.3: {}
+ perfume.js@9.4.0:
+ dependencies:
+ web-vitals: 3.5.2
+
picocolors@1.1.1: {}
pkg-types@2.3.0:
@@ -6135,6 +6202,8 @@ snapshots:
web-streams-polyfill@3.3.3:
optional: true
+ web-vitals@3.5.2: {}
+
which-typed-array@1.1.19:
dependencies:
available-typed-arrays: 1.0.7
@@ -6185,8 +6254,6 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
- yocto-queue@1.2.1: {}
-
zod@4.1.12: {}
zustand@5.0.8(@types/react@19.2.2)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)):
diff --git a/tsconfig.json b/tsconfig.json
index cdd162f..7184c10 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",
@@ -13,8 +17,14 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true
+ "noFallthroughCasesInSwitch": true,
+ "strictNullChecks": 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
+ }
+ }
}
|