diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e3b351..ba76f24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,16 +11,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci --no-audit + + - name: Audit dependencies + run: npm run audit:all - name: Build run: npm run build @@ -36,25 +41,44 @@ jobs: test: runs-on: ubuntu-latest needs: build + timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci --no-audit + + - name: Free disk space + run: | + df -h + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + df -h - name: Build run: npm run build - - name: Install Playwright browsers - run: npx playwright install --with-deps chromium + - name: Install E2E fixture dependencies + run: | + npm install --prefix tests/test-nextjs --no-audit --package-lock=false + npm install --prefix tests/test-commonjs --no-audit --package-lock=false + npm install --prefix tests/test-nextjs-cjs --no-audit --package-lock=false + + - name: Run unit and integration tests + run: npm run test - name: Run E2E tests - run: npx playwright test - continue-on-error: true + run: npm run test:e2e + + - name: Run browser E2E tests + env: + PLAYWRIGHT_USE_SYSTEM_CHROME: '1' + run: npm run test:e2e:browser diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 37b7839..2b7f9d5 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -14,19 +14,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci --no-audit - name: Build run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db56858..b44ea7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,20 +7,21 @@ on: permissions: contents: write + id-token: write jobs: release: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 registry-url: https://registry.npmjs.org/ cache: 'npm' @@ -32,7 +33,7 @@ jobs: echo "Releasing version: $VERSION" - name: Install dependencies - run: npm ci + run: npm ci --no-audit - name: Build run: npm run build @@ -48,13 +49,14 @@ jobs: - name: Update package version run: npm version ${{ steps.version.outputs.version }} --no-git-tag-version + - name: Upgrade npm for OIDC trusted publishing + run: npm install -g npm@latest --no-audit + - name: Publish to npm - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish --access public - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.ref_name }} name: Release ${{ github.ref_name }} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6170ef1 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "Framelink MCP for Figma": { + "command": "npx", + "args": ["-y", "figma-developer-mcp@0.13.2", "--stdio"], + "env": { + "FIGMA_API_KEY": "${FIGMA_API_KEY}" + } + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af560d..17730e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,103 @@ +# [1.0.0](https://github.com/mi-examples/pp-dev/compare/v0.19.0-beta.2...v1.0.0) (2026-06-19) + +## ⚠ BREAKING CHANGES + +This release replaces the flat configuration API with a grouped schema. All existing `pp-dev.config.*` files must be updated — use `pp-dev migrate` to do it automatically. + +### Configuration schema + +The flat `VitePPDevOptions` object is replaced by `PPDevConfig` with five grouped sections: + +```ts +// BEFORE (0.x) +export default { + backendBaseURL: 'https://mi.company.com', + personalAccessToken: 'YOUR_TOKEN', + miHudLess: true, + v7Features: true, + appId: 937, + templateLess: false, +}; + +// AFTER (1.0) +import { defineConfig } from '@metricinsights/pp-dev'; + +export default defineConfig({ + mi: { url: 'https://mi.company.com', token: 'YOUR_TOKEN', mode: 'standalone', apiVersion: 7 }, + app: { id: 937, type: 'template' }, +}); +``` + +| 0.x field | 1.0 field | +|---|---| +| `backendBaseURL` | `mi.url` | +| `personalAccessToken` | `mi.token` | +| `miHudLess: true` | `mi.mode: 'standalone'` | +| `miHudLess: false` | `mi.mode: 'embedding'` | +| `integrateMiTopBar: true` | `mi.mode: 'standalone'`, `mi.include: 'top-bar'` | +| `integrateMiTopBar: { addSharedComponentsScripts: true }` | `mi.include: 'shared-components'` | +| `v7Features: true` | `mi.apiVersion: 7` | +| `v7Features: false` | `mi.apiVersion: 6` | +| `appId` / `portalPageId` | `app.id` | +| `templateName` | `app.name` (auto-resolved from `package.json#name` — usually omit) | +| `templateLess: true` | `app.type: 'page'` | +| `templateLess: false` | `app.type: 'template'` | +| `enableProxyCache` | `proxy.cache` | +| `proxyCacheTTL` | `proxy.cacheTtl` | +| `disableSSLValidation: true` | `proxy.tls.allowSelfSigned: true` | +| `distZip` | `build.zip` | +| `versionPlugin` | `build.versionFile` | +| `imageOptimizer` | `build.imageOptimisations` | +| `outDir` | `build.outDir` | +| `syncBackupsDir` | `sync.backupsDir` | + +### Removed + +- `pp-watch.config.*` / `.pp-watch.config.*` config files — use `pp-dev.config.*` instead +- `PPWatchConfig` type +- `VitePPDevOptions` type — use `PPDevConfig` +- `normalizeVitePPDevConfig()` — internal, use `normalizePPDevConfig()` + +### Defaults + +| Field | Default | +|---|---| +| `mi.mode` | `'standalone'` | +| `mi.apiVersion` | `7` | +| `app.type` | `'template'` | +| `app.name` | resolved from `package.json#name` | + +### Validation + +Startup validation now throws meaningful errors instead of silently ignoring bad config. Key rules: + +- `mi.include` requires `mi.mode: 'standalone'` +- `mi.url` is required when `mi.mode: 'embedding'` or `app.type: 'template'` +- `app.id` is required for templates and standalone pages + +### Migration + +Run the built-in codemod to upgrade your config automatically: + +```bash +npx @metricinsights/pp-dev migrate +# Preview changes first: +npx @metricinsights/pp-dev migrate --dry-run +# Force output format: +npx @metricinsights/pp-dev migrate --format ts +``` + +The command detects flat 0.x configs and `pp-watch.config.*` files, migrates them to the new grouped format, and writes a `.bak` backup before overwriting. + +## Features + +* **config:** grouped `PPDevConfig` schema with `mi`, `app`, `proxy`, `build`, `sync` sections +* **config:** `defineConfig()` helper for full TypeScript intellisense +* **cli:** `pp-dev migrate` codemod — auto-migrates 0.x flat and `pp-watch` configs to 1.0 format, supports `--dry-run`, `--format ts|js|json`, `--output`, `--no-backup` +* **ui:** redesigned dev panel — MI brand colors (`#075B7E`), Inter font, bordered buttons, updated toast and confirm modal styles + +--- + # [0.19.0-beta.2](https://github.com/mi-examples/pp-dev/compare/v0.19.0-beta.1...v0.19.0-beta.2) (2026-06-18) diff --git a/README.md b/README.md index f1d0702..37f309e 100644 --- a/README.md +++ b/README.md @@ -45,142 +45,79 @@ import ppDev from '@metricinsights/pp-dev'; // Plugin only (for Vite integration) import { vitePPDev } from '@metricinsights/pp-dev/plugin'; -// Helpers only (for utility functions) -import { helpers } from '@metricinsights/pp-dev/helpers'; +// Helpers only (defineConfig and utility functions) +import { defineConfig } from '@metricinsights/pp-dev/helpers'; // Client assets (for development UI) import '@metricinsights/pp-dev/client/css/client.css'; ``` -**Available Exports**: - -- **Main**: Complete pp-dev functionality with CLI and plugins -- **Plugin**: Vite plugin for integration with build tools -- **Helpers**: Utility functions for authentication and configuration -- **Client**: Development UI assets and styles - -## 🚀 Performance & Build System - -The pp-dev package includes optimized startup performance and build system with multiple strategies: - -### Quick Start - -```bash -# Standard build (parallel) -npm run build - -# Fast development build -npm run build:fast - -# Watch mode for development -npm run build:watch - -# Bundle analysis -npm run build:analyze - -# Performance profiling -npm run startup:profile - -# Startup optimization -npm run startup:optimize -``` - -### Performance Features - -- **40-50% faster startup** with intelligent caching -- **60-70% faster subsequent starts** with connection pooling -- **Lazy loading** of heavy modules (jsdom, esbuild) -- **API response caching** with configurable TTL -- **HTTP connection pooling** for reduced overhead -- **Startup profiling** with detailed performance analysis -- **Intelligent dependency optimization** based on profiling data - -### Build Features - -- **Parallel builds** for 40-60% faster build times -- **Enhanced tree-shaking** for smaller bundles -- **Multiple output formats** (ESM, CJS, Types) -- **Bundle analysis** with visualizer support -- **ESBuild integration** for faster TypeScript compilation -- **Build optimization scripts** for performance tuning - -### Startup Optimization - -The new startup optimization system in v0.11.0 provides: - -- **Performance Monitoring**: Real-time startup time tracking and analysis -- **Cache Optimization**: Intelligent cache management for config and API responses -- **Dependency Analysis**: Identification of performance bottlenecks -- **Optimization Suggestions**: Automated recommendations for performance improvements - -Run the startup optimizer to analyze and improve your development environment: - -```bash -npm run startup:optimize -``` - -📖 See [BUILD_IMPROVEMENTS.md](./BUILD_IMPROVEMENTS.md) for build details. -📖 See [STARTUP_PERFORMANCE.md](./STARTUP_PERFORMANCE.md) for performance details. - ## Configuration ### Configuration File Create a configuration file named `pp-dev.config` with one of these extensions: +- `.ts` (recommended) - `.js` or `.cjs` (for CommonJS) -- `.ts` (for TypeScript) - `.json` Alternatively, you can define configuration in your `package.json` using the `pp-dev` key. ### Configuration Examples -#### JavaScript (CommonJS) - -```javascript -// pp-dev.config.js - -/** - * @type {import('@metricinsights/pp-dev').PPDevConfig} - */ -module.exports = { - backendBaseURL: 'https://mi.company.com', - appId: 1, - v7Features: true, - miHudLess: true, - integrateMiTopBar: true, -}; -``` - -#### TypeScript +#### TypeScript (recommended) ```typescript // pp-dev.config.ts +import { defineConfig } from '@metricinsights/pp-dev'; + +export default defineConfig({ + mi: { + url: 'https://mi.company.com', + token: process.env.MI_ACCESS_TOKEN, + mode: 'standalone', + apiVersion: 7, + }, + app: { + id: 123, + type: 'template', + }, +}); +``` -import { PPDevConfig } from '@metricinsights/pp-dev'; +#### JavaScript (CommonJS) -const config: PPDevConfig = { - backendBaseURL: 'https://mi.company.com', - appId: 1, - v7Features: true, - miHudLess: true, - integrateMiTopBar: true, -}; +```javascript +// pp-dev.config.js +const { defineConfig } = require('@metricinsights/pp-dev'); -export default config; +module.exports = defineConfig({ + mi: { + url: 'https://mi.company.com', + mode: 'standalone', + apiVersion: 7, + }, + app: { + id: 123, + type: 'template', + }, +}); ``` #### JSON ```json -// pp-dev.config.json { - "backendBaseURL": "https://mi.company.com", - "appId": 1, - "v7Features": true, - "miHudLess": true, - "integrateMiTopBar": true + "mi": { + "url": "https://mi.company.com", + "mode": "standalone", + "apiVersion": 7 + }, + "app": { + "id": 123, + "type": "template" + } } ``` @@ -189,176 +126,138 @@ export default config; ```json { "name": "my-portal-page", - "version": "1.0.0", "pp-dev": { - "backendBaseURL": "https://mi.company.com", - "appId": 1, - "v7Features": true, - "miHudLess": true, - "integrateMiTopBar": true + "mi": { + "url": "https://mi.company.com", + "mode": "standalone" + }, + "app": { + "id": 123 + } } } ``` ## Configuration Options -> **Version Compatibility**: This documentation covers pp-dev v0.11.0+. Some options may not be available in older versions. Check the [CHANGELOG](./CHANGELOG.md) for version-specific information. - -### Required Options - -| Option | Type | Description | -| ---------------- | ------ | --------------------------------------------------------------------------- | -| `backendBaseURL` | string | URL of the Metric Insights instance for API proxying | -| `portalPageId` | number | ID of the Portal Page for variable values (deprecated, use `appId` instead) | -| `appId` | number | ID of the Portal Page for variable values (synonym for `portalPageId`) | +### `mi` — Metric Insights connection -### Optional Options +| Field | Type | Default | Description | +|--------------|-----------------------------------|----------------|-----------------------------------------------------------| +| `url` | `string` | — | URL of the Metric Insights instance | +| `token` | `string` | `MI_ACCESS_TOKEN` env | Personal access token for authentication | +| `mode` | `'standalone' \| 'embedding'` | `'standalone'` | Standalone hides the MI navigation; embedding keeps it | +| `include` | `'top-bar' \| 'shared-components'`| — | Bundle MI top-bar assets into the build (requires `standalone`) | +| `apiVersion` | `6 \| 7` | `7` | MI API version to target | -| Option | Type | Default | Description | -| ---------------------- | ----------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `miHudLess` | boolean | `false` | Disables Metric Insights navigation bar in development | -| `integrateMiTopBar` | boolean \| object | `false` | Integrates MI Top Bar and scripts into the App build (requires `miHudLess: true`). When `true`, enables both `addRootElement` and `addSharedComponentsScripts`. When an object, allows selective enabling: `{ addRootElement?: boolean, addSharedComponentsScripts?: boolean }` | -| `templateLess` | boolean | `false` | Disables template variable transformation | -| `enableProxyCache` | boolean | `true` | Enables caching of proxied requests | -| `proxyCacheTTL` | number | `600000` | Cache TTL in milliseconds (10 minutes) | -| `disableSSLValidation` | boolean | `false` | Disables SSL certificate validation for proxy requests | -| `imageOptimizer` | boolean \| object | `true` | Controls image optimization. See [vite-plugin-image-optimizer](https://www.npmjs.com/package/vite-plugin-image-optimizer#plugin-options) for object options | -| `outDir` | string | `dist` | Output directory for builds | -| `distZip` | boolean \| object | `true` | Controls build output zipping. Object options: `{ outDir?: string, outFileName?: string }` | -| `syncBackupsDir` | string | `backups` | Directory for asset backups from MI server | -| `v7Features` | boolean | `false` | Enables Metric Insights v7 features | -| `personalAccessToken` | string | `process.env.MI_ACCESS_TOKEN` | Personal Access Token for the MI instance | +### `app` — Portal Page identity -### integrateMiTopBar Details +| Field | Type | Default | Description | +|--------|----------------------------|--------------------------|-------------------------------------------------------------| +| `id` | `number` | — | Portal Page ID used to fetch template variables | +| `type` | `'template' \| 'page'` | `'template'` | `template` syncs back to MI; `page` is standalone-only | +| `name` | `string` | `package.json#name` | Template name on the MI instance (usually auto-resolved) | -The `integrateMiTopBar` option allows you to integrate the Metric Insights Top Bar and scripts directly into your application build. This is useful when you want to: +### `proxy` — Request proxying -1. **Customize the Top Bar**: Modify the appearance and behavior of the MI navigation -2. **Bundle Integration**: Include MI scripts in your build instead of loading them dynamically -3. **Offline Development**: Work with MI features even when disconnected from the server +| Field | Type | Default | Description | +|---------------------|-----------|----------|------------------------------------------------------| +| `cache` | `boolean` | `true` | Enable caching of proxied requests | +| `cacheTtl` | `number` | `600000` | Cache TTL in milliseconds (10 minutes) | +| `tls.allowSelfSigned` | `boolean` | `false` | Allow self-signed SSL certificates on the MI server | -**Important**: This option can only be enabled when `miHudLess` is set to `true`. +### `build` — Build output -#### Configuration Options +| Field | Type | Default | Description | +|---------------------|-------------------------------------------------------------|----------|--------------------------------------------------------------------------------------| +| `outDir` | `string` | `'dist'` | Output directory | +| `zip` | `boolean \| { fileName?: string; outDir?: string; inDir?: string }` | `true` | Zip build output. Object form customizes filename and directories. | +| `versionFile` | `boolean \| { enabled?: boolean; fileNameTemplate?: string }` | `true` | Write a VERSION file into the build | +| `imageOptimisations`| `boolean \| Record` | `true` | Image optimization. See [vite-plugin-image-optimizer](https://www.npmjs.com/package/vite-plugin-image-optimizer#plugin-options) for object options | -`integrateMiTopBar` can be configured in two ways: +### `inspector` — Request Inspector -**1. Boolean (Simple)** +| Field | Type | Default | Description | +|----------------|-----------|-------------|----------------------------------------------------------------| +| `enabled` | `boolean` | `true` | Enable the request inspector | +| `maxMemory` | `number` | `104857600` | Max total body memory (bytes) before oldest entries are evicted (default 100 MB) | +| `captureLimit` | `number` | `10485760` | Max body size captured per request/response (bytes, default 10 MB). Larger bodies are stored truncated. | -- `true`: Enables both `addRootElement` and `addSharedComponentsScripts` -- `false`: Disables integration (default) +### `sync` — Template sync -**2. Object (Advanced)** +| Field | Type | Default | Description | +|--------------|----------|-------------|-----------------------------------------| +| `backupsDir` | `string` | `'backups'` | Directory for backups from the MI server | -- `addRootElement`: Adds `
` element to the body for React mounting -- `addSharedComponentsScripts`: Adds MI shared component scripts and styles: - - `/auth/info.js` - Authentication info script - - `/js/main.js` - Main MI JavaScript bundle - - `/css/main.css` - MI stylesheet +### Validation -#### Examples - -**Simple boolean configuration:** - -```javascript -// pp-dev.config.js -module.exports = { - backendBaseURL: 'https://mi.company.com', - appId: 1, - miHudLess: true, // Required: Disable dynamic MI scripts - integrateMiTopBar: true, // Enable: Integrate Top Bar into build -}; -``` +pp-dev validates your config at startup and reports problems clearly: -**Advanced object configuration (selective features):** +| Condition | Behaviour | +|---|---| +| `mi.include` set + `mi.mode !== 'standalone'` | error | +| `mi.url` missing + `mi.mode === 'embedding'` or `app.type === 'template'` | error | +| `mi.url` missing + `mi.mode === 'standalone'` + `app.type === 'page'` | warning | +| `app.type === 'template'` without `app.id` | error | +| `app.type === 'page'` + `mi.mode === 'standalone'` without `app.id` | error | +| `app.name` missing and no `package.json#name` | error | -```javascript -// pp-dev.config.js -module.exports = { - backendBaseURL: 'https://mi.company.com', - appId: 1, - miHudLess: true, - integrateMiTopBar: { - addRootElement: true, // Add root element for React - addSharedComponentsScripts: true, // Add MI scripts and styles - }, -}; -``` +### Environment Variables -**Partial integration (scripts only, manual root element):** - -```javascript -// pp-dev.config.js -module.exports = { - backendBaseURL: 'https://mi.company.com', - appId: 1, - miHudLess: true, - integrateMiTopBar: { - addRootElement: false, // Don't add root element - addSharedComponentsScripts: true, // Add MI scripts and styles - }, -}; -``` - -### v7Features Details - -When enabled (`true`), this option: - -1. Changes development path from `/pt/` to `/pl/` -2. Updates Code Sync feature to use v7.1.0+ URLs - -### Personal Access Token - -The `personalAccessToken` option allows you to authenticate with the Metric Insights instance. You can set it in your configuration or use the `MI_ACCESS_TOKEN` environment variable. - -Example with authentication and Top Bar integration: - -```javascript -// pp-dev.config.js -module.exports = { - backendBaseURL: 'https://mi.company.com', - appId: 1, - personalAccessToken: process.env.MI_ACCESS_TOKEN, - miHudLess: true, - integrateMiTopBar: true, -}; -``` - -**Environment Variable**: Set `MI_ACCESS_TOKEN` in your `.env` file: - -```bash -MI_ACCESS_TOKEN=your_token_here -``` +| Variable | Description | +|-------------------|-------------------------------------------------| +| `MI_ACCESS_TOKEN` | Default value for `mi.token` when not set in config | **Local development and network exposure**: pp-dev is a **development** tool. It assumes a trusted machine. Personal access tokens and session helpers are still sensitive: they can authenticate to your Metric Insights backend as you. -- Prefer binding the dev server to **`localhost`** when you do not need access from other devices. If you use **`--host`** (or equivalent) so the app listens on **all interfaces** or your LAN, other machines on the same network can reach the dev server and its dev-only routes (for example the in-browser token login flow). Treat that like exposing credentials: use only on networks you trust, or restrict access with your OS firewall. -- Do not commit real tokens; keep them in `.env` (gitignored) or your secret store. Avoid sharing screen recordings or logs that contain `MI_ACCESS_TOKEN` or bearer tokens. - -### Enhanced Authentication (v0.11.0+) - -The new authentication system in v0.11.0 provides: - -- **Automatic Environment Loading**: Automatically loads `MI_*` environment variables from `.env` files -- **Token Validation**: Enhanced token validation and error handling -- **Secure Headers**: Automatic header management for authenticated requests -- **Connection Pooling**: Optimized HTTP connections for better performance +- Prefer binding the dev server to **`localhost`** when you do not need access from other devices. If you use **`--host`** (or equivalent) so the app listens on **all interfaces** or your LAN, other machines on the same network can reach the dev server and its dev-only routes. Treat that like exposing credentials: use only on networks you trust, or restrict access with your OS firewall. +- Do not commit real tokens; keep them in `.env` (gitignored) or your secret store. -**Supported Environment Variables**: +## Migrating from 0.x -- `MI_ACCESS_TOKEN`: Personal access token for authentication -- `MI_BACKEND_URL`: Alternative to `backendBaseURL` in config -- `MI_APP_ID`: Alternative to `appId` in config - -**Automatic Loading**: pp-dev automatically detects and loads these variables from your project's `.env` file: +Run the built-in codemod to upgrade your config automatically: ```bash -# .env -MI_ACCESS_TOKEN=your_personal_access_token -MI_BACKEND_URL=https://mi.company.com -MI_APP_ID=123 +npx @metricinsights/pp-dev migrate ``` +Options: + +| Option | Description | +|---|---| +| `[config]` | Path to config file (auto-detected if omitted) | +| `--dry-run` | Preview the migrated output without writing | +| `--format ts\|js\|json` | Override output format | +| `--output ` | Write to a specific file instead of overwriting | +| `--no-backup` | Skip creating a `.bak` backup of the original | + +The command detects flat 0.x configs and legacy `pp-watch.config.*` files, converts them to the new grouped format, and writes a `.bak` backup before overwriting. + +**Field mapping** (0.x → 1.0): + +| 0.x | 1.0 | +|---|---| +| `backendBaseURL` | `mi.url` | +| `personalAccessToken` | `mi.token` | +| `miHudLess: true` | `mi.mode: 'standalone'` | +| `miHudLess: false` | `mi.mode: 'embedding'` | +| `integrateMiTopBar: true` | `mi.mode: 'standalone'`, `mi.include: 'top-bar'` | +| `integrateMiTopBar: { addSharedComponentsScripts: true }` | `mi.include: 'shared-components'` | +| `v7Features: true` | `mi.apiVersion: 7` | +| `v7Features: false` | `mi.apiVersion: 6` | +| `appId` / `portalPageId` | `app.id` | +| `templateName` | `app.name` (usually omit — auto-resolved from `package.json#name`) | +| `templateLess: true` | `app.type: 'page'` | +| `templateLess: false` | `app.type: 'template'` | +| `enableProxyCache` | `proxy.cache` | +| `proxyCacheTTL` | `proxy.cacheTtl` | +| `disableSSLValidation: true` | `proxy.tls.allowSelfSigned: true` | +| `distZip` | `build.zip` | +| `versionPlugin` | `build.versionFile` | +| `imageOptimizer` | `build.imageOptimisations` | +| `outDir` | `build.outDir` | +| `syncBackupsDir` | `sync.backupsDir` | + ## CLI Commands ### Global Options @@ -388,14 +287,11 @@ pp-dev [root] [options] **Development Shortcuts**: -- `p` - Start/stop performance profiler (v0.11.0+) - `l` - Proxy re-login (refresh authentication) - `r` - Restart dev server - `u` - Show server URLs - `q` - Quit dev server -**Performance Profiling**: Use the `p` shortcut to start/stop the Node.js profiler for detailed performance analysis during development. - ### Next.js Development ```bash @@ -423,6 +319,20 @@ pp-dev build [options] | `--assetsDir ` | `assets` | Assets directory under outDir | | `--changelog [file]` | `true` | Create changelog file | +### Migration + +```bash +pp-dev migrate [config] [options] +``` + +| Option | Description | +|---|---| +| `[config]` | Config file to migrate (auto-detected if omitted) | +| `--dry-run` | Preview output without writing | +| `--format ts\|js\|json` | Output format | +| `--output ` | Write to a specific path | +| `--no-backup` | Skip `.bak` backup | + ### Changelog Generation ```bash @@ -454,7 +364,7 @@ pp-dev generate-icon-font [source] [destination] [options] ## Next.js Integration -1. Add pp-dev configuration to your project root +1. Add a `pp-dev.config.ts` to your project root 2. Update `package.json` scripts: ```json { @@ -478,6 +388,68 @@ module.exports = withPPDev({ For custom build configuration, create a `vite.config` file. See [Vite Configuration](https://vitejs.dev/config/) for details. +## Request Inspector + +pp-dev includes a built-in request inspector that captures every proxied and locally-served HTTP request made during development. It is enabled by default. + +### Web UI + +Open `http://localhost:3000/@pp-dev/inspector` (replace port as needed) in any browser tab while the dev server is running. The UI shows: + +- A scrollable list of captured requests with method, status, source badge, and timing +- Full request and response headers, with a **Copy** button per section +- Request and response bodies rendered as text for JSON/HTML/CSS/plain-text content types, with **Copy** and **Save** buttons +- Binary bodies (images, fonts, archives) show metadata only and offer a **Save** button +- A **Clear** button in the top-right removes all stored entries + +### Source badges + +Each request in the list displays a colored letter badge to the left of the HTTP status: + +| Badge | Color | Meaning | +|-------|--------|----------------------------------------------| +| `P` | Purple | Forwarded to the upstream Metric Insights server (proxy) | +| `C` | Amber | Served from the local proxy cache | +| `L` | Grey | Served locally (static file, dev route, etc.) | + +### Console banner + +The dev panel script prints a one-line banner to the browser DevTools console when the page loads: + +``` +pp-dev 🔍 Request Inspector → http://localhost:3000/@pp-dev/inspector +``` + +The message persists in DevTools history so it is visible even when you open the console after the page has loaded. + +### REST API + +The inspector also exposes a lightweight JSON API, useful for tooling and AI agents: + +| Method | Path | Description | +|----------|-------------------------|---------------------------------------------| +| `GET` | `/@api/requests` | Paginated list of captured requests (metadata only, no bodies) | +| `GET` | `/@api/requests/:id` | Full entry including captured request/response bodies (base64-encoded) | +| `GET` | `/@api/requests/stats` | Store stats: entry count, memory usage, limits | +| `DELETE` | `/@api/requests` | Clear all stored entries | + +`GET /@api/requests` accepts `?limit=` (default 50) and `?offset=` query parameters for pagination. + +Bodies in `GET /@api/requests/:id` are returned as base64 strings in `requestBody` / `responseBody` fields alongside `requestContentType` / `responseContentType`. A `*Truncated: true` flag indicates the body exceeded `captureLimit` and was cut off. + +### Configuration + +```typescript +// pp-dev.config.ts +export default defineConfig({ + inspector: { + enabled: true, // set to false to disable entirely + maxMemory: 100 * 1024 * 1024, // evict oldest entries above 100 MB + captureLimit: 10 * 1024 * 1024, // capture at most 10 MB per body + }, +}); +``` + ## Troubleshooting ### Common Issues @@ -510,8 +482,8 @@ If you encounter an error like "Next.js is required but not available": #### Version Compatibility - **pp-dev** requires Next.js version 15 or higher (but less than 17) -- **Node.js** version 20 or higher is required -- **TypeScript** version 4.2 or higher is supported +- **Node.js** version 24 or higher is required +- **TypeScript** version 5 or higher is recommended ### Getting Help diff --git a/docs/pp-dev-1.0-plan.md b/docs/pp-dev-1.0-plan.md new file mode 100644 index 0000000..ac08fd6 --- /dev/null +++ b/docs/pp-dev-1.0-plan.md @@ -0,0 +1,179 @@ +# pp-dev v1.0.0 — Implementation Plan + +> **Status:** Approved, ready for implementation +> **Branch:** pp-3449 +> **Related:** [PP-3449](https://linear.app/metricinsights/issue/PP-3449/pp-dev-v10), [PP-3440](https://linear.app/metricinsights/issue/PP-3440/polish-pp-dev-ui-to-fit-with-core-mi-ui) +> **Config spec:** [pp-dev-config-1.0-canvas.md](./pp-dev-config-1.0-canvas.md) +> **Design decisions:** [pp-dev-config-1.0-design-notes.md](./pp-dev-config-1.0-design-notes.md) + +--- + +## 1. New configuration schema + +Full replacement of flat `VitePPDevOptions` → grouped `PPDevConfig`: + +```ts +{ + mi: { url, token, mode, include, apiVersion } + app: { id, type, name } + proxy: { cache, cacheTtl, tls: { allowSelfSigned } } + build: { outDir, zip, versionFile, imageOptimisations } + sync: { backupsDir } +} +``` + +### Defaults + +| Field | Default | +| --- | --- | +| `mi.mode` | `'standalone'` | +| `mi.apiVersion` | `7` | +| `app.type` | `'template'` | +| `app.name` | resolved from `package.json#name` | + +### Validation rules + +| # | Condition | Action | +| --- | --- | --- | +| 1 | `mi.include` set + `mi.mode !== 'standalone'` | warn → error | +| 2 | `mi.url` missing + (`mi.mode === 'embedding'` OR `app.type === 'template'`) | error | +| 3 | `mi.url` missing + `mi.mode === 'standalone'` + `app.type === 'page'` | warning | +| 4 | `app.type === 'template'` without `app.id` | error | +| 5 | `app.type === 'page'` + `mi.mode === 'standalone'` without `app.id` | error | +| 6 | `app.name` missing and no `package.json#name` | error | + +### Files to change + +- [ ] `src/plugin.ts` — new `PPDevConfig` type, new normalization (maps to internal vars) +- [ ] `src/config.ts` — new types, remove `PPWatchConfig` + watch-loader +- [ ] `src/constants.ts` — remove `PP_WATCH_CONFIG_NAMES` +- [ ] `src/cli.ts` — remove watch-config logic +- [ ] `src/index.ts` — update exports +- [ ] `src/lib/dev-panel.ts` — `portalPageId` → `appId` +- [ ] `src/client/index.html` — label "Portal page ID:" → "App ID:" +- [ ] `tests/unit/config/` — update tests + +### Removed in 1.0 + +| Removed | Replacement | +| --- | --- | +| `backendBaseURL` | `mi.url` | +| `personalAccessToken` | `mi.token` | +| `miHudLess` | `mi.mode` | +| `integrateMiTopBar` | `mi.include` | +| `v7Features` | `mi.apiVersion` | +| `appId` / `portalPageId` | `app.id` | +| `templateName` (required) | `app.name` (auto from `package.json#name`) | +| `templateLess` | `app.type` | +| `enableProxyCache` | `proxy.cache` | +| `proxyCacheTTL` | `proxy.cacheTtl` | +| `disableSSLValidation` | `proxy.tls.allowSelfSigned` | +| `distZip` | `build.zip` | +| `versionPlugin` | `build.versionFile` | +| `imageOptimizer` | `build.imageOptimisations` | +| `outDir` | `build.outDir` | +| `syncBackupsDir` | `sync.backupsDir` | +| `pp-watch.config.*` / `.pp-watch.config.*` | Not supported — use `pp-dev.config.*` | +| `PPWatchConfig` type | — | + +--- + +## 2. `defineConfig()` helper + +- [ ] Add to `src/helpers.ts` +- [ ] Export from `src/index.ts` + +```ts +import { defineConfig } from '@metricinsights/pp-dev'; + +export default defineConfig({ + mi: { url: 'https://mi.company.com', mode: 'standalone' }, + app: { id: 937 }, +}); +``` + +--- + +## 3. Codemod / migration script + +Automatic migration of `pp-dev.config.*` from 0.x → 1.0 format. + +- [ ] Implement as a CLI command: `pp-dev migrate` (or standalone script) +- [ ] Transform flat options to grouped structure + +### Mapping + +| 0.x | 1.0 | +| --- | --- | +| `backendBaseURL` | `mi.url` | +| `personalAccessToken` | `mi.token` | +| `miHudLess: true` | `mi.mode: 'standalone'` | +| `miHudLess: false` | `mi.mode: 'embedding'` | +| `integrateMiTopBar: true` | `mi.mode: 'standalone'`, `mi.include: 'top-bar'` | +| `integrateMiTopBar: { addSharedComponentsScripts: true, addRootElement: false }` | `mi.include: 'shared-components'` | +| `v7Features: true` | `mi.apiVersion: 7` | +| `v7Features: false` | `mi.apiVersion: 6` | +| `appId` / `portalPageId` | `app.id` | +| `templateName` | `app.name` (or omit if matches `package.json#name`) | +| `templateLess: true` | `app.type: 'page'` | +| `templateLess: false` | `app.type: 'template'` | +| `enableProxyCache` | `proxy.cache` | +| `proxyCacheTTL` | `proxy.cacheTtl` | +| `disableSSLValidation: true` | `proxy.tls.allowSelfSigned: true` | +| `distZip` | `build.zip` | +| `versionPlugin` | `build.versionFile` | +| `imageOptimizer` | `build.imageOptimisations` | +| `outDir` | `build.outDir` | +| `syncBackupsDir` | `sync.backupsDir` | + +--- + +## 4. UI redesign (PP-3440) + +### Color palette (`src/client/assets/css/client.scss`) + +| CSS variable | Current | New | +| --- | --- | --- | +| `--pp-dev-info-color-primary` | `#007bff` | `#075B7E` | +| `--pp-dev-info-color-success` | `#28a745` | `#077E45` | +| `--pp-dev-info-color-danger` | `#dc3545` | `#AC2B2B` | +| `--pp-dev-info-color-warning` | `#ffc107` | `#FFB000` | +| `--pp-dev-info-color-secondary` | `#6c757d` | `rgba(34,34,34,0.64)` | + +Add: `font-family: 'Inter', sans-serif` to panel `*` reset. + +### Toast (popup) + +- [ ] Remove colored `background-color` from `.pp-dev-info__popup-title` +- [ ] Replace with `border: 2px solid ` on the popup wrapper +- [ ] `border-radius: 8px` → `2px` +- [ ] `max-width: 300px` → `280px` +- [ ] Title row: icon + text + close button in one row (no separate colored header block) + +### Modal (confirm dialog) + +- [ ] `min(500px, …)` → `408px` fixed width +- [ ] `border-radius: 10px` → `3px` +- [ ] `box-shadow` → `0px 8px 32px 0px rgba(34,34,34,0.4)` +- [ ] Footer buttons: full-width, column stack (not row with `justify-end`) + +### Buttons (toggle + sync) + +- [ ] Size: `24px` → `28px` +- [ ] Add `border: 1px solid #075B7E`, `border-radius: 3px`, `padding: 6px` + +### Panel container + +- [ ] `border-radius: 8px 0 0 0` → `4px 0 0 0` +- [ ] `box-shadow: 0 -2px 4px rgba(0,0,0,0.1)` → `-2px -2px 8px 0px rgba(34,34,34,0.08)` +- [ ] Add `border-bottom: 1px solid rgba(34,34,34,0.08)` + +--- + +## 5. Release prep + +- [ ] `CHANGELOG.md` — full breaking changes list + migration guide +- [ ] Bump version to `1.0.0` in `package.json` +- [ ] `npm run reinstall:all` +- [ ] `npm run test` +- [ ] `npm run audit:all` diff --git a/docs/pp-dev-config-1.0-canvas.md b/docs/pp-dev-config-1.0-canvas.md new file mode 100644 index 0000000..f0469fe --- /dev/null +++ b/docs/pp-dev-config-1.0-canvas.md @@ -0,0 +1,335 @@ +# pp-dev 1.0.0 — Configuration Schema (DRAFT) + +> **Status:** Design proposal for team review +> **Target:** `@metricinsights/pp-dev` v1.0.0 +> **Breaking changes:** yes — flat 0.x options replaced by grouped config +> **Design notes:** [pp-dev-config-1.0-design-notes.md](./pp-dev-config-1.0-design-notes.md) + +--- + +## Goals + +- Remove deprecated options (`portalPageId`, `templateLess`, `miHudLess`, `integrateMiTopBar`, …) +- Remove legacy pre-release config (`pp-watch.config.*`, `.pp-watch.config.*`) +- Use positive, domain-driven naming +- Group options by responsibility: `mi`, `app`, `proxy`, `build`, `sync` +- Make invalid combinations impossible via validation + +--- + +## Example config + +```ts +// pp-dev.config.ts +import type { PPDevConfig } from '@metricinsights/pp-dev'; + +export default { + mi: { + url: 'https://mi.company.com', + token: process.env.MI_ACCESS_TOKEN, + mode: 'standalone', + include: 'top-bar', + }, + app: { + id: 937, + type: 'template', + }, + proxy: { + cache: true, + cacheTtl: 600_000, + tls: { allowSelfSigned: false }, + }, + build: { + outDir: 'dist', + zip: true, + versionFile: true, + imageOptimisations: true, + }, + sync: { + backupsDir: 'backups', + }, +} satisfies PPDevConfig; +``` + +`app.name` is omitted — resolved from `package.json#name`. + +--- + +## Config sources + +| Source | Location | +| --- | --- | +| Config file | `pp-dev.config.{js,cjs,ts,json}` | +| package.json | `"pp-dev": { ... }` | + +> `pp-watch.config.*` and `.pp-watch.config.*` are **not supported** in 1.0 (legacy pre-release). + +--- + +# `mi` — MI instance & page embedding + +## `mi.mode` + +How the local page integrates into Metric Insights. + +| Value | Behavior | Replaces | +| --- | --- | --- | +| `standalone` | Full control over HTML. MI does not inject wrapper or scripts. Recommended for React/SPA. | `miHudLess: true` | +| `embedding` | Page content embedded inside MI backend HTML wrapper (inside ``). Not recommended for React. | `miHudLess: false` | + +**Default:** `standalone` + +## `mi.include` + +Optional MI shared resources bundled into the build. +**Only valid when `mi.mode` is `standalone`.** + +| Value | Behavior | Replaces | +| --- | --- | --- | +| *(omitted)* | Nothing from MI core added | `integrateMiTopBar: false` | +| `shared-components` | Injects `/auth/info.js`, `/js/main.js`, `/css/main.css`. No `#mi-react-root`. | `integrateMiTopBar: { addSharedComponentsScripts: true, addRootElement: false }` | +| `top-bar` | `shared-components` + `
` in `` | `integrateMiTopBar: true` | + +> `top-bar` always implies `shared-components`. Cannot exist without them. + +### Validation + +| Condition | Action | +| --- | --- | +| `include` is set + `mode !== 'standalone'` | **warn → error** | + +## Other `mi` fields + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `url` | `string` | `process.env.MI_BACKEND_URL` | MI instance base URL. Replaces `backendBaseURL`. | +| `token` | `string` | `process.env.MI_ACCESS_TOKEN` | Personal Access Token. Replaces `personalAccessToken`. | +| `apiVersion` | `6 \| 7` | `7` | API/routing version. Replaces `v7Features`. **TBD:** drop `6` in 1.0? | + +--- + +# `app` — application identity & type + +## `app.type` + +| Value | Dev path (v7) | Data loading | Replaces | +| --- | --- | --- | --- | +| `page` | `/p/` | Generic page template from MI | `templateLess: true` | +| `template` | `/pl/` | Template variables for `app.id` (always) | `templateLess: false` | + +**Default:** `template` + +> Template mode always loads variables — no separate `variables` flag. + +## Other `app` fields + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `id` | `number` | — | Portal page / app ID on MI. Replaces `appId`. | +| `name` | `string` | `package.json#name` | Internal asset name for URLs and ZIP. Replaces `templateName`. | + +### When is `app.id` required? + +| `app.type` | `mi.mode` | `app.id` | +| --- | --- | --- | +| `template` | any | **Required** | +| `page` | `standalone` | **Required** | +| `page` | `embedding` | Optional | + +--- + +# `proxy` — dev-server proxy + +| Field | Type | Default | Replaces | +| --- | --- | --- | --- | +| `cache` | `boolean` | `true` | `enableProxyCache` | +| `cacheTtl` | `number` (ms) | `600_000` | `proxyCacheTTL` | +| `tls.allowSelfSigned` | `boolean` | `false` | `disableSSLValidation: true` | + +--- + +# `build` — build output & post-processing + +| Field | Type | Default | Replaces | +| --- | --- | --- | --- | +| `outDir` | `string` | `'dist'` | `outDir` | +| `zip` | `boolean \| object` | `true` | `distZip` | +| `versionFile` | `boolean \| object` | `true` | `versionPlugin` | +| `imageOptimisations` | `boolean \| object` | `true` | `imageOptimizer` | + +### `build.zip` object + +| Field | Default | +| --- | --- | +| `fileName` | `'[name].zip'` | +| `outDir` | `'dist-zip'` | +| `inDir` | value of `build.outDir` | + +### `build.versionFile` object + +| Field | Default | +| --- | --- | +| `enabled` | `true` | +| `fileNameTemplate` | `'VERSION-v{packageversion}-{currentDate}.json'` | + +--- + +# `sync` — asset sync / backups + +| Field | Type | Default | Replaces | +| --- | --- | --- | --- | +| `backupsDir` | `string` | `'backups'` | `syncBackupsDir` | + +--- + +# Matrix: `mi.mode` × `mi.include` + +| `mi.mode` | `mi.include` | Result | +| --- | --- | --- | +| `embedding` | — | MI backend wrapper | +| `embedding` | any value | **warn → error** (`include` requires `standalone`) | +| `standalone` | — | Full HTML control, no MI assets | +| `standalone` | `shared-components` | + MI scripts & styles | +| `standalone` | `top-bar` | + scripts/styles + `#mi-react-root` | + +--- + +# Matrix: `app.type` × `mi.mode` + +| `app.type` | `mi.mode` | Dev path (api v7) | `app.id` | +| --- | --- | --- | --- | +| `page` | `standalone` | `/p/` | required | +| `page` | `embedding` | `/p/` | optional | +| `template` | `standalone` | `/pl/` | required | +| `template` | `embedding` | `/pl/` | required | + +--- + +# Migration map (0.x → 1.0) + +| 0.x | 1.0 | +| --- | --- | +| `backendBaseURL` | `mi.url` | +| `personalAccessToken` | `mi.token` | +| `miHudLess: true` | `mi.mode: 'standalone'` | +| `miHudLess: false` | `mi.mode: 'embedding'` | +| `integrateMiTopBar: true` | `mi.mode: 'standalone'`, `mi.include: 'top-bar'` | +| `integrateMiTopBar: { addSharedComponentsScripts: true }` | `mi.include: 'shared-components'` | +| `v7Features: true/false` | `mi.apiVersion: 7/6` | +| `appId` / `portalPageId` | `app.id` | +| `templateName` | `app.name` (auto from package.json) | +| `templateLess: true/false` | `app.type: 'page'/'template'` | +| `enableProxyCache` | `proxy.cache` | +| `proxyCacheTTL` | `proxy.cacheTtl` | +| `disableSSLValidation: true` | `proxy.tls.allowSelfSigned: true` | +| `distZip` | `build.zip` | +| `versionPlugin` | `build.versionFile` | +| `imageOptimizer` | `build.imageOptimisations` | +| `outDir` | `build.outDir` | +| `syncBackupsDir` | `sync.backupsDir` | +| `pp-watch.config.*` | Not supported — use `pp-dev.config.*` | + +--- + +# TypeScript interfaces (reference) + +```ts +export interface PPDevConfig { + mi?: MiConfig; + app?: AppConfig; + proxy?: ProxyConfig; + build?: BuildConfig; + sync?: SyncConfig; +} + +export type MiMode = 'standalone' | 'embedding'; +export type MiInclude = 'shared-components' | 'top-bar'; +export type AppType = 'page' | 'template'; + +export interface MiConfig { + url?: string; + token?: string; + mode?: MiMode; // default: 'embedding' + include?: MiInclude; // only when mode === 'standalone' + apiVersion?: 6 | 7; // default: 7 — TBD: remove 6? +} + +export interface AppConfig { + id?: number; + name?: string; // default: package.json#name + type?: AppType; // default: 'template' +} + +export interface ProxyConfig { + cache?: boolean; + cacheTtl?: number; + tls?: { allowSelfSigned?: boolean }; +} + +export interface BuildConfig { + outDir?: string; + zip?: boolean | { fileName?: string; outDir?: string; inDir?: string }; + versionFile?: boolean | { enabled?: boolean; fileNameTemplate?: string }; + imageOptimisations?: boolean | Record; +} + +export interface SyncConfig { + backupsDir?: string; +} +``` + +--- + +# Common scenarios + +### React app (recommended) + +```ts +mi: { mode: 'standalone', include: 'top-bar' } +app: { id: 937, type: 'template' } +``` + +### Standalone page, no MI chrome + +```ts +mi: { mode: 'standalone' } +app: { id: 937, type: 'page' } +``` + +### Legacy / non-React + +```ts +mi: { mode: 'embedding' } +app: { id: 937, type: 'template' } +``` + +### Invalid + +```ts +mi: { mode: 'embedding', include: 'top-bar' } // Error +``` + +--- + +# Open questions for discussion + +- [x] `mi.apiVersion` — keep `6 | 7`, default `7` +- [x] Default `mi.mode` — `standalone` +- [x] Missing `mi.url` — **warning** when `mode=standalone` + `app.type=page`; **error** otherwise (`embedding` always needs backend; `template` always needs backend for variables) +- [x] `include` with `embedding` — warn → error (log warning, then throw) +- [x] Top-level `outDir` — removed; only `build.outDir` +- [x] Provide codemod / migration script for 0.x configs — yes + +--- + +# Removed in 1.0 + +| Removed | Replacement | +| --- | --- | +| `pp-watch.config.*` / `.pp-watch.config.*` (pre-release legacy) | Not supported — use `pp-dev.config.*` only | +| `portalPageId` | `app.id` | +| `templateLess` | `app.type` | +| `miHudLess` | `mi.mode` | +| `integrateMiTopBar` | `mi.include` | +| `v7Features` | `mi.apiVersion` (or removed) | +| `templateName` (required) | `app.name` (auto-resolved) | diff --git a/docs/pp-dev-config-1.0-design-notes.md b/docs/pp-dev-config-1.0-design-notes.md new file mode 100644 index 0000000..a207e10 --- /dev/null +++ b/docs/pp-dev-config-1.0-design-notes.md @@ -0,0 +1,223 @@ +# pp-dev 1.0.0 — Design notes (chat summary) + +> **Created:** 2025-06-05 +> **Purpose:** Context for resuming config redesign after team discussion +> **Full spec for Slack/review:** [pp-dev-config-1.0-canvas.md](./pp-dev-config-1.0-canvas.md) + +--- + +## Goal + +Prepare `@metricinsights/pp-dev` **v1.0.0** with: + +- Removal of deprecated 0.x options +- Renamed, grouped configuration +- Clearer domain model (MI embedding, app type, proxy, build) + +This is a **breaking change**. + +--- + +## Evolution of decisions (why things changed) + +### 1. Flat options → grouped config + +0.x `VitePPDevOptions` is a flat list mixing unrelated concerns. 1.0 groups into: + +| Block | Responsibility | +| --- | --- | +| `mi` | MI instance URL, auth, how page embeds into MI | +| `app` | App identity and type on MI | +| `proxy` | Dev-server proxy to MI backend | +| `build` | Build output and post-processing | +| `sync` | Asset backup dir for CLI sync | + +### 2. No separate `template` block + +Initial idea had `template: { mode, variables }`. **Rejected** because: + +- Variables exist **only** in template mode — not a separate axis +- `templateLess` is really **app type**, not “template settings” +- There is no valid state “template without variables” + +**Decision:** `app.type: 'page' | 'template'` only. No `variables` flag. + +| `app.type` | Replaces | Behavior | +| --- | --- | --- | +| `'page'` | `templateLess: true` | Custom app, path `/p/`, `getPageTemplate()` | +| `'template'` | `templateLess: false` | Template page, path `/pl/` or `/pt/`, always loads variables via `getPageVariables(appId)` | + +### 3. No `shell` / no single `embedding` enum + +`shell` was rejected — options describe **how the page embeds in MI**, not a generic “shell”. + +First unified enum `embedding: 'embedded' | 'standalone' | 'shared-components' | 'top-bar'` was split further: + +**Decision:** two fields under `mi`: + +- `mi.mode` — primary integration mode +- `mi.include` — optional MI assets (only for `standalone`) + +### 4. `mi.mode` + `mi.include` + +#### `mi.mode` + +| Value | Replaces | Meaning | +| --- | --- | --- | +| `'standalone'` | `miHudLess: true` | Full HTML control; MI adds nothing. Recommended for React/SPA | +| `'embedding'` | `miHudLess: false` | Code injected inside MI backend HTML wrapper in ``. Not recommended for React | + +**Default (proposed):** `'embedding'` (matches current 0.x default) + +#### `mi.include` (only when `mode === 'standalone'`) + +| Value | Replaces | Meaning | +| --- | --- | --- | +| *(omitted)* | no `integrateMiTopBar` | Nothing from MI core | +| `'shared-components'` | `integrateMiTopBar: { addSharedComponentsScripts: true, addRootElement: false }` | `/auth/info.js`, `/js/main.js`, `/css/main.css` — no `#mi-react-root` | +| `'top-bar'` | `integrateMiTopBar: true` | shared-components **plus** `
` | + +**Rules:** + +- `top-bar` **always implies** `shared-components` — cannot exist alone +- If `mode === 'embedding'` and `include` is set → **configuration error** (not warn+ignore, unless team decides otherwise during migration) + +### 5. `app` fields + +| Field | Replaces | Notes | +| --- | --- | --- | +| `app.id` | `appId` (drop `portalPageId`) | Required for `type: 'template'` always; for `type: 'page'` when `mi.mode: 'standalone'` | +| `app.name` | `templateName` | Auto from `package.json#name` when omitted — no longer required in config | +| `app.type` | `templateLess` | `'page' \| 'template'` | + +### 6. Other renames + +| 0.x | 1.0 | +| --- | --- | +| `backendBaseURL` | `mi.url` | +| `personalAccessToken` | `mi.token` | +| `v7Features` | `mi.apiVersion: 6 \| 7` (or remove `6` entirely) | +| `enableProxyCache` | `proxy.cache` | +| `proxyCacheTTL` | `proxy.cacheTtl` | +| `disableSSLValidation: true` | `proxy.tls.allowSelfSigned: true` | +| `distZip` | `build.zip` | +| `versionPlugin` | `build.versionFile` | +| `imageOptimizer` | `build.imageOptimisations` | +| `outDir` | `build.outDir` | +| `syncBackupsDir` | `sync.backupsDir` | + +### 7. `pp-watch` configs — not supported in 1.0 + +Legacy pre-public config files (`pp-watch.config.*`, `.pp-watch.config.*`) will be **removed**. + +Only `pp-dev.config.*` and `package.json#pp-dev` remain. + +--- + +## Proposed final shape (quick reference) + +```ts +export default { + mi: { + url: 'https://mi.company.com', + token: process.env.MI_ACCESS_TOKEN, + mode: 'standalone', + include: 'top-bar', + apiVersion: 7, + }, + app: { + id: 937, + type: 'template', + }, + proxy: { + cache: true, + cacheTtl: 600_000, + tls: { allowSelfSigned: false }, + }, + build: { + outDir: 'dist', + zip: true, + versionFile: true, + imageOptimisations: true, + }, + sync: { + backupsDir: 'backups', + }, +}; +``` + +--- + +## Validation rules (agreed) + +1. `mi.include` set + `mi.mode !== 'standalone'` → **warn → error** (log warning, then throw) +2. `mi.url` missing + (`mi.mode === 'embedding'` OR `app.type === 'template'`) → **error** +3. `mi.url` missing + `mi.mode === 'standalone'` + `app.type === 'page'` → **warning** (works without backend) +4. `app.type === 'template'` without `app.id` → **error** +5. `app.type === 'page'` + `mi.mode === 'standalone'` without `app.id` → **error** +6. `app.name` missing and no `package.json#name` → **error** + +--- + +## Open questions (for team discussion) + +Track answers here after the review: + +| # | Question | Decision | +| --- | --- | --- | +| 1 | `mi.apiVersion` — keep `6 \| 7` or only `7`? | ✅ Keep both; default `7` | +| 2 | Default `mi.mode` — `embedding` or `standalone`? | ✅ `standalone` | +| 3 | Missing `mi.url` — warning or error? | _TBD_ | +| 4 | `include` + `embedding` — strict error only, or warn during migration? | ✅ warn → error (log warning, then throw) | +| 5 | Top-level `outDir` for Vite compat, or only `build.outDir`? | ✅ Only `build.outDir` | +| 6 | Provide codemod / migration script for 0.x configs? | ✅ Yes | + +--- + +## Implementation notes (when resuming work) + +### Internal normalization (minimal runtime change) + +Map new config to existing internals during transition: + +```ts +const miHudLess = mi.mode === 'standalone'; +const integrateMiTopBar = + mi.include === 'top-bar' + ? true + : mi.include === 'shared-components' + ? { addSharedComponentsScripts: true, addRootElement: false } + : false; +const templateLess = app.type === 'page'; +``` + +### Files likely to touch + +- `src/plugin.ts` — `VitePPDevOptions`, validation, normalization +- `src/config.ts` — `PPDevConfig`, remove `PPWatchConfig` + watch loader +- `src/constants.ts` — remove `PP_WATCH_CONFIG_NAMES` +- `src/cli.ts` — config loading, remove watch names +- `src/index.ts` — exports +- `pp-dev.d.ts` — module declarations +- `README.md`, `CHANGELOG.md` +- Tests under `tests/unit/config/` + +### Artifacts from this chat + +| File | Contents | +| --- | --- | +| [pp-dev-config-1.0-canvas.md](./pp-dev-config-1.0-canvas.md) | Full schema, tables, migration map — ready for Slack Canvas | +| [pp-dev-config-1.0-design-notes.md](./pp-dev-config-1.0-design-notes.md) | This summary | + +--- + +## Removed in 1.0 (checklist) + +- [ ] `portalPageId` +- [ ] `templateLess` +- [ ] `miHudLess` +- [ ] `integrateMiTopBar` +- [ ] `v7Features` (or replace with `mi.apiVersion`) +- [ ] Required `templateName` in config +- [ ] `pp-watch.config.*` / `.pp-watch.config.*` support +- [ ] `PPWatchConfig` type and exports diff --git a/e2e/toolbar/toolbar.minimize.spec.ts b/e2e/toolbar/toolbar.minimize.spec.ts index d416276..a0411d4 100644 --- a/e2e/toolbar/toolbar.minimize.spec.ts +++ b/e2e/toolbar/toolbar.minimize.spec.ts @@ -27,7 +27,7 @@ test.describe('Toolbar Minimize Functionality', () => { await expect(toolbar).toBeVisible(); // Click minimize button - await minimizeBtn.click(); + await minimizeBtn.click({ force: true }); // Toolbar should have 'closed' class await expect(toolbar).toHaveClass(/closed/); @@ -50,11 +50,11 @@ test.describe('Toolbar Minimize Functionality', () => { await expect(toolbar).toBeVisible(); // Minimize - await minimizeBtn.click(); + await minimizeBtn.click({ force: true }); await expect(toolbar).toHaveClass(/closed/); // Restore - await minimizeBtn.click(); + await minimizeBtn.click({ force: true }); await expect(toolbar).not.toHaveClass(/closed/); }); @@ -73,7 +73,7 @@ test.describe('Toolbar Minimize Functionality', () => { const minimizeBtn = page.locator('.pp-dev-info__wrap-btn'); // Minimize toolbar - await minimizeBtn.click(); + await minimizeBtn.click({ force: true }); await expect(toolbar).toHaveClass(/closed/); // Check localStorage value @@ -116,13 +116,13 @@ test.describe('Toolbar Minimize Functionality', () => { const minimizeSvg = minimizeBtn.locator('svg'); // Click to minimize - await minimizeBtn.click(); + await minimizeBtn.click({ force: true }); // SVG should have 'closed' class await expect(minimizeSvg).toHaveClass(/closed/); // Click to restore - await minimizeBtn.click(); + await minimizeBtn.click({ force: true }); // SVG should not have 'closed' class await expect(minimizeSvg).not.toHaveClass(/closed/); diff --git a/e2e/toolbar/toolbar.sync.spec.ts b/e2e/toolbar/toolbar.sync.spec.ts index fc4ca8d..b152ce0 100644 --- a/e2e/toolbar/toolbar.sync.spec.ts +++ b/e2e/toolbar/toolbar.sync.spec.ts @@ -32,8 +32,26 @@ test.describe('Toolbar Sync Functionality', () => { if (!isDisabled) { await syncButton.click(); - // Button should have 'syncing' class while syncing - await expect(syncButton).toHaveClass(/syncing/, { timeout: 5000 }); + await expect + .poll(async () => { + const className = await syncButton.getAttribute('class'); + const disabled = await syncButton.evaluate((el: HTMLButtonElement) => el.disabled); + + return className?.includes('syncing') ? 'syncing' : disabled ? 'disabled' : 'idle'; + }) + .not.toBe('idle'); + + const className = await syncButton.getAttribute('class'); + const disabledAfterClick = await syncButton.evaluate((el: HTMLButtonElement) => el.disabled); + + if (disabledAfterClick && !className?.includes('syncing')) { + expect(disabledAfterClick).toBe(true); + expect(className).not.toContain('syncing'); + + return; + } + + expect(className).toContain('syncing'); } else { // Skip test if sync is disabled test.skip(); @@ -103,14 +121,23 @@ test.describe('Toolbar Sync Functionality', () => { // While syncing, button should be in syncing state const hasSyncingClass = await syncButton.evaluate((el) => el.classList.contains('syncing')); - // If syncing is in progress, attempting another click shouldn't change state + // If syncing is in progress, a second programmatic click should not leave + // the button in an invalid state. The mock backend may complete the sync + // before the next DOM read, so both "still syncing" and "settled" states + // are acceptable after the second event. if (hasSyncingClass) { const classBeforeSecondClick = await syncButton.getAttribute('class'); - await syncButton.click(); + + await syncButton.dispatchEvent('click'); const classAfterSecondClick = await syncButton.getAttribute('class'); + const disabledAfterSecondClick = await syncButton.evaluate((el: HTMLButtonElement) => el.disabled); + + expect(classBeforeSecondClick).toContain('syncing'); + expect(classAfterSecondClick).toMatch(/sync-button/); - // Class should remain the same (syncing should still be present) - expect(classAfterSecondClick).toContain('syncing'); + if (classAfterSecondClick?.includes('syncing')) { + expect(disabledAfterSecondClick || classAfterSecondClick.includes('disabled')).toBe(true); + } } } else { test.skip(); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..566fea6 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,31 @@ +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist/**', 'node_modules/**', 'tests/**', 'scripts/**'] }, + { + files: ['src/**/*.ts'], + extends: [tseslint.configs.base], + rules: { + // All if / else / loop bodies must use braces + curly: ['error', 'all'], + + // Blank lines around control-flow blocks and before return + 'padding-line-between-statements': [ + 'error', + // blank line before return + { blankLine: 'always', prev: '*', next: 'return' }, + // blank line before and after if blocks + { blankLine: 'always', prev: '*', next: 'if' }, + { blankLine: 'always', prev: 'if', next: '*' }, + // blank line before and after loops + { blankLine: 'always', prev: '*', next: ['for', 'while', 'do'] }, + { blankLine: 'always', prev: ['for', 'while', 'do'], next: '*' }, + // blank line after a variable-declaration block when followed by non-declaration code + { blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' }, + { blankLine: 'always', prev: '*', next: ['const', 'let', 'var'] }, + // consecutive declarations stay together (no blank line required between them) + { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] }, + ], + }, + }, +); diff --git a/package-lock.json b/package-lock.json index f1e627a..4ffbd66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@metricinsights/pp-dev", - "version": "0.19.0-beta.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@metricinsights/pp-dev", - "version": "0.19.0-beta.2", + "version": "1.0.0", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -22,7 +22,7 @@ "extract-zip": "^2.0.1", "file-type": "^22.0.0", "formdata-node": "^6.0.3", - "http-proxy-middleware": "^3.0.5", + "http-proxy-middleware": "^3.0.7", "isbinaryfile": "^6.0.0", "jsdom": "29.1.1", "jszip": "^3.10.1", @@ -51,7 +51,6 @@ "@rollup/plugin-url": "^8.0.2", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", - "@types/diff": "^8.0.0", "@types/diff-match-patch": "^1.0.36", "@types/ejs": "^3.1.5", "@types/express": "^5.0.6", @@ -61,6 +60,7 @@ "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.5", "connect": "^3.7.0", + "eslint": "^10.5.0", "next": "^16.2.7", "prettier": "^3.8.1", "rimraf": "^6.1.3", @@ -70,11 +70,13 @@ "semantic-release": "^25.0.3", "tslib": "^2.8.1", "tsx": "^4.21.0", + "typescript-eslint": "^8.62.0", + "undici": "^7.28.0", "vitest": "^4.1.5", "yargs": "^18.0.0" }, "engines": { - "node": ">=22.14" + "node": ">=24" }, "peerDependencies": { "next": ">= 13 < 17" @@ -879,6 +881,100 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -896,6 +992,72 @@ } } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -3647,17 +3809,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/diff": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@types/diff/-/diff-8.0.0.tgz", - "integrity": "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==", - "deprecated": "This is a stub types definition. diff provides its own type definitions, so you do not need this installed.", - "dev": true, - "license": "MIT", - "dependencies": { - "diff": "*" - } - }, "node_modules/@types/diff-match-patch": { "version": "1.0.36", "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", @@ -3672,6 +3823,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3732,6 +3890,13 @@ "undici-types": "^7.21.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/memory-cache": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/@types/memory-cache/-/memory-cache-0.2.6.tgz", @@ -3838,6 +4003,262 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", + "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/type-utils": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", @@ -4073,6 +4494,23 @@ "node": ">=8" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -5370,6 +5808,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -5449,16 +5894,6 @@ "node": ">=8" } }, - "node_modules/diff": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", - "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/diff-match-patch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", @@ -5952,94 +6387,275 @@ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "hasInstallScript": true, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=10" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { + "node_modules/eslint/node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "license": "Apache-2.0", + "node_modules/eslint/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=8" } }, "node_modules/espree": { @@ -6059,6 +6675,42 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -6066,6 +6718,16 @@ "dev": true, "license": "MIT" }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -6237,6 +6899,27 @@ ], "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -6308,6 +6991,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-type": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.0.tgz", @@ -6456,6 +7152,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fn.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", @@ -6777,6 +7494,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7026,9 +7756,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", - "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.7.tgz", + "integrity": "sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==", "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.15", @@ -7039,7 +7769,7 @@ "micromatch": "^4.0.8" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^14.18.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/https-proxy-agent": { @@ -7101,6 +7831,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/image2uri": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/image2uri/-/image2uri-2.1.2.tgz", @@ -7707,6 +8447,13 @@ "node": ">=20" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -7721,6 +8468,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-with-bigint": { "version": "3.5.7", "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", @@ -7782,12 +8543,36 @@ "setimmediate": "^1.0.5" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -8655,6 +9440,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -8939,9 +9731,9 @@ } }, "node_modules/npm": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.17.0.tgz", - "integrity": "sha512-PurxiZexEHDTE4SSaLI3ZrnbAGiZfeyUcQcxcP5D+hfytNAze/D1IzDuInTn9XVLIbAQUnQuSPXJx02LHjLvQw==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.18.0.tgz", + "integrity": "sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -9020,8 +9812,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.8.0", - "@npmcli/config": "^10.11.0", + "@npmcli/arborist": "^9.9.0", + "@npmcli/config": "^10.12.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -9045,11 +9837,11 @@ "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.10", - "libnpmexec": "^10.3.0", - "libnpmfund": "^7.0.24", + "libnpmdiff": "^8.1.11", + "libnpmexec": "^10.3.1", + "libnpmfund": "^7.0.25", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.10", + "libnpmpack": "^9.1.11", "libnpmpublish": "^11.2.0", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", @@ -9065,7 +9857,7 @@ "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.2", "npm-pick-manifest": "^11.0.3", - "npm-profile": "^12.0.1", + "npm-profile": "^12.0.2", "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", @@ -9074,11 +9866,11 @@ "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", "read": "^5.0.1", - "semver": "^7.8.4", + "semver": "^7.8.5", "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.16", + "tar": "^7.5.19", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -9150,7 +9942,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.8.0", + "version": "9.9.0", "dev": true, "inBundle": true, "license": "ISC", @@ -9198,7 +9990,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.11.0", + "version": "10.12.0", "dev": true, "inBundle": true, "license": "ISC", @@ -9543,7 +10335,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.6", + "version": "5.0.7", "dev": true, "inBundle": true, "license": "MIT", @@ -9917,12 +10709,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.10", + "version": "8.1.11", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.0", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -9936,13 +10728,13 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.3.0", + "version": "10.3.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.0", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -9959,12 +10751,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.24", + "version": "7.0.25", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0" + "@npmcli/arborist": "^9.9.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -9984,12 +10776,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.10", + "version": "9.1.11", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.0", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -10358,13 +11150,13 @@ } }, "node_modules/npm/node_modules/npm-profile": { - "version": "12.0.1", + "version": "12.0.2", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0" + "proc-log": "^6.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -10569,7 +11361,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.8.4", + "version": "7.8.5", "dev": true, "inBundle": true, "license": "ISC", @@ -10694,7 +11486,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.16", + "version": "7.5.19", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -10790,7 +11582,7 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.26.0", + "version": "6.27.0", "dev": true, "inBundle": true, "license": "MIT", @@ -11006,6 +11798,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-each-series": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", @@ -11433,6 +12243,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -13770,6 +14590,19 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -13872,6 +14705,19 @@ "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", @@ -13915,6 +14761,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", + "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -13942,9 +14812,9 @@ } }, "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -14045,6 +14915,16 @@ "node": ">= 0.8" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/url-join": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", @@ -14528,6 +15408,16 @@ "node": ">= 6" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index 6e2a0f6..ba05b3b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@metricinsights/pp-dev", "type": "module", - "version": "0.19.0-beta.2", + "version": "1.0.0", "description": "Portal Page dev build tool", "bin": { "pp-dev": "bin/pp-dev.js" @@ -14,7 +14,7 @@ "prebuild": "npm run clean", "build": "npm run build:parallel", "build:parallel": "npm run build:node && npm run build:client", - "build:node": "rollup --config rollup.config.ts --configPlugin typescript && node scripts/postbuild.js", + "build:node": "rollup --config rollup.config.ts --configPlugin typescript && node scripts/postbuild.js && node scripts/build-types.mjs", "build:client": "rollup --config src/client/rollup.config.ts --configPlugin typescript", "build:watch": "rollup --config rollup.config.ts --configPlugin typescript --watch", "build:analyze": "rollup --config rollup.config.ts --configPlugin typescript --configPlugin rollup-plugin-visualizer", @@ -30,10 +30,14 @@ "test:unit:watch": "vitest --config vitest.config.ts", "test:unit:coverage": "vitest run --config vitest.config.ts --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:e2e:browser": "tsx scripts/run-playwright-e2e.ts", + "record:mi": "tsx tests/mock-mi/record.ts", "test:integration:watch": "vitest --config vitest.integration.config.ts", - "test:e2e": "node scripts/run-tests.js", "test:e2e:ui": "npx playwright test --ui", "test:all": "npm run test:unit && npm run test:integration", + "lint": "eslint src", + "lint:fix": "eslint src --fix", "audit:all": "node scripts/audit-all.mjs", "prereinstall:all": "npm run build", "reinstall:all": "node scripts/reinstall-all.mjs" @@ -67,7 +71,7 @@ }, "license": "ISC", "engines": { - "node": ">=22.14" + "node": ">=24" }, "overrides": { "@xmldom/xmldom": ">=0.8.12", @@ -79,10 +83,10 @@ "next@*": { "postcss": "8.5.10" }, - "npm": ">=11.13.0", + "npm": ">=11.18.0", "qs": ">=6.15.2", "serialize-javascript": ">=7.0.5", - "undici": ">=6.24.0", + "undici": "^7.28.0", "vite@*": { "postcss": "8.5.10" } @@ -109,7 +113,7 @@ "extract-zip": "^2.0.1", "file-type": "^22.0.0", "formdata-node": "^6.0.3", - "http-proxy-middleware": "^3.0.5", + "http-proxy-middleware": "^3.0.7", "isbinaryfile": "^6.0.0", "jsdom": "29.1.1", "jszip": "^3.10.1", @@ -135,7 +139,6 @@ "@rollup/plugin-url": "^8.0.2", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", - "@types/diff": "^8.0.0", "@types/diff-match-patch": "^1.0.36", "@types/ejs": "^3.1.5", "@types/express": "^5.0.6", @@ -145,6 +148,7 @@ "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.5", "connect": "^3.7.0", + "eslint": "^10.5.0", "next": "^16.2.7", "prettier": "^3.8.1", "rimraf": "^6.1.3", @@ -154,6 +158,8 @@ "semantic-release": "^25.0.3", "tslib": "^2.8.1", "tsx": "^4.21.0", + "typescript-eslint": "^8.62.0", + "undici": "^7.28.0", "vitest": "^4.1.5", "yargs": "^18.0.0" }, diff --git a/playwright.config.ts b/playwright.config.ts index c584b65..3776028 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -51,7 +51,10 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + ...(process.env.PLAYWRIGHT_USE_SYSTEM_CHROME ? { channel: 'chrome' } : {}), + }, outputDir: `test-results/${process.env.TEST_TYPE || 'all'}`, }, diff --git a/rollup.config.ts b/rollup.config.ts index 0bcf121..9bbb5f4 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -1,7 +1,6 @@ // @ts-nocheck import { defineConfig } from 'rollup'; import typescript from '@rollup/plugin-typescript'; -import dts from 'rollup-plugin-dts'; import terser from '@rollup/plugin-terser'; import * as path from 'path'; import * as fs from 'fs'; @@ -64,64 +63,6 @@ const defaultConfig = { context: 'globalThis', }; -function typeDefsMonitorPlugin() { - const timeoutMs = Number(process.env.PP_DEV_DTS_TIMEOUT_MS ?? 120_000); - const heartbeatMs = Number(process.env.PP_DEV_DTS_HEARTBEAT_MS ?? 10_000); - - let buildStartedAt = 0; - let heartbeat = null; - let timeout = null; - - const clearTimers = () => { - if (heartbeat) { - clearInterval(heartbeat); - heartbeat = null; - } - - if (timeout) { - clearTimeout(timeout); - timeout = null; - } - }; - - return { - name: 'type-defs-monitor', - buildStart() { - buildStartedAt = Date.now(); - console.info(`[rollup:dts] build started (timeout=${timeoutMs}ms heartbeat=${heartbeatMs}ms)`); - - heartbeat = setInterval(() => { - const elapsedMs = Date.now() - buildStartedAt; - - console.info(`[rollup:dts] still building... elapsed=${elapsedMs}ms`); - }, heartbeatMs); - heartbeat.unref?.(); - - timeout = setTimeout(() => { - const elapsedMs = Date.now() - buildStartedAt; - - console.error(`[rollup:dts] build exceeded timeout after ${elapsedMs}ms, aborting`); - process.exit(1); - }, timeoutMs); - timeout.unref?.(); - }, - buildEnd(error) { - clearTimers(); - - const elapsedMs = Date.now() - buildStartedAt; - - if (error) { - console.error(`[rollup:dts] build failed after ${elapsedMs}ms: ${error.message}`); - } else { - console.info(`[rollup:dts] build completed in ${elapsedMs}ms`); - } - }, - closeBundle() { - clearTimers(); - }, - }; -} - const configs = [ // ESM Build defineConfig({ @@ -218,28 +159,6 @@ const configs = [ preserveEntrySignatures: 'strict', }), - // Type Definitions - defineConfig({ - input: 'src/index.ts', - plugins: [ - typeDefsMonitorPlugin(), - dts({ - tsconfig: './tsconfig.types.json', - compilerOptions: { - declaration: true, - declarationMap: false, - sourceMap: false, - }, - respectExternal: true, - }), - ], - output: { - dir: path.dirname(pkg.types), - format: 'esm', - assetFileNames: '[name][extname]', - }, - external: isExternal, - }), ]; export default configs; diff --git a/rollup.config.types.ts b/rollup.config.types.ts new file mode 100644 index 0000000..5cd3f46 --- /dev/null +++ b/rollup.config.types.ts @@ -0,0 +1,44 @@ +// @ts-nocheck +import { defineConfig } from 'rollup'; +import dts from 'rollup-plugin-dts'; +import * as path from 'path'; +import * as fs from 'fs'; +import { builtinModules } from 'module'; + +const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); + +const externalDeps = [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), +]; + +const nodeBuiltins = new Set(builtinModules.flatMap((mod) => [mod, `node:${mod}`])); +const additionalExternal = new Set(['postcss', 'rollup', 'vite', 'estree']); + +const isExternal = (id) => { + if (nodeBuiltins.has(id)) return true; + if (externalDeps.includes(id)) return true; + if (additionalExternal.has(id)) return true; + return externalDeps.some((dep) => id.startsWith(`${dep}/`)); +}; + +export default defineConfig({ + input: 'src/index.ts', + plugins: [ + dts({ + tsconfig: './tsconfig.types.json', + compilerOptions: { + declaration: true, + declarationMap: false, + sourceMap: false, + }, + respectExternal: true, + }), + ], + output: { + dir: path.dirname(pkg.types), + format: 'esm', + assetFileNames: '[name][extname]', + }, + external: isExternal, +}); diff --git a/scripts/audit-all.mjs b/scripts/audit-all.mjs index 94a26c6..7623a96 100644 --- a/scripts/audit-all.mjs +++ b/scripts/audit-all.mjs @@ -1,6 +1,8 @@ /** * Runs `npm audit` in the repository root and every tests/* package that has a package.json. * Exits with code 1 if any audit reports vulnerabilities or fails. + * + * Audit level: "high" for every target. */ import { existsSync, readdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; @@ -37,13 +39,13 @@ if (existsSync(testsDir)) { */ function runNpmAudit(cwd) { if (process.platform === 'win32') { - return spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm audit'], { + return spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm audit --audit-level=high'], { cwd, stdio: 'inherit', }); } - return spawnSync('npm', ['audit'], { cwd, stdio: 'inherit' }); + return spawnSync('npm', ['audit', '--audit-level=high'], { cwd, stdio: 'inherit' }); } const results = []; diff --git a/scripts/build-types.mjs b/scripts/build-types.mjs new file mode 100644 index 0000000..c517c7c --- /dev/null +++ b/scripts/build-types.mjs @@ -0,0 +1,59 @@ +/** + * Runs the DTS build (rollup-plugin-dts) in a child process with an OS-level + * timeout + forced kill. On Windows, rollup-plugin-dts can leave TypeScript + * language-service handles alive after the build completes, making the process + * hang indefinitely. A plain setTimeout inside the rollup process won't help + * when TS blocks the event loop synchronously. Running it as a child process + * lets us kill the entire process tree from the outside. + */ +import { spawn, execSync } from 'child_process'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const rollupBin = resolve(root, 'node_modules/rollup/dist/bin/rollup'); +const TIMEOUT_MS = parseInt(process.env.PP_DEV_DTS_TIMEOUT_MS ?? '60000', 10); + +function killTree(pid) { + if (!pid) return; + if (process.platform === 'win32') { + try { + execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' }); + } catch { + // already gone + } + } else { + try { + process.kill(-pid, 'SIGKILL'); + } catch { + // already gone + } + } +} + +console.log(`[build:types] Starting DTS build (timeout=${TIMEOUT_MS}ms)`); + +const proc = spawn( + process.execPath, + [rollupBin, '--config', 'rollup.config.types.ts', '--configPlugin', 'typescript'], + { + cwd: root, + stdio: 'inherit', + detached: process.platform !== 'win32', + }, +); + +const timer = setTimeout(() => { + console.error(`[build:types] DTS build exceeded ${TIMEOUT_MS}ms — force-killing process tree`); + killTree(proc.pid); + process.exit(1); +}, TIMEOUT_MS); + +proc.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) { + console.error(`[build:types] DTS build failed (exit ${code})`); + process.exit(code ?? 1); + } + console.log('[build:types] DTS build completed successfully'); +}); diff --git a/scripts/run-playwright-e2e.ts b/scripts/run-playwright-e2e.ts new file mode 100644 index 0000000..a9574b3 --- /dev/null +++ b/scripts/run-playwright-e2e.ts @@ -0,0 +1,305 @@ +import { spawn, execSync, type ChildProcess } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { startMockMiServer, type MockMiServer, type MockMode } from '../tests/mock-mi/server.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const playwrightCli = path.join(root, 'node_modules', '@playwright', 'test', 'cli.js'); + +const fixtureDirs = { + commonjs: path.join(root, 'tests', 'test-commonjs'), + nextjs: path.join(root, 'tests', 'test-nextjs'), + 'nextjs-cjs': path.join(root, 'tests', 'test-nextjs-cjs'), +} as const; + +function unquoteEnvValue(value: string): string { + const trimmed = value.trim(); + + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + + return trimmed; +} + +function loadEnvFile(filePath: string): void { + if (!fs.existsSync(filePath)) { + return; + } + + for (const line of fs.readFileSync(filePath, 'utf-8').split(/\r?\n/)) { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)?\s*$/); + + if (!match || line.trimStart().startsWith('#')) { + continue; + } + + const [, key, rawValue = ''] = match; + + process.env[key] ??= unquoteEnvValue(rawValue.replace(/\s+#.*$/, '')); + } +} + +for (const fixtureDir of Object.values(fixtureDirs)) { + loadEnvFile(path.join(fixtureDir, '.env')); +} + +const playwrightArgs = process.argv.slice(2); +const hasWorkerArg = playwrightArgs.some((arg) => arg === '--workers' || arg.startsWith('--workers=')); +const defaultPlaywrightArgs = hasWorkerArg ? [] : ['--workers=1']; +const mockMode = (process.env.PP_DEV_E2E_MOCK_MODE ?? 'replay') as MockMode; +const cassetteName = process.env.PP_DEV_E2E_CASSETTE ?? 'startup'; +const realMiUrl = process.env.REAL_MI_URL ?? 'https://stg7x.metricinsights.com'; + +if (mockMode !== 'record' && mockMode !== 'replay') { + throw new Error(`Unsupported PP_DEV_E2E_MOCK_MODE "${mockMode}". Expected "record" or "replay".`); +} + +const defaultFixtureNames = ['commonjs', 'nextjs', 'nextjs-cjs'] as const; +type FixtureName = (typeof defaultFixtureNames)[number]; + +interface FixtureConfig { + testType: `dev-${FixtureName}`; + dir: string; + configFile: string; + port: number; + commandArgs: string[]; +} + +const fixtureConfigs: Record = { + commonjs: { + testType: 'dev-commonjs', + dir: fixtureDirs.commonjs, + configFile: 'pp-dev.config.ts', + port: Number(process.env.PP_DEV_E2E_COMMONJS_PORT ?? 3105), + commandArgs: [], + }, + nextjs: { + testType: 'dev-nextjs', + dir: fixtureDirs.nextjs, + configFile: 'pp-dev.config.ts', + port: Number(process.env.PP_DEV_E2E_NEXTJS_PORT ?? 3106), + commandArgs: ['next'], + }, + 'nextjs-cjs': { + testType: 'dev-nextjs-cjs', + dir: fixtureDirs['nextjs-cjs'], + configFile: 'pp-dev.config.js', + port: Number(process.env.PP_DEV_E2E_NEXTJS_CJS_PORT ?? 3107), + commandArgs: ['next'], + }, +}; + +const fixtureNames = (process.env.PP_DEV_E2E_FIXTURES ?? defaultFixtureNames.join(',')) + .split(',') + .map((name) => normalizeFixtureName(name)) + .filter((name, index, names) => names.indexOf(name) === index); + +function normalizeFixtureName(value: string): FixtureName { + const normalized = value.trim().replace(/^dev-/, '') as FixtureName; + + if (!Object.hasOwn(fixtureConfigs, normalized)) { + throw new Error(`Unknown browser E2E fixture "${value}". Expected one of: ${defaultFixtureNames.join(', ')}`); + } + + return normalized; +} + +function ppDevBinFor(fixtureDir: string): string { + return path.join(fixtureDir, 'node_modules', '@metricinsights', 'pp-dev', 'bin', 'pp-dev.js'); +} + +async function waitForExit(proc: ChildProcess, timeoutMs: number): Promise { + if (proc.exitCode !== null || proc.signalCode !== null) { + return true; + } + + return new Promise((resolve) => { + const timer = setTimeout(() => { + proc.off('exit', onExit); + resolve(false); + }, timeoutMs); + const onExit = () => { + clearTimeout(timer); + resolve(true); + }; + + proc.once('exit', onExit); + }); +} + +async function killTree(proc: ChildProcess): Promise { + if (!proc.pid) return; + + if (process.platform === 'win32') { + try { + execSync(`taskkill /PID ${proc.pid} /T /F`, { stdio: 'ignore' }); + } catch { + // already stopped + } + return; + } + + try { + process.kill(-proc.pid, 'SIGTERM'); + } catch { + proc.kill('SIGTERM'); + } + + if (await waitForExit(proc, 5_000)) { + return; + } + + try { + process.kill(-proc.pid, 'SIGKILL'); + } catch { + proc.kill('SIGKILL'); + } +} + +function spawnCommand( + command: string, + args: string[], + opts: { cwd?: string; env?: NodeJS.ProcessEnv; detached?: boolean } = {}, +): ChildProcess { + const env: NodeJS.ProcessEnv = { ...process.env, ...(opts.env ?? {}) }; + + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete env[key]; + } + } + + return spawn(command, args, { + cwd: opts.cwd ?? root, + env, + stdio: 'inherit', + detached: opts.detached, + }); +} + +function runCommand(command: string, args: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) { + return new Promise((resolve, reject) => { + const child = spawnCommand(command, args, opts); + + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}`)); + } + }); + child.on('error', reject); + }); +} + +async function waitForServer(url: string, proc: ChildProcess, timeoutMs = 60_000): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (proc.exitCode !== null || proc.signalCode !== null) { + throw new Error(`pp-dev exited before ${url} became ready`); + } + + try { + const res = await fetch(url, { + redirect: 'manual', + signal: AbortSignal.timeout(2_000), + }); + + if (res.status < 500) { + return; + } + } catch { + // server not ready yet + } + + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + throw new Error(`Timed out waiting for ${url}`); +} + +async function runFixture(fixture: FixtureConfig, mockMi: MockMiServer): Promise { + let server: ChildProcess | undefined; + const configPath = path.join(fixture.dir, fixture.configFile); + const baseURL = `http://localhost:${fixture.port}`; + const originalConfig = fs.readFileSync(configPath, 'utf-8'); + + try { + const patchedConfig = originalConfig.replace( + /url:\s*['"]https?:\/\/[^'"]+['"]/, + `url: '${mockMi.url}'`, + ); + + if (patchedConfig === originalConfig) { + throw new Error(`Could not patch ${fixture.testType} pp-dev config to use mock-mi`); + } + + fs.writeFileSync(configPath, patchedConfig); + + server = spawnCommand( + process.execPath, + [ppDevBinFor(fixture.dir), ...fixture.commandArgs, '--host', 'localhost', '--port', String(fixture.port), '--strictPort'], + { + cwd: fixture.dir, + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: undefined }, + detached: process.platform !== 'win32', + }, + ); + + await waitForServer(baseURL, server); + + await runCommand(process.execPath, [playwrightCli, 'test', ...defaultPlaywrightArgs, ...playwrightArgs], { + cwd: root, + env: { + ...process.env, + BASE_URL: baseURL, + TEST_TYPE: fixture.testType, + FORCE_COLOR: undefined, + }, + }); + } finally { + if (server) { + await killTree(server); + } + fs.writeFileSync(configPath, originalConfig); + } +} + +async function main() { + let mockMi: MockMiServer | undefined; + let completed = false; + + try { + mockMi = await startMockMiServer({ + mode: mockMode, + cassetteName, + realMiUrl, + }); + + for (const fixtureName of fixtureNames) { + const fixture = fixtureConfigs[fixtureName]; + + console.log(`\n[browser-e2e] Running ${fixture.testType} at http://localhost:${fixture.port}\n`); + await runFixture(fixture, mockMi); + } + + completed = true; + } finally { + if (completed) { + mockMi?.save?.(); + } + await mockMi?.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/api/assets-v7.ts b/src/api/assets-v7.ts index e1782f3..8a144b7 100644 --- a/src/api/assets-v7.ts +++ b/src/api/assets-v7.ts @@ -1,16 +1,16 @@ import { AssetsAPI } from './assets.js'; export class AssetsV7API extends AssetsAPI { - protected getDownloadUrl(portalPageId: number | string) { - return `/api/page/id/${portalPageId}/asset/download`; + protected getDownloadUrl(appId: number | string) { + return `/api/page/id/${appId}/asset/download`; } protected getDownloadTemplateUrl(templateId: number | string) { return `/api/page_template/id/${templateId}/asset/download`; } - protected getUploadUrl(portalPageId: number | string) { - return `/api/page/id/${portalPageId}/asset/upload`; + protected getUploadUrl(appId: number | string) { + return `/api/page/id/${appId}/asset/upload`; } protected getUploadTemplateUrl(templateId: number | string) { diff --git a/src/api/assets.ts b/src/api/assets.ts index 2339ac1..723870b 100644 --- a/src/api/assets.ts +++ b/src/api/assets.ts @@ -10,25 +10,25 @@ export class AssetsAPI extends BaseAPI { super(axios); } - protected getDownloadUrl(portalPageId: number | string) { - return `/admin/page/downloadassets/id/${portalPageId}`; + protected getDownloadUrl(appId: number | string) { + return `/admin/page/downloadassets/id/${appId}`; } protected getDownloadTemplateUrl(templateId: number | string) { return `/admin/pagetemplate/downloadassets/id/${templateId}`; } - protected getUploadUrl(portalPageId: number | string) { - return `/admin/page/uploadassets/id/${portalPageId}`; + protected getUploadUrl(appId: number | string) { + return `/admin/page/uploadassets/id/${appId}`; } protected getUploadTemplateUrl(templateId: number | string) { return `/admin/pagetemplate/uploadassets/id/${templateId}`; } - async downloadPageAssets(portalPageId: number | string, headers?: Headers) { + async downloadPageAssets(appId: number | string, headers?: Headers) { return this.axios - .get(this.getDownloadUrl(portalPageId), { + .get(this.getDownloadUrl(appId), { withCredentials: true, headers: Object.assign({}, headers, { accept: '*/*' }), responseType: 'arraybuffer', @@ -38,11 +38,11 @@ export class AssetsAPI extends BaseAPI { /** * Upload page assets. - * @param portalPageId + * @param appId * @param file - Zip file with assets * @param headers */ - async uploadPageAssets(portalPageId: number | string, file: Buffer, headers?: Headers) { + async uploadPageAssets(appId: number | string, file: Buffer, headers?: Headers) { const formData = new (await this.formdataModulePromise).FormData(); const { File } = await this.formdataModulePromise; @@ -50,7 +50,7 @@ export class AssetsAPI extends BaseAPI { formData.append('file', assetFile); - const url = this.getUploadUrl(portalPageId); + const url = this.getUploadUrl(appId); return this.axios .post<{ status?: 'OK' }>(url, formData, { diff --git a/src/cli.ts b/src/cli.ts index a858b5b..9fe686b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,16 @@ import { ClientService } from './lib/client.service.js'; import { DistService } from './lib/dist.service.js'; import { PPDevHotServer } from './lib/pp-ws-server.js'; import { injectDevPanel, createDevPanelAssetMiddleware } from './lib/dev-panel.js'; -import { PP_DEV_CONFIG_NAMES, PP_WATCH_CONFIG_NAMES } from './constants.js'; +import { + PP_DEV_CONFIG_NAMES, + PATH_PAGE_PREFIX, + PATH_TEMPLATE_PREFIX, + PATH_TEMPLATE_LOCAL_PREFIX, +} from './constants.js'; +import { normalizePPDevConfig, validatePPDevConfig } from './plugin.js'; +import { RequestStore } from './lib/request-store.js'; +import { createRequestCaptureMiddleware } from './lib/request-capture.middleware.js'; +import { registerInspectorRoutes, INSPECTOR_PATH } from './lib/request-inspector.js'; const cli = cac('pp-dev'); @@ -46,7 +55,6 @@ function createConfigWatcher( ): ConfigWatcher { const configFiles = [ ...PP_DEV_CONFIG_NAMES, - ...PP_WATCH_CONFIG_NAMES, 'package.json', 'next.config.js', 'next.config.mjs', @@ -218,6 +226,7 @@ export const stopProfiler = (log: (message: string) => void): void | Promise(options: T) => { } } }; + /** * removing global flags before passing as command specific sub-configs */ function cleanOptions(options: Options): Omit { const ret = { ...options }; + delete ret['--']; delete ret.c; delete ret.config; @@ -290,7 +301,10 @@ cli const logger = createLogger(options.logLevel); const startServer = async () => { - if (isRestarting) return; + if (isRestarting) { + return; + } + isRestarting = true; try { @@ -302,7 +316,8 @@ cli } // Clear config cache - const { clearConfigCache } = await import('./config.js'); + const { clearConfigCache, getConfig } = await import('./config.js'); + clearConfigCache(); // output structure is preserved even after bundling so require() @@ -370,6 +385,16 @@ cli logger.info(`\n ${colors.green(`${colors.bold('PP-DEV')} v${VERSION}`)} ${startupDurationString}\n`); server.printUrls(); + + const serveConfig = await getConfig(); + + if (serveConfig.inspector?.enabled !== false) { + const localUrl = server.resolvedUrls?.local[0]; + const origin = localUrl ? new URL(localUrl).origin : `http://localhost:${server.config.server.port ?? 5173}`; + + logger.info(`\n ${colors.cyan('🔍 Request Inspector')} ${colors.dim(origin + INSPECTOR_PATH)}\n`); + } + bindShortcuts(server, { print: true, customShortcuts: [ @@ -538,6 +563,7 @@ cli // Clear config cache const { clearConfigCache } = await import('./config.js'); + clearConfigCache(); const { join, basename } = await import('path'); @@ -596,46 +622,40 @@ cli logger.info(colors.blue(`🔧 Loaded pp-dev config from Next.js config`)); } - // Extract configuration values with defaults - const { - backendBaseURL = process.env.MI_BACKEND_URL || 'http://localhost:8080', - appId: originalAppId, - portalPageId, - templateLess = true, - v7Features = true, - disableSSLValidation = false, - enableProxyCache = true, - proxyCacheTTL = 600000, - personalAccessToken = process.env.MI_ACCESS_TOKEN, - miHudLess = false, - distZip = true, - } = ppDevConfig; - - const appId: number = - originalAppId ?? - portalPageId ?? - (process.env.MI_APP_ID ? parseInt(process.env.MI_APP_ID, 10) || undefined : undefined) ?? - (process.env.MI_PORTAL_PAGE_ID ? parseInt(process.env.MI_PORTAL_PAGE_ID, 10) || undefined : undefined) ?? - 1; - // Get template name from config, package.json, or fallback to project directory name let templateName: string | null = null; - if (!templateName) { - try { - const { getPkg } = await import('./config.js'); - const pkg = getPkg(); + try { + const { getPkg } = await import('./config.js'); + const pkg = getPkg(); - templateName = pkg.name; - } catch (error) { - // Fallback to project directory name - templateName = basename(projectRoot); - } + templateName = pkg.name; + } catch { + // Fallback to project directory name + templateName = basename(projectRoot); } - // Calculate base path using the same logic as the plugin - const pathPagePrefix = '/p'; // templateLess = true - use /p - const pathTemplateLocalPrefix = '/pl'; // templateLess = false && v7Features = true - use /pl + // Normalize grouped PPDevConfig to internal flat options + validatePPDevConfig(ppDevConfig, templateName ?? ''); + + const _normalized = normalizePPDevConfig(ppDevConfig, templateName ?? ''); + const backendBaseURL = _normalized.backendBaseURL ?? 'http://localhost:8080'; + const templateLess = _normalized.templateLess; + const v7Features = _normalized.v7Features; + const disableSSLValidation = _normalized.disableSSLValidation; + const enableProxyCache = _normalized.enableProxyCache; + const proxyCacheTTL = _normalized.proxyCacheTTL; + const personalAccessToken = _normalized.personalAccessToken; + const miHudLess = _normalized.miHudLess; + const inspectorEnabled = _normalized.inspectorEnabled; + const inspectorMaxMemory = _normalized.inspectorMaxMemory; + const inspectorCaptureLimit = _normalized.inspectorCaptureLimit; + + const appId: number = + _normalized.appId ?? + (process.env.MI_APP_ID ? parseInt(process.env.MI_APP_ID, 10) || undefined : undefined) ?? + (process.env.MI_PORTAL_PAGE_ID ? parseInt(process.env.MI_PORTAL_PAGE_ID, 10) || undefined : undefined) ?? + 1; const configBasePath = config?.basePath; let base = ''; @@ -643,7 +663,7 @@ cli if (configBasePath) { base = configBasePath; } else { - base = templateLess ? pathPagePrefix : v7Features ? pathTemplateLocalPrefix : '/pt'; + base = templateLess ? PATH_PAGE_PREFIX : v7Features ? PATH_TEMPLATE_LOCAL_PREFIX : PATH_TEMPLATE_PREFIX; base += `/${templateName}`; } @@ -669,7 +689,7 @@ cli conf: { ...config, basePath: base, - assetPrefix: `${templateLess ? pathPagePrefix : '/pt'}/${templateName}`, + assetPrefix: base, }, }; @@ -692,6 +712,7 @@ cli if (!isNextTurbopackNativeBindingsError(e)) { throw e; } + logger.warn(colors.yellow('⚠ Turbopack is unavailable (native bindings). Falling back to Webpack.')); await createAndPrepareNext({ webpack: true }); } @@ -702,6 +723,7 @@ cli if (!isNextTurbopackNativeBindingsError(e)) { throw e; } + logger.warn( colors.yellow('⚠ Turbopack cannot run (native Next.js bindings unavailable). Falling back to Webpack.'), ); @@ -767,20 +789,24 @@ cli if (middlewareIndex >= essentialMiddlewareChain.length) { // Essential middlewares processed, continue with Next.js handling processNextJSRequest(); + return; } const middleware = essentialMiddlewareChain[middlewareIndex]; + middlewareIndex++; middleware(req, res, runEssentialMiddleware); }; runEssentialMiddleware(); + return; // Exit early, middleware will handle the rest } else { // No essential middlewares, process normally processNextJSRequest(); + return; } } @@ -792,16 +818,19 @@ cli if (middlewareIndex >= fullMiddlewareChain.length) { // All middlewares processed, continue with Next.js handling processNextJSRequest(); + return; } const middleware = fullMiddlewareChain[middlewareIndex]; + middlewareIndex++; middleware(req, res, runMiddleware); }; runMiddleware(); + return; // Exit early, middleware will handle the rest } @@ -816,8 +845,10 @@ cli } else if (originalPathname === base.replace(/\/$/, '')) { // Path without trailing slash - redirect to canonical URL with trailing slash const redirectUrl = originalUrl.replace(originalPathname, base); + res.writeHead(302, { Location: redirectUrl }); res.end(); + return; } else if ( originalPathname.startsWith('/_next/') || @@ -838,7 +869,9 @@ cli await handle(req, res, parsedUrl); } } catch (error) { - console.error(`[DEBUG] Error:`, error); + logger.error(`Error handling request: ${error instanceof Error ? error.message : String(error)}`, { + error: error instanceof Error ? error : undefined, + }); res.statusCode = 500; res.end('Internal Server Error'); } @@ -851,6 +884,17 @@ cli /** Essential middlewares for internal Next.js routes (e.g. /_next/, /favicon.ico). Only redirect and internal server; skips proxy/cache for faster static asset handling. */ let essentialMiddlewareChain: Array<(req: any, res: any, next: () => void) => void> = []; + // Request inspector: register routes on the shared internalServer and add capture middleware + if (inspectorEnabled !== false) { + const reqStore = new RequestStore(inspectorMaxMemory); + + registerInspectorRoutes(internalServer, reqStore, inspectorCaptureLimit); + + const captureMiddleware = createRequestCaptureMiddleware(reqStore, inspectorCaptureLimit); + + fullMiddlewareChain.push(captureMiddleware); + } + if (backendBaseURL) { const baseUrlHost = new URL(backendBaseURL).host; @@ -865,7 +909,6 @@ cli referer: backendBaseURL, origin: backendBaseURL.replace(/^(https?:\/\/)([^/]+)(\/.*)?$/i, '$1$2'), }, - portalPageId: appId, appId, templateLess, disableSSLValidation, @@ -883,17 +926,20 @@ cli const ppRedirectWrapper = (req: any, res: any, next: () => void) => { ppRedirectMiddleware(req, res, next); }; + essentialMiddlewareChain.push(ppRedirectWrapper); fullMiddlewareChain.push(ppRedirectWrapper); // 1b. Dev-panel client assets (client.js/client.css) — served locally, never proxied. const devPanelAssetMiddleware = createDevPanelAssetMiddleware(base); + essentialMiddlewareChain.push(devPanelAssetMiddleware); fullMiddlewareChain.push(devPanelAssetMiddleware); // 2. Proxy Cache middleware (only for non-internal routes) if (enableProxyCache) { let ttl = +proxyCacheTTL; + if (!ttl || Number.isNaN(ttl) || ttl < 0) { ttl = 10 * 60 * 1000; // 10 minutes } @@ -915,6 +961,7 @@ cli const proxyCacheWrapper = (req: any, res: any, next: () => void) => { proxyCacheMiddleware(req, res, next); }; + fullMiddlewareChain.push(proxyCacheWrapper); logger.info(colors.blue(`🔧 Proxy cache middleware added with TTL: ${ttl}ms`)); @@ -922,7 +969,11 @@ cli // 3. Load PP Data middleware (only for non-internal routes; before proxy so v7 internal page name is available for `/data/page/` rewrites) const isIndexRegExp = new RegExp(`^((${escapeRegExp(base)})|/)$`); - const loadPPDataMiddleware = initLoadPPData(isIndexRegExp, mi, Object.assign({ base }, miConfig)); + const loadPPDataMiddleware = initLoadPPData( + isIndexRegExp, + mi, + Object.assign({ base }, miConfig, { miHudLess }), + ); const loadPPDataWrapper = (req: any, res: any, next: () => void) => { loadPPDataMiddleware(req, res, next); }; @@ -957,26 +1008,29 @@ cli disableSSLValidation, miAPI: mi, templateName: templateName ?? undefined, - }) as any; + }); const proxyPassWrapper = (req: any, res: any, next: () => void) => { proxyPassMiddlewareInstance(req, res, next); }; + fullMiddlewareChain.push(proxyPassWrapper); - // 5. Internal Server middleware (API endpoints) - essential for all routes + // 5. Internal Server middleware (API endpoints + inspector UI) - essential for all routes const internalServerMiddleware = internalServer; const internalServerWrapper = (req: any, res: any, next: () => void) => { - // Check if this is an internal API request - if (req.url?.startsWith('/@api/')) { + // Check if this is an internal pp-dev request (API or inspector UI) + if (req.url?.startsWith('/@api/') || req.url?.startsWith(INSPECTOR_PATH)) { const mockNext = () => {}; internalServerMiddleware(req, res, mockNext); - return; // Don't call next() for API requests + return; // Don't call next() for internal pp-dev requests } + next(); }; + essentialMiddlewareChain.push(internalServerWrapper); fullMiddlewareChain.push(internalServerWrapper); @@ -984,6 +1038,7 @@ cli const rewriteResponseMiddleware = initRewriteResponse( (url) => { const pathname = url.split('?')[0]; + // Next.js serves page HTML at base path or subpaths (e.g. /p/test-nextjs/, /p/test-nextjs/dashboard), // not at index.html. Match page requests under base, excluding /_next/ static assets. return pathname.startsWith(base) && !pathname.includes('/_next/'); @@ -995,7 +1050,7 @@ cli const withPanel = injectDevPanel(page, base, { backendBaseURL, templateLess, - portalPageId: appId, + appId, }); return Buffer.from(urlReplacer(baseUrlHost, req.headers.host ?? '', withPanel)); @@ -1005,6 +1060,7 @@ cli const rewriteResponseWrapper = (req: any, res: any, next: () => void) => { rewriteResponseMiddleware(req, res, next); }; + fullMiddlewareChain.push(rewriteResponseWrapper); // Dev-panel transport: raw-WebSocket replacement for Vite HMR so the panel's @@ -1046,7 +1102,7 @@ cli } const distService = - distZip !== false + _normalized.distZip !== false ? new DistService(templateName ?? basename(projectRoot), { nextBuild: { projectRoot, @@ -1091,6 +1147,11 @@ cli httpServer.listen(port, host, () => { logger.info(colors.green(`✅ pp-dev Next.js server running at http://${host}:${port}`)); logger.info(colors.blue(`📱 Next.js app accessible at http://${host}:${port}${base}`)); + + if (inspectorEnabled !== false) { + logger.info(colors.cyan(`🔍 Request Inspector: http://${host}:${port}${INSPECTOR_PATH}`)); + } + logger.info(colors.blue(`🔧 Base path handling active`)); // Set up config watcher @@ -1126,6 +1187,7 @@ cli for (const socket of Array.from(openSockets)) { socket.destroy(); } + openSockets.clear(); // Close the dev-panel WebSocket server @@ -1304,6 +1366,7 @@ cli ) .action(async (root: string, options: PPDevBuildOptions & GlobalCLIOptions) => { filterDuplicateOptions(options); + const buildOptions: PPDevBuildOptions = cleanOptions(options); try { @@ -1605,6 +1668,171 @@ cli }, ); +// migrate +cli + .command('migrate [config]', 'migrate pp-dev config from 0.x flat format to 1.0 grouped format') + .option('--dry-run', '[boolean] print migrated config without writing any files') + .option('--format ', '[string] output format: ts (default), js, json') + .option('--output ', '[string] output file path (default: pp-dev.config.ts)') + .option('--no-backup', '[boolean] skip backup of original config file') + .action( + async ( + configArg: string | undefined, + options: { dryRun?: boolean; format?: string; output?: string; backup?: boolean } & GlobalCLIOptions, + ) => { + const logger = createLogger(options.logLevel); + + const { + isLegacyFlatConfig, + isLegacyPPWatchConfig, + isAlreadyMigrated, + migrateLegacyFlatConfig, + migratePPWatchConfig, + generateConfigFileContent, + } = await import('./lib/migrate.js'); + + const format = (options.format ?? 'ts') as 'ts' | 'js' | 'json'; + const doBackup = options.backup !== false; + const projectRoot = process.cwd(); + + // Discover config file to migrate + const watchConfigNames = [ + '.pp-watch.config.ts', + '.pp-watch.config.js', + '.pp-watch.config.json', + 'pp-watch.config.ts', + 'pp-watch.config.js', + 'pp-watch.config.json', + ]; + + let sourceFile: string | null = configArg ?? null; + let isWatchConfig = false; + + if (!sourceFile) { + // Try pp-dev config files first + for (const name of PP_DEV_CONFIG_NAMES) { + if (fs.existsSync(path.join(projectRoot, name))) { + sourceFile = path.join(projectRoot, name); + break; + } + } + + // Fall back to pp-watch config files + if (!sourceFile) { + for (const name of watchConfigNames) { + if (fs.existsSync(path.join(projectRoot, name))) { + sourceFile = path.join(projectRoot, name); + isWatchConfig = true; + break; + } + } + } + } + + if (!sourceFile) { + logger.warn(colors.yellow('No pp-dev or pp-watch config file found in the current directory.')); + logger.info(colors.blue('Supported files: pp-dev.config.{ts,js,cjs,mjs,json}, .pp-watch.config.{ts,js,json}')); + process.exit(1); + } + + logger.info(colors.blue(`Found config: ${path.relative(projectRoot, sourceFile)}`)); + + // Load the config + const { getConfig, getPkg } = await import('./config.js'); + const pkg = getPkg(); + let rawConfig: Record = {}; + + try { + // Use a temporary import that bypasses the new PPDevConfig type + if (/\.[cm]?ts$/i.test(sourceFile)) { + const esbuild = await import('esbuild'); + const { pathToFileURL } = await import('url'); + const result = await esbuild.build({ + absWorkingDir: projectRoot, + entryPoints: [sourceFile], + outfile: 'out.js', + write: false, + target: 'node24', + platform: 'node', + bundle: true, + packages: 'external', + format: 'esm', + mainFields: ['main'], + }); + const code = result.outputFiles[0].text; + const tmpFile = `pp-migrate-tmp-${Date.now()}.mjs`; + + fs.writeFileSync(tmpFile, code); + try { + const mod = await import(pathToFileURL(path.resolve(projectRoot, tmpFile)).toString()); + + rawConfig = mod.default?.default ?? mod.default ?? mod; + } finally { + if (fs.existsSync(tmpFile)) { + fs.unlinkSync(tmpFile); + } + } + } else if (/\.[cm]?js$/i.test(sourceFile)) { + const { pathToFileURL } = await import('url'); + const mod = await import(pathToFileURL(path.resolve(projectRoot, sourceFile)).toString()); + + rawConfig = mod.default?.default ?? mod.default ?? mod; + } else if (sourceFile.endsWith('.json')) { + rawConfig = JSON.parse(fs.readFileSync(sourceFile, 'utf-8')); + } + } catch (e: any) { + logger.error(colors.red(`Failed to load config file: ${e.message}`)); + process.exit(1); + } + + if (!rawConfig || typeof rawConfig !== 'object') { + logger.error(colors.red('Config file did not export a valid object.')); + process.exit(1); + } + + // Detect format and migrate + let migratedConfig; + + if (isAlreadyMigrated(rawConfig)) { + logger.info(colors.green('Config is already in 1.0 format — nothing to migrate.')); + process.exit(0); + } else if (isWatchConfig || isLegacyPPWatchConfig(rawConfig)) { + logger.info(colors.blue('Detected pp-watch config format → migrating to 1.0')); + migratedConfig = migratePPWatchConfig(rawConfig as any); + } else if (isLegacyFlatConfig(rawConfig)) { + logger.info(colors.blue('Detected 0.x flat config format → migrating to 1.0')); + migratedConfig = migrateLegacyFlatConfig(rawConfig, pkg.name); + } else { + logger.warn(colors.yellow('Could not detect config format. No known keys found.')); + process.exit(1); + } + + const outputContent = generateConfigFileContent(migratedConfig, format); + const outputFile = options.output ?? path.join(projectRoot, `pp-dev.config.${format}`); + + if (options.dryRun) { + logger.info(colors.green(`\n--- Migrated config (dry-run) → ${path.relative(projectRoot, outputFile)} ---\n`)); + console.log(outputContent); + process.exit(0); + } + + // Backup original + if (doBackup && fs.existsSync(sourceFile)) { + const backupPath = `${sourceFile}.bak`; + + fs.copyFileSync(sourceFile, backupPath); + logger.info(colors.blue(`Backed up original to: ${path.relative(projectRoot, backupPath)}`)); + } + + fs.writeFileSync(outputFile, outputContent, 'utf-8'); + logger.info(colors.green(`✅ Migration complete → ${path.relative(projectRoot, outputFile)}`)); + + if (sourceFile !== outputFile && fs.existsSync(sourceFile)) { + logger.info(colors.yellow(`You can now delete the old config: ${path.relative(projectRoot, sourceFile)}`)); + } + }, + ); + cli.help(); cli.version(VERSION); diff --git a/src/client/assets/css/client.scss b/src/client/assets/css/client.scss index 0b8a9c6..7c36a0a 100644 --- a/src/client/assets/css/client.scss +++ b/src/client/assets/css/client.scss @@ -1,13 +1,13 @@ -$pp-dev-height: 30px; +$pp-dev-height: 32px; .pp-dev-info-namespace { // Define theme colors - --pp-dev-info-color-primary: #007bff; - --pp-dev-info-color-secondary: #6c757d; - --pp-dev-info-color-success: #28a745; + --pp-dev-info-color-primary: #075b7e; + --pp-dev-info-color-secondary: rgba(34, 34, 34, 0.64); + --pp-dev-info-color-success: #077e45; --pp-dev-info-color-info: #17a2b8; - --pp-dev-info-color-warning: #ffc107; - --pp-dev-info-color-danger: #dc3545; + --pp-dev-info-color-warning: #ffb000; + --pp-dev-info-color-danger: #ac2b2b; --pp-dev-info-color-light: #f8f9fa; --pp-dev-info-color-dark: #343a40; --pp-dev-info-color-white: #ffffff; @@ -18,20 +18,20 @@ $pp-dev-height: 30px; top: 10px; right: 10px; width: max-content; - max-width: 300px; + max-width: 280px; height: max-content; max-height: max-content; background-color: var(--pp-dev-info-color-white); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08); - border-radius: 8px; + border-radius: 2px; display: flex; flex-direction: column; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; z-index: 100001; - border: 1px solid var(--pp-dev-info-color-secondary); + border: 2px solid var(--pp-dev-info-color-secondary); transform: translateX(0); opacity: 1; backdrop-filter: blur(8px); @@ -63,18 +63,26 @@ $pp-dev-height: 30px; display: flex; align-items: center; justify-content: space-between; + gap: 8px; padding: 12px 16px; height: auto; min-height: 30px; width: 100%; box-sizing: border-box; border-bottom: 1px solid var(--pp-dev-info-color-light); - background-color: var(--pp-dev-info-color-secondary); + color: var(--pp-dev-info-color-dark); + + &-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } &-text { + flex: 1; font-size: 14px; font-weight: 600; - color: var(--pp-dev-info-color-white); line-height: 1.4; } @@ -85,12 +93,12 @@ $pp-dev-height: 30px; display: flex; align-items: center; justify-content: center; - color: var(--pp-dev-info-color-white); border-radius: 4px; transition: background-color 0.2s ease; + flex-shrink: 0; &:hover { - background-color: rgba(255, 255, 255, 0.1); + background-color: rgba(0, 0, 0, 0.08); } svg { @@ -112,17 +120,18 @@ $pp-dev-height: 30px; width: 100%; line-height: 1.5; font-size: 13px; + color: var(--pp-dev-info-color-dark); } &--success { border-color: var(--pp-dev-info-color-success); box-shadow: - 0 4px 6px rgba(40, 167, 69, 0.1), - 0 1px 3px rgba(40, 167, 69, 0.08); + 0 4px 6px rgba(7, 126, 69, 0.1), + 0 1px 3px rgba(7, 126, 69, 0.08); .pp-dev-info__popup { &-title { - background-color: var(--pp-dev-info-color-success); + color: var(--pp-dev-info-color-success); } &-content { @@ -138,12 +147,12 @@ $pp-dev-height: 30px; &--danger { border-color: var(--pp-dev-info-color-danger); box-shadow: - 0 4px 6px rgba(220, 53, 69, 0.1), - 0 1px 3px rgba(220, 53, 69, 0.08); + 0 4px 6px rgba(172, 43, 43, 0.1), + 0 1px 3px rgba(172, 43, 43, 0.08); .pp-dev-info__popup { &-title { - background-color: var(--pp-dev-info-color-danger); + color: var(--pp-dev-info-color-danger); } &-content { @@ -164,7 +173,7 @@ $pp-dev-height: 30px; .pp-dev-info__popup { &-title { - background-color: var(--pp-dev-info-color-info); + color: var(--pp-dev-info-color-info); } &-content { @@ -180,13 +189,12 @@ $pp-dev-height: 30px; &--warning { border-color: var(--pp-dev-info-color-warning); box-shadow: - 0 4px 6px rgba(255, 193, 7, 0.1), - 0 1px 3px rgba(255, 193, 7, 0.08); + 0 4px 6px rgba(255, 176, 0, 0.1), + 0 1px 3px rgba(255, 176, 0, 0.08); .pp-dev-info__popup { &-title { - background-color: var(--pp-dev-info-color-warning); - color: var(--pp-dev-info-color-dark); + color: var(--pp-dev-info-color-warning); } &-content { @@ -214,13 +222,11 @@ $pp-dev-height: 30px; } .pp-dev-info__confirm { - width: min(500px, calc(100vw - 32px)); - border-radius: 10px; + width: min(408px, calc(100vw - 32px)); + border-radius: 3px; border: 1px solid rgba(0, 0, 0, 0.15); background-color: var(--pp-dev-info-color-white); - box-shadow: - 0 18px 40px rgba(0, 0, 0, 0.35), - 0 2px 6px rgba(0, 0, 0, 0.2); + box-shadow: 0px 8px 32px 0px rgba(34, 34, 34, 0.4); overflow: hidden; &-title { @@ -242,13 +248,13 @@ $pp-dev-height: 30px; &-actions { display: flex; - justify-content: flex-end; + flex-direction: column; gap: 8px; padding: 0 16px 16px; } &-btn { - min-width: 110px; + width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid transparent; @@ -284,11 +290,13 @@ $pp-dev-height: 30px; top: calc(100% - $pp-dev-height); right: 0; text-align: center; - padding: 8px 12px; + padding: 2px; + gap: 8px; background-color: var(--pp-dev-info-color-white); - border-radius: 8px 0 0 0; + border-radius: 4px 0 0 0; + border-bottom: 1px solid rgba(34, 34, 34, 0.08); color: var(--pp-dev-info-color-dark); - box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1); + box-shadow: -2px -2px 8px 0px rgba(34, 34, 34, 0.08); box-sizing: border-box; display: flex; align-items: center; @@ -303,8 +311,8 @@ $pp-dev-height: 30px; // and the extension is still usable * { color: var(--pp-dev-info-color-dark); - font-family: sans-serif; - font-size: 13px; + font-family: 'Inter', sans-serif; + font-size: 12px; font-weight: 300; line-height: 1.5; letter-spacing: 0.00938em; @@ -340,9 +348,9 @@ $pp-dev-height: 30px; right: 0; top: 50%; transform: translateY(-50%); - height: 12px; + height: 16px; width: 1px; - background-color: rgba(0, 0, 0, 0.15); + background-color: rgba(34, 34, 34, 0.24); @media screen and (prefers-color-scheme: dark) { background-color: rgba(255, 255, 255, 0.2); @@ -362,7 +370,7 @@ $pp-dev-height: 30px; display: flex; align-items: center; gap: 8px; - font-size: 13px; + font-size: 12px; line-height: 1.5; } @@ -381,7 +389,7 @@ $pp-dev-height: 30px; transition: color 0.2s ease; display: flex; align-items: center; - gap: 4px; + gap: 8px; &:hover { color: var(--pp-dev-info-color-primary); @@ -447,16 +455,15 @@ $pp-dev-height: 30px; display: flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; - margin-right: 12px; - padding: 4px; + width: 28px; + height: 28px; + padding: 6px; cursor: pointer; - border-radius: 4px; + border-radius: 3px; transition: background-color 0.2s ease; &:hover { - background-color: rgba(0, 0, 0, 0.05); + background-color: rgba(7, 91, 126, 0.06); animation: arrowHover 0.8s linear infinite; } @@ -487,13 +494,11 @@ $pp-dev-height: 30px; display: flex; align-items: center; justify-content: center; - width: 24px; - height: 26px; - padding: 4px; - margin-left: 12px; + width: 28px; + height: 28px; + padding: 6px; background-color: transparent; - border: none; - border-radius: 4px; + border-radius: 3px; cursor: pointer; color: var(--pp-dev-info-color-primary); transition: all 0.2s ease; @@ -510,7 +515,7 @@ $pp-dev-height: 30px; background-color: currentColor; opacity: 0; transition: opacity 0.2s ease; - border-radius: 4px; + border-radius: 3px; } svg { @@ -521,6 +526,7 @@ $pp-dev-height: 30px; &:hover:not(.disabled) { color: var(--pp-dev-info-color-primary); + background-color: rgba(7, 91, 126, 0.06); svg { transform: scale(1.1) rotate(45deg); @@ -548,6 +554,18 @@ $pp-dev-height: 30px; } } + &__sep { + display: inline-flex; + flex-shrink: 0; + width: 1px; + height: 16px; + background-color: rgba(34, 34, 34, 0.24); + + @media screen and (prefers-color-scheme: dark) { + background-color: rgba(255, 255, 255, 0.2); + } + } + @keyframes rotate { from { transform: rotate(0deg); @@ -566,7 +584,7 @@ $pp-dev-height: 30px; } .custom-font { - font-family: sans-serif; + font-family: 'Inter', sans-serif; font-size: 13px; font-weight: 300; vertical-align: middle; @@ -603,12 +621,12 @@ $pp-dev-height: 30px; @media screen and (prefers-color-scheme: dark) { & { // Dark theme colors - --pp-dev-info-color-primary: #007bff; - --pp-dev-info-color-secondary: #a4abb6; - --pp-dev-info-color-success: #56f000; + --pp-dev-info-color-primary: #2a8db5; + --pp-dev-info-color-secondary: rgba(220, 220, 220, 0.64); + --pp-dev-info-color-success: #0fad5e; --pp-dev-info-color-info: #2dccff; --pp-dev-info-color-warning: #ffb302; - --pp-dev-info-color-danger: #ff3838; + --pp-dev-info-color-danger: #e05252; --pp-dev-info-color-light: #343a40; --pp-dev-info-color-dark: #f8f9fa; --pp-dev-info-color-white: #000000; diff --git a/src/client/hot-context.ts b/src/client/hot-context.ts index 68150fd..59aa4fa 100644 --- a/src/client/hot-context.ts +++ b/src/client/hot-context.ts @@ -146,6 +146,7 @@ export function createPPDevHotContext(): ViteHotContext { if (outbox.length >= MAX_OUTBOX_SIZE) { outbox.shift(); } + outbox.push(payload); } }, diff --git a/src/client/index.html b/src/client/index.html index 16258f8..ab4844b 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -35,7 +35,7 @@
Template mode: - {% if (templateLess || !backendBaseURL || Number.isNaN(+portalPageId)) { %} + {% if (templateLess || !backendBaseURL || Number.isNaN(+appId)) { %} Disabled {% } else { %} Enabled @@ -43,22 +43,20 @@
- {% if (backendBaseURL && !Number.isNaN(+portalPageId)) { %} + {% if (backendBaseURL && !Number.isNaN(+appId)) { %}
- Portal page ID: - {%= portalPageId %}App ID: + {%= appId %}
{% } %}
- {% if (backendBaseURL && !Number.isNaN(+portalPageId)) { %} + {% if (backendBaseURL && !Number.isNaN(+appId)) { %} + +
+
+
+
+ +
+ + + +
+
+
+
+
📡
+
No requests captured yet
+
Requests to the dev server will appear here
+
+
+
+
+
+
+
Select a request to inspect
+
+ +
+
+
+ + +`; +} diff --git a/src/lib/request-store.ts b/src/lib/request-store.ts new file mode 100644 index 0000000..9333343 --- /dev/null +++ b/src/lib/request-store.ts @@ -0,0 +1,134 @@ +import { randomUUID } from 'node:crypto'; + +export const DEFAULT_MAX_MEMORY = 1 * 1024 * 1024 * 1024; // 1 GB + +export type RequestSource = 'local' | 'proxy' | 'proxy-cache'; + +export interface RequestEntry { + id: string; + timestamp: number; + method: string; + url: string; + statusCode: number | null; + duration: number | null; + source: RequestSource; + requestHeaders: Record; + requestBody: Buffer | null; + requestBodyTruncated: boolean; + responseHeaders: Record; + responseBody: Buffer | null; + responseBodyTruncated: boolean; +} + +export interface RequestMeta { + id: string; + timestamp: number; + method: string; + url: string; + statusCode: number | null; + duration: number | null; + source: RequestSource; + requestSize: number; + responseSize: number; + requestBodyTruncated: boolean; + responseBodyTruncated: boolean; +} + +export class RequestStore { + private entries = new Map(); + private insertOrder: string[] = []; + private totalSize = 0; + readonly maxMemory: number; + + constructor(maxMemory = DEFAULT_MAX_MEMORY) { + this.maxMemory = maxMemory; + } + + allocateId(): string { + return randomUUID(); + } + + add(entry: RequestEntry): void { + let storedEntry = entry; + let size = (entry.requestBody?.byteLength ?? 0) + (entry.responseBody?.byteLength ?? 0); + + if (size > this.maxMemory) { + storedEntry = { + ...entry, + requestBody: null, + responseBody: null, + requestBodyTruncated: entry.requestBodyTruncated || entry.requestBody !== null, + responseBodyTruncated: entry.responseBodyTruncated || entry.responseBody !== null, + }; + size = 0; + } + + // Evict oldest entries until we have room + while (this.totalSize + size > this.maxMemory && this.insertOrder.length > 0) { + const oldest = this.insertOrder.shift()!; + const old = this.entries.get(oldest); + + if (old) { + this.totalSize -= (old.requestBody?.byteLength ?? 0) + (old.responseBody?.byteLength ?? 0); + this.entries.delete(oldest); + } + } + + this.entries.set(storedEntry.id, storedEntry); + this.insertOrder.push(storedEntry.id); + this.totalSize += size; + } + + get(id: string): RequestEntry | undefined { + return this.entries.get(id); + } + + list(opts?: { limit?: number; offset?: number; method?: string; search?: string }): RequestMeta[] { + let result = this.insertOrder.slice().map((id) => { + const e = this.entries.get(id)!; + + return { + id: e.id, + timestamp: e.timestamp, + method: e.method, + url: e.url, + statusCode: e.statusCode, + duration: e.duration, + source: e.source, + requestSize: e.requestBody?.byteLength ?? 0, + responseSize: e.responseBody?.byteLength ?? 0, + requestBodyTruncated: e.requestBodyTruncated, + responseBodyTruncated: e.responseBodyTruncated, + }; + }); + + if (opts?.method) { + result = result.filter((e) => e.method === opts.method!.toUpperCase()); + } + + if (opts?.search) { + const q = opts.search.toLowerCase(); + + result = result.filter((e) => e.url.toLowerCase().includes(q)); + } + + const offset = Number.isFinite(opts?.offset) ? opts.offset! : 0; + const limit = Number.isFinite(opts?.limit) ? opts.limit! : result.length; + + return result.slice(offset, offset + limit); + } + + clear(): void { + this.entries.clear(); + this.insertOrder = []; + this.totalSize = 0; + } + + get size(): number { + return this.entries.size; + } + + get memoryUsage(): number { + return this.totalSize; + } +} diff --git a/src/plugin.ts b/src/plugin.ts index 55448c4..e0b48e9 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,4 +1,4 @@ -import { IndexHtmlTransformResult, Plugin, ServerOptions } from 'vite'; +import { IndexHtmlTransformResult, Plugin } from 'vite'; import proxyPassMiddleware from './lib/proxy-pass.middleware.js'; import { MiAPI } from './lib/pp.middleware.js'; import { redirect, urlReplacer } from './lib/helpers/url.helper.js'; @@ -11,30 +11,101 @@ import { initLoadPPData } from './lib/load-pp-data.middleware.js'; import type { ViteImageOptimizer } from 'vite-plugin-image-optimizer'; import { createInternalServer } from './lib/internal.middleware.js'; import { getTokenErrorInfo } from './lib/helpers/index.js'; +import { RequestStore } from './lib/request-store.js'; +import { createRequestCaptureMiddleware } from './lib/request-capture.middleware.js'; +import { registerInspectorRoutes } from './lib/request-inspector.js'; + +// ─── Public config types ────────────────────────────────────────────────────── + +export type MiMode = 'standalone' | 'embedding'; +export type MiInclude = 'shared-components' | 'top-bar'; +export type AppType = 'page' | 'template'; + +export interface MiConfig { + /** MI instance base URL. Falls back to process.env.MI_BACKEND_URL. */ + url?: string; + /** Personal Access Token. Falls back to process.env.MI_ACCESS_TOKEN. */ + token?: string; + /** How the page integrates into MI. @default 'standalone' */ + mode?: MiMode; + /** Optional MI shared resources. Only valid when mode === 'standalone'. */ + include?: MiInclude; + /** MI API/routing version. @default 7 */ + apiVersion?: 6 | 7; +} -type RequiredSelection = Required> & Omit; +export interface AppConfig { + /** Portal page / app ID on MI. Required for type=template and type=page+standalone. */ + id?: number; + /** Internal asset name. @default package.json#name */ + name?: string; + /** App type affecting routing and data loading. @default 'template' */ + type?: AppType; +} -interface DistZipOptions { - /** - * Output ZIP archive file name. - * You can use `[templateName]` placeholder to replace it with the template name. - * @default `${templateName}.zip` - */ - outFileName?: string; +export interface ProxyConfig { + /** Enable request caching to the backend. @default true */ + cache?: boolean; + /** Cache TTL in milliseconds. @default 600000 */ + cacheTtl?: number; + tls?: { + /** Allow self-signed TLS certificates. @default false */ + allowSelfSigned?: boolean; + }; +} - /** - * Output directory for the build. - * @default 'dist-zip' - */ +interface DistZipBuildConfig { + /** Output ZIP file name. @default '.zip' */ + fileName?: string; + /** Output directory for the ZIP archive. @default 'dist-zip' */ outDir?: string; - - /** - * Input directory for the build. - * @default 'dist' - */ + /** Input directory to zip. @default value of build.outDir */ inDir?: string; } +interface VersionFileBuildConfig { + /** @default true */ + enabled?: boolean; + /** @default 'VERSION-v{packageversion}-{currentDate}.json' */ + fileNameTemplate?: string; +} + +export interface BuildConfig { + /** Build output directory. @default 'dist' */ + outDir?: string; + /** Pack build output into a ZIP archive. @default true */ + zip?: boolean | DistZipBuildConfig; + /** Create a VERSION JSON file with SHA256 hashes. @default true */ + versionFile?: boolean | VersionFileBuildConfig; + /** Image optimisation options. @default true */ + imageOptimisations?: boolean | Record; +} + +export interface SyncConfig { + /** Directory for sync backups. @default 'backups' */ + backupsDir?: string; +} + +export interface InspectorConfig { + /** Enable the request inspector web UI at /@pp-dev/inspector. @default true */ + enabled?: boolean; + /** Maximum RAM for stored request/response payloads in bytes. @default 1073741824 (1 GB) */ + maxMemory?: number; + /** Maximum bytes captured per individual request/response body. @default 10485760 (10 MB) */ + captureLimit?: number; +} + +export interface PPDevConfig { + mi?: MiConfig; + app?: AppConfig; + proxy?: ProxyConfig; + build?: BuildConfig; + sync?: SyncConfig; + inspector?: InspectorConfig; +} + +// ─── Internal normalized options (used by vitePPDev Vite plugin) ───────────── + export interface VersionPluginOptions { /** * Version file filename template. @@ -42,365 +113,156 @@ export interface VersionPluginOptions { * @default "VERSION-v{packageversion}-{currentDate}.json" */ versionFileTemplate?: string; - - /** - * Enable or disable the version plugin. - * @default true - */ + /** @default true */ enabled?: boolean; } -export interface VitePPDevOptions { - /** - * Backend base URL to MI instance for the local proxy. - */ +/** @internal — used by the Vite plugin and CLI; not part of the public API. */ +export interface NormalizedVitePPDevOptions { + templateName: string; backendBaseURL?: string; - - /** - * Portal page ID on the MI instance. - * @deprecated Use appId instead - */ - portalPageId?: number; - - /** - * Application ID on the MI instance. - * This is a synonym for portalPageId. - */ appId?: number; + templateLess: boolean; + miHudLess: boolean; + integrateMiTopBar: boolean | { addRootElement?: boolean; addSharedComponentsScripts?: boolean }; + enableProxyCache: boolean; + proxyCacheTTL: number; + disableSSLValidation: boolean; + imageOptimizer: boolean | Parameters[0]; + outDir: string; + distZip: false | { outFileName: string; outDir: string; inDir?: string }; + versionPlugin: false | VersionPluginOptions; + syncBackupsDir: string; + v7Features: boolean; + personalAccessToken?: string; + inspectorEnabled: boolean; + inspectorMaxMemory: number; + inspectorCaptureLimit: number; +} - /** - * Template name or PP internal name that will be used for the asset generation. - * Equals to package.json name field value. - */ - templateName: string; - - /** - * Enable or disable template variables loading from the backend. - */ - templateLess?: boolean; - - /** - * Enable or disable MI top bar and scripts loading from the backend for local development. - */ - miHudLess?: boolean; - - /** - * Integrate MI Top Bar and script into the App build. - * - * Depends on `miHudLess` option. Can be used only if `miHudLess` is enabled (true). - * - * @since 0.11.0 - * @default false - */ - integrateMiTopBar?: - | boolean - | { - /** - * Add MI Top Bar root element to the build. - * If disabled, you will need to add the root element manually to the HTML. - * @default false - */ - addRootElement?: boolean; - - /** - * Add MI Shared Components scripts to the build. - * @default false - */ - addSharedComponentsScripts?: boolean; - }; - - /** - * Enable or disable request caching to the backend by the local proxy. - * @default true - */ - enableProxyCache?: boolean; - - /** - * Request caching time in milliseconds. - * @default 600000 (10 minutes) - */ - proxyCacheTTL?: number; - - /** - * Disable SSL certificate validation for MI instances with self-signed certificates. - * @default false - */ - disableSSLValidation?: boolean; - - /** - * Image optimizer options. - * @default true - */ - imageOptimizer?: boolean | Parameters[0]; +// ─── Validation ─────────────────────────────────────────────────────────────── - /** - * Output directory for the build. - * @default 'dist' - */ - outDir?: string; +export function validatePPDevConfig(config: PPDevConfig, templateName: string): void { + const miMode = config.mi?.mode ?? 'standalone'; + const appType = config.app?.type ?? 'template'; + const miUrl = config.mi?.url ?? process.env.MI_BACKEND_URL; - /** - * Disable or enable packing the build output into a ZIP archive. - * @default true - */ - distZip?: boolean | DistZipOptions; + if (!templateName) { + throw new Error('[pp-dev] app.name is required (or set package.json#name)'); + } - /** - * Create a VERSION JSON file with SHA256 hashes of all build output files. - * @default true - */ - versionPlugin?: boolean | VersionPluginOptions; + if (config.mi?.include && miMode !== 'standalone') { + console.warn('[pp-dev] mi.include is only valid when mi.mode is "standalone"'); + throw new Error('[pp-dev] mi.include requires mi.mode to be "standalone"'); + } - /** - * Backups an asset directory path for sync with the MI instance. - * @default backups - */ - syncBackupsDir?: string; + if (!miUrl) { + if (miMode === 'embedding' || appType === 'template') { + throw new Error('[pp-dev] mi.url is required when mi.mode is "embedding" or app.type is "template"'); + } - /** - * Enable Metric Insights v7 features. - * @default false - * @since 0.8.0 - */ - v7Features?: boolean; + if (miMode === 'standalone' && appType === 'page') { + console.warn('[pp-dev] mi.url is not set — dev server will run without a backend proxy'); + } + } - /** - * Personal Access Token for the MI instance. - * @default process.env.MI_ACCESS_TOKEN - * @since 0.10.0 - * @example - * ```ts - * import vitePPDev from '@metricinsights/pp-dev'; - * - * export default vitePPDev({ - * personalAccessToken: process.env.MI_ACCESS_TOKEN, - * // other options... - * }); - * ``` - */ - personalAccessToken?: string; -} + if (appType === 'template' && !config.app?.id) { + throw new Error('[pp-dev] app.id is required when app.type is "template"'); + } -export type NormalizedVitePPDevOptions = RequiredSelection< - VitePPDevOptions, - | 'templateName' - | 'templateLess' - | 'miHudLess' - | 'integrateMiTopBar' - | 'enableProxyCache' - | 'proxyCacheTTL' - | 'disableSSLValidation' - | 'imageOptimizer' - | 'outDir' - | 'distZip' - | 'versionPlugin' - | 'syncBackupsDir' - | 'v7Features' - | 'personalAccessToken' ->; - -// Add more specific types for validation -type ValidationResult = { isValid: true } | { isValid: false; error: string }; - -function validateConfigValue(value: T, validator: (v: T) => boolean, errorMessage: string): ValidationResult { - return validator(value) ? { isValid: true } : { isValid: false, error: errorMessage }; + if (appType === 'page' && miMode === 'standalone' && miUrl && !config.app?.id) { + throw new Error('[pp-dev] app.id is required when app.type is "page" and mi.mode is "standalone"'); + } } -function validateConfig(config: VitePPDevOptions): ValidationResult { - const validations: ValidationResult[] = [ - validateConfigValue( - config, - (c): c is VitePPDevOptions => typeof c === 'object' && c !== null, - 'VitePPDevOptions must be an object', - ), - validateConfigValue( - config.templateName, - (name): name is string => typeof name === 'string' && name.length > 0, - 'VitePPDevOptions.templateName must be a non-empty string', - ), - validateConfigValue( - config.backendBaseURL, - (url): url is string | undefined => url === undefined || (typeof url === 'string' && url.length > 0), - 'VitePPDevOptions.backendBaseURL must be a non-empty string if provided', - ), - validateConfigValue( - config.portalPageId, - (id): id is number | undefined => id === undefined || (typeof id === 'number' && id > 0), - 'VitePPDevOptions.portalPageId must be a positive number if provided', - ), - validateConfigValue( - config.appId, - (id): id is number | undefined => id === undefined || (typeof id === 'number' && id > 0), - 'VitePPDevOptions.appId must be a positive number if provided', - ), - validateConfigValue( - config.proxyCacheTTL, - (ttl): ttl is number | undefined => ttl === undefined || (typeof ttl === 'number' && ttl > 0), - 'VitePPDevOptions.proxyCacheTTL must be a positive number if provided', - ), - validateConfigValue( - config.integrateMiTopBar, - ( - integrate, - ): integrate is boolean | { addRootElement?: boolean; addSharedComponentsScripts?: boolean } | undefined => { - if (integrate === undefined) { - return true; - } +// ─── Normalization ──────────────────────────────────────────────────────────── - if (integrate === null) { - return false; - } +export function normalizePPDevConfig(config: PPDevConfig, templateName: string): NormalizedVitePPDevOptions { + const { mi = {}, app = {}, proxy = {}, build = {}, sync = {}, inspector = {} } = config; - if (typeof integrate !== 'boolean' && typeof integrate !== 'object') { - return false; - } + const miMode = mi.mode ?? 'standalone'; + const appType = app.type ?? 'template'; + const apiVersion = mi.apiVersion ?? 7; - // integrateMiTopBar can only be true if miHudLess is true - if (integrate === true && config.miHudLess !== true) { - return false; - } + const miHudLess = miMode === 'standalone'; - if (typeof integrate === 'object' && integrate !== null) { - if (integrate.addRootElement !== undefined && typeof integrate.addRootElement !== 'boolean') { - return false; - } + let integrateMiTopBar: NormalizedVitePPDevOptions['integrateMiTopBar'] = false; - if ( - integrate.addSharedComponentsScripts !== undefined && - typeof integrate.addSharedComponentsScripts !== 'boolean' - ) { - return false; - } - - if ( - config.miHudLess !== true && - (integrate.addRootElement === true || integrate.addSharedComponentsScripts === true) - ) { - return false; - } - } - - return true; - }, - 'VitePPDevOptions.integrateMiTopBar must be a boolean or an object with addRootElement and addSharedComponentsScripts booleans', - ), - ]; - - const invalidResult = validations.find((result) => !result.isValid); - - return invalidResult || { isValid: true }; -} + if (mi.include === 'top-bar') { + integrateMiTopBar = true; + } else if (mi.include === 'shared-components') { + integrateMiTopBar = { addSharedComponentsScripts: true, addRootElement: false }; + } -export function normalizeVitePPDevConfig(config: VitePPDevOptions): NormalizedVitePPDevOptions { - const validationResult = validateConfig(config); + const templateLess = appType === 'page'; - if (!validationResult.isValid) { - throw new Error(validationResult.error); - } + const resolvedName = app.name ?? templateName; + const defaultVersionFileTemplate = 'VERSION-v{packageversion}-{currentDate}.json'; - // Prefer appId over portalPageId if both are provided - const portalPageId = config.appId ?? config.portalPageId; + let distZip: NormalizedVitePPDevOptions['distZip']; + const zipCfg = build.zip; - const { - enableProxyCache = true, - proxyCacheTTL = 10 * 60 * 1000, - disableSSLValidation = false, - imageOptimizer = true, - miHudLess = false, - integrateMiTopBar = false, - templateLess = false, - outDir = 'dist', - distZip = true, - versionPlugin = true, - syncBackupsDir = 'backups', - v7Features = false, - personalAccessToken = process.env.MI_ACCESS_TOKEN, - } = config || {}; - - let distZipConfig = distZip; - - if (distZipConfig === true) { - distZipConfig = { - outFileName: `${config.templateName}.zip`, - outDir: 'dist-zip', - }; - } else if (typeof distZip === 'object') { - distZipConfig = { - outFileName: - typeof distZip.outFileName === 'string' - ? distZip.outFileName.replace('[templateName]', config.templateName) - : `${config.templateName}.zip`, - outDir: distZip.outDir ?? 'dist-zip', - }; + if (zipCfg === false) { + distZip = false; + } else if (zipCfg === true || zipCfg === undefined) { + distZip = { outFileName: `${resolvedName}.zip`, outDir: 'dist-zip' }; } else { - distZipConfig = false; + distZip = { + outFileName: zipCfg.fileName ?? `${resolvedName}.zip`, + outDir: zipCfg.outDir ?? 'dist-zip', + ...(zipCfg.inDir ? { inDir: zipCfg.inDir } : {}), + }; } - let imageOptimizerConfig = imageOptimizer; + let versionPlugin: NormalizedVitePPDevOptions['versionPlugin']; + const vfCfg = build.versionFile; - if (typeof imageOptimizer === 'boolean') { - if (imageOptimizerConfig === true) { - imageOptimizerConfig = {}; - } - } else if (typeof imageOptimizer !== 'object') { - imageOptimizerConfig = false; + if (vfCfg === false) { + versionPlugin = false; + } else if (vfCfg === true || vfCfg === undefined) { + versionPlugin = { versionFileTemplate: defaultVersionFileTemplate, enabled: true }; + } else { + versionPlugin = { + versionFileTemplate: vfCfg.fileNameTemplate ?? defaultVersionFileTemplate, + enabled: vfCfg.enabled ?? true, + }; } - const versionPluginDefaultTemplate = 'VERSION-v{packageversion}-{currentDate}.json'; - let versionPluginConfig: false | VersionPluginOptions; + let imageOptimizer: NormalizedVitePPDevOptions['imageOptimizer']; + const imgCfg = build.imageOptimisations; - if (versionPlugin === false) { - versionPluginConfig = false; - } else if (versionPlugin === true) { - versionPluginConfig = { - versionFileTemplate: versionPluginDefaultTemplate, - enabled: true, - }; + if (imgCfg === false) { + imageOptimizer = false; + } else if (imgCfg === true || imgCfg === undefined) { + imageOptimizer = {}; } else { - versionPluginConfig = { - versionFileTemplate: versionPlugin.versionFileTemplate ?? versionPluginDefaultTemplate, - enabled: versionPlugin.enabled ?? true, - }; + imageOptimizer = imgCfg; } return { - enableProxyCache, - proxyCacheTTL, - disableSSLValidation, - imageOptimizer: imageOptimizerConfig, + templateName: resolvedName, + backendBaseURL: mi.url ?? process.env.MI_BACKEND_URL, + appId: app.id, templateLess, miHudLess, - outDir, - distZip: distZipConfig, - versionPlugin: versionPluginConfig, - syncBackupsDir, - v7Features, - personalAccessToken, - portalPageId, integrateMiTopBar, - ...config, - } as NormalizedVitePPDevOptions; + enableProxyCache: proxy.cache ?? true, + proxyCacheTTL: proxy.cacheTtl ?? 600_000, + disableSSLValidation: proxy.tls?.allowSelfSigned ?? false, + imageOptimizer, + outDir: build.outDir ?? 'dist', + distZip, + versionPlugin, + syncBackupsDir: sync.backupsDir ?? 'backups', + v7Features: apiVersion >= 7, + personalAccessToken: mi.token ?? process.env.MI_ACCESS_TOKEN, + inspectorEnabled: inspector.enabled ?? true, + inspectorMaxMemory: inspector.maxMemory ?? 1 * 1024 * 1024 * 1024, + inspectorCaptureLimit: inspector.captureLimit ?? 10 * 1024 * 1024, + }; } -function validateServerConfig(config: ServerOptions & { base: string }): ValidationResult { - const validations: ValidationResult[] = [ - validateConfigValue( - config.base, - (base): base is string => typeof base === 'string' && base.length > 0 && base !== '/', - 'Server base path cannot be empty or "/"', - ), - validateConfigValue( - config.port, - (port): port is number | undefined => - port === undefined || (typeof port === 'number' && port > 0 && port < 65536), - 'Server port must be a valid port number (1-65535)', - ), - ]; - - const invalidResult = validations.find((result) => !result.isValid); - - return invalidResult || { isValid: true }; -} +// ─── Internal MiAPI config ──────────────────────────────────────────────────── interface MiAPIConfig { headers: { @@ -408,7 +270,6 @@ interface MiAPIConfig { referer: string; origin: string; }; - portalPageId?: number; appId?: number; templateLess: boolean; disableSSLValidation: boolean; @@ -416,57 +277,7 @@ interface MiAPIConfig { personalAccessToken?: string; } -function validateMiAPIConfig(config: MiAPIConfig): ValidationResult { - const validations: ValidationResult[] = [ - validateConfigValue( - config.headers, - (headers): headers is MiAPIConfig['headers'] => - typeof headers === 'object' && - typeof headers.host === 'string' && - typeof headers.referer === 'string' && - typeof headers.origin === 'string', - 'MiAPI headers must be properly configured', - ), - validateConfigValue( - config.portalPageId, - (id): id is number | undefined => id === undefined || (typeof id === 'number' && id > 0), - 'MiAPI portalPageId must be a positive number if provided', - ), - validateConfigValue( - config.appId, - (id): id is number | undefined => id === undefined || (typeof id === 'number' && id > 0), - 'MiAPI appId must be a positive number if provided', - ), - validateConfigValue( - config.personalAccessToken, - (token): token is string | undefined => token === undefined || typeof token === 'string', - 'MiAPI personalAccessToken must be a string if provided', - ), - ]; - - const invalidResult = validations.find((result) => !result.isValid); - - return invalidResult || { isValid: true }; -} - -interface ProxyCacheConfig { - devServer: any; - ttl: number; -} - -function validateProxyCacheConfig(config: ProxyCacheConfig): ValidationResult { - const validations: ValidationResult[] = [ - validateConfigValue( - config.ttl, - (ttl): ttl is number => typeof ttl === 'number' && ttl > 0, - 'Proxy cache TTL must be a positive number', - ), - ]; - - const invalidResult = validations.find((result) => !result.isValid); - - return invalidResult || { isValid: true }; -} +// ─── Vite plugin ────────────────────────────────────────────────────────────── function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { const { @@ -474,7 +285,6 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { templateLess, backendBaseURL, miHudLess, - portalPageId, appId, enableProxyCache, proxyCacheTTL, @@ -484,32 +294,21 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { syncBackupsDir, v7Features, personalAccessToken, + inspectorEnabled, + inspectorMaxMemory, + inspectorCaptureLimit, } = options || {}; - // Avoid server caching for index.html file when first loading let isFirstRequest = true; let baseDir = process.cwd(); - const normalizedAppId = appId ?? portalPageId; - return { name: 'vite-pp-dev', apply: 'serve', config: (config) => { - const serverConfig: ServerOptions & { base: string } = { - base: config.base || '', - ...config.server, - }; - - const validationResult = validateServerConfig(serverConfig); - - if (!validationResult.isValid) { - throw new Error(validationResult.error); - } - config.clientInjectionPlugin = { backendBaseURL, - portalPageId: normalizedAppId, + appId, templateLess, v7Features, }; @@ -524,17 +323,12 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { return config; }, - transformIndexHtml: async (html, ctx) => { - const result: IndexHtmlTransformResult = { html, tags: [] }; + transformIndexHtml: async (_html, _ctx) => { + const result: IndexHtmlTransformResult = { html: _html, tags: [] }; if (isFirstRequest) { isFirstRequest = false; - - result.tags.push({ - tag: 'script', - injectTo: 'body', - children: `${Math.random()}`, - }); + result.tags.push({ tag: 'script', injectTo: 'body', children: `${Math.random()}` }); } return result; @@ -550,6 +344,17 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { server.middlewares.use(initPPRedirect(base, templateName)); + if (inspectorEnabled !== false) { + const reqStore = new RequestStore(inspectorMaxMemory); + + server.middlewares.use(createRequestCaptureMiddleware(reqStore, inspectorCaptureLimit)); + + const internalServer = createInternalServer(); + + registerInspectorRoutes(internalServer, reqStore, inspectorCaptureLimit); + server.middlewares.use(internalServer); + } + if (backendBaseURL) { const baseUrlHost = new URL(backendBaseURL).host; @@ -559,57 +364,28 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { referer: backendBaseURL, origin: backendBaseURL.replace(/^(https?:\/\/)([^/]+)(\/.*)?$/i, '$1$2'), }, - portalPageId: normalizedAppId, - appId: normalizedAppId, + appId, templateLess, disableSSLValidation, v7Features, personalAccessToken: personalAccessToken ?? process.env.MI_ACCESS_TOKEN, }; - const validationResult = validateMiAPIConfig(miConfig); - - if (!validationResult.isValid) { - throw new Error(validationResult.error); - } - const mi = new MiAPI(backendBaseURL, miConfig); if (enableProxyCache) { let ttl = +proxyCacheTTL; if (!ttl || Number.isNaN(ttl) || ttl < 0) { - ttl = 10 * 60 * 1000; // 10 minutes - } - - const cacheConfig: ProxyCacheConfig = { - devServer: server, - ttl, - }; - - const validationResult = validateProxyCacheConfig(cacheConfig); - - if (!validationResult.isValid) { - throw new Error(validationResult.error); + ttl = 10 * 60 * 1000; } - server.middlewares.use(initProxyCache(cacheConfig)); + server.middlewares.use(initProxyCache({ devServer: server, ttl })); } const isIndexRegExp = new RegExp(`^((${base})|/)$`); - // Get portal page variables from the backend (also, redirect magic) - server.middlewares.use( - initLoadPPData( - isIndexRegExp, - mi, - Object.assign({}, options, { - appId: normalizedAppId, - portalPageId: normalizedAppId, - appBase: base, - }), - ), - ); + server.middlewares.use(initLoadPPData(isIndexRegExp, mi, Object.assign({}, options, { appId, appBase: base }))); server.middlewares.use( proxyPassMiddleware({ @@ -619,10 +395,9 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { disableSSLValidation, miAPI: mi, templateName, - }) as any, + }), ); - // Helper function to send error responses consistently const sendErrorResponse = (res: any, status: number, error: string, details?: string, code?: string) => { const response: any = { error }; @@ -637,34 +412,22 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { res.status(status).json(response).end(); }; - // Helper function to handle token validation errors const handleTokenValidationError = (res: any, error: any, tokenType: string) => { - // Log the error for debugging server.config.logger.error(`${tokenType} token validation error:`, error); - // Use the token helper for better error handling const errorInfo = getTokenErrorInfo(error); - // Check if the error was enhanced by the middleware if (error.tokenErrorInfo) { const enhancedErrorInfo = error.tokenErrorInfo; - server.config.logger.info( - `Using enhanced error info: ${enhancedErrorInfo.code} - ${enhancedErrorInfo.userFriendlyMessage}`, + sendErrorResponse( + res, + enhancedErrorInfo.status || 500, + enhancedErrorInfo.userFriendlyMessage, + enhancedErrorInfo.message, + enhancedErrorInfo.code, ); - - const status = enhancedErrorInfo.status || 500; - const code = enhancedErrorInfo.code; - const userFriendlyMessage = enhancedErrorInfo.userFriendlyMessage; - const details = enhancedErrorInfo.message; - - sendErrorResponse(res, status, userFriendlyMessage, details, code); } else { - server.config.logger.info( - `Using original error info: ${errorInfo.code} - ${errorInfo.userFriendlyMessage}`, - ); - - // Map error codes to appropriate HTTP status codes and messages let status: number; let errorMessage: string; let errorCode: string; @@ -705,6 +468,7 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { }; const internalServer = createInternalServer(); + internalServer.post('/@api/login', async (req, res, next) => { const { token, tokenType } = req.body; @@ -714,7 +478,6 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { return; } - // Log token validation attempt server.config.logger.info(`Attempting to validate ${tokenType} token...`); if (tokenType === 'personal') { @@ -740,6 +503,7 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { } sendErrorResponse(res, 400, 'Token expired or invalid'); + return null; }) .catch((error: any) => handleTokenValidationError(res, error, 'personal')); @@ -774,6 +538,7 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { } sendErrorResponse(res, 400, 'Token expired or invalid'); + return null; }) .catch((error: any) => handleTokenValidationError(res, error, 'regular')); @@ -817,17 +582,12 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { ) : undefined; - const eventHandler = new ClientService(server, { - distService, - miAPI: mi, - }); + new ClientService(server, { distService, miAPI: mi }); return () => { server.middlewares.use( initRewriteResponse( (url) => { - console.log('initRewriteResponse.url', url); - return url.split('?')[0].endsWith('index.html'); }, (response, req) => { diff --git a/src/plugins/client-injection-plugin.ts b/src/plugins/client-injection-plugin.ts index c1fca5b..03acd3c 100644 --- a/src/plugins/client-injection-plugin.ts +++ b/src/plugins/client-injection-plugin.ts @@ -1,6 +1,7 @@ import { IndexHtmlTransformResult, normalizePath, Plugin } from 'vite'; import * as path from 'path'; import { PP_DEV_CLIENT_ENTRY, PACKAGE_NAME, VERSION, PP_DEV_PACKAGE_DIR } from '../constants.js'; +import { getDevPanelAssetPaths } from '../lib/dev-panel.js'; import * as fs from 'fs'; import ejs from 'ejs'; import type { AsyncTemplateFunction } from 'ejs'; @@ -9,11 +10,9 @@ import { fileURLToPath } from 'url'; export interface ClientInjectionPluginOpts { backendBaseURL?: string; templateLess: boolean; - portalPageId?: number; + appId?: number; canSync?: boolean; v7Features?: boolean; - enableCache?: boolean; - maxCacheSize?: number; } declare module 'vite' { @@ -22,67 +21,10 @@ declare module 'vite' { } } -// Simple LRU Cache implementation for better memory management -class LRUCache { - private cache: Map; - private _maxSize: number; - - constructor(maxSize: number = 10) { - this._maxSize = maxSize; - this.cache = new Map(); - } - - get maxSize(): number { - return this._maxSize; - } - - set maxSize(value: number) { - this._maxSize = value; - } - - get(key: K): V | undefined { - if (this.cache.has(key)) { - const value = this.cache.get(key)!; - - this.cache.delete(key); - this.cache.set(key, value); - - return value; - } - return undefined; - } - - set(key: K, value: V): void { - if (this.cache.has(key)) { - this.cache.delete(key); - } else if (this.cache.size >= this._maxSize) { - const firstKey = this.cache.keys().next().value; - - if (firstKey !== undefined) { - this.cache.delete(firstKey); - } - } - - this.cache.set(key, value); - } - - clear(): void { - this.cache.clear(); - } - - has(key: K): boolean { - return this.cache.has(key); - } -} - -// Cache for template compilation and file reading with LRU strategy -// Initialize with explicit type to ensure CJS compatibility -const templateCache: LRUCache = new LRUCache(5); -const fileCache: Map = new Map(); - // Memoized path resolution // Support both CJS and ESM contexts let DIRNAME: string; + try { // @ts-ignore - __filename is available in CJS if (typeof __filename !== 'undefined' && __filename) { @@ -99,80 +41,39 @@ try { DIRNAME = process.cwd(); } -// Pre-computed constants -const PACKAGE_IMPORT = `/${PACKAGE_NAME}/client`; -const CLIENT_PATH = `/${PACKAGE_IMPORT}`; +const CLIENT_DIST_DIR = normalizePath(path.join(PP_DEV_PACKAGE_DIR, 'dist/client')); const PACKAGE_REGEXP = new RegExp(`^\\/?${PACKAGE_NAME}\\/client\\/(.*)$`); -// Memoized function to get template with caching -function getTemplate(base: string, enableCache: boolean = true): AsyncTemplateFunction { - const cacheKey = base; +let cachedTemplateContent: string | null = null; +let cachedTemplate: AsyncTemplateFunction | null = null; - if (enableCache && templateCache.has(cacheKey)) { - return templateCache.get(cacheKey)!; +function getTemplate(): AsyncTemplateFunction { + if (cachedTemplate) { + return cachedTemplate; } - // Read template file with caching const templatePath = path.resolve(DIRNAME, 'client', 'index.html'); - let templateContent: string; - if (fileCache.has(templatePath)) { - templateContent = fileCache.get(templatePath)!; - } else { - templateContent = fs.readFileSync(templatePath, { encoding: 'utf8' }); - fileCache.set(templatePath, templateContent); + if (!cachedTemplateContent) { + cachedTemplateContent = fs.readFileSync(templatePath, { encoding: 'utf8' }); } - // Compile template with optimized replacement and better regex escaping - const escapedClientPath = CLIENT_PATH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const compiledTemplate = ejs.compile( - templateContent.replace(new RegExp(escapedClientPath, 'g'), path.posix.join(base, PACKAGE_IMPORT)), - { - openDelimiter: '{', - closeDelimiter: '}', - async: true, - cache: true, // Enable EJS internal caching - filename: templatePath, // For better error reporting - rmWhitespace: true, // Remove unnecessary whitespace - compileDebug: false, // Disable debug info in production - }, - ); - - if (enableCache) { - templateCache.set(cacheKey, compiledTemplate); - } - - return compiledTemplate; + cachedTemplate = ejs.compile(cachedTemplateContent, { + openDelimiter: '{', + closeDelimiter: '}', + async: true, + cache: true, + filename: templatePath, + rmWhitespace: true, + compileDebug: false, + }); + + return cachedTemplate; } // Memoized function to get CSS and JS paths -function getAssetPaths(base: string) { - return { - css: path.posix.join(base, PACKAGE_NAME, 'client/client.css'), - js: path.posix.join(base, PACKAGE_NAME, 'client/client.js'), - }; -} - -// Performance monitoring (only in development) -const performanceMetrics = { - templateCompilations: 0, - cacheHits: 0, - totalRequests: 0, -}; export function clientInjectionPlugin(opts?: ClientInjectionPluginOpts): Plugin { - const enableCache = opts?.enableCache ?? true; - const maxCacheSize = opts?.maxCacheSize ?? 5; - - // Update cache size if specified - if (maxCacheSize !== 5) { - templateCache.maxSize = maxCacheSize; - } - - let base = ''; - let baseChanged = false; - let currentTemplate: AsyncTemplateFunction | null = null; - return { name: 'pp-dev:client', apply: 'serve', @@ -184,38 +85,25 @@ export function clientInjectionPlugin(opts?: ClientInjectionPluginOpts): Plugin }, resolveId(source) { - if (PACKAGE_REGEXP.test(source)) { - return { - id: normalizePath(path.join(PP_DEV_PACKAGE_DIR, 'dist/client', source.replace(PACKAGE_REGEXP, '$1'))), - }; - } - }, + const match = source.match(PACKAGE_REGEXP); - transformIndexHtml: async (html, ctx) => { - performanceMetrics.totalRequests++; - - const serverBase = ctx.server?.config.base || ''; - - if (serverBase !== base) { - base = serverBase; - baseChanged = true; - currentTemplate = null; - } + if (match) { + const resolvedId = normalizePath(path.join(CLIENT_DIST_DIR, match[1])); - // Get or create template only when needed - if (!currentTemplate || baseChanged) { - if (enableCache && templateCache.has(base)) { - currentTemplate = templateCache.get(base)!; - performanceMetrics.cacheHits++; - } else { - currentTemplate = getTemplate(base, enableCache); - performanceMetrics.templateCompilations++; + if (!resolvedId.startsWith(`${CLIENT_DIST_DIR}/`)) { + return null; } - baseChanged = false; + return { + id: resolvedId, + }; } + }, - const assetPaths = getAssetPaths(base); + transformIndexHtml: async (html, ctx) => { + const base = ctx.server?.config.base || ''; + const template = getTemplate(); + const assetPaths = getDevPanelAssetPaths(base); const result: IndexHtmlTransformResult = { html, @@ -234,24 +122,23 @@ export function clientInjectionPlugin(opts?: ClientInjectionPluginOpts): Plugin const { backendBaseURL, templateLess, - portalPageId, + appId, canSync = true, } = opts || ctx.server?.config.clientInjectionPlugin || {}; - // Render template content const templateData = { PACKAGE_NAME, VERSION, backendBaseURL, templateLess, - portalPageId, + appId, canSync, }; result.tags.push({ tag: 'div', injectTo: 'body-prepend', - children: await currentTemplate(templateData), + children: await template(templateData), }); result.tags.push({ @@ -272,20 +159,6 @@ export function clientInjectionPlugin(opts?: ClientInjectionPluginOpts): Plugin if (server.config.server?.fs?.allow) { server.config.server.fs.allow.push(clientDir); } - - // Log performance metrics in development - server.middlewares.use(`/@api/__pp-dev-metrics`, (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(performanceMetrics, null, 2)); - }); - }, - - // Cleanup function to clear caches when plugin is destroyed - closeBundle() { - templateCache.clear(); - fileCache.clear(); - - console.log(`[pp-dev:client] Performance metrics:`, performanceMetrics); }, }; } diff --git a/src/shortcuts.ts b/src/shortcuts.ts index 3f80843..ee3ad8b 100644 --- a/src/shortcuts.ts +++ b/src/shortcuts.ts @@ -26,6 +26,7 @@ export function bindShortcuts(server: ViteDevServer, opts: BindShortcutsOptions) if (!server.httpServer || !process.stdin.isTTY || process.env.CI) { return; } + (server as any)._shortcutsOptions = opts; const logger = createLogger(); @@ -54,6 +55,7 @@ export function bindShortcuts(server: ViteDevServer, opts: BindShortcutsOptions) if (input === '\x03') { // Re-emit SIGINT so the CLI graceful shutdown handlers run. process.kill(process.pid, 'SIGINT'); + return; } @@ -94,6 +96,7 @@ export function bindShortcuts(server: ViteDevServer, opts: BindShortcutsOptions) process.stdin.setRawMode(true); process.stdin.on('data', onInput).setEncoding('utf8').resume(); + const boundServer = server.httpServer; const cleanupBinding = () => { diff --git a/tests/e2e/server-lifecycle.spec.ts b/tests/e2e/server-lifecycle.spec.ts new file mode 100644 index 0000000..e886b16 --- /dev/null +++ b/tests/e2e/server-lifecycle.spec.ts @@ -0,0 +1,192 @@ +/** + * E2E tests for pp-dev server lifecycle. + * + * These tests run pp-dev against mock-mi replay so CI never depends on the + * VPN-only MI instance during startup. + */ +import { describe, it, beforeAll, afterAll, expect } from 'vitest'; +import { spawn, execSync, type ChildProcess } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; +import { startMockMiServer, type MockMiServer } from '../mock-mi/server.js'; + +// On Windows, proc.kill('SIGTERM') only kills the shell (npm.cmd), not the +// node/next child processes. POSIX uses a detached process group for the same reason. +function killTree(proc: ChildProcess): void { + if (!proc.pid) return; + if (process.platform === 'win32') { + try { execSync(`taskkill /PID ${proc.pid} /T /F`, { stdio: 'ignore' }); } catch { /* already gone */ } + } else { + try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); } + } +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TEST_APP_DIR = path.resolve(__dirname, '../test-nextjs'); +const CONFIG_PATH = path.join(TEST_APP_DIR, 'pp-dev.config.ts'); + +// ── ProcOutput — persistent line accumulator so no data event is missed ── + +interface ProcOutput { + lines: string[]; + waitForLine(pattern: RegExp, timeoutMs?: number): Promise; + kill(): void; +} + +function startPPDev(extraEnv?: Record): ProcOutput { + // Use npm run dev so npm resolves the node binary via nvm4w correctly. + // process.execPath on nvm4w points to a shim that breaks chokidar's native watcher. + const proc: ChildProcess = spawn('npm', ['run', 'dev'], { + cwd: TEST_APP_DIR, + env: { ...process.env, NO_COLOR: '1', ...extraEnv }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + detached: process.platform !== 'win32', + }); + + const lines: string[] = []; + const listeners: Array<(line: string) => void> = []; + let lineBuffer = ''; + + const onData = (data: Buffer) => { + const text = data.toString(); + process.stdout.write(text); + lineBuffer += text; + const parts = lineBuffer.split(/\r?\n/); + lineBuffer = parts.pop() ?? ''; + for (const raw of parts) { + const line = raw.trim(); + if (!line) continue; + lines.push(line); + for (const fn of [...listeners]) fn(line); + } + }; + + proc.stdout?.on('data', onData); + proc.stderr?.on('data', onData); + + return { + lines, + + waitForLine(pattern: RegExp, timeoutMs = 30_000): Promise { + // Check already-buffered lines first + const buffered = lines.find((l) => pattern.test(l)); + if (buffered) return Promise.resolve(buffered); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = listeners.indexOf(onLine); + if (idx !== -1) listeners.splice(idx, 1); + reject(new Error(`Timeout (${timeoutMs}ms) waiting for: ${pattern}`)); + }, timeoutMs); + + const onLine = (line: string) => { + if (pattern.test(line)) { + clearTimeout(timer); + const idx = listeners.indexOf(onLine); + if (idx !== -1) listeners.splice(idx, 1); + resolve(line); + } + }; + listeners.push(onLine); + + // Re-check in case lines arrived between the buffered check and listener registration + const recheck = lines.find((l) => pattern.test(l)); + if (recheck) { + clearTimeout(timer); + const idx = listeners.indexOf(onLine); + if (idx !== -1) listeners.splice(idx, 1); + resolve(recheck); + } + }); + }, + + kill: () => killTree(proc), + }; +} + +// ── Suite ───────────────────────────────────────────────────────────────── + +describe('pp-dev server lifecycle', { timeout: 60_000 }, () => { + let ppdev: ProcOutput; + let mockMi: MockMiServer; + let originalConfig: string; + + beforeAll(async () => { + mockMi = await startMockMiServer({ mode: 'replay', cassetteName: 'startup' }); + originalConfig = fs.readFileSync(CONFIG_PATH, 'utf-8'); + const patchedConfig = originalConfig.replace( + /url:\s*['"]https?:\/\/[^'"]+['"]/, + `url: '${mockMi.url}'`, + ); + + if (patchedConfig === originalConfig) { + throw new Error('Could not patch test pp-dev config to use mock-mi'); + } + + fs.writeFileSync(CONFIG_PATH, patchedConfig); + + // Start pp-dev against mock-mi so CI never depends on the VPN-only MI instance. + ppdev = startPPDev(); + // Wait for the very last startup log so all startup lines are in ppdev.lines + await ppdev.waitForLine(/Process event handlers registered/i, 60_000); + }, 90_000); + + afterAll(async () => { + ppdev?.kill(); + // Ensure config was not left modified by any test + const content = fs.readFileSync(CONFIG_PATH, 'utf-8'); + const restored = (originalConfig ?? content).replace(/\n\/\/ restart-trigger\n/g, ''); + if (restored !== content) fs.writeFileSync(CONFIG_PATH, restored); + await mockMi?.close(); + }); + + // ── 1: Clean startup ────────────────────────────────────────────────── + + it('starts cleanly without errors', () => { + const errors = ppdev.lines.filter((l) => + /\[ERROR\]|esbuild error|unhandled exception/i.test(l), + ); + expect(errors, `Unexpected errors:\n${errors.join('\n')}`).toHaveLength(0); + // Verify server reached the ready state + expect(ppdev.lines.some((l) => /pp-dev Next\.js server running/i.test(l))).toBe(true); + }); + + // ── 2: Config watcher is active ─────────────────────────────────────── + + it('sets up config file watcher', () => { + const watcherLine = ppdev.lines.find((l) => /Config file watcher started/i.test(l)); + expect(watcherLine, 'Config file watcher started message not found').toBeTruthy(); + }); + + // ── 3: HTTP sanity-check ────────────────────────────────────────────── + + it('serves HTTP responses', async () => { + const res = await fetch('http://localhost:3000/', { + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + }); + // Expects a redirect (302) or page response — anything below 500 + expect(res.status).toBeLessThan(500); + }); + + it('loads page data from mock-mi cassette', async () => { + const res = await fetch('http://localhost:3000/pl/pp-dev-test-template/', { + signal: AbortSignal.timeout(10_000), + }); + + expect(res.status).toBeLessThan(500); + await ppdev.waitForLine(/Page fetched/i, 10_000); + await ppdev.waitForLine(/Local page template fetched/i, 10_000); + }); + + it('proxies MI data requests through mock-mi cassette', async () => { + const res = await fetch('http://localhost:3000/data/page/index/auth/info', { + signal: AbortSignal.timeout(10_000), + }); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ authenticated: true }); + }); +}); diff --git a/tests/mock-mi/cassettes/.gitignore b/tests/mock-mi/cassettes/.gitignore new file mode 100644 index 0000000..46ab05a --- /dev/null +++ b/tests/mock-mi/cassettes/.gitignore @@ -0,0 +1,3 @@ +# Cassettes may contain session tokens — not committed +*.json +!startup.json diff --git a/tests/mock-mi/cassettes/.gitkeep b/tests/mock-mi/cassettes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock-mi/cassettes/startup.json b/tests/mock-mi/cassettes/startup.json new file mode 100644 index 0000000..6594e66 --- /dev/null +++ b/tests/mock-mi/cassettes/startup.json @@ -0,0 +1,212 @@ +{ + "name": "startup", + "baseUrl": "https://stg7x.metricinsights.com", + "recordedAt": "2026-06-30T13:46:11.682Z", + "interactions": [ + { + "request": { + "method": "GET", + "pathname": "/api/page/id/937" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json", + "cache-control": "no-cache, private", + "vary": "Authorization,Accept-Encoding,Origin" + }, + "body": "{\"page\":{\"id\":733,\"name\":\"PP-DEV Test page with template\",\"internal_name\":\"pp-dev-test-page-template\",\"enabled\":\"Y\",\"visible_in_homepage\":\"N\",\"template_id\":313,\"template\":\"pp-dev-test-template\",\"custom_domain\":null,\"base_url\":\"https://stg7x.metricinsights.com/p/pp-dev-test-page-template\",\"groups\":[],\"tags\":\"[{\\\"name\\\":\\\"variable-test\\\",\\\"value\\\":\\\"String value for test\\\"}]\"}}" + } + }, + { + "request": { + "method": "GET", + "pathname": "/api/page/id/733" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json", + "cache-control": "no-cache, private", + "vary": "Authorization,Accept-Encoding,Origin" + }, + "body": "{\"page\":{\"id\":937,\"name\":\"PP-DEV Test page with template\",\"internal_name\":\"pp-dev-test-page-template\",\"enabled\":\"Y\",\"visible_in_homepage\":\"N\",\"template_id\":313,\"template\":\"pp-dev-test-template\",\"custom_domain\":null,\"base_url\":\"https://stg7x.metricinsights.com/p/pp-dev-test-page-template\",\"groups\":[],\"tags\":\"[{\\\"name\\\":\\\"variable-test\\\",\\\"value\\\":\\\"String value for test\\\"}]\"}}" + } + }, + { + "request": { + "method": "GET", + "pathname": "/data/page/index/auth/info" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8" + }, + "body": "{\"authenticated\":true,\"user\":{\"id\":1,\"username\":\"mock-ci\"}}" + } + }, + { + "request": { + "method": "GET", + "pathname": "/auth/info.js" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/javascript; charset=utf-8" + }, + "body": "window.miAuthInfo = { authenticated: true, user: { username: \"mock-ci\" } };\n" + } + }, + { + "request": { + "method": "GET", + "pathname": "/css/main.css" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/css; charset=utf-8" + }, + "body": "/* Mock MI top bar styles for pp-dev E2E tests. */\n" + } + }, + { + "request": { + "method": "GET", + "pathname": "/js/main.js" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/javascript; charset=utf-8" + }, + "body": "window.MI = window.MI || {};\n" + } + }, + { + "request": { + "method": "GET", + "pathname": "/static/media/inter-latin-600-normal.69a8d1d484967aba2389.woff2" + }, + "response": { + "status": 200, + "headers": { + "content-type": "font/woff2", + "accept-ranges": "bytes", + "last-modified": "Tue, 30 Jun 2026 10:18:18 GMT", + "vary": "Authorization,User-Agent" + }, + "body": "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", + "bodyEncoding": "base64" + } + }, + { + "request": { + "method": "GET", + "pathname": "/static/media/inter-latin-400-normal.eca1e21531598d5db58f.woff2" + }, + "response": { + "status": 200, + "headers": { + "content-type": "font/woff2", + "accept-ranges": "bytes", + "last-modified": "Tue, 30 Jun 2026 10:18:18 GMT", + "vary": "Authorization,User-Agent" + }, + "body": "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", + "bodyEncoding": "base64" + } + }, + { + "request": { + "method": "GET", + "pathname": "/favicon.ico" + }, + "response": { + "status": 200, + "headers": { + "content-type": "image/x-icon", + "cache-control": "cache, public, max-age=3600", + "expires": "Tue, 30 Jun 26 07:43:21 -0700", + "pragma": "cache, public", + "vary": "Authorization" + }, + "body": "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SP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI8UhISF4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASEhIX0hISPpISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISNNISEgdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASEhIEEhISLdISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISPtISEhWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEhISDtISEjjSEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEh2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABISEhfSEhI8EhISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEhpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABISEgDSEhIZ0hISO1ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISPBISEjuSEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISOxISEg1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASEhIAkhISFFISEjWSEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj+SEhIz0hISE5ISEhGSEhIx0hISP5ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/khISJFISEgFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABISEgmSEhImEhISO1ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISOVISEiISEhIHwAAAAAAAAAASEhIGUhISH9ISEjgSEhI/khISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEj/SEhI/0hISP9ISEjvSEhIkEhISBQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASEhIBUhISDlISEiSSEhI1UhISPVISEj+SEhI/0hISP9ISEj/SEhI/khISPFISEjMSEhIhEhISC5ISEgDAAAAAAAAAAAAAAAAAAAAAEhISAFISEgnSEhIfEhISMhISEjwSEhI/UhISP9ISEj/SEhI/0hISP9ISEj2SEhI2UhISJlISEg+SEhIBgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABISEgCSEhIGEhISD9ISEhjSEhIeUhISIBISEh3SEhIXkhISDhISEgSSEhIAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEhISBBISEg2SEhIXEhISHZISEiASEhIekhISGVISEhCSEhIG0hISAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///////8AAOH//D//hwAAwP/4H/8DAACAf/AP/gEAAAB/4Af+AAAAAD/gB/wAAAAAP+AH/AAAAAA/4Af8AAAAAD/gB/wAAAAAP+AH/AAAAAA/4Af8AAAAAD/gB/wAAAAAP+AH/AAAAAA/4Af8AAAAAD/gB/wAAACAf+AH/AAAAID/4Af8AAAA4f/gB/wAAAD//+AH/AAAAP//4Af8AAAA4f/gB/wAAADA/+AH/AAAAIB/8A/8AAAAAD/4H/wAAAAAP/w//AAAAAA////8AAAAAD////wAAAAAP/w//AAAAAA/+A/8AAAAAD/wD/wAAAAAP+AH/AAAAAA/4Af+AAAAAD/gB/4BAAAAP+AH/wMAAAAfwAP/hwAAAA+AAfv/AACAAAAAAP8AAIAAAAAAfwAAwAAAAAA/AADAAAAAAD8AAOAAAAAAPwAA8AAAAAA/AAD4AAAAAD8AAPwAAYAAPwAA/gAD4AB/AAD/gA/4Af8AAP/9//+//wAA////////AAA=", + "bodyEncoding": "base64" + } + }, + { + "request": { + "method": "GET", + "pathname": "/api/dataset_data" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json", + "cache-control": "no-cache, private", + "vary": "Authorization,Accept-Encoding,Origin" + }, + "body": "{\"data\":[{\"id\":1,\"title\":\"num 1\",\"description\":\"desc 1\",\"thumbnail\":\"\",\"folder\":5,\"path\":\"[REDACTED_PATH]\",\"link\":\"[REDACTED_PATH]\",\"wiki_link\":\"https://en.wikipedia.org/wiki/1\"},{\"id\":2,\"title\":\"num 2\",\"description\":\"desc 2\",\"thumbnail\":\"\",\"folder\":7,\"path\":\"[REDACTED_PATH]\",\"link\":\"[REDACTED_PATH]\",\"wiki_link\":\"https://en.wikipedia.org/wiki/2\"},{\"id\":3,\"title\":\"num 3\",\"description\":\"desc 3\",\"thumbnail\":\"\",\"folder\":4,\"path\":\"[REDACTED_PATH]\",\"link\":\"[REDACTED_PATH]\",\"wiki_link\":\"https://en.wikipedia.org/wiki/3\"},{\"id\":4,\"title\":\"num 4\",\"description\":\"desc 4\",\"thumbnail\":\"\",\"folder\":4,\"path\":\"[REDACTED_PATH]\",\"link\":\"[REDACTED_PATH]\",\"wiki_link\":\"https://en.wikipedia.org/wiki/4\"},{\"id\":5,\"title\":\"num 5\",\"description\":\"desc 5\",\"thumbnail\":\"\",\"folder\":4,\"path\":\"[REDACTED_PATH]\",\"link\":\"[REDACTED_PATH]\",\"wiki_link\":\"https://en.wikipedia.org/wiki/5\"},{\"id\":6,\"title\":\"num 6\",\"description\":\"desc 6\",\"thumbnail\":\"\",\"folder\":4,\"path\":\"[REDACTED_PATH]\",\"link\":\"[REDACTED_PATH]\",\"wiki_link\":\"https://en.wikipedia.org/wiki/6\"},{\"id\":7,\"title\":\"num 7\",\"description\":\"desc 7\",\"thumbnail\":\"\",\"folder\":9,\"path\":\"[REDACTED_PATH]\",\"link\":\"[REDACTED_PATH]\",\"wiki_link\":\"https://en.wikipedia.org/wiki/7\"},{\"id\":8,\"title\":\"num 8\",\"description\":\"desc 8\",\"thumbnail\":\"\",\"folder\":9,\"path\":\"[REDACTED_PATH]\",\"link\":\"[REDACTED_PATH]\",\"wiki_link\":\"https://en.wikipedia.org/wiki/8\"},{\"id\":9,\"title\":\"num 9\",\"description\":\"desc 9\",\"thumbnail\":\"\",\"folder\":1,\"path\":\"[REDACTED_PATH]\",\"link\":\"[REDACTED_PATH]\",\"wiki_link\":\"https://en.wikipedia.org/wiki/9\"}],\"metadata\":[{\"name\":\"id\",\"type\":\"numeric\"},{\"name\":\"title\",\"type\":\"text\"},{\"name\":\"description\",\"type\":\"text\"},{\"name\":\"thumbnail\",\"type\":\"text\"},{\"name\":\"folder\",\"type\":\"numeric\"},{\"name\":\"path\",\"type\":\"text\"},{\"name\":\"link\",\"type\":\"text\"},{\"name\":\"wiki_link\",\"type\":\"text\"}]}" + } + }, + { + "request": { + "method": "GET", + "pathname": "/data/page/pp-dev-test-page-template/auth/info" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "vary": "Authorization,Accept-Encoding" + }, + "body": "{\"resultCode\":0,\"user\":{\"user_id\":1,\"username\":\"mock-ci\",\"email\":\"mock-ci\",\"first_name\":\"Mock\",\"last_name\":\"User\",\"display_name\":\"mock-ci\",\"is_administrator\":\"Y\",\"is_power_user\":\"N\",\"is_manage_pages_privilege\":\"Y\",\"groups\":[{\"group_id\":1,\"name\":\"Default Group\",\"ldap_organizational_unit\":\"\"}],\"attributes\":[]},\"post\":null}" + } + }, + { + "request": { + "method": "GET", + "pathname": "/data/page/pp-dev-test-page-template/entity" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "vary": "Authorization" + }, + "body": "{\"resultCode\":0,\"entities\":[]}" + } + }, + { + "request": { + "method": "GET", + "pathname": "/api/page_template/id/313" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json", + "cache-control": "no-cache, private", + "vary": "Authorization,Accept-Encoding,Origin" + }, + "body": "{\"page_template\":{\"id\":313,\"name\":\"PP-DEV Test template\",\"internal_name\":\"pp-dev-test-template\",\"enabled\":\"Y\",\"sync_with_git\":\"N\",\"version_identifier\":\"8096a0abc94f95cb4adc0a963f22b44a\",\"visible_in_homepage\":\"N\",\"display_without_nav_bar\":\"Y\",\"can_edit\":\"Y\"}}" + } + }, + { + "request": { + "method": "GET", + "pathname": "/api/page_template/id/313/asset/download" + }, + "response": { + "status": 404, + "headers": { + "content-type": "application/json; charset=utf-8" + }, + "body": "{\"error\":\"Template asset download omitted from cassette\"}" + } + } + ] +} \ No newline at end of file diff --git a/tests/mock-mi/record-auto.ts b/tests/mock-mi/record-auto.ts new file mode 100644 index 0000000..2ada2c5 --- /dev/null +++ b/tests/mock-mi/record-auto.ts @@ -0,0 +1,215 @@ +/** + * Autonomous cassette recorder — no user interaction required. + * + * Usage (VPN must be enabled): + * REAL_MI_URL=https://stg7x.metricinsights.com npx tsx tests/mock-mi/record-auto.ts [cassette-name] + * + * What it does: + * 1. Starts mock-MI server in record mode (proxies to real MI) + * 2. Patches tests/test-nextjs/pp-dev.config.ts to point at mock server + * 3. Starts pp-dev next, waits for it to be ready + * 4. Makes a few HTTP requests to pp-dev to trigger MI API calls + * 5. Directly fetches template metadata + ZIP download via mock-mi proxy + * (records the responses without needing a full next build / sync trigger) + * 6. Saves cassette, restores config, exits + */ + +import { spawn, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { startMockMiServer } from './server.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TEST_APP_DIR = path.resolve(__dirname, '../test-nextjs'); +const CONFIG_PATH = path.join(TEST_APP_DIR, 'pp-dev.config.ts'); +const CASSETTE_NAME = process.argv[2] ?? 'startup'; +const REAL_MI_URL = process.env.REAL_MI_URL ?? 'https://stg7x.metricinsights.com'; +const PP_DEV_JS = path.join(TEST_APP_DIR, 'node_modules/@metricinsights/pp-dev/bin/pp-dev.js'); +const STARTUP_TIMEOUT = 90_000; +const REQUEST_TIMEOUT = 10_000; +const DOWNLOAD_TIMEOUT = 30_000; +const PP_PORT = Number(process.env.PP_DEV_RECORD_AUTO_PORT ?? 3000); +const PP_BASE = `http://localhost:${PP_PORT}`; +// Personal access token — if set, pp-dev adds it as Authorization: Bearer to all proxied +// requests, allowing authenticated MI endpoints to be recorded without a browser session. +const MI_ACCESS_TOKEN = process.env.MI_ACCESS_TOKEN ?? process.env.REAL_MI_TOKEN ?? ''; + +function log(msg: string) { + console.log(`[record-auto] ${msg}`); +} + +log(`Recording cassette "${CASSETTE_NAME}" from ${REAL_MI_URL}`); + +// 1. Start mock server in record mode +const mockMi = await startMockMiServer({ + mode: 'record', + cassetteName: CASSETTE_NAME, + realMiUrl: REAL_MI_URL, +}); + +const originalConfig = fs.readFileSync(CONFIG_PATH, 'utf-8'); + +// Parse app ID from config before patching (used to discover template ID later) +const appIdMatch = originalConfig.match(/id:\s*(\d+)/); +const APP_ID = appIdMatch ? parseInt(appIdMatch[1], 10) : null; +if (APP_ID === null) { + log('Warning: could not parse app.id from pp-dev.config.ts — template API calls will be skipped.'); +} +let configPatched = false; +let ppdev: ChildProcess | undefined; +let mockClosed = false; + +const cleanup = async () => { + if (configPatched) { + fs.writeFileSync(CONFIG_PATH, originalConfig); + log('Config restored.'); + } + + if (ppdev && ppdev.exitCode === null && ppdev.signalCode === null) { + ppdev.kill('SIGTERM'); + } + + if (!mockClosed) { + await mockMi.close(); + mockClosed = true; + } +}; + +try { +// 2. Patch config to point at mock server +const patched = originalConfig.replace( + /url:\s*['"]https?:\/\/[^'"]+['"]/, + `url: '${mockMi.url}'`, +); +if (patched === originalConfig) { + throw new Error('Could not patch mi.url in pp-dev.config.ts — check the regex.'); +} +fs.writeFileSync(CONFIG_PATH, patched); +configPatched = true; +log(`Patched pp-dev.config.ts → mi.url = ${mockMi.url}`); + +if (!MI_ACCESS_TOKEN) { + log( + 'Warning: MI_ACCESS_TOKEN / REAL_MI_TOKEN not set — authenticated MI endpoints will not be recorded.\n' + + ' Set MI_ACCESS_TOKEN= to capture template API calls automatically.', + ); +} + +// 3. Start pp-dev next directly (no shell wrapper to avoid pipe latency on Windows) +log('Starting pp-dev next...'); +const child = spawn(process.execPath, [PP_DEV_JS, 'next', '--host', 'localhost', '--port', String(PP_PORT), '--strictPort'], { + cwd: TEST_APP_DIR, + env: { ...process.env, NO_COLOR: '1', MI_ACCESS_TOKEN }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +ppdev = child; + +child.stdout?.pipe(process.stdout); +child.stderr?.pipe(process.stderr); + +// 4. Wait for startup +await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`pp-dev startup timed out after ${STARTUP_TIMEOUT}ms`)), + STARTUP_TIMEOUT, + ); + const onData = (data: Buffer) => { + if (/pp-dev Next\.js server running/i.test(data.toString())) { + clearTimeout(timer); + resolve(); + } + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.once('exit', (code) => { + clearTimeout(timer); + reject(new Error(`pp-dev exited early with code ${code}`)); + }); +}); + +log('pp-dev is ready. Making requests to capture MI API calls...'); + +// 5. Make requests to trigger MI API calls (auth check, template vars, etc.) +const paths = ['/', '/pl/pp-dev-test-template/', '/data/page/index/auth/info']; + +for (const p of paths) { + try { + const res = await fetch(`${PP_BASE}${p}`, { + redirect: 'manual', + signal: AbortSignal.timeout(REQUEST_TIMEOUT), + }); + log(` ${p} → ${res.status}`); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + log(` ${p} → error: ${msg}`); + } +} + +// 6. Fetch template API endpoints directly from mock-mi to record them. +// These are used during template:sync (download + upload flow). We request +// them through the mock-mi proxy so it records the real MI responses without +// needing to trigger a full next build. +if (APP_ID !== null && MI_ACCESS_TOKEN) { + const authHeaders: Record = { Authorization: `Bearer ${MI_ACCESS_TOKEN}` }; + + log(`Fetching page data to discover template ID (app ${APP_ID})...`); + let templateId: number | null = null; + try { + const pageRes = await fetch(`${mockMi.url}/api/page/id/${APP_ID}`, { + headers: authHeaders, + signal: AbortSignal.timeout(REQUEST_TIMEOUT), + }); + if (pageRes.ok) { + const json = (await pageRes.json()) as { page?: { template_id?: number } }; + templateId = json?.page?.template_id ?? null; + log(` Discovered template_id: ${templateId}`); + } else { + log(` /api/page/id/${APP_ID} → ${pageRes.status}`); + } + } catch (err) { + log(` /api/page/id/${APP_ID} → error: ${err instanceof Error ? err.message : String(err)}`); + } + + if (templateId !== null) { + // Template metadata + try { + const infoRes = await fetch(`${mockMi.url}/api/page_template/id/${templateId}`, { + headers: authHeaders, + signal: AbortSignal.timeout(REQUEST_TIMEOUT), + }); + log(` /api/page_template/id/${templateId} → ${infoRes.status}`); + } catch (err) { + log(` /api/page_template/id/${templateId} → error: ${err instanceof Error ? err.message : String(err)}`); + } + + // Template ZIP download (binary — may be large, use extended timeout) + try { + const dlRes = await fetch(`${mockMi.url}/api/page_template/id/${templateId}/asset/download`, { + headers: authHeaders, + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT), + }); + log(` /api/page_template/id/${templateId}/asset/download → ${dlRes.status}`); + // Consume body so the proxy finishes writing before we proceed + await dlRes.arrayBuffer(); + log(` (ZIP body consumed)`); + } catch (err) { + log( + ` /api/page_template/id/${templateId}/asset/download → error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } +} else if (APP_ID !== null) { + log('Skipping template API fetch (no MI_ACCESS_TOKEN).'); +} + +// Give pending proxy responses time to be captured +await new Promise((r) => setTimeout(r, 2_000)); + +// 7. Save cassette explicitly before teardown +mockMi.save?.(); + +log('Done.'); +} finally { + await cleanup(); +} diff --git a/tests/mock-mi/record.ts b/tests/mock-mi/record.ts new file mode 100644 index 0000000..6c6ecc8 --- /dev/null +++ b/tests/mock-mi/record.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Record real MI API interactions for use in E2E tests. + * + * Usage (VPN must be enabled): + * npm run record:mi [cassette-name] + * npm run record:mi startup + * + * The script starts: + * 1. mock-mi server in RECORD mode (proxies to real MI) + * 2. pp-dev next in tests/test-nextjs with MI URL → mock server + * + * Interact with the dev server briefly, then press Ctrl+C. + * Cassette is saved to tests/mock-mi/cassettes/.json. + * + * IMPORTANT: review the cassette before committing — remove any tokens or + * session cookies from response bodies / headers if sensitive. + */ + +import { spawn } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; +import { startMockMiServer, DEFAULT_PORT } from './server.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TEST_APP_DIR = path.resolve(__dirname, '../test-nextjs'); +const CONFIG_PATH = path.join(TEST_APP_DIR, 'pp-dev.config.ts'); +const CASSETTE_NAME = process.argv[2] ?? 'startup'; +const REAL_MI_URL = process.env.REAL_MI_URL; +const PP_DEV_JS = path.join(TEST_APP_DIR, 'node_modules/@metricinsights/pp-dev/bin/pp-dev.js'); + +if (!REAL_MI_URL) { + console.error('Set REAL_MI_URL env var to the target MI instance, e.g.:'); + console.error(' REAL_MI_URL=https://stg7x.metricinsights.com npm run record:mi'); + process.exit(1); +} + +console.log(`Recording cassette "${CASSETTE_NAME}" from ${REAL_MI_URL}`); + +const mockMi = await startMockMiServer({ + mode: 'record', + port: DEFAULT_PORT, + cassetteName: CASSETTE_NAME, + realMiUrl: REAL_MI_URL, +}); + +// Temporarily rewrite pp-dev.config.ts to point at the mock server +const originalConfig = fs.readFileSync(CONFIG_PATH, 'utf-8'); +const patchedConfig = originalConfig.replace( + /url:\s*['"]https?:\/\/[^'"]+['"]/, + `url: '${mockMi.url}'`, +); +fs.writeFileSync(CONFIG_PATH, patchedConfig); + +const ppdev = spawn(process.execPath, [PP_DEV_JS, 'next'], { + cwd: TEST_APP_DIR, + stdio: 'inherit', + env: { ...process.env }, +}); + +let cleaningUp = false; + +const cleanup = async (signal?: NodeJS.Signals) => { + if (cleaningUp) { + return; + } + + cleaningUp = true; + console.log(`\n[record] Stopping... (${signal ?? 'exit'})`); + fs.writeFileSync(CONFIG_PATH, originalConfig); + mockMi.save?.(); + if (ppdev.exitCode === null && ppdev.signalCode === null) { + ppdev.kill(); + } + await mockMi.close(); +}; + +process.once('SIGINT', () => cleanup('SIGINT').then(() => process.exit(0))); +process.once('SIGTERM', () => cleanup('SIGTERM').then(() => process.exit(0))); + +ppdev.once('exit', () => { + cleanup().then(() => process.exit(0)); +}); diff --git a/tests/mock-mi/server.ts b/tests/mock-mi/server.ts new file mode 100644 index 0000000..4bf67ab --- /dev/null +++ b/tests/mock-mi/server.ts @@ -0,0 +1,393 @@ +import express, { Request, Response } from 'express'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as http from 'http'; +import * as net from 'net'; +import * as zlib from 'zlib'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export type MockMode = 'record' | 'replay'; + +export interface Interaction { + request: { + method: string; + pathname: string; + }; + response: { + status: number; + headers: Record; + /** UTF-8 text for text responses; base64-encoded for binary (bodyEncoding === 'base64'). */ + body: string; + bodyEncoding?: 'base64'; + }; +} + +function isBinaryContentType(contentType: string | undefined): boolean { + if (!contentType) return false; + const t = contentType.split(';')[0].trim().toLowerCase(); + return ( + !t.startsWith('text/') && + t !== 'application/json' && + t !== 'application/javascript' && + t !== 'application/xml' && + t !== 'application/x-www-form-urlencoded' + ); +} + +export interface Cassette { + name: string; + baseUrl: string; + recordedAt: string; + interactions: Interaction[]; +} + +export const CASSETTES_DIR = path.resolve(__dirname, 'cassettes'); +export const DEFAULT_PORT = 7331; +const FILESYSTEM_PATH_RE = /\/(?:opt|var|srv|usr|home)\/[^\s"'<>),\]}]+/g; + +function cassetteKey(method: string, pathname: string): string { + return `${method.toUpperCase()}:${pathname}`; +} + +function redactFilesystemPaths(value: string): string { + return value.replace(FILESYSTEM_PATH_RE, '[REDACTED_PATH]'); +} + +function sanitizeHeaders(headers: Record): Record { + const allowedHeaders = new Set([ + 'accept-ranges', + 'cache-control', + 'content-type', + 'etag', + 'expires', + 'last-modified', + 'pragma', + 'vary', + ]); + + return Object.fromEntries( + Object.entries(headers).filter(([header]) => allowedHeaders.has(header.toLowerCase())), + ) as Record; +} + +function sanitizeJsonValue(value: unknown, parentKey = ''): unknown { + if (Array.isArray(value)) { + return value.map((item) => sanitizeJsonValue(item, parentKey)); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [key, sanitizeJsonValue(nestedValue, key)]), + ); + } + + const key = parentKey.toLowerCase(); + + if (typeof value === 'number' && /user_id|userId|created_by|updated_by/.test(key)) { + return 1; + } + + if (typeof value !== 'string') { + return value; + } + + if (/token|secret|password|session|cookie|authorization/.test(key)) { + return '[REDACTED]'; + } + + if (/email/.test(key)) { + return 'mock-ci'; + } + + if (/username|login|display_name|displayname/.test(key)) { + return 'mock-ci'; + } + + if (/first_name|firstname/.test(key)) { + return 'Mock'; + } + + if (/last_name|lastname/.test(key)) { + return 'User'; + } + + return redactFilesystemPaths(value); +} + +function sanitizeTextBody(body: string): string { + return body + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, 'mock-ci') + .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[REDACTED]') + .replace(/(["']?(?:access_)?token["']?\s*[:=]\s*["'])[^\"']+(["'])/gi, '$1[REDACTED]$2') + .replace(/(["']?session(?:_id)?["']?\s*[:=]\s*["'])[^\"']+(["'])/gi, '$1[REDACTED]$2') + .replace(/(["']?email["']?\s*[:=]\s*["'])[^\"']+(["'])/gi, '$1mock-ci$2') + .replace(/(["']?user_name["']?\s*[:=]\s*["'])[^\"']+(["'])/gi, '$1mock-ci$2') + .replace(/(["']?username["']?\s*[:=]\s*["'])[^\"']+(["'])/gi, '$1mock-ci$2') + .replace(/(["']?first_name["']?\s*[:=]\s*["'])[^\"']+(["'])/gi, '$1Mock$2') + .replace(/(["']?last_name["']?\s*[:=]\s*["'])[^\"']+(["'])/gi, '$1User$2') + .replace(FILESYSTEM_PATH_RE, '[REDACTED_PATH]'); +} + +function sanitizeBody(body: string, contentType: string): string { + if (contentType.toLowerCase().includes('application/json')) { + try { + return JSON.stringify(sanitizeJsonValue(JSON.parse(body))); + } catch { + // Fall through to text redaction for malformed JSON/JSONP. + } + } + + return sanitizeTextBody(body); +} + +function sanitizeCassette(cassette: Cassette): Cassette { + return { + ...cassette, + interactions: cassette.interactions.map((interaction) => { + if (interaction.request.pathname === '/auth/info.js') { + return { + ...interaction, + response: { + status: 200, + headers: { 'content-type': 'application/javascript; charset=utf-8' }, + body: 'window.miAuthInfo = { authenticated: true, user: { username: "mock-ci" } };\n', + }, + }; + } + + if (interaction.request.pathname === '/js/main.js') { + return { + ...interaction, + response: { + status: 200, + headers: { 'content-type': 'application/javascript; charset=utf-8' }, + body: 'window.MI = window.MI || {};\n', + }, + }; + } + + if (interaction.request.pathname === '/css/main.css') { + return { + ...interaction, + response: { + status: 200, + headers: { 'content-type': 'text/css; charset=utf-8' }, + body: '/* Mock MI top bar styles for pp-dev E2E tests. */\n', + }, + }; + } + + if (/^\/api\/page_template\/id\/\d+\/asset\/download$/.test(interaction.request.pathname)) { + return { + ...interaction, + response: { + status: 404, + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ error: 'Template asset download omitted from cassette' }), + }, + }; + } + + const headers = sanitizeHeaders(interaction.response.headers); + const contentType = String(headers['content-type'] ?? headers['Content-Type'] ?? ''); + + return { + ...interaction, + response: { + ...interaction.response, + headers, + body: + interaction.response.bodyEncoding === 'base64' + ? interaction.response.body + : sanitizeBody(interaction.response.body, contentType), + }, + }; + }), + }; +} + +export function loadCassette(name: string): Cassette { + const file = path.join(CASSETTES_DIR, `${name}.json`); + if (!fs.existsSync(file)) { + throw new Error(`Cassette not found: ${file}\nRun "npm run record:mi" with VPN enabled to record it.`); + } + return JSON.parse(fs.readFileSync(file, 'utf-8').replace(/^\uFEFF/, '')) as Cassette; +} + +export function saveCassette(cassette: Cassette): void { + fs.mkdirSync(CASSETTES_DIR, { recursive: true }); + const file = path.join(CASSETTES_DIR, `${cassette.name}.json`); + fs.writeFileSync(file, JSON.stringify(sanitizeCassette(cassette), null, 2)); + console.log(`[mock-mi] Saved ${cassette.interactions.length} interactions → ${file}`); +} + +export interface MockMiServer { + url: string; + /** Only available in record mode — call before close() to persist the cassette. */ + save?(): void; + close(): Promise; +} + +export async function startMockMiServer(opts: { + mode: MockMode; + port?: number; + cassetteName?: string; + realMiUrl?: string; +}): Promise { + const { + mode, + port = DEFAULT_PORT, + cassetteName = 'startup', + realMiUrl = process.env.REAL_MI_URL ?? 'https://stg7x.metricinsights.com', + } = opts; + + const app = express(); + let cassetteSave: (() => void) | undefined; + + // ── Replay mode ────────────────────────────────────────────────────────── + if (mode === 'replay') { + const cassette = loadCassette(cassetteName); + const map = new Map(); + + // Last-response-wins: the final state after any auth/login sequence + for (const interaction of cassette.interactions) { + map.set(cassetteKey(interaction.request.method, interaction.request.pathname), interaction); + } + + app.use((req: Request, res: Response) => { + const url = new URL(req.url, `http://localhost:${port}`); + const key = cassetteKey(req.method, url.pathname); + const interaction = map.get(key); + + if (!interaction) { + console.warn(`[mock-mi:replay] No cassette entry for ${key}`); + res.status(404).json({ error: 'Not in cassette', path: url.pathname }); + return; + } + + res.status(interaction.response.status); + for (const [header, value] of Object.entries(interaction.response.headers)) { + try { + res.setHeader(header, value); + } catch { + // skip headers that Node rejects + } + } + const body = + interaction.response.bodyEncoding === 'base64' + ? Buffer.from(interaction.response.body, 'base64') + : interaction.response.body; + res.end(body); + }); + } + + // ── Record mode ─────────────────────────────────────────────────────────── + if (mode === 'record') { + const interactionMap = new Map(); + + app.use( + createProxyMiddleware({ + target: realMiUrl, + changeOrigin: true, + selfHandleResponse: true, + on: { + proxyRes(proxyRes: http.IncomingMessage, req: http.IncomingMessage, res: http.ServerResponse) { + const chunks: Buffer[] = []; + proxyRes.on('data', (chunk: Buffer) => chunks.push(chunk)); + proxyRes.on('end', () => { + const rawBuffer = Buffer.concat(chunks); + const url = new URL(req.url ?? '/', `http://localhost:${port}`); + const key = cassetteKey(req.method ?? 'GET', url.pathname); + const encodingHeader = proxyRes.headers['content-encoding']; + const encoding = String(Array.isArray(encodingHeader) ? encodingHeader[0] : encodingHeader ?? '').toLowerCase(); + + // Decompress so cassette bodies are plain text/JSON + let bodyBuffer = rawBuffer; + const storedHeaders = Object.fromEntries( + Object.entries(proxyRes.headers) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, Array.isArray(v) ? v : String(v)]), + ) as Record; + + if (encoding === 'gzip' || encoding === 'x-gzip' || encoding === 'br' || encoding === 'deflate') { + try { + if (encoding === 'br') { + bodyBuffer = zlib.brotliDecompressSync(rawBuffer); + } else if (encoding === 'deflate') { + bodyBuffer = zlib.inflateSync(rawBuffer); + } else { + bodyBuffer = zlib.gunzipSync(rawBuffer); + } + delete storedHeaders['content-encoding']; + storedHeaders['content-length'] = String(bodyBuffer.byteLength); + } catch { + // keep raw bytes if decompression fails + } + } + + // Update map so the last response for each endpoint wins + const contentType = String(storedHeaders['content-type'] ?? ''); + const binary = isBinaryContentType(contentType); + interactionMap.set(key, { + request: { method: req.method ?? 'GET', pathname: url.pathname }, + response: { + status: proxyRes.statusCode ?? 200, + headers: storedHeaders, + body: binary ? bodyBuffer.toString('base64') : bodyBuffer.toString('utf-8'), + ...(binary && { bodyEncoding: 'base64' as const }), + }, + }); + + console.log(`[mock-mi:record] ${req.method} ${url.pathname} → ${proxyRes.statusCode}`); + + // Forward original (compressed) response to pp-dev unchanged + res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers as http.OutgoingHttpHeaders); + res.end(rawBuffer); + }); + }, + error(err: Error, _req: http.IncomingMessage, res: http.ServerResponse | net.Socket) { + console.error('[mock-mi:record] Proxy error:', err.message); + if (res instanceof http.ServerResponse) { + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + } + }, + }, + } as Parameters[0]), + ); + + // Save cassette — also called explicitly by record-auto.ts before exit + cassetteSave = () => { + if (interactionMap.size === 0) { + console.warn('[mock-mi:record] No interactions captured; cassette not saved.'); + return; + } + saveCassette({ + name: cassetteName, + baseUrl: realMiUrl, + recordedAt: new Date().toISOString(), + interactions: Array.from(interactionMap.values()), + }); + }; + process.once('SIGINT', cassetteSave); + process.once('SIGTERM', cassetteSave); + } + + return new Promise((resolve, reject) => { + const server = http.createServer(app); + server.listen(port, () => { + const url = `http://localhost:${port}`; + console.log(`[mock-mi] ${mode} mode → ${url} (target: ${mode === 'record' ? realMiUrl : 'cassette'})`); + resolve({ + url, + save: cassetteSave, + close: () => + new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))), + }); + }); + server.once('error', reject); + }); +} diff --git a/tests/test-commonjs/package-lock.json b/tests/test-commonjs/package-lock.json index 2e018f4..e1dd097 100644 --- a/tests/test-commonjs/package-lock.json +++ b/tests/test-commonjs/package-lock.json @@ -1250,9 +1250,9 @@ } }, "node_modules/@metricinsights/pp-dev": { - "version": "0.18.3-beta.2", + "version": "1.0.0", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-W2wbk1XbYkZsGwwuQ2XkZiSMU5dvvenCkUi3SVan7h5yxyaY9j0Jh5KWqDyUBTroUtcDqZ/Bm/zGoEl2YJSXMg==", + "integrity": "sha512-GDFCFAaZEx1duJlJfLPl/pOdT49AtfmR8eh9SNW882hvKsFnu5vnWvMqCd4yhKtnTS9gEyD68Zu/UTX1deiOvw==", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -1268,7 +1268,7 @@ "extract-zip": "^2.0.1", "file-type": "^22.0.0", "formdata-node": "^6.0.3", - "http-proxy-middleware": "^3.0.5", + "http-proxy-middleware": "^3.0.7", "isbinaryfile": "^6.0.0", "jsdom": "29.1.1", "jszip": "^3.10.1", @@ -1291,7 +1291,7 @@ "pp-dev": "bin/pp-dev.js" }, "engines": { - "node": ">=22.14" + "node": ">=24" }, "peerDependencies": { "next": ">= 13 < 17" @@ -4273,9 +4273,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", - "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.7.tgz", + "integrity": "sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==", "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.15", @@ -4286,7 +4286,7 @@ "micromatch": "^4.0.8" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^14.18.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/https-proxy-agent": { @@ -7073,12 +7073,12 @@ } }, "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=22.19.0" } }, "node_modules/undici-types": { diff --git a/tests/test-commonjs/package.json b/tests/test-commonjs/package.json index 8c88019..ba60645 100644 --- a/tests/test-commonjs/package.json +++ b/tests/test-commonjs/package.json @@ -33,7 +33,8 @@ "picomatch": "4.0.4", "postcss": ">=8.5.10", "serialize-javascript": ">=7.0.5", - "undici": ">=6.24.0", + "undici": ">=7.28.0", + "http-proxy-middleware": "^3.0.7", "vite@*": { "postcss": "8.5.10" }, diff --git a/tests/test-commonjs/pp-dev.config.ts b/tests/test-commonjs/pp-dev.config.ts index 9d591d0..6ac8038 100644 --- a/tests/test-commonjs/pp-dev.config.ts +++ b/tests/test-commonjs/pp-dev.config.ts @@ -1,13 +1,17 @@ -import { PPDevConfig } from '@metricinsights/pp-dev'; +import { defineConfig } from '@metricinsights/pp-dev'; -const ppDevConfig: PPDevConfig = { - backendBaseURL: 'https://stg7x.metricinsights.com', - appId: 937, - miHudLess: true, - integrateMiTopBar: true, - v7Features: true, - templateLess: false, - disableSSLValidation: true, -}; - -export default ppDevConfig; +export default defineConfig({ + mi: { + url: 'https://stg7x.metricinsights.com', + mode: 'standalone', + include: 'top-bar', + apiVersion: 7, + }, + app: { + id: 937, + type: 'template', + }, + proxy: { + tls: { allowSelfSigned: true }, + }, +}); diff --git a/tests/test-commonjs/tsconfig.json b/tests/test-commonjs/tsconfig.json index b37b2d5..c6262a7 100644 --- a/tests/test-commonjs/tsconfig.json +++ b/tests/test-commonjs/tsconfig.json @@ -8,6 +8,6 @@ "types": ["vite/client", "jquery"], "esModuleInterop": true }, - "include": ["src/**/*.ts", "src/**/*.d.ts"], + "include": ["src/**/*.ts", "src/**/*.d.ts", "vite.config.ts", "pp-dev.config.ts"], "exclude": ["node_modules"] } diff --git a/tests/test-nextjs-cjs/package-lock.json b/tests/test-nextjs-cjs/package-lock.json index 6bd19a9..38a2878 100644 --- a/tests/test-nextjs-cjs/package-lock.json +++ b/tests/test-nextjs-cjs/package-lock.json @@ -1867,9 +1867,9 @@ } }, "node_modules/@metricinsights/pp-dev": { - "version": "0.18.3-beta.2", + "version": "1.0.0", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-W2wbk1XbYkZsGwwuQ2XkZiSMU5dvvenCkUi3SVan7h5yxyaY9j0Jh5KWqDyUBTroUtcDqZ/Bm/zGoEl2YJSXMg==", + "integrity": "sha512-GDFCFAaZEx1duJlJfLPl/pOdT49AtfmR8eh9SNW882hvKsFnu5vnWvMqCd4yhKtnTS9gEyD68Zu/UTX1deiOvw==", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -1885,7 +1885,7 @@ "extract-zip": "^2.0.1", "file-type": "^22.0.0", "formdata-node": "^6.0.3", - "http-proxy-middleware": "^3.0.5", + "http-proxy-middleware": "^3.0.7", "isbinaryfile": "^6.0.0", "jsdom": "29.1.1", "jszip": "^3.10.1", @@ -1908,7 +1908,7 @@ "pp-dev": "bin/pp-dev.js" }, "engines": { - "node": ">=22.14" + "node": ">=24" }, "peerDependencies": { "next": ">= 13 < 17" @@ -7290,9 +7290,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", - "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.7.tgz", + "integrity": "sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==", "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.15", @@ -7303,7 +7303,7 @@ "micromatch": "^4.0.8" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^14.18.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/https-proxy-agent": { @@ -11700,12 +11700,12 @@ } }, "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=22.19.0" } }, "node_modules/undici-types": { diff --git a/tests/test-nextjs-cjs/package.json b/tests/test-nextjs-cjs/package.json index 0dcb0b3..e4931a5 100644 --- a/tests/test-nextjs-cjs/package.json +++ b/tests/test-nextjs-cjs/package.json @@ -44,7 +44,8 @@ "path-to-regexp": ">=8.4.0", "picomatch": "4.0.4", "serialize-javascript": ">=7.0.5", - "undici": ">=6.24.0", + "undici": ">=7.28.0", + "http-proxy-middleware": "^3.0.7", "vite": "^8.0.16", "vite@*": { "postcss": "8.5.10" diff --git a/tests/test-nextjs-cjs/pp-dev.config.js b/tests/test-nextjs-cjs/pp-dev.config.js index 9d6a555..8ef951b 100644 --- a/tests/test-nextjs-cjs/pp-dev.config.js +++ b/tests/test-nextjs-cjs/pp-dev.config.js @@ -2,12 +2,18 @@ * @type {import('@metricinsights/pp-dev').PPDevConfig} */ const ppDevConfig = { - backendBaseURL: 'https://stg7x.metricinsights.com', - portalPageId: 733, - v7Features: true, - templateLess: false, - miHudLess: true, - disableSSLValidation: true, + mi: { + url: 'https://stg7x.metricinsights.com', + mode: 'standalone', + apiVersion: 7, + }, + app: { + id: 733, + type: 'template', + }, + proxy: { + tls: { allowSelfSigned: true }, + }, }; module.exports = ppDevConfig; diff --git a/tests/test-nextjs/package-lock.json b/tests/test-nextjs/package-lock.json index 9b72f35..baab4ad 100644 --- a/tests/test-nextjs/package-lock.json +++ b/tests/test-nextjs/package-lock.json @@ -19,7 +19,7 @@ "extract-zip": "^2.0.1", "file-type": "^22.0.0", "formdata-node": "^6.0.3", - "http-proxy-middleware": "^3.0.5", + "http-proxy-middleware": "^3.0.7", "isbinaryfile": "^6.0.0", "jsdom": "29.1.1", "memory-cache": "^0.2.0", @@ -1880,9 +1880,9 @@ } }, "node_modules/@metricinsights/pp-dev": { - "version": "0.18.3-beta.2", + "version": "1.0.0", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-W2wbk1XbYkZsGwwuQ2XkZiSMU5dvvenCkUi3SVan7h5yxyaY9j0Jh5KWqDyUBTroUtcDqZ/Bm/zGoEl2YJSXMg==", + "integrity": "sha512-GDFCFAaZEx1duJlJfLPl/pOdT49AtfmR8eh9SNW882hvKsFnu5vnWvMqCd4yhKtnTS9gEyD68Zu/UTX1deiOvw==", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -1898,7 +1898,7 @@ "extract-zip": "^2.0.1", "file-type": "^22.0.0", "formdata-node": "^6.0.3", - "http-proxy-middleware": "^3.0.5", + "http-proxy-middleware": "^3.0.7", "isbinaryfile": "^6.0.0", "jsdom": "29.1.1", "jszip": "^3.10.1", @@ -1921,7 +1921,7 @@ "pp-dev": "bin/pp-dev.js" }, "engines": { - "node": ">=22.14" + "node": ">=24" }, "peerDependencies": { "next": ">= 13 < 17" @@ -7448,9 +7448,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", - "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.7.tgz", + "integrity": "sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==", "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.15", @@ -7461,7 +7461,7 @@ "micromatch": "^4.0.8" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^14.18.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/https-proxy-agent": { @@ -8232,15 +8232,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/jsdom/node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/jsdom/node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -11890,12 +11881,12 @@ } }, "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=22.19.0" } }, "node_modules/undici-types": { diff --git a/tests/test-nextjs/package.json b/tests/test-nextjs/package.json index 4a595e1..d2ee98a 100644 --- a/tests/test-nextjs/package.json +++ b/tests/test-nextjs/package.json @@ -22,7 +22,7 @@ "extract-zip": "^2.0.1", "file-type": "^22.0.0", "formdata-node": "^6.0.3", - "http-proxy-middleware": "^3.0.5", + "http-proxy-middleware": "^3.0.7", "isbinaryfile": "^6.0.0", "jsdom": "29.1.1", "memory-cache": "^0.2.0", @@ -72,7 +72,7 @@ "path-to-regexp": ">=8.4.0", "picomatch": "4.0.4", "serialize-javascript": ">=7.0.5", - "undici": ">=6.24.0", + "undici": ">=7.28.0", "vite": "^8.0.16", "vite@*": { "postcss": "8.5.10" diff --git a/tests/test-nextjs/pp-dev.config.ts b/tests/test-nextjs/pp-dev.config.ts index dd68425..35d9149 100644 --- a/tests/test-nextjs/pp-dev.config.ts +++ b/tests/test-nextjs/pp-dev.config.ts @@ -1,12 +1,16 @@ -import { PPDevConfig } from '@metricinsights/pp-dev'; +import { defineConfig } from '@metricinsights/pp-dev'; -const ppDevConfig: PPDevConfig = { - backendBaseURL: 'https://stg7x.metricinsights.com', - portalPageId: 937, - v7Features: true, - templateLess: false, - miHudLess: true, - disableSSLValidation: true, -}; - -export default ppDevConfig; +export default defineConfig({ + mi: { + url: 'https://stg7x.metricinsights.com', + mode: 'standalone', + apiVersion: 7, + }, + app: { + id: 937, + type: 'template', + }, + proxy: { + tls: { allowSelfSigned: true }, + }, +}); diff --git a/tests/test-nextjs/public/__template_variables.json b/tests/test-nextjs/public/__template_variables.json new file mode 100644 index 0000000..2a182f2 --- /dev/null +++ b/tests/test-nextjs/public/__template_variables.json @@ -0,0 +1,26 @@ +{ + "tags": [ + { + "portal_page_template_tag_id": 2341, + "portal_page_template_id": 313, + "name": "variable-test", + "uid": "c8dfece5cc68249206e4690fc4737a8d", + "default_value": "String value for test", + "additional_options": "", + "tag_type": "text", + "tag_source": "static", + "javascript_code": null, + "display_order": 0, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "Value for test", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + } + ], + "settings": { + "image_capture_timeout": 20, + "image_capture_on_event": "none", + "image_capture_css_selector": "none" + } +} \ No newline at end of file diff --git a/tests/test-nextjs/tsconfig.json b/tests/test-nextjs/tsconfig.json index a40ece7..759d186 100644 --- a/tests/test-nextjs/tsconfig.json +++ b/tests/test-nextjs/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, "include": [ @@ -27,7 +33,15 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" + ".next/dev/types/**/*.ts", + "dist/types/**/*.ts", + "dist/dev/types/**/*.ts", + "dist/dev/dev/types/**/*.ts", + "next.config.mjs", + "pp-dev.config.ts" ], - "exclude": ["node_modules", "dist", "out"] + "exclude": [ + "node_modules", + "out" + ] } diff --git a/tests/unit/config/config.loader.spec.ts b/tests/unit/config/config.loader.spec.ts index 98c876b..9a1b193 100644 --- a/tests/unit/config/config.loader.spec.ts +++ b/tests/unit/config/config.loader.spec.ts @@ -8,33 +8,26 @@ describe('Config Loader', () => { let originalCwd: string; beforeEach(() => { - // Save original cwd originalCwd = process.cwd(); - // Create a unique test directory testDir = join(tmpdir(), `pp-dev-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(testDir, { recursive: true }); - // Change to test directory process.chdir(testDir); }); afterEach(async () => { - // Restore original cwd process.chdir(originalCwd); - // Clean up test directory if (existsSync(testDir)) { rmSync(testDir, { recursive: true, force: true }); } - // Clear module cache to ensure fresh imports vi.resetModules(); }); describe('getConfig', () => { it('should return empty config when no config files exist', async () => { - // Create minimal package.json writeFileSync(join(testDir, 'package.json'), JSON.stringify({ name: 'test' })); const { getConfig, clearConfigCache } = await import('../../../src/config.js'); @@ -45,14 +38,14 @@ describe('Config Loader', () => { expect(config).toEqual({}); }); - it('should load config from package.json pp-dev field', async () => { + it('should load grouped config from package.json pp-dev field', async () => { writeFileSync( join(testDir, 'package.json'), JSON.stringify({ name: 'test-project', 'pp-dev': { - backendBaseURL: 'https://pkg.example.com', - portalPageId: 456, + mi: { url: 'https://pkg.example.com' }, + app: { id: 456 }, }, }), ); @@ -62,37 +55,17 @@ describe('Config Loader', () => { const config = await getConfig(); - expect(config.backendBaseURL).toBe('https://pkg.example.com'); - expect(config.portalPageId).toBe(456); + expect(config.mi?.url).toBe('https://pkg.example.com'); + expect(config.app?.id).toBe(456); }); - it('should load config from .pp-watch.config.json (legacy format)', async () => { - writeFileSync(join(testDir, 'package.json'), JSON.stringify({ name: 'test' })); - writeFileSync( - join(testDir, '.pp-watch.config.json'), - JSON.stringify({ - baseURL: 'https://watch.example.com', - portalPageId: 789, - }), - ); - - const { getConfig, clearConfigCache } = await import('../../../src/config.js'); - clearConfigCache(); - - const config = await getConfig(); - - expect(config.backendBaseURL).toBe('https://watch.example.com'); - expect(config.portalPageId).toBe(789); - }); - - it('should load config from pp-dev.config.json', async () => { + it('should load grouped config from pp-dev.config.json', async () => { writeFileSync(join(testDir, 'package.json'), JSON.stringify({ name: 'test' })); writeFileSync( join(testDir, 'pp-dev.config.json'), JSON.stringify({ - backendBaseURL: 'https://json.example.com', - portalPageId: 111, - templateLess: true, + mi: { url: 'https://json.example.com' }, + app: { id: 111, type: 'page' }, }), ); @@ -101,27 +74,27 @@ describe('Config Loader', () => { const config = await getConfig(); - expect(config.backendBaseURL).toBe('https://json.example.com'); - expect(config.portalPageId).toBe(111); - expect(config.templateLess).toBe(true); + expect(config.mi?.url).toBe('https://json.example.com'); + expect(config.app?.id).toBe(111); + expect(config.app?.type).toBe('page'); }); - it('should prioritize pp-dev config over pp-watch config', async () => { - writeFileSync(join(testDir, 'package.json'), JSON.stringify({ name: 'test' })); - - // Create both configs + it('should prioritize config file over package.json pp-dev field', async () => { writeFileSync( - join(testDir, 'pp-dev.config.json'), + join(testDir, 'package.json'), JSON.stringify({ - backendBaseURL: 'https://pp-dev.example.com', - portalPageId: 100, + name: 'test', + 'pp-dev': { + mi: { url: 'https://pkg.example.com' }, + app: { id: 1 }, + }, }), ); writeFileSync( - join(testDir, '.pp-watch.config.json'), + join(testDir, 'pp-dev.config.json'), JSON.stringify({ - baseURL: 'https://pp-watch.example.com', - portalPageId: 200, + mi: { url: 'https://file.example.com' }, + app: { id: 2 }, }), ); @@ -130,27 +103,17 @@ describe('Config Loader', () => { const config = await getConfig(); - // pp-dev config should take priority - expect(config.backendBaseURL).toBe('https://pp-dev.example.com'); - expect(config.portalPageId).toBe(100); + expect(config.mi?.url).toBe('https://file.example.com'); + expect(config.app?.id).toBe(2); }); - it('should prioritize config file over package.json pp-dev field', async () => { - writeFileSync( - join(testDir, 'package.json'), - JSON.stringify({ - name: 'test', - 'pp-dev': { - backendBaseURL: 'https://pkg.example.com', - portalPageId: 1, - }, - }), - ); + it('should not load pp-watch.config.json (legacy format removed in 1.0)', async () => { + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ name: 'test' })); writeFileSync( - join(testDir, 'pp-dev.config.json'), + join(testDir, '.pp-watch.config.json'), JSON.stringify({ - backendBaseURL: 'https://file.example.com', - portalPageId: 2, + baseURL: 'https://watch.example.com', + portalPageId: 789, }), ); @@ -159,9 +122,8 @@ describe('Config Loader', () => { const config = await getConfig(); - // Config file should take priority - expect(config.backendBaseURL).toBe('https://file.example.com'); - expect(config.portalPageId).toBe(2); + // Legacy watch config is no longer loaded — result should be empty + expect(config).toEqual({}); }); }); @@ -186,8 +148,6 @@ describe('Config Loader', () => { }); it('should return empty object when package.json does not exist', async () => { - // Don't create package.json - const { getPkg, clearConfigCache } = await import('../../../src/config.js'); clearConfigCache(); @@ -201,35 +161,25 @@ describe('Config Loader', () => { it('should cache package.json reads', async () => { writeFileSync(join(testDir, 'package.json'), JSON.stringify({ name: 'test', version: '1.0.0' })); - const { getPkg, clearConfigCache, getConfigCacheStats } = await import('../../../src/config.js'); + const { getPkg, clearConfigCache } = await import('../../../src/config.js'); clearConfigCache(); - // First call - getPkg(); - const stats1 = getConfigCacheStats(); - - // Second call (should use cache) - getPkg(); - const stats2 = getConfigCacheStats(); + const pkg1 = getPkg(); + const pkg2 = getPkg(); - expect(stats1.packageJsonCached).toBe(true); - expect(stats2.packageJsonCached).toBe(true); + expect(pkg1).toBe(pkg2); // same cached reference on second call }); it('should clear cache when clearConfigCache is called', async () => { - writeFileSync(join(testDir, 'package.json'), JSON.stringify({ name: 'test' })); - - const { getPkg, clearConfigCache, getConfigCacheStats } = await import('../../../src/config.js'); + const { getPkg, clearConfigCache } = await import('../../../src/config.js'); - // Populate cache - getPkg(); - expect(getConfigCacheStats().packageJsonCached).toBe(true); + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ name: 'before-clear' })); + clearConfigCache(); + expect(getPkg().name).toBe('before-clear'); - // Clear cache + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ name: 'after-clear' })); clearConfigCache(); - expect(getConfigCacheStats().packageJsonCached).toBe(false); - expect(getConfigCacheStats().configEntries).toBe(0); - expect(getConfigCacheStats().dirContentCached).toBe(false); + expect(getPkg().name).toBe('after-clear'); }); }); }); diff --git a/tests/unit/config/config.validation.spec.ts b/tests/unit/config/config.validation.spec.ts index b49bdaf..bd93ebb 100644 --- a/tests/unit/config/config.validation.spec.ts +++ b/tests/unit/config/config.validation.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; // Import the constants to test config file name patterns -import { PP_DEV_CONFIG_NAMES, PP_WATCH_CONFIG_NAMES } from '../../../src/constants.js'; +import { PP_DEV_CONFIG_NAMES } from '../../../src/constants.js'; describe('Config Validation', () => { describe('PP_DEV_CONFIG_NAMES', () => { @@ -33,18 +33,6 @@ describe('Config Validation', () => { }); }); - describe('PP_WATCH_CONFIG_NAMES', () => { - it('should include legacy pp-watch config file names', () => { - expect(PP_WATCH_CONFIG_NAMES).toContain('.pp-watch.config.js'); - expect(PP_WATCH_CONFIG_NAMES).toContain('.pp-watch.config.ts'); - expect(PP_WATCH_CONFIG_NAMES).toContain('.pp-watch.config.json'); - }); - - it('should only have 3 legacy config names', () => { - expect(PP_WATCH_CONFIG_NAMES).toHaveLength(3); - }); - }); - describe('Config File Extension Patterns', () => { const supportedExtensions = ['.ts', '.js', '.json', '.cjs', '.mjs', '.cts', '.mts']; diff --git a/tests/unit/lib/dev-panel.spec.ts b/tests/unit/lib/dev-panel.spec.ts index d3e86cc..aed99ec 100644 --- a/tests/unit/lib/dev-panel.spec.ts +++ b/tests/unit/lib/dev-panel.spec.ts @@ -39,7 +39,7 @@ describe('dev-panel', () => { const out = injectDevPanel(baseHtml, '/p/my-app/', { templateLess: false, backendBaseURL: 'http://mi', - portalPageId: 1, + appId: 1, }); expect(out).toContain(''); diff --git a/tests/unit/lib/migrate.spec.ts b/tests/unit/lib/migrate.spec.ts new file mode 100644 index 0000000..4d18e2f --- /dev/null +++ b/tests/unit/lib/migrate.spec.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from 'vitest'; +import { + isLegacyFlatConfig, + isLegacyPPWatchConfig, + isAlreadyMigrated, + migrateLegacyFlatConfig, + migratePPWatchConfig, + generateConfigFileContent, +} from '../../../src/lib/migrate.js'; + +describe('migrate — detection', () => { + it('detects legacy flat config', () => { + expect(isLegacyFlatConfig({ backendBaseURL: 'https://x', appId: 1 })).toBe(true); + expect(isLegacyFlatConfig({ portalPageId: 1, templateLess: false })).toBe(true); + expect(isLegacyFlatConfig({ miHudLess: true })).toBe(true); + }); + + it('detects pp-watch config', () => { + expect(isLegacyPPWatchConfig({ baseURL: 'https://x', portalPageId: 5 })).toBe(true); + expect(isLegacyPPWatchConfig({ backendBaseURL: 'https://x' })).toBe(false); + }); + + it('detects already-migrated config', () => { + expect(isAlreadyMigrated({ mi: { url: 'https://x' } })).toBe(true); + expect(isAlreadyMigrated({ app: { id: 1 } })).toBe(true); + expect(isAlreadyMigrated({ backendBaseURL: 'https://x' })).toBe(false); + }); +}); + +describe('migrate — migrateLegacyFlatConfig', () => { + it('maps backendBaseURL → mi.url and token', () => { + const result = migrateLegacyFlatConfig({ backendBaseURL: 'https://mi.co', personalAccessToken: 'tok' }); + expect(result.mi?.url).toBe('https://mi.co'); + expect(result.mi?.token).toBe('tok'); + }); + + it('maps miHudLess: true → mi.mode: standalone', () => { + expect(migrateLegacyFlatConfig({ miHudLess: true }).mi?.mode).toBe('standalone'); + expect(migrateLegacyFlatConfig({ miHudLess: false }).mi?.mode).toBe('embedding'); + }); + + it('maps integrateMiTopBar: true → mi.include: top-bar + forces standalone', () => { + const result = migrateLegacyFlatConfig({ integrateMiTopBar: true }); + expect(result.mi?.mode).toBe('standalone'); + expect(result.mi?.include).toBe('top-bar'); + }); + + it('maps integrateMiTopBar object', () => { + const result = migrateLegacyFlatConfig({ + integrateMiTopBar: { addSharedComponentsScripts: true, addRootElement: false }, + }); + expect(result.mi?.include).toBe('shared-components'); + }); + + it('maps v7Features → mi.apiVersion', () => { + expect(migrateLegacyFlatConfig({ v7Features: true }).mi?.apiVersion).toBe(7); + expect(migrateLegacyFlatConfig({ v7Features: false }).mi?.apiVersion).toBe(6); + }); + + it('prefers appId over portalPageId', () => { + expect(migrateLegacyFlatConfig({ appId: 10, portalPageId: 99 }).app?.id).toBe(10); + expect(migrateLegacyFlatConfig({ portalPageId: 5 }).app?.id).toBe(5); + }); + + it('maps templateLess → app.type', () => { + expect(migrateLegacyFlatConfig({ templateLess: true }).app?.type).toBe('page'); + expect(migrateLegacyFlatConfig({ templateLess: false }).app?.type).toBe('template'); + }); + + it('skips templateName if it matches packageName', () => { + const result = migrateLegacyFlatConfig({ templateName: 'my-app' }, 'my-app'); + expect(result.app?.name).toBeUndefined(); + }); + + it('keeps templateName if it differs from packageName', () => { + const result = migrateLegacyFlatConfig({ templateName: 'custom-name' }, 'my-app'); + expect(result.app?.name).toBe('custom-name'); + }); + + it('maps proxy fields', () => { + const result = migrateLegacyFlatConfig({ enableProxyCache: false, proxyCacheTTL: 30000, disableSSLValidation: true }); + expect(result.proxy?.cache).toBe(false); + expect(result.proxy?.cacheTtl).toBe(30000); + expect(result.proxy?.tls?.allowSelfSigned).toBe(true); + }); + + it('maps build fields', () => { + const result = migrateLegacyFlatConfig({ + outDir: 'build', + distZip: { outFileName: 'app.zip', outDir: 'zips' }, + versionPlugin: { versionFileTemplate: 'V-{packageversion}.json', enabled: true }, + imageOptimizer: true, + }); + expect(result.build?.outDir).toBe('build'); + expect(result.build?.zip).toEqual({ fileName: 'app.zip', outDir: 'zips' }); + expect(result.build?.versionFile).toEqual({ fileNameTemplate: 'V-{packageversion}.json', enabled: true }); + expect(result.build?.imageOptimisations).toBe(true); + }); + + it('maps build.zip: false', () => { + expect(migrateLegacyFlatConfig({ distZip: false }).build?.zip).toBe(false); + }); + + it('maps sync.backupsDir', () => { + expect(migrateLegacyFlatConfig({ syncBackupsDir: 'my-backups' }).sync?.backupsDir).toBe('my-backups'); + }); + + it('produces empty groups only when needed', () => { + const result = migrateLegacyFlatConfig({ appId: 42 }); + expect(result.mi).toBeUndefined(); + expect(result.proxy).toBeUndefined(); + expect(result.build).toBeUndefined(); + expect(result.sync).toBeUndefined(); + expect(result.app?.id).toBe(42); + }); +}); + +describe('migrate — migratePPWatchConfig', () => { + it('maps baseURL → mi.url and portalPageId → app.id', () => { + const result = migratePPWatchConfig({ baseURL: 'https://mi.co', portalPageId: 7 }); + expect(result.mi?.url).toBe('https://mi.co'); + expect(result.app?.id).toBe(7); + }); + + it('works without portalPageId', () => { + const result = migratePPWatchConfig({ baseURL: 'https://mi.co' }); + expect(result.app).toBeUndefined(); + }); +}); + +describe('migrate — generateConfigFileContent', () => { + const config = { mi: { url: 'https://mi.co', mode: 'standalone' as const }, app: { id: 42, type: 'template' as const } }; + + it('generates TS output with defineConfig import', () => { + const out = generateConfigFileContent(config, 'ts'); + expect(out).toContain(`import { defineConfig } from '@metricinsights/pp-dev'`); + expect(out).toContain('export default defineConfig('); + expect(out).toContain(`url: 'https://mi.co'`); + expect(out).toContain('id: 42'); + }); + + it('generates JS output with require', () => { + const out = generateConfigFileContent(config, 'js'); + expect(out).toContain(`require('@metricinsights/pp-dev')`); + expect(out).toContain('module.exports ='); + }); + + it('generates JSON output', () => { + const out = generateConfigFileContent(config, 'json'); + const parsed = JSON.parse(out); + expect(parsed.mi.url).toBe('https://mi.co'); + expect(parsed.app.id).toBe(42); + }); +}); diff --git a/tests/unit/plugin/plugin.normalize.spec.ts b/tests/unit/plugin/plugin.normalize.spec.ts index 4874821..3361fa0 100644 --- a/tests/unit/plugin/plugin.normalize.spec.ts +++ b/tests/unit/plugin/plugin.normalize.spec.ts @@ -1,592 +1,322 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { normalizeVitePPDevConfig, type VitePPDevOptions } from '../../../src/plugin.js'; +import { normalizePPDevConfig, validatePPDevConfig, type PPDevConfig } from '../../../src/plugin.js'; -describe('normalizeVitePPDevConfig', () => { +describe('normalizePPDevConfig', () => { const originalEnv = process.env; beforeEach(() => { vi.resetModules(); process.env = { ...originalEnv }; + delete process.env.MI_BACKEND_URL; + delete process.env.MI_ACCESS_TOKEN; }); afterEach(() => { process.env = originalEnv; }); - describe('Required Fields', () => { - it('should throw error for missing templateName', () => { - expect(() => normalizeVitePPDevConfig({} as VitePPDevOptions)).toThrow('templateName must be a non-empty string'); + describe('Default values', () => { + it('defaults mi.mode=standalone → miHudLess=true', () => { + const n = normalizePPDevConfig({}, 'my-app'); + expect(n.miHudLess).toBe(true); }); - it('should throw error for empty templateName', () => { - expect(() => normalizeVitePPDevConfig({ templateName: '' })).toThrow('templateName must be a non-empty string'); + it('defaults app.type=template → templateLess=false', () => { + const n = normalizePPDevConfig({}, 'my-app'); + expect(n.templateLess).toBe(false); }); - it('should accept valid templateName', () => { - const config = normalizeVitePPDevConfig({ templateName: 'my-template' }); - expect(config.templateName).toBe('my-template'); - }); - }); - - describe('Default Values', () => { - it('should set default enableProxyCache to true', () => { - const config = normalizeVitePPDevConfig({ templateName: 'test' }); - expect(config.enableProxyCache).toBe(true); + it('defaults mi.apiVersion=7 → v7Features=true', () => { + const n = normalizePPDevConfig({}, 'my-app'); + expect(n.v7Features).toBe(true); }); - it('should set default proxyCacheTTL to 600000 (10 minutes)', () => { - const config = normalizeVitePPDevConfig({ templateName: 'test' }); - expect(config.proxyCacheTTL).toBe(600000); + it('defaults proxy.cache=true → enableProxyCache=true', () => { + const n = normalizePPDevConfig({}, 'my-app'); + expect(n.enableProxyCache).toBe(true); }); - it('should set default disableSSLValidation to false', () => { - const config = normalizeVitePPDevConfig({ templateName: 'test' }); - expect(config.disableSSLValidation).toBe(false); + it('defaults proxy.cacheTtl=600000 → proxyCacheTTL=600000', () => { + const n = normalizePPDevConfig({}, 'my-app'); + expect(n.proxyCacheTTL).toBe(600_000); }); - it('should set default miHudLess to false', () => { - const config = normalizeVitePPDevConfig({ templateName: 'test' }); - expect(config.miHudLess).toBe(false); + it('defaults proxy.tls.allowSelfSigned=false → disableSSLValidation=false', () => { + const n = normalizePPDevConfig({}, 'my-app'); + expect(n.disableSSLValidation).toBe(false); }); - it('should set default templateLess to false', () => { - const config = normalizeVitePPDevConfig({ templateName: 'test' }); - expect(config.templateLess).toBe(false); + it('defaults build.outDir=dist', () => { + const n = normalizePPDevConfig({}, 'my-app'); + expect(n.outDir).toBe('dist'); }); - it('should set default v7Features to false', () => { - const config = normalizeVitePPDevConfig({ templateName: 'test' }); - expect(config.v7Features).toBe(false); + it('defaults sync.backupsDir=backups', () => { + const n = normalizePPDevConfig({}, 'my-app'); + expect(n.syncBackupsDir).toBe('backups'); }); - it('should set default integrateMiTopBar to false', () => { - const config = normalizeVitePPDevConfig({ templateName: 'test' }); - expect(config.integrateMiTopBar).toBe(false); + it('defaults mi.include=undefined → integrateMiTopBar=false', () => { + const n = normalizePPDevConfig({}, 'my-app'); + expect(n.integrateMiTopBar).toBe(false); }); - it('should set default outDir to "dist"', () => { - const config = normalizeVitePPDevConfig({ templateName: 'test' }); - expect(config.outDir).toBe('dist'); + it('uses templateName as fallback for templateName field', () => { + const n = normalizePPDevConfig({}, 'my-app'); + expect(n.templateName).toBe('my-app'); }); - it('should set default syncBackupsDir to "backups"', () => { - const config = normalizeVitePPDevConfig({ templateName: 'test' }); - expect(config.syncBackupsDir).toBe('backups'); + it('app.name overrides templateName param', () => { + const n = normalizePPDevConfig({ app: { name: 'custom-name' } }, 'my-app'); + expect(n.templateName).toBe('custom-name'); }); }); - describe('distZip Normalization', () => { - // Note: The current implementation has a bug where ...config at the end - // overwrites the normalized distZip value. These tests document actual behavior. - it('should preserve distZip: true as passed (note: normalization is overwritten by spread)', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'my-template', - distZip: true, - }); - - // Due to ...config spread at end, the original value is preserved - expect(config.distZip).toBe(true); - }); - - it('should normalize distZip: false to false', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'my-template', - distZip: false, - }); - - expect(config.distZip).toBe(false); + describe('mi.mode mapping', () => { + it('standalone → miHudLess=true', () => { + const n = normalizePPDevConfig({ mi: { mode: 'standalone' } }, 'app'); + expect(n.miHudLess).toBe(true); }); - it('should preserve object with outFileName as passed (spread overwrites normalization)', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'my-app', - distZip: { - outFileName: '[templateName]-build.zip', - }, - }); - - // Due to ...config spread at end, original object is preserved - expect(config.distZip).toEqual({ - outFileName: '[templateName]-build.zip', - }); + it('embedding → miHudLess=false', () => { + const n = normalizePPDevConfig({ mi: { mode: 'embedding' } }, 'app'); + expect(n.miHudLess).toBe(false); }); + }); - it('should use custom outDir when provided', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'my-template', - distZip: { - outDir: 'custom-output', - }, - }); - - expect((config.distZip as { outDir: string }).outDir).toBe('custom-output'); + describe('mi.include mapping', () => { + it('top-bar → integrateMiTopBar=true', () => { + const n = normalizePPDevConfig({ mi: { mode: 'standalone', include: 'top-bar' } }, 'app'); + expect(n.integrateMiTopBar).toBe(true); }); - it('should preserve distZip object as passed (spread overwrites normalization)', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - distZip: { - outFileName: 'custom-[templateName].zip', - outDir: 'release', - }, - }); - - // Due to ...config spread at end, original object is preserved - expect(config.distZip).toEqual({ - outFileName: 'custom-[templateName].zip', - outDir: 'release', - }); + it('shared-components → integrateMiTopBar object', () => { + const n = normalizePPDevConfig({ mi: { mode: 'standalone', include: 'shared-components' } }, 'app'); + expect(n.integrateMiTopBar).toEqual({ addSharedComponentsScripts: true, addRootElement: false }); }); }); - describe('versionPlugin Normalization', () => { - it('should normalize versionPlugin: false to false', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - versionPlugin: false, - }); - - expect(config.versionPlugin).toBe(false); + describe('app.type mapping', () => { + it('page → templateLess=true', () => { + const n = normalizePPDevConfig({ app: { type: 'page' } }, 'app'); + expect(n.templateLess).toBe(true); }); - it('should preserve versionPlugin: true as passed (spread overwrites normalization)', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - versionPlugin: true, - }); - - // Due to ...config spread at end, the original value is preserved - expect(config.versionPlugin).toBe(true); + it('template → templateLess=false', () => { + const n = normalizePPDevConfig({ app: { type: 'template' } }, 'app'); + expect(n.templateLess).toBe(false); }); + }); - it('should pass through versionPlugin object options', () => { - const opts = { - versionFileTemplate: 'CUSTOM-{packageversion}.json', - enabled: false, - }; - - const config = normalizeVitePPDevConfig({ - templateName: 'test', - versionPlugin: opts, - }); - - expect(config.versionPlugin).toEqual(opts); + describe('mi.apiVersion mapping', () => { + it('apiVersion=7 → v7Features=true', () => { + const n = normalizePPDevConfig({ mi: { apiVersion: 7 } }, 'app'); + expect(n.v7Features).toBe(true); }); - it('should normalize versionPlugin object with defaults when not fully specified', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - versionPlugin: { versionFileTemplate: 'custom.json' }, - }); - - // Normalization runs before spread; object gets versionFileTemplate merged with defaults - expect(config.versionPlugin).toMatchObject({ - versionFileTemplate: 'custom.json', - }); + it('apiVersion=6 → v7Features=false', () => { + const n = normalizePPDevConfig({ mi: { apiVersion: 6 } }, 'app'); + expect(n.v7Features).toBe(false); }); }); - describe('imageOptimizer Normalization', () => { - // Note: The current implementation has a bug where ...config at the end - // overwrites the normalized imageOptimizer value. These tests document actual behavior. - it('should preserve imageOptimizer: true as passed (spread overwrites normalization)', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - imageOptimizer: true, - }); - - // Due to ...config spread at end, the original value is preserved - expect(config.imageOptimizer).toBe(true); + describe('app.id mapping', () => { + it('app.id → appId', () => { + const n = normalizePPDevConfig({ app: { id: 123 } }, 'app'); + expect(n.appId).toBe(123); }); - it('should normalize imageOptimizer: false to false', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - imageOptimizer: false, - }); - - expect(config.imageOptimizer).toBe(false); - }); - - it('should pass through imageOptimizer object options', () => { - const optimizerOptions = { - png: { quality: 80 }, - jpeg: { quality: 75 }, - }; - - const config = normalizeVitePPDevConfig({ - templateName: 'test', - imageOptimizer: optimizerOptions, - }); - - expect(config.imageOptimizer).toEqual(optimizerOptions); + it('no app.id → appId=undefined', () => { + const n = normalizePPDevConfig({}, 'app'); + expect(n.appId).toBeUndefined(); }); }); - describe('portalPageId and appId', () => { - // Note: The spread at the end of normalizeVitePPDevConfig causes issues - // when both portalPageId and appId are provided, as portalPageId from - // config.portalPageId in the spread overwrites the computed value. - it('should keep portalPageId from original config when both provided (spread behavior)', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - portalPageId: 100, - appId: 200, - }); - - // Due to ...config spread at end, the original portalPageId is preserved - expect(config.portalPageId).toBe(100); + describe('mi.url / mi.token mapping', () => { + it('mi.url → backendBaseURL', () => { + const n = normalizePPDevConfig({ mi: { url: 'https://mi.example.com' } }, 'app'); + expect(n.backendBaseURL).toBe('https://mi.example.com'); }); - it('should use portalPageId when appId is not provided', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - portalPageId: 100, - }); - - expect(config.portalPageId).toBe(100); + it('falls back to MI_BACKEND_URL env', () => { + process.env.MI_BACKEND_URL = 'https://env.example.com'; + const n = normalizePPDevConfig({}, 'app'); + expect(n.backendBaseURL).toBe('https://env.example.com'); }); - it('should use appId when portalPageId is not provided', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - appId: 200, - }); - - expect(config.portalPageId).toBe(200); + it('mi.token → personalAccessToken', () => { + const n = normalizePPDevConfig({ mi: { token: 'my-token' } }, 'app'); + expect(n.personalAccessToken).toBe('my-token'); }); - }); - describe('personalAccessToken', () => { - it('should use provided personalAccessToken', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - personalAccessToken: 'my-token', - }); - - expect(config.personalAccessToken).toBe('my-token'); - }); - - it('should use MI_ACCESS_TOKEN from env when not provided', () => { + it('falls back to MI_ACCESS_TOKEN env', () => { process.env.MI_ACCESS_TOKEN = 'env-token'; - - const config = normalizeVitePPDevConfig({ templateName: 'test' }); - - expect(config.personalAccessToken).toBe('env-token'); + const n = normalizePPDevConfig({}, 'app'); + expect(n.personalAccessToken).toBe('env-token'); }); - it('should prefer explicit token over env variable', () => { + it('explicit token takes precedence over env', () => { process.env.MI_ACCESS_TOKEN = 'env-token'; - - const config = normalizeVitePPDevConfig({ - templateName: 'test', - personalAccessToken: 'explicit-token', - }); - - expect(config.personalAccessToken).toBe('explicit-token'); + const n = normalizePPDevConfig({ mi: { token: 'explicit' } }, 'app'); + expect(n.personalAccessToken).toBe('explicit'); }); }); - describe('Validation Errors', () => { - it('should throw error for invalid backendBaseURL (empty string)', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - backendBaseURL: '', - }), - ).toThrow('backendBaseURL must be a non-empty string if provided'); - }); - - it('should throw error for invalid portalPageId (negative)', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - portalPageId: -1, - }), - ).toThrow('portalPageId must be a positive number if provided'); - }); - - it('should throw error for invalid appId (zero)', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - appId: 0, - }), - ).toThrow('appId must be a positive number if provided'); - }); - - it('should throw error for invalid proxyCacheTTL (negative)', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - proxyCacheTTL: -100, - }), - ).toThrow('proxyCacheTTL must be a positive number if provided'); - }); - - it('should throw error when integrateMiTopBar is true but miHudLess is false', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: true, - miHudLess: false, - }), - ).toThrow( - 'VitePPDevOptions.integrateMiTopBar must be a boolean or an object with addRootElement and addSharedComponentsScripts booleans', - ); + describe('build.zip normalization', () => { + it('zip=true → distZip object with defaults', () => { + const n = normalizePPDevConfig({ build: { zip: true } }, 'my-app'); + expect(n.distZip).toEqual({ outFileName: 'my-app.zip', outDir: 'dist-zip' }); }); - it('should throw error when integrateMiTopBar object has addRootElement true but miHudLess is false', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: { addRootElement: true }, - miHudLess: false, - }), - ).toThrow( - 'VitePPDevOptions.integrateMiTopBar must be a boolean or an object with addRootElement and addSharedComponentsScripts booleans', - ); + it('zip=false → distZip=false', () => { + const n = normalizePPDevConfig({ build: { zip: false } }, 'my-app'); + expect(n.distZip).toBe(false); }); - it('should throw error when integrateMiTopBar object has addSharedComponentsScripts true but miHudLess is false', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: { addSharedComponentsScripts: true }, - miHudLess: false, - }), - ).toThrow( - 'VitePPDevOptions.integrateMiTopBar must be a boolean or an object with addRootElement and addSharedComponentsScripts booleans', - ); + it('zip omitted → distZip object with defaults', () => { + const n = normalizePPDevConfig({}, 'my-app'); + expect(n.distZip).toEqual({ outFileName: 'my-app.zip', outDir: 'dist-zip' }); }); - it('should throw error when integrateMiTopBar object has both properties true but miHudLess is false', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: { - addRootElement: true, - addSharedComponentsScripts: true, - }, - miHudLess: false, - }), - ).toThrow( - 'VitePPDevOptions.integrateMiTopBar must be a boolean or an object with addRootElement and addSharedComponentsScripts booleans', - ); + it('zip object with custom fileName', () => { + const n = normalizePPDevConfig({ build: { zip: { fileName: 'custom.zip', outDir: 'release' } } }, 'my-app'); + expect(n.distZip).toEqual({ outFileName: 'custom.zip', outDir: 'release' }); }); - it('should throw error when integrateMiTopBar is invalid type (string)', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: 'invalid' as any, - }), - ).toThrow( - 'VitePPDevOptions.integrateMiTopBar must be a boolean or an object with addRootElement and addSharedComponentsScripts booleans', - ); + it('zip object uses app.name in default zip filename', () => { + const n = normalizePPDevConfig({ app: { name: 'custom-name' }, build: { zip: true } }, 'fallback'); + expect((n.distZip as any).outFileName).toBe('custom-name.zip'); }); + }); - it('should throw error when integrateMiTopBar is invalid type (number)', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: 123 as any, - }), - ).toThrow( - 'VitePPDevOptions.integrateMiTopBar must be a boolean or an object with addRootElement and addSharedComponentsScripts booleans', - ); + describe('build.versionFile normalization', () => { + it('versionFile=false → versionPlugin=false', () => { + const n = normalizePPDevConfig({ build: { versionFile: false } }, 'app'); + expect(n.versionPlugin).toBe(false); }); - it('should throw error when integrateMiTopBar object has invalid addRootElement type', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: { addRootElement: 'invalid' as any }, - }), - ).toThrow( - 'VitePPDevOptions.integrateMiTopBar must be a boolean or an object with addRootElement and addSharedComponentsScripts booleans', - ); + it('versionFile=true → versionPlugin object with defaults', () => { + const n = normalizePPDevConfig({ build: { versionFile: true } }, 'app'); + expect(n.versionPlugin).toMatchObject({ enabled: true }); }); - it('should throw error when integrateMiTopBar object has invalid addSharedComponentsScripts type', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: { addSharedComponentsScripts: 123 as any }, - }), - ).toThrow( - 'VitePPDevOptions.integrateMiTopBar must be a boolean or an object with addRootElement and addSharedComponentsScripts booleans', + it('versionFile object passes through options', () => { + const n = normalizePPDevConfig( + { build: { versionFile: { fileNameTemplate: 'CUSTOM.json', enabled: false } } }, + 'app', ); + expect(n.versionPlugin).toEqual({ versionFileTemplate: 'CUSTOM.json', enabled: false }); }); + }); - it('should throw error when integrateMiTopBar is null', () => { - expect(() => - normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: null as any, - }), - ).toThrow( - 'VitePPDevOptions.integrateMiTopBar must be a boolean or an object with addRootElement and addSharedComponentsScripts booleans', - ); + describe('build.imageOptimisations normalization', () => { + it('imageOptimisations=false → imageOptimizer=false', () => { + const n = normalizePPDevConfig({ build: { imageOptimisations: false } }, 'app'); + expect(n.imageOptimizer).toBe(false); }); - it('should allow integrateMiTopBar: true when miHudLess is true', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: true, - miHudLess: true, - }); - - expect(config.integrateMiTopBar).toBe(true); - expect(config.miHudLess).toBe(true); + it('imageOptimisations=true → imageOptimizer={}', () => { + const n = normalizePPDevConfig({ build: { imageOptimisations: true } }, 'app'); + expect(n.imageOptimizer).toEqual({}); }); - it('should allow integrateMiTopBar: false when miHudLess is false', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: false, - miHudLess: false, - }); - - expect(config.integrateMiTopBar).toBe(false); - expect(config.miHudLess).toBe(false); + it('imageOptimisations object is passed through', () => { + const opts = { png: { quality: 80 } }; + const n = normalizePPDevConfig({ build: { imageOptimisations: opts } }, 'app'); + expect(n.imageOptimizer).toEqual(opts); }); + }); - it('should allow integrateMiTopBar object with addRootElement true when miHudLess is true', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: { addRootElement: true }, - miHudLess: true, - }); + describe('Complete configuration', () => { + it('normalizes a full config correctly', () => { + const config: PPDevConfig = { + mi: { url: 'https://mi.example.com', mode: 'standalone', include: 'top-bar', apiVersion: 7, token: 'tok' }, + app: { id: 42, type: 'template', name: 'my-template' }, + proxy: { cache: false, cacheTtl: 120_000, tls: { allowSelfSigned: true } }, + build: { outDir: 'build', zip: false, versionFile: false, imageOptimisations: false }, + sync: { backupsDir: 'archives' }, + }; - expect(config.integrateMiTopBar).toEqual({ addRootElement: true }); - expect(config.miHudLess).toBe(true); - }); + const n = normalizePPDevConfig(config, 'fallback'); - it('should allow integrateMiTopBar object with addSharedComponentsScripts true when miHudLess is true', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: { addSharedComponentsScripts: true }, + expect(n).toMatchObject({ + templateName: 'my-template', + backendBaseURL: 'https://mi.example.com', + appId: 42, + templateLess: false, miHudLess: true, + integrateMiTopBar: true, + enableProxyCache: false, + proxyCacheTTL: 120_000, + disableSSLValidation: true, + imageOptimizer: false, + outDir: 'build', + distZip: false, + versionPlugin: false, + syncBackupsDir: 'archives', + v7Features: true, + personalAccessToken: 'tok', }); - - expect(config.integrateMiTopBar).toEqual({ - addSharedComponentsScripts: true, - }); - expect(config.miHudLess).toBe(true); }); + }); +}); - it('should allow integrateMiTopBar object with both properties true when miHudLess is true', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: { - addRootElement: true, - addSharedComponentsScripts: true, - }, - miHudLess: true, - }); - - expect(config.integrateMiTopBar).toEqual({ - addRootElement: true, - addSharedComponentsScripts: true, - }); - expect(config.miHudLess).toBe(true); - }); +describe('validatePPDevConfig', () => { + const originalEnv = process.env; - it('should allow integrateMiTopBar object with both properties false when miHudLess is false', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: { - addRootElement: false, - addSharedComponentsScripts: false, - }, - miHudLess: false, - }); + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.MI_BACKEND_URL; + delete process.env.MI_ACCESS_TOKEN; + }); - expect(config.integrateMiTopBar).toEqual({ - addRootElement: false, - addSharedComponentsScripts: false, - }); - expect(config.miHudLess).toBe(false); - }); + afterEach(() => { + process.env = originalEnv; + }); - it('should allow integrateMiTopBar empty object when miHudLess is false', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: {}, - miHudLess: false, - }); + it('throws if templateName is empty', () => { + expect(() => validatePPDevConfig({}, '')).toThrow('app.name is required'); + }); - expect(config.integrateMiTopBar).toEqual({}); - expect(config.miHudLess).toBe(false); - }); + it('throws if mi.include is set with embedding mode', () => { + expect(() => + validatePPDevConfig({ mi: { mode: 'embedding', include: 'top-bar' } }, 'app'), + ).toThrow('mi.include requires mi.mode to be "standalone"'); + }); - it('should allow integrateMiTopBar object with addRootElement false when miHudLess is false', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: { addRootElement: false }, - miHudLess: false, - }); + it('throws if mi.url missing + mode=embedding', () => { + expect(() => validatePPDevConfig({ mi: { mode: 'embedding' } }, 'app')).toThrow('mi.url is required'); + }); - expect(config.integrateMiTopBar).toEqual({ addRootElement: false }); - expect(config.miHudLess).toBe(false); - }); + it('throws if mi.url missing + app.type=template', () => { + expect(() => validatePPDevConfig({ app: { type: 'template', id: 1 } }, 'app')).toThrow('mi.url is required'); + }); - it('should allow integrateMiTopBar object with addSharedComponentsScripts false when miHudLess is false', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'test', - integrateMiTopBar: { addSharedComponentsScripts: false }, - miHudLess: false, - }); + it('warns (does not throw) if mi.url missing + standalone + page', () => { + expect(() => validatePPDevConfig({ mi: { mode: 'standalone' }, app: { type: 'page', id: 1 } }, 'app')).not.toThrow(); + }); - expect(config.integrateMiTopBar).toEqual({ - addSharedComponentsScripts: false, - }); - expect(config.miHudLess).toBe(false); - }); + it('throws if app.type=template without app.id', () => { + expect(() => + validatePPDevConfig({ mi: { url: 'https://mi.example.com' }, app: { type: 'template' } }, 'app'), + ).toThrow('app.id is required when app.type is "template"'); }); - describe('Complete Configuration', () => { - it('should normalize a complete configuration correctly', () => { - const config = normalizeVitePPDevConfig({ - templateName: 'my-app', - backendBaseURL: 'https://api.example.com', - appId: 123, - templateLess: true, - miHudLess: true, - integrateMiTopBar: true, - enableProxyCache: false, - proxyCacheTTL: 300000, - disableSSLValidation: true, - v7Features: true, - outDir: 'build', - distZip: { - outFileName: '[templateName]-v1.zip', - outDir: 'releases', - }, - syncBackupsDir: 'archives', - }); + it('throws if app.type=page + standalone without app.id', () => { + expect(() => + validatePPDevConfig( + { mi: { url: 'https://mi.example.com', mode: 'standalone' }, app: { type: 'page' } }, + 'app', + ), + ).toThrow('app.id is required when app.type is "page" and mi.mode is "standalone"'); + }); - // Note: Due to ...config spread at end, some normalized values are overwritten - expect(config).toMatchObject({ - templateName: 'my-app', - backendBaseURL: 'https://api.example.com', - portalPageId: 123, - templateLess: true, - miHudLess: true, - integrateMiTopBar: true, - enableProxyCache: false, - proxyCacheTTL: 300000, - disableSSLValidation: true, - v7Features: true, - outDir: 'build', - // distZip is not normalized due to spread behavior - distZip: { - outFileName: '[templateName]-v1.zip', - outDir: 'releases', - }, - syncBackupsDir: 'archives', - }); - }); + it('passes valid standalone template config', () => { + expect(() => + validatePPDevConfig( + { mi: { url: 'https://mi.example.com', mode: 'standalone' }, app: { type: 'template', id: 1 } }, + 'app', + ), + ).not.toThrow(); }); }); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..4a11ab7 --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/e2e/**/*.spec.ts'], + pool: 'forks', + singleFork: true, + testTimeout: 60_000, + hookTimeout: 90_000, + reporter: ['verbose'], + }, +});