diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..292e1fc --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,54 @@ +name: E2E Tests + +on: + push: + branches: [main, 'feat/**'] + pull_request: + branches: [main] + +jobs: + e2e: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + + - name: Install fixture app dependencies + run: npm run e2e:fixture:install + + - name: Build extension + run: npm run build + + - name: Run E2E tests + run: xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' npx playwright test --config=e2e/playwright.config.ts + env: + CI: true + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Upload test traces + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-traces + path: test-results/ + retention-days: 7 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..8e175ba --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,35 @@ +name: Unit Tests + +on: + push: + branches: [main, 'feat/**'] + pull_request: + branches: [main] + +jobs: + unit: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm test + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index a544c84..b06f39a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules .DS_STORE /coverage dist/* +e2e/fixtures/aurelia-app/dist +/playwright-report +/test-results diff --git a/README.md b/README.md index bb7c854..e151d4c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This project is a work in progress. The current version is not yet available on ---- -A browser extension for debugging Aurelia 1 and 2 applications. Features a top-level DevTools tab with modern, professional interface and dual-tab architecture for comprehensive debugging. +A browser extension for debugging Aurelia 1 and 2 applications. Integrates as a sidebar pane in Chrome's Elements panel for seamless component inspection. ## Features @@ -71,7 +71,7 @@ Install the latest Node.js and npm versions. 1. Run `npm run start` to start development mode 2. Load the extension in Chrome (see Installation > Manual Installation) 3. Pin the Aurelia Extension in the toolbar to verify detection: "Aurelia 2 detected on this page." -4. Open Developer Tools and navigate to the "⚡ Aurelia" tab +4. Open Developer Tools, go to the Elements panel, and find the "Aurelia" sidebar pane 5. For code changes: - Reload the extension in `chrome://extensions` - Close and reopen Developer Tools (or Ctrl+R in the DevTools inspect window) @@ -166,29 +166,31 @@ This event-first approach keeps the surface area tiny (just listen and dispatch) ## Architecture ### Core Components -- **Main Application** (`src/main.ts`, `src/app.ts`) - Aurelia 2 app rendering the DevTools UI +- **Sidebar Application** (`src/sidebar/`) - Aurelia 2 app rendering the Elements panel sidebar - **Extension Scripts**: - `detector.ts` - Detects Aurelia versions on web pages - `background.ts` - Service worker managing extension state - `contentscript.ts` - Finds Aurelia instances in DOM - - `devtools.js` - Creates the DevTools panel + - `devtools.js` - Creates the Elements sidebar pane ### Build System -- **Vite** - Modern build tool replacing Webpack +- **Vite** - Modern build tool - **TypeScript** - Type safety and modern JavaScript features -- **Aurelia 2** - Framework for the DevTools UI itself +- **Aurelia 2** - Framework for the sidebar UI itself ### File Structure ``` src/ -├── main.ts # Entry point -├── app.ts, app.html # Main Aurelia app -├── backend/ # Debug host and communication +├── sidebar/ # Sidebar application +│ ├── main.ts # Entry point +│ ├── sidebar-app.ts # Main ViewModel +│ ├── sidebar-app.html # Template +│ ├── sidebar-app.css # Styles +│ └── sidebar-debug-host.ts # Communication layer ├── background/ # Service worker ├── contentscript/ # Page content interaction ├── detector/ # Aurelia version detection -├── devtools/ # DevTools panel creation -├── resources/elements/ # UI components +├── devtools/ # Sidebar pane creation ├── shared/ # Common types and utilities └── popups/ # Extension popup pages ``` diff --git a/docs/deployment.md b/docs/deployment.md index 4625231..e9d0e25 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -190,14 +190,15 @@ All deployments must pass: ```bash # Required files in dist/: ├── manifest.json # Extension manifest -├── index.html # DevTools panel entry +├── sidebar.html # Sidebar pane entry ├── build/ -│ ├── entry.js # Main application -│ ├── background.js # Service worker -│ ├── contentscript.js # Content script -│ └── detector.js # Aurelia detector -├── images/ # Extension icons -└── popups/ # Extension popups +│ ├── sidebar.js # Sidebar application +│ ├── background.js # Service worker +│ ├── contentscript.js # Content script +│ └── detector.js # Aurelia detector +├── devtools/ # DevTools page +├── images/ # Extension icons +└── popups/ # Extension popups ``` ## Chrome Web Store Review Process diff --git a/e2e/fixtures/aurelia-app/index.html b/e2e/fixtures/aurelia-app/index.html new file mode 100644 index 0000000..a97152c --- /dev/null +++ b/e2e/fixtures/aurelia-app/index.html @@ -0,0 +1,11 @@ + + + + + Aurelia 2 E2E Fixture + + + + + + diff --git a/e2e/fixtures/aurelia-app/package-lock.json b/e2e/fixtures/aurelia-app/package-lock.json new file mode 100644 index 0000000..73098ae --- /dev/null +++ b/e2e/fixtures/aurelia-app/package-lock.json @@ -0,0 +1,1913 @@ +{ + "name": "aurelia-e2e-fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aurelia-e2e-fixture", + "version": "1.0.0", + "dependencies": { + "aurelia": "^2.0.0-beta.25" + }, + "devDependencies": { + "@aurelia/vite-plugin": "^2.0.0-beta.25", + "typescript": "^5.7.2", + "vite": "^5.4.10" + } + }, + "node_modules/@aurelia/expression-parser": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/@aurelia/expression-parser/-/expression-parser-2.0.0-beta.25.tgz", + "integrity": "sha512-aIbXxC+jBjeoNX/Pshrj/RYBFDx03YK5MEBG/DAYZUy9GKFguAMNpWGGj4hbtDncZetKtuDAK8sOPMffaF4Tyg==", + "license": "MIT", + "dependencies": { + "@aurelia/kernel": "2.0.0-beta.25" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/@aurelia/fetch-client": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/@aurelia/fetch-client/-/fetch-client-2.0.0-beta.25.tgz", + "integrity": "sha512-3bynSrG0gSmUbxaFJokJfEtwJ85bRBsg8RHnczmv3R5xLIMOUcMYpJVOangMM1ek4qEwlzi0Q1srpT2hrxPKlA==", + "license": "MIT", + "dependencies": { + "@aurelia/kernel": "2.0.0-beta.25", + "@aurelia/metadata": "2.0.0-beta.25" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/@aurelia/kernel": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/@aurelia/kernel/-/kernel-2.0.0-beta.25.tgz", + "integrity": "sha512-U8I/FH4RJOkXe2igTy4G6U34aSdDLa43f4C+z4IU4jvgVpJTyu9+B92n/tUI7TtE+lOly8sbi7GQbgaPP9lntw==", + "license": "MIT", + "dependencies": { + "@aurelia/metadata": "2.0.0-beta.25", + "@aurelia/platform": "2.0.0-beta.25" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/@aurelia/metadata": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/@aurelia/metadata/-/metadata-2.0.0-beta.25.tgz", + "integrity": "sha512-N/TihhPtbM/2FbcOGkCuCOpTjRyQTz7Z3GyNkJGxiIgTdMnzrpslwoC3GT/Qeh3lP28zR96jfAbAOlSyy8wY6g==", + "license": "MIT", + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/@aurelia/platform": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/@aurelia/platform/-/platform-2.0.0-beta.25.tgz", + "integrity": "sha512-dE8Uk9oX5rjhhFgwEqV/NTUOrVrWaYHIv4MQFxiiDqEpTLyMLQDykVk+Q0sxqeIIw8xWw0XNQUs9NNExEcntjw==", + "license": "MIT", + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/@aurelia/platform-browser": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/@aurelia/platform-browser/-/platform-browser-2.0.0-beta.25.tgz", + "integrity": "sha512-V9s7/kHB5yLI3P8a5la/0Y/p2h938HAHQ28eJTYwaSLC88y0ZktmG5MUS/uzORCjpANFxK1JrKfpI+v3kGfdZQ==", + "license": "MIT", + "dependencies": { + "@aurelia/platform": "2.0.0-beta.25" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/@aurelia/plugin-conventions": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/@aurelia/plugin-conventions/-/plugin-conventions-2.0.0-beta.25.tgz", + "integrity": "sha512-egYB1CykdqCJQj/BHj8agF1r5sB2k8vkjq53I7wEaapSEg7z8lTR0+pU6d9GHy3lYkl+6p++3UIb+zVlPwIK3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@aurelia/expression-parser": "2.0.0-beta.25", + "@aurelia/kernel": "2.0.0-beta.25", + "@aurelia/metadata": "2.0.0-beta.25", + "@aurelia/platform": "2.0.0-beta.25", + "@aurelia/runtime": "2.0.0-beta.25", + "@aurelia/runtime-html": "2.0.0-beta.25", + "@aurelia/template-compiler": "2.0.0-beta.25", + "modify-code": "^2.1.3", + "parse5": "^7.1.2", + "typescript": "^5.4.2" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/@aurelia/runtime": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/@aurelia/runtime/-/runtime-2.0.0-beta.25.tgz", + "integrity": "sha512-geRbXQYBt/IBgVJgvfmbvw9+vOHbtiP8hlnN58jz9/U5nFX3G+UHukOOEraLUk4Jt7OA90bOTfLDIw6JtPLkzQ==", + "license": "MIT", + "dependencies": { + "@aurelia/expression-parser": "2.0.0-beta.25", + "@aurelia/kernel": "2.0.0-beta.25", + "@aurelia/metadata": "2.0.0-beta.25", + "@aurelia/platform": "2.0.0-beta.25" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/@aurelia/runtime-html": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/@aurelia/runtime-html/-/runtime-html-2.0.0-beta.25.tgz", + "integrity": "sha512-hKIGPCLd+o8osMq2lfD1xOu6qxdfjVM3D6nAu7oWLvMCPVUY0cNTv4fPSDXO9ZsSUWcsSyqeHgchpqNP9/g3Tg==", + "license": "MIT", + "dependencies": { + "@aurelia/expression-parser": "2.0.0-beta.25", + "@aurelia/kernel": "2.0.0-beta.25", + "@aurelia/metadata": "2.0.0-beta.25", + "@aurelia/platform": "2.0.0-beta.25", + "@aurelia/platform-browser": "2.0.0-beta.25", + "@aurelia/runtime": "2.0.0-beta.25", + "@aurelia/template-compiler": "2.0.0-beta.25" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/@aurelia/template-compiler": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/@aurelia/template-compiler/-/template-compiler-2.0.0-beta.25.tgz", + "integrity": "sha512-IfgKKtduYt3VbGRDdzvKdA3RqHWQ7l3y0kp0IpS0OQGL0PYFD3xN/mCiHtjaonFs+siJf9C+LvoGhFkfGA6vPg==", + "license": "MIT", + "dependencies": { + "@aurelia/expression-parser": "2.0.0-beta.25", + "@aurelia/kernel": "2.0.0-beta.25", + "@aurelia/metadata": "2.0.0-beta.25" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/@aurelia/vite-plugin": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/@aurelia/vite-plugin/-/vite-plugin-2.0.0-beta.25.tgz", + "integrity": "sha512-K3hIg02Q/mLw7zEl8dX1pZCDQtsnOGh4i+imuLnUcASDt4GRCdwFRiako4dyQRm57hD2/Fj1WrdiJCFuP/pVkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@aurelia/kernel": "2.0.0-beta.25", + "@aurelia/metadata": "2.0.0-beta.25", + "@aurelia/platform": "2.0.0-beta.25", + "@aurelia/plugin-conventions": "2.0.0-beta.25", + "@aurelia/runtime": "2.0.0-beta.25", + "@rollup/pluginutils": "5.0.2", + "loader-utils": "^2.0.0", + "vite": "^7.0.2" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@aurelia/vite-plugin/node_modules/vite": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", + "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", + "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/aurelia": { + "version": "2.0.0-beta.25", + "resolved": "https://registry.npmjs.org/aurelia/-/aurelia-2.0.0-beta.25.tgz", + "integrity": "sha512-97kZ8QaZB65mo5RrQk6Ln9DUoXEfpoBeO8lDaLEk4Bl4NlEBJAY/sHjDRQm83+S7aCAuEzc9NGb2+b0ZNhGlJQ==", + "license": "MIT", + "dependencies": { + "@aurelia/expression-parser": "2.0.0-beta.25", + "@aurelia/fetch-client": "2.0.0-beta.25", + "@aurelia/kernel": "2.0.0-beta.25", + "@aurelia/metadata": "2.0.0-beta.25", + "@aurelia/platform": "2.0.0-beta.25", + "@aurelia/platform-browser": "2.0.0-beta.25", + "@aurelia/runtime": "2.0.0-beta.25", + "@aurelia/runtime-html": "2.0.0-beta.25", + "@aurelia/template-compiler": "2.0.0-beta.25" + }, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/modify-code": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/modify-code/-/modify-code-2.1.4.tgz", + "integrity": "sha512-/MNa/Wpkki5AHuaZc+L43Di/CMKwcgb740sR5yXNt/f7vwYGPt7g4G5OBPtviW/3sgauuPZZDmY+p5DJ6raHWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "^0.7.4" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/e2e/fixtures/aurelia-app/package.json b/e2e/fixtures/aurelia-app/package.json new file mode 100644 index 0000000..62b26f0 --- /dev/null +++ b/e2e/fixtures/aurelia-app/package.json @@ -0,0 +1,19 @@ +{ + "name": "aurelia-e2e-fixture", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "aurelia": "^2.0.0-beta.25" + }, + "devDependencies": { + "@aurelia/vite-plugin": "^2.0.0-beta.25", + "typescript": "^5.7.2", + "vite": "^5.4.10" + } +} diff --git a/e2e/fixtures/aurelia-app/src/app.html b/e2e/fixtures/aurelia-app/src/app.html new file mode 100644 index 0000000..3f01056 --- /dev/null +++ b/e2e/fixtures/aurelia-app/src/app.html @@ -0,0 +1,24 @@ +
+

${message}

+ +
+

Counter Components

+ + +
+ +
+

User Cards

+ +
+ +
+ + +
+
diff --git a/e2e/fixtures/aurelia-app/src/app.ts b/e2e/fixtures/aurelia-app/src/app.ts new file mode 100644 index 0000000..f619d01 --- /dev/null +++ b/e2e/fixtures/aurelia-app/src/app.ts @@ -0,0 +1,22 @@ +export class App { + message = 'Aurelia 2 E2E Test App'; + counter = 0; + + users = [ + { name: 'John Doe', email: 'john@example.com', role: 'admin' }, + { name: 'Jane Smith', email: 'jane@example.com', role: 'user' }, + ]; + + increment() { + this.counter++; + } + + addUser() { + const id = this.users.length + 1; + this.users.push({ + name: `User ${id}`, + email: `user${id}@example.com`, + role: 'user' + }); + } +} diff --git a/e2e/fixtures/aurelia-app/src/components/counter.html b/e2e/fixtures/aurelia-app/src/components/counter.html new file mode 100644 index 0000000..c8626e3 --- /dev/null +++ b/e2e/fixtures/aurelia-app/src/components/counter.html @@ -0,0 +1,6 @@ +
+ ${label}: + + ${value} + +
diff --git a/e2e/fixtures/aurelia-app/src/components/counter.ts b/e2e/fixtures/aurelia-app/src/components/counter.ts new file mode 100644 index 0000000..8a988d5 --- /dev/null +++ b/e2e/fixtures/aurelia-app/src/components/counter.ts @@ -0,0 +1,14 @@ +import { bindable } from 'aurelia'; + +export class Counter { + @bindable value = 0; + @bindable label = 'Count'; + + increment() { + this.value++; + } + + decrement() { + this.value--; + } +} diff --git a/e2e/fixtures/aurelia-app/src/components/user-card.html b/e2e/fixtures/aurelia-app/src/components/user-card.html new file mode 100644 index 0000000..32a3e30 --- /dev/null +++ b/e2e/fixtures/aurelia-app/src/components/user-card.html @@ -0,0 +1,8 @@ +
+
${initials}
+
+

${name}

+

${email}

+ ${role} +
+
diff --git a/e2e/fixtures/aurelia-app/src/components/user-card.ts b/e2e/fixtures/aurelia-app/src/components/user-card.ts new file mode 100644 index 0000000..ca5e7c6 --- /dev/null +++ b/e2e/fixtures/aurelia-app/src/components/user-card.ts @@ -0,0 +1,19 @@ +import { bindable } from 'aurelia'; + +export class UserCard { + @bindable name = ''; + @bindable email = ''; + @bindable role = 'user'; + + get initials() { + return this.name + .split(' ') + .map(n => n[0]) + .join('') + .toUpperCase(); + } + + get isAdmin() { + return this.role === 'admin'; + } +} diff --git a/e2e/fixtures/aurelia-app/src/main.ts b/e2e/fixtures/aurelia-app/src/main.ts new file mode 100644 index 0000000..b54fc8f --- /dev/null +++ b/e2e/fixtures/aurelia-app/src/main.ts @@ -0,0 +1,9 @@ +import Aurelia from 'aurelia'; +import { App } from './app'; +import { Counter } from './components/counter'; +import { UserCard } from './components/user-card'; + +new Aurelia() + .register(Counter, UserCard) + .app(App) + .start(); diff --git a/e2e/fixtures/aurelia-app/tsconfig.json b/e2e/fixtures/aurelia-app/tsconfig.json new file mode 100644 index 0000000..b9f6634 --- /dev/null +++ b/e2e/fixtures/aurelia-app/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/e2e/fixtures/aurelia-app/vite.config.ts b/e2e/fixtures/aurelia-app/vite.config.ts new file mode 100644 index 0000000..a821617 --- /dev/null +++ b/e2e/fixtures/aurelia-app/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import aurelia from '@aurelia/vite-plugin'; + +export default defineConfig({ + plugins: [aurelia()], + server: { + port: 5173, + strictPort: true, + }, +}); diff --git a/e2e/helpers/extension-fixture.ts b/e2e/helpers/extension-fixture.ts new file mode 100644 index 0000000..3731b99 --- /dev/null +++ b/e2e/helpers/extension-fixture.ts @@ -0,0 +1,46 @@ +import { test as base, chromium, BrowserContext } from '@playwright/test'; +import path from 'path'; + +export type ExtensionFixtures = { + context: BrowserContext; + extensionId: string; +}; + +export const test = base.extend({ + context: async ({}, use) => { + const extensionPath = path.join(__dirname, '..', '..', 'dist'); + + const args = [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + '--no-first-run', + '--disable-default-apps', + '--disable-gpu', + '--disable-dev-shm-usage', + ]; + + if (process.env.CI) { + args.push('--headless=new'); + } + + const context = await chromium.launchPersistentContext('', { + headless: false, + args, + }); + + await use(context); + await context.close(); + }, + + extensionId: async ({ context }, use) => { + let serviceWorker = context.serviceWorkers()[0]; + if (!serviceWorker) { + serviceWorker = await context.waitForEvent('serviceworker', { timeout: 30000 }); + } + + const extensionId = serviceWorker.url().split('/')[2]; + await use(extensionId); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/helpers/extension-utils.ts b/e2e/helpers/extension-utils.ts new file mode 100644 index 0000000..fbc689b --- /dev/null +++ b/e2e/helpers/extension-utils.ts @@ -0,0 +1,39 @@ +import { BrowserContext, Page } from '@playwright/test'; + +export async function getExtensionPopup( + context: BrowserContext, + extensionId: string, + popupPath: string +): Promise { + const popupUrl = `chrome-extension://${extensionId}/${popupPath}`; + const page = await context.newPage(); + await page.goto(popupUrl); + return page; +} + +export async function waitForAureliaDetection( + page: Page, + expectedVersion: number = 2, + timeout: number = 10000 +): Promise { + await page.waitForFunction( + (version) => (window as any).__AURELIA_DEVTOOLS_DETECTED_VERSION__ === version, + expectedVersion, + { timeout } + ); +} + +export async function getDetectionState(page: Page): Promise<{ + version: number | null; + state: string | null; +}> { + return page.evaluate(() => ({ + version: (window as any).__AURELIA_DEVTOOLS_DETECTED_VERSION__ ?? null, + state: (window as any).__AURELIA_DEVTOOLS_DETECTION_STATE__ ?? null, + })); +} + +export async function waitForPageLoad(page: Page): Promise { + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle'); +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..d460d4d --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from '@playwright/test'; +import path from 'path'; + +const fixtureAppPath = path.join(__dirname, 'fixtures', 'aurelia-app'); + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI ? 'github' : 'html', + timeout: 30000, + + use: { + baseURL: 'http://localhost:4173', + trace: 'on-first-retry', + }, + + webServer: { + command: 'npm run build && npm run preview', + cwd: fixtureAppPath, + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, + + projects: [ + { + name: 'chromium-extension', + use: { + browserName: 'chromium', + }, + }, + ], +}); diff --git a/e2e/tests/background.spec.ts b/e2e/tests/background.spec.ts new file mode 100644 index 0000000..54a92e7 --- /dev/null +++ b/e2e/tests/background.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '../helpers/extension-fixture'; +import { waitForPageLoad } from '../helpers/extension-utils'; + +test.describe('Background Script', () => { + test('service worker is running', async ({ context, extensionId }) => { + const serviceWorker = context.serviceWorkers()[0]; + expect(serviceWorker).toBeTruthy(); + expect(serviceWorker.url()).toContain(extensionId); + expect(serviceWorker.url()).toContain('background.js'); + }); + + test('extension has correct manifest permissions', async ({ context, extensionId }) => { + const manifestPage = await context.newPage(); + await manifestPage.goto(`chrome-extension://${extensionId}/manifest.json`); + + const manifestText = await manifestPage.locator('body').textContent(); + const manifest = JSON.parse(manifestText || '{}'); + + expect(manifest.manifest_version).toBe(3); + expect(manifest.permissions).toContain('activeTab'); + expect(manifest.content_scripts).toHaveLength(2); + + await manifestPage.close(); + }); + + test('extension icon files exist', async ({ context, extensionId }) => { + const iconPage = await context.newPage(); + + const response = await iconPage.goto(`chrome-extension://${extensionId}/images/16.png`); + expect(response?.status()).toBe(200); + + const greyResponse = await iconPage.goto(`chrome-extension://${extensionId}/images/16-GREY.png`); + expect(greyResponse?.status()).toBe(200); + + await iconPage.close(); + }); + + test('content scripts are defined in manifest', async ({ context, extensionId }) => { + const manifestPage = await context.newPage(); + await manifestPage.goto(`chrome-extension://${extensionId}/manifest.json`); + + const manifestText = await manifestPage.locator('body').textContent(); + const manifest = JSON.parse(manifestText || '{}'); + + const contentScripts = manifest.content_scripts; + expect(contentScripts).toBeDefined(); + + const scriptPaths = contentScripts.flatMap((cs: any) => cs.js); + expect(scriptPaths).toContain('build/detector.js'); + expect(scriptPaths).toContain('build/contentscript.js'); + + await manifestPage.close(); + }); +}); diff --git a/e2e/tests/components.spec.ts b/e2e/tests/components.spec.ts new file mode 100644 index 0000000..51f68f0 --- /dev/null +++ b/e2e/tests/components.spec.ts @@ -0,0 +1,153 @@ +import { test, expect } from '../helpers/extension-fixture'; +import { waitForPageLoad } from '../helpers/extension-utils'; + +test.describe('Aurelia Components', () => { + test('renders multiple custom elements', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('/'); + await waitForPageLoad(page); + + const counters = await page.locator('counter').count(); + expect(counters).toBe(2); + + const userCards = await page.locator('user-card').count(); + expect(userCards).toBe(2); + + await page.close(); + }); + + test('counter component has bindable properties', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('/'); + await waitForPageLoad(page); + + const mainCounter = page.locator('counter').first(); + const label = await mainCounter.locator('.counter-label').textContent(); + expect(label).toContain('Main Counter'); + + await page.close(); + }); + + test('counter increment button works', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('/'); + await waitForPageLoad(page); + + const counter = page.locator('counter').first(); + const valueBefore = await counter.locator('.counter-value').textContent(); + + await counter.locator('.increment').click(); + + const valueAfter = await counter.locator('.counter-value').textContent(); + expect(parseInt(valueAfter || '0')).toBe(parseInt(valueBefore || '0') + 1); + + await page.close(); + }); + + test('user-card displays user information', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('/'); + await waitForPageLoad(page); + + const firstCard = page.locator('user-card').first(); + + const name = await firstCard.locator('.user-name').textContent(); + expect(name).toBe('John Doe'); + + const email = await firstCard.locator('.user-email').textContent(); + expect(email).toBe('john@example.com'); + + const role = await firstCard.locator('.user-role').textContent(); + expect(role).toBe('admin'); + + await page.close(); + }); + + test('user-card computed property shows initials', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('/'); + await waitForPageLoad(page); + + const firstCard = page.locator('user-card').first(); + const initials = await firstCard.locator('.user-avatar').textContent(); + expect(initials).toBe('JD'); + + await page.close(); + }); + + test('add user button creates new user-card', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('/'); + await waitForPageLoad(page); + + const initialCount = await page.locator('user-card').count(); + expect(initialCount).toBe(2); + + await page.locator('.add-user-btn').click(); + + await page.waitForTimeout(500); + const newCount = await page.locator('user-card').count(); + expect(newCount).toBe(3); + + await page.close(); + }); + + test('all components have $au property', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('/'); + await waitForPageLoad(page); + + const hasAuOnApp = await page.evaluate(() => { + const app = document.querySelector('app'); + return app && '$au' in app; + }); + expect(hasAuOnApp).toBe(true); + + const hasAuOnCounter = await page.evaluate(() => { + const counter = document.querySelector('counter'); + return counter && '$au' in counter; + }); + expect(hasAuOnCounter).toBe(true); + + const hasAuOnUserCard = await page.evaluate(() => { + const card = document.querySelector('user-card'); + return card && '$au' in card; + }); + expect(hasAuOnUserCard).toBe(true); + + await page.close(); + }); + + test('component controllers are accessible via $au', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('/'); + await waitForPageLoad(page); + + const controllerInfo = await page.evaluate(() => { + const counter = document.querySelector('counter') as any; + if (!counter || !counter.$au) return null; + + const controller = counter.$au['au:resource:custom-element']; + return { + hasController: !!controller, + hasViewModel: !!controller?.viewModel, + viewModelType: controller?.viewModel?.constructor?.name, + }; + }); + + expect(controllerInfo).toBeTruthy(); + expect(controllerInfo?.hasController).toBe(true); + expect(controllerInfo?.hasViewModel).toBe(true); + expect(controllerInfo?.viewModelType).toBe('Counter'); + + await page.close(); + }); +}); diff --git a/e2e/tests/content-script.spec.ts b/e2e/tests/content-script.spec.ts new file mode 100644 index 0000000..e3d557c --- /dev/null +++ b/e2e/tests/content-script.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '../helpers/extension-fixture'; +import { waitForPageLoad } from '../helpers/extension-utils'; + +test.describe('Content Script', () => { + test('Aurelia 2 app has $aurelia property on root element', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('/'); + await waitForPageLoad(page); + await page.waitForTimeout(2000); + + const hasAureliaProperty = await page.evaluate(() => { + const app = document.querySelector('app'); + return app && '$aurelia' in app; + }); + + expect(hasAureliaProperty).toBe(true); + + await page.close(); + }); + + test('extension service worker is active', async ({ context, extensionId }) => { + expect(extensionId).toBeTruthy(); + expect(extensionId.length).toBeGreaterThan(10); + }); +}); diff --git a/e2e/tests/detection.spec.ts b/e2e/tests/detection.spec.ts new file mode 100644 index 0000000..557e01a --- /dev/null +++ b/e2e/tests/detection.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '../helpers/extension-fixture'; +import { waitForAureliaDetection, getDetectionState, waitForPageLoad } from '../helpers/extension-utils'; + +test.describe('Aurelia Detection Flow', () => { + test('Aurelia fixture app renders correctly', async ({ context }) => { + const page = await context.newPage(); + + page.on('console', msg => console.log('PAGE LOG:', msg.text())); + page.on('pageerror', err => console.log('PAGE ERROR:', err.message)); + + await page.goto('/', { waitUntil: 'domcontentloaded' }); + + await page.waitForSelector('h1', { timeout: 15000 }); + const heading = await page.locator('h1').textContent(); + expect(heading).toBe('Aurelia 2 E2E Test App'); + + await page.close(); + }); + + test('Aurelia 2 app has $au property on custom element', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('/'); + await waitForPageLoad(page); + + await page.waitForTimeout(2000); + + const hasAuProperty = await page.evaluate(() => { + const app = document.querySelector('app'); + return app && '$au' in app; + }); + + expect(hasAuProperty).toBe(true); + + await page.close(); + }); + + test('Aurelia 2 bootstraps successfully', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('/'); + await waitForPageLoad(page); + + const isBootstrapped = await page.evaluate(() => { + const app = document.querySelector('app'); + return app && ('$au' in app || '$aurelia' in app); + }); + + expect(isBootstrapped).toBe(true); + + await page.close(); + }); + + test('does not detect Aurelia on non-Aurelia page', async ({ context }) => { + const page = await context.newPage(); + + await page.goto('about:blank'); + await page.waitForTimeout(1000); + + const state = await getDetectionState(page); + expect(state.version).toBeNull(); + + await page.close(); + }); +}); diff --git a/e2e/tests/popup.spec.ts b/e2e/tests/popup.spec.ts new file mode 100644 index 0000000..37d0a20 --- /dev/null +++ b/e2e/tests/popup.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '../helpers/extension-fixture'; +import { getExtensionPopup } from '../helpers/extension-utils'; + +test.describe('Extension Popup', () => { + test('shows "Aurelia not detected" popup by default', async ({ context, extensionId }) => { + const popup = await getExtensionPopup(context, extensionId, 'popups/missing.html'); + + const content = await popup.locator('h3').textContent(); + expect(content).toContain('Aurelia not detected'); + + await popup.close(); + }); + + test('enabled-v2 popup has correct content', async ({ context, extensionId }) => { + const popup = await getExtensionPopup(context, extensionId, 'popups/enabled-v2.html'); + + const heading = await popup.locator('h3').textContent(); + expect(heading).toContain('Aurelia 2 detected'); + + const instructions = await popup.locator('p').textContent(); + expect(instructions).toContain('DevTools'); + + await popup.close(); + }); +}); diff --git a/e2e/tests/sidebar.spec.ts b/e2e/tests/sidebar.spec.ts new file mode 100644 index 0000000..f71ab2f --- /dev/null +++ b/e2e/tests/sidebar.spec.ts @@ -0,0 +1,415 @@ +import { test, expect } from '../helpers/extension-fixture'; + +test.describe('Sidebar Panel', () => { + test('sidebar HTML loads correctly', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + const root = await page.locator('sidebar-app'); + await expect(root).toBeVisible(); + + await page.close(); + }); + + test('sidebar CSS is loaded', async ({ context, extensionId }) => { + const page = await context.newPage(); + + const cssResponse = await page.goto(`chrome-extension://${extensionId}/build/sidebar.css`); + expect(cssResponse?.status()).toBe(200); + + await page.close(); + }); + + test('sidebar JS is loaded', async ({ context, extensionId }) => { + const page = await context.newPage(); + + const jsResponse = await page.goto(`chrome-extension://${extensionId}/build/sidebar.js`); + expect(jsResponse?.status()).toBe(200); + + await page.close(); + }); + + test('sidebar shows detection state initially', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + await page.waitForTimeout(1000); + + const checkingState = await page.locator('text=Detecting Aurelia').count(); + const notFoundState = await page.locator('text=No Aurelia detected').count(); + const detectedContent = await page.locator('.sidebar-content').count(); + + expect(checkingState + notFoundState + detectedContent).toBeGreaterThan(0); + + await page.close(); + }); + + test('sidebar has toolbar when Aurelia detected', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + await page.evaluate(() => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.detectionState = 'detected'; + app.aureliaDetected = true; + } + }); + + await page.waitForTimeout(500); + + const toolbar = page.locator('.toolbar'); + await expect(toolbar).toBeVisible(); + + await page.close(); + }); + + test('sidebar has search input', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + await page.evaluate(() => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.detectionState = 'detected'; + app.aureliaDetected = true; + } + }); + + await page.waitForTimeout(500); + + const searchInput = page.locator('.search-input'); + await expect(searchInput).toBeVisible(); + + await page.close(); + }); + + test('sidebar shows empty state when no element selected', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + await page.evaluate(() => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.detectionState = 'detected'; + app.aureliaDetected = true; + app.selectedElement = null; + } + }); + + await page.waitForTimeout(500); + + const emptyState = page.locator('.empty-state'); + await expect(emptyState).toBeVisible(); + + await page.close(); + }); + + test('sidebar displays component info when element is selected', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + const mockComponent = { + name: 'my-component', + key: 'au:resource:custom-element:my-component', + bindables: [ + { name: 'value', value: 42, type: 'number' }, + { name: 'label', value: 'Test', type: 'string' }, + ], + properties: [ + { name: 'count', value: 10, type: 'number' }, + ], + }; + + await page.evaluate((component) => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.detectionState = 'detected'; + app.aureliaDetected = true; + app.selectedElement = component; + app.selectedNodeType = 'custom-element'; + } + }, mockComponent); + + await page.waitForTimeout(500); + + const componentName = page.locator('.component-name'); + await expect(componentName).toHaveText('my-component'); + + await page.close(); + }); + + test('sidebar sections can be expanded and collapsed', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + const mockComponent = { + name: 'test-component', + key: 'au:resource:custom-element:test-component', + bindables: [{ name: 'value', value: 1, type: 'number' }], + properties: [{ name: 'prop', value: 'test', type: 'string' }], + }; + + await page.evaluate((component) => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.detectionState = 'detected'; + app.aureliaDetected = true; + app.selectedElement = component; + app.selectedNodeType = 'custom-element'; + } + }, mockComponent); + + await page.waitForTimeout(500); + + const bindablesSection = page.locator('.section-header:has-text("Bindables")'); + await expect(bindablesSection).toBeVisible(); + + await bindablesSection.click(); + await page.waitForTimeout(200); + + await bindablesSection.click(); + await page.waitForTimeout(200); + + await page.close(); + }); + + test('sidebar displays property values correctly', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + const mockComponent = { + name: 'prop-test', + key: 'au:resource:custom-element:prop-test', + bindables: [ + { name: 'stringVal', value: 'hello', type: 'string' }, + { name: 'numberVal', value: 42, type: 'number' }, + { name: 'boolVal', value: true, type: 'boolean' }, + ], + properties: [], + }; + + await page.evaluate((component) => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.detectionState = 'detected'; + app.aureliaDetected = true; + app.selectedElement = component; + app.selectedNodeType = 'custom-element'; + app.expandedSections.bindables = true; + } + }, mockComponent); + + await page.waitForTimeout(500); + + const stringProperty = page.locator('.property-name:has-text("stringVal")'); + await expect(stringProperty).toBeVisible(); + + const numberProperty = page.locator('.property-name:has-text("numberVal")'); + await expect(numberProperty).toBeVisible(); + + const boolProperty = page.locator('.property-name:has-text("boolVal")'); + await expect(boolProperty).toBeVisible(); + + await page.close(); + }); + + test('sidebar shows binding context indicator for non-component elements', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + const mockComponent = { + name: 'parent-component', + key: 'au:resource:custom-element:parent-component', + bindables: [], + properties: [], + }; + + await page.evaluate((component) => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.detectionState = 'detected'; + app.aureliaDetected = true; + app.selectedElement = component; + app.selectedNodeType = 'custom-element'; + app.selectedElementTagName = 'div'; + app.isShowingBindingContext = true; + } + }, mockComponent); + + await page.waitForTimeout(500); + + const bindingContextLabel = page.locator('.binding-context-label'); + await expect(bindingContextLabel).toBeVisible(); + + const selectedElement = page.locator('.selected-element'); + await expect(selectedElement).toContainText('div'); + + await page.close(); + }); + + test('sidebar shows extension invalidated state', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + await page.evaluate(() => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.extensionInvalidated = true; + } + }); + + await page.waitForTimeout(500); + + const invalidatedMessage = page.locator('.state-message.error'); + await expect(invalidatedMessage).toBeVisible(); + + const reloadText = page.locator('text=reload DevTools'); + await expect(reloadText).toBeVisible(); + + await page.close(); + }); + + test('sidebar element picker button exists', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + await page.evaluate(() => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.detectionState = 'detected'; + app.aureliaDetected = true; + } + }); + + await page.waitForTimeout(500); + + const pickerButton = page.locator('.tool-btn').first(); + await expect(pickerButton).toBeVisible(); + + await page.close(); + }); + + test('sidebar follow selection button exists', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + await page.evaluate(() => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.detectionState = 'detected'; + app.aureliaDetected = true; + } + }); + + await page.waitForTimeout(500); + + const toolButtons = await page.locator('.tool-btn').count(); + expect(toolButtons).toBeGreaterThanOrEqual(2); + + await page.close(); + }); +}); + +test.describe('Sidebar Expression Evaluator', () => { + test('expression evaluator section exists', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + const mockComponent = { + name: 'eval-test', + key: 'au:resource:custom-element:eval-test', + bindables: [], + properties: [], + }; + + await page.evaluate((component) => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.detectionState = 'detected'; + app.aureliaDetected = true; + app.selectedElement = component; + app.selectedNodeType = 'custom-element'; + } + }, mockComponent); + + await page.waitForTimeout(500); + + const evaluateSection = page.locator('.section-header:has-text("Evaluate")'); + await expect(evaluateSection).toBeVisible(); + + await page.close(); + }); + + test('expression input and run button exist', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidebar.html`); + + await page.waitForSelector('sidebar-app', { timeout: 10000 }); + + const mockComponent = { + name: 'eval-test', + key: 'au:resource:custom-element:eval-test', + bindables: [], + properties: [], + }; + + await page.evaluate((component) => { + const appElement = document.querySelector('sidebar-app') as any; + const app = appElement?.$controller?.viewModel || appElement?.$au?.['au:resource:custom-element']?.viewModel; + if (app) { + app.detectionState = 'detected'; + app.aureliaDetected = true; + app.selectedElement = component; + app.selectedNodeType = 'custom-element'; + app.expandedSections.expression = true; + } + }, mockComponent); + + await page.waitForTimeout(500); + + const expressionInput = page.locator('.expression-input'); + await expect(expressionInput).toBeVisible(); + + const runButton = page.locator('.eval-btn'); + await expect(runButton).toBeVisible(); + + await page.close(); + }); +}); diff --git a/index.html b/index.html deleted file mode 100644 index c833c55..0000000 --- a/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - Aurelia Devtools - - - - - - - diff --git a/manifest.json b/manifest.json index 3c2810f..c7d09be 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Aurelia Inspector", - "version": "0.2", + "version": "1.0.0", "description": "Chrome DevTools extension for inspecting Aurelia v1 and v2 applications.", "author": "Aurelia Team", "devtools_page": "devtools/devtools.html", @@ -14,7 +14,8 @@ { "resources": [ "devtools/devtools.html", - "devtools-background.html" + "devtools-background.html", + "sidebar.html" ], "matches": [ "*://*/*" diff --git a/package-lock.json b/package-lock.json index 37d7703..8fd644d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aurelia/devtools", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aurelia/devtools", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@tailwindcss/vite": "^4.1.11", @@ -16,6 +16,7 @@ "devDependencies": { "@aurelia/testing": "^2.0.0-beta.25", "@aurelia/vite-plugin": "^2.0.0-beta.25", + "@playwright/test": "^1.57.0", "@tailwindcss/forms": "^0.5.10", "@types/chrome": "0.0.290", "@types/jest": "^29.5.12", @@ -2097,6 +2098,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", @@ -7257,6 +7274,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index 078fe5e..059342b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@aurelia/devtools", "description": "A browser extension for debugging Aurelia 1 and 2 applications.", - "version": "0.2.0", + "version": "1.0.0", "repository": { "type": "git", "url": "https://github.com/aurelia/devtools" @@ -15,6 +15,7 @@ "devDependencies": { "@aurelia/testing": "^2.0.0-beta.25", "@aurelia/vite-plugin": "^2.0.0-beta.25", + "@playwright/test": "^1.57.0", "@tailwindcss/forms": "^0.5.10", "@types/chrome": "0.0.290", "@types/jest": "^29.5.12", @@ -47,6 +48,11 @@ "analyze": "rimraf dist && vite-bundle-analyzer", "test": "NODE_OPTIONS=--no-warnings jest --config jest.config.mjs --passWithNoTests", "test:watch": "NODE_OPTIONS=--no-warnings jest --config jest.config.mjs --watch", - "test:ci": "NODE_OPTIONS=--no-warnings jest --config jest.config.mjs --runInBand" + "test:ci": "NODE_OPTIONS=--no-warnings jest --config jest.config.mjs --runInBand", + "test:e2e": "npm run build && npx playwright test --config=e2e/playwright.config.ts", + "test:e2e:headed": "npm run build && npx playwright test --config=e2e/playwright.config.ts --headed", + "test:e2e:debug": "npm run build && npx playwright test --config=e2e/playwright.config.ts --debug", + "test:e2e:ui": "npm run build && npx playwright test --config=e2e/playwright.config.ts --ui", + "e2e:fixture:install": "cd e2e/fixtures/aurelia-app && npm install" } } diff --git a/sidebar.html b/sidebar.html new file mode 100644 index 0000000..dc1d865 --- /dev/null +++ b/sidebar.html @@ -0,0 +1,12 @@ + + + + + Aurelia DevTools Sidebar + + + + + + + diff --git a/src/app.html b/src/app.html deleted file mode 100644 index 833c467..0000000 --- a/src/app.html +++ /dev/null @@ -1,1156 +0,0 @@ -
- -
-
-
🔌
-

Extension Disconnected

-

- The Aurelia DevTools extension was disabled or updated. - Please close and reopen DevTools to restore functionality. -

-
-
- - - -
- -
-
- -
-
-
- - - - - - - - - - - -
- - -
- - ${visibleComponentNodes.length}${searchQuery ? ' of ' + - totalComponentNodeCount : ''} ${activeTab === 'all' ? 'component' : activeTab.slice(0, -1)}${visibleComponentNodes.length - !== 1 ? 's' : ''} - -
-
- - - - - - -
-
- -
-
-
🔍
-

Looking for Aurelia...

-

- Scanning the page for Aurelia v1 or v2 applications. -

-
- -
-
-

No Aurelia application detected

-

- This page does not appear to have an Aurelia v1 or v2 application running. - The Aurelia DevTools require an active Aurelia application to function. -

- -
- -
-
🛑
-

Aurelia DevTools disabled

-

- This application has opted out of Aurelia DevTools inspections. - Remove the opt-out flag on the page to re-enable the inspector. -

-
- -
-
🌲
-

No Aurelia components found

-

- Make sure the page has Aurelia components and they are properly - registered. -

- -
- -
-
🔍
-

No matching components

-

- Try a different search term or clear the search to see all - components. -

- -
- -
-
-
- - - - - - - - - <${item.node.name}> - @${item.node.name} - - .${item.node.matchedProperty} - ${item.node.data.info.customAttributesInfo.length} -
-
-
-
-
- - -
- - -
-
-
-
- - - -
-

Select a component

-

- Choose a component from the tree to view its properties and - details. -

-
-
- -
- -
-
- - - - - - - - - <${selectedElement ? selectedElement.name : 'component'}> - - - @${selectedElement ? selectedElement.name : 'attribute'} - - -
-
- -
-
- -
- -
-
- Also known as: - ${selectedElement.aliases.join(', ')} -
-
- - -
-
- - Bindables - (${selectedElement.bindables.length}) -
-
-
No bindable properties
-
    -
  • -
    -
    - - ${row.property.name} - : - - - "${row.property.value}" - - - - used in: - ${row.property.expression} - - - - - - -
    -
    -
  • -
-
- -
- -
- 🏠 - Properties - (${selectedElement.properties.length}) -
-
-
No properties
-
    -
  • -
    -
    - - ${row.property.name} - : - - - "${row.property.value}" - - - - used in: - ${row.property.expression} - - - - - - -
    -
    -
  • -
-
- -
- -
- 🛠️ - Controller - (${selectedElement.controller.properties.length}) -
-
-
No controller fields
-
    -
  • -
    -
    - - ${row.property.name} - : - - - "${row.property.value}" - - - - used in: - ${row.property.expression} - - - - - - -
    -
    -
  • -
-
-
- - -
-
- ⚙️ - Custom Attributes - (${selectedElementAttributes.length}) -
-
-
-
- ${attribute.name} -
-
-
- - Bindables - (${attribute.bindables.length}) -
-
-
No bindable properties
-
    -
  • -
    -
    - - ${row.property.name} - : - - - "${row.property.value}" - - - - used in: - ${row.property.expression} - - - - -
    -
    -
  • -
-
- -
- -
- 🏠 - Properties - (${attribute.properties.length}) -
-
-
No properties
-
    -
  • -
    -
    - - ${row.property.name} - : - - - "${row.property.value}" - - - used in: - ${row.property.expression} - - - - -
    -
    -
  • -
-
-
-
-
-
- - -
-
- 🔄 - Lifecycle Hooks - (${implementedHooksCount}/${totalHooksCount}) - v${lifecycleHooks.version} -
-
-
-
- - ${hook.name} - async -
-
-
-
- - -
-
- 📐 - Computed Properties - (${computedProperties.length}) -
-
-
    -
  • -
    -
    - ${prop.name} - - get - set - - : - ${prop.value} -
    -
    -
  • -
-
-
- - -
-
- 💉 - Dependencies - (${dependencies.dependencies.length}) -
-
-
    -
  • - - 🔧 - 📋 - 🎫 - - - ${dep.name} - ${dep.type} -
  • -
-
-
- - -
-
- 🛤️ - Route Info - -
-
-
-
- Route: - ${routeInfo.currentRoute} -
-
- Params: -
    -
  • - ${param.name} - = - ${param.value} -
  • -
-
-
- Query: -
    -
  • - ${param.name} - = - ${param.value} -
  • -
-
-
-
-
- - -
-
- 📦 - Slots - (${activeSlotCount}/${slotInfo.slots.length}) -
-
-
    -
  • - - ${slot.name} - (${slot.nodeCount} nodes) - empty -
  • -
-
-
- - -
- - -
-
- - -
- -
- History: - -
- -
-
- Result - (${expressionResultType}) - -
-
${expressionResult}
-
${expressionError}
-
-
-
-
-
-
-
-
- - -
-
-
-
Interaction Timeline
-
- Captured delegate/trigger events with before/after snapshots. Applying snapshots overwrites the component's view-model state. -
-
-
- - -
-
- -
-
⚠️
-

Failed to load interactions: ${interactionError}

- -
- -
-
-

Loading recent interactions…

-
- -
-
🕑
-

No interactions captured yet. Trigger actions in the app to see them here.

- -
- -
-
-
-
- ${entry.eventName} - ${entry.mode || 'delegate'} - · ${entry.duration}ms -
-
- ${entry.vmName} - · ${entry.handlerName} - · ${new Date(entry.timestamp).toLocaleTimeString()} -
-
- ${entry.domPath} -
-
- from: ${entry.detail.from} - → ${entry.detail.to} - (${entry.detail.trigger || entry.detail.source || 'router'}) - #${entry.detail.id} -
-
- ⚠️ ${entry.error} -
-
-
- -
-
-
-
- -
-
-
- ${activeExternalIcon} -
-

${activeExternalTitle}

-

${activeExternalDescription}

-
-
-
- -
-
-
-
-
-

Loading panel data…

-
-
-
⚠️
-

${activeExternalError}

- -
-
-
🧩
-

No panel data available yet.

- -
-
-
- ${activeExternalResult.summary} -
-
-
-
-

${section.title || 'Details'}

-

${section.description}

-
-
-
-
- ${row.label || 'Value'} - ${row.hint} -
-
-
${JSON.stringify(row.value, null, 2)}
- ${row.value} - ${row.value} -
-
-
-
- - - - - - - - - - - -
${col}
${cell}
-
-
-
-
-

Data

-
${JSON.stringify(activeExternalResult.data, null, 2)}
-
-
-
-
- -
-
diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index 3feb57b..0000000 --- a/src/app.ts +++ /dev/null @@ -1,2029 +0,0 @@ -import { DebugHost, SelectionChanged } from './backend/debug-host'; -import { - ValueConverterInstance, - ICustomElementViewModel, - IPlatform, -} from 'aurelia'; -import { IControllerInfo, AureliaComponentSnapshot, AureliaComponentTreeNode, AureliaInfo, ComputedPropertyInfo, DISnapshot, EventInteractionRecord, ExternalPanelContext, ExternalPanelDefinition, ExternalPanelSnapshot, InteractionPhase, LifecycleHooksSnapshot, PluginDevtoolsResult, Property, PropertyChangeRecord, PropertySnapshot, RouteSnapshot, SlotSnapshot } from './shared/types'; -import { resolve } from '@aurelia/kernel'; - -export class App implements ICustomElementViewModel { - debugInfo: any; - isDarkTheme: boolean = false; - JSON = JSON; - - // Tab management - private readonly coreTabs: DevtoolsTabDefinition[] = [ - { id: 'all', label: 'All', icon: '🌲', kind: 'core' }, - { id: 'components', label: 'Components', icon: '📦', kind: 'core' }, - { id: 'attributes', label: 'Attributes', icon: '🔧', kind: 'core' }, - { id: 'interactions', label: 'Interactions', icon: '⏱️', kind: 'core' }, - ]; - activeTab: string = 'all'; - tabs: DevtoolsTabDefinition[] = [...this.coreTabs]; - externalTabs: DevtoolsTabDefinition[] = []; - externalPanels: Record = {}; - private externalPanelsVersion = 0; - private externalPanelLoading: Record = {}; - private externalRefreshHandle: ReturnType | null = null; - - // Inspector tab data - selectedElement: IControllerInfo = undefined; - selectedElementAttributes: IControllerInfo[] = undefined; - selectedNodeType: 'custom-element' | 'custom-attribute' = 'custom-element'; - - // Components tab data - componentSnapshot: AureliaComponentSnapshot = { tree: [], flat: [] }; - allAureliaObjects: AureliaInfo[] = undefined; - componentTree: ComponentNode[] = []; - viewMode: 'tree' | 'list' = 'tree'; - selectedBreadcrumb: ComponentNode[] = []; - selectedComponentId: string = undefined; - searchQuery: string = ''; - searchMode: 'name' | 'property' | 'all' = 'name'; - isElementPickerActive: boolean = false; - // Preference: follow Chrome Elements selection automatically - followChromeSelection: boolean = true; - // UI: animate refresh icon when user-triggered refresh happens - isRefreshing: boolean = false; - propertyRowsRevision: number = 0; - - // Interaction timeline - interactionLog: EventInteractionRecord[] = []; - interactionLoading: boolean = false; - interactionError: string | null = null; - private interactionSignature: string = ''; - private runtimeInteractionListener: ((msg: any, sender: any) => void) | null = null; - - // Property watching - private propertyChangeListener: ((msg: any, sender: any) => void) | null = null; - private treeChangeListener: ((msg: any, sender: any) => void) | null = null; - - // Expression evaluation - expressionInput: string = ''; - expressionResult: string = ''; - expressionResultType: string = ''; - expressionError: string = ''; - expressionHistory: string[] = []; - isExpressionPanelOpen: boolean = false; - - // Enhanced component inspection - lifecycleHooks: LifecycleHooksSnapshot | null = null; - computedProperties: ComputedPropertyInfo[] = []; - dependencies: DISnapshot | null = null; - routeInfo: RouteSnapshot | null = null; - slotInfo: SlotSnapshot | null = null; - - // Copy feedback - copiedPropertyId: string | null = null; - - // Detection status - aureliaDetected: boolean = false; - aureliaVersion: number | null = null; - detectionState: 'checking' | 'detected' | 'not-found' | 'disabled' = 'checking'; - extensionInvalidated: boolean = false; - - private debugHost: DebugHost = resolve(DebugHost); - private plat: IPlatform = resolve(IPlatform); - - attaching() { - this.debugHost.attach(this); - this.isDarkTheme = (chrome?.devtools?.panels as any)?.themeName === 'dark'; - [].join(); - - if (this.isDarkTheme) { - document.querySelector('html').style.background = '#202124'; - } - - // Restore persisted preference for following Elements selection - try { - const persisted = localStorage.getItem('au-devtools.followChromeSelection'); - if (persisted != null) this.followChromeSelection = persisted === 'true'; - } catch {} - - // Restore preferred view mode - try { - const persistedViewMode = localStorage.getItem('au-devtools.viewMode'); - if (persistedViewMode === 'tree' || persistedViewMode === 'list') { - this.viewMode = persistedViewMode; - } - } catch {} - - // Wire interaction push stream from content script - this.registerInteractionStream(); - - // Wire property and tree change streams - this.registerPropertyChangeStream(); - this.registerTreeChangeStream(); - - // Check detection state set by devtools.js - this.checkDetectionState(); - - // Delay component loading to allow Aurelia hooks to install, then try regardless of detection flags - setTimeout(() => { - this.checkDetectionState(); - this.loadAllComponents(); - }, 1000); - - // Poll for detection state changes - this.startDetectionPolling(); - this.refreshExternalPanels(); - } - - get currentController() { - return this.selectedElement; - } - - checkDetectionState() { - if (chrome && chrome.devtools) { - chrome.devtools.inspectedWindow.eval( - `({ - state: window.__AURELIA_DEVTOOLS_DETECTION_STATE__, - version: window.__AURELIA_DEVTOOLS_VERSION__ - })`, - (result: {state: string, version: number}, isException?: any) => { - if (!isException && result) { - - switch (result.state) { - case 'detected': - this.aureliaDetected = true; - this.aureliaVersion = result.version; - this.detectionState = 'detected'; - break; - case 'disabled': - this.aureliaDetected = false; - this.aureliaVersion = null; - this.detectionState = 'disabled'; - break; - case 'not-found': - this.aureliaDetected = false; - this.aureliaVersion = null; - this.detectionState = 'not-found'; - break; - case 'checking': - default: - this.detectionState = 'checking'; - break; - } - } - } - ); - } - } - - startDetectionPolling() { - // Poll for detection state changes every 2 seconds - setInterval(() => { - if (this.checkExtensionInvalidated()) { - return; - } - this.checkDetectionState(); - if (this.detectionState === 'disabled') { - return; - } - // Keep trying to load until we find components, regardless of detection flags - if (!this.allAureliaObjects?.length) { - this.loadAllComponents(); - } else { - // Check for tree changes and reload if changed - this.debugHost.checkComponentTreeChanges().then((hasChanged) => { - if (hasChanged) { - this.loadAllComponents(); - } - }); - } - this.refreshExternalPanels(); - }, 2000); - } - - checkExtensionInvalidated(): boolean { - try { - if (!chrome?.runtime?.id) { - this.extensionInvalidated = true; - return true; - } - } catch { - this.extensionInvalidated = true; - return true; - } - return false; - } - - recheckAurelia() { - this.checkDetectionState(); - if (this.aureliaDetected || this.detectionState === 'not-found') { - this.loadAllComponents(); - } - } - - // Tab management methods - switchTab(tabId: string) { - this.activeTab = tabId; - if (this.isComponentTab(tabId)) { - this.loadAllComponents(); - } else if (tabId === 'interactions') { - this.loadInteractionLog(true); - } else { - this.refreshExternalPanels(true); - this.notifyExternalSelection(); - } - } - - get isExternalTabActive(): boolean { - return this.isExternalTab(this.activeTab); - } - - get activeExternalIcon(): string { - return this.getActiveExternalPanel()?.icon || '🧩'; - } - - get activeExternalTitle(): string { - return this.getActiveExternalPanel()?.label || 'Inspector'; - } - - get activeExternalDescription(): string | undefined { - return this.getActiveExternalPanel()?.description; - } - - get activeExternalResult(): PluginDevtoolsResult | undefined { - return this.getActiveExternalPanel(); - } - - get activeExternalError(): string | null { - const panel = this.getActiveExternalPanel(); - if (!panel) { - return null; - } - return panel.status === 'error' ? panel.error || 'Panel error' : null; - } - - get isActiveExternalLoading(): boolean { - const panelId = this.getExternalPanelIdFromTab(this.activeTab); - if (!panelId) { - return false; - } - return !!this.externalPanelLoading[panelId]; - } - - refreshActiveExternalTab() { - if (!this.isExternalTabActive) { - return; - } - this.requestExternalPanelRefresh(); - } - - private getActiveExternalPanel(): ExternalPanelDefinition | undefined { - const panelId = this.getExternalPanelIdFromTab(this.activeTab); - if (!panelId) { - return undefined; - } - return this.externalPanels[panelId]; - } - - private isCoreTab(tabId: string): tabId is CoreTabId { - return tabId === 'all' || tabId === 'components' || tabId === 'attributes' || tabId === 'interactions'; - } - - private isComponentTab(tabId: string): tabId is ComponentTabId { - return tabId === 'all' || tabId === 'components' || tabId === 'attributes'; - } - - private isExternalTab(tabId: string): boolean { - return !!this.getExternalPanelIdFromTab(tabId); - } - - private getExternalPanelIdFromTab(tabId: string): string | null { - if (!tabId) { - return null; - } - if (tabId.startsWith('external:')) { - return tabId.slice('external:'.length); - } - const panel = this.externalTabs.find((tab) => tab.id === tabId && tab.panelId); - return panel?.panelId || null; - } - - refreshExternalPanels(force = false): Promise { - if (this.detectionState === 'disabled') { - return Promise.resolve(); - } - - return this.debugHost - .getExternalPanelsSnapshot() - .then((snapshot: ExternalPanelSnapshot) => { - if (!snapshot) { - return; - } - if (!force && snapshot.version === this.externalPanelsVersion) { - return; - } - this.externalPanelsVersion = snapshot.version; - this.applyExternalPanels(snapshot); - }) - .catch((error) => { - console.warn('Failed to load external Aurelia panels', error); - }); - } - - private applyExternalPanels(snapshot: ExternalPanelSnapshot) { - const nextPanels: Record = {}; - const tabs = (snapshot.panels || []) - .filter((panel) => panel && panel.id) - .map((panel) => { - nextPanels[panel.id] = panel; - this.externalPanelLoading[panel.id] = false; - return { - id: `external:${panel.id}`, - label: panel.label || panel.id, - icon: panel.icon || '🧩', - kind: 'external' as const, - panelId: panel.id, - description: panel.description, - order: panel.order ?? 0, - }; - }) - .sort((a, b) => { - const orderDiff = (a.order ?? 0) - (b.order ?? 0); - if (orderDiff !== 0) { - return orderDiff; - } - return a.label.localeCompare(b.label); - }); - - this.externalPanels = nextPanels; - this.externalTabs = tabs; - this.tabs = [...this.coreTabs, ...this.externalTabs]; - - if (!this.tabs.some((tab) => tab.id === this.activeTab)) { - this.activeTab = 'all'; - } - } - - private requestExternalPanelRefresh() { - if (this.detectionState === 'disabled') { - return; - } - const panelId = this.getExternalPanelIdFromTab(this.activeTab); - if (!panelId) { - return; - } - this.setExternalPanelLoading(panelId, true); - this.debugHost.emitExternalPanelEvent('aurelia-devtools:request-panel', { - id: panelId, - context: this.buildExternalPanelContext(), - }); - this.scheduleExternalPanelRefresh(); - } - - private scheduleExternalPanelRefresh(delay = 150) { - if (this.externalRefreshHandle) { - clearTimeout(this.externalRefreshHandle); - } - this.externalRefreshHandle = setTimeout(() => { - this.refreshExternalPanels(true); - this.externalRefreshHandle = null; - }, delay); - } - - private setExternalPanelLoading(panelId: string, isLoading: boolean) { - this.externalPanelLoading[panelId] = isLoading; - } - - private buildExternalPanelContext(): ExternalPanelContext { - const selectedNode = this.selectedComponentId ? this.findComponentById(this.selectedComponentId) : null; - const selectedInfo = selectedNode ? selectedNode.data.info : null; - - return { - selectedComponentId: this.selectedComponentId, - selectedNodeType: selectedNode?.type, - selectedDomPath: selectedNode?.domPath, - aureliaVersion: this.aureliaVersion, - selectedInfo, - }; - } - - private notifyExternalSelection() { - this.debugHost.emitExternalPanelEvent('aurelia-devtools:selection-changed', this.buildExternalPanelContext()); - } - - // Component discovery methods - loadAllComponents(): Promise { - if (this.detectionState === 'disabled') { - this.handleComponentSnapshot({ tree: [], flat: [] }); - return Promise.resolve(); - } - - return new Promise((resolve, reject) => { - this.plat.queueMicrotask(() => { - this.debugHost - .getAllComponents() - .then((snapshot) => { - this.handleComponentSnapshot(snapshot); - this.refreshExternalPanels(); - - const hasData = !!(snapshot?.tree?.length || snapshot?.flat?.length); - if (hasData) { - this.aureliaDetected = true; - this.detectionState = 'detected'; - } - - resolve(); - }) - .catch((error) => { - console.warn('Failed to load components:', error); - this.handleComponentSnapshot({ tree: [], flat: [] }); - reject(error); - }); - }); - }); - } - - // Interaction timeline - async loadInteractionLog(force = false, silent = false): Promise { - if (this.interactionLoading && !force && !silent) return; - - const previousSignature = this.interactionSignature; - if (!silent) { - this.interactionLoading = true; - this.interactionError = null; - } - try { - const log = await this.debugHost.getInteractionLog(); - const sorted = Array.isArray(log) - ? [...log].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)) - : []; - const signature = sorted.map((e) => `${e.id}:${e.timestamp}`).join('|'); - this.interactionSignature = signature; - if (signature !== previousSignature) { - this.interactionLog = sorted; - } - } catch (error) { - if (!silent) { - this.interactionError = (error as any)?.message || String(error); - this.interactionLog = []; - } - } finally { - if (!silent) { - this.interactionLoading = false; - } - } - } - - async replayInteraction(id: string): Promise { - if (!id) return; - try { - await this.debugHost.replayInteraction(id); - } catch (error) { - console.warn('Replay failed', error); - } - } - - async applyInteractionSnapshot(id: string, phase: InteractionPhase): Promise { - if (!id) return; - try { - await this.debugHost.applyInteractionSnapshot(id, phase); - } catch (error) { - console.warn('Apply snapshot failed', error); - } - } - - async clearInteractionLog(): Promise { - // Optimistically clear local log for immediate UI feedback - this.interactionLog = []; - this.interactionSignature = ''; - this.interactionError = null; - - try { - await this.debugHost.clearInteractionLog(); - } catch (error) { - console.warn('Clear interaction log failed', error); - } - } - - // Enhanced component inspection - async loadEnhancedInfo(): Promise { - const componentKey = this.selectedElement?.key || this.selectedElement?.name; - if (!componentKey) { - this.clearEnhancedInfo(); - return; - } - - try { - const [hooks, computed, deps, route, slots] = await Promise.all([ - this.debugHost.getLifecycleHooks(componentKey), - this.debugHost.getComputedProperties(componentKey), - this.debugHost.getDependencies(componentKey), - this.debugHost.getRouteInfo(componentKey), - this.debugHost.getSlotInfo(componentKey), - ]); - - this.lifecycleHooks = hooks; - this.computedProperties = computed || []; - this.dependencies = deps; - this.routeInfo = route; - this.slotInfo = slots; - } catch (error) { - this.clearEnhancedInfo(); - } - } - - clearEnhancedInfo(): void { - this.lifecycleHooks = null; - this.computedProperties = []; - this.dependencies = null; - this.routeInfo = null; - this.slotInfo = null; - } - - get implementedHooksCount(): number { - if (!this.lifecycleHooks?.hooks) return 0; - return this.lifecycleHooks.hooks.filter(h => h.implemented).length; - } - - get totalHooksCount(): number { - if (!this.lifecycleHooks?.hooks) return 0; - return this.lifecycleHooks.hooks.length; - } - - get hasRouteParams(): boolean { - return !!(this.routeInfo && (this.routeInfo.params.length > 0 || this.routeInfo.queryParams.length > 0)); - } - - get activeSlotCount(): number { - if (!this.slotInfo?.slots) return 0; - return this.slotInfo.slots.filter(s => s.hasContent).length; - } - - get hasComputedProperties(): boolean { - return this.computedProperties.length > 0; - } - - get hasDependencies(): boolean { - return !!(this.dependencies?.dependencies?.length); - } - - get hasSlots(): boolean { - return !!(this.slotInfo?.slots?.length); - } - - get hasInteractions(): boolean { - return Array.isArray(this.interactionLog) && this.interactionLog.length > 0; - } - - get formattedInteractionLog(): EventInteractionRecord[] { - return this.interactionLog || []; - } - - private registerInteractionStream() { - if (!(chrome?.runtime?.onMessage?.addListener)) return; - this.runtimeInteractionListener = (message: any) => { - if (message && message.type === 'au-devtools:interaction' && message.entry) { - this.handleIncomingInteraction(message.entry as EventInteractionRecord); - } - }; - chrome.runtime.onMessage.addListener(this.runtimeInteractionListener); - } - - private handleIncomingInteraction(entry: EventInteractionRecord) { - if (!entry || !entry.id) return; - const existing = this.interactionLog.find((e) => e.id === entry.id); - if (existing) return; - const next = [entry, ...(this.interactionLog || [])]; - next.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); - this.interactionLog = next; - this.interactionSignature = next.map((e) => `${e.id}:${e.timestamp}`).join('|'); - } - - private registerPropertyChangeStream() { - if (!(chrome?.runtime?.onMessage?.addListener)) return; - this.propertyChangeListener = (message: any) => { - if (message && message.type === 'au-devtools:property-change') { - this.onPropertyChanges(message.changes, message.snapshot); - } - }; - chrome.runtime.onMessage.addListener(this.propertyChangeListener); - } - - private registerTreeChangeStream() { - if (!(chrome?.runtime?.onMessage?.addListener)) return; - this.treeChangeListener = (message: any) => { - if (message && message.type === 'au-devtools:tree-change') { - this.loadAllComponents(); - } - }; - chrome.runtime.onMessage.addListener(this.treeChangeListener); - } - - onPropertyChanges(changes: PropertyChangeRecord[], snapshot: PropertySnapshot) { - if (!changes || !changes.length || !this.selectedElement) return; - - const selectedKey = this.selectedElement.key || this.selectedElement.name; - if (!selectedKey || snapshot?.componentKey !== selectedKey) return; - - let hasUpdates = false; - - for (const change of changes) { - const bindable = this.selectedElement.bindables?.find((b) => b.name === change.propertyName); - if (bindable) { - bindable.value = change.newValue; - hasUpdates = true; - continue; - } - - const property = this.selectedElement.properties?.find((p) => p.name === change.propertyName); - if (property) { - property.value = change.newValue; - hasUpdates = true; - } - } - - if (hasUpdates) { - this.refreshPropertyBindings(); - } - } - - // User-triggered refresh with spinner animation - refreshComponents() { - if (this.isRefreshing) return; - this.isRefreshing = true; - this.loadAllComponents() - .finally(() => { - // Allow a short delay so the animation is visible even on fast refresh - setTimeout(() => (this.isRefreshing = false), 300); - }); - } - - public handleComponentSnapshot(snapshot: AureliaComponentSnapshot) { - const safeSnapshot = snapshot || { tree: [], flat: [] }; - this.componentSnapshot = safeSnapshot; - - const rawTree = (safeSnapshot.tree && safeSnapshot.tree.length) - ? safeSnapshot.tree - : this.convertFlatListToTreeNodes(safeSnapshot.flat || []); - - const fallbackFlat = (safeSnapshot.flat && safeSnapshot.flat.length) - ? safeSnapshot.flat - : this.flattenRawTree(rawTree); - - this.allAureliaObjects = fallbackFlat; - this.componentTree = this.mapRawTreeToComponentNodes(rawTree); - - if (this.selectedComponentId) { - const existingNode = this.findComponentById(this.selectedComponentId); - if (existingNode) { - this.applySelectionFromNode(existingNode); - } else { - this.debugHost.stopPropertyWatching(); - this.selectedComponentId = undefined; - this.selectedElement = undefined; - this.selectedElementAttributes = undefined; - this.selectedBreadcrumb = []; - this.selectedNodeType = 'custom-element'; - } - } else { - this.selectedBreadcrumb = []; - this.selectedNodeType = 'custom-element'; - } - } - - private convertFlatListToTreeNodes(components: AureliaInfo[]): AureliaComponentTreeNode[] { - if (!components || !components.length) { - return []; - } - - const nodes: AureliaComponentTreeNode[] = []; - const seenElements = new Set(); - - components.forEach((component, index) => { - const elementInfo = component?.customElementInfo ?? null; - const attrs = this.normalizeAttributes(component?.customAttributesInfo ?? [], elementInfo); - - if (elementInfo) { - const elementId = elementInfo.key || elementInfo.name || `flat-${index}`; - if (!seenElements.has(elementId)) { - nodes.push({ - id: elementId, - domPath: '', - tagName: elementInfo.name ?? null, - customElementInfo: elementInfo, - customAttributesInfo: attrs, - children: [], - }); - seenElements.add(elementId); - } - } else if (attrs.length) { - attrs.forEach((attr, attrIndex) => { - nodes.push({ - id: `flat-attr-${index}-${attrIndex}`, - domPath: '', - tagName: null, - customElementInfo: null, - customAttributesInfo: [attr], - children: [], - }); - }); - } - }); - - return nodes; - } - - private mapRawTreeToComponentNodes(rawNodes: AureliaComponentTreeNode[]): ComponentNode[] { - if (!rawNodes || !rawNodes.length) { - return []; - } - - const mapped: ComponentNode[] = []; - for (const rawNode of rawNodes) { - mapped.push(...this.transformRawNode(rawNode, 'root')); - } - return this.sortComponentNodes(mapped); - } - - private transformRawNode(rawNode: AureliaComponentTreeNode, parentId: string): ComponentNode[] { - if (!rawNode) return []; - - const elementInfo = rawNode.customElementInfo ?? null; - const normalizedAttributes = this.normalizeAttributes(rawNode.customAttributesInfo || [], elementInfo); - const rawChildren = rawNode.children || []; - - if (elementInfo) { - const baseId = rawNode.id || `${parentId}-${Math.random().toString(36).slice(2)}`; - const elementName = elementInfo.name || rawNode.tagName || 'unknown-element'; - - const elementNode: ComponentNode = { - id: baseId, - name: elementName, - type: 'custom-element', - domPath: rawNode.domPath || '', - tagName: rawNode.tagName || null, - children: [], - data: { - kind: 'element', - raw: rawNode, - info: { - customElementInfo: elementInfo, - customAttributesInfo: normalizedAttributes, - }, - }, - expanded: false, - hasAttributes: normalizedAttributes.length > 0, - }; - - const attributeChildren = normalizedAttributes.map((attr, index) => this.createAttributeNode(baseId, rawNode, attr, index)); - elementNode.children.push(...attributeChildren); - - for (const child of rawChildren) { - elementNode.children.push(...this.transformRawNode(child, baseId)); - } - - elementNode.children = this.sortComponentNodes(elementNode.children); - elementNode.hasAttributes = elementNode.children.some(child => child.type === 'custom-attribute'); - - return [elementNode]; - } - - const nodes: ComponentNode[] = []; - normalizedAttributes.forEach((attr, index) => { - nodes.push(this.createAttributeNode(parentId, rawNode, attr, index)); - }); - - for (const child of rawChildren) { - nodes.push(...this.transformRawNode(child, parentId)); - } - - return nodes; - } - - private createAttributeNode(parentId: string, owner: AureliaComponentTreeNode, attr: IControllerInfo, index: number): ComponentNode { - const attrName = attr?.name || 'custom-attribute'; - const ownerIdentifier = String(owner?.id || owner?.domPath || parentId || 'attr-owner'); - const attrIdentifier = String(attr?.key || attr?.name || `attr-${index}`); - const nodeId = `${ownerIdentifier}::${attrIdentifier}::${index}`; - return { - id: nodeId, - name: attrName, - type: 'custom-attribute', - domPath: owner?.domPath || '', - tagName: owner?.tagName || null, - children: [], - data: { - kind: 'attribute', - raw: attr, - owner, - info: { - customElementInfo: null, - customAttributesInfo: attr ? [attr] : [], - }, - }, - expanded: false, - hasAttributes: false, - }; - } - - private flattenRawTree(rawNodes: AureliaComponentTreeNode[]): AureliaInfo[] { - const collected: AureliaInfo[] = []; - - const walk = (nodes: AureliaComponentTreeNode[]) => { - for (const node of nodes) { - if (node.customElementInfo || (node.customAttributesInfo && node.customAttributesInfo.length)) { - collected.push({ - customElementInfo: node.customElementInfo, - customAttributesInfo: node.customAttributesInfo || [], - }); - } - if (node.children && node.children.length) { - walk(node.children); - } - } - }; - - walk(rawNodes || []); - return collected; - } - - get filteredComponentTreeByTab(): ComponentNode[] { - if (!this.isComponentTab(this.activeTab)) { - return []; - } - - // First apply tab filtering to the full tree - let tabFilteredTree: ComponentNode[]; - - switch (this.activeTab as ComponentTabId) { - case 'components': - tabFilteredTree = this.filterTreeByType(this.componentTree, 'custom-element'); - break; - case 'attributes': - tabFilteredTree = this.filterTreeByType(this.componentTree, 'custom-attribute'); - break; - case 'all': - default: - tabFilteredTree = this.componentTree; - break; - } - - // Then apply search filtering to the tab-filtered tree - if (!this.searchQuery.trim()) { - return tabFilteredTree; - } - - const query = this.searchQuery.toLowerCase(); - return this.filterComponentTree(tabFilteredTree, query); - } - - get visibleComponentNodes(): ComponentDisplayNode[] { - const roots = this.filteredComponentTreeByTab; - if (!roots || !roots.length) { - return []; - } - - const searchActive = !!this.searchQuery.trim(); - const items: ComponentDisplayNode[] = []; - - const traverse = (nodes: ComponentNode[], depth: number) => { - for (const node of nodes) { - items.push({ node, depth }); - - const hasChildren = node.children && node.children.length > 0; - if (!hasChildren) continue; - - const shouldShowChildren = this.viewMode === 'list' || node.expanded || searchActive; - if (shouldShowChildren) { - traverse(node.children, depth + 1); - } - } - }; - - traverse(roots, 0); - return items; - } - - get breadcrumbSegments(): BreadcrumbSegment[] { - if (!this.selectedBreadcrumb || !this.selectedBreadcrumb.length) { - return []; - } - - return this.selectedBreadcrumb.map((node) => ({ - id: node.id, - label: node.type === 'custom-element' ? `<${node.name}>` : `@${node.name}`, - type: node.type, - })); - } - - get totalComponentNodeCount(): number { - const countNodes = (nodes: ComponentNode[]): number => { - return nodes.reduce((total, node) => total + 1 + countNodes(node.children || []), 0); - }; - - return countNodes(this.componentTree || []); - } - - private normalizeAttributes(attrs: IControllerInfo[], elementInfo: IControllerInfo | null | undefined): IControllerInfo[] { - if (!attrs || !attrs.length) { - return []; - } - - const seen = new Set(); - - return attrs.filter((attr) => { - if (!attr) return false; - const key = (attr.key || attr.name || '').toString().toLowerCase(); - const sameAsElement = !!(elementInfo && ((attr.key && elementInfo.key && attr.key === elementInfo.key) || (attr.name && elementInfo.name && attr.name === elementInfo.name))); - if (sameAsElement) return false; - if (key && seen.has(key)) return false; - if (key) { - seen.add(key); - } - return true; - }); - } - - private sortComponentNodes(nodes: ComponentNode[]): ComponentNode[] { - if (!nodes || !nodes.length) return []; - - const cloned = nodes.map((node) => ({ - ...node, - children: this.sortComponentNodes(node.children || []), - })); - - cloned.sort((a, b) => { - if (a.type === b.type) { - return a.name.localeCompare(b.name); - } - return a.type === 'custom-element' ? -1 : 1; - }); - - return cloned; - } - - private filterTreeByType(nodes: ComponentNode[], type: 'custom-element' | 'custom-attribute'): ComponentNode[] { - if (!nodes || !nodes.length) { - return []; - } - - const filtered: ComponentNode[] = []; - - for (const node of nodes) { - const filteredChildren = this.filterTreeByType(node.children || [], type); - - if (node.type === type) { - filtered.push({ - ...node, - children: filteredChildren, - }); - continue; - } - - if (filteredChildren.length) { - filtered.push(...filteredChildren); - } - } - - return filtered; - } - - selectComponent(componentId: string) { - const component = this.findComponentById(componentId); - if (component) { - this.applySelectionFromNode(component); - } - } - - private applySelectionFromNode(node: ComponentNode) { - this.selectedComponentId = node.id; - this.selectedNodeType = node.type; - - if (node.data.kind === 'attribute') { - const attributeInfo = node.data.raw; - this.selectedElement = attributeInfo || null; - this.selectedElement.bindables = this.selectedElement?.bindables || []; - this.selectedElement.properties = this.selectedElement?.properties || []; - this.selectedElementAttributes = []; - this.markPropertyRowsDirty(); - } else { - const elementInfo = node.data.info.customElementInfo; - const attributeInfos = node.data.info.customAttributesInfo || []; - this.selectedElement = elementInfo || null; - this.selectedElement.bindables = this.selectedElement?.bindables || []; - this.selectedElement.properties = this.selectedElement?.properties || []; - this.selectedElementAttributes = attributeInfos.filter((attr) => { - try { - if (!attr) return false; - const sameKey = !!(attr.key && elementInfo?.key && attr.key === elementInfo.key); - const sameName = !!(attr.name && elementInfo?.name && attr.name === elementInfo.name); - return !(sameKey || sameName); - } catch { - return true; - } - }); - this.markPropertyRowsDirty(); - } - - const path = this.findNodePathById(node.id); - if (path && path.length) { - this.selectedBreadcrumb = [...path]; - path.forEach((ancestor) => { - if (ancestor.data.kind === 'element') { - ancestor.expanded = true; - } - }); - } else { - this.selectedBreadcrumb = [node]; - } - - // Start watching the selected component for property changes - const componentKey = this.selectedElement?.key || this.selectedElement?.name; - if (componentKey) { - this.debugHost.startPropertyWatching({ componentKey, pollInterval: 500 }); - } - - // Load enhanced inspection data - this.loadEnhancedInfo(); - - if (this.isExternalTabActive) { - this.scheduleExternalPanelRefresh(); - } - this.notifyExternalSelection(); - } - - private findNodePathById(nodeId: string): ComponentNode[] | null { - const path: ComponentNode[] = []; - - const traverse = (nodes: ComponentNode[]): boolean => { - for (const node of nodes) { - path.push(node); - if (node.id === nodeId) { - return true; - } - if (node.children && node.children.length && traverse(node.children)) { - return true; - } - path.pop(); - } - return false; - }; - - const found = traverse(this.componentTree || []); - return found ? [...path] : null; - } - - findComponentById(id: string): ComponentNode | undefined { - const findInTree = (nodes: ComponentNode[]): ComponentNode | undefined => { - for (const node of nodes) { - if (node.id === id) return node; - const found = findInTree(node.children); - if (found) return found; - } - return undefined; - }; - return findInTree(this.componentTree); - } - - handleExpandToggle(node: ComponentNode, event?: Event) { - event?.stopPropagation(); - if (!node?.id) { - return; - } - this.toggleComponentExpansion(node.id); - } - - toggleComponentExpansion(componentId: string) { - const component = this.findComponentById(componentId); - if (component) { - component.expanded = !component.expanded; - } - } - - handlePropertyRowClick(property: any, event?: Event) { - if (!property?.canExpand || property?.isEditing) { - return; - } - - const target = event?.target as HTMLElement | null; - if (!target) return; - - if (target.closest('.property-value-wrapper') || target.closest('.property-editor')) { - return; - } - - if (target.closest('.expand-button')) { - return; - } - - event?.stopPropagation(); - this.togglePropertyExpansion(property); - } - - // Search and filter functionality - filterComponents() { - // Search filtering is now handled in the filteredComponentTreeByTab getter - // This method is kept for compatibility but doesn't need to do anything - // since the reactive getter automatically handles filtering - } - - private filterComponentTree( - nodes: ComponentNode[], - query: string - ): ComponentNode[] { - const filtered: ComponentNode[] = []; - const lowerQuery = query.toLowerCase(); - - for (const node of nodes) { - let matchesSearch = false; - let matchedPropertyName: string | null = null; - - // Name-based search (with fuzzy matching) - if (this.searchMode === 'name' || this.searchMode === 'all') { - const queryVariants = getNamingVariants(query); - const nodeVariants = getNamingVariants(node.name); - - // Exact substring match (highest priority) - const exactMatch = nodeVariants.some((variant) => - queryVariants.some((q) => variant.includes(q)) - ); - - // Fuzzy match (lower priority) - const isFuzzyMatch = fuzzyMatch(node.name.toLowerCase(), lowerQuery); - - // Type match - const typeMatch = node.type.toLowerCase().includes(lowerQuery); - - matchesSearch = exactMatch || isFuzzyMatch || typeMatch; - } - - // Property value search - if ((this.searchMode === 'property' || this.searchMode === 'all') && !matchesSearch) { - const info = node.data.kind === 'element' - ? node.data.info.customElementInfo - : node.data.raw; - - if (info) { - matchedPropertyName = this.searchInProperties(info, lowerQuery); - if (matchedPropertyName) { - matchesSearch = true; - } - } - } - - const filteredChildren = this.filterComponentTree(node.children, query); - - if (matchesSearch || filteredChildren.length > 0) { - const filteredNode: ComponentNode = { - ...node, - children: filteredChildren, - expanded: filteredChildren.length > 0 || node.expanded, - matchedProperty: matchedPropertyName, - }; - filtered.push(filteredNode); - } - } - - return filtered; - } - - private searchInProperties(info: IControllerInfo, query: string): string | null { - const allProperties = [ - ...(info.bindables || []), - ...(info.properties || []), - ]; - - for (const prop of allProperties) { - if (!prop) continue; - - // Search in property name - if (prop.name?.toLowerCase().includes(query)) { - return prop.name; - } - - // Search in property value - const valueStr = this.propertyValueToSearchString(prop.value); - if (valueStr.toLowerCase().includes(query)) { - return prop.name; - } - } - - return null; - } - - private propertyValueToSearchString(value: unknown): string { - if (value === null) return 'null'; - if (value === undefined) return 'undefined'; - if (typeof value === 'string') return value; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value)) return `[${value.length} items]`; - if (typeof value === 'object') { - try { - return JSON.stringify(value); - } catch { - return '[object]'; - } - } - return String(value); - } - - clearSearch() { - this.searchQuery = ''; - // Filtering automatically updates via the reactive getter - } - - setSearchMode(mode: 'name' | 'property' | 'all') { - this.searchMode = mode; - } - - cycleSearchMode() { - const modes: Array<'name' | 'property' | 'all'> = ['name', 'property', 'all']; - const currentIndex = modes.indexOf(this.searchMode); - this.searchMode = modes[(currentIndex + 1) % modes.length]; - } - - get searchModeLabel(): string { - switch (this.searchMode) { - case 'name': return 'Name'; - case 'property': return 'Props'; - case 'all': return 'All'; - } - } - - get searchPlaceholder(): string { - switch (this.searchMode) { - case 'name': return 'Search components...'; - case 'property': return 'Search property values...'; - case 'all': return 'Search names & properties...'; - } - } - - // Component highlighting methods - highlightComponent(component: ComponentNode) { - // Pass the component info to debugHost for highlighting - const info = component.data.info; - this.debugHost.highlightComponent({ - name: component.name, - type: component.type, - ...info, - }); - } - - unhighlightComponent() { - this.debugHost.unhighlightComponent(); - } - - // Element picker functionality - toggleElementPicker() { - this.isElementPickerActive = !this.isElementPickerActive; - - if (this.isElementPickerActive) { - this.debugHost.startElementPicker(); - } else { - this.debugHost.stopElementPicker(); - } - } - - onElementPicked(componentInfo: AureliaInfo) { - // Stop the picker - this.isElementPickerActive = false; - this.debugHost.stopElementPicker(); - - // Find the component in our tree and select it - const foundComponent = this.findComponentInTreeByInfo(componentInfo); - if (foundComponent) { - this.applySelectionFromNode(foundComponent); - } else { - // If not found, refresh components and try again - this.loadAllComponents().then(() => { - const foundComponentAfterRefresh = - this.findComponentInTreeByInfo(componentInfo); - if (foundComponentAfterRefresh) { - this.applySelectionFromNode(foundComponentAfterRefresh); - } - }); - } - } - - toggleFollowChromeSelection() { - this.followChromeSelection = !this.followChromeSelection; - try { - localStorage.setItem('au-devtools.followChromeSelection', String(this.followChromeSelection)); - } catch {} - } - - toggleViewMode() { - const nextMode = this.viewMode === 'tree' ? 'list' : 'tree'; - this.setViewMode(nextMode); - } - - setViewMode(mode: 'tree' | 'list') { - if (this.viewMode === mode) { - return; - } - this.viewMode = mode; - try { - localStorage.setItem('au-devtools.viewMode', mode); - } catch {} - } - - revealInElements() { - const node = this.findComponentById(this.selectedComponentId); - if (!node) return; - const info = node.data.info; - this.debugHost.revealInElements({ - name: node.name, - type: node.type, - ...info, - }); - } - - private findComponentInTreeByInfo( - componentInfo: AureliaInfo - ): ComponentNode | undefined { - if (!componentInfo) return undefined; - - const targetElement = componentInfo.customElementInfo || null; - const targetAttributes = componentInfo.customAttributesInfo || []; - const domPath = (componentInfo as any)?.__auDevtoolsDomPath as string | undefined; - - if (domPath) { - const byPath = this.findNodeByDomPath(domPath, targetElement, targetAttributes); - if (byPath) { - return byPath; - } - } - - return this.searchNodesForMatch(this.componentTree, targetElement, targetAttributes); - } - - private findNodeByDomPath( - domPath: string, - targetElement: IControllerInfo | null, - targetAttributes: IControllerInfo[] - ): ComponentNode | undefined { - if (!domPath) return undefined; - - let attributeMatch: ComponentNode | undefined; - let elementMatch: ComponentNode | undefined; - - const visit = (nodes: ComponentNode[]) => { - for (const node of nodes) { - if (node.domPath === domPath) { - if (node.data.kind === 'attribute' && targetAttributes.length && this.nodeMatchesAttribute(node, targetAttributes)) { - attributeMatch = attributeMatch || node; - } - if (node.data.kind === 'element' && this.nodeMatchesElement(node, targetElement)) { - elementMatch = elementMatch || node; - } - } - - if (node.children?.length) { - visit(node.children); - } - } - }; - - visit(this.componentTree || []); - - if (targetElement && elementMatch) { - return elementMatch; - } - - if (!targetElement && targetAttributes.length) { - return attributeMatch || elementMatch; - } - - return elementMatch || attributeMatch; - } - - private searchNodesForMatch( - nodes: ComponentNode[], - targetElement: IControllerInfo | null, - targetAttributes: IControllerInfo[] - ): ComponentNode | undefined { - for (const node of nodes) { - if (this.nodeMatchesTarget(node, targetElement, targetAttributes)) { - return node; - } - - const foundInChildren = this.searchNodesForMatch(node.children || [], targetElement, targetAttributes); - if (foundInChildren) { - return foundInChildren; - } - } - - return undefined; - } - - private nodeMatchesTarget( - node: ComponentNode, - targetElement: IControllerInfo | null, - targetAttributes: IControllerInfo[] - ): boolean { - if (node.data.kind === 'element') { - if (targetElement && this.nodeMatchesElement(node, targetElement)) { - return true; - } - - if (!targetElement && targetAttributes.length) { - return this.nodeContainsAnyAttribute(node.data.info.customAttributesInfo || [], targetAttributes); - } - } - - if (node.data.kind === 'attribute') { - const attrInfo = node.data.raw; - return targetAttributes.some((candidate) => this.isSameControllerInfo(attrInfo, candidate)); - } - - return false; - } - - private nodeMatchesElement(node: ComponentNode, targetElement: IControllerInfo | null): boolean { - if (!targetElement || node.data.kind !== 'element') { - return false; - } - - const nodeElement = node.data.info.customElementInfo; - return this.isSameControllerInfo(nodeElement, targetElement); - } - - private nodeMatchesAttribute(node: ComponentNode, targetAttributes: IControllerInfo[]): boolean { - if (node.data.kind !== 'attribute' || !targetAttributes?.length) { - return false; - } - - const attributeInfo = node.data.raw as IControllerInfo; - return targetAttributes.some((candidate) => this.isSameControllerInfo(attributeInfo, candidate)); - } - - private nodeContainsAnyAttribute( - nodeAttributes: IControllerInfo[], - targetAttributes: IControllerInfo[] - ): boolean { - if (!nodeAttributes?.length || !targetAttributes?.length) { - return false; - } - - return targetAttributes.some((candidate) => - nodeAttributes.some((existing) => this.isSameControllerInfo(existing, candidate)) - ); - } - - private isSameControllerInfo(a: IControllerInfo | null | undefined, b: IControllerInfo | null | undefined): boolean { - if (!a || !b) return false; - - if (a.key && b.key && a.key === b.key) { - return true; - } - - if (a.name && b.name && a.name === b.name) { - return true; - } - - const aAliases = Array.isArray(a.aliases) ? a.aliases : []; - const bAliases = Array.isArray(b.aliases) ? b.aliases : []; - - return aAliases.some((alias: any) => alias && (alias === b.name || bAliases.includes(alias))) - || bAliases.some((alias: any) => alias && alias === a.name); - } - - valueChanged(element: IControllerInfo) { - this.plat.queueMicrotask(() => this.debugHost.updateValues(element)); - } - - // Property editing methods for inline properties - editProperty(property: any) { - const editableTypes = [ - 'string', - 'number', - 'boolean', - 'bigint', - 'null', - 'undefined', - ]; - if (editableTypes.includes(property.type) || property.canEdit) { - property.isEditing = true; - property.originalValue = property.value; - } - } - - saveProperty(property: any, newValue: string) { - const originalType = property.type; - let convertedValue: any = newValue; - - try { - // Convert the value based on the original type - switch (originalType) { - case 'number': - const numValue = Number(newValue); - if (!isNaN(numValue)) { - convertedValue = numValue; - } else { - property.value = property.originalValue; // Revert on invalid input - property.isEditing = false; - delete property.originalValue; - return; - } - break; - - case 'boolean': - const lowerValue = newValue.toLowerCase(); - if (lowerValue === 'true' || lowerValue === 'false') { - convertedValue = lowerValue === 'true'; - } else { - property.value = property.originalValue; // Revert on invalid input - property.isEditing = false; - delete property.originalValue; - return; - } - break; - - case 'null': - if (newValue === 'null' || newValue === '') { - convertedValue = null; - } else { - convertedValue = newValue; // Convert to string if not null - property.type = 'string'; - } - break; - - case 'undefined': - if (newValue === 'undefined' || newValue === '') { - convertedValue = undefined; - } else { - convertedValue = newValue; // Convert to string if not undefined - property.type = 'string'; - } - break; - - case 'string': - default: - convertedValue = newValue; - property.type = 'string'; - break; - } - - // Update the property value - property.value = convertedValue; - property.isEditing = false; - delete property.originalValue; - - // Update the actual property value via debugHost - this.plat.queueMicrotask(() => { - this.debugHost.updateValues(this.selectedElement, property); - - // Force UI update by refreshing just this property's binding - this.refreshPropertyBindings(); - }); - } catch (error) { - console.warn('Failed to convert property value:', error); - property.value = property.originalValue; // Revert on error - property.isEditing = false; - delete property.originalValue; - } - } - - cancelPropertyEdit(property: any) { - property.value = property.originalValue; - property.isEditing = false; - delete property.originalValue; - } - - // Copy property value to clipboard - async copyPropertyValue(property: Property, event?: Event) { - event?.stopPropagation(); - - let valueToCopy: string; - if (property.value === null) { - valueToCopy = 'null'; - } else if (property.value === undefined) { - valueToCopy = 'undefined'; - } else if (typeof property.value === 'object') { - try { - valueToCopy = JSON.stringify(property.value, null, 2); - } catch { - valueToCopy = String(property.value); - } - } else { - valueToCopy = String(property.value); - } - - try { - await navigator.clipboard.writeText(valueToCopy); - this.copiedPropertyId = `${property.name}-${property.debugId || ''}`; - setTimeout(() => { - this.copiedPropertyId = null; - }, 1500); - } catch (error) { - console.warn('Failed to copy to clipboard:', error); - } - } - - isPropertyCopied(property: Property): boolean { - return this.copiedPropertyId === `${property.name}-${property.debugId || ''}`; - } - - // Export component state as JSON - async exportComponentAsJson(copyToClipboard = true) { - if (!this.selectedElement) return; - - const exportData = { - meta: { - name: this.selectedElement.name, - type: this.selectedNodeType, - key: this.selectedElement.key, - aliases: this.selectedElement.aliases, - exportedAt: new Date().toISOString(), - }, - bindables: this.serializeProperties(this.selectedElement.bindables || []), - properties: this.serializeProperties(this.selectedElement.properties || []), - controller: this.selectedElement.controller - ? { properties: this.serializeProperties(this.selectedElement.controller.properties || []) } - : undefined, - customAttributes: this.selectedElementAttributes?.map(attr => ({ - name: attr.name, - bindables: this.serializeProperties(attr.bindables || []), - properties: this.serializeProperties(attr.properties || []), - })) || [], - }; - - const jsonString = JSON.stringify(exportData, null, 2); - - if (copyToClipboard) { - try { - await navigator.clipboard.writeText(jsonString); - this.copiedPropertyId = '__export__'; - setTimeout(() => { - this.copiedPropertyId = null; - }, 1500); - } catch (error) { - console.warn('Failed to copy export to clipboard:', error); - this.downloadJson(jsonString, `${this.selectedElement.name || 'component'}-state.json`); - } - } else { - this.downloadJson(jsonString, `${this.selectedElement.name || 'component'}-state.json`); - } - } - - get isExportCopied(): boolean { - return this.copiedPropertyId === '__export__'; - } - - private serializeProperties(properties: Property[]): Record { - const result: Record = {}; - for (const prop of properties) { - if (prop && prop.name) { - result[prop.name] = { - value: prop.value, - type: prop.type, - }; - } - } - return result; - } - - private downloadJson(content: string, filename: string) { - const blob = new Blob([content], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - - // Expression evaluation - toggleExpressionPanel() { - this.isExpressionPanelOpen = !this.isExpressionPanelOpen; - } - - async evaluateExpression() { - const expression = this.expressionInput.trim(); - if (!expression || !this.selectedElement) return; - - this.expressionError = ''; - this.expressionResult = ''; - this.expressionResultType = ''; - - // Add to history if not duplicate - if (!this.expressionHistory.includes(expression)) { - this.expressionHistory = [expression, ...this.expressionHistory.slice(0, 9)]; - } - - const componentKey = this.selectedElement.key || this.selectedElement.name; - if (!componentKey) { - this.expressionError = 'No component selected'; - return; - } - - const code = ` - (function() { - try { - var hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || !hook.evaluateInComponentContext) { - return { error: 'DevTools hook not available' }; - } - var result = hook.evaluateInComponentContext(${JSON.stringify(componentKey)}, ${JSON.stringify(expression)}); - return result; - } catch (e) { - return { error: e.message || String(e) }; - } - })() - `; - - if (chrome?.devtools?.inspectedWindow) { - chrome.devtools.inspectedWindow.eval( - code, - (result: any, isException?: any) => { - if (isException) { - this.expressionError = String(isException); - return; - } - - if (result && result.error) { - this.expressionError = result.error; - return; - } - - if (result && result.success) { - this.expressionResultType = result.type || typeof result.value; - try { - if (result.value === undefined) { - this.expressionResult = 'undefined'; - } else if (result.value === null) { - this.expressionResult = 'null'; - } else if (typeof result.value === 'object') { - this.expressionResult = JSON.stringify(result.value, null, 2); - } else { - this.expressionResult = String(result.value); - } - } catch { - this.expressionResult = String(result.value); - } - } else { - this.expressionError = 'Unknown response format'; - } - } - ); - } - } - - selectHistoryExpression(expr: string) { - this.expressionInput = expr; - } - - clearExpressionResult() { - this.expressionResult = ''; - this.expressionResultType = ''; - this.expressionError = ''; - } - - refreshPropertyBindings() { - // Force Aurelia to re-render property bindings by updating object references - if (this.selectedElement) { - // Ensure arrays exist to avoid template errors - this.selectedElement.bindables = this.selectedElement.bindables || []; - this.selectedElement.properties = this.selectedElement.properties || []; - - // Update the bindables array reference to trigger change detection - if (this.selectedElement.bindables) { - this.selectedElement.bindables = [...this.selectedElement.bindables]; - } - - // Update the properties array reference to trigger change detection - if (this.selectedElement.properties) { - this.selectedElement.properties = [...this.selectedElement.properties]; - } - } - this.markPropertyRowsDirty(); - } - - // Object expansion functionality - togglePropertyExpansion(property: any) { - if (property.canExpand) { - if (!property.isExpanded) { - // Expanding - load the expanded value - - if (!property.expandedValue) { - // Load expanded value using debugHost with callback - this.loadExpandedPropertyValue(property); - } else { - // Already have expanded value, just toggle visibility - property.isExpanded = true; - } - } else { - // Collapsing - just hide the expanded content - property.isExpanded = false; - } - this.markPropertyRowsDirty(); - } else { - } - } - - private loadExpandedPropertyValue(property: any) { - if (property.debugId && chrome && chrome.devtools) { - const code = `window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__.getExpandedDebugValueForId(${property.debugId})`; - - chrome.devtools.inspectedWindow.eval( - code, - (result: any, isException?: any) => { - if (isException) { - console.warn('Failed to get expanded value:', isException); - return; - } - - // No-op: remove debug logging after verification - - property.expandedValue = result; - property.isExpanded = true; - - // Force UI update without calling refreshPropertyBindings to avoid conflicts with editing - this.plat.queueMicrotask(() => { - // Trigger change detection by updating the property reference - if (this.selectedElement?.bindables?.includes(property)) { - const index = this.selectedElement.bindables.indexOf(property); - this.selectedElement.bindables[index] = { ...property }; - } - if (this.selectedElement?.properties?.includes(property)) { - const index = this.selectedElement.properties.indexOf(property); - this.selectedElement.properties[index] = { ...property }; - } - // Handle controller top-level properties - if ((this.selectedElement as any)?.controller?.properties?.includes?.(property)) { - const arr = (this.selectedElement as any).controller.properties as any[]; - const index = arr.indexOf(property); - if (index !== -1) arr[index] = { ...property }; - } - - // Handle nested properties - find parent and update its expanded value - this.updateNestedPropertyReference(property); - this.markPropertyRowsDirty(); - }); - } - ); - } - } - - private updateNestedPropertyReference(property: any) { - // Helper function to recursively search and update nested properties - const updateInArray = (arr: any[]): boolean => { - if (!arr) return false; - - for (let i = 0; i < arr.length; i++) { - const item = arr[i]; - if (item.expandedValue && item.expandedValue.properties) { - // Check if this property is a child of this item - const childIndex = item.expandedValue.properties.indexOf(property); - if (childIndex !== -1) { - // Found it! Update the parent's expanded value reference - item.expandedValue = { ...item.expandedValue }; - item.expandedValue.properties = [...item.expandedValue.properties]; - item.expandedValue.properties[childIndex] = { ...property }; - return true; - } - - // Recursively check nested properties - if (updateInArray(item.expandedValue.properties)) { - // A nested update occurred, so update this level too - item.expandedValue = { ...item.expandedValue }; - item.expandedValue.properties = [...item.expandedValue.properties]; - return true; - } - } - } - return false; - }; - - // Check in bindables, properties, and controller properties (for deeper nesting) - if (this.selectedElement) { - updateInArray(this.selectedElement.bindables || []); - updateInArray(this.selectedElement.properties || []); - // Include controller internals if present - updateInArray((this.selectedElement as any)?.controller?.properties || []); - } - } - - private markPropertyRowsDirty() { - this.propertyRowsRevision++; - } - - getPropertyRows(properties?: Property[], _revision: number = this.propertyRowsRevision): PropertyRow[] { - if (!properties || !properties.length) { - return []; - } - return this.flattenProperties(properties, 0); - } - - private flattenProperties(properties: Property[], depth: number): PropertyRow[] { - const rows: PropertyRow[] = []; - for (const prop of properties) { - if (!prop) continue; - rows.push({ property: prop, depth }); - if (prop.isExpanded && prop.expandedValue?.properties?.length) { - rows.push(...this.flattenProperties(prop.expandedValue.properties, depth + 1)); - } - } - return rows; - } -} - -interface BreadcrumbSegment { - id: string; - label: string; - type: 'custom-element' | 'custom-attribute'; -} - -interface PropertyRow { - property: Property; - depth: number; -} - -interface ComponentNode { - id: string; - name: string; - type: 'custom-element' | 'custom-attribute'; - domPath: string; - tagName: string | null; - children: ComponentNode[]; - data: ComponentNodeData; - expanded: boolean; - hasAttributes: boolean; - matchedProperty?: string | null; -} - -type ComponentNodeData = - | { - kind: 'element'; - raw: AureliaComponentTreeNode; - info: AureliaInfo; - } - | { - kind: 'attribute'; - raw: IControllerInfo; - owner: AureliaComponentTreeNode; - info: AureliaInfo; - }; - -interface ComponentDisplayNode { - node: ComponentNode; - depth: number; -} - -type ComponentTabId = 'all' | 'components' | 'attributes'; -type CoreTabId = ComponentTabId | 'interactions'; - -interface DevtoolsTabDefinition { - id: string; - label: string; - icon: string; - kind: 'core' | 'external'; - panelId?: string; - description?: string; - order?: number; -} - -export class StringifyValueConverter implements ValueConverterInstance { - toView(value: unknown) { - return JSON.stringify(value); - } -} - -function toKebabCase(str: string): string { - return str - .replace(/([a-z])([A-Z])/g, '$1-$2') - .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') - .toLowerCase(); -} - -function toPascalCase(str: string): string { - return str - .split('-') - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join(''); -} - -function toCamelCase(str: string): string { - const pascal = toPascalCase(str); - return pascal.charAt(0).toLowerCase() + pascal.slice(1); -} - -function getNamingVariants(name: string): string[] { - const lower = name.toLowerCase(); - const kebab = toKebabCase(name); - const pascal = toPascalCase(kebab); - const camel = toCamelCase(kebab); - const variants = new Set([lower, kebab.toLowerCase(), pascal.toLowerCase(), camel.toLowerCase()]); - return [...variants]; -} - -function fuzzyMatch(text: string, pattern: string): boolean { - if (!pattern || !text) return false; - if (pattern.length > text.length) return false; - - let patternIdx = 0; - for (let i = 0; i < text.length && patternIdx < pattern.length; i++) { - if (text[i] === pattern[patternIdx]) { - patternIdx++; - } - } - - return patternIdx === pattern.length; -} diff --git a/src/backend/debug-host.ts b/src/backend/debug-host.ts deleted file mode 100644 index 3b92a90..0000000 --- a/src/backend/debug-host.ts +++ /dev/null @@ -1,1158 +0,0 @@ -import { AureliaComponentSnapshot, AureliaInfo, ComputedPropertyInfo, DISnapshot, EventInteractionRecord, ExternalPanelSnapshot, IControllerInfo, InteractionPhase, LifecycleHooksSnapshot, Property, PropertyChangeRecord, PropertySnapshot, RouteSnapshot, SlotSnapshot, WatchOptions } from '../shared/types'; -import { App } from './../app'; -import { ICustomElementViewModel } from 'aurelia'; - -declare let aureliaDebugger; - -export class SelectionChanged { - constructor(public debugInfo: IControllerInfo) {} -} - -export class DebugHost implements ICustomElementViewModel { - consumer: App; - private pickerPollingInterval: number | null = null; - private propertyWatchInterval: number | null = null; - private lastPropertySnapshot: PropertySnapshot | null = null; - private watchingComponentKey: string | null = null; - private componentTreeSignature: string = ''; - - attach(consumer: App) { - this.consumer = consumer; - if (chrome && chrome.devtools) { - chrome.devtools.network.onNavigated.addListener(() => { - this.getAllComponents().then((snapshot) => { - this.consumer.handleComponentSnapshot(snapshot || { tree: [], flat: [] }); - }); - }); - - chrome.devtools.panels.elements.onSelectionChanged.addListener(() => { - // Respect consumer preference for following Elements selection - if (this.consumer && (this.consumer as any).followChromeSelection === false) { - return; - } - const selectionEval = ` - (function() { - const target = typeof $0 !== 'undefined' ? $0 : null; - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || !hook.getCustomElementInfo || !target) { - return null; - } - - function getDomPath(element) { - if (!element || element.nodeType !== 1) { - return ''; - } - - const segments = []; - let current = element; - - while (current && current.nodeType === 1 && current !== document) { - const tag = current.tagName ? current.tagName.toLowerCase() : 'unknown'; - let index = 1; - let sibling = current; - - while ((sibling = sibling.previousElementSibling)) { - if (sibling.tagName === current.tagName) { - index++; - } - } - - segments.push(tag + ':nth-of-type(' + index + ')'); - current = current.parentElement; - } - - segments.push('html'); - return segments.reverse().join(' > '); - } - - const info = hook.getCustomElementInfo(target); - if (!info) { - return null; - } - - let hostElement = null; - if (typeof hook.findElementByComponentInfo === 'function') { - try { - hostElement = hook.findElementByComponentInfo(info) || null; - } catch (err) { - hostElement = null; - } - } - - if (!hostElement) { - if (target.nodeType === 1) { - hostElement = target; - } else if (target.parentElement) { - hostElement = target.parentElement; - } - } - - if (hostElement) { - const domPath = getDomPath(hostElement); - if (domPath) { - info.__auDevtoolsDomPath = domPath; - if (info.customElementInfo) { - info.customElementInfo.__auDevtoolsDomPath = domPath; - } - if (Array.isArray(info.customAttributesInfo)) { - info.customAttributesInfo.forEach(function(attr) { - if (attr) { - attr.__auDevtoolsDomPath = domPath; - } - }); - } - } - } - - return info; - })(); - `; - chrome.devtools.inspectedWindow.eval( - selectionEval, - (debugObject: AureliaInfo) => { - if (!debugObject) return; - // Sync the selection in our panel/component tree - if (this.consumer && typeof this.consumer.onElementPicked === 'function') { - this.consumer.onElementPicked(debugObject); - } else { - // Fallback: update basic fields - this.consumer.selectedElement = debugObject?.customElementInfo; - this.consumer.selectedElementAttributes = debugObject?.customAttributesInfo; - } - } - ); - }); - - // Initial load of components - add delay to let Aurelia initialize - setTimeout(() => { - this.getAllComponents().then((snapshot) => { - this.consumer.handleComponentSnapshot(snapshot || { tree: [], flat: [] }); - }); - }, 1000); - } - } - - - updateValues( - value: IControllerInfo, - property?: Property - ) { - chrome.devtools.inspectedWindow.eval( - `window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__.updateValues(${JSON.stringify( - value - )}, ${JSON.stringify(property)})` - ); - } - - updateDebugValue(debugInfo: Property) { - let value = debugInfo.value; - - if (debugInfo.type === 'string') { - value = "'" + value + "'"; - } - - let code = `aureliaDebugger.updateValueForId(${debugInfo.debugId}, ${value})`; - chrome.devtools.inspectedWindow.eval(code); - } - - toggleDebugValueExpansion(debugInfo: Property) { - if (debugInfo.canExpand) { - debugInfo.isExpanded = !debugInfo.isExpanded; - - if (debugInfo.isExpanded && !debugInfo.expandedValue) { - let code = `window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__.getExpandedDebugValueForId(${debugInfo.debugId});`; - chrome.devtools.inspectedWindow.eval( - code, - (expandedValue: IControllerInfo) => { - debugInfo.expandedValue = expandedValue; - debugInfo.isExpanded = true; - } - ); - } - } - } - - getAllComponents(): Promise { - return new Promise((resolve) => { - if (chrome && chrome.devtools) { - const getComponentsCode = ` - (() => { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook) { - return { kind: 'empty', data: [] }; - } - - try { - if (hook.getComponentTree) { - const tree = hook.getComponentTree() || []; - return { kind: 'tree', data: tree }; - } - - if (hook.getAllInfo) { - const flat = hook.getAllInfo() || []; - return { kind: 'flat', data: flat }; - } - - return { kind: 'empty', data: [] }; - } catch (error) { - return { kind: 'error', data: [] }; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval( - getComponentsCode, - (result: { kind: string; data: any }) => { - if (!result) { - resolve({ tree: [], flat: [] }); - return; - } - - if (result.kind === 'tree') { - resolve({ tree: Array.isArray(result.data) ? result.data : [], flat: [] }); - return; - } - - if (result.kind === 'flat') { - const flatData = Array.isArray(result.data) ? result.data : []; - resolve({ tree: [], flat: flatData }); - return; - } - - if (!result.data || result.data.length === 0) { - setTimeout(() => { - chrome.devtools.inspectedWindow.eval( - getComponentsCode, - (second: { kind: string; data: any }) => { - if (!second || !Array.isArray(second.data) || second.data.length === 0) { - const fallbackScan = ` - (function(){ - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || !hook.getCustomElementInfo) return []; - const results = []; - const seen = new Set(); - const els = document.querySelectorAll('*'); - for (const el of els) { - try { - const info = hook.getCustomElementInfo(el, false); - if (info && (info.customElementInfo || (info.customAttributesInfo && info.customAttributesInfo.length))) { - const key = info.customElementInfo?.key || info.customElementInfo?.name || (info.customAttributesInfo && info.customAttributesInfo.map(a=>a?.key||a?.name).join('|')) || Math.random().toString(36).slice(2); - if (!seen.has(key)) { - seen.add(key); - results.push(info); - } - } - } catch {} - } - return { kind: 'flat', data: results }; - } catch { return { kind: 'flat', data: [] }; } - })(); - `; - chrome.devtools.inspectedWindow.eval( - fallbackScan, - (fallback: { kind: string; data: any }) => { - if (fallback && fallback.kind === 'tree') { - resolve({ tree: Array.isArray(fallback.data) ? fallback.data : [], flat: [] }); - } else { - const fallbackFlat = fallback && Array.isArray(fallback.data) ? fallback.data : []; - resolve({ tree: [], flat: fallbackFlat }); - } - } - ); - } else { - if (second.kind === 'tree') { - resolve({ tree: Array.isArray(second.data) ? second.data : [], flat: [] }); - } else { - const secondFlat = Array.isArray(second.data) ? second.data : []; - resolve({ tree: [], flat: secondFlat }); - } - } - } - ); - }, 250); - } else { - if (result.kind === 'tree') { - resolve({ tree: Array.isArray(result.data) ? result.data : [], flat: [] }); - } else { - const fallbackFlat = Array.isArray(result.data) ? result.data : []; - resolve({ tree: [], flat: fallbackFlat }); - } - } - } - ); - } else { - resolve({ tree: [], flat: [] }); - } - }); - } - - getExternalPanelsSnapshot(): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve({ version: 0, panels: [] }); - return; - } - - const expression = ` - (function() { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || typeof hook.getExternalPanelsSnapshot !== 'function') { - return { version: 0, panels: [] }; - } - try { - return hook.getExternalPanelsSnapshot(); - } catch (error) { - return { version: 0, panels: [] }; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval( - expression, - (result: ExternalPanelSnapshot) => { - if (!result || typeof result !== 'object') { - resolve({ version: 0, panels: [] }); - return; - } - const version = typeof result.version === 'number' ? result.version : 0; - const panels = Array.isArray(result.panels) ? result.panels : []; - resolve({ version, panels }); - } - ); - }); - } - - emitExternalPanelEvent(eventName: string, payload: Record = {}): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve(false); - return; - } - - let serializedPayload = '{}'; - try { - serializedPayload = JSON.stringify(payload || {}); - } catch { - serializedPayload = '{}'; - } - - const expression = ` - (function() { - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (hook && typeof hook.emitDevtoolsEvent === 'function') { - return hook.emitDevtoolsEvent(${JSON.stringify(eventName)}, ${serializedPayload}); - } - window.dispatchEvent(new CustomEvent(${JSON.stringify(eventName)}, { detail: ${serializedPayload} })); - return true; - } catch (error) { - return false; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: boolean) => { - resolve(Boolean(result)); - }); - }); - } - - getInteractionLog(): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve([]); - return; - } - - const expression = ` - (function() { - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || typeof hook.getInteractionLog !== 'function') { - return []; - } - const log = hook.getInteractionLog(); - return Array.isArray(log) ? log : []; - } catch (error) { - return []; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: EventInteractionRecord[]) => { - if (!Array.isArray(result)) { - resolve([]); - return; - } - resolve(result); - }); - }); - } - - replayInteraction(interactionId: string): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve(false); - return; - } - - const expression = ` - (function() { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || typeof hook.replayInteraction !== 'function') { - return false; - } - try { - return hook.replayInteraction(${JSON.stringify(interactionId)}); - } catch (error) { - return false; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: boolean) => { - resolve(Boolean(result)); - }); - }); - } - - applyInteractionSnapshot(interactionId: string, phase: InteractionPhase): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve(false); - return; - } - - const expression = ` - (function() { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || typeof hook.applyInteractionSnapshot !== 'function') { - return false; - } - try { - return hook.applyInteractionSnapshot(${JSON.stringify(interactionId)}, ${JSON.stringify(phase)}); - } catch (error) { - return false; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: boolean) => { - resolve(Boolean(result)); - }); - }); - } - - clearInteractionLog(): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve(false); - return; - } - - const expression = ` - (function() { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || typeof hook.clearInteractionLog !== 'function') { - return false; - } - try { - return hook.clearInteractionLog(); - } catch (error) { - return false; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: boolean) => { - resolve(Boolean(result)); - }); - }); - } - - // Component highlighting functionality - highlightComponent(componentInfo: any) { - if (chrome && chrome.devtools) { - const highlightCode = ` - (() => { - // Remove existing highlights - document.querySelectorAll('.aurelia-devtools-highlight').forEach(el => { - el.classList.remove('aurelia-devtools-highlight'); - }); - - // Add CSS for highlighting if not exists - if (!document.getElementById('aurelia-devtools-styles')) { - const style = document.createElement('style'); - style.id = 'aurelia-devtools-styles'; - style.textContent = \` - .aurelia-devtools-highlight { - outline: 2px solid #1967d2 !important; - outline-offset: -2px !important; - background-color: rgba(25, 103, 210, 0.1) !important; - box-shadow: inset 0 0 0 2px rgba(25, 103, 210, 0.3) !important; - position: relative !important; - } - .aurelia-devtools-highlight::after { - content: '${componentInfo.name || 'component'}'; - position: absolute !important; - top: -20px !important; - left: 0 !important; - background: #1967d2 !important; - color: white !important; - padding: 2px 6px !important; - font-size: 11px !important; - font-family: monospace !important; - border-radius: 2px !important; - z-index: 10000 !important; - pointer-events: none !important; - white-space: nowrap !important; - } - \`; - document.head.appendChild(style); - } - - // Find and highlight the element - const aureliaHook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (aureliaHook && aureliaHook.findElementByComponentInfo) { - const element = aureliaHook.findElementByComponentInfo(${JSON.stringify( - componentInfo - )}); - if (element) { - element.classList.add('aurelia-devtools-highlight'); - return true; - } - } - return false; - })(); - `; - - chrome.devtools.inspectedWindow.eval(highlightCode); - } - } - - unhighlightComponent() { - if (chrome && chrome.devtools) { - const unhighlightCode = ` - document.querySelectorAll('.aurelia-devtools-highlight').forEach(el => { - el.classList.remove('aurelia-devtools-highlight'); - }); - `; - - chrome.devtools.inspectedWindow.eval(unhighlightCode); - } - } - - // Element picker functionality - startElementPicker() { - if (chrome && chrome.devtools) { - // Start polling for picked components - this.startPickerPolling(); - - const pickerCode = ` - (() => { - // Remove existing picker if any - window.__aureliaDevtoolsStopPicker && window.__aureliaDevtoolsStopPicker(); - - let isPickerActive = true; - let currentHighlight = null; - - function getDomPath(element) { - if (!element || element.nodeType !== 1) { - return ''; - } - - const segments = []; - let current = element; - - while (current && current.nodeType === 1 && current !== document) { - const tag = current.tagName ? current.tagName.toLowerCase() : 'unknown'; - let index = 1; - let sibling = current; - - while ((sibling = sibling.previousElementSibling)) { - if (sibling.tagName === current.tagName) { - index++; - } - } - - segments.push(tag + ':nth-of-type(' + index + ')'); - current = current.parentElement; - } - - segments.push('html'); - return segments.reverse().join(' > '); - } - - // Add picker styles - if (!document.getElementById('aurelia-picker-styles')) { - const style = document.createElement('style'); - style.id = 'aurelia-picker-styles'; - style.textContent = \` - .aurelia-picker-highlight { - outline: 2px solid #ff6b35 !important; - outline-offset: -2px !important; - background-color: rgba(255, 107, 53, 0.1) !important; - box-shadow: inset 0 0 0 2px rgba(255, 107, 53, 0.3) !important; - cursor: crosshair !important; - position: relative !important; - } - .aurelia-picker-highlight::after { - content: 'Click to select'; - position: absolute !important; - top: -20px !important; - left: 0 !important; - background: #ff6b35 !important; - color: white !important; - padding: 2px 6px !important; - font-size: 11px !important; - font-family: monospace !important; - border-radius: 2px !important; - z-index: 10000 !important; - pointer-events: none !important; - white-space: nowrap !important; - } - * { - cursor: crosshair !important; - } - \`; - document.head.appendChild(style); - } - - function highlightElement(element) { - if (currentHighlight) { - currentHighlight.classList.remove('aurelia-picker-highlight'); - } - element.classList.add('aurelia-picker-highlight'); - currentHighlight = element; - } - - function unhighlightElement() { - if (currentHighlight) { - currentHighlight.classList.remove('aurelia-picker-highlight'); - currentHighlight = null; - } - } - - function onMouseOver(event) { - if (!isPickerActive) return; - event.stopPropagation(); - highlightElement(event.target); - } - - function onMouseOut(event) { - if (!isPickerActive) return; - event.stopPropagation(); - unhighlightElement(); - } - - function onClick(event) { - if (!isPickerActive) return; - event.preventDefault(); - event.stopPropagation(); - - const aureliaHook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (aureliaHook) { - const componentInfo = aureliaHook.getCustomElementInfo(event.target, true); - - if (componentInfo) { - const domPath = getDomPath(event.target); - componentInfo.__auDevtoolsDomPath = domPath; - if (componentInfo.customElementInfo) { - componentInfo.customElementInfo.__auDevtoolsDomPath = domPath; - } - if (componentInfo.customAttributesInfo && componentInfo.customAttributesInfo.length) { - componentInfo.customAttributesInfo.forEach(attr => { - if (attr) { - attr.__auDevtoolsDomPath = domPath; - } - }); - } - // Store the picked component info in a global variable that DevTools can access - window.__AURELIA_DEVTOOLS_PICKED_COMPONENT__ = componentInfo; - } else { - window.__AURELIA_DEVTOOLS_PICKED_COMPONENT__ = null; - } - } - - // Stop picker - window.__aureliaDevtoolsStopPicker(); - } - - // Add event listeners - document.addEventListener('mouseover', onMouseOver, true); - document.addEventListener('mouseout', onMouseOut, true); - document.addEventListener('click', onClick, true); - - // Store stop function - window.__aureliaDevtoolsStopPicker = function() { - isPickerActive = false; - unhighlightElement(); - document.removeEventListener('mouseover', onMouseOver, true); - document.removeEventListener('mouseout', onMouseOut, true); - document.removeEventListener('click', onClick, true); - - // Remove styles - const pickerStyles = document.getElementById('aurelia-picker-styles'); - if (pickerStyles) { - pickerStyles.remove(); - } - - delete window.__aureliaDevtoolsStopPicker; - }; - - return true; - })(); - `; - - chrome.devtools.inspectedWindow.eval(pickerCode); - } - } - - stopElementPicker() { - if (chrome && chrome.devtools) { - // Stop polling - this.stopPickerPolling(); - - const stopPickerCode = ` - window.__aureliaDevtoolsStopPicker && window.__aureliaDevtoolsStopPicker(); - `; - - chrome.devtools.inspectedWindow.eval(stopPickerCode); - } - } - - private startPickerPolling() { - // Stop any existing polling - this.stopPickerPolling(); - - // Start polling for picked components - this.pickerPollingInterval = setInterval(() => { - this.checkForPickedComponent(); - }, 100) as any; - } - - private stopPickerPolling() { - if (this.pickerPollingInterval) { - clearInterval(this.pickerPollingInterval); - this.pickerPollingInterval = null; - } - } - - private checkForPickedComponent() { - if (chrome && chrome.devtools) { - const checkCode = ` - (() => { - const picked = window.__AURELIA_DEVTOOLS_PICKED_COMPONENT__; - if (picked) { - // Clear the picked component - window.__AURELIA_DEVTOOLS_PICKED_COMPONENT__ = null; - return picked; - } - return null; - })(); - `; - - chrome.devtools.inspectedWindow.eval( - checkCode, - (componentInfo: AureliaInfo) => { - if (componentInfo) { - this.stopPickerPolling(); // Stop polling when we get a component - this.consumer.onElementPicked(componentInfo); - } - } - ); - } - } - - // Reveal a component's DOM element in the Elements panel - revealInElements(componentInfo: any) { - if (chrome && chrome.devtools) { - const code = `(() => { - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || !hook.findElementByComponentInfo) return false; - const el = hook.findElementByComponentInfo(${JSON.stringify(componentInfo)}); - if (el) { - // Built-in DevTools helper to select and reveal an element - // eslint-disable-next-line no-undef - inspect(el); - return true; - } - return false; - } catch { return false; } - })();`; - chrome.devtools.inspectedWindow.eval(code); - } - } - - startPropertyWatching(options: WatchOptions) { - this.stopPropertyWatching(); - - this.watchingComponentKey = options.componentKey; - const interval = options.pollInterval || 500; - - this.getPropertySnapshot(options.componentKey).then((snapshot) => { - this.lastPropertySnapshot = snapshot; - }); - - this.propertyWatchInterval = setInterval(() => { - this.checkForPropertyChanges(); - }, interval) as any; - } - - stopPropertyWatching() { - if (this.propertyWatchInterval) { - clearInterval(this.propertyWatchInterval); - this.propertyWatchInterval = null; - } - this.watchingComponentKey = null; - this.lastPropertySnapshot = null; - } - - private checkForPropertyChanges() { - if (!this.watchingComponentKey) return; - - this.getPropertySnapshot(this.watchingComponentKey).then((snapshot) => { - if (!snapshot || !this.lastPropertySnapshot) { - this.lastPropertySnapshot = snapshot; - return; - } - - const changes = this.diffPropertySnapshots(this.lastPropertySnapshot, snapshot); - - if (changes.length > 0) { - this.lastPropertySnapshot = snapshot; - if (this.consumer && typeof this.consumer.onPropertyChanges === 'function') { - this.consumer.onPropertyChanges(changes, snapshot); - } - } - }); - } - - private diffPropertySnapshots( - oldSnapshot: PropertySnapshot, - newSnapshot: PropertySnapshot - ): PropertyChangeRecord[] { - const changes: PropertyChangeRecord[] = []; - - const compareProperties = ( - oldProps: Array<{ name: string; value: unknown; type: string }>, - newProps: Array<{ name: string; value: unknown; type: string }>, - propertyType: 'bindable' | 'property' - ) => { - const oldMap = new Map(oldProps.map((p) => [p.name, p])); - const newMap = new Map(newProps.map((p) => [p.name, p])); - - for (const [name, newProp] of newMap) { - const oldProp = oldMap.get(name); - if (!oldProp) { - changes.push({ - componentKey: newSnapshot.componentKey, - propertyName: name, - propertyType, - oldValue: undefined, - newValue: newProp.value, - timestamp: newSnapshot.timestamp, - }); - } else if (!this.valuesEqual(oldProp.value, newProp.value)) { - changes.push({ - componentKey: newSnapshot.componentKey, - propertyName: name, - propertyType, - oldValue: oldProp.value, - newValue: newProp.value, - timestamp: newSnapshot.timestamp, - }); - } - } - }; - - compareProperties(oldSnapshot.bindables, newSnapshot.bindables, 'bindable'); - compareProperties(oldSnapshot.properties, newSnapshot.properties, 'property'); - - return changes; - } - - private valuesEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (typeof a !== typeof b) return false; - if (a === null || b === null) return a === b; - if (typeof a === 'object') { - try { - return JSON.stringify(a) === JSON.stringify(b); - } catch { - return false; - } - } - return false; - } - - getPropertySnapshot(componentKey: string): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve(null); - return; - } - - const expression = ` - (function() { - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || !hook.getComponentByKey) { - return null; - } - const info = hook.getComponentByKey(${JSON.stringify(componentKey)}); - if (!info) { - return null; - } - const serializeValue = (val) => { - if (val === null) return { value: null, type: 'null' }; - if (val === undefined) return { value: undefined, type: 'undefined' }; - const t = typeof val; - if (t === 'function') return { value: '[Function]', type: 'function' }; - if (t === 'object') { - try { - return { value: JSON.parse(JSON.stringify(val)), type: Array.isArray(val) ? 'array' : 'object' }; - } catch { - return { value: '[Object]', type: 'object' }; - } - } - return { value: val, type: t }; - }; - const bindables = (info.bindables || []).map(b => ({ - name: b.name, - ...serializeValue(b.value) - })); - const properties = (info.properties || []).map(p => ({ - name: p.name, - ...serializeValue(p.value) - })); - return { - componentKey: ${JSON.stringify(componentKey)}, - bindables, - properties, - timestamp: Date.now() - }; - } catch (e) { - return null; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: PropertySnapshot | null) => { - resolve(result); - }); - }); - } - - refreshSelectedComponent(): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools) || !this.watchingComponentKey) { - resolve(null); - return; - } - - const expression = ` - (function() { - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || !hook.getComponentByKey) { - return null; - } - return hook.getComponentByKey(${JSON.stringify(this.watchingComponentKey)}); - } catch (e) { - return null; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: IControllerInfo | null) => { - resolve(result); - }); - }); - } - - checkComponentTreeChanges(): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve(false); - return; - } - - const expression = ` - (function() { - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook) return ''; - - const getSignature = (nodes) => { - if (!nodes || !nodes.length) return ''; - return nodes.map(n => { - const key = n.customElementInfo?.key || n.customElementInfo?.name || n.id || ''; - const attrKeys = (n.customAttributesInfo || []).map(a => a?.key || a?.name || '').join(','); - const childSig = n.children ? getSignature(n.children) : ''; - return key + ':' + attrKeys + '(' + childSig + ')'; - }).join('|'); - }; - - if (hook.getComponentTree) { - const tree = hook.getComponentTree() || []; - return getSignature(tree); - } - - if (hook.getAllInfo) { - const flat = hook.getAllInfo() || []; - return flat.map(c => (c.customElementInfo?.key || c.customElementInfo?.name || '')).join('|'); - } - - return ''; - } catch (e) { - return ''; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (signature: string) => { - const hasChanged = signature !== this.componentTreeSignature; - this.componentTreeSignature = signature; - resolve(hasChanged); - }); - }); - } - - getLifecycleHooks(componentKey: string): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve(null); - return; - } - - const expression = ` - (function() { - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || typeof hook.getLifecycleHooks !== 'function') { - return null; - } - return hook.getLifecycleHooks(${JSON.stringify(componentKey)}); - } catch (error) { - return null; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: LifecycleHooksSnapshot | null) => { - resolve(result); - }); - }); - } - - getComputedProperties(componentKey: string): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve([]); - return; - } - - const expression = ` - (function() { - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || typeof hook.getComputedProperties !== 'function') { - return []; - } - return hook.getComputedProperties(${JSON.stringify(componentKey)}) || []; - } catch (error) { - return []; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: ComputedPropertyInfo[]) => { - resolve(Array.isArray(result) ? result : []); - }); - }); - } - - getDependencies(componentKey: string): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve(null); - return; - } - - const expression = ` - (function() { - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || typeof hook.getDependencies !== 'function') { - return null; - } - return hook.getDependencies(${JSON.stringify(componentKey)}); - } catch (error) { - return null; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: DISnapshot | null) => { - resolve(result); - }); - }); - } - - getRouteInfo(componentKey: string): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve(null); - return; - } - - const expression = ` - (function() { - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || typeof hook.getRouteInfo !== 'function') { - return null; - } - return hook.getRouteInfo(${JSON.stringify(componentKey)}); - } catch (error) { - return null; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: RouteSnapshot | null) => { - resolve(result); - }); - }); - } - - getSlotInfo(componentKey: string): Promise { - return new Promise((resolve) => { - if (!(chrome && chrome.devtools)) { - resolve(null); - return; - } - - const expression = ` - (function() { - try { - const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || typeof hook.getSlotInfo !== 'function') { - return null; - } - return hook.getSlotInfo(${JSON.stringify(componentKey)}); - } catch (error) { - return null; - } - })(); - `; - - chrome.devtools.inspectedWindow.eval(expression, (result: SlotSnapshot | null) => { - resolve(result); - }); - }); - } -} diff --git a/src/devtools/devtools.js b/src/devtools/devtools.js index 9045a24..86d6cac 100644 --- a/src/devtools/devtools.js +++ b/src/devtools/devtools.js @@ -1,6 +1,6 @@ -let panelCreated = false; let detectedVersion = null; let elementsSidebarPane = null; +let sidebarCreated = false; const optOutCheckExpression = ` (function() { @@ -36,45 +36,33 @@ function installHooksIfAllowed() { chrome.devtools.inspectedWindow.eval(hooksAsStringv2); } -// Always create the panel immediately -function createAureliaPanel() { - if (panelCreated) return; - - const panelHtml = "index.html"; - const panelTitle = "Aurelia"; - - chrome.devtools.panels.create( - panelTitle, - "images/16.png", - panelHtml, - function (panel) { - panelCreated = true; - - // Set initial detection state to false - let the app handle detection unless opted out - chrome.devtools.inspectedWindow.eval(` - if (!(${optOutCheckExpression})) { - window.__AURELIA_DEVTOOLS_DETECTION_STATE__ = 'checking'; - } - `); - - // Proactively install hooks when the panel opens - installHooksIfAllowed(); +// Set initial detection state +function initializeDetectionState() { + chrome.devtools.inspectedWindow.eval(` + if (!(${optOutCheckExpression})) { + window.__AURELIA_DEVTOOLS_DETECTION_STATE__ = 'checking'; } - ); + `); + installHooksIfAllowed(); } -// Create an Elements sidebar to show Aurelia info for $0 +// Create an enhanced Elements sidebar - this is now the primary UI function createElementsSidebar() { if (!chrome?.devtools?.panels?.elements?.createSidebarPane) return; if (elementsSidebarPane) return; chrome.devtools.panels.elements.createSidebarPane('Aurelia', function(pane) { elementsSidebarPane = pane; + sidebarCreated = true; + let pageSet = false; try { - pane.setPage('index.html'); + // Use the new sidebar-specific HTML page + // Path is relative to extension root, not devtools folder + pane.setPage('../sidebar.html'); pageSet = true; } catch (error) { + console.error('Failed to set sidebar page:', error); pageSet = false; } @@ -86,6 +74,7 @@ function createElementsSidebar() { pane.onShown.addListener(() => installHooksIfAllowed()); } catch {} } else { + // Fallback to setExpression if setPage fails const updateSidebar = () => { if (!elementsSidebarPane) return; const expr = `(() => { @@ -103,7 +92,7 @@ function createElementsSidebar() { }, }; - // Get binding info for the selected node (works for text nodes too) + // Get binding info for the selected node if (hook.getNodeBindingInfo) { const bindingInfo = hook.getNodeBindingInfo($0); if (bindingInfo) { @@ -162,19 +151,13 @@ function createElementsSidebar() { // Update detection state when version is detected function updateDetectionState(version) { - if (panelCreated) { - chrome.devtools.inspectedWindow.eval(` - window.__AURELIA_DEVTOOLS_DETECTED_VERSION__ = ${version}; - window.__AURELIA_DEVTOOLS_VERSION__ = ${version}; - window.__AURELIA_DEVTOOLS_DETECTION_STATE__ = 'detected'; - `); - } + chrome.devtools.inspectedWindow.eval(` + window.__AURELIA_DEVTOOLS_DETECTED_VERSION__ = ${version}; + window.__AURELIA_DEVTOOLS_VERSION__ = ${version}; + window.__AURELIA_DEVTOOLS_DETECTION_STATE__ = 'detected'; + `); } -// Create panel immediately when devtools opens -createAureliaPanel(); -createElementsSidebar(); - // Listen for Aurelia detection messages chrome.runtime.onMessage.addListener((req, sender) => { if (sender.tab && req.aureliaDetected && req.version) { @@ -251,7 +234,7 @@ chrome.devtools.inspectedWindow.eval( } else if (result && result.status === 'detected' && result.version) { detectedVersion = result.version; updateDetectionState(result.version); - } else if (panelCreated && result && result.status === 'not-found') { + } else if (sidebarCreated && result && result.status === 'not-found') { chrome.devtools.inspectedWindow.eval(` window.__AURELIA_DEVTOOLS_DETECTION_STATE__ = 'not-found'; `); @@ -280,6 +263,8 @@ function install(debugValueLookup) { const interactionLog = []; let interactionSequence = 0; const MAX_INTERACTION_LOG = 200; + let isRecordingInteractions = false; + const activePropertyWatchers = new Map(); installEventProxy(); installNavigationListeners(); installRouterEventTap(); @@ -298,6 +283,8 @@ function install(debugValueLookup) { replayInteraction: (id) => replayInteraction(id), applyInteractionSnapshot: (id, phase) => applyInteractionSnapshot(id, phase), clearInteractionLog: () => clearInteractionLog(), + startInteractionRecording: () => startInteractionRecording(), + stopInteractionRecording: () => stopInteractionRecording(), getAllInfo: (root) => { root = root ?? document.body; return [ @@ -762,6 +749,11 @@ function install(debugValueLookup) { return extractDependencies(controller); }, + getEnhancedDISnapshot: (componentKey) => { + const controller = findControllerByKey(componentKey); + return extractEnhancedDISnapshot(controller); + }, + getRouteInfo: (componentKey) => { const controller = findControllerByKey(componentKey); return extractRouteInfo(controller); @@ -771,6 +763,73 @@ function install(debugValueLookup) { const controller = findControllerByKey(componentKey); return extractSlotInfo(controller); }, + + getTemplateSnapshot: (componentKey) => { + const controller = findControllerByKey(componentKey); + return extractTemplateSnapshot(controller); + }, + + attachPropertyWatchers: (componentKey) => attachPropertyWatchers(componentKey), + detachPropertyWatchers: (componentKey) => detachPropertyWatchers(componentKey), + detachAllPropertyWatchers: () => detachAllPropertyWatchers(), + + getComponentByKey: (componentKey) => { + const controller = findControllerByKey(componentKey); + if (!controller) return null; + return extractControllerInfo(controller); + }, + + getSimplifiedComponentTree: () => { + try { + const fullTree = hooks.getComponentTree(); + const simplifyNode = (node) => { + const simplified = []; + + if (node.customElementInfo && node.customElementInfo.name) { + simplified.push({ + key: node.customElementInfo.key || node.customElementInfo.name, + name: node.customElementInfo.name, + tagName: node.tagName || node.customElementInfo.name, + type: 'custom-element', + hasChildren: (node.children && node.children.length > 0) || + (node.customAttributesInfo && node.customAttributesInfo.length > 0), + childCount: (node.children ? node.children.length : 0) + + (node.customAttributesInfo ? node.customAttributesInfo.length : 0), + children: [ + ...(node.customAttributesInfo || []).map(attr => ({ + key: attr.key || attr.name, + name: attr.name, + tagName: node.tagName || '', + type: 'custom-attribute', + hasChildren: false, + childCount: 0, + children: [] + })), + ...(node.children || []).flatMap(child => simplifyNode(child)) + ] + }); + } else if (node.customAttributesInfo && node.customAttributesInfo.length > 0) { + for (const attr of node.customAttributesInfo) { + simplified.push({ + key: attr.key || attr.name, + name: attr.name, + tagName: node.tagName || '', + type: 'custom-attribute', + hasChildren: node.children && node.children.length > 0, + childCount: node.children ? node.children.length : 0, + children: (node.children || []).flatMap(child => simplifyNode(child)) + }); + } + } + + return simplified; + }; + + return fullTree.flatMap(root => simplifyNode(root)); + } catch (error) { + return []; + } + }, }; function installEventProxy() { @@ -1305,6 +1364,8 @@ function install(debugValueLookup) { } function logInteraction(entry, meta) { + if (!isRecordingInteractions) return; + interactionLog.push({ ...entry, ...meta, @@ -1322,6 +1383,16 @@ function install(debugValueLookup) { } catch {} } + function startInteractionRecording() { + isRecordingInteractions = true; + return true; + } + + function stopInteractionRecording() { + isRecordingInteractions = false; + return true; + } + function installNavigationListeners() { try { let lastHref = location.href; @@ -1601,6 +1672,134 @@ function install(debugValueLookup) { return null; } + function serializePropertyValue(value) { + if (value === null) return { value: null, type: 'null' }; + if (value === undefined) return { value: undefined, type: 'undefined' }; + const t = typeof value; + if (t === 'function') return { value: '[Function]', type: 'function' }; + if (t === 'object') { + try { + return { value: JSON.parse(JSON.stringify(value)), type: Array.isArray(value) ? 'array' : 'object' }; + } catch { + return { value: '[Object]', type: 'object' }; + } + } + return { value, type: t }; + } + + function attachPropertyWatchers(componentKey) { + if (!componentKey) return false; + + detachPropertyWatchers(componentKey); + + const controller = findControllerByKey(componentKey); + if (!controller) return false; + + const viewModel = controller.viewModel || controller.bindingContext || controller; + if (!viewModel) return false; + + const version = detectControllerVersion(controller); + if (version !== 2) { + return false; + } + + const container = controller.container; + if (!container) return false; + + let observerLocator = null; + try { + const IObserverLocator = container.get && container.getAll + ? Array.from(container.getAll ? container.getAll(Symbol.for('au:resource:observer-locator')) || [] : []).find(Boolean) + : null; + + if (!IObserverLocator) { + const tryGet = (key) => { + try { return container.get(key); } catch { return null; } + }; + observerLocator = tryGet(Symbol.for('au:resource:observer-locator')) + || tryGet('IObserverLocator') + || (container.root && container.root.get && tryGet.call(container.root, Symbol.for('au:resource:observer-locator'))); + } else { + observerLocator = IObserverLocator; + } + } catch {} + + if (!observerLocator) { + try { + if (typeof viewModel.$controller !== 'undefined' && viewModel.$controller.container) { + const c = viewModel.$controller.container; + observerLocator = c.get && c.get(Symbol.for('au:resource:observer-locator')); + } + } catch {} + } + + if (!observerLocator || typeof observerLocator.getObserver !== 'function') { + return false; + } + + const unsubscribers = []; + const propertyKeys = Object.keys(viewModel).filter(k => + !k.startsWith('$') && + !k.startsWith('_') && + typeof viewModel[k] !== 'function' + ); + + for (const key of propertyKeys) { + try { + const observer = observerLocator.getObserver(viewModel, key); + if (!observer || typeof observer.subscribe !== 'function') continue; + + const subscriber = { + handleChange(newValue, oldValue) { + const serializedNew = serializePropertyValue(newValue); + const serializedOld = serializePropertyValue(oldValue); + emitDevtoolsEvent('aurelia-devtools:property-change', { + componentKey, + propertyName: key, + newValue: serializedNew.value, + oldValue: serializedOld.value, + newType: serializedNew.type, + oldType: serializedOld.type, + timestamp: Date.now() + }); + } + }; + + observer.subscribe(subscriber); + unsubscribers.push(() => { + try { observer.unsubscribe(subscriber); } catch {} + }); + } catch {} + } + + if (unsubscribers.length > 0) { + activePropertyWatchers.set(componentKey, { unsubscribers, viewModel }); + return true; + } + + return false; + } + + function detachPropertyWatchers(componentKey) { + if (!componentKey) return false; + + const entry = activePropertyWatchers.get(componentKey); + if (entry) { + for (const unsub of entry.unsubscribers) { + try { unsub(); } catch {} + } + activePropertyWatchers.delete(componentKey); + return true; + } + return false; + } + + function detachAllPropertyWatchers() { + for (const [key] of activePropertyWatchers) { + detachPropertyWatchers(key); + } + } + function extractLifecycleHooks(controller) { if (!controller) return null; @@ -1740,6 +1939,350 @@ function install(debugValueLookup) { } } + function getRegistrationCount(container) { + try { + const resolvers = container._resolvers || container['_resolvers']; + if (resolvers && resolvers.size !== undefined) { + return resolvers.size; + } + } catch {} + return -1; + } + + function getContainerOwnerName(container) { + try { + if (container.root === container) return 'Root'; + + const res = container.res; + if (res) { + for (const key in res) { + if (key.startsWith('au:resource:custom-element:')) { + return key.replace('au:resource:custom-element:', ''); + } + } + } + + if (container._resolver && container._resolver.Type) { + return container._resolver.Type.name || null; + } + } catch {} + return null; + } + + function extractContainerHierarchy(controller) { + const container = controller.container; + if (!container) return null; + + try { + const ancestors = []; + let current = container; + let depthCounter = 0; + + while (current) { + ancestors.push({ + id: typeof current.id === 'number' ? current.id : depthCounter, + depth: typeof current.depth === 'number' ? current.depth : depthCounter, + isRoot: current.parent === null, + registrationCount: getRegistrationCount(current), + ownerName: getContainerOwnerName(current) + }); + current = current.parent; + depthCounter++; + } + + return { + current: ancestors[0], + ancestors: ancestors.slice(1) + }; + } catch { + return null; + } + } + + function findProvidingContainerDepth(container, key) { + let current = container; + let depth = 0; + + while (current) { + try { + if (typeof current.has === 'function' && current.has(key, false)) { + return typeof current.depth === 'number' ? current.depth : depth; + } + } catch {} + current = current.parent; + depth++; + } + + return -1; + } + + function findProvidingContainer(container, key) { + let current = container; + let depthCounter = 0; + + while (current) { + try { + if (typeof current.has === 'function' && current.has(key, false)) { + return { + id: typeof current.id === 'number' ? current.id : depthCounter, + depth: typeof current.depth === 'number' ? current.depth : depthCounter, + isRoot: current.parent === null, + registrationCount: getRegistrationCount(current) + }; + } + } catch {} + current = current.parent; + depthCounter++; + } + + return null; + } + + function getValueType(value) { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (Array.isArray(value)) return 'Array'; + const type = typeof value; + if (type === 'object' && value.constructor && value.constructor.name !== 'Object') { + return value.constructor.name; + } + return type; + } + + function serializeForDevtools(value, depth = 0, maxDepth = 2, seen = new WeakSet()) { + if (depth > maxDepth) return '[Max depth]'; + if (value === null) return null; + if (value === undefined) return undefined; + + const type = typeof value; + + if (type === 'string' || type === 'number' || type === 'boolean') { + return value; + } + + if (type === 'function') { + return '[Function: ' + (value.name || 'anonymous') + ']'; + } + + if (type === 'object') { + if (seen.has(value)) return '[Circular]'; + seen.add(value); + + if (Array.isArray(value)) { + if (value.length > 10) { + return '[Array(' + value.length + ')]'; + } + return value.slice(0, 10).map(function(v) { + return serializeForDevtools(v, depth + 1, maxDepth, seen); + }); + } + + if (value.constructor && value.constructor.name !== 'Object') { + const className = value.constructor.name; + const preview = { __type__: className }; + const keys = Object.keys(value).slice(0, 5); + keys.forEach(function(k) { + preview[k] = serializeForDevtools(value[k], depth + 1, maxDepth, seen); + }); + return preview; + } + + const result = {}; + const keys = Object.keys(value).slice(0, 10); + keys.forEach(function(k) { + result[k] = serializeForDevtools(value[k], depth + 1, maxDepth, seen); + }); + return result; + } + + return String(value); + } + + function getInstancePreview(instance, maxProps) { + if (!instance || typeof instance !== 'object') return null; + + try { + const preview = {}; + const keys = Object.keys(instance); + let count = 0; + + for (let i = 0; i < keys.length && count < maxProps; i++) { + const key = keys[i]; + if (key.startsWith('_') || key.startsWith('$')) continue; + + const val = instance[key]; + const type = typeof val; + + if (type === 'function') continue; + + if (type === 'string' || type === 'number' || type === 'boolean' || val === null) { + preview[key] = val; + count++; + } else if (Array.isArray(val)) { + preview[key] = '[Array(' + val.length + ')]'; + count++; + } else if (type === 'object') { + preview[key] = '{...}'; + count++; + } + } + + return Object.keys(preview).length > 0 ? preview : null; + } catch { + return null; + } + } + + function extractEnhancedDependencies(controller) { + const version = detectControllerVersion(controller); + if (version !== 2) return null; + + const container = controller.container; + const Type = controller.definition && controller.definition.Type; + if (!Type || !Type.inject || !container) return null; + + try { + const deps = []; + const injectKeys = Array.isArray(Type.inject) ? Type.inject : [Type.inject]; + + injectKeys.forEach(function(key) { + const dep = { + name: extractKeyName(key) || 'unknown', + key: String(key), + type: inferDependencyType(key), + containerDepth: findProvidingContainerDepth(container, key), + containerInfo: findProvidingContainer(container, key), + resolvedValue: null, + resolvedType: null, + instanceName: null, + instancePreview: null + }; + + try { + const resolved = container.get(key); + dep.resolvedType = getValueType(resolved); + + if (resolved && typeof resolved === 'object') { + const ctorName = resolved.constructor ? resolved.constructor.name : null; + if (ctorName && ctorName !== 'Object' && ctorName.length > 1) { + dep.instanceName = ctorName; + } + dep.instancePreview = getInstancePreview(resolved, 4); + } else { + dep.resolvedValue = resolved; + } + } catch {} + + deps.push(dep); + }); + + return deps; + } catch { + return null; + } + } + + function inferServiceType(key) { + const keyStr = String(key); + if (keyStr.includes('au:resource:')) { + return 'resource'; + } + return inferDependencyType(key); + } + + function isInternalAureliaKey(keyStr) { + if (keyStr.startsWith('au:')) return true; + if (keyStr.startsWith('IWindow')) return true; + if (keyStr.startsWith('ILocation')) return true; + if (keyStr.startsWith('IHistory')) return true; + if (keyStr.includes('__au_')) return true; + if (keyStr === 'function Object() { [native code] }') return true; + if (keyStr === 'function Array() { [native code] }') return true; + return false; + } + + function extractAvailableServices(controller) { + const version = detectControllerVersion(controller); + if (version !== 2) return null; + + const container = controller.container; + if (!container) return null; + + try { + const services = []; + const seen = new Set(); + let current = container; + let isAncestor = false; + + while (current) { + try { + const resolvers = current._resolvers || current['_resolvers']; + if (resolvers && typeof resolvers.forEach === 'function') { + resolvers.forEach(function(resolver, key) { + const keyStr = String(key); + + if (isInternalAureliaKey(keyStr)) return; + if (seen.has(keyStr)) return; + + seen.add(keyStr); + + const name = extractKeyName(key) || keyStr; + if (name.length <= 2) return; + + services.push({ + name: name, + key: keyStr, + type: inferServiceType(key), + isFromAncestor: isAncestor + }); + }); + } + } catch {} + + current = current.parent; + isAncestor = true; + } + + return services; + } catch { + return null; + } + } + + function extractEnhancedDISnapshot(controller) { + if (!controller) return null; + + const version = detectControllerVersion(controller); + if (version !== 2) { + return extractDependencies(controller); + } + + try { + const result = { + version: 2, + dependencies: [], + containerHierarchy: null, + availableServices: [] + }; + + try { + result.dependencies = extractEnhancedDependencies(controller) || []; + } catch {} + + try { + result.containerHierarchy = extractContainerHierarchy(controller); + } catch {} + + try { + result.availableServices = extractAvailableServices(controller) || []; + } catch {} + + return result; + } catch { + return extractDependencies(controller); + } + } + function extractRouteInfo(controller) { if (!controller) return null; @@ -1823,6 +2366,229 @@ function install(debugValueLookup) { } } + function extractTemplateSnapshot(controller) { + if (!controller) return null; + + try { + const version = detectControllerVersion(controller); + const viewModel = controller.viewModel || controller.bindingContext || controller; + const componentKey = controller.definition?.key || controller.definition?.name || + controller.behavior?.elementName || controller.behavior?.attributeName || 'unknown'; + const componentName = controller.definition?.name || + controller.behavior?.elementName || controller.behavior?.attributeName || 'unknown'; + + const bindings = extractTemplateBindings(controller, version); + const controllers = extractTemplateControllers(controller, version); + + let hasSlots = false; + let shadowMode = 'none'; + let isContainerless = false; + + if (version === 2) { + const def = controller.definition || controller._compiledDef; + hasSlots = def?.hasSlots || false; + shadowMode = def?.shadowOptions?.mode || 'none'; + isContainerless = def?.containerless || false; + } + + return { + componentKey, + componentName, + bindings, + controllers, + instructions: [], + hasSlots, + shadowMode, + isContainerless + }; + } catch { + return null; + } + } + + function extractTemplateBindings(controller, version) { + const bindings = []; + let bindingId = 0; + + try { + let bindingList = []; + + if (version === 2) { + bindingList = controller.bindings || []; + } else { + const view = controller.view; + bindingList = view?.bindings || []; + } + + for (const binding of bindingList) { + if (!binding) continue; + + const ast = binding.ast || binding.sourceExpression || binding.expression; + if (!ast) continue; + + const kind = ast.$kind || ast.kind || ast.type || (ast.constructor && ast.constructor.name) || 'unknown'; + const expr = unparseExpression(ast); + + let type = 'property'; + if (kind === 'Interpolation' || kind === 'InterpolationBinding') { + type = 'interpolation'; + } else if (binding.targetEvent || kind === 'ListenerBinding' || kind === 'Listener') { + type = 'listener'; + } else if (kind === 'RefBinding' || binding.targetProperty === '$ref') { + type = 'ref'; + } else if (kind === 'LetBinding') { + type = 'let'; + } else if (binding.targetAttribute || kind === 'AttributeBinding') { + type = 'attribute'; + } + + let mode = 'default'; + if (binding.mode !== undefined) { + const modeMap = { 0: 'default', 1: 'oneTime', 2: 'toView', 3: 'fromView', 4: 'twoWay' }; + mode = modeMap[binding.mode] || 'default'; + } else if (binding.updateSource && binding.updateTarget) { + mode = 'twoWay'; + } else if (binding.updateTarget) { + mode = 'toView'; + } else if (binding.updateSource) { + mode = 'fromView'; + } + + let value = undefined; + let valueType = 'unknown'; + try { + if (binding._value !== undefined) { + value = binding._value; + } else if (binding.value !== undefined) { + value = binding.value; + } else if (binding._scope && ast) { + const scope = binding._scope; + if (scope.bindingContext) { + const propName = ast.name || (ast.object && ast.object.name); + if (propName && scope.bindingContext[propName] !== undefined) { + value = scope.bindingContext[propName]; + } + } + } + valueType = value === null ? 'null' : value === undefined ? 'undefined' : + Array.isArray(value) ? 'array' : typeof value; + if (valueType === 'object' || valueType === 'array') { + value = formatPreview(value); + } + } catch {} + + const target = binding.targetProperty || binding.targetEvent || binding.targetAttribute || 'unknown'; + + bindings.push({ + id: `binding-${bindingId++}`, + type, + expression: expr, + target, + value, + valueType, + mode, + isBound: binding.isBound !== false + }); + } + } catch {} + + return bindings; + } + + function extractTemplateControllers(controller, version) { + const controllers = []; + let controllerId = 0; + + try { + const children = controller.children || []; + + for (const child of children) { + if (!child) continue; + + const defName = child.definition?.name || child.vmKind || ''; + const vmKind = child.vmKind; + + if (vmKind === 'synthetic' || defName === 'if' || defName === 'else' || + defName === 'repeat' || defName === 'with' || defName === 'switch' || + defName === 'case' || defName === 'au-slot' || defName === 'portal') { + + const vm = child.viewModel; + let type = defName || 'other'; + let expression = ''; + let isActive = child.isActive !== false; + let condition = undefined; + let items = undefined; + let itemCount = undefined; + let localVariable = undefined; + let cachedViews = undefined; + + if (type === 'if' || type === 'else') { + condition = vm?.value; + isActive = !!condition; + if (vm?.ifView) cachedViews = 1; + if (vm?.elseView) cachedViews = (cachedViews || 0) + 1; + } + + if (type === 'repeat') { + const repeatVm = vm; + localVariable = repeatVm?.local; + itemCount = repeatVm?.items?.length ?? repeatVm?._normalizedItems?.length ?? 0; + + if (repeatVm?.forOf) { + expression = unparseExpression(repeatVm.forOf); + } + + const maxItems = 10; + items = []; + const normalizedItems = repeatVm?._normalizedItems || repeatVm?.items || []; + const count = Math.min(normalizedItems.length, maxItems); + + for (let i = 0; i < count; i++) { + const itemValue = normalizedItems[i]; + items.push({ + index: i, + value: formatPreview(itemValue), + isFirst: i === 0, + isLast: i === normalizedItems.length - 1, + isEven: i % 2 === 0, + isOdd: i % 2 === 1 + }); + } + + if (normalizedItems.length > maxItems) { + items.push({ + index: maxItems, + value: `... and ${normalizedItems.length - maxItems} more`, + isFirst: false, + isLast: true, + isEven: false, + isOdd: false + }); + } + } + + controllers.push({ + id: `tc-${controllerId++}`, + type, + name: defName, + expression, + isActive, + condition, + items, + itemCount, + localVariable, + cachedViews + }); + } + + const nestedControllers = extractTemplateControllers(child, version); + controllers.push(...nestedControllers); + } + } catch {} + + return controllers; + } + return { hooks, debugValueLookup }; function registerExternalPanelBridge() { @@ -3357,3 +4123,8 @@ chrome.runtime.onConnect.addListener((port) => { chrome.devtools.network.onNavigated.addListener(() => { installHooksIfAllowed(); }); + +// Initialize: create sidebar only (no top-level panel) +// Must be at the end of the file after hooksAsStringv2 is defined +initializeDetectionState(); +createElementsSidebar(); diff --git a/src/shared/types.ts b/src/shared/types.ts index e35837e..92be92e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -222,6 +222,45 @@ export interface DISnapshot { containerDepth: number; } +export interface ContainerInfo { + id: number; + depth: number; + isRoot: boolean; + registrationCount: number; + ownerName?: string; +} + +export interface EnhancedDependencyInfo { + name: string; + key: string; + type: 'service' | 'token' | 'interface' | 'unknown'; + containerDepth: number; + containerInfo: ContainerInfo | null; + resolvedValue?: unknown; + resolvedType?: string; + instanceName?: string; + instancePreview?: Record; +} + +export interface ContainerHierarchy { + current: ContainerInfo; + ancestors: ContainerInfo[]; +} + +export interface AvailableService { + name: string; + key: string; + type: 'service' | 'token' | 'interface' | 'resource' | 'unknown'; + isFromAncestor: boolean; +} + +export interface EnhancedDISnapshot { + version: 2; + dependencies: EnhancedDependencyInfo[]; + containerHierarchy: ContainerHierarchy | null; + availableServices: AvailableService[]; +} + export interface RouteParamInfo { name: string; value: string; @@ -245,3 +284,84 @@ export interface SlotSnapshot { slots: SlotInfo[]; hasDefaultSlot: boolean; } + +export interface ComponentTreeNode { + key: string; + name: string; + tagName: string; + type: 'custom-element' | 'custom-attribute'; + hasChildren: boolean; + childCount: number; + isExpanded?: boolean; + children?: ComponentTreeNode[]; +} + +export interface ComponentTreeRow { + node: ComponentTreeNode; + depth: number; +} + +export interface TimelineEvent { + id: string; + type: 'property-change' | 'lifecycle' | 'interaction'; + componentKey: string; + componentName: string; + timestamp: number; + detail: string; + data?: Record; +} + +// Template Debugger Types +export type BindingMode = 'oneTime' | 'toView' | 'fromView' | 'twoWay' | 'default'; + +export interface TemplateBinding { + id: string; + type: 'property' | 'attribute' | 'interpolation' | 'listener' | 'ref' | 'let'; + expression: string; + target: string; + value: unknown; + valueType: string; + mode?: BindingMode; + isBound: boolean; +} + +export interface RepeatItem { + index: number; + key?: string; + value: unknown; + isFirst: boolean; + isLast: boolean; + isEven: boolean; + isOdd: boolean; +} + +export interface TemplateControllerInfo { + id: string; + type: 'if' | 'else' | 'repeat' | 'with' | 'switch' | 'case' | 'au-slot' | 'portal' | 'other'; + name: string; + expression?: string; + isActive: boolean; + condition?: unknown; + items?: RepeatItem[]; + itemCount?: number; + localVariable?: string; + cachedViews?: number; +} + +export interface TemplateInstructionInfo { + type: string; + description: string; + target?: string; + details?: Record; +} + +export interface TemplateSnapshot { + componentKey: string; + componentName: string; + bindings: TemplateBinding[]; + controllers: TemplateControllerInfo[]; + instructions: TemplateInstructionInfo[]; + hasSlots: boolean; + shadowMode: 'open' | 'closed' | 'none'; + isContainerless: boolean; +} diff --git a/src/main.ts b/src/sidebar/main.ts similarity index 69% rename from src/main.ts rename to src/sidebar/main.ts index 832e9b8..5105b9d 100644 --- a/src/main.ts +++ b/src/sidebar/main.ts @@ -1,14 +1,14 @@ import Aurelia, { DI, IPlatform, PLATFORM, Registration } from 'aurelia'; import { StandardConfiguration } from '@aurelia/runtime-html'; -import './styles.css'; -import { App } from './app'; - +import './sidebar-app.css'; +import { SidebarApp } from './sidebar-app'; const aurelia = new Aurelia( DI.createContainer().register( Registration.instance(IPlatform, PLATFORM), - StandardConfiguration + StandardConfiguration ) -).app(App); +).app(SidebarApp); + aurelia.start(); diff --git a/src/sidebar/sidebar-app.css b/src/sidebar/sidebar-app.css new file mode 100644 index 0000000..6da4a1a --- /dev/null +++ b/src/sidebar/sidebar-app.css @@ -0,0 +1,1509 @@ +/* Sidebar-specific styles - optimized for ~350px width */ + +:root { + --sidebar-bg: #fff; + --sidebar-text: #1f2937; + --sidebar-text-secondary: #6b7280; + --sidebar-border: #e5e7eb; + --sidebar-hover: #f3f4f6; + --sidebar-active: #dbeafe; + --sidebar-accent: #2563eb; + + --type-string: #d73a49; + --type-number: #005cc5; + --type-boolean: #e36209; + --type-null: #6f42c1; + --type-object: #22863a; + --type-function: #6f42c1; + + --font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.dark { + --sidebar-bg: #1e1e1e; + --sidebar-text: #e5e5e5; + --sidebar-text-secondary: #9ca3af; + --sidebar-border: #3f3f3f; + --sidebar-hover: #2d2d2d; + --sidebar-active: #1e3a5f; + --sidebar-accent: #60a5fa; + + --type-string: #f28b82; + --type-number: #8ab4f8; + --type-boolean: #fdd663; + --type-null: #c58af9; + --type-object: #81c995; + --type-function: #c58af9; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-sans); + font-size: 11px; + line-height: 1.4; + color: var(--sidebar-text); + background: var(--sidebar-bg); + overflow-x: hidden; +} + +/* State messages */ +.state-message { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + color: var(--sidebar-text-secondary); +} + +.state-message.error { + color: #dc2626; +} + +.state-message.warning { + color: #d97706; +} + +.state-message .icon { + font-size: 14px; + width: 20px; + text-align: center; +} + +.state-message .icon.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Toolbar */ +.toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 6px; + border-bottom: 1px solid var(--sidebar-border); + background: var(--sidebar-bg); + position: sticky; + top: 0; + z-index: 10; +} + +.tool-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--sidebar-text-secondary); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} + +.tool-btn:hover { + background: var(--sidebar-hover); + color: var(--sidebar-text); +} + +.tool-btn.active { + background: var(--sidebar-active); + color: var(--sidebar-accent); +} + +/* Search */ +.search-wrapper { + flex: 1; + position: relative; + min-width: 0; +} + +.search-input { + width: 100%; + height: 24px; + padding: 0 24px 0 8px; + border: 1px solid var(--sidebar-border); + border-radius: 4px; + background: var(--sidebar-bg); + color: var(--sidebar-text); + font-size: 11px; + outline: none; +} + +.search-input:focus { + border-color: var(--sidebar-accent); +} + +.clear-btn { + position: absolute; + right: 2px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + border: none; + background: transparent; + color: var(--sidebar-text-secondary); + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} + +.search-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 200px; + overflow-y: auto; + background: var(--sidebar-bg); + border: 1px solid var(--sidebar-border); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 100; + margin-top: 2px; +} + +.search-result { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + cursor: pointer; +} + +.search-result:hover { + background: var(--sidebar-hover); +} + +.result-icon { + font-size: 12px; +} + +.result-name { + font-family: var(--font-mono); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Empty state */ +.empty-state { + padding: 16px 12px; + color: var(--sidebar-text-secondary); + text-align: center; + font-size: 11px; +} + +/* Component header */ +.component-header { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 6px 8px; + background: var(--sidebar-hover); + border-bottom: 1px solid var(--sidebar-border); +} + +.component-header-info { + flex: 1; + min-width: 0; +} + +.component-main-line { + display: flex; + align-items: center; + gap: 6px; +} + +.component-icon { + font-size: 12px; +} + +.component-name { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--sidebar-accent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.binding-context-label { + display: flex; + align-items: center; + gap: 4px; + margin-top: 2px; + padding-left: 18px; + font-size: 10px; + color: var(--sidebar-text-secondary); +} + +.context-indicator { + color: var(--sidebar-accent); + font-size: 9px; +} + +.selected-element { + font-family: var(--font-mono); + color: var(--type-object); +} + +.context-text { + font-style: italic; +} + +.header-actions { + display: flex; + gap: 2px; +} + +.icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + background: transparent; + color: var(--sidebar-text-secondary); + border-radius: 3px; + cursor: pointer; +} + +.icon-btn:hover { + background: var(--sidebar-border); + color: var(--sidebar-text); +} + +.icon-btn.copied { + color: #22c55e; +} + +/* Sections */ +.sections { + overflow-y: auto; +} + +.section { + border-bottom: 1px solid var(--sidebar-border); +} + +.section-header { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 8px; + cursor: pointer; + user-select: none; +} + +.section-header:hover { + background: var(--sidebar-hover); +} + +.expand-icon { + font-size: 8px; + color: var(--sidebar-text-secondary); + width: 10px; +} + +.section-title { + font-size: 11px; + font-weight: 500; + flex: 1; +} + +.section-count { + font-size: 10px; + color: var(--sidebar-text-secondary); + background: var(--sidebar-border); + padding: 1px 5px; + border-radius: 8px; +} + +.section-content { + padding: 2px 0; + background: var(--sidebar-bg); +} + +/* Property rows */ +.property-row { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + min-height: 20px; + font-family: var(--font-mono); + font-size: 11px; +} + +.property-row:hover { + background: var(--sidebar-hover); +} + +.property-row.sub { + font-size: 10px; +} + +.expand-btn { + width: 14px; + height: 14px; + padding: 0; + border: none; + background: transparent; + color: var(--sidebar-text-secondary); + cursor: pointer; + font-size: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.property-name { + color: var(--type-object); + flex-shrink: 0; +} + +.property-colon { + color: var(--sidebar-text-secondary); + flex-shrink: 0; +} + +.property-value { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: default; +} + +.property-value.type-string { color: var(--type-string); } +.property-value.type-number { color: var(--type-number); } +.property-value.type-boolean { color: var(--type-boolean); } +.property-value.type-null { color: var(--type-null); } +.property-value.type-object { color: var(--type-object); } +.property-value.type-function { color: var(--type-function); } + +.property-editor { + flex: 1; + min-width: 50px; + height: 18px; + padding: 0 4px; + border: 1px solid var(--sidebar-accent); + border-radius: 2px; + background: var(--sidebar-bg); + color: var(--sidebar-text); + font-family: var(--font-mono); + font-size: 11px; + outline: none; +} + +.copy-btn { + width: 18px; + height: 18px; + padding: 0; + border: none; + background: transparent; + color: var(--sidebar-text-secondary); + cursor: pointer; + font-size: 10px; + opacity: 0; + flex-shrink: 0; +} + +.property-row:hover .copy-btn { + opacity: 1; +} + +.copy-btn.copied { + color: #22c55e; + opacity: 1; +} + +/* Attribute items */ +.attribute-item { + padding: 2px 0; +} + +.attribute-name { + padding: 2px 8px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--type-function); + font-weight: 500; +} + +/* Lifecycle hooks */ +.hook-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + font-size: 11px; +} + +.hook-indicator { + width: 14px; + font-size: 10px; + text-align: center; +} + +.hook-indicator.implemented { + color: #22c55e; +} + +.hook-indicator.not-implemented { + color: var(--sidebar-text-secondary); +} + +.hook-name { + font-family: var(--font-mono); +} + +.hook-async { + font-size: 9px; + color: var(--sidebar-accent); + padding: 1px 4px; + background: var(--sidebar-active); + border-radius: 3px; +} + +/* Computed properties */ +.computed-type { + font-size: 9px; + color: var(--sidebar-text-secondary); + margin-left: auto; +} + +/* Dependencies */ +.dependency-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + font-size: 11px; +} + +.dep-icon { + font-size: 10px; + color: var(--sidebar-accent); +} + +.dep-name { + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Enhanced DI (v2) */ +.section-badge.v2 { + margin-left: auto; + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; + background: #8b5cf6; + color: white; +} + +.dark .section-badge.v2 { + background: #7c3aed; +} + +.di-subsection { + margin-bottom: 8px; +} + +.di-subsection:last-child { + margin-bottom: 0; +} + +.subsection-title { + font-size: 10px; + font-weight: 500; + color: var(--sidebar-text-secondary); + padding: 2px 8px; + margin-bottom: 2px; +} + +.subsection-title.clickable { + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; +} + +.subsection-title.clickable:hover { + color: var(--sidebar-text); +} + +.dependency-row-enhanced { + padding: 4px 8px; + border-bottom: 1px solid var(--sidebar-border); +} + +.dependency-row-enhanced:last-child { + border-bottom: none; +} + +.dep-main { + display: flex; + align-items: center; + gap: 6px; +} + +.dep-instance-name { + font-family: var(--font-mono); + font-weight: 500; + color: var(--sidebar-accent); +} + +.dep-key-name { + font-family: var(--font-mono); + color: var(--sidebar-text-secondary); +} + +.dep-preview { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 3px; + padding-left: 16px; +} + +.preview-prop { + font-size: 10px; + background: var(--sidebar-hover); + padding: 1px 4px; + border-radius: 2px; +} + +.preview-key { + color: var(--sidebar-text-secondary); +} + +.preview-val { + font-family: var(--font-mono); + color: var(--type-string); +} + +.container-tree { + padding: 2px 8px; +} + +.container-node { + display: flex; + align-items: center; + gap: 4px; + padding: 1px 0; + font-size: 10px; +} + +.container-marker { + color: var(--sidebar-text-secondary); +} + +.container-marker.current { + color: var(--sidebar-accent); +} + +.container-id { + font-family: var(--font-mono); + color: var(--sidebar-text-secondary); +} + +.container-owner { + font-family: var(--font-mono); + font-size: 10px; +} + +.container-owner.current { + color: var(--sidebar-accent); + font-weight: 500; +} + +.container-node.current .container-id { + color: var(--sidebar-accent); + font-weight: 500; +} + +.services-list { + max-height: 150px; + overflow-y: auto; +} + +.service-row { + display: flex; + align-items: center; + gap: 4px; + padding: 1px 8px; + font-size: 10px; +} + +.svc-icon { + font-size: 8px; + color: var(--sidebar-text-secondary); +} + +.svc-name { + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.inherited-badge { + font-size: 8px; + padding: 0 3px; + border-radius: 2px; + background: var(--sidebar-hover); + color: var(--sidebar-text-secondary); +} + +.empty-state { + padding: 8px; + text-align: center; + font-size: 10px; + color: var(--sidebar-text-secondary); + font-style: italic; +} + +/* Route info */ +.route-info { + padding: 4px 8px; +} + +.route-path { + font-family: var(--font-mono); + font-size: 11px; + color: var(--sidebar-accent); +} + +.route-params { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.param { + font-family: var(--font-mono); + font-size: 10px; + padding: 1px 4px; + background: var(--sidebar-hover); + border-radius: 3px; +} + +/* Slots */ +.slot-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + font-size: 11px; +} + +.slot-indicator { + font-size: 10px; +} + +.slot-indicator.filled { + color: #22c55e; +} + +.slot-indicator.empty { + color: var(--sidebar-text-secondary); +} + +.slot-name { + font-family: var(--font-mono); +} + +/* Expression evaluator */ +.expression-panel { + padding: 6px 8px; +} + +.expression-input-wrapper { + display: flex; + gap: 4px; +} + +.expression-input { + flex: 1; + height: 24px; + padding: 0 8px; + border: 1px solid var(--sidebar-border); + border-radius: 4px; + background: var(--sidebar-bg); + color: var(--sidebar-text); + font-family: var(--font-mono); + font-size: 11px; + outline: none; +} + +.expression-input:focus { + border-color: var(--sidebar-accent); +} + +.eval-btn { + height: 24px; + padding: 0 10px; + border: none; + background: var(--sidebar-accent); + color: #fff; + border-radius: 4px; + font-size: 11px; + cursor: pointer; +} + +.eval-btn:hover { + opacity: 0.9; +} + +.expression-error { + margin-top: 6px; + padding: 6px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 4px; + color: #dc2626; + font-size: 10px; +} + +.dark .expression-error { + background: #450a0a; + border-color: #7f1d1d; +} + +.expression-result { + margin-top: 6px; + padding: 6px; + background: var(--sidebar-hover); + border-radius: 4px; +} + +.result-type { + font-size: 9px; + color: var(--sidebar-text-secondary); + text-transform: uppercase; +} + +.result-value { + margin-top: 4px; + font-family: var(--font-mono); + font-size: 11px; + white-space: pre-wrap; + word-break: break-all; + max-height: 100px; + overflow-y: auto; +} + +.expression-history { + margin-top: 6px; + border-top: 1px solid var(--sidebar-border); + padding-top: 6px; +} + +.history-item { + padding: 3px 6px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--sidebar-text-secondary); + cursor: pointer; + border-radius: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-item:hover { + background: var(--sidebar-hover); + color: var(--sidebar-text); +} + +/* Component Tree Panel */ +.tree-panel { + border-bottom: 1px solid var(--sidebar-border); +} + +.tree-header { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 8px; + cursor: pointer; + user-select: none; + background: var(--sidebar-hover); +} + +.tree-header:hover { + background: var(--sidebar-active); +} + +.tree-title { + font-size: 11px; + font-weight: 500; + flex: 1; +} + +.tree-count { + font-size: 10px; + color: var(--sidebar-text-secondary); + background: var(--sidebar-border); + padding: 1px 5px; + border-radius: 8px; +} + +.refresh-btn { + width: 18px; + height: 18px; + padding: 0; + border: none; + background: transparent; + color: var(--sidebar-text-secondary); + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; +} + +.refresh-btn:hover { + background: var(--sidebar-border); + color: var(--sidebar-text); +} + +.tree-content { + max-height: 250px; + overflow-y: auto; + padding: 2px 0; + background: var(--sidebar-bg); +} + +.tree-node { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + min-height: 20px; + cursor: pointer; + user-select: none; +} + +.tree-node:hover { + background: var(--sidebar-hover); +} + +.tree-node.selected { + background: var(--sidebar-active); +} + +.tree-node.selected .tree-node-name { + color: var(--sidebar-accent); + font-weight: 500; +} + +.tree-expand-btn { + width: 14px; + height: 14px; + padding: 0; + border: none; + background: transparent; + color: var(--sidebar-text-secondary); + cursor: pointer; + font-size: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.tree-expand-btn:hover { + color: var(--sidebar-text); +} + +.tree-expand-spacer { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.tree-node-icon { + font-size: 10px; + color: var(--sidebar-text-secondary); + flex-shrink: 0; +} + +.tree-node-icon.custom-element { + color: var(--sidebar-accent); +} + +.tree-node-icon.custom-attribute { + color: var(--type-function); +} + +.tree-node-name { + font-family: var(--font-mono); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.tree-node-badge { + font-size: 8px; + padding: 1px 4px; + border-radius: 2px; + flex-shrink: 0; +} + +.tree-node-badge.attr { + background: var(--type-function); + color: white; +} + +/* Timeline / Interaction Recorder */ +.recording-indicator { + color: #dc2626; + animation: pulse 1s ease-in-out infinite; + margin-left: auto; + font-size: 10px; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.timeline-panel { + padding: 0; +} + +.timeline-controls { + display: flex; + gap: 4px; + padding: 6px 8px; + border-bottom: 1px solid var(--sidebar-border); +} + +.timeline-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: 1px solid var(--sidebar-border); + border-radius: 3px; + background: var(--sidebar-bg); + color: var(--sidebar-text); + font-size: 10px; + cursor: pointer; + transition: all 0.15s ease; +} + +.timeline-btn:hover:not(:disabled) { + background: var(--sidebar-hover); +} + +.timeline-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.timeline-btn .btn-icon { + font-size: 9px; +} + +.timeline-btn.start .btn-icon { + color: #22c55e; +} + +.timeline-btn.stop .btn-icon { + color: #dc2626; +} + +.timeline-btn.clear .btn-icon { + color: var(--sidebar-text-secondary); +} + +.timeline-empty { + padding: 16px 8px; + text-align: center; + color: var(--sidebar-text-secondary); + font-size: 10px; +} + +.timeline-empty.recording { + color: #dc2626; +} + +.timeline-events { + max-height: 300px; + overflow-y: auto; +} + +.timeline-event { + padding: 6px 8px; + border-bottom: 1px solid var(--sidebar-border); + cursor: pointer; + transition: background 0.15s ease; +} + +.timeline-event:hover { + background: var(--sidebar-hover); +} + +.timeline-event:last-child { + border-bottom: none; +} + +.event-header { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.event-time { + font-family: var(--font-mono); + font-size: 9px; + color: var(--sidebar-text-secondary); + flex-shrink: 0; +} + +.event-type { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + color: var(--sidebar-accent); +} + +.event-component { + font-family: var(--font-mono); + font-size: 10px; + color: var(--type-object); + cursor: pointer; +} + +.event-component:hover { + text-decoration: underline; +} + +.event-handler { + font-family: var(--font-mono); + font-size: 10px; + color: var(--type-function); +} + +.event-duration { + font-size: 9px; + color: var(--sidebar-text-secondary); + margin-left: auto; +} + +.event-details { + margin-top: 6px; + padding-top: 6px; + border-top: 1px dashed var(--sidebar-border); +} + +.event-snapshot { + margin-bottom: 4px; +} + +.snapshot-label { + font-size: 9px; + font-weight: 500; + color: var(--sidebar-text-secondary); + display: block; + margin-bottom: 2px; +} + +.snapshot-data { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + padding-left: 8px; +} + +.snapshot-prop { + font-family: var(--font-mono); + font-size: 10px; + color: var(--sidebar-text); +} + +.event-error { + font-family: var(--font-mono); + font-size: 10px; + color: #dc2626; + margin-top: 4px; +} + +/* Event type colors */ +.timeline-event.event-property { + border-left: 2px solid var(--type-object); +} + +.timeline-event.event-lifecycle { + border-left: 2px solid var(--type-function); +} + +.timeline-event.event-interaction { + border-left: 2px solid var(--sidebar-accent); +} + +.timeline-event.event-binding { + border-left: 2px solid var(--type-string); +} + +/* Template Debugger */ +.template-panel { + padding: 0; +} + +.template-subsection { + padding: 6px 0; + border-bottom: 1px solid var(--sidebar-border); +} + +.template-subsection:last-child { + border-bottom: none; +} + +.subsection-title { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + font-size: 10px; + font-weight: 600; + color: var(--sidebar-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.subsection-icon { + font-size: 11px; +} + +/* Template Bindings */ +.template-binding { + padding: 4px 8px; + cursor: pointer; + transition: background 0.15s ease; + border-left: 2px solid transparent; +} + +.template-binding:hover { + background: var(--sidebar-hover); +} + +.template-binding.property { + border-left-color: var(--sidebar-accent); +} + +.template-binding.attribute { + border-left-color: var(--type-function); +} + +.template-binding.interpolation { + border-left-color: var(--type-string); +} + +.template-binding.listener { + border-left-color: var(--type-boolean); +} + +.template-binding.ref { + border-left-color: var(--type-null); +} + +.template-binding.let { + border-left-color: var(--type-number); +} + +.binding-header { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; +} + +.binding-type-icon { + width: 16px; + text-align: center; + color: var(--sidebar-text-secondary); + flex-shrink: 0; +} + +.binding-target { + font-weight: 500; + color: var(--sidebar-accent); +} + +.binding-mode { + font-size: 9px; + padding: 1px 4px; + border-radius: 2px; + flex-shrink: 0; +} + +.binding-mode.mode-one-time { + background: var(--type-null); + color: white; +} + +.binding-mode.mode-to-view { + background: var(--sidebar-accent); + color: white; +} + +.binding-mode.mode-from-view { + background: var(--type-boolean); + color: white; +} + +.binding-mode.mode-two-way { + background: var(--type-object); + color: white; +} + +.binding-mode.mode-default { + background: var(--sidebar-text-secondary); + color: white; +} + +.binding-expression { + color: var(--type-string); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.binding-details { + margin-top: 6px; + padding: 6px 8px; + background: var(--sidebar-hover); + border-radius: 3px; +} + +.binding-detail-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; +} + +.detail-label { + font-size: 9px; + color: var(--sidebar-text-secondary); + min-width: 50px; +} + +.detail-value { + font-family: var(--font-mono); + font-size: 10px; +} + +/* Template Controllers */ +.template-controller { + padding: 4px 8px; + cursor: pointer; + transition: background 0.15s ease; + border-left: 2px solid var(--sidebar-accent); +} + +.template-controller:hover { + background: var(--sidebar-hover); +} + +.template-controller.if, +.template-controller.else { + border-left-color: var(--type-boolean); +} + +.template-controller.repeat { + border-left-color: var(--type-object); +} + +.template-controller.with { + border-left-color: var(--type-function); +} + +.template-controller.inactive { + opacity: 0.6; +} + +.controller-header { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; +} + +.controller-type-icon { + width: 16px; + text-align: center; + flex-shrink: 0; +} + +.controller-type { + font-weight: 600; + color: var(--sidebar-accent); +} + +.controller-expression { + color: var(--type-string); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.controller-status { + font-size: 10px; + flex-shrink: 0; +} + +.controller-status.active { + color: #22c55e; +} + +.controller-status.inactive { + color: #dc2626; +} + +.controller-details { + margin-top: 6px; + padding: 6px 8px; + background: var(--sidebar-hover); + border-radius: 3px; +} + +.controller-detail-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; +} + +/* Repeat Items */ +.repeat-details { + display: flex; + flex-direction: column; + gap: 4px; +} + +.repeat-items { + margin-top: 6px; + padding-top: 6px; + border-top: 1px dashed var(--sidebar-border); +} + +.repeat-items-header { + font-size: 9px; + color: var(--sidebar-text-secondary); + margin-bottom: 4px; +} + +.repeat-item { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; + font-family: var(--font-mono); + font-size: 10px; +} + +.item-index { + color: var(--type-number); + min-width: 24px; +} + +.item-value { + color: var(--sidebar-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.item-flags { + display: flex; + gap: 3px; + flex-shrink: 0; +} + +.item-flag { + font-size: 8px; + padding: 1px 3px; + border-radius: 2px; + background: var(--sidebar-border); + color: var(--sidebar-text-secondary); +} + +.item-flag.first { + background: var(--type-object); + color: white; +} + +.item-flag.last { + background: var(--type-function); + color: white; +} + +/* Template Meta */ +.template-meta { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 6px 8px; + border-top: 1px solid var(--sidebar-border); +} + +.meta-badge { + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + background: var(--sidebar-hover); + color: var(--sidebar-text-secondary); +} + +.meta-badge.slots { + background: var(--type-function); + color: white; +} + +.meta-badge.shadow { + background: var(--type-null); + color: white; +} + +.meta-badge.containerless { + background: var(--type-boolean); + color: white; +} diff --git a/src/sidebar/sidebar-app.html b/src/sidebar/sidebar-app.html new file mode 100644 index 0000000..e2ab424 --- /dev/null +++ b/src/sidebar/sidebar-app.html @@ -0,0 +1,617 @@ + diff --git a/src/sidebar/sidebar-app.ts b/src/sidebar/sidebar-app.ts new file mode 100644 index 0000000..7780532 --- /dev/null +++ b/src/sidebar/sidebar-app.ts @@ -0,0 +1,1085 @@ +import { ICustomElementViewModel, IPlatform } from 'aurelia'; +import { resolve } from '@aurelia/kernel'; +import { SidebarDebugHost } from './sidebar-debug-host'; +import { + AureliaInfo, + ComponentTreeNode, + ComponentTreeRow, + ComputedPropertyInfo, + DISnapshot, + EnhancedDISnapshot, + EventInteractionRecord, + IControllerInfo, + LifecycleHooksSnapshot, + Property, + PropertyChangeRecord, + PropertySnapshot, + RouteSnapshot, + SlotSnapshot, + TemplateBinding, + TemplateControllerInfo, + TemplateSnapshot, +} from '../shared/types'; + +export class SidebarApp implements ICustomElementViewModel { + isDarkTheme = false; + + // Detection state + aureliaDetected = false; + aureliaVersion: number | null = null; + detectionState: 'checking' | 'detected' | 'not-found' | 'disabled' = 'checking'; + extensionInvalidated = false; + + // Selected component + selectedElement: IControllerInfo | null = null; + selectedElementAttributes: IControllerInfo[] = []; + selectedNodeType: 'custom-element' | 'custom-attribute' = 'custom-element'; + selectedElementTagName: string | null = null; + isShowingBindingContext = false; + + // Element picker + isElementPickerActive = false; + followChromeSelection = true; + + // Component tree + componentTree: ComponentTreeNode[] = []; + expandedTreeNodes: Set = new Set(); + selectedTreeNodeKey: string | null = null; + isTreePanelExpanded = true; + treeRevision = 0; + + // Timeline / Interaction recorder + isRecording = false; + timelineEvents: EventInteractionRecord[] = []; + expandedTimelineEvents: Set = new Set(); + private interactionListener: ((msg: any) => void) | null = null; + + // Search + searchQuery = ''; + searchResults: SearchResult[] = []; + isSearchOpen = false; + + // Collapsible sections state - use object for Aurelia reactivity + expandedSections: Record = { + bindables: true, + properties: true, + controller: false, + attributes: false, + lifecycle: false, + computed: false, + dependencies: false, + route: false, + slots: false, + expression: false, + timeline: false, + template: false, + }; + + // Enhanced inspection + lifecycleHooks: LifecycleHooksSnapshot | null = null; + computedProperties: ComputedPropertyInfo[] = []; + dependencies: DISnapshot | null = null; + routeInfo: RouteSnapshot | null = null; + slotInfo: SlotSnapshot | null = null; + + // Template debugger + templateSnapshot: TemplateSnapshot | null = null; + expandedBindings: Set = new Set(); + expandedControllers: Set = new Set(); + + // Enhanced DI inspection (Aurelia 2 only) + enhancedDI: EnhancedDISnapshot | null = null; + showAvailableServices = false; + + // Expression evaluation + expressionInput = ''; + expressionResult = ''; + expressionResultType = ''; + expressionError = ''; + expressionHistory: string[] = []; + + // Property editing + copiedPropertyId: string | null = null; + propertyRowsRevision = 0; + + // Property change listener + private propertyChangeListener: ((msg: any, sender: any) => void) | null = null; + + private debugHost: SidebarDebugHost = resolve(SidebarDebugHost); + private plat: IPlatform = resolve(IPlatform); + + attaching() { + this.debugHost.attach(this); + this.isDarkTheme = (chrome?.devtools?.panels as any)?.themeName === 'dark'; + + if (this.isDarkTheme) { + document.documentElement.classList.add('dark'); + } + + // Restore preferences + try { + const persisted = localStorage.getItem('au-devtools.followChromeSelection'); + if (persisted != null) this.followChromeSelection = persisted === 'true'; + } catch {} + + // Register property change listener + this.registerPropertyChangeStream(); + + // Register interaction listener for timeline + this.registerInteractionStream(); + + // Check detection state + this.checkDetectionState(); + + // Start detection polling + this.startDetectionPolling(); + + // Load component tree + this.loadComponentTree(); + } + + detaching() { + if (this.propertyChangeListener && chrome?.runtime?.onMessage?.removeListener) { + chrome.runtime.onMessage.removeListener(this.propertyChangeListener); + } + if (this.interactionListener && chrome?.runtime?.onMessage?.removeListener) { + chrome.runtime.onMessage.removeListener(this.interactionListener); + } + } + + private registerPropertyChangeStream() { + if (!(chrome?.runtime?.onMessage?.addListener)) return; + this.propertyChangeListener = (message: any) => { + if (message?.type === 'au-devtools:property-change') { + this.onPropertyChanges(message.changes, message.snapshot); + } + }; + chrome.runtime.onMessage.addListener(this.propertyChangeListener); + } + + private registerInteractionStream() { + if (!(chrome?.runtime?.onMessage?.addListener)) return; + this.interactionListener = (message: any) => { + if (message?.type === 'au-devtools:interaction' && this.isRecording) { + this.timelineEvents = [...this.timelineEvents, message.entry]; + } + }; + chrome.runtime.onMessage.addListener(this.interactionListener); + } + + onPropertyChanges(changes: PropertyChangeRecord[], snapshot: PropertySnapshot) { + if (!changes?.length || !this.selectedElement) return; + + const selectedKey = this.selectedElement.key || this.selectedElement.name; + if (!selectedKey || snapshot?.componentKey !== selectedKey) return; + + let hasUpdates = false; + + for (const change of changes) { + const bindable = this.selectedElement.bindables?.find(b => b.name === change.propertyName); + if (bindable) { + bindable.value = change.newValue; + hasUpdates = true; + continue; + } + + const property = this.selectedElement.properties?.find(p => p.name === change.propertyName); + if (property) { + property.value = change.newValue; + hasUpdates = true; + } + } + + if (hasUpdates) { + this.markPropertyRowsDirty(); + } + } + + checkDetectionState() { + if (chrome?.devtools) { + chrome.devtools.inspectedWindow.eval( + `({ + state: window.__AURELIA_DEVTOOLS_DETECTION_STATE__, + version: window.__AURELIA_DEVTOOLS_VERSION__ + })`, + (result: { state: string; version: number }, isException?: any) => { + if (!isException && result) { + switch (result.state) { + case 'detected': + this.aureliaDetected = true; + this.aureliaVersion = result.version; + this.detectionState = 'detected'; + break; + case 'disabled': + this.aureliaDetected = false; + this.aureliaVersion = null; + this.detectionState = 'disabled'; + break; + case 'not-found': + this.aureliaDetected = false; + this.aureliaVersion = null; + this.detectionState = 'not-found'; + break; + default: + this.detectionState = 'checking'; + break; + } + } + } + ); + } + } + + startDetectionPolling() { + setInterval(() => { + if (this.checkExtensionInvalidated()) return; + this.checkDetectionState(); + }, 2000); + } + + checkExtensionInvalidated(): boolean { + try { + if (!chrome?.runtime?.id) { + this.extensionInvalidated = true; + return true; + } + } catch { + this.extensionInvalidated = true; + return true; + } + return false; + } + + // Called by debug host when element is selected in Elements panel + onElementPicked(componentInfo: AureliaInfo) { + this.isElementPickerActive = false; + this.debugHost.stopElementPicker(); + + if (!componentInfo) { + this.clearSelection(); + return; + } + + const elementInfo = componentInfo.customElementInfo; + const attributesInfo = componentInfo.customAttributesInfo || []; + + // Track the actual selected element and whether we're showing inherited binding context + this.selectedElementTagName = (componentInfo as any).__selectedElement || null; + this.isShowingBindingContext = !!(componentInfo as any).__isBindingContext; + + if (elementInfo) { + this.selectedElement = elementInfo; + this.selectedElement.bindables = this.selectedElement.bindables || []; + this.selectedElement.properties = this.selectedElement.properties || []; + this.selectedElementAttributes = attributesInfo; + this.selectedNodeType = 'custom-element'; + } else if (attributesInfo.length > 0) { + this.selectedElement = attributesInfo[0]; + this.selectedElement.bindables = this.selectedElement.bindables || []; + this.selectedElement.properties = this.selectedElement.properties || []; + this.selectedElementAttributes = []; + this.selectedNodeType = 'custom-attribute'; + } else { + this.clearSelection(); + return; + } + + // Start watching for property changes + const componentKey = this.selectedElement?.key || this.selectedElement?.name; + if (componentKey) { + this.debugHost.startPropertyWatching({ componentKey, pollInterval: 500 }); + this.selectedTreeNodeKey = componentKey; + } + + // Load enhanced info + this.loadEnhancedInfo(); + this.markPropertyRowsDirty(); + + // Refresh tree if needed + if (!this.componentTree?.length) { + this.loadComponentTree(); + } + } + + clearSelection() { + this.debugHost.stopPropertyWatching(); + this.selectedElement = null; + this.selectedElementAttributes = []; + this.selectedElementTagName = null; + this.isShowingBindingContext = false; + this.clearEnhancedInfo(); + } + + // Section expansion + toggleSection(sectionId: string) { + this.expandedSections[sectionId] = !this.expandedSections[sectionId]; + } + + // Element picker + toggleElementPicker() { + this.isElementPickerActive = !this.isElementPickerActive; + + if (this.isElementPickerActive) { + this.debugHost.startElementPicker(); + } else { + this.debugHost.stopElementPicker(); + } + } + + toggleFollowChromeSelection() { + this.followChromeSelection = !this.followChromeSelection; + try { + localStorage.setItem('au-devtools.followChromeSelection', String(this.followChromeSelection)); + } catch {} + } + + // Component tree + async loadComponentTree(): Promise { + try { + const tree = await this.debugHost.getComponentTree(); + this.componentTree = tree; + this.treeRevision++; + } catch { + this.componentTree = []; + } + } + + toggleTreePanel(): void { + this.isTreePanelExpanded = !this.isTreePanelExpanded; + } + + toggleTreeNode(node: ComponentTreeNode, event?: Event): void { + event?.stopPropagation(); + if (!node.hasChildren) return; + + const key = node.key; + if (this.expandedTreeNodes.has(key)) { + this.expandedTreeNodes.delete(key); + } else { + this.expandedTreeNodes.add(key); + } + this.treeRevision++; + } + + selectTreeNode(node: ComponentTreeNode): void { + this.selectedTreeNodeKey = node.key; + this.debugHost.selectComponentByKey(node.key); + } + + getTreeRows(_revision: number = this.treeRevision): ComponentTreeRow[] { + if (!this.componentTree?.length) return []; + return this.flattenTreeNodes(this.componentTree, 0); + } + + private flattenTreeNodes(nodes: ComponentTreeNode[], depth: number): ComponentTreeRow[] { + const rows: ComponentTreeRow[] = []; + for (const node of nodes) { + if (!node) continue; + rows.push({ node, depth }); + if (this.expandedTreeNodes.has(node.key) && node.children?.length) { + rows.push(...this.flattenTreeNodes(node.children, depth + 1)); + } + } + return rows; + } + + isTreeNodeExpanded(node: ComponentTreeNode): boolean { + return this.expandedTreeNodes.has(node.key); + } + + isTreeNodeSelected(node: ComponentTreeNode): boolean { + return this.selectedTreeNodeKey === node.key; + } + + get hasComponentTree(): boolean { + return this.componentTree.length > 0; + } + + get componentTreeCount(): number { + const countNodes = (nodes: ComponentTreeNode[]): number => { + let count = 0; + for (const node of nodes) { + count++; + if (node.children?.length) { + count += countNodes(node.children); + } + } + return count; + }; + return countNodes(this.componentTree); + } + + // Timeline / Interaction Recorder + async startRecording(): Promise { + this.isRecording = true; + await this.debugHost.startInteractionRecording(); + } + + async stopRecording(): Promise { + this.isRecording = false; + await this.debugHost.stopInteractionRecording(); + } + + clearTimeline(): void { + this.timelineEvents = []; + this.expandedTimelineEvents.clear(); + this.debugHost.clearInteractionLog(); + } + + toggleTimelineEvent(event: EventInteractionRecord): void { + const id = event.id; + if (this.expandedTimelineEvents.has(id)) { + this.expandedTimelineEvents.delete(id); + } else { + this.expandedTimelineEvents.add(id); + } + this.timelineEvents = [...this.timelineEvents]; + } + + isTimelineEventExpanded(event: EventInteractionRecord): boolean { + return this.expandedTimelineEvents.has(event.id); + } + + selectTimelineComponent(event: EventInteractionRecord): void { + if (event.target?.componentKey) { + this.debugHost.selectComponentByKey(event.target.componentKey); + } + } + + get hasTimelineEvents(): boolean { + return this.timelineEvents.length > 0; + } + + get timelineEventCount(): number { + return this.timelineEvents.length; + } + + formatTimelineTimestamp(timestamp: number): string { + const date = new Date(timestamp); + const time = date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + const ms = String(date.getMilliseconds()).padStart(3, '0'); + return `${time}.${ms}`; + } + + getTimelineEventTypeClass(type: string): string { + const typeMap: Record = { + 'property-change': 'event-property', + 'lifecycle': 'event-lifecycle', + 'interaction': 'event-interaction', + 'binding': 'event-binding', + }; + return typeMap[type] || 'event-default'; + } + + // Template Debugger + get hasTemplateInfo(): boolean { + return this.templateSnapshot !== null && + (this.templateSnapshot.bindings.length > 0 || + this.templateSnapshot.controllers.length > 0); + } + + get templateBindings(): TemplateBinding[] { + return this.templateSnapshot?.bindings || []; + } + + get templateControllers(): TemplateControllerInfo[] { + return this.templateSnapshot?.controllers || []; + } + + get templateBindingsCount(): number { + return this.templateSnapshot?.bindings?.length || 0; + } + + get templateControllersCount(): number { + return this.templateSnapshot?.controllers?.length || 0; + } + + toggleBindingExpand(binding: TemplateBinding): void { + if (this.expandedBindings.has(binding.id)) { + this.expandedBindings.delete(binding.id); + } else { + this.expandedBindings.add(binding.id); + } + this.templateSnapshot = { ...this.templateSnapshot! }; + } + + isBindingExpanded(binding: TemplateBinding): boolean { + return this.expandedBindings.has(binding.id); + } + + toggleControllerExpand(controller: TemplateControllerInfo): void { + if (this.expandedControllers.has(controller.id)) { + this.expandedControllers.delete(controller.id); + } else { + this.expandedControllers.add(controller.id); + } + this.templateSnapshot = { ...this.templateSnapshot! }; + } + + isControllerExpanded(controller: TemplateControllerInfo): boolean { + return this.expandedControllers.has(controller.id); + } + + getBindingTypeIcon(type: string): string { + const icons: Record = { + 'property': '→', + 'attribute': '@', + 'interpolation': '${}', + 'listener': '⚡', + 'ref': '🔗', + 'let': '𝑙', + }; + return icons[type] || '•'; + } + + getBindingModeClass(mode: string): string { + const classes: Record = { + 'oneTime': 'mode-one-time', + 'toView': 'mode-to-view', + 'fromView': 'mode-from-view', + 'twoWay': 'mode-two-way', + 'default': 'mode-default', + }; + return classes[mode] || 'mode-default'; + } + + getBindingModeLabel(mode: string): string { + const labels: Record = { + 'oneTime': 'one-time', + 'toView': '→', + 'fromView': '←', + 'twoWay': '↔', + 'default': '→', + }; + return labels[mode] || '→'; + } + + getControllerTypeIcon(type: string): string { + const icons: Record = { + 'if': '❓', + 'else': '❔', + 'repeat': '↻', + 'with': '📦', + 'switch': '⚪', + 'case': '⚫', + 'au-slot': '📁', + 'portal': '🔼', + }; + return icons[type] || '◆'; + } + + formatBindingValue(value: unknown): string { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + if (typeof value === 'string') return `"${value}"`; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + } + + // Search + handleSearchInput(event: Event) { + const input = event.target as HTMLInputElement; + this.searchQuery = input.value; + + if (this.searchQuery.trim()) { + this.performSearch(); + this.isSearchOpen = true; + } else { + this.searchResults = []; + this.isSearchOpen = false; + } + } + + async performSearch() { + const query = this.searchQuery.toLowerCase().trim(); + if (!query) { + this.searchResults = []; + return; + } + + const results = await this.debugHost.searchComponents(query); + this.searchResults = results; + } + + selectSearchResult(result: SearchResult) { + this.debugHost.selectComponentByKey(result.key); + this.searchQuery = ''; + this.searchResults = []; + this.isSearchOpen = false; + } + + clearSearch() { + this.searchQuery = ''; + this.searchResults = []; + this.isSearchOpen = false; + } + + // Enhanced info + async loadEnhancedInfo(): Promise { + const componentKey = this.selectedElement?.key || this.selectedElement?.name; + if (!componentKey) { + this.clearEnhancedInfo(); + return; + } + + try { + const [hooks, computed, enhancedDI, route, slots, template] = await Promise.all([ + this.debugHost.getLifecycleHooks(componentKey), + this.debugHost.getComputedProperties(componentKey), + this.debugHost.getEnhancedDISnapshot(componentKey), + this.debugHost.getRouteInfo(componentKey), + this.debugHost.getSlotInfo(componentKey), + this.debugHost.getTemplateSnapshot(componentKey), + ]); + + this.lifecycleHooks = hooks; + this.computedProperties = computed || []; + this.routeInfo = route; + this.slotInfo = slots; + this.templateSnapshot = template; + this.expandedBindings.clear(); + this.expandedControllers.clear(); + + if (enhancedDI && 'version' in enhancedDI && enhancedDI.version === 2) { + this.enhancedDI = enhancedDI as EnhancedDISnapshot; + this.dependencies = null; + } else { + this.enhancedDI = null; + this.dependencies = enhancedDI as DISnapshot; + } + } catch { + this.clearEnhancedInfo(); + } + } + + clearEnhancedInfo() { + this.lifecycleHooks = null; + this.computedProperties = []; + this.dependencies = null; + this.enhancedDI = null; + this.showAvailableServices = false; + this.routeInfo = null; + this.slotInfo = null; + this.templateSnapshot = null; + this.expandedBindings.clear(); + this.expandedControllers.clear(); + } + + get implementedHooksCount(): number { + if (!this.lifecycleHooks?.hooks) return 0; + return this.lifecycleHooks.hooks.filter(h => h.implemented).length; + } + + get totalHooksCount(): number { + return this.lifecycleHooks?.hooks?.length || 0; + } + + get activeSlotCount(): number { + if (!this.slotInfo?.slots) return 0; + return this.slotInfo.slots.filter(s => s.hasContent).length; + } + + get hasBindables(): boolean { + return (this.selectedElement?.bindables?.length ?? 0) > 0; + } + + get hasProperties(): boolean { + return (this.selectedElement?.properties?.length ?? 0) > 0; + } + + get hasController(): boolean { + return !!(this.selectedElement as any)?.controller?.properties?.length; + } + + get hasCustomAttributes(): boolean { + return this.selectedElementAttributes.length > 0; + } + + get hasLifecycleHooks(): boolean { + return (this.lifecycleHooks?.hooks?.length ?? 0) > 0; + } + + get hasComputedProperties(): boolean { + return this.computedProperties.length > 0; + } + + get hasDependencies(): boolean { + return (this.dependencies?.dependencies?.length ?? 0) > 0; + } + + get isEnhancedDI(): boolean { + return this.enhancedDI?.version === 2; + } + + get hasEnhancedDependencies(): boolean { + return (this.enhancedDI?.dependencies?.length ?? 0) > 0; + } + + get hasContainerHierarchy(): boolean { + return !!this.enhancedDI?.containerHierarchy; + } + + get availableServicesCount(): number { + return this.enhancedDI?.availableServices?.length ?? 0; + } + + get containerAncestorsReversed(): Array<{ id: number; depth: number; isRoot: boolean; registrationCount: number }> { + if (!this.enhancedDI?.containerHierarchy?.ancestors) return []; + return [...this.enhancedDI.containerHierarchy.ancestors].reverse(); + } + + toggleAvailableServices(): void { + this.showAvailableServices = !this.showAvailableServices; + } + + formatResolvedValue(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return `"${value}"`; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (typeof value === 'object') { + if ('__type__' in (value as object)) { + const typed = value as { __type__: string }; + return `${typed.__type__} {...}`; + } + return JSON.stringify(value).slice(0, 50) + (JSON.stringify(value).length > 50 ? '...' : ''); + } + return String(value); + } + + get hasRouteInfo(): boolean { + return !!(this.routeInfo?.currentRoute); + } + + get hasSlots(): boolean { + return (this.slotInfo?.slots?.length ?? 0) > 0; + } + + // Property editing + editProperty(property: Property & { originalValue?: unknown }) { + const editableTypes = ['string', 'number', 'boolean', 'bigint', 'null', 'undefined']; + if (editableTypes.includes(property.type) || property.canEdit) { + property.isEditing = true; + (property as any).originalValue = property.value; + } + } + + saveProperty(property: Property, newValue: string) { + const originalType = property.type; + let convertedValue: unknown = newValue; + + try { + switch (originalType) { + case 'number': { + const numValue = Number(newValue); + if (isNaN(numValue)) { + this.revertProperty(property); + return; + } + convertedValue = numValue; + break; + } + case 'boolean': { + const lower = newValue.toLowerCase(); + if (lower !== 'true' && lower !== 'false') { + this.revertProperty(property); + return; + } + convertedValue = lower === 'true'; + break; + } + case 'null': + convertedValue = newValue === 'null' || newValue === '' ? null : newValue; + if (convertedValue !== null) property.type = 'string'; + break; + case 'undefined': + convertedValue = newValue === 'undefined' || newValue === '' ? undefined : newValue; + if (convertedValue !== undefined) property.type = 'string'; + break; + default: + convertedValue = newValue; + property.type = 'string'; + break; + } + + property.value = convertedValue; + property.isEditing = false; + delete (property as any).originalValue; + + this.plat.queueMicrotask(() => { + this.debugHost.updateValues(this.selectedElement!, property); + this.markPropertyRowsDirty(); + }); + } catch { + this.revertProperty(property); + } + } + + cancelPropertyEdit(property: Property) { + this.revertProperty(property); + } + + private revertProperty(property: Property) { + property.value = (property as any).originalValue; + property.isEditing = false; + delete (property as any).originalValue; + } + + async copyPropertyValue(property: Property, event?: Event) { + event?.stopPropagation(); + + let valueToCopy: string; + if (property.value === null) { + valueToCopy = 'null'; + } else if (property.value === undefined) { + valueToCopy = 'undefined'; + } else if (typeof property.value === 'object') { + try { + valueToCopy = JSON.stringify(property.value, null, 2); + } catch { + valueToCopy = String(property.value); + } + } else { + valueToCopy = String(property.value); + } + + try { + await navigator.clipboard.writeText(valueToCopy); + this.copiedPropertyId = `${property.name}-${property.debugId || ''}`; + setTimeout(() => { + this.copiedPropertyId = null; + }, 1500); + } catch {} + } + + isPropertyCopied(property: Property): boolean { + return this.copiedPropertyId === `${property.name}-${property.debugId || ''}`; + } + + // Property expansion + togglePropertyExpansion(property: Property) { + if (!property.canExpand) return; + + if (!property.isExpanded) { + if (!property.expandedValue) { + this.loadExpandedPropertyValue(property); + } else { + property.isExpanded = true; + } + } else { + property.isExpanded = false; + } + this.markPropertyRowsDirty(); + } + + private loadExpandedPropertyValue(property: Property) { + if (property.debugId && chrome?.devtools) { + const code = `window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__.getExpandedDebugValueForId(${property.debugId})`; + + chrome.devtools.inspectedWindow.eval(code, (result: any, isException?: any) => { + if (isException) return; + + property.expandedValue = result; + property.isExpanded = true; + this.markPropertyRowsDirty(); + }); + } + } + + private markPropertyRowsDirty() { + this.propertyRowsRevision++; + } + + getPropertyRows(properties?: Property[], _revision: number = this.propertyRowsRevision): PropertyRow[] { + if (!properties?.length) return []; + return this.flattenProperties(properties, 0); + } + + private flattenProperties(properties: Property[], depth: number): PropertyRow[] { + const rows: PropertyRow[] = []; + for (const prop of properties) { + if (!prop) continue; + rows.push({ property: prop, depth }); + if (prop.isExpanded && prop.expandedValue?.properties?.length) { + rows.push(...this.flattenProperties(prop.expandedValue.properties, depth + 1)); + } + } + return rows; + } + + // Expression evaluation + async evaluateExpression() { + const expression = this.expressionInput.trim(); + if (!expression || !this.selectedElement) return; + + this.expressionError = ''; + this.expressionResult = ''; + this.expressionResultType = ''; + + if (!this.expressionHistory.includes(expression)) { + this.expressionHistory = [expression, ...this.expressionHistory.slice(0, 9)]; + } + + const componentKey = this.selectedElement.key || this.selectedElement.name; + if (!componentKey) { + this.expressionError = 'No component selected'; + return; + } + + const code = ` + (function() { + try { + var hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || !hook.evaluateInComponentContext) { + return { error: 'DevTools hook not available' }; + } + var result = hook.evaluateInComponentContext(${JSON.stringify(componentKey)}, ${JSON.stringify(expression)}); + return result; + } catch (e) { + return { error: e.message || String(e) }; + } + })() + `; + + if (chrome?.devtools?.inspectedWindow) { + chrome.devtools.inspectedWindow.eval(code, (result: any, isException?: any) => { + if (isException) { + this.expressionError = String(isException); + return; + } + + if (result?.error) { + this.expressionError = result.error; + return; + } + + if (result?.success) { + this.expressionResultType = result.type || typeof result.value; + try { + if (result.value === undefined) { + this.expressionResult = 'undefined'; + } else if (result.value === null) { + this.expressionResult = 'null'; + } else if (typeof result.value === 'object') { + this.expressionResult = JSON.stringify(result.value, null, 2); + } else { + this.expressionResult = String(result.value); + } + } catch { + this.expressionResult = String(result.value); + } + } else { + this.expressionError = 'Unknown response format'; + } + }); + } + } + + selectHistoryExpression(expr: string) { + this.expressionInput = expr; + } + + clearExpressionResult() { + this.expressionResult = ''; + this.expressionResultType = ''; + this.expressionError = ''; + } + + // Export + async exportComponentAsJson() { + if (!this.selectedElement) return; + + const exportData = { + meta: { + name: this.selectedElement.name, + type: this.selectedNodeType, + key: this.selectedElement.key, + exportedAt: new Date().toISOString(), + }, + bindables: this.serializeProperties(this.selectedElement.bindables || []), + properties: this.serializeProperties(this.selectedElement.properties || []), + customAttributes: this.selectedElementAttributes.map(attr => ({ + name: attr.name, + bindables: this.serializeProperties(attr.bindables || []), + properties: this.serializeProperties(attr.properties || []), + })), + }; + + const jsonString = JSON.stringify(exportData, null, 2); + + try { + await navigator.clipboard.writeText(jsonString); + this.copiedPropertyId = '__export__'; + setTimeout(() => { + this.copiedPropertyId = null; + }, 1500); + } catch {} + } + + private serializeProperties(properties: Property[]): Record { + const result: Record = {}; + for (const prop of properties) { + if (prop?.name) { + result[prop.name] = { value: prop.value, type: prop.type }; + } + } + return result; + } + + get isExportCopied(): boolean { + return this.copiedPropertyId === '__export__'; + } + + // Reveal in Elements + revealInElements() { + if (!this.selectedElement) return; + this.debugHost.revealInElements({ + name: this.selectedElement.name, + type: this.selectedNodeType, + customElementInfo: this.selectedNodeType === 'custom-element' ? this.selectedElement : null, + customAttributesInfo: this.selectedNodeType === 'custom-attribute' ? [this.selectedElement] : this.selectedElementAttributes, + }); + } + + // Format property value for display + formatPropertyValue(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return `"${value}"`; + if (typeof value === 'object') { + if (Array.isArray(value)) return `Array(${value.length})`; + return '{...}'; + } + return String(value); + } + + getPropertyTypeClass(type: string): string { + const typeMap: Record = { + string: 'type-string', + number: 'type-number', + boolean: 'type-boolean', + null: 'type-null', + undefined: 'type-null', + object: 'type-object', + array: 'type-object', + function: 'type-function', + }; + return typeMap[type] || 'type-default'; + } +} + +interface SearchResult { + key: string; + name: string; + type: 'custom-element' | 'custom-attribute'; +} + +interface PropertyRow { + property: Property; + depth: number; +} diff --git a/src/sidebar/sidebar-debug-host.ts b/src/sidebar/sidebar-debug-host.ts new file mode 100644 index 0000000..3968320 --- /dev/null +++ b/src/sidebar/sidebar-debug-host.ts @@ -0,0 +1,822 @@ +import { + AureliaInfo, + ComponentTreeNode, + ComputedPropertyInfo, + DISnapshot, + EnhancedDISnapshot, + IControllerInfo, + LifecycleHooksSnapshot, + Property, + PropertySnapshot, + RouteSnapshot, + SlotSnapshot, + TemplateSnapshot, + WatchOptions, +} from '../shared/types'; +import { SidebarApp } from './sidebar-app'; + +interface SearchResult { + key: string; + name: string; + type: 'custom-element' | 'custom-attribute'; +} + +export class SidebarDebugHost { + consumer: SidebarApp | null = null; + private pickerPollingInterval: number | null = null; + private propertyWatchInterval: number | null = null; + private lastPropertySnapshot: PropertySnapshot | null = null; + private watchingComponentKey: string | null = null; + private useEventDrivenWatching = false; + + attach(consumer: SidebarApp) { + this.consumer = consumer; + + if (chrome?.devtools) { + // Listen for element selection changes in the Elements panel + chrome.devtools.panels.elements.onSelectionChanged.addListener(() => { + if (this.consumer && this.consumer.followChromeSelection) { + this.getSelectedElementInfo(); + } + }); + + // Get initial selection + setTimeout(() => { + if (this.consumer?.followChromeSelection) { + this.getSelectedElementInfo(); + } + }, 500); + } + } + + private getSelectedElementInfo() { + if (!chrome?.devtools) return; + + const selectionEval = ` + (function() { + const target = typeof $0 !== 'undefined' ? $0 : null; + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || !target) { + return null; + } + + function getDomPath(element) { + if (!element || element.nodeType !== 1) return ''; + const segments = []; + let current = element; + while (current && current.nodeType === 1 && current !== document) { + const tag = current.tagName ? current.tagName.toLowerCase() : 'unknown'; + let index = 1; + let sibling = current; + while ((sibling = sibling.previousElementSibling)) { + if (sibling.tagName === current.tagName) index++; + } + segments.push(tag + ':nth-of-type(' + index + ')'); + current = current.parentElement; + } + segments.push('html'); + return segments.reverse().join(' > '); + } + + // Get component info - traverse=true means it will walk up the DOM + // to find the nearest Aurelia component/binding context + let info = null; + let isBindingContext = false; + const selectedTagName = target.tagName ? target.tagName.toLowerCase() : 'unknown'; + + if (hook.getCustomElementInfo) { + info = hook.getCustomElementInfo(target, false); + } + + // If no component found via getCustomElementInfo, try to find binding context + // by walking up the DOM manually looking for $au or .au properties + if (!info || (!info.customElementInfo && (!info.customAttributesInfo || !info.customAttributesInfo.length))) { + let el = target.parentElement; + while (el && el !== document.body) { + // Check for Aurelia v2 + if (el.$au) { + const auV2 = el.$au; + const customElement = auV2['au:resource:custom-element']; + if (customElement) { + // Re-call with this element to get proper info extraction + info = hook.getCustomElementInfo(el, false); + if (info) { + isBindingContext = true; + break; + } + } + } + // Check for Aurelia v1 + if (el.au && el.au.controller) { + info = hook.getCustomElementInfo(el, false); + if (info) { + isBindingContext = true; + break; + } + } + el = el.parentElement; + } + } + + if (!info) return null; + + const domPath = getDomPath(target); + info.__auDevtoolsDomPath = domPath; + info.__selectedElement = selectedTagName; + info.__isBindingContext = isBindingContext; + if (info.customElementInfo) { + info.customElementInfo.__auDevtoolsDomPath = domPath; + } + if (Array.isArray(info.customAttributesInfo)) { + info.customAttributesInfo.forEach(attr => { + if (attr) attr.__auDevtoolsDomPath = domPath; + }); + } + + return info; + })(); + `; + + chrome.devtools.inspectedWindow.eval(selectionEval, (info: AureliaInfo) => { + if (info && this.consumer) { + this.consumer.onElementPicked(info); + } + }); + } + + updateValues(componentInfo: IControllerInfo, property?: Property) { + if (!chrome?.devtools) return; + + chrome.devtools.inspectedWindow.eval( + `window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__.updateValues(${JSON.stringify( + componentInfo + )}, ${JSON.stringify(property)})` + ); + } + + // Element picker + startElementPicker() { + if (!chrome?.devtools) return; + + this.startPickerPolling(); + + const pickerCode = ` + (() => { + window.__aureliaDevtoolsStopPicker && window.__aureliaDevtoolsStopPicker(); + + let isPickerActive = true; + let currentHighlight = null; + + function getDomPath(element) { + if (!element || element.nodeType !== 1) return ''; + const segments = []; + let current = element; + while (current && current.nodeType === 1 && current !== document) { + const tag = current.tagName ? current.tagName.toLowerCase() : 'unknown'; + let index = 1; + let sibling = current; + while ((sibling = sibling.previousElementSibling)) { + if (sibling.tagName === current.tagName) index++; + } + segments.push(tag + ':nth-of-type(' + index + ')'); + current = current.parentElement; + } + segments.push('html'); + return segments.reverse().join(' > '); + } + + if (!document.getElementById('aurelia-picker-styles')) { + const style = document.createElement('style'); + style.id = 'aurelia-picker-styles'; + style.textContent = \` + .aurelia-picker-highlight { + outline: 2px solid #ff6b35 !important; + outline-offset: -2px !important; + background-color: rgba(255, 107, 53, 0.1) !important; + cursor: crosshair !important; + } + * { cursor: crosshair !important; } + \`; + document.head.appendChild(style); + } + + function highlightElement(element) { + if (currentHighlight) currentHighlight.classList.remove('aurelia-picker-highlight'); + element.classList.add('aurelia-picker-highlight'); + currentHighlight = element; + } + + function unhighlightElement() { + if (currentHighlight) { + currentHighlight.classList.remove('aurelia-picker-highlight'); + currentHighlight = null; + } + } + + function onMouseOver(event) { + if (!isPickerActive) return; + event.stopPropagation(); + highlightElement(event.target); + } + + function onMouseOut(event) { + if (!isPickerActive) return; + event.stopPropagation(); + unhighlightElement(); + } + + function onClick(event) { + if (!isPickerActive) return; + event.preventDefault(); + event.stopPropagation(); + + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (hook) { + const info = hook.getCustomElementInfo(event.target, true); + if (info) { + const domPath = getDomPath(event.target); + info.__auDevtoolsDomPath = domPath; + if (info.customElementInfo) info.customElementInfo.__auDevtoolsDomPath = domPath; + if (info.customAttributesInfo) { + info.customAttributesInfo.forEach(attr => { + if (attr) attr.__auDevtoolsDomPath = domPath; + }); + } + window.__AURELIA_DEVTOOLS_PICKED_COMPONENT__ = info; + } else { + window.__AURELIA_DEVTOOLS_PICKED_COMPONENT__ = null; + } + } + + window.__aureliaDevtoolsStopPicker(); + } + + document.addEventListener('mouseover', onMouseOver, true); + document.addEventListener('mouseout', onMouseOut, true); + document.addEventListener('click', onClick, true); + + window.__aureliaDevtoolsStopPicker = function() { + isPickerActive = false; + unhighlightElement(); + document.removeEventListener('mouseover', onMouseOver, true); + document.removeEventListener('mouseout', onMouseOut, true); + document.removeEventListener('click', onClick, true); + const styles = document.getElementById('aurelia-picker-styles'); + if (styles) styles.remove(); + delete window.__aureliaDevtoolsStopPicker; + }; + + return true; + })(); + `; + + chrome.devtools.inspectedWindow.eval(pickerCode); + } + + stopElementPicker() { + if (!chrome?.devtools) return; + + this.stopPickerPolling(); + + chrome.devtools.inspectedWindow.eval( + `window.__aureliaDevtoolsStopPicker && window.__aureliaDevtoolsStopPicker();` + ); + } + + private startPickerPolling() { + this.stopPickerPolling(); + this.pickerPollingInterval = setInterval(() => { + this.checkForPickedComponent(); + }, 100) as unknown as number; + } + + private stopPickerPolling() { + if (this.pickerPollingInterval) { + clearInterval(this.pickerPollingInterval); + this.pickerPollingInterval = null; + } + } + + private checkForPickedComponent() { + if (!chrome?.devtools) return; + + chrome.devtools.inspectedWindow.eval( + `(() => { + const picked = window.__AURELIA_DEVTOOLS_PICKED_COMPONENT__; + if (picked) { + window.__AURELIA_DEVTOOLS_PICKED_COMPONENT__ = null; + return picked; + } + return null; + })();`, + (info: AureliaInfo) => { + if (info && this.consumer) { + this.stopPickerPolling(); + this.consumer.onElementPicked(info); + } + } + ); + } + + // Property watching + startPropertyWatching(options: WatchOptions) { + this.stopPropertyWatching(); + this.watchingComponentKey = options.componentKey; + + this.tryEventDrivenWatching(options.componentKey).then(success => { + if (success) { + this.useEventDrivenWatching = true; + } else { + this.useEventDrivenWatching = false; + const interval = options.pollInterval || 500; + + this.getPropertySnapshot(options.componentKey).then(snapshot => { + this.lastPropertySnapshot = snapshot; + }); + + this.propertyWatchInterval = setInterval(() => { + this.checkForPropertyChanges(); + }, interval) as unknown as number; + } + }); + } + + private tryEventDrivenWatching(componentKey: string): Promise { + return new Promise(resolve => { + if (!chrome?.devtools) { + resolve(false); + return; + } + + const expr = ` + (function() { + try { + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || typeof hook.attachPropertyWatchers !== 'function') return false; + return hook.attachPropertyWatchers(${JSON.stringify(componentKey)}); + } catch { return false; } + })(); + `; + + chrome.devtools.inspectedWindow.eval(expr, (result: boolean) => { + resolve(result === true); + }); + }); + } + + stopPropertyWatching() { + if (this.propertyWatchInterval) { + clearInterval(this.propertyWatchInterval); + this.propertyWatchInterval = null; + } + + if (this.useEventDrivenWatching && this.watchingComponentKey && chrome?.devtools) { + const componentKey = this.watchingComponentKey; + chrome.devtools.inspectedWindow.eval(` + (function() { + try { + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (hook && typeof hook.detachPropertyWatchers === 'function') { + hook.detachPropertyWatchers(${JSON.stringify(componentKey)}); + } + } catch {} + })(); + `); + } + + this.watchingComponentKey = null; + this.lastPropertySnapshot = null; + this.useEventDrivenWatching = false; + } + + private checkForPropertyChanges() { + if (!this.watchingComponentKey) return; + + this.getPropertySnapshot(this.watchingComponentKey).then(snapshot => { + if (!snapshot || !this.lastPropertySnapshot) { + this.lastPropertySnapshot = snapshot; + return; + } + + const changes = this.diffPropertySnapshots(this.lastPropertySnapshot, snapshot); + if (changes.length > 0) { + this.lastPropertySnapshot = snapshot; + if (this.consumer) { + this.consumer.onPropertyChanges(changes, snapshot); + } + } + }); + } + + private diffPropertySnapshots(oldSnap: PropertySnapshot, newSnap: PropertySnapshot) { + const changes: any[] = []; + + const compare = (oldProps: any[], newProps: any[], type: string) => { + const oldMap = new Map(oldProps.map(p => [p.name, p])); + const newMap = new Map(newProps.map(p => [p.name, p])); + + for (const [name, newProp] of newMap) { + const oldProp = oldMap.get(name); + if (!oldProp || !this.valuesEqual(oldProp.value, newProp.value)) { + changes.push({ + componentKey: newSnap.componentKey, + propertyName: name, + propertyType: type, + oldValue: oldProp?.value, + newValue: newProp.value, + timestamp: newSnap.timestamp, + }); + } + } + }; + + compare(oldSnap.bindables, newSnap.bindables, 'bindable'); + compare(oldSnap.properties, newSnap.properties, 'property'); + + return changes; + } + + private valuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (a === null || b === null) return a === b; + if (typeof a === 'object') { + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } + } + return false; + } + + getPropertySnapshot(componentKey: string): Promise { + return new Promise(resolve => { + if (!chrome?.devtools) { + resolve(null); + return; + } + + const expr = ` + (function() { + try { + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || !hook.getComponentByKey) return null; + const info = hook.getComponentByKey(${JSON.stringify(componentKey)}); + if (!info) return null; + + const serialize = val => { + if (val === null) return { value: null, type: 'null' }; + if (val === undefined) return { value: undefined, type: 'undefined' }; + const t = typeof val; + if (t === 'function') return { value: '[Function]', type: 'function' }; + if (t === 'object') { + try { + return { value: JSON.parse(JSON.stringify(val)), type: Array.isArray(val) ? 'array' : 'object' }; + } catch { return { value: '[Object]', type: 'object' }; } + } + return { value: val, type: t }; + }; + + return { + componentKey: ${JSON.stringify(componentKey)}, + bindables: (info.bindables || []).map(b => ({ name: b.name, ...serialize(b.value) })), + properties: (info.properties || []).map(p => ({ name: p.name, ...serialize(p.value) })), + timestamp: Date.now() + }; + } catch { return null; } + })(); + `; + + chrome.devtools.inspectedWindow.eval(expr, (result: PropertySnapshot | null) => { + resolve(result); + }); + }); + } + + // Search + searchComponents(query: string): Promise { + return new Promise(resolve => { + if (!chrome?.devtools) { + resolve([]); + return; + } + + const expr = ` + (function() { + try { + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (!hook) return []; + + const results = []; + const query = ${JSON.stringify(query.toLowerCase())}; + + const processTree = (nodes) => { + if (!nodes) return; + for (const node of nodes) { + if (node.customElementInfo) { + const name = node.customElementInfo.name || ''; + if (name.toLowerCase().includes(query)) { + results.push({ + key: node.customElementInfo.key || name, + name: name, + type: 'custom-element' + }); + } + } + if (node.customAttributesInfo) { + for (const attr of node.customAttributesInfo) { + if (attr && attr.name && attr.name.toLowerCase().includes(query)) { + results.push({ + key: attr.key || attr.name, + name: attr.name, + type: 'custom-attribute' + }); + } + } + } + if (node.children) processTree(node.children); + } + }; + + if (hook.getComponentTree) { + processTree(hook.getComponentTree() || []); + } else if (hook.getAllInfo) { + const flat = hook.getAllInfo() || []; + for (const item of flat) { + if (item.customElementInfo) { + const name = item.customElementInfo.name || ''; + if (name.toLowerCase().includes(query)) { + results.push({ + key: item.customElementInfo.key || name, + name: name, + type: 'custom-element' + }); + } + } + } + } + + return results.slice(0, 20); + } catch { return []; } + })(); + `; + + chrome.devtools.inspectedWindow.eval(expr, (results: SearchResult[]) => { + resolve(Array.isArray(results) ? results : []); + }); + }); + } + + selectComponentByKey(key: string): void { + if (!chrome?.devtools) return; + + const expr = ` + (function() { + try { + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (!hook) return null; + + const targetKey = ${JSON.stringify(key)}; + let result = null; + + // Try getComponentByKey first if available + if (hook.getComponentByKey) { + const info = hook.getComponentByKey(targetKey); + if (info) { + result = { customElementInfo: info, customAttributesInfo: [] }; + } + } + + // Otherwise search through the tree + if (!result) { + const findInTree = (nodes) => { + if (!nodes) return null; + for (const node of nodes) { + if (node.customElementInfo) { + const nodeKey = node.customElementInfo.key || node.customElementInfo.name; + if (nodeKey === targetKey) { + return { + customElementInfo: node.customElementInfo, + customAttributesInfo: node.customAttributesInfo || [] + }; + } + } + if (node.customAttributesInfo) { + for (const attr of node.customAttributesInfo) { + const attrKey = attr.key || attr.name; + if (attrKey === targetKey) { + return { + customElementInfo: null, + customAttributesInfo: [attr] + }; + } + } + } + if (node.children) { + const found = findInTree(node.children); + if (found) return found; + } + } + return null; + }; + + if (hook.getComponentTree) { + result = findInTree(hook.getComponentTree() || []); + } + } + + // Fallback to flat list + if (!result && hook.getAllInfo) { + const flat = hook.getAllInfo() || []; + for (const item of flat) { + if (item.customElementInfo) { + const nodeKey = item.customElementInfo.key || item.customElementInfo.name; + if (nodeKey === targetKey) { + result = { + customElementInfo: item.customElementInfo, + customAttributesInfo: item.customAttributesInfo || [] + }; + break; + } + } + } + } + + // If found, also select the element in the Elements panel + if (result && hook.findElementByComponentInfo) { + const el = hook.findElementByComponentInfo(result); + if (el) { + inspect(el); + } + } + + return result; + } catch { return null; } + })(); + `; + + chrome.devtools.inspectedWindow.eval(expr, (info: AureliaInfo) => { + if (info && this.consumer) { + this.consumer.onElementPicked(info); + } + }); + } + + // Reveal in Elements + revealInElements(componentInfo: any) { + if (!chrome?.devtools) return; + + const code = `(() => { + try { + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || !hook.findElementByComponentInfo) return false; + const el = hook.findElementByComponentInfo(${JSON.stringify(componentInfo)}); + if (el) { + inspect(el); + return true; + } + return false; + } catch { return false; } + })();`; + + chrome.devtools.inspectedWindow.eval(code); + } + + // Enhanced info methods + getLifecycleHooks(componentKey: string): Promise { + return this.evalHook('getLifecycleHooks', componentKey); + } + + getComputedProperties(componentKey: string): Promise { + return this.evalHook('getComputedProperties', componentKey).then(r => r || []); + } + + getDependencies(componentKey: string): Promise { + return this.evalHook('getDependencies', componentKey); + } + + getEnhancedDISnapshot(componentKey: string): Promise { + return this.evalHook('getEnhancedDISnapshot', componentKey); + } + + getRouteInfo(componentKey: string): Promise { + return this.evalHook('getRouteInfo', componentKey); + } + + getSlotInfo(componentKey: string): Promise { + return this.evalHook('getSlotInfo', componentKey); + } + + getTemplateSnapshot(componentKey: string): Promise { + return this.evalHook('getTemplateSnapshot', componentKey); + } + + getComponentTree(): Promise { + return new Promise(resolve => { + if (!chrome?.devtools) { + resolve([]); + return; + } + + const expr = ` + (function() { + try { + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || typeof hook.getSimplifiedComponentTree !== 'function') return []; + return hook.getSimplifiedComponentTree(); + } catch { return []; } + })(); + `; + + chrome.devtools.inspectedWindow.eval(expr, (result: ComponentTreeNode[]) => { + resolve(Array.isArray(result) ? result : []); + }); + }); + } + + // Timeline / Interaction recording + startInteractionRecording(): Promise { + return new Promise(resolve => { + if (!chrome?.devtools) { + resolve(false); + return; + } + + const expr = ` + (function() { + try { + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || typeof hook.startInteractionRecording !== 'function') return false; + return hook.startInteractionRecording(); + } catch { return false; } + })(); + `; + + chrome.devtools.inspectedWindow.eval(expr, (result: boolean) => { + resolve(result === true); + }); + }); + } + + stopInteractionRecording(): Promise { + return new Promise(resolve => { + if (!chrome?.devtools) { + resolve(false); + return; + } + + const expr = ` + (function() { + try { + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || typeof hook.stopInteractionRecording !== 'function') return false; + return hook.stopInteractionRecording(); + } catch { return false; } + })(); + `; + + chrome.devtools.inspectedWindow.eval(expr, (result: boolean) => { + resolve(result === true); + }); + }); + } + + clearInteractionLog(): void { + if (!chrome?.devtools) return; + + chrome.devtools.inspectedWindow.eval(` + (function() { + try { + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (hook && typeof hook.clearInteractionLog === 'function') { + hook.clearInteractionLog(); + } + } catch {} + })(); + `); + } + + private evalHook(method: string, componentKey: string): Promise { + return new Promise(resolve => { + if (!chrome?.devtools) { + resolve(null); + return; + } + + const expr = ` + (function() { + try { + const hook = window.__AURELIA_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || typeof hook.${method} !== 'function') return null; + return hook.${method}(${JSON.stringify(componentKey)}); + } catch { return null; } + })(); + `; + + chrome.devtools.inspectedWindow.eval(expr, (result: T | null) => { + resolve(result); + }); + }); + } +} diff --git a/src/styles.css b/src/styles.css deleted file mode 100644 index dd44e4c..0000000 --- a/src/styles.css +++ /dev/null @@ -1,1872 +0,0 @@ -@import "tailwindcss"; - -@theme { - /* Design System Colors - Light Theme */ - --color-background: #ffffff; - --color-foreground: #202124; - --color-muted: #f8f9fa; - --color-muted-foreground: #5f6368; - --color-accent: #1967d2; - --color-accent-hover: #1557b0; - --color-accent-light: #8ab4f8; - --color-border: #dadce0; - --color-border-light: #e8eaed; - --color-border-subtle: #f1f3f4; - --color-danger: #d93025; - --color-warning: #e36209; - --color-success: #1e8e3e; - - /* Dark Theme Colors */ - --color-dark-background: #202124; - --color-dark-foreground: #e8eaed; - --color-dark-muted: #2d2e30; - --color-dark-muted-foreground: #9aa0a6; - --color-dark-accent: #8ab4f8; - --color-dark-accent-hover: #aecbfa; - --color-dark-border: #5f6368; - --color-dark-border-light: #3c4043; - --color-dark-border-subtle: #48494a; - --color-dark-danger: #f28b82; - --color-dark-warning: #fdd663; - --color-dark-success: #81c995; - - /* Syntax Highlighting Colors */ - --color-syntax-string: #d73a49; - --color-syntax-number: #005cc5; - --color-syntax-boolean: #e36209; - --color-syntax-null: #6f42c1; - --color-syntax-object: #24292e; - --color-syntax-property: #7c4dff; - - /* Dark Syntax Colors */ - --color-dark-syntax-string: #f28b82; - --color-dark-syntax-number: #8ab4f8; - --color-dark-syntax-boolean: #fdd663; - --color-dark-syntax-null: #c58af9; - --color-dark-syntax-object: #e8eaed; - --color-dark-syntax-property: #bb86fc; - - /* Typography */ - --font-family-sans: "Segoe UI", system-ui, -apple-system, sans-serif; - --font-family-mono: "Roboto Mono", "Consolas", monospace; - - /* Sizing */ - --size-header: 32px; - --size-tab: 24px; - --size-icon-sm: 12px; - --size-icon: 14px; - --size-icon-md: 16px; - --size-icon-lg: 18px; - --size-tree-indent: 16px; - --size-tree-expand: 20px; - --size-splitter: 4px; - - /* Spacing */ - --space-xs: 2px; - --space-sm: 4px; - --space-md: 6px; - --space-lg: 8px; - --space-xl: 12px; - --space-2xl: 16px; - --space-3xl: 24px; - --space-4xl: 40px; - - /* Radius */ - --radius-sm: 2px; - --radius: 4px; - --radius-lg: 8px; - - /* Shadows */ - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); - --shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); - - /* Transitions */ - --duration-fast: 0.1s; - --duration-normal: 0.2s; - --timing: ease; -} - -/* Base Styles */ -@layer base { - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - body { - font-family: var(--font-family-sans); - font-size: 12px; - line-height: 1.5; - background: var(--color-background); - color: var(--color-foreground); - overflow: hidden; - } - - button, input, optgroup, select, textarea { - font-family: var(--font-family-sans); - font-size: 100%; - line-height: 1.15; - margin: 0; - } - - button, input { - overflow: visible; - } - - button, select { - text-transform: none; - } - - button, [type="button"], [type="reset"], [type="submit"] { - -webkit-appearance: button; - } - - button::-moz-focus-inner, [type="button"]::-moz-focus-inner, - [type="reset"]::-moz-focus-inner, [type="submit"]::-moz-focus-inner { - border-style: none; - padding: 0; - } - - button:-moz-focusring, [type="button"]:-moz-focusring, - [type="reset"]:-moz-focusring, [type="submit"]:-moz-focusring { - outline: 1px dotted ButtonText; - } -} - -/* Component Classes */ -@layer components { - /* DevTools Root */ - .devtools-root { - @apply h-screen bg-white text-gray-900 flex flex-col overflow-hidden; - font-family: var(--font-family-sans); - font-size: 12px; - } - - .devtools-root.dark { - @apply bg-gray-900 text-gray-100; - } - - /* Header */ - .devtools-header { - @apply bg-gray-50 border-b border-gray-200 px-4 py-2 flex items-center justify-between flex-shrink-0; - min-height: var(--size-header); - } - - .dark .devtools-header { - @apply bg-gray-800 border-gray-600; - } - - .header-content { - @apply flex items-center gap-2 min-w-0 flex-1; - } - - .header-title { - @apply font-medium text-sm text-blue-600 whitespace-nowrap; - } - - .dark .header-title { - @apply text-blue-400; - } - - .header-info { - @apply flex items-center gap-1.5 text-gray-500 text-xs flex-1 min-w-0 justify-end; - } - - .dark .header-info { - @apply text-gray-400; - } - - /* Tab Bar */ - .tab-bar { - @apply bg-gray-50 border-b border-gray-200 flex px-4 flex-shrink-0; - } - - .dark .tab-bar { - @apply bg-gray-800 border-gray-600; - } - - .tab-button { - @apply bg-transparent border-none py-2 px-3 mr-2 flex items-center gap-1.5 text-xs cursor-pointer - border-b-2 border-transparent text-gray-600 font-medium transition-all duration-200; - } - - .tab-button:hover { - @apply bg-gray-100 rounded-t; - } - - .tab-button.active { - @apply text-blue-600 border-blue-600 bg-white -mb-px; - } - - .dark .tab-button { - @apply text-gray-400; - } - - .dark .tab-button:hover { - @apply bg-gray-700; - } - - .dark .tab-button.active { - @apply text-blue-400 border-blue-400 bg-gray-900; - } - - /* Main Content */ - .devtools-main { - @apply flex-1 overflow-hidden p-0; - } - - /* Split Pane Layout */ - .components-split-pane { - @apply flex flex-1 overflow-hidden; - } - - .components-tree-panel { - @apply flex-shrink-0 w-80 min-w-48 max-w-96 flex flex-col border-r border-gray-200 bg-white; - } - - /* Add custom scrollbar styling to tree panel scrollable areas */ - .components-tree-panel .flex-1.overflow-y-auto { - scrollbar-width: thin; - scrollbar-color: rgba(0, 0, 0, 0.2) transparent; - } - - .components-tree-panel .flex-1.overflow-y-auto::-webkit-scrollbar { - width: 6px; - } - - .components-tree-panel .flex-1.overflow-y-auto::-webkit-scrollbar-track { - background: transparent; - } - - .components-tree-panel .flex-1.overflow-y-auto::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.2); - border-radius: 3px; - transition: background-color 0.2s ease; - } - - .components-tree-panel .flex-1.overflow-y-auto::-webkit-scrollbar-thumb:hover { - background-color: rgba(0, 0, 0, 0.4); - } - - .dark .components-tree-panel .flex-1.overflow-y-auto { - scrollbar-color: rgba(255, 255, 255, 0.3) transparent; - } - - .dark .components-tree-panel .flex-1.overflow-y-auto::-webkit-scrollbar-thumb { - background-color: rgba(255, 255, 255, 0.3); - } - - .dark .components-tree-panel .flex-1.overflow-y-auto::-webkit-scrollbar-thumb:hover { - background-color: rgba(255, 255, 255, 0.5); - } - - .dark .components-tree-panel { - @apply bg-gray-900 border-gray-600; - } - - .splitter-vertical { - @apply w-1 bg-gray-100 cursor-col-resize flex-shrink-0 border-l border-r border-gray-200; - } - - .splitter-vertical:hover { - @apply bg-gray-200; - } - - .dark .splitter-vertical { - @apply bg-gray-700 border-gray-600; - } - - .dark .splitter-vertical:hover { - @apply bg-gray-600; - } - - .component-details-panel { - @apply flex-1 flex flex-col bg-white overflow-hidden; - } - - .dark .component-details-panel { - @apply bg-gray-900; - } - - .component-details { - @apply h-full flex flex-col; - } - - /* Toolbar */ - .components-toolbar { - @apply bg-white border-b border-gray-200 p-2 flex flex-col gap-2 flex-shrink-0; - } - - .dark .components-toolbar { - @apply bg-gray-900 border-gray-600; - } - - .toolbar-top { - @apply flex items-center gap-3; - } - - .toolbar-button { - @apply bg-gray-50 border border-gray-200 rounded px-2 py-1.5 flex items-center gap-1 text-xs - cursor-pointer text-gray-700 transition-colors; - } - - .toolbar-button.icon-only { - @apply px-1.5 py-1; - } - - /* Refresh animation with subtle scale-in */ - @keyframes spinScale { - 0% { transform: scale(0.9) rotate(0deg); } - 10% { transform: scale(1.0) rotate(36deg); } - 100% { transform: scale(1.0) rotate(360deg); } - } - .toolbar-button.spinning .refresh-icon { - animation: spinScale 0.9s linear infinite; - transform-origin: 50% 50%; - will-change: transform; - } - - .toolbar-button:hover { - @apply bg-gray-100 border-gray-300; - } - - .toolbar-button.active { - @apply bg-blue-600 border-blue-600 text-white; - } - - .toolbar-button.active:hover { - @apply bg-blue-700 border-blue-700; - } - - .dark .toolbar-button { - @apply bg-gray-700 border-gray-600 text-gray-100; - } - - .dark .toolbar-button:hover { - @apply bg-gray-600 border-blue-400; - } - - .dark .toolbar-button.active { - @apply bg-blue-400 border-blue-400 text-gray-900; - } - - .dark .toolbar-button.active:hover { - @apply bg-blue-300 border-blue-300; - } - - .toggle { - @apply flex items-center gap-1 text-xs text-gray-600; - } - - /* Search */ - .search-container { - @apply relative flex items-center w-full; - } - - .search-input { - @apply flex-1 border border-gray-200 rounded px-7 py-1.5 text-xs bg-white text-gray-900 min-w-0; - } - - .search-input:focus { - @apply outline-none border-blue-600 shadow-sm; - box-shadow: 0 0 0 1px var(--color-accent); - } - - .search-input::placeholder { - @apply text-gray-400; - } - - .dark .search-input { - @apply bg-gray-800 border-gray-600 text-gray-100; - } - - .dark .search-input:focus { - @apply border-blue-400; - box-shadow: 0 0 0 1px var(--color-dark-accent); - } - - .dark .search-input::placeholder { - @apply text-gray-400; - } - - .search-icon { - @apply absolute left-2 text-gray-600 pointer-events-none z-10; - } - - .dark .search-icon { - @apply text-gray-400; - } - - .clear-search { - @apply absolute right-1.5 bg-transparent border-none p-0.5 cursor-pointer text-gray-600 - rounded-sm flex items-center justify-center; - } - - .clear-search:hover { - @apply bg-black/10 text-gray-900; - } - - .dark .clear-search { - @apply text-gray-400; - } - - .dark .clear-search:hover { - @apply bg-white/10 text-gray-100; - } - - /* Component Tree */ - .component-tree { - @apply py-2; - } - - .component-tree-row { - @apply w-full; - } - - .component-item { - @apply flex items-center gap-2 py-1 pr-4 cursor-pointer transition-colors min-h-6 rounded-sm; - } - - .component-item:hover { - @apply bg-gray-50; - } - - .component-item.selected { - @apply bg-blue-50 text-blue-600 font-medium; - } - - .component-item.selected .component-name.custom-attribute { - @apply text-red-600; - } - - .dark .component-item:hover { - @apply bg-gray-800; - } - - .dark .component-item.selected { - @apply bg-blue-900 text-blue-400; - } - - .dark .component-item.selected .component-name.custom-attribute { - @apply text-red-300; - } - - .expand-toggle { - @apply bg-transparent border-none p-0 mr-1 w-3 h-3 flex items-center justify-center - cursor-pointer text-gray-600; - } - - .expand-toggle[disabled] { - @apply cursor-default opacity-60; - } - - .expand-toggle:hover { - @apply bg-black/10 rounded-sm; - } - - .dark .expand-toggle { - @apply text-gray-400; - } - - .dark .expand-toggle:hover { - @apply bg-white/10; - } - - .expand-icon { - @apply transition-transform duration-100; - } - - .expand-icon.expanded { - @apply rotate-90; - } - - .component-icon { - @apply flex items-center flex-shrink-0; - } - - .component-icon.custom-element { - @apply text-blue-600; - } - - .component-icon.custom-attribute { - @apply text-red-600; - } - - .dark .component-icon.custom-element { - @apply text-blue-400; - } - - .dark .component-icon.custom-attribute { - @apply text-red-400; - } - - .component-name { - @apply text-xs flex-1 min-w-0; - font-family: var(--font-family-mono); - } - - .component-name.custom-element { - @apply text-blue-600 font-medium; - text-decoration: underline; - text-decoration-style: dashed; - text-decoration-color: rgba(37, 99, 235, 0.5); - text-underline-offset: 3px; - } - - .component-name.custom-attribute { - @apply text-red-600 italic; - text-decoration: underline; - text-decoration-style: dotted; - text-decoration-color: rgba(220, 38, 38, 0.5); - text-underline-offset: 3px; - } - - .dark .component-name.custom-element { - @apply text-blue-400; - text-decoration-color: rgba(96, 165, 250, 0.5); - } - - .dark .component-name.custom-attribute { - @apply text-red-400; - text-decoration-color: rgba(248, 113, 113, 0.5); - } - - .component-badge { - @apply bg-blue-50 text-blue-600 text-[10px] font-semibold px-1 rounded-full ml-2 min-w-4 text-center; - } - - .dark .component-badge { - @apply bg-blue-900 text-blue-400; - } - - .view-mode-toggle { - @apply flex items-center gap-1 ml-2; - } - - .view-mode-icon { - @apply text-sm leading-none; - } - - .component-breadcrumbs { - @apply flex items-center gap-1 text-[11px] text-gray-500 mb-2 flex-wrap; - } - - .component-breadcrumbs .breadcrumb-item { - @apply bg-transparent border-none px-1 py-0.5 rounded-sm cursor-pointer text-gray-600; - } - - .component-breadcrumbs .breadcrumb-item.custom-element { - @apply text-blue-600; - } - - .component-breadcrumbs .breadcrumb-item.custom-attribute { - @apply text-red-600 italic; - } - - .component-breadcrumbs .breadcrumb-item:hover { - @apply bg-gray-100 text-gray-800; - } - - .component-breadcrumbs .breadcrumb-item.active { - @apply font-medium bg-blue-50; - } - - .dark .component-breadcrumbs { - @apply text-gray-400; - } - - .dark .component-breadcrumbs .breadcrumb-item { - @apply text-gray-300; - } - - .dark .component-breadcrumbs .breadcrumb-item.custom-element { - @apply text-blue-300; - } - - .dark .component-breadcrumbs .breadcrumb-item.custom-attribute { - @apply text-red-300 italic; - } - - .dark .component-breadcrumbs .breadcrumb-item:hover { - @apply bg-white/10 text-gray-100; - } - - .dark .component-breadcrumbs .breadcrumb-item.active { - @apply font-medium bg-blue-900; - } - - .breadcrumb-separator { - @apply text-gray-400 text-xs; - } - - .dark .breadcrumb-separator { - @apply text-gray-500; - } - - .dark .component-children { - @apply border-gray-700; - } - - /* Properties */ - .properties-section { - @apply mb-4 border border-gray-200 rounded-lg overflow-hidden; - } - - /* Visual separator between property groups */ - .properties-separator { - @apply my-2 border-t border-gray-200; - } - - .dark .properties-separator { - @apply border-gray-700; - } - - .properties-section:first-child { - @apply mt-0; - } - - .properties-section:last-child { - @apply mb-0; - } - - .dark .properties-section { - @apply border-gray-600; - } - - .section-header { - @apply bg-gray-50 px-3 py-1 border-b border-gray-200 flex items-center gap-1.5 font-medium - text-xs uppercase tracking-wide; - } - - .dark .section-header { - @apply bg-gray-800 border-gray-600; - } - - .section-title { - @apply text-gray-900; - } - - .dark .section-title { - @apply text-gray-100; - } - - .property-count { - @apply text-gray-600 font-normal; - } - - .dark .property-count { - @apply text-gray-400; - } - - .section-content { - @apply bg-white; - } - - .dark .section-content { - @apply bg-gray-900; - } - - .property-item { - @apply border-b border-gray-100 px-3 py-0.5; - } - - .property-item:last-child { - @apply border-b-0; - } - - .dark .property-item { - @apply border-gray-700; - } - - /* Property Values */ - .property-row { - @apply text-xs; - line-height: 1.35; - } - - .property-main { - @apply flex items-center py-0 min-h-4 gap-1.5; - } - - .property-main.expandable { - @apply cursor-pointer; - } - - .property-main.expandable:hover .property-name { - @apply text-blue-600; - } - - .dark .property-main.expandable:hover .property-name { - @apply text-blue-300; - } - - .property-name { - @apply font-medium mr-1 flex-shrink-0; - font-family: var(--font-family-mono); - color: var(--color-syntax-property); - } - - .dark .property-name { - color: var(--color-dark-syntax-property); - } - - .property-separator { - @apply text-gray-600 mr-1.5; - font-family: var(--font-family-mono); - } - - .dark .property-separator { - @apply text-gray-400; - } - - .property-value-wrapper { - @apply flex-1 relative; - } - - .property-value { - @apply cursor-pointer rounded-sm px-0.5 py-0 -mx-0.5 -my-0.5 break-words; - font-family: var(--font-family-mono); - } - - .property-value:hover { - @apply bg-black/5; - } - - .property-value.editable { - @apply border border-transparent transition-all duration-100; - } - - .property-value.editable:hover { - @apply bg-blue-50 border-blue-200; - } - - .property-value.readonly { - @apply cursor-default opacity-70; - } - - .property-value.readonly:hover { - @apply bg-transparent; - } - - .property-value.string { - color: var(--color-syntax-string); - } - - .property-value.number { - color: var(--color-syntax-number); - } - - .property-value.boolean { - color: var(--color-syntax-boolean); - @apply font-medium; - } - - .property-value.null, - .property-value.undefined { - color: var(--color-syntax-null); - @apply italic; - } - - .property-value.object, - .property-value.array, - .property-value.node { - color: var(--color-syntax-object); - @apply italic; - } - - .property-value.binding { - color: var(--color-syntax-object); - @apply italic; - } - - .property-value.function { - color: var(--color-syntax-boolean); - @apply font-medium italic; - } - - .property-expression { - @apply inline-flex items-center ml-2 text-gray-500 text-[11px] gap-1; - font-family: var(--font-family-mono); - opacity: 0.85; - } - - .dark .property-expression { - @apply text-gray-400; - } - - .expression-label { - @apply text-gray-400 text-[10px] font-normal; - font-family: var(--font-family-sans); - } - - .dark .expression-label { - @apply text-gray-500; - } - - .expression-code { - @apply px-1 py-0.5 rounded text-[11px]; - background: rgba(0, 0, 0, 0.05); - color: var(--color-syntax-property); - font-family: var(--font-family-mono); - word-break: break-all; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .dark .expression-code { - background: rgba(255, 255, 255, 0.05); - color: var(--color-dark-syntax-property); - } - - .expand-button { - @apply flex items-center justify-center w-5 h-5 mr-2 rounded-sm border border-transparent text-gray-500; - } - - .expand-button:hover { - @apply bg-gray-200 border-gray-300; - } - - .dark .expand-button { - @apply text-gray-300; - } - - .dark .expand-button:hover { - @apply bg-gray-700 border-gray-600; - } - - .expand-button svg { - pointer-events: none; - } - - .dark .property-value:hover { - @apply bg-white/5; - } - - .dark .property-value.editable:hover { - @apply bg-blue-400/10 border-blue-400/30; - } - - .dark .property-value.readonly:hover { - @apply bg-transparent; - } - - .dark .property-value.string { - color: var(--color-dark-syntax-string); - } - - .dark .property-value.number { - color: var(--color-dark-syntax-number); - } - - .dark .property-value.boolean { - color: var(--color-dark-syntax-boolean); - } - - .dark .property-value.null, - .dark .property-value.undefined { - color: var(--color-dark-syntax-null); - } - - .dark .property-value.object, - .dark .property-value.array, - .dark .property-value.node { - color: var(--color-dark-syntax-object); - } - - .dark .property-value.binding { - color: var(--color-dark-syntax-object); - } - - .dark .property-value.function { - color: var(--color-dark-syntax-boolean); - } - - .property-editor { - @apply border border-blue-500 rounded px-1 py-0.5 outline-none bg-white w-full; - font-family: var(--font-family-mono); - font-size: 12px; - box-shadow: var(--shadow-sm); - } - - .dark .property-editor { - @apply bg-gray-700 border-blue-400 text-gray-100; - } - - .property-children { - @apply ml-4 border-l border-gray-100 pl-2 mt-0.5; - } - - .dark .property-children { - @apply border-gray-700; - } - - /* Empty States */ - .empty-state { - @apply p-3 text-gray-600 italic text-xs text-center; - } - - .dark .empty-state { - @apply text-gray-400; - } - - .empty-object { - @apply text-gray-600 italic text-xs py-0.5; - } - - .dark .empty-object { - @apply text-gray-400; - } - - .empty-components { - @apply flex flex-col items-center justify-center h-full p-10 text-center; - } - - .empty-components .empty-icon { - @apply text-5xl mb-4 opacity-50; - } - - .empty-components h3 { - @apply text-base font-normal mb-2 text-gray-600; - } - - .empty-components p { - @apply text-xs text-gray-500 mb-4 max-w-xs; - } - - .dark .empty-components h3 { - @apply text-gray-400; - } - - .dark .empty-components p { - @apply text-gray-500; - } - - .refresh-button { - @apply bg-blue-600 text-white border-none rounded px-4 py-2 text-xs cursor-pointer; - } - - .refresh-button:hover { - @apply bg-blue-700; - } - - .dark .refresh-button { - @apply bg-blue-400 text-gray-900; - } - - .dark .refresh-button:hover { - @apply bg-blue-300; - } - - /* Details Panel */ - .empty-details { - @apply flex-1 flex items-center justify-center p-10; - } - - .empty-details-content { - @apply text-center max-w-xs; - } - - .empty-details-icon { - @apply mb-4 text-gray-600 text-center flex items-center justify-center; - } - - .empty-details h3 { - @apply text-base font-normal mb-2 text-gray-600; - } - - .empty-details p { - @apply text-xs text-gray-500 leading-6; - } - - .dark .empty-details-icon { - @apply text-gray-400; - } - - .dark .empty-details h3 { - @apply text-gray-400; - } - - .dark .empty-details p { - @apply text-gray-500; - } - - .details-header { - @apply bg-gray-50 border-b border-gray-200 px-4 py-3 flex-shrink-0; - } - - .dark .details-header { - @apply bg-gray-800 border-gray-600; - } - - .selected-component-info { - @apply flex items-center gap-2; - } - - .selected-component-name { - @apply text-sm font-medium text-blue-600; - font-family: var(--font-family-mono); - } - - .dark .selected-component-name { - @apply text-blue-400; - } - - .details-content { - @apply flex-1 overflow-y-auto p-2; - font-size: 11px; - /* Custom scrollbar styling */ - scrollbar-width: thin; - scrollbar-color: rgba(0, 0, 0, 0.2) transparent; - } - - .details-content::-webkit-scrollbar { - width: 6px; - } - - .details-content::-webkit-scrollbar-track { - background: transparent; - } - - .details-content::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.2); - border-radius: 3px; - transition: background-color 0.2s ease; - } - - .details-content::-webkit-scrollbar-thumb:hover { - background-color: rgba(0, 0, 0, 0.4); - } - - .dark .details-content { - scrollbar-color: rgba(255, 255, 255, 0.3) transparent; - } - - .dark .details-content::-webkit-scrollbar-thumb { - background-color: rgba(255, 255, 255, 0.3); - } - - .dark .details-content::-webkit-scrollbar-thumb:hover { - background-color: rgba(255, 255, 255, 0.5); - } - - /* Element Info */ - .element-info { - @apply bg-gray-50 border border-gray-200 rounded-lg p-3 mb-4; - } - - .dark .element-info { - @apply bg-gray-800 border-gray-600; - } - - .element-tag { - @apply text-sm font-medium mb-1; - font-family: var(--font-family-mono); - } - - .tag-brackets { - @apply text-gray-600; - } - - .dark .tag-brackets { - @apply text-gray-400; - } - - .element-name { - @apply text-blue-600; - } - - .dark .element-name { - @apply text-blue-400; - } - - .element-aliases { - @apply text-xs text-gray-600; - } - - .dark .element-aliases { - @apply text-gray-400; - } - - .aliases-label { - @apply font-medium; - } - - .alias-list { - font-family: var(--font-family-mono); - } - - /* Attributes */ - .attribute-group { - @apply px-3 py-2 border-b border-gray-100; - } - - .attribute-group:last-child { - @apply border-b-0; - } - - .dark .attribute-group { - @apply border-gray-700; - } - - .attribute-header { - @apply mb-2; - } - - .attribute-name { - @apply text-xs font-medium text-red-600; - font-family: var(--font-family-mono); - } - - .dark .attribute-name { - @apply text-red-400; - } - - .component-count { - @apply text-xs text-gray-600; - } - - .dark .component-count { - @apply text-gray-400; - } - - /* Inspector View */ - .inspector-view { - @apply h-full overflow-y-auto; - /* Custom scrollbar styling */ - scrollbar-width: thin; - scrollbar-color: rgba(0, 0, 0, 0.2) transparent; - } - - .inspector-view::-webkit-scrollbar { - width: 6px; - } - - .inspector-view::-webkit-scrollbar-track { - background: transparent; - } - - .inspector-view::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.2); - border-radius: 3px; - transition: background-color 0.2s ease; - } - - .inspector-view::-webkit-scrollbar-thumb:hover { - background-color: rgba(0, 0, 0, 0.4); - } - - .dark .inspector-view { - scrollbar-color: rgba(255, 255, 255, 0.3) transparent; - } - - .dark .inspector-view::-webkit-scrollbar-thumb { - background-color: rgba(255, 255, 255, 0.3); - } - - .dark .inspector-view::-webkit-scrollbar-thumb:hover { - background-color: rgba(255, 255, 255, 0.5); - } - - .inspector-panel { - @apply p-4; - } - - /* Empty Inspector */ - .empty-inspector { - @apply flex-1 flex items-center justify-center p-10; - } - - .empty-content { - @apply text-center max-w-xs; - } - - .empty-icon { - @apply mb-4 text-4xl opacity-50 text-center flex items-center justify-center; - } - - .empty-title { - @apply text-base font-normal mb-2 text-gray-600; - } - - .dark .empty-title { - @apply text-gray-400; - } - - .empty-description { - @apply text-xs text-gray-500 leading-6; - } - - .dark .empty-description { - @apply text-gray-500; - } - -.max-depth-indicator { - @apply text-xs text-gray-500 italic ml-2; -} - -.dark .max-depth-indicator { - @apply text-gray-400; -} - - /* Plugin Panel */ - .plugin-panel { - display: flex; - flex-direction: column; - height: 100%; - border-radius: 12px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(0, 0, 0, 0.06); - padding: 16px; - } - - .dark .plugin-panel { - background: rgba(0, 0, 0, 0.35); - border-color: rgba(255, 255, 255, 0.08); - } - - .plugin-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 16px; - } - - .plugin-panel-title { - display: flex; - align-items: center; - gap: 12px; - } - - .plugin-panel-icon { - font-size: 28px; - } - - .plugin-panel-heading { - margin: 0; - font-size: 20px; - } - - .plugin-panel-description { - margin: 4px 0 0; - color: rgba(0, 0, 0, 0.65); - font-size: 13px; - } - - .dark .plugin-panel-description { - color: rgba(255, 255, 255, 0.65); - } - - .plugin-panel-body { - flex: 1; - border-radius: 8px; - padding: 16px; - background: rgba(0, 0, 0, 0.05); - overflow-y: auto; - } - - .dark .plugin-panel-body { - background: rgba(0, 0, 0, 0.4); - } - - .plugin-state { - text-align: center; - padding: 48px 12px; - color: rgba(0, 0, 0, 0.65); - } - - .dark .plugin-state { - color: rgba(255, 255, 255, 0.75); - } - - .plugin-content { - display: flex; - flex-direction: column; - gap: 16px; - } - - .plugin-summary { - font-size: 14px; - line-height: 1.5; - padding: 12px; - border-radius: 8px; - background: rgba(25, 103, 210, 0.1); - border: 1px solid rgba(25, 103, 210, 0.35); - } - - .plugin-sections { - display: flex; - flex-direction: column; - gap: 12px; - } - - .plugin-section { - border: 1px solid rgba(0, 0, 0, 0.08); - border-radius: 8px; - padding: 12px; - background: rgba(255, 255, 255, 0.9); - } - - .dark .plugin-section { - background: rgba(0, 0, 0, 0.25); - border-color: rgba(255, 255, 255, 0.08); - } - - .plugin-section-header h3 { - margin: 0 0 4px; - font-size: 15px; - } - - .plugin-section-header p { - margin: 0; - font-size: 12px; - color: rgba(0, 0, 0, 0.6); - } - - .dark .plugin-section-header p { - color: rgba(255, 255, 255, 0.65); - } - - .plugin-rows { - margin: 12px 0 0; - } - - .plugin-row { - display: grid; - grid-template-columns: 180px 1fr; - gap: 12px; - font-size: 12px; - padding: 6px 0; - border-bottom: 1px solid rgba(0, 0, 0, 0.06); - } - - .dark .plugin-row { - border-color: rgba(255, 255, 255, 0.08); - } - - .plugin-row:last-child { - border-bottom: none; - } - - .plugin-row dt { - font-weight: 600; - color: rgba(0, 0, 0, 0.8); - } - - .dark .plugin-row dt { - color: rgba(255, 255, 255, 0.85); - } - - .plugin-row-hint { - display: block; - font-weight: 400; - font-size: 11px; - color: rgba(0, 0, 0, 0.45); - } - - .dark .plugin-row-hint { - color: rgba(255, 255, 255, 0.5); - } - - .plugin-row dd { - margin: 0; - } - - .plugin-row pre, - .plugin-row code { - background: rgba(0, 0, 0, 0.05); - border-radius: 6px; - padding: 8px; - font-size: 11px; - white-space: pre-wrap; - word-break: break-word; - } - - .dark .plugin-row pre, - .dark .plugin-row code { - background: rgba(0, 0, 0, 0.45); - } - - .plugin-table-wrapper { - overflow-x: auto; - margin-top: 12px; - } - - .plugin-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; - } - - .plugin-table th, - .plugin-table td { - border: 1px solid rgba(0, 0, 0, 0.1); - padding: 6px 8px; - text-align: left; - } - - .dark .plugin-table th, - .dark .plugin-table td { - border-color: rgba(255, 255, 255, 0.15); - } - - .plugin-json { - border: 1px solid rgba(0, 0, 0, 0.08); - border-radius: 8px; - padding: 12px; - background: rgba(255, 255, 255, 0.85); - } - - .dark .plugin-json { - background: rgba(0, 0, 0, 0.35); - border-color: rgba(255, 255, 255, 0.08); - } - - .plugin-json pre { - margin: 0; - max-height: 320px; - overflow: auto; - font-size: 12px; - } - - /* Interaction timeline */ - .interaction-panel { - @apply h-full flex flex-col gap-3 p-4 overflow-hidden; - } - - .interaction-header { - @apply flex items-start justify-between gap-3; - } - - .interaction-header-left { - @apply flex flex-col gap-1 min-w-0; - } - - .interaction-title { - @apply text-sm font-semibold; - } - - .interaction-subtitle { - @apply text-xs text-gray-600 dark:text-gray-400 mt-0.5 max-w-2xl; - } - - .interaction-actions { - @apply flex items-center gap-2; - } - - .interaction-list { - @apply flex-1 overflow-y-auto flex flex-col gap-2; - } - - .interaction-row { - @apply border border-gray-200 dark:border-gray-700 rounded px-3 py-2 flex justify-between gap-3 bg-white dark:bg-gray-900 shadow-sm; - } - - .interaction-meta { - @apply flex-1 min-w-0; - } - - .interaction-event { - @apply flex items-center gap-2 text-sm font-medium; - } - - .interaction-event-name { - @apply text-gray-900 dark:text-gray-100; - } - - .interaction-mode { - @apply text-[10px] uppercase tracking-wide px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300; - } - - .interaction-duration { - @apply text-xs text-gray-500; - } - - .interaction-context { - @apply text-xs text-gray-600 dark:text-gray-400 mt-0.5 flex flex-wrap gap-1; - } - - .interaction-path { - @apply text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate; - } - - .interaction-router { - @apply text-xs text-blue-700 dark:text-blue-300 mt-0.5 flex flex-wrap gap-1; - } - - .interaction-error { - @apply text-xs text-red-600 dark:text-red-400 mt-0.5; - } - - .interaction-commands { - @apply flex flex-col gap-1 flex-shrink-0; - } - - .interaction-empty { - @apply border border-dashed border-gray-300 dark:border-gray-700 rounded p-6 text-center text-sm text-gray-600 dark:text-gray-400 flex flex-col items-center gap-2; - } - - .interaction-empty--error { - @apply border-red-300 dark:border-red-600 text-red-700 dark:text-red-300; - } - - /* Search Mode Toggle */ - .search-mode-toggle { - @apply flex-shrink-0 px-2 py-1 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 - bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 cursor-pointer - hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors; - min-width: 42px; - text-align: center; - } - - .search-input.has-mode-toggle { - padding-left: 8px; - } - - /* Matched Property Badge */ - .matched-property-badge { - @apply ml-1 text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/40 - text-amber-700 dark:text-amber-300 font-mono truncate max-w-24; - } - - /* Details Header Actions */ - .details-header { - @apply flex items-center justify-between; - } - - .details-header-actions { - @apply flex items-center gap-1; - } - - .export-button.copied { - @apply text-green-600 dark:text-green-400; - } - - /* Copy Property Button */ - .copy-property-button { - @apply flex-shrink-0 opacity-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 - text-gray-500 dark:text-gray-400 transition-all cursor-pointer border-none bg-transparent; - margin-left: auto; - } - - .property-row:hover .copy-property-button { - @apply opacity-100; - } - - .copy-property-button.copied { - @apply opacity-100 text-green-600 dark:text-green-400; - } - - .property-main { - @apply flex items-center gap-1 flex-1 min-w-0; - } - - /* Expression Evaluation Panel */ - .expression-panel { - @apply mt-4 border-t border-gray-200 dark:border-gray-700 pt-3; - } - - .expression-panel-toggle { - @apply flex items-center gap-2 w-full px-2 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 - bg-transparent border-none cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors; - } - - .expression-panel-toggle .expand-icon { - @apply text-gray-500 dark:text-gray-400 transition-transform; - } - - .expression-panel-toggle .expand-icon.expanded { - @apply rotate-90; - } - - .expression-panel-content { - @apply mt-2 flex flex-col gap-2; - } - - .expression-input-row { - @apply flex items-center gap-2; - } - - .expression-input { - @apply flex-1 px-2 py-1.5 text-xs font-mono border border-gray-300 dark:border-gray-600 - rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 - focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500; - } - - .expression-history { - @apply flex flex-wrap items-center gap-1 text-xs; - } - - .expression-history-label { - @apply text-gray-500 dark:text-gray-400; - } - - .expression-history-item { - @apply px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 - hover:bg-gray-200 dark:hover:bg-gray-600 cursor-pointer border-none font-mono text-xs truncate max-w-32; - } - - .expression-result { - @apply border border-gray-200 dark:border-gray-700 rounded p-2 bg-gray-50 dark:bg-gray-800/50; - } - - .expression-result-header { - @apply flex items-center gap-2 mb-1; - } - - .expression-result-label { - @apply text-xs font-medium text-gray-700 dark:text-gray-300; - } - - .expression-result-type { - @apply text-xs text-gray-500 dark:text-gray-400 font-mono; - } - - .expression-clear-button { - @apply ml-auto px-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 - cursor-pointer border-none bg-transparent text-sm font-bold; - } - - .expression-result-value { - @apply text-xs font-mono whitespace-pre-wrap text-gray-900 dark:text-gray-100 - max-h-32 overflow-auto m-0; - } - - .expression-error { - @apply text-xs text-red-600 dark:text-red-400; - } - - /* Enhanced Component Inspection Styles */ - - /* Version Badge */ - .version-badge { - @apply ml-2 px-1.5 py-0.5 text-xs rounded bg-purple-100 text-purple-700 - dark:bg-purple-900/50 dark:text-purple-300; - } - - /* Lifecycle Hooks Grid */ - .lifecycle-hooks-grid { - @apply grid gap-1 p-2; - grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); - } - - .lifecycle-hook-item { - @apply flex items-center gap-1.5 px-2 py-1 rounded text-xs; - background: var(--color-muted, #f3f4f6); - transition: all 0.15s ease; - } - - .dark .lifecycle-hook-item { - background: var(--color-dark-muted, #374151); - } - - .lifecycle-hook-item.implemented { - @apply bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400; - } - - .lifecycle-hook-item.not-implemented { - @apply opacity-50 text-gray-500 dark:text-gray-500; - } - - .hook-indicator { - @apply w-2 h-2 rounded-full flex-shrink-0; - } - - .hook-indicator.active { - @apply bg-green-500 dark:bg-green-400; - } - - .hook-indicator.inactive { - @apply bg-gray-300 dark:bg-gray-600; - } - - .hook-name { - @apply font-mono text-xs truncate; - } - - .async-badge { - @apply ml-auto px-1 py-0.5 text-xs rounded bg-amber-100 text-amber-700 - dark:bg-amber-900/50 dark:text-amber-300; - font-size: 9px; - } - - /* Getter/Setter Badges */ - .getter-setter-badge { - @apply ml-1 inline-flex gap-0.5; - } - - .badge-get, - .badge-set { - @apply px-1 rounded; - font-size: 9px; - } - - .badge-get { - @apply bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300; - } - - .badge-set { - @apply bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300; - } - - /* Dependencies List */ - .dependencies-list { - @apply list-none p-0 m-0; - } - - .dependency-item { - @apply flex items-center gap-2 px-2 py-1.5 border-b border-gray-100 dark:border-gray-700; - } - - .dependency-item:last-child { - @apply border-b-0; - } - - .dependency-type-icon { - @apply text-sm flex-shrink-0; - } - - .dependency-name { - @apply font-mono text-xs flex-1 truncate; - } - - .dependency-type-label { - @apply text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 - dark:bg-gray-700 dark:text-gray-400; - } - - /* Route Information */ - .route-info-content { - @apply p-2 space-y-2; - } - - .route-current, - .route-params, - .route-query { - @apply flex flex-wrap items-start gap-2; - } - - .route-label { - @apply text-xs font-medium text-gray-500 dark:text-gray-400 min-w-14; - } - - .route-path { - @apply font-mono text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700; - } - - .params-list { - @apply list-none p-0 m-0 flex flex-wrap gap-1; - } - - .param-item { - @apply flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 dark:bg-gray-700; - } - - .param-name { - @apply font-medium text-purple-600 dark:text-purple-400; - } - - .param-separator { - @apply mx-0.5 text-gray-400; - } - - .param-value { - @apply text-green-600 dark:text-green-400; - } - - .navigating-indicator { - @apply ml-2; - animation: pulse 1s infinite; - } - - @keyframes pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } - } - - /* Slot List */ - .slots-list { - @apply list-none p-0 m-0; - } - - .slot-item { - @apply flex items-center gap-2 px-2 py-1.5 border-b border-gray-100 dark:border-gray-700; - } - - .slot-item:last-child { - @apply border-b-0; - } - - .slot-item.empty { - @apply opacity-60; - } - - .slot-indicator { - @apply w-2 h-2 rounded-full flex-shrink-0; - } - - .slot-indicator.active { - @apply bg-green-500 dark:bg-green-400; - } - - .slot-indicator.inactive { - @apply bg-gray-300 dark:bg-gray-600; - } - - .slot-name { - @apply font-mono text-xs; - } - - .slot-node-count { - @apply text-xs text-gray-500 dark:text-gray-400; - } - - .slot-empty-label { - @apply ml-auto text-xs italic text-gray-400 dark:text-gray-500; - } - - /* Extension invalidated overlay */ - .extension-invalidated-overlay { - @apply fixed inset-0 flex items-center justify-center; - background-color: var(--color-background); - z-index: 1000; - } - - .dark .extension-invalidated-overlay { - background-color: var(--color-dark-background); - } - - .extension-invalidated-content { - @apply flex flex-col items-center justify-center p-10 text-center max-w-md; - } - - .extension-invalidated-content .empty-icon { - @apply text-6xl mb-4 opacity-60; - } - - .extension-invalidated-content h3 { - @apply text-lg font-medium mb-3; - color: var(--color-foreground); - } - - .dark .extension-invalidated-content h3 { - color: var(--color-dark-foreground); - } - - .extension-invalidated-content p { - @apply text-sm leading-relaxed; - color: var(--color-muted-foreground); - } - - .dark .extension-invalidated-content p { - color: var(--color-dark-muted-foreground); - } -} diff --git a/tests/app-expression.spec.ts b/tests/app-expression.spec.ts deleted file mode 100644 index 8ba5b54..0000000 --- a/tests/app-expression.spec.ts +++ /dev/null @@ -1,454 +0,0 @@ -import './setup'; -import { ChromeTest } from './setup'; -import { stubDebugHost, stubPlatform } from './helpers'; - -let AppClass: any; - -async function createApp() { - jest.resetModules(); - const mod = await import('@/app'); - AppClass = mod.App; - const app = Object.create(AppClass.prototype); - - app.coreTabs = [ - { id: 'all', label: 'All', icon: '🌲', kind: 'core' }, - { id: 'components', label: 'Components', icon: '📦', kind: 'core' }, - { id: 'attributes', label: 'Attributes', icon: '🔧', kind: 'core' }, - { id: 'interactions', label: 'Interactions', icon: '⏱️', kind: 'core' }, - ]; - app.activeTab = 'all'; - app.tabs = [...app.coreTabs]; - app.externalTabs = []; - app.externalPanels = {}; - app.externalPanelsVersion = 0; - app.externalPanelLoading = {}; - app.externalRefreshHandle = null; - app.selectedElement = undefined; - app.selectedElementAttributes = undefined; - app.allAureliaObjects = undefined; - app.componentTree = []; - app.componentSnapshot = { tree: [], flat: [] }; - app.selectedComponentId = undefined; - app.selectedBreadcrumb = []; - app.selectedNodeType = 'custom-element'; - app.searchQuery = ''; - app.searchMode = 'name'; - app.viewMode = 'tree'; - app.isElementPickerActive = false; - app.interactionLog = []; - app.interactionLoading = false; - app.interactionError = null; - app.aureliaDetected = false; - app.aureliaVersion = null; - app.detectionState = 'checking'; - app.isRefreshing = false; - app.expressionInput = ''; - app.expressionResult = ''; - app.expressionResultType = ''; - app.expressionError = ''; - app.expressionHistory = []; - app.isExpressionPanelOpen = false; - app.copiedPropertyId = null; - app.propertyRowsRevision = 0; - - const debugHost = stubDebugHost(); - const plat = stubPlatform(); - app.debugHost = debugHost; - app.plat = plat; - - return { app, debugHost, plat }; -} - -describe('App expression evaluation', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('evaluateExpression does nothing with empty input', async () => { - app.expressionInput = ''; - app.selectedElement = { name: 'comp', key: 'comp-key' }; - - await app.evaluateExpression(); - - expect(app.expressionError).toBe(''); - expect(app.expressionResult).toBe(''); - }); - - it('evaluateExpression does nothing without selected element', async () => { - app.expressionInput = 'this.count'; - app.selectedElement = undefined; - - await app.evaluateExpression(); - - expect(chrome.devtools.inspectedWindow.eval).not.toHaveBeenCalled(); - }); - - it('evaluateExpression sets error when no component key', async () => { - app.expressionInput = 'this.count'; - app.selectedElement = {}; - - await app.evaluateExpression(); - - expect(app.expressionError).toBe('No component selected'); - }); - - it('evaluateExpression adds to history', async () => { - app.expressionInput = 'this.count'; - app.selectedElement = { name: 'comp', key: 'comp-key' }; - app.expressionHistory = []; - ChromeTest.setEvalToReturn([{ result: { success: true, value: 42, type: 'number' } }]); - - await app.evaluateExpression(); - - expect(app.expressionHistory).toContain('this.count'); - }); - - it('evaluateExpression does not duplicate history', async () => { - app.expressionInput = 'this.count'; - app.selectedElement = { name: 'comp', key: 'comp-key' }; - app.expressionHistory = ['this.count']; - ChromeTest.setEvalToReturn([{ result: { success: true, value: 42, type: 'number' } }]); - - await app.evaluateExpression(); - - expect(app.expressionHistory.filter((h: string) => h === 'this.count')).toHaveLength(1); - }); - - it('evaluateExpression handles eval exception', async () => { - app.expressionInput = 'this.count'; - app.selectedElement = { name: 'comp', key: 'comp-key' }; - ChromeTest.setEvalImplementation((_expr: string, cb: Function) => { - cb(undefined, 'Syntax Error'); - }); - - await app.evaluateExpression(); - - expect(app.expressionError).toBe('Syntax Error'); - }); - - it('evaluateExpression handles result error', async () => { - app.expressionInput = 'this.count'; - app.selectedElement = { name: 'comp', key: 'comp-key' }; - ChromeTest.setEvalToReturn([{ result: { error: 'Component not found' } }]); - - await app.evaluateExpression(); - - expect(app.expressionError).toBe('Component not found'); - }); - - it('evaluateExpression handles successful number result', async () => { - app.expressionInput = 'this.count'; - app.selectedElement = { name: 'comp', key: 'comp-key' }; - ChromeTest.setEvalToReturn([{ result: { success: true, value: 42, type: 'number' } }]); - - await app.evaluateExpression(); - - expect(app.expressionResult).toBe('42'); - expect(app.expressionResultType).toBe('number'); - expect(app.expressionError).toBe(''); - }); - - it('evaluateExpression handles successful string result', async () => { - app.expressionInput = 'this.name'; - app.selectedElement = { name: 'comp', key: 'comp-key' }; - ChromeTest.setEvalToReturn([{ result: { success: true, value: 'hello', type: 'string' } }]); - - await app.evaluateExpression(); - - expect(app.expressionResult).toBe('hello'); - expect(app.expressionResultType).toBe('string'); - }); - - it('evaluateExpression handles undefined result', async () => { - app.expressionInput = 'this.missing'; - app.selectedElement = { name: 'comp', key: 'comp-key' }; - ChromeTest.setEvalToReturn([{ result: { success: true, value: undefined, type: 'undefined' } }]); - - await app.evaluateExpression(); - - expect(app.expressionResult).toBe('undefined'); - }); - - it('evaluateExpression handles null result', async () => { - app.expressionInput = 'this.nullable'; - app.selectedElement = { name: 'comp', key: 'comp-key' }; - ChromeTest.setEvalToReturn([{ result: { success: true, value: null, type: 'object' } }]); - - await app.evaluateExpression(); - - expect(app.expressionResult).toBe('null'); - }); - - it('evaluateExpression handles object result', async () => { - app.expressionInput = 'this.data'; - app.selectedElement = { name: 'comp', key: 'comp-key' }; - ChromeTest.setEvalToReturn([{ result: { success: true, value: { foo: 'bar' }, type: 'object' } }]); - - await app.evaluateExpression(); - - expect(app.expressionResult).toContain('foo'); - expect(app.expressionResult).toContain('bar'); - }); - - it('evaluateExpression handles unknown response format', async () => { - app.expressionInput = 'this.count'; - app.selectedElement = { name: 'comp', key: 'comp-key' }; - ChromeTest.setEvalToReturn([{ result: {} }]); - - await app.evaluateExpression(); - - expect(app.expressionError).toBe('Unknown response format'); - }); -}); - -describe('App property expansion', () => { - let app: any; - let plat: any; - - beforeEach(async () => { - jest.useFakeTimers(); - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - plat = result.plat; - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('togglePropertyExpansion expands expandable properties', () => { - const prop = { canExpand: true, isExpanded: false, debugId: 1, expandedValue: { properties: [] } }; - app.selectedElement = { bindables: [prop], properties: [] }; - - app.togglePropertyExpansion(prop); - - expect(prop.isExpanded).toBe(true); - }); - - it('togglePropertyExpansion collapses expanded properties', () => { - const prop = { canExpand: true, isExpanded: true, debugId: 1, expandedValue: { properties: [] } }; - app.selectedElement = { bindables: [prop], properties: [] }; - - app.togglePropertyExpansion(prop); - - expect(prop.isExpanded).toBe(false); - }); - - it('togglePropertyExpansion does nothing for non-expandable', () => { - const prop = { canExpand: false, isExpanded: false }; - app.selectedElement = { bindables: [prop], properties: [] }; - - app.togglePropertyExpansion(prop); - - expect(prop.isExpanded).toBe(false); - }); - - it('loadExpandedPropertyValue fetches expanded value via eval', () => { - const prop = { canExpand: true, isExpanded: false, debugId: 42, expandedValue: null }; - app.selectedElement = { bindables: [prop], properties: [] }; - - ChromeTest.setEvalToReturn([{ result: { properties: [{ name: 'child', value: 'test' }] } }]); - - app.loadExpandedPropertyValue(prop); - - expect(chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); - const code = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls[0][0]; - expect(code).toContain('getExpandedDebugValueForId(42)'); - }); - - it('loadExpandedPropertyValue handles eval exception', () => { - const prop = { canExpand: true, isExpanded: false, debugId: 42, expandedValue: null }; - app.selectedElement = { bindables: [], properties: [prop] }; - - ChromeTest.setEvalImplementation((_expr: string, cb: Function) => { - cb(undefined, 'Error loading'); - }); - - app.loadExpandedPropertyValue(prop); - - expect(prop.isExpanded).toBe(false); - expect(prop.expandedValue).toBeNull(); - }); - - it('markPropertyRowsDirty increments revision', () => { - const initial = app.propertyRowsRevision; - app.markPropertyRowsDirty(); - expect(app.propertyRowsRevision).toBe(initial + 1); - }); -}); - -describe('App node path finding', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('findNodePathById returns path to nested node', () => { - app.componentTree = [ - { - id: 'root', - name: 'root', - children: [ - { - id: 'child', - name: 'child', - children: [ - { id: 'grandchild', name: 'grandchild', children: [] } - ] - } - ] - } - ]; - - const path = app.findNodePathById('grandchild'); - - expect(path).toHaveLength(3); - expect(path[0].id).toBe('root'); - expect(path[1].id).toBe('child'); - expect(path[2].id).toBe('grandchild'); - }); - - it('findNodePathById returns null for non-existent node', () => { - app.componentTree = [{ id: 'root', name: 'root', children: [] }]; - - const path = app.findNodePathById('nonexistent'); - - expect(path).toBeNull(); - }); - - it('findNodePathById handles empty tree', () => { - app.componentTree = []; - - const path = app.findNodePathById('any'); - - expect(path).toBeNull(); - }); -}); - -describe('App component matching', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('nodeMatchesElement returns false for non-element nodes', () => { - const node = { data: { kind: 'attribute' } }; - const target = { name: 'comp', key: 'comp-key' }; - - expect(app.nodeMatchesElement(node, target)).toBe(false); - }); - - it('nodeMatchesElement returns false without target', () => { - const node = { data: { kind: 'element', info: { customElementInfo: { name: 'comp' } } } }; - - expect(app.nodeMatchesElement(node, null)).toBe(false); - }); - - it('nodeMatchesAttribute returns false for non-attribute nodes', () => { - const node = { data: { kind: 'element' } }; - - expect(app.nodeMatchesAttribute(node, [{ name: 'attr' }])).toBe(false); - }); - - it('nodeMatchesAttribute returns false without target attributes', () => { - const node = { data: { kind: 'attribute', raw: { name: 'attr' } } }; - - expect(app.nodeMatchesAttribute(node, [])).toBe(false); - expect(app.nodeMatchesAttribute(node, null)).toBe(false); - }); - - it('nodeContainsAnyAttribute returns false for empty arrays', () => { - expect(app.nodeContainsAnyAttribute([], [{ name: 'attr' }])).toBe(false); - expect(app.nodeContainsAnyAttribute([{ name: 'attr' }], [])).toBe(false); - expect(app.nodeContainsAnyAttribute(null, [{ name: 'attr' }])).toBe(false); - }); - - it('searchNodesForMatch finds matching element', () => { - const nodes = [ - { - id: 'comp1', - data: { kind: 'element', info: { customElementInfo: { name: 'target', key: 'target-key' }, customAttributesInfo: [] } }, - children: [] - } - ]; - const target = { name: 'target', key: 'target-key' }; - - const found = app.searchNodesForMatch(nodes, target, []); - - expect(found).toBeDefined(); - expect(found.id).toBe('comp1'); - }); - - it('searchNodesForMatch searches children', () => { - const nodes = [ - { - id: 'parent', - data: { kind: 'element', info: { customElementInfo: { name: 'parent' }, customAttributesInfo: [] } }, - children: [ - { - id: 'child', - data: { kind: 'element', info: { customElementInfo: { name: 'target', key: 'target-key' }, customAttributesInfo: [] } }, - children: [] - } - ] - } - ]; - const target = { name: 'target', key: 'target-key' }; - - const found = app.searchNodesForMatch(nodes, target, []); - - expect(found).toBeDefined(); - expect(found.id).toBe('child'); - }); -}); - -describe('App handleExpandToggle', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('stops event propagation', () => { - const node = { id: 'test', expanded: false }; - app.componentTree = [node]; - const event = { stopPropagation: jest.fn() }; - - app.handleExpandToggle(node, event); - - expect(event.stopPropagation).toHaveBeenCalled(); - }); - - it('does nothing for node without id', () => { - const node = { expanded: false }; - app.componentTree = []; - const spy = jest.spyOn(app, 'toggleComponentExpansion'); - - app.handleExpandToggle(node); - - expect(spy).not.toHaveBeenCalled(); - spy.mockRestore(); - }); - - it('does nothing for null node', () => { - const spy = jest.spyOn(app, 'toggleComponentExpansion'); - - app.handleExpandToggle(null); - - expect(spy).not.toHaveBeenCalled(); - spy.mockRestore(); - }); -}); diff --git a/tests/app-extras.spec.ts b/tests/app-extras.spec.ts deleted file mode 100644 index 10eb43b..0000000 --- a/tests/app-extras.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import './setup'; -import { ChromeTest } from './setup'; -import { stubDebugHost, stubPlatform } from './helpers'; - -describe('App extra behaviors', () => { - let app: any; - let AppClass: any; - let debugHost: any; - let plat: any; - - beforeEach(async () => { - jest.useFakeTimers(); - ChromeTest.reset(); - jest.resetModules(); - const mod = await import('@/app'); - AppClass = mod.App; - app = Object.create(AppClass.prototype); - // seed minimal state - app.activeTab = 'all'; - app.tabs = []; - app.selectedElement = undefined; - app.selectedElementAttributes = undefined; - app.allAureliaObjects = undefined; - app.componentTree = []; - app.filteredComponentTree = []; - app.selectedComponentId = undefined; - app.searchQuery = ''; - app.isElementPickerActive = false; - app.aureliaDetected = false; - app.aureliaVersion = null; - app.detectionState = 'checking'; - - debugHost = stubDebugHost({ revealInElements: jest.fn() }); - plat = stubPlatform(); - app.debugHost = debugHost; - app.plat = plat; - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('toggleElementPicker starts and stops picker via debugHost', () => { - expect(app.isElementPickerActive).toBe(false); - app.toggleElementPicker(); - expect(app.isElementPickerActive).toBe(true); - expect(debugHost.startElementPicker).toHaveBeenCalled(); - - app.toggleElementPicker(); - expect(app.isElementPickerActive).toBe(false); - expect(debugHost.stopElementPicker).toHaveBeenCalled(); - }); - - it('toggleFollowChromeSelection persists to localStorage', () => { - const setItem = jest.spyOn(window.localStorage.__proto__, 'setItem'); - const initial = app.followChromeSelection; - app.toggleFollowChromeSelection(); - expect(app.followChromeSelection).toBe(!initial); - expect(setItem).toHaveBeenCalledWith('au-devtools.followChromeSelection', String(!initial)); - setItem.mockRestore(); - }); - - it('valueChanged schedules updateValues microtask', () => { - const el = { name: 'x' } as any; - app.valueChanged(el); - jest.runOnlyPendingTimers(); - expect(debugHost.updateValues).toHaveBeenCalledWith(el); - }); - - it('revealInElements calls debugHost.revealInElements with selected component info', () => { - // Create minimal tree - const node = { - id: 'n1', name: 'comp', type: 'custom-element', children: [], expanded: false, - data: { customElementInfo: { key: 'k' }, customAttributesInfo: [] } - }; - app.componentTree = [node]; - app.selectedComponentId = 'n1'; - - app.revealInElements(); - expect(debugHost.revealInElements).toHaveBeenCalledWith(expect.objectContaining({ name: 'comp', type: 'custom-element' })); - }); -}); diff --git a/tests/app-interactions.spec.ts b/tests/app-interactions.spec.ts deleted file mode 100644 index e2b2695..0000000 --- a/tests/app-interactions.spec.ts +++ /dev/null @@ -1,497 +0,0 @@ -import './setup'; -import { ChromeTest } from './setup'; -import { stubDebugHost, stubPlatform } from './helpers'; - -let AppClass: any; - -async function createApp() { - jest.resetModules(); - const mod = await import('@/app'); - AppClass = mod.App; - const app = Object.create(AppClass.prototype); - - app.coreTabs = [ - { id: 'all', label: 'All', icon: '🌲', kind: 'core' }, - { id: 'components', label: 'Components', icon: '📦', kind: 'core' }, - { id: 'attributes', label: 'Attributes', icon: '🔧', kind: 'core' }, - { id: 'interactions', label: 'Interactions', icon: '⏱️', kind: 'core' }, - ]; - app.activeTab = 'all'; - app.tabs = [...app.coreTabs]; - app.externalTabs = []; - app.externalPanels = {}; - app.externalPanelsVersion = 0; - app.externalPanelLoading = {}; - app.externalRefreshHandle = null; - app.selectedElement = undefined; - app.selectedElementAttributes = undefined; - app.allAureliaObjects = undefined; - app.componentTree = []; - app.componentSnapshot = { tree: [], flat: [] }; - app.selectedComponentId = undefined; - app.selectedBreadcrumb = []; - app.selectedNodeType = 'custom-element'; - app.searchQuery = ''; - app.searchMode = 'name'; - app.viewMode = 'tree'; - app.isElementPickerActive = false; - app.interactionLog = []; - app.interactionLoading = false; - app.interactionError = null; - app.interactionSignature = ''; - app.aureliaDetected = false; - app.aureliaVersion = null; - app.detectionState = 'checking'; - app.isRefreshing = false; - app.copiedPropertyId = null; - app.propertyRowsRevision = 0; - - const debugHost = stubDebugHost(); - const plat = stubPlatform(); - app.debugHost = debugHost; - app.plat = plat; - - return { app, debugHost, plat }; -} - -describe('App interaction log loading', () => { - let app: any; - let debugHost: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - debugHost = result.debugHost; - }); - - it('loadInteractionLog fetches and sorts log', async () => { - debugHost.getInteractionLog.mockResolvedValue([ - { id: 'evt-1', timestamp: 100 }, - { id: 'evt-2', timestamp: 200 }, - ]); - - await app.loadInteractionLog(); - - expect(app.interactionLog).toHaveLength(2); - expect(app.interactionLog[0].id).toBe('evt-2'); - expect(app.interactionLog[1].id).toBe('evt-1'); - }); - - it('loadInteractionLog sets loading state', async () => { - let resolvePromise: Function; - debugHost.getInteractionLog.mockReturnValue(new Promise(r => { resolvePromise = r; })); - - const promise = app.loadInteractionLog(); - expect(app.interactionLoading).toBe(true); - - resolvePromise!([]); - await promise; - expect(app.interactionLoading).toBe(false); - }); - - it('loadInteractionLog handles errors', async () => { - debugHost.getInteractionLog.mockRejectedValue(new Error('Network error')); - - await app.loadInteractionLog(); - - expect(app.interactionError).toBe('Network error'); - expect(app.interactionLog).toEqual([]); - }); - - it('loadInteractionLog silent mode does not update loading state', async () => { - debugHost.getInteractionLog.mockResolvedValue([]); - app.interactionLoading = false; - - await app.loadInteractionLog(false, true); - - expect(app.interactionLoading).toBe(false); - }); - - it('loadInteractionLog skips reload if signature unchanged', async () => { - debugHost.getInteractionLog.mockResolvedValue([ - { id: 'evt-1', timestamp: 100 } - ]); - - await app.loadInteractionLog(); - const firstLog = app.interactionLog; - - await app.loadInteractionLog(); - expect(app.interactionLog).toBe(firstLog); - }); -}); - -describe('App interaction replay and snapshot', () => { - let app: any; - let debugHost: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - debugHost = result.debugHost; - }); - - it('replayInteraction calls debugHost', async () => { - debugHost.replayInteraction.mockResolvedValue(true); - - await app.replayInteraction('evt-1'); - - expect(debugHost.replayInteraction).toHaveBeenCalledWith('evt-1'); - }); - - it('replayInteraction does nothing for empty id', async () => { - await app.replayInteraction(''); - await app.replayInteraction(null); - - expect(debugHost.replayInteraction).not.toHaveBeenCalled(); - }); - - it('replayInteraction handles errors gracefully', async () => { - debugHost.replayInteraction.mockRejectedValue(new Error('Replay failed')); - - await expect(app.replayInteraction('evt-1')).resolves.toBeUndefined(); - }); - - it('applyInteractionSnapshot calls debugHost with phase', async () => { - debugHost.applyInteractionSnapshot.mockResolvedValue(true); - - await app.applyInteractionSnapshot('evt-1', 'before'); - - expect(debugHost.applyInteractionSnapshot).toHaveBeenCalledWith('evt-1', 'before'); - }); - - it('applyInteractionSnapshot does nothing for empty id', async () => { - await app.applyInteractionSnapshot('', 'before'); - - expect(debugHost.applyInteractionSnapshot).not.toHaveBeenCalled(); - }); - - it('clearInteractionLog clears log optimistically and calls debugHost', async () => { - app.interactionLog = [{ id: 'evt-1' }]; - app.interactionSignature = 'evt-1:100'; - debugHost.clearInteractionLog.mockResolvedValue(true); - - await app.clearInteractionLog(); - - expect(app.interactionLog).toEqual([]); - expect(app.interactionSignature).toBe(''); - expect(debugHost.clearInteractionLog).toHaveBeenCalled(); - }); -}); - -describe('App incoming interaction handling', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('handleIncomingInteraction adds new entries', () => { - app.interactionLog = []; - app.handleIncomingInteraction({ id: 'evt-1', timestamp: 100 }); - - expect(app.interactionLog).toHaveLength(1); - expect(app.interactionLog[0].id).toBe('evt-1'); - }); - - it('handleIncomingInteraction ignores duplicates', () => { - app.interactionLog = [{ id: 'evt-1', timestamp: 100 }]; - app.handleIncomingInteraction({ id: 'evt-1', timestamp: 100 }); - - expect(app.interactionLog).toHaveLength(1); - }); - - it('handleIncomingInteraction ignores invalid entries', () => { - app.interactionLog = []; - app.handleIncomingInteraction(null); - app.handleIncomingInteraction({}); - app.handleIncomingInteraction({ id: '' }); - - expect(app.interactionLog).toHaveLength(0); - }); - - it('handleIncomingInteraction sorts by timestamp descending', () => { - app.interactionLog = [{ id: 'evt-1', timestamp: 100 }]; - app.handleIncomingInteraction({ id: 'evt-2', timestamp: 200 }); - - expect(app.interactionLog[0].id).toBe('evt-2'); - expect(app.interactionLog[1].id).toBe('evt-1'); - }); - - it('handleIncomingInteraction updates signature', () => { - app.interactionLog = []; - app.interactionSignature = ''; - app.handleIncomingInteraction({ id: 'evt-1', timestamp: 100 }); - - expect(app.interactionSignature).toContain('evt-1'); - }); -}); - -describe('App property editing', () => { - let app: any; - let debugHost: any; - let plat: any; - - beforeEach(async () => { - jest.useFakeTimers(); - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - debugHost = result.debugHost; - plat = result.plat; - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('editProperty sets editing state for editable types', () => { - const prop = { type: 'string', value: 'hello', isEditing: false }; - app.editProperty(prop); - expect(prop.isEditing).toBe(true); - expect(prop.originalValue).toBe('hello'); - }); - - it('editProperty does not edit non-editable types', () => { - const prop = { type: 'function', value: () => {}, isEditing: false }; - app.editProperty(prop); - expect(prop.isEditing).toBe(false); - }); - - it('cancelPropertyEdit reverts value and clears editing state', () => { - const prop = { type: 'string', value: 'new', isEditing: true, originalValue: 'old' }; - app.cancelPropertyEdit(prop); - expect(prop.value).toBe('old'); - expect(prop.isEditing).toBe(false); - expect(prop.originalValue).toBeUndefined(); - }); - - it('saveProperty handles null conversion', () => { - const prop = { type: 'null', value: null, isEditing: true, originalValue: null }; - app.selectedElement = { properties: [prop], bindables: [] }; - - app.saveProperty(prop, 'null'); - - expect(prop.value).toBeNull(); - expect(prop.isEditing).toBe(false); - }); - - it('saveProperty converts null input to string when not null', () => { - const prop = { type: 'null', value: null, isEditing: true, originalValue: null }; - app.selectedElement = { properties: [prop], bindables: [] }; - - app.saveProperty(prop, 'some text'); - - expect(prop.value).toBe('some text'); - expect(prop.type).toBe('string'); - }); - - it('saveProperty handles undefined conversion', () => { - const prop = { type: 'undefined', value: undefined, isEditing: true, originalValue: undefined }; - app.selectedElement = { properties: [prop], bindables: [] }; - - app.saveProperty(prop, 'undefined'); - - expect(prop.value).toBeUndefined(); - expect(prop.isEditing).toBe(false); - }); - - it('saveProperty converts undefined input to string when not undefined', () => { - const prop = { type: 'undefined', value: undefined, isEditing: true, originalValue: undefined }; - app.selectedElement = { properties: [prop], bindables: [] }; - - app.saveProperty(prop, 'new value'); - - expect(prop.value).toBe('new value'); - expect(prop.type).toBe('string'); - }); - - it('saveProperty reverts on invalid boolean', () => { - const prop = { type: 'boolean', value: true, isEditing: true, originalValue: true }; - app.selectedElement = { properties: [prop], bindables: [] }; - - app.saveProperty(prop, 'not-a-boolean'); - - expect(prop.value).toBe(true); - expect(prop.isEditing).toBe(false); - }); -}); - -describe('App property copying', () => { - let app: any; - - beforeEach(async () => { - jest.useFakeTimers(); - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - - Object.assign(navigator, { - clipboard: { - writeText: jest.fn().mockResolvedValue(undefined), - }, - }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('copyPropertyValue copies null as string', async () => { - const prop = { name: 'test', value: null, debugId: '1' }; - await app.copyPropertyValue(prop); - - expect(navigator.clipboard.writeText).toHaveBeenCalledWith('null'); - }); - - it('copyPropertyValue copies undefined as string', async () => { - const prop = { name: 'test', value: undefined, debugId: '1' }; - await app.copyPropertyValue(prop); - - expect(navigator.clipboard.writeText).toHaveBeenCalledWith('undefined'); - }); - - it('copyPropertyValue copies objects as JSON', async () => { - const prop = { name: 'test', value: { foo: 'bar' }, debugId: '1' }; - await app.copyPropertyValue(prop); - - expect(navigator.clipboard.writeText).toHaveBeenCalledWith(JSON.stringify({ foo: 'bar' }, null, 2)); - }); - - it('copyPropertyValue copies primitives as strings', async () => { - const prop = { name: 'test', value: 42, debugId: '1' }; - await app.copyPropertyValue(prop); - - expect(navigator.clipboard.writeText).toHaveBeenCalledWith('42'); - }); - - it('copyPropertyValue sets copied state temporarily', async () => { - const prop = { name: 'test', value: 'hello', debugId: '1' }; - await app.copyPropertyValue(prop); - - expect(app.copiedPropertyId).toBe('test-1'); - - jest.advanceTimersByTime(1600); - expect(app.copiedPropertyId).toBeNull(); - }); - - it('isPropertyCopied returns correct state', async () => { - const prop = { name: 'test', value: 'hello', debugId: '1' }; - expect(app.isPropertyCopied(prop)).toBe(false); - - await app.copyPropertyValue(prop); - expect(app.isPropertyCopied(prop)).toBe(true); - }); - - it('copyPropertyValue stops event propagation', async () => { - const prop = { name: 'test', value: 'hello', debugId: '1' }; - const event = { stopPropagation: jest.fn() }; - - await app.copyPropertyValue(prop, event); - - expect(event.stopPropagation).toHaveBeenCalled(); - }); -}); - -describe('App component export', () => { - let app: any; - - beforeEach(async () => { - jest.useFakeTimers(); - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - - Object.assign(navigator, { - clipboard: { - writeText: jest.fn().mockResolvedValue(undefined), - }, - }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('exportComponentAsJson does nothing without selected element', async () => { - app.selectedElement = undefined; - await app.exportComponentAsJson(); - - expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); - }); - - it('exportComponentAsJson copies JSON to clipboard', async () => { - app.selectedElement = { - name: 'my-component', - key: 'my-key', - aliases: [], - bindables: [{ name: 'count', value: 5, type: 'number' }], - properties: [], - }; - app.selectedNodeType = 'custom-element'; - app.selectedElementAttributes = []; - - await app.exportComponentAsJson(); - - expect(navigator.clipboard.writeText).toHaveBeenCalled(); - const calledWith = (navigator.clipboard.writeText as jest.Mock).mock.calls[0][0]; - const parsed = JSON.parse(calledWith); - expect(parsed.meta.name).toBe('my-component'); - expect(parsed.bindables.count.value).toBe(5); - }); - - it('isExportCopied returns correct state', async () => { - app.selectedElement = { name: 'comp', bindables: [], properties: [] }; - app.selectedElementAttributes = []; - - expect(app.isExportCopied).toBe(false); - - await app.exportComponentAsJson(); - expect(app.isExportCopied).toBe(true); - - jest.advanceTimersByTime(1600); - expect(app.isExportCopied).toBe(false); - }); -}); - -describe('App component refresh', () => { - let app: any; - let debugHost: any; - - beforeEach(async () => { - jest.useFakeTimers(); - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - debugHost = result.debugHost; - debugHost.getAllComponents.mockResolvedValue({ tree: [], flat: [] }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('refreshComponents sets isRefreshing during load', async () => { - expect(app.isRefreshing).toBe(false); - - app.refreshComponents(); - expect(app.isRefreshing).toBe(true); - - jest.runAllTimers(); - await Promise.resolve(); - await Promise.resolve(); - jest.runAllTimers(); - - expect(app.isRefreshing).toBe(false); - }); - - it('refreshComponents does not run if already refreshing', () => { - app.isRefreshing = true; - app.refreshComponents(); - - expect(debugHost.getAllComponents).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/app-utils.spec.ts b/tests/app-utils.spec.ts deleted file mode 100644 index 33c85ec..0000000 --- a/tests/app-utils.spec.ts +++ /dev/null @@ -1,610 +0,0 @@ -import './setup'; -import { ChromeTest } from './setup'; -import { stubDebugHost, stubPlatform } from './helpers'; - -let AppClass: any; - -async function createApp() { - jest.resetModules(); - const mod = await import('@/app'); - AppClass = mod.App; - const app = Object.create(AppClass.prototype); - - app.coreTabs = [ - { id: 'all', label: 'All', icon: '🌲', kind: 'core' }, - { id: 'components', label: 'Components', icon: '📦', kind: 'core' }, - { id: 'attributes', label: 'Attributes', icon: '🔧', kind: 'core' }, - { id: 'interactions', label: 'Interactions', icon: '⏱️', kind: 'core' }, - ]; - app.activeTab = 'all'; - app.tabs = [...app.coreTabs]; - app.externalTabs = []; - app.externalPanels = {}; - app.externalPanelsVersion = 0; - app.externalPanelLoading = {}; - app.externalRefreshHandle = null; - app.selectedElement = undefined; - app.selectedElementAttributes = undefined; - app.allAureliaObjects = undefined; - app.componentTree = []; - app.componentSnapshot = { tree: [], flat: [] }; - app.selectedComponentId = undefined; - app.selectedBreadcrumb = []; - app.selectedNodeType = 'custom-element'; - app.searchQuery = ''; - app.searchMode = 'name'; - app.viewMode = 'tree'; - app.isElementPickerActive = false; - app.interactionLog = []; - app.interactionLoading = false; - app.interactionError = null; - app.aureliaDetected = false; - app.aureliaVersion = null; - app.detectionState = 'checking'; - app.isRefreshing = false; - app.expressionInput = ''; - app.expressionResult = ''; - app.expressionResultType = ''; - app.expressionError = ''; - app.expressionHistory = []; - app.isExpressionPanelOpen = false; - app.copiedPropertyId = null; - app.propertyRowsRevision = 0; - app.followChromeSelection = true; - - const debugHost = stubDebugHost(); - const plat = stubPlatform(); - app.debugHost = debugHost; - app.plat = plat; - - return { app, debugHost, plat }; -} - -describe('App view mode management', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('toggleViewMode switches between tree and list', () => { - expect(app.viewMode).toBe('tree'); - app.toggleViewMode(); - expect(app.viewMode).toBe('list'); - app.toggleViewMode(); - expect(app.viewMode).toBe('tree'); - }); - - it('setViewMode sets mode and persists to localStorage', () => { - const setItem = jest.spyOn(window.localStorage.__proto__, 'setItem'); - app.setViewMode('list'); - expect(app.viewMode).toBe('list'); - expect(setItem).toHaveBeenCalledWith('au-devtools.viewMode', 'list'); - setItem.mockRestore(); - }); - - it('setViewMode does nothing when mode is same', () => { - const setItem = jest.spyOn(window.localStorage.__proto__, 'setItem'); - app.setViewMode('tree'); - expect(setItem).not.toHaveBeenCalled(); - setItem.mockRestore(); - }); -}); - -describe('App search mode management', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('setSearchMode changes search mode', () => { - expect(app.searchMode).toBe('name'); - app.setSearchMode('property'); - expect(app.searchMode).toBe('property'); - app.setSearchMode('all'); - expect(app.searchMode).toBe('all'); - }); - - it('cycleSearchMode cycles through modes', () => { - expect(app.searchMode).toBe('name'); - app.cycleSearchMode(); - expect(app.searchMode).toBe('property'); - app.cycleSearchMode(); - expect(app.searchMode).toBe('all'); - app.cycleSearchMode(); - expect(app.searchMode).toBe('name'); - }); - - it('searchModeLabel returns correct labels', () => { - app.searchMode = 'name'; - expect(app.searchModeLabel).toBe('Name'); - app.searchMode = 'property'; - expect(app.searchModeLabel).toBe('Props'); - app.searchMode = 'all'; - expect(app.searchModeLabel).toBe('All'); - }); - - it('searchPlaceholder returns correct placeholders', () => { - app.searchMode = 'name'; - expect(app.searchPlaceholder).toBe('Search components...'); - app.searchMode = 'property'; - expect(app.searchPlaceholder).toBe('Search property values...'); - app.searchMode = 'all'; - expect(app.searchPlaceholder).toBe('Search names & properties...'); - }); -}); - -describe('App expression panel', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('toggleExpressionPanel toggles panel state', () => { - expect(app.isExpressionPanelOpen).toBe(false); - app.toggleExpressionPanel(); - expect(app.isExpressionPanelOpen).toBe(true); - app.toggleExpressionPanel(); - expect(app.isExpressionPanelOpen).toBe(false); - }); - - it('clearExpressionResult clears all expression state', () => { - app.expressionResult = 'test result'; - app.expressionResultType = 'string'; - app.expressionError = 'some error'; - app.clearExpressionResult(); - expect(app.expressionResult).toBe(''); - expect(app.expressionResultType).toBe(''); - expect(app.expressionError).toBe(''); - }); - - it('selectHistoryExpression sets input', () => { - app.selectHistoryExpression('this.count'); - expect(app.expressionInput).toBe('this.count'); - }); -}); - -describe('App flat list conversion', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('convertFlatListToTreeNodes handles empty input', () => { - const result = app.convertFlatListToTreeNodes([]); - expect(result).toEqual([]); - }); - - it('convertFlatListToTreeNodes handles null input', () => { - const result = app.convertFlatListToTreeNodes(null); - expect(result).toEqual([]); - }); - - it('convertFlatListToTreeNodes creates nodes from elements', () => { - const flat = [ - { customElementInfo: { name: 'comp-a', key: 'comp-a', bindables: [], properties: [], aliases: [] }, customAttributesInfo: [] }, - { customElementInfo: { name: 'comp-b', key: 'comp-b', bindables: [], properties: [], aliases: [] }, customAttributesInfo: [] }, - ]; - const result = app.convertFlatListToTreeNodes(flat); - expect(result).toHaveLength(2); - expect(result[0].customElementInfo.name).toBe('comp-a'); - expect(result[1].customElementInfo.name).toBe('comp-b'); - }); - - it('convertFlatListToTreeNodes deduplicates by key', () => { - const flat = [ - { customElementInfo: { name: 'comp-a', key: 'same-key', bindables: [], properties: [], aliases: [] }, customAttributesInfo: [] }, - { customElementInfo: { name: 'comp-a', key: 'same-key', bindables: [], properties: [], aliases: [] }, customAttributesInfo: [] }, - ]; - const result = app.convertFlatListToTreeNodes(flat); - expect(result).toHaveLength(1); - }); - - it('convertFlatListToTreeNodes creates attribute-only nodes', () => { - const flat = [ - { customElementInfo: null, customAttributesInfo: [{ name: 'draggable', key: 'draggable', bindables: [], properties: [], aliases: [] }] }, - ]; - const result = app.convertFlatListToTreeNodes(flat); - expect(result).toHaveLength(1); - expect(result[0].customElementInfo).toBeNull(); - expect(result[0].customAttributesInfo).toHaveLength(1); - }); -}); - -describe('App property value search', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('propertyValueToSearchString handles null', () => { - expect(app.propertyValueToSearchString(null)).toBe('null'); - }); - - it('propertyValueToSearchString handles undefined', () => { - expect(app.propertyValueToSearchString(undefined)).toBe('undefined'); - }); - - it('propertyValueToSearchString handles strings', () => { - expect(app.propertyValueToSearchString('hello')).toBe('hello'); - }); - - it('propertyValueToSearchString handles numbers', () => { - expect(app.propertyValueToSearchString(42)).toBe('42'); - }); - - it('propertyValueToSearchString handles booleans', () => { - expect(app.propertyValueToSearchString(true)).toBe('true'); - expect(app.propertyValueToSearchString(false)).toBe('false'); - }); - - it('propertyValueToSearchString handles arrays', () => { - expect(app.propertyValueToSearchString([1, 2, 3])).toBe('[3 items]'); - }); - - it('propertyValueToSearchString handles objects', () => { - const result = app.propertyValueToSearchString({ foo: 'bar' }); - expect(result).toContain('foo'); - expect(result).toContain('bar'); - }); - - it('searchInProperties finds property by name', () => { - const info = { - bindables: [{ name: 'searchableValue', value: 'test', type: 'string' }], - properties: [], - }; - const result = app.searchInProperties(info, 'searchable'); - expect(result).toBe('searchableValue'); - }); - - it('searchInProperties finds property by value', () => { - const info = { - bindables: [], - properties: [{ name: 'message', value: 'hello world', type: 'string' }], - }; - const result = app.searchInProperties(info, 'hello'); - expect(result).toBe('message'); - }); - - it('searchInProperties returns null when not found', () => { - const info = { - bindables: [{ name: 'count', value: 10, type: 'number' }], - properties: [], - }; - const result = app.searchInProperties(info, 'nonexistent'); - expect(result).toBeNull(); - }); -}); - -describe('App breadcrumb and node count', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('breadcrumbSegments returns empty for no selection', () => { - app.selectedBreadcrumb = []; - expect(app.breadcrumbSegments).toEqual([]); - }); - - it('breadcrumbSegments formats element and attribute labels', () => { - app.selectedBreadcrumb = [ - { id: 'e1', name: 'my-element', type: 'custom-element' }, - { id: 'a1', name: 'my-attr', type: 'custom-attribute' }, - ]; - const segments = app.breadcrumbSegments; - expect(segments).toHaveLength(2); - expect(segments[0].label).toBe(''); - expect(segments[1].label).toBe('@my-attr'); - }); - - it('totalComponentNodeCount counts all nodes recursively', () => { - app.componentTree = [ - { - id: 'root', - name: 'root', - children: [ - { id: 'child1', name: 'child1', children: [] }, - { id: 'child2', name: 'child2', children: [ - { id: 'grandchild', name: 'grandchild', children: [] } - ] }, - ], - }, - ]; - expect(app.totalComponentNodeCount).toBe(4); - }); - - it('totalComponentNodeCount returns 0 for empty tree', () => { - app.componentTree = []; - expect(app.totalComponentNodeCount).toBe(0); - }); -}); - -describe('App external panel helpers', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('isExternalTabActive returns false for core tabs', () => { - app.activeTab = 'all'; - expect(app.isExternalTabActive).toBe(false); - app.activeTab = 'components'; - expect(app.isExternalTabActive).toBe(false); - }); - - it('isExternalTabActive returns true for external tabs', () => { - app.externalTabs = [{ id: 'external:store', panelId: 'store' }]; - app.activeTab = 'external:store'; - expect(app.isExternalTabActive).toBe(true); - }); - - it('activeExternalIcon returns default when no panel', () => { - app.activeTab = 'all'; - expect(app.activeExternalIcon).toBe('🧩'); - }); - - it('activeExternalTitle returns default when no panel', () => { - app.activeTab = 'all'; - expect(app.activeExternalTitle).toBe('Inspector'); - }); - - it('activeExternalError returns null for non-error panels', () => { - app.externalTabs = [{ id: 'external:store', panelId: 'store' }]; - app.externalPanels = { store: { id: 'store', status: 'ready' } }; - app.activeTab = 'external:store'; - expect(app.activeExternalError).toBeNull(); - }); - - it('activeExternalError returns error message for errored panels', () => { - app.externalTabs = [{ id: 'external:store', panelId: 'store' }]; - app.externalPanels = { store: { id: 'store', status: 'error', error: 'Failed to load' } }; - app.activeTab = 'external:store'; - expect(app.activeExternalError).toBe('Failed to load'); - }); -}); - -describe('App interaction helpers', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('hasInteractions returns false for empty log', () => { - app.interactionLog = []; - expect(app.hasInteractions).toBe(false); - }); - - it('hasInteractions returns true for non-empty log', () => { - app.interactionLog = [{ id: 'evt-1' }]; - expect(app.hasInteractions).toBe(true); - }); - - it('formattedInteractionLog returns log or empty array', () => { - app.interactionLog = [{ id: 'evt-1' }]; - expect(app.formattedInteractionLog).toEqual([{ id: 'evt-1' }]); - app.interactionLog = null; - expect(app.formattedInteractionLog).toEqual([]); - }); -}); - -describe('App property rows', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('getPropertyRows returns empty for no properties', () => { - expect(app.getPropertyRows([])).toEqual([]); - expect(app.getPropertyRows(null)).toEqual([]); - expect(app.getPropertyRows(undefined)).toEqual([]); - }); - - it('getPropertyRows flattens properties with depth', () => { - const props = [ - { name: 'a', value: 1 }, - { name: 'b', value: 2 }, - ]; - const rows = app.getPropertyRows(props); - expect(rows).toHaveLength(2); - expect(rows[0].property.name).toBe('a'); - expect(rows[0].depth).toBe(0); - }); - - it('getPropertyRows includes expanded nested properties', () => { - const props = [ - { - name: 'parent', - value: {}, - isExpanded: true, - expandedValue: { - properties: [ - { name: 'child', value: 'nested' } - ] - } - } - ]; - const rows = app.getPropertyRows(props); - expect(rows).toHaveLength(2); - expect(rows[0].depth).toBe(0); - expect(rows[1].property.name).toBe('child'); - expect(rows[1].depth).toBe(1); - }); -}); - -describe('App visible component nodes', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('visibleComponentNodes returns empty for no tree', () => { - app.componentTree = []; - expect(app.visibleComponentNodes).toEqual([]); - }); - - it('visibleComponentNodes includes all in list mode', () => { - app.viewMode = 'list'; - app.componentTree = [ - { - id: 'root', - name: 'root', - type: 'custom-element', - expanded: false, - children: [{ id: 'child', name: 'child', type: 'custom-element', children: [] }] - } - ]; - const nodes = app.visibleComponentNodes; - expect(nodes).toHaveLength(2); - }); - - it('visibleComponentNodes respects expanded state in tree mode', () => { - app.viewMode = 'tree'; - app.componentTree = [ - { - id: 'root', - name: 'root', - type: 'custom-element', - expanded: false, - children: [{ id: 'child', name: 'child', type: 'custom-element', children: [] }] - } - ]; - let nodes = app.visibleComponentNodes; - expect(nodes).toHaveLength(1); - - app.componentTree[0].expanded = true; - nodes = app.visibleComponentNodes; - expect(nodes).toHaveLength(2); - }); - - it('visibleComponentNodes expands all during search', () => { - app.viewMode = 'tree'; - app.searchQuery = 'child'; - app.componentTree = [ - { - id: 'root', - name: 'root', - type: 'custom-element', - expanded: false, - children: [{ id: 'child', name: 'child', type: 'custom-element', children: [] }] - } - ]; - const nodes = app.visibleComponentNodes; - expect(nodes).toHaveLength(2); - }); -}); - -describe('App filter tree by type', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('filterTreeByType filters elements only', () => { - const tree = [ - { id: 'e1', type: 'custom-element', children: [ - { id: 'a1', type: 'custom-attribute', children: [] } - ] } - ]; - const filtered = app.filterTreeByType(tree, 'custom-element'); - expect(filtered).toHaveLength(1); - expect(filtered[0].type).toBe('custom-element'); - expect(filtered[0].children).toHaveLength(0); - }); - - it('filterTreeByType filters attributes only', () => { - const tree = [ - { id: 'e1', type: 'custom-element', children: [ - { id: 'a1', type: 'custom-attribute', children: [] } - ] } - ]; - const filtered = app.filterTreeByType(tree, 'custom-attribute'); - expect(filtered).toHaveLength(1); - expect(filtered[0].type).toBe('custom-attribute'); - }); - - it('filterTreeByType lifts nested matching types', () => { - const tree = [ - { id: 'e1', type: 'custom-element', children: [ - { id: 'e2', type: 'custom-element', children: [ - { id: 'a1', type: 'custom-attribute', children: [] } - ] } - ] } - ]; - const filtered = app.filterTreeByType(tree, 'custom-attribute'); - expect(filtered).toHaveLength(1); - expect(filtered[0].id).toBe('a1'); - }); -}); - -describe('App isSameControllerInfo', () => { - let app: any; - - beforeEach(async () => { - ChromeTest.reset(); - const result = await createApp(); - app = result.app; - }); - - it('returns false for null inputs', () => { - expect(app.isSameControllerInfo(null, null)).toBe(false); - expect(app.isSameControllerInfo({ name: 'a' }, null)).toBe(false); - expect(app.isSameControllerInfo(null, { name: 'b' })).toBe(false); - }); - - it('matches by key', () => { - const a = { key: 'comp-key', name: 'comp' }; - const b = { key: 'comp-key', name: 'different' }; - expect(app.isSameControllerInfo(a, b)).toBe(true); - }); - - it('matches by name when keys missing', () => { - const a = { name: 'comp' }; - const b = { name: 'comp' }; - expect(app.isSameControllerInfo(a, b)).toBe(true); - }); - - it('matches by alias', () => { - const a = { name: 'comp-a', aliases: ['alias-shared'] }; - const b = { name: 'comp-b', aliases: ['alias-shared'] }; - expect(app.isSameControllerInfo(a, b)).toBe(true); - }); - - it('does not match different controllers', () => { - const a = { key: 'key-a', name: 'comp-a' }; - const b = { key: 'key-b', name: 'comp-b' }; - expect(app.isSameControllerInfo(a, b)).toBe(false); - }); -}); diff --git a/tests/app.spec.ts b/tests/app.spec.ts deleted file mode 100644 index efb5589..0000000 --- a/tests/app.spec.ts +++ /dev/null @@ -1,592 +0,0 @@ -import './setup'; -import { ChromeTest } from './setup'; -// We instantiate App without running field initializers to avoid DI -let AppClass: any; -import { stubDebugHost, stubPlatform, nextTick } from './helpers'; -import type { AureliaComponentTreeNode, AureliaInfo, IControllerInfo, Property } from '@/shared/types'; - -function ci(name: string, key?: string): IControllerInfo { - return { - name, - key: key ?? name, - aliases: [], - bindables: [], - properties: [] - } as any; -} - -function ai(element: IControllerInfo | null, attrs: IControllerInfo[] = []): AureliaInfo { - return { - customElementInfo: element as any, - customAttributesInfo: attrs as any - }; -} - -describe('App core logic', () => { - let app: App; - let debugHost: any; - let plat: any; - - beforeEach(async () => { - ChromeTest.reset(); - jest.resetModules(); - // Dynamically import App class - const mod = await import('@/app'); - AppClass = mod.App; - // Create instance without running field initializers (avoid DI resolve calls) - app = Object.create(AppClass.prototype); - // Seed essential fields - (app as any).coreTabs = [ - { id: 'all', label: 'All', icon: '🌲', kind: 'core' }, - { id: 'components', label: 'Components', icon: '📦', kind: 'core' }, - { id: 'attributes', label: 'Attributes', icon: '🔧', kind: 'core' }, - { id: 'interactions', label: 'Interactions', icon: '⏱️', kind: 'core' }, - ]; - (app as any).activeTab = 'all'; - (app as any).tabs = [...(app as any).coreTabs]; - (app as any).externalTabs = []; - (app as any).externalPanels = {}; - (app as any).externalPanelsVersion = 0; - (app as any).externalPanelLoading = {}; - (app as any).externalRefreshHandle = null; - (app as any).selectedElement = undefined; - (app as any).selectedElementAttributes = undefined; - (app as any).allAureliaObjects = undefined; - (app as any).componentTree = []; - (app as any).componentSnapshot = { tree: [], flat: [] }; - (app as any).selectedComponentId = undefined; - (app as any).selectedBreadcrumb = []; - (app as any).selectedNodeType = 'custom-element'; - (app as any).searchQuery = ''; - (app as any).searchMode = 'name'; - (app as any).viewMode = 'tree'; - (app as any).isElementPickerActive = false; - (app as any).interactionLog = []; - (app as any).interactionLoading = false; - (app as any).interactionError = null; - (app as any).aureliaDetected = false; - (app as any).aureliaVersion = null; - (app as any).detectionState = 'checking'; - - debugHost = stubDebugHost(); - plat = stubPlatform(); - // Inject stubs - (app as any).debugHost = debugHost; - (app as any).plat = plat; - }); - - function rawNode( - id: string, - element: IControllerInfo | null, - attrs: IControllerInfo[] = [], - children: AureliaComponentTreeNode[] = [], - domPath: string = '' - ): AureliaComponentTreeNode { - return { - id, - domPath, - tagName: element?.name ?? null, - customElementInfo: element, - customAttributesInfo: attrs, - children, - }; - } - - function applyTree(nodes: AureliaComponentTreeNode[]) { - (app as any).handleComponentSnapshot({ tree: nodes, flat: [] }); - return (app as any).componentTree as any[]; - } - - describe('handleComponentSnapshot', () => { - it('builds nodes, filters duplicate attrs by key/name, sets hasAttributes and sorts', () => { - const element = ci('todo-item', 'todo-item'); - const dupAttr = ci('todo-item', 'todo-item'); - const realAttr = ci('selectable', 'selectable'); - - const tree = applyTree([rawNode('todo', element, [dupAttr, realAttr])]); - - expect(tree).toHaveLength(1); - const node = tree[0]; - expect(node.type).toBe('custom-element'); - expect(node.hasAttributes).toBe(true); - expect(node.children).toHaveLength(1); - expect(node.children[0].type).toBe('custom-attribute'); - expect(node.children[0].name).toBe('selectable'); - }); - - it('lifts standalone custom attributes without parent element', () => { - const attrInfo = ci('draggable'); - const tree = applyTree([rawNode('attr-only', null as any, [attrInfo])]); - - expect(tree).toHaveLength(1); - expect(tree[0].type).toBe('custom-attribute'); - expect(tree[0].name).toBe('draggable'); - }); - }); - - describe('tab filtering and search filtering', () => { - function seedTree() { - return applyTree([ - rawNode('alpha', ci('alpha'), [ci('attr-a'), ci('attr-b')]), - rawNode('beta', ci('beta'), []), - ]); - } - - function nodesAreType(nodes: any[], type: 'custom-element' | 'custom-attribute'): boolean { - return nodes.every((node) => node.type === type && nodesAreType(node.children || [], type)); - } - - it('filteredComponentTreeByTab returns only elements for components tab', () => { - seedTree(); - app.activeTab = 'components'; - const filtered = app.filteredComponentTreeByTab; - expect(nodesAreType(filtered, 'custom-element')).toBe(true); - }); - - it('filteredComponentTreeByTab returns only attributes for attributes tab', () => { - seedTree(); - app.activeTab = 'attributes'; - const filtered = app.filteredComponentTreeByTab; - expect(nodesAreType(filtered, 'custom-attribute')).toBe(true); - }); - - it('search returns parents of matching descendants and auto-expands them', () => { - seedTree(); - app.activeTab = 'all'; - app.searchQuery = 'attr-b'; - const filtered = app.filteredComponentTreeByTab; - // There should be an element parent containing the matching attribute child - const parent = filtered.find(n => n.type === 'custom-element'); - expect(parent).toBeTruthy(); - expect(parent.expanded).toBe(true); - expect(parent.children.some((c: any) => c.name.toLowerCase().includes('attr-b'))).toBe(true); - }); - - it('clearSearch resets query and returns full tree', () => { - seedTree(); - app.searchQuery = 'beta'; - expect(app.filteredComponentTreeByTab.length).toBeGreaterThan(0); - app.clearSearch(); - expect(app.searchQuery).toBe(''); - expect(app.filteredComponentTreeByTab.length).toBeGreaterThan(0); - }); - }); - - describe('selection and expansion', () => { - it('selectComponent sets selectedElement and filters attrs for element node', () => { - const element = ci('alpha', 'alpha'); - const dupAttr = ci('alpha', 'alpha'); - const otherAttr = ci('x', 'x'); - const tree = applyTree([rawNode('alpha', element, [dupAttr, otherAttr])]); - - app.selectComponent(tree[0].id); - expect(app.selectedElement?.name).toBe('alpha'); - expect(app.selectedElementAttributes?.length).toBe(1); - expect(app.selectedElementAttributes?.[0].name).toBe('x'); - }); - - it('selectComponent sets selectedElement to attribute and clears attributes list for attribute node', () => { - const element = ci('alpha'); - const otherAttr = ci('x'); - const tree = applyTree([rawNode('alpha', element, [otherAttr])]); - const attrNode = tree[0].children[0]; - - app.selectComponent(attrNode.id); - expect(app.selectedElement?.name).toBe('x'); - expect(app.selectedElementAttributes).toEqual([]); - }); - - it('toggleComponentExpansion toggles expanded state', () => { - const tree = applyTree([rawNode('alpha', ci('alpha'))]); - const id = tree[0].id; - expect(tree[0].expanded).toBe(false); - app.toggleComponentExpansion(id); - expect(((app as any).findComponentById(id).expanded)).toBe(true); - }); - - it('onElementPicked selects component using DOM path when provided', () => { - const tree = applyTree([ - rawNode('root', ci('root'), [], [ - rawNode('child', ci('child'), [], [], 'html > body > child') - ], 'html > body > root') - ]); - - const pickInfo = { - customElementInfo: ci('child'), - customAttributesInfo: [], - __auDevtoolsDomPath: 'html > body > child' - } as any; - - app.onElementPicked(pickInfo); - - expect(app.selectedComponentId).toBe('child'); - expect(app.selectedElement?.name).toBe('child'); - }); - - it('handlePropertyRowClick toggles expansion when clicking label area', () => { - const prop: any = { canExpand: true, isExpanded: false }; - const event: any = { - target: document.createElement('span'), - stopPropagation: jest.fn(), - }; - const spy = jest.spyOn(app, 'togglePropertyExpansion'); - (event.target as HTMLElement).classList.add('property-name'); - - app.handlePropertyRowClick(prop, event); - - expect(spy).toHaveBeenCalledWith(prop); - spy.mockRestore(); - }); - - it('handlePropertyRowClick ignores clicks in value wrapper', () => { - const prop: any = { canExpand: true, isExpanded: false }; - const valueEl = document.createElement('span'); - valueEl.className = 'property-value-wrapper'; - const event: any = { - target: valueEl, - stopPropagation: jest.fn(), - }; - const spy = jest.spyOn(app, 'togglePropertyExpansion'); - - app.handlePropertyRowClick(prop, event); - - expect(spy).not.toHaveBeenCalled(); - spy.mockRestore(); - }); - }); - - describe('external panel integration', () => { - it('refreshExternalPanels merges snapshot into tabs', async () => { - const snapshot = { - version: 1, - panels: [{ id: 'store', label: 'Store Debugger', icon: '🧠', summary: 'hello' }], - }; - debugHost.getExternalPanelsSnapshot.mockResolvedValue(snapshot); - - await app.refreshExternalPanels(true); - - expect(app.tabs.some((tab: any) => tab.id === 'external:store')).toBe(true); - expect((app as any).externalPanels.store.summary).toBe('hello'); - }); - - it('switchTab triggers external refresh helpers', () => { - const spy = jest.spyOn(app, 'refreshExternalPanels').mockResolvedValue(undefined as any); - (app as any).externalTabs = [{ id: 'external:store', label: 'Store', icon: '🧩', kind: 'external', panelId: 'store' }]; - (app as any).tabs = [...(app as any).coreTabs, ...(app as any).externalTabs]; - - app.switchTab('external:store'); - - expect(spy).toHaveBeenCalledWith(true); - }); - - it('refreshActiveExternalTab emits request event', () => { - (app as any).externalTabs = [{ id: 'external:store', label: 'Store', icon: '🧩', kind: 'external', panelId: 'store' }]; - (app as any).tabs = [...(app as any).coreTabs, ...(app as any).externalTabs]; - debugHost.emitExternalPanelEvent.mockResolvedValue(true); - app.activeTab = 'external:store'; - - app.refreshActiveExternalTab(); - - expect(debugHost.emitExternalPanelEvent).toHaveBeenCalledWith( - 'aurelia-devtools:request-panel', - expect.objectContaining({ id: 'store' }) - ); - }); - - it('applySelectionFromNode notifies external panels when active', () => { - const node: any = { - id: 'alpha', - type: 'custom-element', - domPath: 'html > body:nth-of-type(1)', - children: [], - expanded: false, - hasAttributes: false, - name: 'alpha', - tagName: 'div', - data: { kind: 'element', info: ai(ci('alpha')), raw: null }, - }; - app.activeTab = 'external:store'; - - (app as any).applySelectionFromNode(node); - - expect(debugHost.emitExternalPanelEvent).toHaveBeenCalledWith( - 'aurelia-devtools:selection-changed', - expect.objectContaining({ selectedComponentId: 'alpha' }) - ); - }); - }); - - describe('interaction timeline', () => { - it('switchTab to interactions triggers log load', () => { - const spy = jest.spyOn(app, 'loadInteractionLog').mockResolvedValue(undefined as any); - - app.switchTab('interactions'); - - expect(spy).toHaveBeenCalledWith(true); - spy.mockRestore(); - }); - - it('applyInteractionSnapshot forwards phase', async () => { - await app.applyInteractionSnapshot('evt-2', 'before'); - expect(debugHost.applyInteractionSnapshot).toHaveBeenCalledWith('evt-2', 'before'); - }); - - it('clearInteractionLog clears and reloads', async () => { - const loadSpy = jest.spyOn(app, 'loadInteractionLog').mockResolvedValue(undefined as any); - - await app.clearInteractionLog(); - - expect(debugHost.clearInteractionLog).toHaveBeenCalled(); - // We clear optimistically; reload is best-effort so only assert the host call - loadSpy.mockRestore(); - }); - }); - - describe('property editing and expansion', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - function makeProp(type: Property['type'], value: any): any { - return { type, value, isEditing: true } as any; - } - - it('saveProperty converts number and calls updateValues', async () => { - const prop = makeProp('number', 1); - (app as any).selectedElement = { properties: [prop], bindables: [] }; - - app.saveProperty(prop, '42'); - // queueMicrotask is implemented with setTimeout(0) in stubPlatform - jest.runOnlyPendingTimers(); - - expect(prop.value).toBe(42); - expect(prop.isEditing).toBe(false); - expect(debugHost.updateValues).toHaveBeenCalled(); - }); - - it('saveProperty handles invalid number by revert', () => { - const original = 5; - const prop = { type: 'number', value: original, isEditing: true, originalValue: original } as any; - (app as any).selectedElement = { properties: [prop], bindables: [] }; - - app.saveProperty(prop, 'not-a-number'); - expect(prop.value).toBe(original); - expect(prop.isEditing).toBe(false); - }); - - it('saveProperty converts boolean', () => { - const prop = makeProp('boolean', false); - (app as any).selectedElement = { properties: [prop], bindables: [] }; - app.saveProperty(prop, 'true'); - jest.runOnlyPendingTimers(); - expect(prop.value).toBe(true); - }); - - it('togglePropertyExpansion loads expanded value via inspectedWindow.eval', async () => { - const prop: any = { type: 'object', value: {}, canExpand: true, isExpanded: false, debugId: 7 }; - (app as any).selectedElement = { properties: [prop], bindables: [] }; - - ChromeTest.setEvalToReturn([{ result: { properties: [{ name: 'child', type: 'string', value: 'x' }] } }]); - - app.togglePropertyExpansion(prop); - // The eval callback is synchronous in our mock; microtask used to reassign references - jest.runOnlyPendingTimers(); - - expect(prop.isExpanded).toBe(true); - expect(prop.expandedValue).toBeTruthy(); - expect(((app as any).selectedElement.properties)[0]).toEqual(expect.objectContaining({ isExpanded: true })); - }); - }); - - describe('highlighting delegates to debugHost', () => { - it('highlightComponent forwards minimal payload', () => { - const tree = applyTree([rawNode('alpha', ci('alpha'), [ci('x')])]); - app.highlightComponent(tree[0]); - expect(debugHost.highlightComponent).toHaveBeenCalledWith(expect.objectContaining({ - name: 'alpha', - type: 'custom-element', - customElementInfo: expect.objectContaining({ name: 'alpha' }), - })); - }); - - it('unhighlightComponent forwards to debugHost', () => { - app.unhighlightComponent(); - expect(debugHost.unhighlightComponent).toHaveBeenCalled(); - }); - }); - - describe('property watching and reactivity', () => { - function rawNode( - id: string, - element: IControllerInfo | null, - attrs: IControllerInfo[] = [], - children: AureliaComponentTreeNode[] = [], - domPath: string = '' - ): AureliaComponentTreeNode { - return { - id, - domPath, - tagName: element?.name ?? null, - customElementInfo: element, - customAttributesInfo: attrs, - children, - }; - } - - function applyTree(nodes: AureliaComponentTreeNode[]) { - (app as any).handleComponentSnapshot({ tree: nodes, flat: [] }); - return (app as any).componentTree as any[]; - } - - it('selectComponent starts property watching with component key', () => { - const tree = applyTree([rawNode('alpha', ci('alpha', 'alpha-key'))]); - app.selectComponent(tree[0].id); - - expect(debugHost.startPropertyWatching).toHaveBeenCalledWith({ - componentKey: 'alpha-key', - pollInterval: 500, - }); - }); - - it('selectComponent falls back to name if key not available', () => { - const element = ci('my-element'); - delete (element as any).key; - const tree = applyTree([rawNode('beta', element)]); - - app.selectComponent(tree[0].id); - - expect(debugHost.startPropertyWatching).toHaveBeenCalledWith({ - componentKey: 'my-element', - pollInterval: 500, - }); - }); - - it('handleComponentSnapshot stops property watching when selected node is removed', () => { - const tree = applyTree([rawNode('alpha', ci('alpha'))]); - app.selectComponent(tree[0].id); - - // Clear the component tree so selected component no longer exists - (app as any).handleComponentSnapshot({ tree: [], flat: [] }); - - expect(debugHost.stopPropertyWatching).toHaveBeenCalled(); - expect(app.selectedComponentId).toBeUndefined(); - }); - - it('onPropertyChanges updates bindable values', () => { - const bindable: any = { name: 'count', value: 0, type: 'number' }; - (app as any).selectedElement = { - key: 'test-key', - name: 'test', - bindables: [bindable], - properties: [], - }; - - const changes = [ - { - componentKey: 'test-key', - propertyName: 'count', - propertyType: 'bindable', - oldValue: 0, - newValue: 5, - timestamp: Date.now(), - }, - ]; - - const snapshot = { - componentKey: 'test-key', - bindables: [{ name: 'count', value: 5, type: 'number' }], - properties: [], - timestamp: Date.now(), - }; - - app.onPropertyChanges(changes as any, snapshot as any); - - expect(bindable.value).toBe(5); - }); - - it('onPropertyChanges updates property values', () => { - const property: any = { name: 'message', value: 'old', type: 'string' }; - (app as any).selectedElement = { - key: 'test-key', - name: 'test', - bindables: [], - properties: [property], - }; - - const changes = [ - { - componentKey: 'test-key', - propertyName: 'message', - propertyType: 'property', - oldValue: 'old', - newValue: 'new', - timestamp: Date.now(), - }, - ]; - - const snapshot = { - componentKey: 'test-key', - bindables: [], - properties: [{ name: 'message', value: 'new', type: 'string' }], - timestamp: Date.now(), - }; - - app.onPropertyChanges(changes as any, snapshot as any); - - expect(property.value).toBe('new'); - }); - - it('onPropertyChanges ignores changes for different component', () => { - const property: any = { name: 'message', value: 'original', type: 'string' }; - (app as any).selectedElement = { - key: 'selected-key', - name: 'selected', - bindables: [], - properties: [property], - }; - - const changes = [ - { - componentKey: 'different-key', - propertyName: 'message', - propertyType: 'property', - oldValue: 'old', - newValue: 'new', - timestamp: Date.now(), - }, - ]; - - const snapshot = { - componentKey: 'different-key', - bindables: [], - properties: [{ name: 'message', value: 'new', type: 'string' }], - timestamp: Date.now(), - }; - - app.onPropertyChanges(changes as any, snapshot as any); - - expect(property.value).toBe('original'); - }); - - it('onPropertyChanges does nothing when no selected element', () => { - (app as any).selectedElement = undefined; - - const changes = [ - { - componentKey: 'any-key', - propertyName: 'prop', - propertyType: 'property', - oldValue: 'old', - newValue: 'new', - timestamp: Date.now(), - }, - ]; - - expect(() => app.onPropertyChanges(changes as any, {} as any)).not.toThrow(); - }); - }); -}); diff --git a/tests/debug-host-coverage.spec.ts b/tests/debug-host-coverage.spec.ts deleted file mode 100644 index 7f94cf1..0000000 --- a/tests/debug-host-coverage.spec.ts +++ /dev/null @@ -1,517 +0,0 @@ -import './setup'; -import { ChromeTest } from './setup'; - -describe('DebugHost additional coverage', () => { - let DebugHost: any; - let debugHost: any; - let mockConsumer: any; - - beforeEach(async () => { - ChromeTest.restoreChrome(); - jest.resetModules(); - ChromeTest.reset(); - - const mod = await import('@/backend/debug-host'); - DebugHost = mod.DebugHost; - debugHost = new DebugHost(); - - mockConsumer = { - handleComponentSnapshot: jest.fn(), - onElementPicked: jest.fn(), - selectedElement: null, - selectedElementAttributes: null, - followChromeSelection: true, - onPropertyChanges: jest.fn(), - }; - }); - - afterEach(() => { - ChromeTest.restoreChrome(); - }); - - describe('attach with navigation and selection', () => { - it('handles navigation event when attached', async () => { - ChromeTest.setEvalToReturn([{ result: { kind: 'tree', data: [] } }]); - - debugHost.attach(mockConsumer); - - ChromeTest.triggerNavigated('http://localhost/new-page'); - await Promise.resolve(); - - expect(chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); - }); - - it('skips selection when followChromeSelection is false', () => { - mockConsumer.followChromeSelection = false; - debugHost.attach(mockConsumer); - - ChromeTest.triggerSelectionChanged(); - - expect(chrome.devtools.inspectedWindow.eval).not.toHaveBeenCalled(); - }); - - it('handles selection with debugObject using onElementPicked', () => { - debugHost.attach(mockConsumer); - - const debugObject = { - customElementInfo: { name: 'my-element' }, - customAttributesInfo: [{ name: 'my-attr' }], - }; - - ChromeTest.setEvalImplementation((_expr: string, cb: Function) => { - cb(debugObject); - }); - - ChromeTest.triggerSelectionChanged(); - - expect(mockConsumer.onElementPicked).toHaveBeenCalledWith(debugObject); - }); - - it('handles selection with null debugObject', () => { - debugHost.attach(mockConsumer); - - ChromeTest.setEvalImplementation((_expr: string, cb: Function) => { - cb(null); - }); - - ChromeTest.triggerSelectionChanged(); - - expect(mockConsumer.onElementPicked).not.toHaveBeenCalled(); - }); - - it('falls back to direct property assignment when onElementPicked not available', () => { - const simpleConsumer = { - handleComponentSnapshot: jest.fn(), - selectedElement: null, - selectedElementAttributes: null, - followChromeSelection: true, - }; - - debugHost.attach(simpleConsumer); - - const debugObject = { - customElementInfo: { name: 'my-element' }, - customAttributesInfo: [{ name: 'my-attr' }], - }; - - ChromeTest.setEvalImplementation((_expr: string, cb: Function) => { - cb(debugObject); - }); - - ChromeTest.triggerSelectionChanged(); - - expect(simpleConsumer.selectedElement).toEqual({ name: 'my-element' }); - expect(simpleConsumer.selectedElementAttributes).toEqual([{ name: 'my-attr' }]); - }); - }); - - describe('getAllComponents edge cases', () => { - it('handles null result from eval', async () => { - debugHost.consumer = mockConsumer; - ChromeTest.setEvalImplementation((_expr: string, cb: Function) => { - cb(null); - }); - - const result = await debugHost.getAllComponents(); - - expect(result).toEqual({ tree: [], flat: [] }); - }); - - it('handles flat data result', async () => { - debugHost.consumer = mockConsumer; - ChromeTest.setEvalImplementation((_expr: string, cb: Function) => { - cb({ kind: 'flat', data: [{ name: 'comp1' }, { name: 'comp2' }] }); - }); - - const result = await debugHost.getAllComponents(); - - expect(result).toEqual({ tree: [], flat: [{ name: 'comp1' }, { name: 'comp2' }] }); - }); - - it('handles tree data result', async () => { - debugHost.consumer = mockConsumer; - ChromeTest.setEvalImplementation((_expr: string, cb: Function) => { - cb({ kind: 'tree', data: [{ id: 'root', children: [] }] }); - }); - - const result = await debugHost.getAllComponents(); - - expect(result).toEqual({ tree: [{ id: 'root', children: [] }], flat: [] }); - }); - - it('handles non-array data gracefully', async () => { - debugHost.consumer = mockConsumer; - ChromeTest.setEvalImplementation((_expr: string, cb: Function) => { - cb({ kind: 'tree', data: 'not-an-array' }); - }); - - const result = await debugHost.getAllComponents(); - - expect(result).toEqual({ tree: [], flat: [] }); - }); - - it('returns empty when chrome is not available', async () => { - ChromeTest.removeChrome(); - debugHost.consumer = mockConsumer; - - const result = await debugHost.getAllComponents(); - - expect(result).toEqual({ tree: [], flat: [] }); - ChromeTest.restoreChrome(); - }); - }); - - describe('updateValues', () => { - it('calls eval with correct code', () => { - debugHost.consumer = mockConsumer; - - const value = { name: 'comp', key: 'comp-key' }; - const property = { name: 'count', value: 5 }; - - debugHost.updateValues(value, property); - - expect(chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); - const code = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls[0][0]; - expect(code).toContain('updateValues'); - expect(code).toContain('comp'); - }); - }); - - describe('updateDebugValue', () => { - it('wraps string values in quotes', () => { - debugHost.consumer = mockConsumer; - - const debugInfo = { debugId: 1, value: 'hello', type: 'string' }; - debugHost.updateDebugValue(debugInfo); - - expect(chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); - const code = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls[0][0]; - expect(code).toContain("'hello'"); - }); - - it('does not wrap non-string values', () => { - debugHost.consumer = mockConsumer; - - const debugInfo = { debugId: 1, value: 42, type: 'number' }; - debugHost.updateDebugValue(debugInfo); - - expect(chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); - const code = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls[0][0]; - expect(code).toContain('42'); - expect(code).not.toContain("'42'"); - }); - }); - - describe('toggleDebugValueExpansion', () => { - it('expands expandable property without existing value', () => { - debugHost.consumer = mockConsumer; - - const debugInfo = { debugId: 1, canExpand: true, isExpanded: false, expandedValue: null }; - - ChromeTest.setEvalImplementation((_expr: string, cb: Function) => { - cb({ properties: [{ name: 'child', value: 'test' }] }); - }); - - debugHost.toggleDebugValueExpansion(debugInfo); - - expect(debugInfo.isExpanded).toBe(true); - expect(chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); - }); - - it('collapses already expanded property', () => { - debugHost.consumer = mockConsumer; - - const debugInfo = { - debugId: 1, - canExpand: true, - isExpanded: true, - expandedValue: { properties: [] }, - }; - - debugHost.toggleDebugValueExpansion(debugInfo); - - expect(debugInfo.isExpanded).toBe(false); - }); - - it('does nothing for non-expandable property', () => { - debugHost.consumer = mockConsumer; - - const debugInfo = { debugId: 1, canExpand: false, isExpanded: false }; - - debugHost.toggleDebugValueExpansion(debugInfo); - - expect(debugInfo.isExpanded).toBe(false); - expect(chrome.devtools.inspectedWindow.eval).not.toHaveBeenCalled(); - }); - }); - - describe('revealInElements', () => { - it('calls eval with inspect code', () => { - debugHost.consumer = mockConsumer; - - const componentInfo = { name: 'my-element', key: 'my-key' }; - debugHost.revealInElements(componentInfo); - - expect(chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); - const code = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls[0][0]; - expect(code).toContain('findElementByComponentInfo'); - expect(code).toContain('inspect'); - }); - }); - - describe('property watching', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('starts property watching with specified interval', async () => { - debugHost.consumer = mockConsumer; - - ChromeTest.setEvalToReturn([{ result: { componentKey: 'comp-key', bindables: [], properties: [], timestamp: 1 } }]); - - debugHost.startPropertyWatching({ componentKey: 'comp-key', pollInterval: 200 }); - - expect(debugHost.watchingComponentKey).toBe('comp-key'); - - jest.advanceTimersByTime(200); - - expect(chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); - - debugHost.stopPropertyWatching(); - }); - - it('stops property watching and clears state', () => { - debugHost.consumer = mockConsumer; - debugHost.watchingComponentKey = 'comp-key'; - debugHost.propertyWatchInterval = setInterval(() => {}, 1000); - - debugHost.stopPropertyWatching(); - - expect(debugHost.watchingComponentKey).toBeNull(); - expect(debugHost.propertyWatchInterval).toBeNull(); - }); - - it('detects property changes and notifies consumer', async () => { - debugHost.consumer = mockConsumer; - debugHost.watchingComponentKey = 'comp-key'; - - const oldSnapshot = { - componentKey: 'comp-key', - bindables: [{ name: 'count', value: 1, type: 'number' }], - properties: [], - timestamp: 1, - }; - const newSnapshot = { - componentKey: 'comp-key', - bindables: [{ name: 'count', value: 2, type: 'number' }], - properties: [], - timestamp: 2, - }; - - debugHost.lastPropertySnapshot = oldSnapshot; - - ChromeTest.setEvalToReturn([{ result: newSnapshot }]); - - await debugHost.checkForPropertyChanges(); - - expect(mockConsumer.onPropertyChanges).toHaveBeenCalled(); - const [changes] = mockConsumer.onPropertyChanges.mock.calls[0]; - expect(changes).toHaveLength(1); - expect(changes[0].propertyName).toBe('count'); - expect(changes[0].oldValue).toBe(1); - expect(changes[0].newValue).toBe(2); - }); - - it('does not notify when no changes', async () => { - debugHost.consumer = mockConsumer; - debugHost.watchingComponentKey = 'comp-key'; - - const snapshot = { - componentKey: 'comp-key', - bindables: [{ name: 'count', value: 1, type: 'number' }], - properties: [], - timestamp: 1, - }; - - debugHost.lastPropertySnapshot = snapshot; - - ChromeTest.setEvalToReturn([{ result: snapshot }]); - - await debugHost.checkForPropertyChanges(); - - expect(mockConsumer.onPropertyChanges).not.toHaveBeenCalled(); - }); - - it('handles null snapshot during change check', async () => { - debugHost.consumer = mockConsumer; - debugHost.watchingComponentKey = 'comp-key'; - debugHost.lastPropertySnapshot = null; - - ChromeTest.setEvalToReturn([{ result: null }]); - - await debugHost.checkForPropertyChanges(); - - expect(mockConsumer.onPropertyChanges).not.toHaveBeenCalled(); - }); - }); - - describe('refreshSelectedComponent', () => { - it('returns null when no component is being watched', async () => { - debugHost.consumer = mockConsumer; - debugHost.watchingComponentKey = null; - - const result = await debugHost.refreshSelectedComponent(); - - expect(result).toBeNull(); - }); - - it('returns component info when watching', async () => { - debugHost.consumer = mockConsumer; - debugHost.watchingComponentKey = 'comp-key'; - - const componentInfo = { name: 'my-component', key: 'comp-key' }; - ChromeTest.setEvalToReturn([{ result: componentInfo }]); - - const result = await debugHost.refreshSelectedComponent(); - - expect(result).toEqual(componentInfo); - }); - - it('returns null when chrome is not available', async () => { - ChromeTest.removeChrome(); - debugHost.consumer = mockConsumer; - debugHost.watchingComponentKey = 'comp-key'; - - const result = await debugHost.refreshSelectedComponent(); - - expect(result).toBeNull(); - ChromeTest.restoreChrome(); - }); - }); - - describe('checkComponentTreeChanges', () => { - it('returns true when tree signature changes', async () => { - debugHost.consumer = mockConsumer; - debugHost.componentTreeSignature = 'old-signature'; - - ChromeTest.setEvalToReturn([{ result: 'new-signature' }]); - - const result = await debugHost.checkComponentTreeChanges(); - - expect(result).toBe(true); - expect(debugHost.componentTreeSignature).toBe('new-signature'); - }); - - it('returns false when tree signature unchanged', async () => { - debugHost.consumer = mockConsumer; - debugHost.componentTreeSignature = 'same-signature'; - - ChromeTest.setEvalToReturn([{ result: 'same-signature' }]); - - const result = await debugHost.checkComponentTreeChanges(); - - expect(result).toBe(false); - }); - - it('returns false when chrome is not available', async () => { - ChromeTest.removeChrome(); - debugHost.consumer = mockConsumer; - - const result = await debugHost.checkComponentTreeChanges(); - - expect(result).toBe(false); - ChromeTest.restoreChrome(); - }); - }); - - describe('valuesEqual', () => { - it('returns true for identical primitives', () => { - expect(debugHost.valuesEqual(1, 1)).toBe(true); - expect(debugHost.valuesEqual('a', 'a')).toBe(true); - expect(debugHost.valuesEqual(true, true)).toBe(true); - }); - - it('returns false for different primitives', () => { - expect(debugHost.valuesEqual(1, 2)).toBe(false); - expect(debugHost.valuesEqual('a', 'b')).toBe(false); - }); - - it('returns false for different types', () => { - expect(debugHost.valuesEqual(1, '1')).toBe(false); - expect(debugHost.valuesEqual(null, undefined)).toBe(false); - }); - - it('returns true for equal objects', () => { - expect(debugHost.valuesEqual({ a: 1 }, { a: 1 })).toBe(true); - }); - - it('returns false for different objects', () => { - expect(debugHost.valuesEqual({ a: 1 }, { a: 2 })).toBe(false); - }); - - it('handles null values', () => { - expect(debugHost.valuesEqual(null, null)).toBe(true); - expect(debugHost.valuesEqual(null, {})).toBe(false); - }); - }); - - describe('diffPropertySnapshots', () => { - it('detects new properties', () => { - const oldSnapshot = { - componentKey: 'comp', - bindables: [], - properties: [], - timestamp: 1, - }; - const newSnapshot = { - componentKey: 'comp', - bindables: [{ name: 'newProp', value: 1, type: 'number' }], - properties: [], - timestamp: 2, - }; - - const changes = debugHost.diffPropertySnapshots(oldSnapshot, newSnapshot); - - expect(changes).toHaveLength(1); - expect(changes[0].propertyName).toBe('newProp'); - expect(changes[0].oldValue).toBeUndefined(); - expect(changes[0].newValue).toBe(1); - }); - - it('detects changed properties in both bindables and properties', () => { - const oldSnapshot = { - componentKey: 'comp', - bindables: [{ name: 'a', value: 1, type: 'number' }], - properties: [{ name: 'b', value: 'old', type: 'string' }], - timestamp: 1, - }; - const newSnapshot = { - componentKey: 'comp', - bindables: [{ name: 'a', value: 2, type: 'number' }], - properties: [{ name: 'b', value: 'new', type: 'string' }], - timestamp: 2, - }; - - const changes = debugHost.diffPropertySnapshots(oldSnapshot, newSnapshot); - - expect(changes).toHaveLength(2); - expect(changes.map(c => c.propertyName)).toContain('a'); - expect(changes.map(c => c.propertyName)).toContain('b'); - }); - }); -}); - -describe('SelectionChanged class', () => { - it('stores debugInfo in constructor', async () => { - const mod = await import('@/backend/debug-host'); - const debugInfo = { name: 'component', key: 'comp-key' }; - - const event = new mod.SelectionChanged(debugInfo as any); - - expect(event.debugInfo).toBe(debugInfo); - }); -}); diff --git a/tests/debug-host-extra.spec.ts b/tests/debug-host-extra.spec.ts deleted file mode 100644 index af6562e..0000000 --- a/tests/debug-host-extra.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -import './setup'; -import { ChromeTest } from './setup'; -import { DebugHost } from '@/backend/debug-host'; - -class ConsumerStub { - followChromeSelection = false; - onElementPicked = jest.fn(); - handleComponentSnapshot = jest.fn(); - componentSnapshot = { tree: [], flat: [] }; -} - -describe('DebugHost additional behaviors', () => { - let host: DebugHost; - let consumer: ConsumerStub; - - beforeEach(() => { - ChromeTest.reset(); - consumer = new ConsumerStub(); - host = new DebugHost(); - // @ts-ignore - host.attach(consumer as any); - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('respects followChromeSelection=false and does not eval on selection change', () => { - ChromeTest.triggerSelectionChanged(); - expect(chrome.devtools.inspectedWindow.eval).not.toHaveBeenCalled(); - }); - - it('element picker start/stop triggers eval and polling is stopped after stop', () => { - const evalMock = chrome.devtools.inspectedWindow.eval as jest.Mock; - expect(evalMock).not.toHaveBeenCalled(); - - host.startElementPicker(); - // Picker code is injected immediately once - expect(evalMock).toHaveBeenCalled(); - const callsAfterStart = evalMock.mock.calls.length; - - // Polling will call eval periodically to check for picked component - jest.advanceTimersByTime(350); - const callsDuringPolling = evalMock.mock.calls.length; - expect(callsDuringPolling).toBeGreaterThan(callsAfterStart); - - host.stopElementPicker(); - const callsAfterStop = evalMock.mock.calls.length; - // Further timer advances should not increase call count - jest.advanceTimersByTime(300); - expect(evalMock.mock.calls.length).toBe(callsAfterStop); - }); - - it('picks a component via single poll and calls consumer.onElementPicked', () => { - // Make eval return a picked component when checkForPickedComponent runs - ChromeTest.setEvalImplementation((expr: string, cb?: (r: any) => void) => { - if (expr && expr.includes('__AURELIA_DEVTOOLS_PICKED_COMPONENT__')) { - cb && cb({ customElementInfo: { name: 'picked', key: 'picked', bindables: [], properties: [], aliases: [] }, customAttributesInfo: [] }); - } - }); - - // Directly invoke poller once - (host as any).checkForPickedComponent(); - expect(consumer.onElementPicked).toHaveBeenCalled(); - }); - - it('getInteractionLog proxies through inspectedWindow', async () => { - const log = [{ id: 'evt-1', eventName: 'click' }]; - ChromeTest.setEvalToReturn([{ result: log }]); - - const result = await host.getInteractionLog(); - - expect(result).toEqual(log); - const evalArg = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls.pop()?.[0]; - expect(evalArg).toContain('getInteractionLog'); - }); - - it('replayInteraction delegates to hook', async () => { - ChromeTest.setEvalToReturn([{ result: true }]); - - const ok = await host.replayInteraction('evt-1'); - - expect(ok).toBe(true); - const evalArg = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls.pop()?.[0]; - expect(evalArg).toContain('replayInteraction'); - expect(evalArg).toContain('evt-1'); - }); - - it('applyInteractionSnapshot forwards phase', async () => { - ChromeTest.setEvalToReturn([{ result: true }]); - - const ok = await host.applyInteractionSnapshot('evt-2', 'after'); - - expect(ok).toBe(true); - const evalArg = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls.pop()?.[0]; - expect(evalArg).toContain('applyInteractionSnapshot'); - expect(evalArg).toContain('after'); - }); - - it('clearInteractionLog calls hook clear', async () => { - ChromeTest.setEvalToReturn([{ result: true }]); - - const ok = await host.clearInteractionLog(); - - expect(ok).toBe(true); - const evalArg = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls.pop()?.[0]; - expect(evalArg).toContain('clearInteractionLog'); - }); -}); diff --git a/tests/debug-host.spec.ts b/tests/debug-host.spec.ts deleted file mode 100644 index 4ce76cc..0000000 --- a/tests/debug-host.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import './setup'; -import { ChromeTest } from './setup'; -import { DebugHost } from '@/backend/debug-host'; - -/** Minimal App consumer stub */ -class AppConsumerStub { - componentSnapshot = { tree: [], flat: [] }; - handleComponentSnapshot = jest.fn((snapshot) => { - this.componentSnapshot = snapshot; - }); - onElementPicked = jest.fn(); -} - -describe('DebugHost', () => { - let host: DebugHost; - let consumer: AppConsumerStub; - - beforeEach(() => { - ChromeTest.reset(); - consumer = new AppConsumerStub(); - host = new DebugHost(); - // attach will set up listeners and schedule initial load, but we can ignore timers here - // @ts-ignore - host.attach(consumer as any); - }); - - it('getAllComponents falls back when initial eval returns empty twice', async () => { - const fallback = [{ customElementInfo: { name: 'x', key: 'k', bindables: [], properties: [], aliases: [] }, customAttributesInfo: [] }]; - ChromeTest.setEvalToReturn([ - { result: [] }, - { result: { kind: 'flat', data: [] } }, - { result: { kind: 'flat', data: fallback } }, - ]); - - const result = await host.getAllComponents(); - expect(result).toEqual({ tree: [], flat: fallback }); - }); - - it('getAllComponents returns immediate non-empty result', async () => { - const first = [{ - id: 'root', - domPath: 'html > body > root:nth-of-type(1)', - tagName: 'app-root', - customElementInfo: { name: 'app-root', key: 'app-root', bindables: [], properties: [], aliases: [] }, - customAttributesInfo: [], - children: [], - }]; - ChromeTest.setEvalToReturn([{ result: { kind: 'tree', data: first } }]); - - const result = await host.getAllComponents(); - expect(result).toEqual({ tree: first, flat: [] }); - }); - - it('injects DOM path metadata when syncing Chrome selection', () => { - ChromeTest.triggerSelectionChanged(); - expect(chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); - const code = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls[0][0]; - expect(code).toContain('__auDevtoolsDomPath'); - expect(code).toContain('getDomPath'); - }); - - it('highlightComponent/unhighlightComponent call inspectedWindow.eval with injected code', () => { - host.highlightComponent({ name: 'Comp' }); - expect(chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); - const code = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls[0][0]; - expect(code).toContain('aurelia-devtools-highlight'); - - (chrome.devtools.inspectedWindow.eval as jest.Mock).mockClear(); - host.unhighlightComponent(); - expect(chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); - const code2 = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls[0][0]; - expect(code2).toContain("querySelectorAll('.aurelia-devtools-highlight')"); - }); - - it('getExternalPanelsSnapshot returns fallback when hook unavailable', async () => { - ChromeTest.setEvalToReturn([{ result: { version: 0, panels: [] } }]); - const snapshot = await host.getExternalPanelsSnapshot(); - expect(snapshot).toEqual({ version: 0, panels: [] }); - }); - - it('emitExternalPanelEvent proxies to inspected window', async () => { - ChromeTest.setEvalToReturn([{ result: true }]); - const ok = await host.emitExternalPanelEvent('aurelia-devtools:request-panel', { selectedComponentId: 'alpha' }); - expect(ok).toBe(true); - const evalCode = (chrome.devtools.inspectedWindow.eval as jest.Mock).mock.calls.pop()?.[0]; - expect(evalCode).toContain('emitDevtoolsEvent'); - }); -}); diff --git a/tests/reactivity.spec.ts b/tests/reactivity.spec.ts deleted file mode 100644 index 4483552..0000000 --- a/tests/reactivity.spec.ts +++ /dev/null @@ -1,365 +0,0 @@ -import './setup'; -import { ChromeTest } from './setup'; -import { DebugHost } from '@/backend/debug-host'; -import { PropertyChangeRecord, PropertySnapshot } from '@/shared/types'; - -class ConsumerStub { - followChromeSelection = false; - onElementPicked = jest.fn(); - handleComponentSnapshot = jest.fn(); - onPropertyChanges = jest.fn(); - componentSnapshot = { tree: [], flat: [] }; -} - -describe('DebugHost property watching', () => { - let host: DebugHost; - let consumer: ConsumerStub; - - beforeEach(() => { - ChromeTest.reset(); - consumer = new ConsumerStub(); - host = new DebugHost(); - // @ts-ignore - host.attach(consumer as any); - jest.useFakeTimers(); - }); - - afterEach(() => { - host.stopPropertyWatching(); - jest.useRealTimers(); - }); - - describe('startPropertyWatching', () => { - it('should start polling for property changes', () => { - const evalMock = chrome.devtools.inspectedWindow.eval as jest.Mock; - evalMock.mockClear(); - - host.startPropertyWatching({ componentKey: 'test-component' }); - - // Initial snapshot fetch - expect(evalMock).toHaveBeenCalled(); - - // Advance timer to trigger polling - jest.advanceTimersByTime(600); - expect(evalMock.mock.calls.length).toBeGreaterThan(1); - }); - - it('should stop previous watcher when starting new one', () => { - host.startPropertyWatching({ componentKey: 'component-1' }); - const intervalsBefore = jest.getTimerCount(); - - host.startPropertyWatching({ componentKey: 'component-2' }); - - // Should not have more intervals than before - expect(jest.getTimerCount()).toBeLessThanOrEqual(intervalsBefore); - }); - - it('should respect custom poll interval', () => { - const evalMock = chrome.devtools.inspectedWindow.eval as jest.Mock; - evalMock.mockClear(); - - host.startPropertyWatching({ componentKey: 'test', pollInterval: 1000 }); - - // Initial call - const initialCalls = evalMock.mock.calls.length; - - // Advance less than poll interval - jest.advanceTimersByTime(500); - expect(evalMock.mock.calls.length).toBe(initialCalls); - - // Advance past poll interval - jest.advanceTimersByTime(600); - expect(evalMock.mock.calls.length).toBeGreaterThan(initialCalls); - }); - }); - - describe('stopPropertyWatching', () => { - it('should stop polling when called', () => { - const evalMock = chrome.devtools.inspectedWindow.eval as jest.Mock; - - host.startPropertyWatching({ componentKey: 'test' }); - evalMock.mockClear(); - - host.stopPropertyWatching(); - - jest.advanceTimersByTime(2000); - expect(evalMock).not.toHaveBeenCalled(); - }); - - it('should clear internal state', () => { - host.startPropertyWatching({ componentKey: 'test' }); - host.stopPropertyWatching(); - - expect((host as any).watchingComponentKey).toBeNull(); - expect((host as any).lastPropertySnapshot).toBeNull(); - }); - }); - - describe('property change detection', () => { - it('should detect and report property changes via direct call', () => { - // Test diffPropertySnapshots directly since polling involves promises - const oldSnapshot: PropertySnapshot = { - componentKey: 'test', - bindables: [{ name: 'value', value: 'old', type: 'string' }], - properties: [], - timestamp: 1000, - }; - - const newSnapshot: PropertySnapshot = { - componentKey: 'test', - bindables: [{ name: 'value', value: 'new', type: 'string' }], - properties: [], - timestamp: 2000, - }; - - // Set up the internal state manually - (host as any).lastPropertySnapshot = oldSnapshot; - (host as any).watchingComponentKey = 'test'; - - // Call diffPropertySnapshots directly - const changes = (host as any).diffPropertySnapshots(oldSnapshot, newSnapshot); - - expect(changes).toHaveLength(1); - expect(changes[0].propertyName).toBe('value'); - expect(changes[0].oldValue).toBe('old'); - expect(changes[0].newValue).toBe('new'); - }); - - it('should not report when values are unchanged', () => { - const snapshot: PropertySnapshot = { - componentKey: 'test', - bindables: [{ name: 'value', value: 'same', type: 'string' }], - properties: [], - timestamp: 1000, - }; - - const sameSnapshot: PropertySnapshot = { - componentKey: 'test', - bindables: [{ name: 'value', value: 'same', type: 'string' }], - properties: [], - timestamp: 2000, - }; - - const changes = (host as any).diffPropertySnapshots(snapshot, sameSnapshot); - expect(changes).toHaveLength(0); - }); - - it('should detect changes in both bindables and properties', () => { - const oldSnapshot: PropertySnapshot = { - componentKey: 'test', - bindables: [{ name: 'bindable1', value: 'a', type: 'string' }], - properties: [{ name: 'prop1', value: 1, type: 'number' }], - timestamp: 1000, - }; - - const newSnapshot: PropertySnapshot = { - componentKey: 'test', - bindables: [{ name: 'bindable1', value: 'b', type: 'string' }], - properties: [{ name: 'prop1', value: 2, type: 'number' }], - timestamp: 2000, - }; - - const changes = (host as any).diffPropertySnapshots(oldSnapshot, newSnapshot); - expect(changes).toHaveLength(2); - expect(changes.find((c: PropertyChangeRecord) => c.propertyType === 'bindable')).toBeDefined(); - expect(changes.find((c: PropertyChangeRecord) => c.propertyType === 'property')).toBeDefined(); - }); - - it('should return changes with correct component key and timestamp', () => { - const oldSnapshot: PropertySnapshot = { - componentKey: 'my-comp', - bindables: [], - properties: [{ name: 'count', value: 10, type: 'number' }], - timestamp: 1000, - }; - - const newSnapshot: PropertySnapshot = { - componentKey: 'my-comp', - bindables: [], - properties: [{ name: 'count', value: 20, type: 'number' }], - timestamp: 2000, - }; - - const changes = (host as any).diffPropertySnapshots(oldSnapshot, newSnapshot); - - expect(changes).toHaveLength(1); - expect(changes[0].componentKey).toBe('my-comp'); - expect(changes[0].timestamp).toBe(2000); - }); - }); - - describe('getPropertySnapshot', () => { - it('should fetch property snapshot via eval', async () => { - const mockSnapshot: PropertySnapshot = { - componentKey: 'my-component', - bindables: [{ name: 'foo', value: 'bar', type: 'string' }], - properties: [], - timestamp: Date.now(), - }; - - ChromeTest.setEvalImplementation((expr: string, cb?: (r: any) => void) => { - if (expr && expr.includes('getComponentByKey')) { - cb && cb(mockSnapshot); - } - }); - - const result = await host.getPropertySnapshot('my-component'); - expect(result).toEqual(mockSnapshot); - }); - - it('should return null when hook is not available', async () => { - ChromeTest.setEvalImplementation((expr: string, cb?: (r: any) => void) => { - cb && cb(null); - }); - - const result = await host.getPropertySnapshot('missing'); - expect(result).toBeNull(); - }); - }); - - describe('checkComponentTreeChanges', () => { - it('should detect tree structure changes', async () => { - let signature = 'initial'; - ChromeTest.setEvalImplementation((expr: string, cb?: (r: any) => void) => { - if (expr && expr.includes('getSignature')) { - cb && cb(signature); - } - }); - - // First call - establishes baseline - const firstResult = await host.checkComponentTreeChanges(); - expect(firstResult).toBe(true); // Changed from empty string - - // Second call - no change - const secondResult = await host.checkComponentTreeChanges(); - expect(secondResult).toBe(false); - - // Third call - signature changed - signature = 'changed'; - const thirdResult = await host.checkComponentTreeChanges(); - expect(thirdResult).toBe(true); - }); - - it('should return false when no chrome APIs available', async () => { - const originalChrome = (global as any).chrome; - (global as any).chrome = undefined; - - const result = await host.checkComponentTreeChanges(); - expect(result).toBe(false); - - (global as any).chrome = originalChrome; - }); - }); -}); - -describe('DebugHost value comparison', () => { - let host: DebugHost; - - beforeEach(() => { - ChromeTest.reset(); - host = new DebugHost(); - }); - - it('should compare primitive values correctly', () => { - const valuesEqual = (host as any).valuesEqual.bind(host); - - expect(valuesEqual(1, 1)).toBe(true); - expect(valuesEqual('a', 'a')).toBe(true); - expect(valuesEqual(true, true)).toBe(true); - expect(valuesEqual(null, null)).toBe(true); - - expect(valuesEqual(1, 2)).toBe(false); - expect(valuesEqual('a', 'b')).toBe(false); - expect(valuesEqual(true, false)).toBe(false); - expect(valuesEqual(null, undefined)).toBe(false); - }); - - it('should compare objects by JSON serialization', () => { - const valuesEqual = (host as any).valuesEqual.bind(host); - - expect(valuesEqual({ a: 1 }, { a: 1 })).toBe(true); - expect(valuesEqual([1, 2], [1, 2])).toBe(true); - - expect(valuesEqual({ a: 1 }, { a: 2 })).toBe(false); - expect(valuesEqual([1, 2], [2, 1])).toBe(false); - }); - - it('should handle different types', () => { - const valuesEqual = (host as any).valuesEqual.bind(host); - - expect(valuesEqual(1, '1')).toBe(false); - expect(valuesEqual({}, [])).toBe(false); - }); -}); - -describe('Property snapshot diffing', () => { - let host: DebugHost; - - beforeEach(() => { - ChromeTest.reset(); - host = new DebugHost(); - }); - - it('should detect added properties', () => { - const diffSnapshots = (host as any).diffPropertySnapshots.bind(host); - - const oldSnapshot: PropertySnapshot = { - componentKey: 'test', - bindables: [], - properties: [], - timestamp: 1000, - }; - - const newSnapshot: PropertySnapshot = { - componentKey: 'test', - bindables: [{ name: 'newProp', value: 'value', type: 'string' }], - properties: [], - timestamp: 2000, - }; - - const changes = diffSnapshots(oldSnapshot, newSnapshot); - expect(changes).toHaveLength(1); - expect(changes[0].propertyName).toBe('newProp'); - expect(changes[0].oldValue).toBeUndefined(); - expect(changes[0].newValue).toBe('value'); - }); - - it('should detect value changes', () => { - const diffSnapshots = (host as any).diffPropertySnapshots.bind(host); - - const oldSnapshot: PropertySnapshot = { - componentKey: 'test', - bindables: [], - properties: [{ name: 'count', value: 1, type: 'number' }], - timestamp: 1000, - }; - - const newSnapshot: PropertySnapshot = { - componentKey: 'test', - bindables: [], - properties: [{ name: 'count', value: 2, type: 'number' }], - timestamp: 2000, - }; - - const changes = diffSnapshots(oldSnapshot, newSnapshot); - expect(changes).toHaveLength(1); - expect(changes[0].propertyName).toBe('count'); - expect(changes[0].oldValue).toBe(1); - expect(changes[0].newValue).toBe(2); - expect(changes[0].propertyType).toBe('property'); - }); - - it('should return empty array when no changes', () => { - const diffSnapshots = (host as any).diffPropertySnapshots.bind(host); - - const snapshot: PropertySnapshot = { - componentKey: 'test', - bindables: [{ name: 'val', value: 'same', type: 'string' }], - properties: [], - timestamp: 1000, - }; - - const changes = diffSnapshots(snapshot, { ...snapshot, timestamp: 2000 }); - expect(changes).toHaveLength(0); - }); -}); diff --git a/tests/setup.ts b/tests/setup.ts index df3ec3e..1d3afc8 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -41,6 +41,7 @@ const inspectedWindowEval = jest.fn(); const createChromeMock = () => ({ runtime: { + id: 'test-extension-id', onMessage, sendMessage: jest.fn(), connect: jest.fn(() => ({ postMessage: jest.fn(), onMessage: mkEvent(), disconnect: jest.fn() })) @@ -88,9 +89,11 @@ export const ChromeTest = { return; } const queue = values.slice(); - (global as any).chrome.devtools.inspectedWindow.eval.mockImplementation((_expr: string, cb: Function) => { + (global as any).chrome.devtools.inspectedWindow.eval.mockImplementation((_expr: string, cb?: Function) => { const next = queue.length ? queue.shift()! : { result: undefined, exception: undefined }; - cb(next.result, next.exception); + if (typeof cb === 'function') { + cb(next.result, next.exception); + } }); }, reset: () => { diff --git a/tests/sidebar-app.spec.ts b/tests/sidebar-app.spec.ts new file mode 100644 index 0000000..bba3e9e --- /dev/null +++ b/tests/sidebar-app.spec.ts @@ -0,0 +1,1134 @@ +import './setup'; +import { ChromeTest } from './setup'; +import { stubPlatform } from './helpers'; +import type { AureliaInfo, IControllerInfo, Property } from '@/shared/types'; + +let SidebarAppClass: any; + +function stubSidebarDebugHost(overrides: Partial> = {}) { + return { + attach: jest.fn(), + startElementPicker: jest.fn(), + stopElementPicker: jest.fn(), + startPropertyWatching: jest.fn(), + stopPropertyWatching: jest.fn(), + updateValues: jest.fn(), + revealInElements: jest.fn(), + searchComponents: jest.fn().mockResolvedValue([]), + selectComponentByKey: jest.fn(), + getLifecycleHooks: jest.fn().mockResolvedValue(null), + getComputedProperties: jest.fn().mockResolvedValue([]), + getDependencies: jest.fn().mockResolvedValue(null), + getEnhancedDISnapshot: jest.fn().mockResolvedValue(null), + getRouteInfo: jest.fn().mockResolvedValue(null), + getSlotInfo: jest.fn().mockResolvedValue(null), + getComponentTree: jest.fn().mockResolvedValue([]), + startInteractionRecording: jest.fn().mockResolvedValue(true), + stopInteractionRecording: jest.fn().mockResolvedValue(true), + clearInteractionLog: jest.fn(), + getTemplateSnapshot: jest.fn().mockResolvedValue(null), + ...overrides + }; +} + +function ci(name: string, key?: string): IControllerInfo { + return { + name, + key: key ?? name, + aliases: [], + bindables: [], + properties: [] + } as any; +} + +function ai(element: IControllerInfo | null, attrs: IControllerInfo[] = []): AureliaInfo { + return { + customElementInfo: element as any, + customAttributesInfo: attrs as any + }; +} + +describe('SidebarApp', () => { + let app: any; + let debugHost: any; + let plat: any; + + beforeEach(async () => { + ChromeTest.reset(); + jest.resetModules(); + + const mod = await import('@/sidebar/sidebar-app'); + SidebarAppClass = mod.SidebarApp; + + app = Object.create(SidebarAppClass.prototype); + + app.isDarkTheme = false; + app.aureliaDetected = false; + app.aureliaVersion = null; + app.detectionState = 'checking'; + app.extensionInvalidated = false; + app.selectedElement = null; + app.selectedElementAttributes = []; + app.selectedNodeType = 'custom-element'; + app.selectedElementTagName = null; + app.isShowingBindingContext = false; + app.isElementPickerActive = false; + app.followChromeSelection = true; + app.searchQuery = ''; + app.searchResults = []; + app.isSearchOpen = false; + app.expandedSections = { + bindables: true, + properties: true, + controller: false, + attributes: false, + lifecycle: false, + computed: false, + dependencies: false, + route: false, + slots: false, + expression: false, + timeline: false, + template: false, + }; + app.componentTree = []; + app.expandedTreeNodes = new Set(); + app.selectedTreeNodeKey = null; + app.isTreePanelExpanded = true; + app.treeRevision = 0; + app.isRecording = false; + app.timelineEvents = []; + app.expandedTimelineEvents = new Set(); + app.templateSnapshot = null; + app.expandedBindings = new Set(); + app.expandedControllers = new Set(); + app.lifecycleHooks = null; + app.computedProperties = []; + app.dependencies = null; + app.routeInfo = null; + app.slotInfo = null; + app.expressionInput = ''; + app.expressionResult = ''; + app.expressionResultType = ''; + app.expressionError = ''; + app.expressionHistory = []; + app.copiedPropertyId = null; + app.propertyRowsRevision = 0; + + debugHost = stubSidebarDebugHost(); + plat = stubPlatform(); + + (app as any).debugHost = debugHost; + (app as any).plat = plat; + }); + + describe('onElementPicked', () => { + it('sets selectedElement for custom element', () => { + const element = ci('my-component', 'my-key'); + element.bindables = [{ name: 'value', value: 1, type: 'number' }] as any; + element.properties = [{ name: 'count', value: 5, type: 'number' }] as any; + + app.onElementPicked(ai(element)); + + expect(app.selectedElement).toBe(element); + expect(app.selectedNodeType).toBe('custom-element'); + expect(app.selectedElementAttributes).toEqual([]); + }); + + it('sets selectedElement for custom attribute when no element', () => { + const attr = ci('my-attr', 'attr-key'); + + app.onElementPicked(ai(null, [attr])); + + expect(app.selectedElement).toBe(attr); + expect(app.selectedNodeType).toBe('custom-attribute'); + }); + + it('clears selection when no component info', () => { + app.selectedElement = ci('existing'); + + app.onElementPicked(null as any); + + expect(app.selectedElement).toBeNull(); + }); + + it('tracks binding context info', () => { + const element = ci('parent-component'); + const info = { + ...ai(element), + __selectedElement: 'div', + __isBindingContext: true + }; + + app.onElementPicked(info); + + expect(app.selectedElementTagName).toBe('div'); + expect(app.isShowingBindingContext).toBe(true); + }); + + it('starts property watching when element selected', () => { + const element = ci('my-component', 'component-key'); + + app.onElementPicked(ai(element)); + + expect(debugHost.startPropertyWatching).toHaveBeenCalledWith({ + componentKey: 'component-key', + pollInterval: 500 + }); + }); + + it('filters out duplicate attributes with same key as element', () => { + const element = ci('my-element', 'my-key'); + const dupAttr = ci('my-element', 'my-key'); + const realAttr = ci('other-attr', 'other-key'); + + app.onElementPicked(ai(element, [dupAttr, realAttr])); + + expect(app.selectedElementAttributes).toHaveLength(2); + }); + }); + + describe('clearSelection', () => { + it('stops property watching and clears all selection state', () => { + app.selectedElement = ci('test'); + app.selectedElementAttributes = [ci('attr')]; + app.selectedElementTagName = 'div'; + app.isShowingBindingContext = true; + + app.clearSelection(); + + expect(debugHost.stopPropertyWatching).toHaveBeenCalled(); + expect(app.selectedElement).toBeNull(); + expect(app.selectedElementAttributes).toEqual([]); + expect(app.selectedElementTagName).toBeNull(); + expect(app.isShowingBindingContext).toBe(false); + }); + }); + + describe('toggleSection', () => { + it('toggles section expanded state', () => { + expect(app.expandedSections.bindables).toBe(true); + + app.toggleSection('bindables'); + + expect(app.expandedSections.bindables).toBe(false); + + app.toggleSection('bindables'); + + expect(app.expandedSections.bindables).toBe(true); + }); + }); + + describe('toggleElementPicker', () => { + it('starts picker when activating', () => { + app.isElementPickerActive = false; + + app.toggleElementPicker(); + + expect(app.isElementPickerActive).toBe(true); + expect(debugHost.startElementPicker).toHaveBeenCalled(); + }); + + it('stops picker when deactivating', () => { + app.isElementPickerActive = true; + + app.toggleElementPicker(); + + expect(app.isElementPickerActive).toBe(false); + expect(debugHost.stopElementPicker).toHaveBeenCalled(); + }); + }); + + describe('toggleFollowChromeSelection', () => { + it('toggles follow state', () => { + app.followChromeSelection = true; + + app.toggleFollowChromeSelection(); + + expect(app.followChromeSelection).toBe(false); + }); + }); + + describe('search', () => { + it('handleSearchInput opens search with results', async () => { + debugHost.searchComponents.mockResolvedValue([ + { key: 'comp-1', name: 'my-component', type: 'custom-element' } + ]); + + const event = { target: { value: 'my' } } as any; + app.handleSearchInput(event); + + expect(app.searchQuery).toBe('my'); + expect(app.isSearchOpen).toBe(true); + }); + + it('handleSearchInput clears results when query empty', () => { + app.searchResults = [{ key: '1', name: 'test', type: 'custom-element' }]; + app.isSearchOpen = true; + + const event = { target: { value: '' } } as any; + app.handleSearchInput(event); + + expect(app.searchResults).toEqual([]); + expect(app.isSearchOpen).toBe(false); + }); + + it('selectSearchResult calls debugHost and clears search', () => { + const result = { key: 'comp-key', name: 'my-comp', type: 'custom-element' as const }; + app.searchQuery = 'my'; + app.isSearchOpen = true; + + app.selectSearchResult(result); + + expect(debugHost.selectComponentByKey).toHaveBeenCalledWith('comp-key'); + expect(app.searchQuery).toBe(''); + expect(app.isSearchOpen).toBe(false); + }); + + it('clearSearch resets search state', () => { + app.searchQuery = 'test'; + app.searchResults = [{ key: '1', name: 'test', type: 'custom-element' }]; + app.isSearchOpen = true; + + app.clearSearch(); + + expect(app.searchQuery).toBe(''); + expect(app.searchResults).toEqual([]); + expect(app.isSearchOpen).toBe(false); + }); + }); + + describe('property editing', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('editProperty sets isEditing and stores original value', () => { + const prop: any = { name: 'test', value: 'original', type: 'string' }; + + app.editProperty(prop); + + expect(prop.isEditing).toBe(true); + expect(prop.originalValue).toBe('original'); + }); + + it('saveProperty converts number and calls updateValues', () => { + const prop: any = { name: 'count', value: 1, type: 'number', isEditing: true }; + app.selectedElement = { bindables: [], properties: [prop] }; + + app.saveProperty(prop, '42'); + jest.runOnlyPendingTimers(); + + expect(prop.value).toBe(42); + expect(prop.isEditing).toBe(false); + expect(debugHost.updateValues).toHaveBeenCalled(); + }); + + it('saveProperty reverts on invalid number', () => { + const prop: any = { name: 'count', value: 5, type: 'number', isEditing: true, originalValue: 5 }; + app.selectedElement = { bindables: [], properties: [prop] }; + + app.saveProperty(prop, 'not-a-number'); + + expect(prop.value).toBe(5); + expect(prop.isEditing).toBe(false); + }); + + it('saveProperty converts boolean true', () => { + const prop: any = { name: 'active', value: false, type: 'boolean', isEditing: true }; + app.selectedElement = { bindables: [], properties: [prop] }; + + app.saveProperty(prop, 'true'); + jest.runOnlyPendingTimers(); + + expect(prop.value).toBe(true); + }); + + it('saveProperty converts boolean false', () => { + const prop: any = { name: 'active', value: true, type: 'boolean', isEditing: true }; + app.selectedElement = { bindables: [], properties: [prop] }; + + app.saveProperty(prop, 'false'); + jest.runOnlyPendingTimers(); + + expect(prop.value).toBe(false); + }); + + it('saveProperty reverts on invalid boolean', () => { + const prop: any = { name: 'active', value: true, type: 'boolean', isEditing: true, originalValue: true }; + app.selectedElement = { bindables: [], properties: [prop] }; + + app.saveProperty(prop, 'invalid'); + + expect(prop.value).toBe(true); + }); + + it('cancelPropertyEdit reverts property', () => { + const prop: any = { name: 'test', value: 'new', type: 'string', isEditing: true, originalValue: 'original' }; + + app.cancelPropertyEdit(prop); + + expect(prop.value).toBe('original'); + expect(prop.isEditing).toBe(false); + }); + }); + + describe('property expansion', () => { + it('togglePropertyExpansion does nothing when canExpand is false', () => { + const prop: any = { canExpand: false, isExpanded: false }; + + app.togglePropertyExpansion(prop); + + expect(prop.isExpanded).toBe(false); + }); + + it('togglePropertyExpansion expands when already has expandedValue', () => { + const prop: any = { canExpand: true, isExpanded: false, expandedValue: { properties: [] } }; + + app.togglePropertyExpansion(prop); + + expect(prop.isExpanded).toBe(true); + }); + + it('togglePropertyExpansion collapses when expanded', () => { + const prop: any = { canExpand: true, isExpanded: true }; + + app.togglePropertyExpansion(prop); + + expect(prop.isExpanded).toBe(false); + }); + }); + + describe('onPropertyChanges', () => { + it('updates bindable values', () => { + const bindable: any = { name: 'count', value: 0, type: 'number' }; + app.selectedElement = { + key: 'test-key', + name: 'test', + bindables: [bindable], + properties: [], + }; + + const changes = [{ + componentKey: 'test-key', + propertyName: 'count', + propertyType: 'bindable', + oldValue: 0, + newValue: 5, + timestamp: Date.now(), + }]; + + const snapshot = { + componentKey: 'test-key', + bindables: [{ name: 'count', value: 5, type: 'number' }], + properties: [], + timestamp: Date.now(), + }; + + app.onPropertyChanges(changes, snapshot); + + expect(bindable.value).toBe(5); + }); + + it('updates property values', () => { + const property: any = { name: 'message', value: 'old', type: 'string' }; + app.selectedElement = { + key: 'test-key', + name: 'test', + bindables: [], + properties: [property], + }; + + const changes = [{ + componentKey: 'test-key', + propertyName: 'message', + propertyType: 'property', + oldValue: 'old', + newValue: 'new', + timestamp: Date.now(), + }]; + + const snapshot = { + componentKey: 'test-key', + bindables: [], + properties: [{ name: 'message', value: 'new', type: 'string' }], + timestamp: Date.now(), + }; + + app.onPropertyChanges(changes, snapshot); + + expect(property.value).toBe('new'); + }); + + it('ignores changes for different component', () => { + const property: any = { name: 'message', value: 'original', type: 'string' }; + app.selectedElement = { + key: 'selected-key', + name: 'selected', + bindables: [], + properties: [property], + }; + + const changes = [{ + componentKey: 'different-key', + propertyName: 'message', + propertyType: 'property', + oldValue: 'old', + newValue: 'new', + timestamp: Date.now(), + }]; + + const snapshot = { + componentKey: 'different-key', + bindables: [], + properties: [], + timestamp: Date.now(), + }; + + app.onPropertyChanges(changes, snapshot); + + expect(property.value).toBe('original'); + }); + + it('does nothing when no selected element', () => { + app.selectedElement = null; + + const changes = [{ + componentKey: 'any-key', + propertyName: 'prop', + propertyType: 'property', + oldValue: 'old', + newValue: 'new', + timestamp: Date.now(), + }]; + + expect(() => app.onPropertyChanges(changes, {})).not.toThrow(); + }); + }); + + describe('computed getters', () => { + it('hasBindables returns true when bindables exist', () => { + app.selectedElement = { bindables: [{ name: 'test' }], properties: [] }; + expect(app.hasBindables).toBe(true); + }); + + it('hasBindables returns false when no bindables', () => { + app.selectedElement = { bindables: [], properties: [] }; + expect(app.hasBindables).toBe(false); + }); + + it('hasProperties returns true when properties exist', () => { + app.selectedElement = { bindables: [], properties: [{ name: 'test' }] }; + expect(app.hasProperties).toBe(true); + }); + + it('hasCustomAttributes returns true when attributes exist', () => { + app.selectedElementAttributes = [ci('test-attr')]; + expect(app.hasCustomAttributes).toBe(true); + }); + + it('hasLifecycleHooks returns true when hooks exist', () => { + app.lifecycleHooks = { hooks: [{ name: 'attached', implemented: true }] }; + expect(app.hasLifecycleHooks).toBe(true); + }); + + it('implementedHooksCount counts implemented hooks', () => { + app.lifecycleHooks = { + hooks: [ + { name: 'attached', implemented: true }, + { name: 'detached', implemented: false }, + { name: 'bound', implemented: true }, + ] + }; + expect(app.implementedHooksCount).toBe(2); + }); + + it('totalHooksCount returns all hooks', () => { + app.lifecycleHooks = { hooks: [{ name: 'a' }, { name: 'b' }, { name: 'c' }] }; + expect(app.totalHooksCount).toBe(3); + }); + + it('activeSlotCount counts slots with content', () => { + app.slotInfo = { + slots: [ + { name: 'default', hasContent: true }, + { name: 'header', hasContent: false }, + { name: 'footer', hasContent: true }, + ] + }; + expect(app.activeSlotCount).toBe(2); + }); + + it('hasRouteInfo returns true when currentRoute exists', () => { + app.routeInfo = { currentRoute: '/users' }; + expect(app.hasRouteInfo).toBe(true); + }); + + it('hasSlots returns true when slots exist', () => { + app.slotInfo = { slots: [{ name: 'default' }] }; + expect(app.hasSlots).toBe(true); + }); + + it('hasComputedProperties returns true when computed exist', () => { + app.computedProperties = [{ name: 'fullName', hasGetter: true }]; + expect(app.hasComputedProperties).toBe(true); + }); + + it('hasDependencies returns true when dependencies exist', () => { + app.dependencies = { dependencies: [{ name: 'HttpClient' }] }; + expect(app.hasDependencies).toBe(true); + }); + }); + + describe('formatPropertyValue', () => { + it('formats null', () => { + expect(app.formatPropertyValue(null)).toBe('null'); + }); + + it('formats undefined', () => { + expect(app.formatPropertyValue(undefined)).toBe('undefined'); + }); + + it('formats string with quotes', () => { + expect(app.formatPropertyValue('hello')).toBe('"hello"'); + }); + + it('formats array with length', () => { + expect(app.formatPropertyValue([1, 2, 3])).toBe('Array(3)'); + }); + + it('formats object as {...}', () => { + expect(app.formatPropertyValue({ a: 1 })).toBe('{...}'); + }); + + it('formats number as string', () => { + expect(app.formatPropertyValue(42)).toBe('42'); + }); + + it('formats boolean as string', () => { + expect(app.formatPropertyValue(true)).toBe('true'); + }); + }); + + describe('getPropertyTypeClass', () => { + it('returns type-string for string', () => { + expect(app.getPropertyTypeClass('string')).toBe('type-string'); + }); + + it('returns type-number for number', () => { + expect(app.getPropertyTypeClass('number')).toBe('type-number'); + }); + + it('returns type-boolean for boolean', () => { + expect(app.getPropertyTypeClass('boolean')).toBe('type-boolean'); + }); + + it('returns type-null for null', () => { + expect(app.getPropertyTypeClass('null')).toBe('type-null'); + }); + + it('returns type-object for object', () => { + expect(app.getPropertyTypeClass('object')).toBe('type-object'); + }); + + it('returns type-function for function', () => { + expect(app.getPropertyTypeClass('function')).toBe('type-function'); + }); + + it('returns type-default for unknown', () => { + expect(app.getPropertyTypeClass('unknown')).toBe('type-default'); + }); + }); + + describe('getPropertyRows', () => { + it('returns empty array for undefined properties', () => { + expect(app.getPropertyRows(undefined)).toEqual([]); + }); + + it('returns empty array for empty properties', () => { + expect(app.getPropertyRows([])).toEqual([]); + }); + + it('flattens properties with depth', () => { + const props = [ + { name: 'a', value: 1 }, + { name: 'b', value: 2 }, + ]; + + const rows = app.getPropertyRows(props); + + expect(rows).toHaveLength(2); + expect(rows[0].depth).toBe(0); + expect(rows[1].depth).toBe(0); + }); + + it('includes expanded nested properties', () => { + const props = [ + { + name: 'obj', + value: {}, + isExpanded: true, + expandedValue: { + properties: [ + { name: 'nested', value: 'inner' } + ] + } + } + ]; + + const rows = app.getPropertyRows(props); + + expect(rows).toHaveLength(2); + expect(rows[0].property.name).toBe('obj'); + expect(rows[0].depth).toBe(0); + expect(rows[1].property.name).toBe('nested'); + expect(rows[1].depth).toBe(1); + }); + }); + + describe('expression evaluation', () => { + it('selectHistoryExpression sets expressionInput', () => { + app.selectHistoryExpression('this.count'); + expect(app.expressionInput).toBe('this.count'); + }); + + it('clearExpressionResult clears all result state', () => { + app.expressionResult = 'some result'; + app.expressionResultType = 'string'; + app.expressionError = 'some error'; + + app.clearExpressionResult(); + + expect(app.expressionResult).toBe(''); + expect(app.expressionResultType).toBe(''); + expect(app.expressionError).toBe(''); + }); + }); + + describe('revealInElements', () => { + it('calls debugHost with component info', () => { + app.selectedElement = ci('my-component', 'my-key'); + app.selectedNodeType = 'custom-element'; + app.selectedElementAttributes = []; + + app.revealInElements(); + + expect(debugHost.revealInElements).toHaveBeenCalledWith({ + name: 'my-component', + type: 'custom-element', + customElementInfo: app.selectedElement, + customAttributesInfo: [], + }); + }); + + it('does nothing when no selectedElement', () => { + app.selectedElement = null; + + app.revealInElements(); + + expect(debugHost.revealInElements).not.toHaveBeenCalled(); + }); + }); + + describe('checkExtensionInvalidated', () => { + it('returns false when chrome.runtime.id exists', () => { + expect(app.checkExtensionInvalidated()).toBe(false); + expect(app.extensionInvalidated).toBe(false); + }); + + it('returns true and sets flag when chrome.runtime.id missing', () => { + const originalId = (global as any).chrome.runtime.id; + delete (global as any).chrome.runtime.id; + + expect(app.checkExtensionInvalidated()).toBe(true); + expect(app.extensionInvalidated).toBe(true); + + (global as any).chrome.runtime.id = originalId; + }); + }); + + describe('component tree', () => { + it('loadComponentTree populates componentTree from debugHost', async () => { + const mockTree = [ + { key: 'app', name: 'App', tagName: 'app', type: 'custom-element', hasChildren: true, childCount: 1 }, + { key: 'header', name: 'Header', tagName: 'header', type: 'custom-element', hasChildren: false, childCount: 0 }, + ]; + debugHost.getComponentTree.mockResolvedValue(mockTree); + + await app.loadComponentTree(); + + expect(app.componentTree).toEqual(mockTree); + expect(app.treeRevision).toBe(1); + }); + + it('loadComponentTree handles errors gracefully', async () => { + debugHost.getComponentTree.mockRejectedValue(new Error('Network error')); + + await app.loadComponentTree(); + + expect(app.componentTree).toEqual([]); + }); + + it('toggleTreePanel toggles isTreePanelExpanded', () => { + expect(app.isTreePanelExpanded).toBe(true); + + app.toggleTreePanel(); + expect(app.isTreePanelExpanded).toBe(false); + + app.toggleTreePanel(); + expect(app.isTreePanelExpanded).toBe(true); + }); + + it('toggleTreeNode expands node with children', () => { + const node = { key: 'app', name: 'App', hasChildren: true }; + + app.toggleTreeNode(node); + + expect(app.expandedTreeNodes.has('app')).toBe(true); + expect(app.treeRevision).toBe(1); + }); + + it('toggleTreeNode collapses already expanded node', () => { + const node = { key: 'app', name: 'App', hasChildren: true }; + app.expandedTreeNodes.add('app'); + + app.toggleTreeNode(node); + + expect(app.expandedTreeNodes.has('app')).toBe(false); + }); + + it('toggleTreeNode does nothing for node without children', () => { + const node = { key: 'leaf', name: 'Leaf', hasChildren: false }; + + app.toggleTreeNode(node); + + expect(app.expandedTreeNodes.has('leaf')).toBe(false); + }); + + it('selectTreeNode updates selection and calls debugHost', () => { + const node = { key: 'my-component', name: 'MyComponent' }; + + app.selectTreeNode(node); + + expect(app.selectedTreeNodeKey).toBe('my-component'); + expect(debugHost.selectComponentByKey).toHaveBeenCalledWith('my-component'); + }); + + it('getTreeRows returns empty for empty tree', () => { + app.componentTree = []; + expect(app.getTreeRows()).toEqual([]); + }); + + it('getTreeRows flattens tree with depth', () => { + app.componentTree = [ + { key: 'app', name: 'App', hasChildren: true, children: [ + { key: 'child', name: 'Child', hasChildren: false } + ]} + ]; + app.expandedTreeNodes.add('app'); + + const rows = app.getTreeRows(); + + expect(rows).toHaveLength(2); + expect(rows[0].node.key).toBe('app'); + expect(rows[0].depth).toBe(0); + expect(rows[1].node.key).toBe('child'); + expect(rows[1].depth).toBe(1); + }); + + it('getTreeRows excludes collapsed children', () => { + app.componentTree = [ + { key: 'app', name: 'App', hasChildren: true, children: [ + { key: 'child', name: 'Child', hasChildren: false } + ]} + ]; + + const rows = app.getTreeRows(); + + expect(rows).toHaveLength(1); + expect(rows[0].node.key).toBe('app'); + }); + + it('isTreeNodeExpanded returns correct state', () => { + const node = { key: 'test' }; + expect(app.isTreeNodeExpanded(node)).toBe(false); + + app.expandedTreeNodes.add('test'); + expect(app.isTreeNodeExpanded(node)).toBe(true); + }); + + it('isTreeNodeSelected returns correct state', () => { + const node = { key: 'test' }; + expect(app.isTreeNodeSelected(node)).toBe(false); + + app.selectedTreeNodeKey = 'test'; + expect(app.isTreeNodeSelected(node)).toBe(true); + }); + + it('hasComponentTree returns true when tree has nodes', () => { + app.componentTree = []; + expect(app.hasComponentTree).toBe(false); + + app.componentTree = [{ key: 'app', name: 'App' }]; + expect(app.hasComponentTree).toBe(true); + }); + + it('componentTreeCount counts all nodes including nested', () => { + app.componentTree = [ + { key: 'app', name: 'App', children: [ + { key: 'child1', name: 'Child1' }, + { key: 'child2', name: 'Child2', children: [ + { key: 'grandchild', name: 'GrandChild' } + ]} + ]}, + { key: 'footer', name: 'Footer' } + ]; + + expect(app.componentTreeCount).toBe(5); + }); + }); + + describe('timeline / interaction recorder', () => { + it('startRecording sets isRecording and calls debugHost', async () => { + await app.startRecording(); + + expect(app.isRecording).toBe(true); + expect(debugHost.startInteractionRecording).toHaveBeenCalled(); + }); + + it('stopRecording clears isRecording and calls debugHost', async () => { + app.isRecording = true; + + await app.stopRecording(); + + expect(app.isRecording).toBe(false); + expect(debugHost.stopInteractionRecording).toHaveBeenCalled(); + }); + + it('clearTimeline clears events and calls debugHost', () => { + app.timelineEvents = [{ id: 'evt-1' }, { id: 'evt-2' }] as any; + app.expandedTimelineEvents.add('evt-1'); + + app.clearTimeline(); + + expect(app.timelineEvents).toEqual([]); + expect(app.expandedTimelineEvents.size).toBe(0); + expect(debugHost.clearInteractionLog).toHaveBeenCalled(); + }); + + it('toggleTimelineEvent expands event', () => { + const event = { id: 'evt-1', eventName: 'click' } as any; + app.timelineEvents = [event]; + + app.toggleTimelineEvent(event); + + expect(app.expandedTimelineEvents.has('evt-1')).toBe(true); + }); + + it('toggleTimelineEvent collapses already expanded event', () => { + const event = { id: 'evt-1', eventName: 'click' } as any; + app.timelineEvents = [event]; + app.expandedTimelineEvents.add('evt-1'); + + app.toggleTimelineEvent(event); + + expect(app.expandedTimelineEvents.has('evt-1')).toBe(false); + }); + + it('isTimelineEventExpanded returns correct state', () => { + const event = { id: 'evt-1' } as any; + + expect(app.isTimelineEventExpanded(event)).toBe(false); + + app.expandedTimelineEvents.add('evt-1'); + expect(app.isTimelineEventExpanded(event)).toBe(true); + }); + + it('selectTimelineComponent calls debugHost when target has componentKey', () => { + const event = { id: 'evt-1', target: { componentKey: 'my-component' } } as any; + + app.selectTimelineComponent(event); + + expect(debugHost.selectComponentByKey).toHaveBeenCalledWith('my-component'); + }); + + it('selectTimelineComponent does nothing when no target componentKey', () => { + const event = { id: 'evt-1', target: null } as any; + + app.selectTimelineComponent(event); + + expect(debugHost.selectComponentByKey).not.toHaveBeenCalled(); + }); + + it('hasTimelineEvents returns true when events exist', () => { + app.timelineEvents = []; + expect(app.hasTimelineEvents).toBe(false); + + app.timelineEvents = [{ id: 'evt-1' }] as any; + expect(app.hasTimelineEvents).toBe(true); + }); + + it('timelineEventCount returns event count', () => { + app.timelineEvents = [{ id: '1' }, { id: '2' }, { id: '3' }] as any; + expect(app.timelineEventCount).toBe(3); + }); + + it('formatTimelineTimestamp formats timestamp with milliseconds', () => { + const timestamp = new Date('2024-01-15T10:30:45.123Z').getTime(); + const result = app.formatTimelineTimestamp(timestamp); + + expect(result).toMatch(/\d{2}:\d{2}:\d{2}\.\d{3}/); + }); + + it('getTimelineEventTypeClass returns correct class for event types', () => { + expect(app.getTimelineEventTypeClass('property-change')).toBe('event-property'); + expect(app.getTimelineEventTypeClass('lifecycle')).toBe('event-lifecycle'); + expect(app.getTimelineEventTypeClass('interaction')).toBe('event-interaction'); + expect(app.getTimelineEventTypeClass('binding')).toBe('event-binding'); + expect(app.getTimelineEventTypeClass('unknown')).toBe('event-default'); + }); + }); + + describe('template debugger', () => { + it('hasTemplateInfo returns false when no snapshot', () => { + app.templateSnapshot = null; + expect(app.hasTemplateInfo).toBe(false); + }); + + it('hasTemplateInfo returns false when snapshot has no bindings or controllers', () => { + app.templateSnapshot = { + componentKey: 'test', + componentName: 'test', + bindings: [], + controllers: [], + instructions: [], + hasSlots: false, + shadowMode: 'none', + isContainerless: false, + }; + expect(app.hasTemplateInfo).toBe(false); + }); + + it('hasTemplateInfo returns true when snapshot has bindings', () => { + app.templateSnapshot = { + componentKey: 'test', + componentName: 'test', + bindings: [{ id: 'b1', type: 'property', expression: 'foo', target: 'bar', value: 1, valueType: 'number', isBound: true }], + controllers: [], + instructions: [], + hasSlots: false, + shadowMode: 'none', + isContainerless: false, + } as any; + expect(app.hasTemplateInfo).toBe(true); + }); + + it('templateBindings returns empty array when no snapshot', () => { + app.templateSnapshot = null; + expect(app.templateBindings).toEqual([]); + }); + + it('templateBindings returns bindings from snapshot', () => { + const bindings = [{ id: 'b1', type: 'property', expression: 'foo' }]; + app.templateSnapshot = { bindings } as any; + expect(app.templateBindings).toEqual(bindings); + }); + + it('templateControllers returns empty array when no snapshot', () => { + app.templateSnapshot = null; + expect(app.templateControllers).toEqual([]); + }); + + it('templateControllers returns controllers from snapshot', () => { + const controllers = [{ id: 'c1', type: 'if', isActive: true }]; + app.templateSnapshot = { controllers } as any; + expect(app.templateControllers).toEqual(controllers); + }); + + it('toggleBindingExpand adds binding to expandedBindings', () => { + app.templateSnapshot = { bindings: [], controllers: [] } as any; + const binding = { id: 'b1' } as any; + + app.toggleBindingExpand(binding); + + expect(app.expandedBindings.has('b1')).toBe(true); + }); + + it('toggleBindingExpand removes binding from expandedBindings', () => { + app.templateSnapshot = { bindings: [], controllers: [] } as any; + app.expandedBindings.add('b1'); + const binding = { id: 'b1' } as any; + + app.toggleBindingExpand(binding); + + expect(app.expandedBindings.has('b1')).toBe(false); + }); + + it('isBindingExpanded returns correct state', () => { + const binding = { id: 'b1' } as any; + + expect(app.isBindingExpanded(binding)).toBe(false); + + app.expandedBindings.add('b1'); + expect(app.isBindingExpanded(binding)).toBe(true); + }); + + it('toggleControllerExpand adds controller to expandedControllers', () => { + app.templateSnapshot = { bindings: [], controllers: [] } as any; + const controller = { id: 'c1' } as any; + + app.toggleControllerExpand(controller); + + expect(app.expandedControllers.has('c1')).toBe(true); + }); + + it('isControllerExpanded returns correct state', () => { + const controller = { id: 'c1' } as any; + + expect(app.isControllerExpanded(controller)).toBe(false); + + app.expandedControllers.add('c1'); + expect(app.isControllerExpanded(controller)).toBe(true); + }); + + it('getBindingTypeIcon returns correct icons', () => { + expect(app.getBindingTypeIcon('property')).toBe('→'); + expect(app.getBindingTypeIcon('listener')).toBe('⚡'); + expect(app.getBindingTypeIcon('interpolation')).toBe('${}'); + expect(app.getBindingTypeIcon('unknown')).toBe('•'); + }); + + it('getBindingModeClass returns correct classes', () => { + expect(app.getBindingModeClass('oneTime')).toBe('mode-one-time'); + expect(app.getBindingModeClass('toView')).toBe('mode-to-view'); + expect(app.getBindingModeClass('fromView')).toBe('mode-from-view'); + expect(app.getBindingModeClass('twoWay')).toBe('mode-two-way'); + expect(app.getBindingModeClass('default')).toBe('mode-default'); + }); + + it('getBindingModeLabel returns correct labels', () => { + expect(app.getBindingModeLabel('oneTime')).toBe('one-time'); + expect(app.getBindingModeLabel('toView')).toBe('→'); + expect(app.getBindingModeLabel('fromView')).toBe('←'); + expect(app.getBindingModeLabel('twoWay')).toBe('↔'); + }); + + it('getControllerTypeIcon returns correct icons', () => { + expect(app.getControllerTypeIcon('if')).toBe('❓'); + expect(app.getControllerTypeIcon('repeat')).toBe('↻'); + expect(app.getControllerTypeIcon('unknown')).toBe('◆'); + }); + + it('formatBindingValue formats values correctly', () => { + expect(app.formatBindingValue(undefined)).toBe('undefined'); + expect(app.formatBindingValue(null)).toBe('null'); + expect(app.formatBindingValue('hello')).toBe('"hello"'); + expect(app.formatBindingValue(42)).toBe('42'); + expect(app.formatBindingValue({ a: 1 })).toBe('{"a":1}'); + }); + }); +}); diff --git a/tests/sidebar-debug-host.spec.ts b/tests/sidebar-debug-host.spec.ts new file mode 100644 index 0000000..dbb2b31 --- /dev/null +++ b/tests/sidebar-debug-host.spec.ts @@ -0,0 +1,542 @@ +import './setup'; +import { ChromeTest } from './setup'; + +let SidebarDebugHostClass: any; + +function createMockConsumer() { + return { + followChromeSelection: true, + onElementPicked: jest.fn(), + onPropertyChanges: jest.fn(), + }; +} + +describe('SidebarDebugHost', () => { + let host: any; + let consumer: any; + + beforeEach(async () => { + ChromeTest.reset(); + jest.resetModules(); + jest.useFakeTimers(); + + const mod = await import('@/sidebar/sidebar-debug-host'); + SidebarDebugHostClass = mod.SidebarDebugHost; + + host = new SidebarDebugHostClass(); + consumer = createMockConsumer(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('attach', () => { + it('sets consumer reference', () => { + host.attach(consumer); + expect(host.consumer).toBe(consumer); + }); + + it('registers selection changed listener', () => { + host.attach(consumer); + + expect((global as any).chrome.devtools.panels.elements.onSelectionChanged.hasListeners()).toBe(true); + }); + }); + + describe('updateValues', () => { + it('calls inspectedWindow.eval with correct arguments', () => { + const componentInfo = { name: 'test', key: 'test-key' }; + const property = { name: 'value', value: 42 }; + + host.updateValues(componentInfo, property); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + const call = (global as any).chrome.devtools.inspectedWindow.eval.mock.calls[0][0]; + expect(call).toContain('updateValues'); + }); + }); + + describe('element picker', () => { + beforeEach(() => { + host.attach(consumer); + }); + + it('startElementPicker injects picker code', () => { + host.startElementPicker(); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + const code = (global as any).chrome.devtools.inspectedWindow.eval.mock.calls[0][0]; + expect(code).toContain('aurelia-picker'); + }); + + it('startElementPicker starts polling', () => { + host.startElementPicker(); + + expect(host.pickerPollingInterval).not.toBeNull(); + }); + + it('stopElementPicker stops polling', () => { + host.startElementPicker(); + host.stopElementPicker(); + + expect(host.pickerPollingInterval).toBeNull(); + }); + + it('stopElementPicker calls cleanup function', () => { + host.stopElementPicker(); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + const code = (global as any).chrome.devtools.inspectedWindow.eval.mock.calls[0][0]; + expect(code).toContain('__aureliaDevtoolsStopPicker'); + }); + }); + + describe('property watching', () => { + beforeEach(() => { + host.attach(consumer); + }); + + it('startPropertyWatching sets watching state', async () => { + ChromeTest.setEvalToReturn([ + { result: false }, + { result: null } + ]); + + host.startPropertyWatching({ componentKey: 'test-key', pollInterval: 500 }); + + expect(host.watchingComponentKey).toBe('test-key'); + + await Promise.resolve(); + await Promise.resolve(); + + expect(host.propertyWatchInterval).not.toBeNull(); + }); + + it('startPropertyWatching uses event-driven watching when available', async () => { + ChromeTest.setEvalToReturn([{ result: true }]); + + host.startPropertyWatching({ componentKey: 'test-key', pollInterval: 500 }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(host.watchingComponentKey).toBe('test-key'); + expect((host as any).useEventDrivenWatching).toBe(true); + expect(host.propertyWatchInterval).toBeNull(); + }); + + it('stopPropertyWatching clears watching state', async () => { + ChromeTest.setEvalToReturn([ + { result: false }, + { result: null } + ]); + host.startPropertyWatching({ componentKey: 'test-key', pollInterval: 500 }); + + await Promise.resolve(); + await Promise.resolve(); + + host.stopPropertyWatching(); + + expect(host.watchingComponentKey).toBeNull(); + expect(host.propertyWatchInterval).toBeNull(); + }); + + it('stopPropertyWatching detaches event-driven watchers', async () => { + ChromeTest.setEvalToReturn([{ result: true }]); + host.startPropertyWatching({ componentKey: 'test-key' }); + + await Promise.resolve(); + await Promise.resolve(); + + ChromeTest.setEvalToReturn([{ result: undefined }]); + host.stopPropertyWatching(); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + expect(host.watchingComponentKey).toBeNull(); + expect((host as any).useEventDrivenWatching).toBe(false); + }); + }); + + describe('searchComponents', () => { + it('returns empty array when no chrome.devtools', async () => { + ChromeTest.removeChrome(); + + const results = await host.searchComponents('test'); + + expect(results).toEqual([]); + + ChromeTest.restoreChrome(); + }); + + it('calls inspectedWindow.eval with search query', async () => { + ChromeTest.setEvalToReturn([{ + result: [ + { key: 'comp-1', name: 'my-component', type: 'custom-element' } + ] + }]); + + const promise = host.searchComponents('my'); + + const results = await promise; + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('my-component'); + }); + + it('returns empty array when result is not array', async () => { + ChromeTest.setEvalToReturn([{ result: null }]); + + const results = await host.searchComponents('test'); + + expect(results).toEqual([]); + }); + }); + + describe('selectComponentByKey', () => { + beforeEach(() => { + host.attach(consumer); + }); + + it('calls inspectedWindow.eval with component key', () => { + ChromeTest.setEvalToReturn([{ result: null }]); + + host.selectComponentByKey('my-key'); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + const code = (global as any).chrome.devtools.inspectedWindow.eval.mock.calls[0][0]; + expect(code).toContain('my-key'); + }); + + it('notifies consumer when component found', () => { + const componentInfo = { + customElementInfo: { name: 'test', key: 'test-key' }, + customAttributesInfo: [] + }; + ChromeTest.setEvalToReturn([{ result: componentInfo }]); + + host.selectComponentByKey('test-key'); + + expect(consumer.onElementPicked).toHaveBeenCalledWith(componentInfo); + }); + }); + + describe('revealInElements', () => { + it('calls inspectedWindow.eval with component info', () => { + ChromeTest.setEvalToReturn([{ result: true }]); + + const componentInfo = { + name: 'test', + type: 'custom-element', + customElementInfo: { name: 'test' }, + customAttributesInfo: [] + }; + + host.revealInElements(componentInfo); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + const code = (global as any).chrome.devtools.inspectedWindow.eval.mock.calls[0][0]; + expect(code).toContain('findElementByComponentInfo'); + expect(code).toContain('inspect'); + }); + }); + + describe('enhanced info methods', () => { + it('getLifecycleHooks calls hook method', async () => { + ChromeTest.setEvalToReturn([{ + result: { hooks: [{ name: 'attached', implemented: true }] } + }]); + + const result = await host.getLifecycleHooks('test-key'); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + const code = (global as any).chrome.devtools.inspectedWindow.eval.mock.calls[0][0]; + expect(code).toContain('getLifecycleHooks'); + expect(result.hooks).toHaveLength(1); + }); + + it('getComputedProperties returns array', async () => { + ChromeTest.setEvalToReturn([{ + result: [{ name: 'fullName', hasGetter: true }] + }]); + + const result = await host.getComputedProperties('test-key'); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('fullName'); + }); + + it('getComputedProperties returns empty array on null', async () => { + ChromeTest.setEvalToReturn([{ result: null }]); + + const result = await host.getComputedProperties('test-key'); + + expect(result).toEqual([]); + }); + + it('getDependencies calls hook method', async () => { + ChromeTest.setEvalToReturn([{ + result: { dependencies: [{ name: 'HttpClient', type: 'class' }] } + }]); + + const result = await host.getDependencies('test-key'); + + expect(result.dependencies).toHaveLength(1); + }); + + it('getRouteInfo calls hook method', async () => { + ChromeTest.setEvalToReturn([{ + result: { currentRoute: '/users', params: [] } + }]); + + const result = await host.getRouteInfo('test-key'); + + expect(result.currentRoute).toBe('/users'); + }); + + it('getSlotInfo calls hook method', async () => { + ChromeTest.setEvalToReturn([{ + result: { slots: [{ name: 'default', hasContent: true }] } + }]); + + const result = await host.getSlotInfo('test-key'); + + expect(result.slots).toHaveLength(1); + }); + }); + + describe('getPropertySnapshot', () => { + it('returns null when no chrome.devtools', async () => { + ChromeTest.removeChrome(); + + const result = await host.getPropertySnapshot('test-key'); + + expect(result).toBeNull(); + + ChromeTest.restoreChrome(); + }); + + it('returns snapshot from eval', async () => { + const snapshot = { + componentKey: 'test-key', + bindables: [{ name: 'value', value: 1, type: 'number' }], + properties: [], + timestamp: Date.now() + }; + ChromeTest.setEvalToReturn([{ result: snapshot }]); + + const result = await host.getPropertySnapshot('test-key'); + + expect(result).toEqual(snapshot); + }); + }); + + describe('property snapshot diffing', () => { + it('detects changes between snapshots', () => { + const oldSnap = { + componentKey: 'test', + bindables: [{ name: 'count', value: 1, type: 'number' }], + properties: [], + timestamp: 1000 + }; + + const newSnap = { + componentKey: 'test', + bindables: [{ name: 'count', value: 2, type: 'number' }], + properties: [], + timestamp: 2000 + }; + + const changes = host.diffPropertySnapshots(oldSnap, newSnap); + + expect(changes).toHaveLength(1); + expect(changes[0].propertyName).toBe('count'); + expect(changes[0].oldValue).toBe(1); + expect(changes[0].newValue).toBe(2); + }); + + it('returns empty array when no changes', () => { + const oldSnap = { + componentKey: 'test', + bindables: [{ name: 'count', value: 1, type: 'number' }], + properties: [], + timestamp: 1000 + }; + + const newSnap = { + componentKey: 'test', + bindables: [{ name: 'count', value: 1, type: 'number' }], + properties: [], + timestamp: 2000 + }; + + const changes = host.diffPropertySnapshots(oldSnap, newSnap); + + expect(changes).toHaveLength(0); + }); + }); + + describe('valuesEqual', () => { + it('returns true for identical primitives', () => { + expect(host.valuesEqual(1, 1)).toBe(true); + expect(host.valuesEqual('test', 'test')).toBe(true); + expect(host.valuesEqual(true, true)).toBe(true); + }); + + it('returns false for different primitives', () => { + expect(host.valuesEqual(1, 2)).toBe(false); + expect(host.valuesEqual('a', 'b')).toBe(false); + }); + + it('returns true for equal objects', () => { + expect(host.valuesEqual({ a: 1 }, { a: 1 })).toBe(true); + }); + + it('returns false for different objects', () => { + expect(host.valuesEqual({ a: 1 }, { a: 2 })).toBe(false); + }); + + it('handles null values', () => { + expect(host.valuesEqual(null, null)).toBe(true); + expect(host.valuesEqual(null, 1)).toBe(false); + }); + }); + + describe('getComponentTree', () => { + it('returns component tree from hook', async () => { + const mockTree = [ + { key: 'app', name: 'App', tagName: 'app', type: 'custom-element', hasChildren: true }, + { key: 'header', name: 'Header', tagName: 'header', type: 'custom-element', hasChildren: false }, + ]; + ChromeTest.setEvalToReturn([{ result: mockTree }]); + + const result = await host.getComponentTree(); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + const code = (global as any).chrome.devtools.inspectedWindow.eval.mock.calls[0][0]; + expect(code).toContain('getSimplifiedComponentTree'); + expect(result).toEqual(mockTree); + }); + + it('returns empty array when result is not array', async () => { + ChromeTest.setEvalToReturn([{ result: null }]); + + const result = await host.getComponentTree(); + + expect(result).toEqual([]); + }); + + it('returns empty array when chrome.devtools unavailable', async () => { + const original = (global as any).chrome.devtools; + delete (global as any).chrome.devtools; + + const result = await host.getComponentTree(); + + expect(result).toEqual([]); + + (global as any).chrome.devtools = original; + }); + }); + + describe('timeline / interaction recording', () => { + it('startInteractionRecording calls hook and returns true on success', async () => { + ChromeTest.setEvalToReturn([{ result: true }]); + + const result = await host.startInteractionRecording(); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + const code = (global as any).chrome.devtools.inspectedWindow.eval.mock.calls[0][0]; + expect(code).toContain('startInteractionRecording'); + expect(result).toBe(true); + }); + + it('startInteractionRecording returns false on failure', async () => { + ChromeTest.setEvalToReturn([{ result: false }]); + + const result = await host.startInteractionRecording(); + + expect(result).toBe(false); + }); + + it('startInteractionRecording returns false when chrome.devtools unavailable', async () => { + const original = (global as any).chrome.devtools; + delete (global as any).chrome.devtools; + + const result = await host.startInteractionRecording(); + + expect(result).toBe(false); + + (global as any).chrome.devtools = original; + }); + + it('stopInteractionRecording calls hook and returns true on success', async () => { + ChromeTest.setEvalToReturn([{ result: true }]); + + const result = await host.stopInteractionRecording(); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + const code = (global as any).chrome.devtools.inspectedWindow.eval.mock.calls[0][0]; + expect(code).toContain('stopInteractionRecording'); + expect(result).toBe(true); + }); + + it('stopInteractionRecording returns false on failure', async () => { + ChromeTest.setEvalToReturn([{ result: false }]); + + const result = await host.stopInteractionRecording(); + + expect(result).toBe(false); + }); + + it('clearInteractionLog calls hook method', () => { + ChromeTest.setEvalToReturn([{ result: undefined }]); + + host.clearInteractionLog(); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + const code = (global as any).chrome.devtools.inspectedWindow.eval.mock.calls[0][0]; + expect(code).toContain('clearInteractionLog'); + }); + + it('clearInteractionLog does nothing when chrome.devtools unavailable', () => { + const original = (global as any).chrome.devtools; + delete (global as any).chrome.devtools; + + expect(() => host.clearInteractionLog()).not.toThrow(); + + (global as any).chrome.devtools = original; + }); + }); + + describe('getTemplateSnapshot', () => { + it('returns template snapshot from hook', async () => { + const mockSnapshot = { + componentKey: 'test-key', + componentName: 'test', + bindings: [{ id: 'b1', type: 'property', expression: 'foo' }], + controllers: [], + instructions: [], + hasSlots: false, + shadowMode: 'none', + isContainerless: false, + }; + ChromeTest.setEvalToReturn([{ result: mockSnapshot }]); + + const result = await host.getTemplateSnapshot('test-key'); + + expect((global as any).chrome.devtools.inspectedWindow.eval).toHaveBeenCalled(); + const code = (global as any).chrome.devtools.inspectedWindow.eval.mock.calls[0][0]; + expect(code).toContain('getTemplateSnapshot'); + expect(result).toEqual(mockSnapshot); + }); + + it('returns null when no result', async () => { + ChromeTest.setEvalToReturn([{ result: null }]); + + const result = await host.getTemplateSnapshot('test-key'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/vite.config.mjs b/vite.config.mjs index 5a2bcca..4c4a7e2 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -16,7 +16,7 @@ export default defineConfig(({ mode }) => { targets: [ { src: 'src/popups', dest: 'dist' }, { src: 'images', dest: 'dist' }, - { src: 'index.html', dest: 'dist' }, + { src: 'sidebar.html', dest: 'dist' }, { src: 'manifest.json', dest: 'dist' }, { src: 'src/devtools', dest: 'dist' }, ], @@ -28,7 +28,7 @@ export default defineConfig(({ mode }) => { minify: false, rollupOptions: { input: { - 'build/entry': resolve(__dirname, 'src/main.ts'), + 'build/sidebar': resolve(__dirname, 'src/sidebar/main.ts'), 'build/detector': resolve(__dirname, 'src/detector/detector.ts'), 'build/background': resolve(__dirname, 'src/background/background.ts'), 'build/contentscript': resolve(__dirname, 'src/contentscript/contentscript.ts'),