diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c75c8d00..905e3088 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,20 @@ jobs: exit 1 fi + # The zip step of `pnpm extension:zip`, on the bundle built above. Without + # PANGULAR_EXTENSION_KEY it holds no key.pem, so the artifact is only for loading and + # checking by hand: nothing here uploads it to the Chrome Web Store. + - name: Zip the Chrome extension + run: node scripts/extension-zip.mjs + + - name: Keep the extension zip + uses: actions/upload-artifact@v4 + with: + name: chrome-extension + path: dist/pangular-inspector-extension.zip + if-no-files-found: error + retention-days: 14 + - name: Smoke-test the CLI run: node bin.mjs --help @@ -81,6 +95,61 @@ jobs: - name: Test devtools package run: pnpm test:devtools + coverage: + name: Test coverage + # A report to read, not a gate: there is no threshold, and the build job is what fails a pull + # request on a broken test. v8 instrumentation slows every test, so the run gives each one + # 20 seconds instead of the default 5, which the slowest agent-tool tests went over. + runs-on: ubuntu-latest + timeout-minutes: 25 + continue-on-error: true + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + - name: Package tests with coverage + run: pnpm test:devtools --coverage --testTimeout=20000 + + - name: Panel tests with coverage + if: ${{ !cancelled() }} + run: pnpm test:panel --coverage --testTimeout=20000 + + # HTML under devtools/ and panel/, plus coverage-summary.json and lcov.info for each. + - name: Keep the coverage report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: coverage + path: dist/coverage + if-no-files-found: ignore + retention-days: 14 + + windows: + name: Package tests on Windows + # The CLI, the hub and the source scans run on developers' Windows machines too, where paths + # use `\` and a drive letter. + runs-on: windows-latest + timeout-minutes: 30 + defaults: + run: + shell: bash + env: + # pnpm runs package scripts with cmd.exe on Windows, and the root `prepare` script is + # written for a POSIX shell. Git Bash, not the WSL `bash` that comes first on PATH. + npm_config_script_shell: C:\Program Files\Git\bin\bash.exe + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + - name: Test devtools package + run: pnpm test:devtools + axe: runs-on: ubuntu-latest timeout-minutes: 15 @@ -97,6 +166,12 @@ jobs: - name: Check the panel with axe run: pnpm test:axe + # The committed extension/ui in extension/panel.html, reading the report test:axe wrote, + # and every status the panel bridge shows before it connects. + - name: Check the extension panel with axe + if: ${{ !cancelled() }} + run: pnpm test:axe:extension + # The e2e jobs below are slow, so they run only when the package, the panel, the SSR demo, the # tools docs (e2e:mcp reads them) or the scripts and fixtures they use change. The engines-floor # job runs only when what the packed package is built from changes. A diff that cannot be diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d49a2d6b..ec406308 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,7 +1,8 @@ name: E2E -# The slow end-to-end checks that do not fit a pull request: every panel view in a real browser, -# multi-tab and lifecycle resilience, and the install matrix from a packed tarball. Pull requests +# The slow end-to-end checks that do not fit a pull request: every panel view and the Chrome +# extension panel in a real browser, multi-tab and lifecycle resilience, and the install matrix +# from a packed tarball. Pull requests # run the security and MCP checks (ci.yml), and a release can run a slice of the install matrix on # its tarball. See apps/docs/src/content/contributing/development.md. on: @@ -54,12 +55,20 @@ jobs: - name: Check every panel view run: pnpm e2e:panel --base http://localhost:4000 --out dist/e2e-panel + # The unpacked extension in headless Chromium, with DevTools open on the demo: the panel + # finds the server, loads extension/ui for the page and lists the App component. + - name: Check the Chrome extension panel + if: ${{ !cancelled() }} + run: pnpm e2e:extension --base http://localhost:4000 --out dist/e2e-extension + - name: Keep the screenshots and results if: always() uses: actions/upload-artifact@v4 with: name: e2e-panel - path: dist/e2e-panel + path: | + dist/e2e-panel + dist/e2e-extension if-no-files-found: ignore resilience: diff --git a/app/vitest.config.ts b/app/vitest.config.ts index a56c67b3..ed3ad3ec 100644 --- a/app/vitest.config.ts +++ b/app/vitest.config.ts @@ -21,5 +21,14 @@ export default defineConfig({ include: ['src/**/*.test.ts'], setupFiles: ['src/test-setup.ts'], execArgv: ['--no-experimental-webstorage'], + // Used only with `--coverage` (`pnpm test:coverage`): a report, with no threshold to meet. + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/__tests__/**', 'src/**/*.test.ts', 'src/test-setup.ts'], + reporter: ['text-summary', 'html', 'json-summary', 'lcov'], + reportOnFailure: true, + reportsDirectory: '../dist/coverage/panel', + }, }, }); diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md index 46be22e0..3552dfb3 100644 --- a/apps/docs/src/content/contributing/chrome-extension.md +++ b/apps/docs/src/content/contributing/chrome-extension.md @@ -53,6 +53,8 @@ This builds the devtools UI (`pnpm devtools:build`), then replaces `extension/ui extension/ui is committed. If you change app/, run pnpm extension:build and commit the result. CI builds the extension and fails when extension/ui is stale. +If you change `panel.html`, `panel-bridge.js` or a status message, run `pnpm test:axe`, then `pnpm test:axe:extension`. It loads `panel.html` with a stand-in for the `chrome` APIs and runs axe on each status the panel shows and on the connected panel. See [Run the checks](./development.md#run-the-checks). + ## Load in Chrome @@ -80,6 +82,8 @@ pnpm extension:zip This runs `extension:build`, then writes `dist/pangular-inspector-extension.zip`. The zip leaves out `.DS_Store` files and drops `key` from the manifest, because the Chrome Web Store refuses a manifest with a `key`. +CI builds the same zip, without a key, on every pull request and push to `main`. Download it from the run's `chrome-extension` artifact to try a branch without building it. + For the first upload of a new store item, give the script the private key so the store keeps the ID `dcogniffeelebaolkkfbopmjcblhblfk`: ```bash diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md index f002c721..7cf643a0 100644 --- a/apps/docs/src/content/contributing/development.md +++ b/apps/docs/src/content/contributing/development.md @@ -151,6 +151,8 @@ pnpm exec nx affected -t test build # Test and build affected projects pnpm test:devtools # Devtools package tests (Vitest) pnpm test:panel # Devtools UI tests (Vitest) pnpm test:axe # axe check of every panel page (needs Chromium) +pnpm test:axe:extension # axe check of the extension panel (after pnpm test:axe) +pnpm test:coverage # Package and panel tests with v8 coverage, into dist/coverage pnpm extension:build # Chrome extension pnpm skills:check # Agent skills and roles in .claude/ pnpm commit:check # Commit messages on your branch @@ -158,8 +160,14 @@ pnpm commit:check # Commit messages on your branch `pnpm typecheck` runs `ngc` on `app/tsconfig.json` and `examples/analog/tsconfig.app.json`, so template errors fail it. `app/tsconfig.json` turns on `strictTemplates`. +The mobile overlays (NativeScript, Angular Native and the Capacitor collector) have no browser or device in the package tests. `packages/devtools/src/__tests__/native-harness.ts` stands in for one in plain Node.js: a fake `connectDevframe` that records each session's calls, the globals of a native runtime (a `WebSocket`, a `fetch` for `__connection.json` and no DOM), NativeScript views, Angular Native nodes and a Capacitor WebView with its bridge. `native-overlays-smoke.test.ts` starts each overlay in it and checks the first report and the clean-up. Point a test file's `devframe/client` mock at the harness with `vi.mock('devframe/client', async () => (await import('./native-harness.ts')).devframeModule())`. + `pnpm test:panel` runs the tests in `app/src/__tests__` in jsdom, with the Analog Angular plugin compiling the components. `pnpm test:axe` builds the package, writes a static report of Angular Travel to `dist/panel-axe`, serves it, and runs axe on every tab and on each hub view (`?view=ngrx`, `analog`, `nativescript`, `capacitor`) in both dark and light color schemes. It also checks each tab's states: a filter that matches nothing in every search box, a project whose source scans come back empty, a server that answers every call with an error, and a server that has not answered yet. Seeded pages stand in for a live app on the Capacitor, Errors, Plugins, Queries and Forms tabs, and for long lists on Components and **SSR & HTTP**. It fails on any violation, page error or state that does not show. Run `pnpm exec playwright install chromium` once before the first run. +`pnpm test:axe:extension` checks the Chrome extension's own page. It serves `extension/` over http with a stand-in for the `chrome` APIs, opens `panel.html` and runs axe on every status the panel bridge shows before it connects: a page that doesn't answer or can't be read, a page not served over http, a host the extension may not reach yet, no devtools server and a server that refuses. Then it serves the report from `pnpm test:axe` on the same origin and checks the connected panel, the committed `extension/ui` in the panel's frame. Both color schemes, at 1280 and 360 px. Run `pnpm test:axe` first, or pass another report folder to `node scripts/extension-axe.mjs`. + +`pnpm test:coverage` runs `pnpm test:devtools --coverage`, then `pnpm test:panel --coverage`, each with `--testTimeout=20000`: v8 instrumentation slows every test, and the slowest ones went over the default 5 seconds. The v8 provider writes an HTML report, `coverage-summary.json` and `lcov.info` to `dist/coverage/devtools` and `dist/coverage/panel`, also when a test fails. There is no threshold: the report shows what the tests reach and nothing fails on a low number. Open `dist/coverage/devtools/index.html` to read it. + `pnpm skills:check` validates the frontmatter of every skill and role and checks that the files and links they mention exist. `pnpm commit:check` checks every commit on your branch that is not on `main` (it compares with `upstream/main`, then `origin/main`, then `main`). ### What CI runs @@ -182,12 +190,15 @@ pnpm commit:check # Commit messages on your branch pnpm extension:build. The job fails when extension/ui differs from the committed copy. + + The zip step of pnpm extension:zip, kept for 14 days as the chrome-extension artifact of the run. It has no key.pem and nothing uploads it to the Chrome Web Store. + node bin.mjs --help. -A separate `axe` job in the same workflow installs Chromium and runs `pnpm test:axe`. A `node-22` job runs `pnpm test:devtools` on the newest Node.js 22, the oldest major `engines.node` (`>=22.12.0`) allows. +A separate `axe` job in the same workflow installs Chromium and runs `pnpm test:axe`, then `pnpm test:axe:extension`. A `coverage` job (**Test coverage**) runs both test suites with `--coverage` and keeps `dist/coverage` as the `coverage` artifact for 14 days. It is marked `continue-on-error`, so it never blocks a pull request: the build job is the test gate. A `node-22` job runs `pnpm test:devtools` on the newest Node.js 22, the oldest major `engines.node` (`>=22.12.0`) allows. A `windows` job (**Package tests on Windows**) runs `pnpm test:devtools` on `windows-latest`, so a path built with `/` or a file name reported with `\` fails there. It runs package scripts with Git Bash, because pnpm uses `cmd.exe` on Windows and the root `prepare` script needs a POSIX shell. In a test, build expected paths with `join()` or `resolve()` from `node:path`, and in a source scan, report file names with `relativeFile()` from `packages/devtools/src/rpc/source-scan.ts`. The source tests can't run on Node.js 22.12 itself: Node.js runs `.ts` files only from 22.18, and the build tools need newer versions too. So when a pull request or push changes `packages/devtools`, `app`, the root manifests, the lockfile, `.nvmrc` or the CI setup, the `engines-floor` job (**Packed package on Node.js 22.12**) packs the package on the `.nvmrc` version, switches to Node.js 22.12.0 and installs the tarball into an empty project with npm. It then runs `npx pangular --version`, imports the `cli`, `hub`, `ssr` and `vite` entry points, and loads the `hub` entry with `require()`. When `engines.node` changes, change the version in that job too. @@ -197,13 +208,16 @@ Two more jobs run when a pull request or push changes `packages/devtools`, `app` The `e2e:*` scripts drive real apps in Playwright's Chromium. Run `pnpm exec playwright install chromium` once first. -| Script | What it checks | Needs | Runs | -| --------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- | -| `pnpm e2e:security` | Secrets planted in a fixture app never reach the panel, agents, logs or the static report; the access gates hold. | `pnpm devtools:build-pkg`. Ports 5231 to 5236. | Pull requests | -| `pnpm e2e:mcp` | Every agent tool, resource and prompt over HTTP and stdio, with bad arguments and config variants. | Builds what it needs. Ports 5150 to 5154. | Pull requests | -| `pnpm e2e:panel` | Every panel view in both themes at 1280 and 360 px: axe, overflow and page errors. | A running app, such as the SSR demo on 4000 (`--base`) started with `PANGULAR_AUTH=false`, or `--auth-log` with its log. | Weekly | -| `pnpm e2e:resilience` | `multitab`, `expiry`, `lifecycle` and `stress`: tabs that close, crash or hide, server restarts and a large app. | `pnpm devtools:build-pkg` and the development build. Port 5401. | Weekly | -| `pnpm e2e:install` | A packed tarball installed into fresh apps per setup, package manager and Angular version, plus the package audit. | Network for npm. A local registry on 4873 and the ports after it. | Weekly, and a few cells on every release | +| Script | What it checks | Needs | Runs | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- | +| `pnpm e2e:security` | Secrets planted in a fixture app never reach the panel, agents, logs or the static report; the access gates hold. | `pnpm devtools:build-pkg`. Ports 5231 to 5236. | Pull requests | +| `pnpm e2e:mcp` | Every agent tool, resource and prompt over HTTP and stdio, with bad arguments and config variants. | Builds what it needs. Ports 5150 to 5154. | Pull requests | +| `pnpm e2e:panel` | Every panel view in both themes at 1280 and 360 px: axe, overflow and page errors. | A running app, such as the SSR demo on 4000 (`--base`) started with `PANGULAR_AUTH=false`, or `--auth-log` with its log. | Weekly | +| `pnpm e2e:extension` | The unpacked extension in headless Chromium with DevTools open on the app: the panel finds the server, loads `extension/ui` for the page and lists the `App` component. | A running app, such as the SSR demo on 4000 (`--base`) started with `PANGULAR_AUTH=false`. | Weekly | +| `pnpm e2e:resilience` | `multitab`, `expiry`, `lifecycle` and `stress`: tabs that close, crash or hide, server restarts and a large app. | `pnpm devtools:build-pkg` and the development build. Port 5401. | Weekly | +| `pnpm e2e:install` | A packed tarball installed into fresh apps per setup, package manager and Angular version, plus the package audit. | Network for npm. A local registry on 4873 and the ports after it. | Weekly, and a few cells on every release | + +`pnpm e2e:extension` loads `extension/` with `--load-extension` in Playwright's Chromium in the new headless mode, which runs extensions, and opens DevTools on each tab with `--auto-open-devtools-for-tabs`. Playwright has no handle on DevTools, so the script selects the panel through the DevTools front end and reads `panel.html` over the Chrome DevTools Protocol. Set `HEADED=1` to watch it. The weekly `Panel views` job runs it after `pnpm e2e:panel`, against the same demo. `.github/workflows/e2e.yml` runs the weekly ones on Mondays and opens an issue when one fails. In `e2e:resilience`, a check that fails on an open issue is marked as a known failure with the issue number: it prints `XFAIL` and does not fail the run, and it prints `XPASS` once it passes, so the mark can go. The `soak` check of `e2e:resilience` runs by hand only. diff --git a/package.json b/package.json index bed264ff..abec0fe4 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "test": "nx test", "test:devtools": "vitest run --root packages/devtools", "test:panel": "vitest run --config app/vitest.config.ts", + "test:coverage": "pnpm test:devtools --coverage --testTimeout=20000 && pnpm test:panel --coverage --testTimeout=20000", "test:axe": "pnpm devtools:build-pkg && node bin.mjs build --outDir dist/panel-axe && node scripts/panel-axe.mjs dist/panel-axe", + "test:axe:extension": "node scripts/extension-axe.mjs dist/panel-axe", "typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.spec.json --noEmit && ngc -p app/tsconfig.json --noEmit && ngc -p examples/analog/tsconfig.app.json --noEmit && tsc -p packages/devtools", "format": "prettier --write .", "format:check": "prettier --check .", @@ -35,6 +37,7 @@ "e2e:mcp": "node scripts/e2e-mcp.mjs", "e2e:security": "node scripts/e2e-security.mjs", "e2e:panel": "node scripts/e2e-panel.mjs", + "e2e:extension": "node scripts/e2e-extension.mjs", "e2e:resilience": "node scripts/e2e-resilience.mjs", "extension:build": "pnpm devtools:build && rm -rf extension/ui && cp -r dist/devtools-ui extension/ui", "extension:zip": "pnpm extension:build && node scripts/extension-zip.mjs", @@ -85,6 +88,7 @@ "@schematics/angular": "^22.1.8", "@types/express": "^5.0.1", "@types/node": "^24.0.0", + "@vitest/coverage-v8": "4.1.11", "jsdom": "^28.0.0", "nx": "23.2.1", "playwright": "^1.63.0", diff --git a/packages/devtools/src/__tests__/auth-guard.test.ts b/packages/devtools/src/__tests__/auth-guard.test.ts index 50189e54..6a741de1 100644 --- a/packages/devtools/src/__tests__/auth-guard.test.ts +++ b/packages/devtools/src/__tests__/auth-guard.test.ts @@ -10,7 +10,7 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { connectDevframe } from 'devframe/client'; import { getTempAuthCode, verifyAuthToken } from 'devframe/node/auth'; import { getInternalContext } from 'devframe/node/hub-internals'; @@ -25,6 +25,15 @@ import { } from '../auth-store.ts'; import { initPangularHub } from '../hub.ts'; +/** + * Checks that `path` exists and, on POSIX, that only its owner can read it. Windows has no such + * modes: `chmod` there only sets the read-only flag, and `stat` reports 0o666. + */ +function expectPrivate(path: string, mode: number) { + const actual = statSync(path).mode & 0o777; + if (process.platform !== 'win32') expect(actual).toBe(mode); +} + describe('CodeGuard', () => { function guard(now: { t: number }, warn = vi.fn()) { return new CodeGuard({ now: () => now.t, warn }); @@ -217,7 +226,8 @@ describe('pruneExpiredTokens', () => { it('names the store after a hash of the project, not its path', () => { const dir = authStoreDir('/work/secret-project', '/home/me'); - expect(dir).toMatch(/^\/home\/me\/\.pangular-inspector\/auth\/[0-9a-f]{16}$/); + expect(dirname(dir)).toBe(join('/home/me', '.pangular-inspector', 'auth')); + expect(basename(dir)).toMatch(/^[0-9a-f]{16}$/); expect(authStoreDir('/work/other', '/home/me')).not.toBe(dir); }); }); @@ -313,7 +323,9 @@ describe('initPangularHub one-time code', () => { async function serve(seed?: (home: string) => void) { const home = mkdtempSync(join(tmpdir(), 'pangular-home-')); + // `homedir()` reads HOME on POSIX and USERPROFILE on Windows. vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); seed?.(home); const hub = initPangularHub({ ws: false, mcp: false }); const server: Server = createServer((req, res) => @@ -373,9 +385,16 @@ describe('initPangularHub one-time code', () => { const dir = authStoreDir(process.cwd(), home); const file = join(dir, 'auth.json'); - await vi.waitFor(() => expect(statSync(file).mode & 0o777).toBe(0o600), { timeout: 3000 }); - expect(statSync(dir).mode & 0o777).toBe(0o700); - expect(Object.keys(JSON.parse(readFileSync(file, 'utf8')).trusted)).toHaveLength(1); + // The store holds an empty `auth.json` from startup and devframe writes the token a moment + // later, so wait for the token itself. Only on POSIX does the mode check also wait for it. + await vi.waitFor( + () => { + expect(Object.keys(JSON.parse(readFileSync(file, 'utf8')).trusted)).toHaveLength(1); + expectPrivate(file, 0o600); + }, + { timeout: 3000 }, + ); + expectPrivate(dir, 0o700); expect(existsSync(join(home, '.devframes', 'devframe', 'auth.json'))).toBe(false); }); @@ -419,7 +438,7 @@ describe('initPangularHub one-time code', () => { expect(Object.keys(JSON.parse(readFileSync(file, 'utf8')).trusted)).toEqual([ 'fresh-token', ]); - expect(statSync(file).mode & 0o777).toBe(0o600); + expectPrivate(file, 0o600); }, { timeout: 3000 }, ); diff --git a/packages/devtools/src/__tests__/capacitor-collector.test.ts b/packages/devtools/src/__tests__/capacitor-collector.test.ts index 3ccc515d..d3333e96 100644 --- a/packages/devtools/src/__tests__/capacitor-collector.test.ts +++ b/packages/devtools/src/__tests__/capacitor-collector.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { capacitorHook, @@ -405,7 +406,7 @@ describe('createCapacitorHook', () => { // In a child process: an unhandled rejection is the thing under test, and // the test runner would report one as a failure. const script = ` - const { createCapacitorHook } = await import(${JSON.stringify(join(__dirname, '../capacitor-collector.ts'))}); + const { createCapacitorHook } = await import(${JSON.stringify(pathToFileURL(join(__dirname, '../capacitor-collector.ts')).href)}); let unhandled = 0; process.on('unhandledRejection', () => unhandled++); const failure = Object.assign(new Error('denied'), { code: 'DENIED' }); diff --git a/packages/devtools/src/__tests__/cli-auth.test.ts b/packages/devtools/src/__tests__/cli-auth.test.ts index 2e5128e8..8c022a55 100644 --- a/packages/devtools/src/__tests__/cli-auth.test.ts +++ b/packages/devtools/src/__tests__/cli-auth.test.ts @@ -8,6 +8,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { authStoreDir } from '../auth-store.ts'; import { cliMcpSetting } from '../cli.ts'; +/** + * Checks that `path` exists and, on POSIX, that only its owner can read it. Windows has no such + * modes: `chmod` there only sets the read-only flag, and `stat` reports 0o666. + */ +function expectPrivate(path: string, mode: number) { + const actual = statSync(path).mode & 0o777; + if (process.platform !== 'win32') expect(actual).toBe(mode); +} + const BIN = fileURLToPath(new URL('../../bin.mjs', import.meta.url)); const REPO = fileURLToPath(new URL('../../../../', import.meta.url)); const MCP_TOKEN = 'cli-mcp-token-0123456789abcdef'; @@ -44,7 +53,9 @@ describe('pangular dev access', () => { cwd: REPO, env: { ...process.env, + // `homedir()` reads HOME on POSIX and USERPROFILE on Windows. HOME: home, + USERPROFILE: home, PANGULAR_ROOT: '', PANGULAR_CONFIG: '', PANGULAR_MCP_TOKEN: MCP_TOKEN, @@ -115,12 +126,16 @@ describe('pangular dev access', () => { ); expect(await rpc.requestTrustWithCode(code)).toBeTruthy(); const dir = authStoreDir(REPO, home); - await vi.waitFor(() => expect(statSync(join(dir, 'auth.json')).mode & 0o777).toBe(0o600), { - timeout: 3000, - }); - expect(statSync(dir).mode & 0o777).toBe(0o700); - expect( - Object.keys(JSON.parse(readFileSync(join(dir, 'auth.json'), 'utf8')).trusted), - ).toHaveLength(1); + // The store holds an empty `auth.json` from startup and devframe writes the token a moment + // later, so wait for the token itself. Only on POSIX does the mode check also wait for it. + const file = join(dir, 'auth.json'); + await vi.waitFor( + () => { + expect(Object.keys(JSON.parse(readFileSync(file, 'utf8')).trusted)).toHaveLength(1); + expectPrivate(file, 0o600); + }, + { timeout: 3000 }, + ); + expectPrivate(dir, 0o700); }, 40_000); }); diff --git a/packages/devtools/src/__tests__/mcp-print-config.test.ts b/packages/devtools/src/__tests__/mcp-print-config.test.ts index f83c439d..a38c047f 100644 --- a/packages/devtools/src/__tests__/mcp-print-config.test.ts +++ b/packages/devtools/src/__tests__/mcp-print-config.test.ts @@ -237,7 +237,10 @@ describe('pangular mcp --print-config', () => { expect(process.cwd()).toBe(cwd); const text = lines.join('\n'); expect(text).not.toContain(SECRET); - expect(text).toContain(`--root ${root} --config ${join(root, 'pangular.config.json')}`); + // Quoted where the shell needs it, as a Windows path with `\` is. + expect(text).toContain( + `--root ${shellQuote(root)} --config ${shellQuote(join(root, 'pangular.config.json'))}`, + ); expect(text).toContain('== HTTP: live pages, at http://127.0.0.1:5173/__devframes/__mcp =='); expect(text).toContain("--header 'Authorization: Bearer ${PANGULAR_MCP_TOKEN}'"); expect(text).not.toContain('Warning'); diff --git a/packages/devtools/src/__tests__/native-harness.ts b/packages/devtools/src/__tests__/native-harness.ts new file mode 100644 index 00000000..19b028a1 --- /dev/null +++ b/packages/devtools/src/__tests__/native-harness.ts @@ -0,0 +1,218 @@ +// A shared headless harness for the mobile overlays: the NativeScript overlay, the Angular Native +// overlay and the Capacitor collector. It runs them in plain Node.js with fake globals in place of +// a device, so no jsdom and no emulator. Each test file still declares its own `vi.mock` calls +// (Vitest hoists them per file), and points the `devframe/client` mock at `devframe`: +// +// vi.mock('devframe/client', async () => (await import('./native-harness.ts')).devframeModule()); +import { vi } from 'vitest'; + +type Handler = (...args: unknown[]) => unknown; + +/** One connection an overlay opened: what it called on the server and what it registered. */ +export interface FakeSession { + options: Record; + calls: [string, unknown][]; + handlers: Map; + close: ReturnType; + /** The payloads of every call to `name`, oldest first. */ + pushes(name: string): T[]; +} + +interface DevframeFake { + sessions: FakeSession[]; + /** Whether the server trusts the app (`ensureTrusted`). */ + trusted: boolean; + /** Answers by function name; any other call answers `{ known: true }`. */ + replies: Record; + connectDevframe: ReturnType; + reset(): void; +} + +function newSession(options: Record): FakeSession { + const calls: [string, unknown][] = []; + return { + options, + calls, + handlers: new Map(), + close: vi.fn(), + pushes: (name: string) => calls.filter(([n]) => n === name).map(([, arg]) => arg as T), + }; +} + +/** A stand-in for `connectDevframe` from `devframe/client`, shared by every overlay under test. */ +export const devframe: DevframeFake = { + sessions: [], + trusted: true, + replies: {}, + connectDevframe: vi.fn(async (options: Record = {}) => { + const session = newSession(options); + devframe.sessions.push(session); + return { + status: 'connected', + connectionMeta: {}, + ensureTrusted: async () => devframe.trusted, + close: session.close, + scope: () => ({ + rpc: { + register: (definition: { name: string; handler: Handler }) => + session.handlers.set(definition.name, definition.handler), + call: vi.fn(async (name: string, arg: unknown) => { + session.calls.push([name, arg]); + return name in devframe.replies ? devframe.replies[name] : { known: true }; + }), + }, + }), + }; + }), + reset() { + devframe.sessions.length = 0; + devframe.trusted = true; + devframe.replies = {}; + devframe.connectDevframe.mockClear(); + }, +}; + +/** The module a test file's `vi.mock('devframe/client', ...)` factory returns. */ +export function devframeModule() { + return { connectDevframe: devframe.connectDevframe }; +} + +/** An RPC scope for code that takes one directly, such as `attachCapacitor`. */ +export function fakeScope() { + const session = newSession({}); + devframe.sessions.push(session); + const scope = { + rpc: { + call: vi.fn(async (name: string, arg: unknown) => { + session.calls.push([name, arg]); + return name in devframe.replies ? devframe.replies[name] : { known: true }; + }), + }, + }; + return { scope, session }; +} + +const g = globalThis as Record; +const RUNTIME_GLOBALS = ['window', 'document', 'location', 'ng', 'WebSocket', 'fetch']; + +/** + * The globals of a NativeScript or Angular Native runtime: a `WebSocket` (the polyfill the + * overlays ask for), a `fetch` that answers `__connection.json`, and no DOM. `ng` is the debug + * API the app exposes in development. Returns the function that puts the real globals back. + */ +export function nativeRuntime( + ng: unknown, + meta: Record = { backend: 'websocket' }, +) { + const saved = new Map( + RUNTIME_GLOBALS.map((name) => [name, Object.getOwnPropertyDescriptor(g, name)]), + ); + for (const name of ['window', 'document', 'location']) delete g[name]; + const fetch = vi.fn(async () => ({ ok: true, status: 200, json: async () => meta })); + Object.assign(g, { ng, WebSocket: class {}, fetch }); + return { + fetch, + restore() { + for (const [name, descriptor] of saved) { + if (descriptor) Object.defineProperty(g, name, descriptor); + else delete g[name]; + } + }, + }; +} + +/** A `@nativescript/core` view: children come from `eachChildView`. */ +export interface FakeNativeView { + typeName: string; + parent: FakeNativeView | null; + isLoaded: boolean; + customCSSName?: string; + eachChildView(callback: (child: FakeNativeView) => boolean | void): void; + [key: string]: unknown; +} + +export function nsView( + typeName: string, + children: FakeNativeView[] = [], + extra: Partial = {}, +): FakeNativeView { + const view: FakeNativeView = { + typeName, + parent: null, + isLoaded: true, + eachChildView: (callback) => { + for (const child of children) if (callback(child) === false) break; + }, + ...extra, + }; + for (const child of children) child.parent = view; + return view; +} + +/** An Angular Native node, as `mount()` builds them. */ +export interface FakeNativeNode { + kind: string; + name: string; + children: FakeNativeNode[]; + parent: FakeNativeNode | null; + props: Record; + host: { setProp(node: FakeNativeNode, key: string, value: unknown): void }; +} + +const engine = { + setProp: (node: FakeNativeNode, key: string, value: unknown) => { + node.props[key] = value; + }, +}; + +export function nativeNode( + name: string, + children: FakeNativeNode[] = [], + kind = 'element', +): FakeNativeNode { + const node: FakeNativeNode = { kind, name, children, parent: null, props: {}, host: engine }; + for (const child of children) child.parent = node; + return node; +} + +/** A component instance whose class carries the selector Angular's compiler gives it. */ +export function fakeComponent( + selector: string, + name: string, + fields: Record = {}, +) { + const Cmp = { [name]: class {} }[name] as new () => object; + Object.assign(Cmp, { ɵcmp: { selectors: [[selector]] } }); + return Object.assign(new Cmp(), fields); +} + +/** + * The page a Capacitor WebView shows, without jsdom: a `location`, a `document` with a title and + * the visibility events, and a `Capacitor` bridge whose native calls resolve. + */ +export function capacitorWebView(platform: 'ios' | 'android' = 'ios') { + const listeners = new Map(); + const doc = { + title: 'Capacitor app', + visibilityState: 'visible' as DocumentVisibilityState, + addEventListener(type: string, listener: EventListenerOrEventListenerObject) { + listeners.set(type, listener); + }, + removeEventListener(type: string) { + listeners.delete(type); + }, + }; + const Capacitor = { + getPlatform: () => platform, + isNativePlatform: () => true, + isPluginAvailable: () => true, + Plugins: { Preferences: {} }, + nativePromise: vi.fn(async (_plugin: string, method: string, _options?: unknown) => + method === 'get' ? { value: 'dark' } : undefined, + ), + nativeCallback: vi.fn(() => '1'), + }; + vi.stubGlobal('location', { pathname: '/home', search: '', origin: 'capacitor://localhost' }); + vi.stubGlobal('document', doc); + return { win: { Capacitor }, doc, Capacitor }; +} diff --git a/packages/devtools/src/__tests__/native-overlays-smoke.test.ts b/packages/devtools/src/__tests__/native-overlays-smoke.test.ts new file mode 100644 index 00000000..775cd715 --- /dev/null +++ b/packages/devtools/src/__tests__/native-overlays-smoke.test.ts @@ -0,0 +1,153 @@ +// Smoke tests for the mobile overlays in the shared headless harness: each one starts against a +// fake runtime, sends its first report with a page id, and leaves nothing behind when stopped. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + capacitorWebView, + devframe, + fakeComponent, + fakeScope, + nativeNode, + nativeRuntime, + nsView, + type FakeNativeView, +} from './native-harness.ts'; + +const nativeScript = vi.hoisted(() => ({ root: null as unknown })); + +vi.mock('devframe/client', async () => (await import('./native-harness.ts')).devframeModule()); +vi.mock('@nativescript/core', () => ({ + Application: { getRootView: () => nativeScript.root }, + isAndroid: false, +})); + +let restore: (() => void) | undefined; + +beforeEach(() => { + vi.useFakeTimers(); + devframe.reset(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + restore?.(); + restore = undefined; + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +interface TreePush { + pageId: string; + count: number; + platform?: string; + roots: { name: string; tag: string }[]; +} + +describe('NativeScript overlay in the headless harness', () => { + it('reports the component tree over a WebSocket session and forgets the page on dispose', async () => { + const label = nsView('Label'); + const host: FakeNativeView = nsView('AppHostView', [nsView('StackLayout', [label])]); + nativeScript.root = host; + const app = fakeComponent('ns-app', 'AppComponent'); + restore = nativeRuntime({ + getComponent: (view: unknown) => (view === host ? app : null), + getRootComponents: () => [app], + getHostElement: (component: unknown) => (component === app ? host : null), + }).restore; + + const { initNativeScriptOverlay } = await import('../overlay-nativescript.ts'); + const dispose = initNativeScriptOverlay({ + baseURL: 'http://localhost:9999/', + intervalMs: 1000, + }); + await vi.waitFor(() => + expect(devframe.sessions[0]?.pushes('push-component-tree').length ?? 0).toBeGreaterThan(0), + ); + + const session = devframe.sessions[0]; + expect(session.options).toMatchObject({ + baseURL: 'http://localhost:9999/', + transport: 'websocket', + }); + const [tree] = session.pushes('push-component-tree'); + expect(tree.pageId).toMatch(/^[a-z0-9]+$/); + expect(tree.count).toBe(1); + expect(tree.roots[0]).toMatchObject({ name: 'AppComponent', tag: 'ns-app' }); + + dispose(); + await vi.advanceTimersByTimeAsync(0); + expect(session.calls.map(([name]) => name)).toContain('forget-component-page'); + expect(session.close).toHaveBeenCalled(); + }); +}); + +describe('Angular Native overlay in the headless harness', () => { + it('reports the component tree, with no location left once it connected', async () => { + const rowHost = nativeNode('app-row', [nativeNode('text')]); + const rootHost = nativeNode('root', [nativeNode('view', [rowHost])]); + const app = fakeComponent('app-root', 'AppComponent'); + const row = fakeComponent('app-row', 'RowComponent'); + const components = new Map([ + [rootHost, app], + [rowHost, row], + ]); + const runtime = nativeRuntime({ + getComponent: (node: unknown) => components.get(node) ?? null, + getRootComponents: () => [app], + getHostElement: (component: unknown) => + [...components].find(([, c]) => c === component)?.[0] ?? null, + }); + restore = runtime.restore; + + const { initAngularNativeOverlay } = await import('../overlay-angular-native.ts'); + const dispose = initAngularNativeOverlay({ + root: rootHost as never, + baseURL: 'http://192.168.1.20:9999/', + intervalMs: 1000, + }); + await vi.waitFor(() => + expect(devframe.sessions[0]?.pushes('push-component-tree').length ?? 0).toBeGreaterThan(0), + ); + + expect(runtime.fetch).toHaveBeenCalledWith('http://192.168.1.20:9999/__connection.json'); + const session = devframe.sessions[0]; + const [tree] = session.pushes('push-component-tree'); + expect(tree).toMatchObject({ count: 2, platform: 'angular-native' }); + expect(tree.pageId).toMatch(/^[a-z0-9]+$/); + expect(typeof (globalThis as { location?: unknown }).location).toBe('undefined'); + + dispose(); + await vi.advanceTimersByTimeAsync(0); + expect(session.calls.map(([name]) => name)).toContain('forget-component-page'); + expect(session.close).toHaveBeenCalled(); + }); +}); + +describe('Capacitor collector in the headless harness', () => { + it('pushes the platform and a native call, then stops pushing', async () => { + const { win, doc, Capacitor } = capacitorWebView('android'); + const { scope, session } = fakeScope(); + const { attachCapacitor } = await import('../capacitor-overlay.ts'); + const capacitor = attachCapacitor(scope, 'cap1', { maxCalls: 50, win, doc }); + await vi.advanceTimersByTimeAsync(0); + + const [first] = session.pushes<{ pageId: string; title: string; info: object }>( + 'push-capacitor', + ); + expect(first).toMatchObject({ pageId: 'cap1', title: 'Capacitor app', url: '/home' }); + expect(first.info).toMatchObject({ platform: 'android', native: true }); + + await Capacitor.nativePromise('Preferences', 'get', { key: 'theme' }); + await vi.advanceTimersByTimeAsync(300); + const calls = session + .pushes<{ calls: { plugin: string; method: string }[] }>('push-capacitor') + .flatMap((report) => report.calls); + expect(calls).toContainEqual(expect.objectContaining({ plugin: 'Preferences', method: 'get' })); + + capacitor.stop(); + const sent = session.calls.length; + await vi.advanceTimersByTimeAsync(20_000); + expect(session.calls.slice(sent).map(([name]) => name)).not.toContain('push-capacitor'); + }); +}); diff --git a/packages/devtools/src/__tests__/skills-install.test.ts b/packages/devtools/src/__tests__/skills-install.test.ts index f73f595b..f4ed8929 100644 --- a/packages/devtools/src/__tests__/skills-install.test.ts +++ b/packages/devtools/src/__tests__/skills-install.test.ts @@ -6,7 +6,7 @@ import { symlinkSync, writeFileSync, } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createPangularCli } from '../cli.ts'; import { @@ -80,13 +80,17 @@ describe('bundled skills', () => { describe('installSkills', () => { it('picks the agent folder, the home folder with global, or --dir', () => { + // Built with the platform's path functions, so the check holds on Windows too, where `resolve` + // adds the drive letter and every separator is a backslash. const cwd = '/work/app'; - expect(skillsTarget({ cwd })).toBe('/work/app/.claude/skills'); - expect(skillsTarget({ cwd, agent: 'agents' })).toBe('/work/app/.agents/skills'); - expect(skillsTarget({ cwd, agent: 'github' })).toBe('/work/app/.github/skills'); - expect(skillsTarget({ cwd, global: true, home: '/home/me' })).toBe('/home/me/.claude/skills'); + expect(skillsTarget({ cwd })).toBe(join(cwd, '.claude/skills')); + expect(skillsTarget({ cwd, agent: 'agents' })).toBe(join(cwd, '.agents/skills')); + expect(skillsTarget({ cwd, agent: 'github' })).toBe(join(cwd, '.github/skills')); + expect(skillsTarget({ cwd, global: true, home: '/home/me' })).toBe( + join('/home/me', '.claude/skills'), + ); expect(skillsTarget({ cwd, dir: 'tools/skills', agent: 'agents' })).toBe( - '/work/app/tools/skills', + resolve(cwd, 'tools/skills'), ); expect(() => skillsTarget({ cwd, agent: 'toString' })).toThrow(/Unknown agent "toString"/); }); @@ -107,8 +111,8 @@ describe('installSkills', () => { expect(result.results.map((entry) => entry.status)).toEqual(['installed', 'installed']); expect(existsSync(join(cwd, '.claude'))).toBe(false); expect(skillsInstallLines(result, cwd)).toEqual([ - ' Would install alpha -> .claude/skills/alpha', - ' Would install beta -> .claude/skills/beta', + ` Would install alpha -> ${join('.claude/skills/alpha')}`, + ` Would install beta -> ${join('.claude/skills/beta')}`, ' Dry run: nothing was written.', ]); }); @@ -204,11 +208,15 @@ describe('pangular skills', () => { it('installs into --root, honours --dry-run, --agent, --dir and --skill', async () => { const cwd = fixtureDir('pangular-skills-cli-'); let { out } = await cli(['skills', 'install', '--dry-run'], cwd); - expect(out).toContain('Would install pangular-inspector -> .claude/skills/pangular-inspector'); + expect(out).toContain( + `Would install pangular-inspector -> ${join('.claude/skills/pangular-inspector')}`, + ); expect(existsSync(join(cwd, '.claude'))).toBe(false); ({ out } = await cli(['skills', 'install', '--agent', 'agents'], cwd)); - expect(out).toContain('Installed pangular-inspector -> .agents/skills/pangular-inspector'); + expect(out).toContain( + `Installed pangular-inspector -> ${join('.agents/skills/pangular-inspector')}`, + ); expect(readFileSync(join(cwd, '.agents/skills/pangular-inspector/SKILL.md'), 'utf8')).toBe( readFileSync(join(bundledSkillsDir(), 'pangular-inspector/SKILL.md'), 'utf8'), ); diff --git a/packages/devtools/src/__tests__/terminal-commands.test.ts b/packages/devtools/src/__tests__/terminal-commands.test.ts index e73c305c..7826820b 100644 --- a/packages/devtools/src/__tests__/terminal-commands.test.ts +++ b/packages/devtools/src/__tests__/terminal-commands.test.ts @@ -123,7 +123,11 @@ describe('terminal command detection', () => { packageManagerFor('/work/app', files({ 'package.json': { packageManager: 'yarn@4.5.0' } })), ).toBe('yarn'); const workspace = (path: string) => - path === '/work/pnpm-lock.yaml' ? '' : path === '/work/app/package.json' ? '{}' : undefined; + path === join('/work', 'pnpm-lock.yaml') + ? '' + : path === join('/work/app', 'package.json') + ? '{}' + : undefined; expect(packageManagerFor('/work/app', workspace)).toBe('pnpm'); expect(packageManagerFor('/work/app', files({ 'package.json': {} }))).toBe('npm'); }); diff --git a/packages/devtools/src/__tests__/vite-overlay.test.ts b/packages/devtools/src/__tests__/vite-overlay.test.ts index 5f7c5097..b359a481 100644 --- a/packages/devtools/src/__tests__/vite-overlay.test.ts +++ b/packages/devtools/src/__tests__/vite-overlay.test.ts @@ -1,6 +1,6 @@ import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { createServer, resolveConfig, type InlineConfig, type ViteDevServer } from 'vite'; import { afterEach, describe, expect, it } from 'vitest'; import pangularVite, { type PangularViteOptions } from '../vite.ts'; @@ -86,7 +86,7 @@ describe('pangularVite overlay injection', () => { expect(src).toBeDefined(); expect(more).toEqual([]); const code = (await server.transformRequest(src!))?.code ?? ''; - expect(code).toContain(stub.split('/').pop()); + expect(code).toContain(basename(stub)); }); it('shares one overlay module with an import in main.ts, so it runs once', async () => { diff --git a/packages/devtools/src/rpc/__tests__/open-in-editor.test.ts b/packages/devtools/src/rpc/__tests__/open-in-editor.test.ts index 41c225af..2240f0d7 100644 --- a/packages/devtools/src/rpc/__tests__/open-in-editor.test.ts +++ b/packages/devtools/src/rpc/__tests__/open-in-editor.test.ts @@ -11,7 +11,9 @@ import { import { fixtureDir } from './fixture-dir.ts'; function project() { - const base = realpathSync(fixtureDir('pangular-open-')); + // `.native` gives the long name Windows resolves to (`C:\Users\runneradmin`), where the + // temp folder's own path is the 8.3 short name (`C:\Users\RUNNER~1`) that `realpathSync` keeps. + const base = realpathSync.native(fixtureDir('pangular-open-')); const root = join(base, 'app'); const outside = join(base, 'secret'); mkdirSync(join(root, 'src/app'), { recursive: true }); diff --git a/packages/devtools/src/rpc/__tests__/source-scan.test.ts b/packages/devtools/src/rpc/__tests__/source-scan.test.ts index eeab9964..62dcb13d 100644 --- a/packages/devtools/src/rpc/__tests__/source-scan.test.ts +++ b/packages/devtools/src/rpc/__tests__/source-scan.test.ts @@ -15,6 +15,7 @@ import { sourceRoots, startsRegex, stripComments, + toPosixPath, } from '../source-scan.ts'; function workspace(files: Record, workspaceJson?: unknown) { @@ -376,3 +377,10 @@ describe('Nx workspaces', () => { } }); }); + +describe('toPosixPath', () => { + it('reports a Windows relative path with forward slashes', () => { + expect(toPosixPath('src\\app\\app.ts', '\\')).toBe('src/app/app.ts'); + expect(toPosixPath('src/app/app.ts', '/')).toBe('src/app/app.ts'); + }); +}); diff --git a/packages/devtools/src/rpc/__tests__/template-lint.test.ts b/packages/devtools/src/rpc/__tests__/template-lint.test.ts index f1e51b78..4a0920e8 100644 --- a/packages/devtools/src/rpc/__tests__/template-lint.test.ts +++ b/packages/devtools/src/rpc/__tests__/template-lint.test.ts @@ -54,7 +54,7 @@ describe('lintTemplates', () => { expect(findings[0]).toMatchObject({ rule: 'template-parse-error', severity: 'error', - file: join('src', 'view.ts'), + file: 'src/view.ts', line: 7, }); expect(findings[0]!.message).not.toMatch(/@\d+:\d+$/); @@ -82,7 +82,7 @@ export class Card {} expect(findings).toEqual([ expect.objectContaining({ rule: 'template-parse-error', - file: join('src', 'card.html'), + file: 'src/card.html', line: 3, }), ]); diff --git a/packages/devtools/src/rpc/forms-source.ts b/packages/devtools/src/rpc/forms-source.ts index e38fafeb..c16a07e8 100644 --- a/packages/devtools/src/rpc/forms-source.ts +++ b/packages/devtools/src/rpc/forms-source.ts @@ -1,7 +1,14 @@ import { readFileSync } from 'node:fs'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join } from 'node:path'; import { splitPath } from '../forms-path.ts'; -import { lineCounter, skipString, sourceRoots, stripComments, walkFiles } from './source-scan.ts'; +import { + lineCounter, + skipString, + sourceRoots, + stripComments, + walkFiles, + relativeFile, +} from './source-scan.ts'; export interface SourceLine { file: string; @@ -197,7 +204,7 @@ export function findFormSource( for (const full of files) { const content = readCached(full); if (content === null || !needle.test(content)) continue; - result = formSourceIn(content, relative(cwd, full), owner, property, path, (name) => + result = formSourceIn(content, relativeFile(cwd, full), owner, property, path, (name) => schemaFile(cwd, files, name, full, content), ); if (result) break; @@ -241,10 +248,10 @@ function schemaFile( const imported = matches.find( (match) => match.full === `${base}.ts` || match.full === join(base, 'index.ts'), ); - return imported ? { content: imported.content, file: relative(cwd, imported.full) } : null; + return imported ? { content: imported.content, file: relativeFile(cwd, imported.full) } : null; } if (matches.length !== 1) return null; - return { content: matches[0].content, file: relative(cwd, matches[0].full) }; + return { content: matches[0].content, file: relativeFile(cwd, matches[0].full) }; } function readCached(full: string): string | null { diff --git a/packages/devtools/src/rpc/get-components.ts b/packages/devtools/src/rpc/get-components.ts index 504da759..97992630 100644 --- a/packages/devtools/src/rpc/get-components.ts +++ b/packages/devtools/src/rpc/get-components.ts @@ -2,7 +2,6 @@ import { defineRpcFunction } from 'devframe'; import * as v from 'valibot'; import { describable } from './agent-schema.ts'; import { readFileSync } from 'node:fs'; -import { relative } from 'node:path'; import { angularMajor } from './angular-version.ts'; import { ANNOTATION, @@ -14,6 +13,7 @@ import { sourceRoots, stripComments, walkFiles, + relativeFile, } from './source-scan.ts'; const ComponentSchema = v.object({ @@ -68,7 +68,9 @@ function scanComponents(cwd: string, major: number | undefined): ComponentInfo[] walkFiles(root, (full, entry) => { if (!entry.endsWith('.ts') || entry.endsWith('.spec.ts')) return; try { - components.push(...componentsIn(readFileSync(full, 'utf-8'), relative(cwd, full), major)); + components.push( + ...componentsIn(readFileSync(full, 'utf-8'), relativeFile(cwd, full), major), + ); } catch { // skip } diff --git a/packages/devtools/src/rpc/get-ngrx-store.ts b/packages/devtools/src/rpc/get-ngrx-store.ts index 6dc0f1e9..21a6fa03 100644 --- a/packages/devtools/src/rpc/get-ngrx-store.ts +++ b/packages/devtools/src/rpc/get-ngrx-store.ts @@ -2,7 +2,6 @@ import { defineRpcFunction } from 'devframe'; import * as v from 'valibot'; import { describable } from './agent-schema.ts'; import { readFileSync } from 'node:fs'; -import { relative } from 'node:path'; import { lineCounter, maskRegexes, @@ -11,6 +10,7 @@ import { sourceRoots, stripComments, walkFiles, + relativeFile, } from './source-scan.ts'; const NgrxStoreEntrySchema = v.object({ @@ -170,7 +170,7 @@ function scanFile(full: string, cwd: string, out: NgrxStoreEntry[]) { // the app. const content = maskRegexes(maskStrings(stripComments(raw))); const lineAt = lineCounter(content); - const relPath = relative(cwd, full); + const relPath = relativeFile(cwd, full); for (const { pattern, kind } of NGRX_PATTERNS) { pattern.lastIndex = 0; diff --git a/packages/devtools/src/rpc/get-pipes.ts b/packages/devtools/src/rpc/get-pipes.ts index 54be1031..fc9ba3a8 100644 --- a/packages/devtools/src/rpc/get-pipes.ts +++ b/packages/devtools/src/rpc/get-pipes.ts @@ -16,6 +16,8 @@ import { sourceRoots, stripComments, walkFiles, + relativeFile, + toPosixPath, } from './source-scan.ts'; const UsageSiteSchema = v.object({ @@ -98,7 +100,7 @@ export function scanPipes(cwd: string): PipeInfo[] { if (!entry.endsWith('.ts') || entry.endsWith('.spec.ts')) return; try { const content = readFileSync(full, 'utf-8'); - const relPath = relative(cwd, full); + const relPath = relativeFile(cwd, full); pipes.push(...pipesIn(content, relPath, major)); collectUsages(content, relPath, full, cwd, usagesByName); } catch { @@ -259,7 +261,7 @@ function templateFromUrl(url: string, fullPath: string, cwd: string): TemplateSo // call that would mix a real path with an unresolved `cwd` and produce a // long, wrong `../../` chain wherever `cwd` sits behind a symlink itself // (e.g. macOS's /var -> /private/var). - return { text, file: inside, lineAt: lineCounter(text), baseOffset: 0 }; + return { text, file: toPosixPath(inside), lineAt: lineCounter(text), baseOffset: 0 }; } catch { // templateUrl does not resolve to a readable file return null; diff --git a/packages/devtools/src/rpc/get-providers.ts b/packages/devtools/src/rpc/get-providers.ts index f7799d57..41b87204 100644 --- a/packages/devtools/src/rpc/get-providers.ts +++ b/packages/devtools/src/rpc/get-providers.ts @@ -8,12 +8,11 @@ import { sourceRoots, stripComments, walkFiles, + relativeFile, } from './source-scan.ts'; import * as v from 'valibot'; import { describable } from './agent-schema.ts'; import { readFileSync } from 'node:fs'; -import { relative } from 'node:path'; - const ProviderEntrySchema = v.object({ token: v.string(), source: v.string(), @@ -97,7 +96,7 @@ function scanFile(full: string, cwd: string, out: ProviderEntry[]) { // providers, so match against masked source. Masking keeps the length, // so offsets still line up. const code = maskRegexes(maskStrings(source)); - const relPath = relative(cwd, full); + const relPath = relativeFile(cwd, full); const lineAt = lineCounter(code); // @Injectable({ ... }) or @Service, matched in two steps: the decorator diff --git a/packages/devtools/src/rpc/get-signals.ts b/packages/devtools/src/rpc/get-signals.ts index e23dadbd..b617fc45 100644 --- a/packages/devtools/src/rpc/get-signals.ts +++ b/packages/devtools/src/rpc/get-signals.ts @@ -2,7 +2,6 @@ import { defineRpcFunction } from 'devframe'; import * as v from 'valibot'; import { describable } from './agent-schema.ts'; import { readFileSync } from 'node:fs'; -import { relative } from 'node:path'; import { ANNOTATION, classScopes, @@ -11,6 +10,7 @@ import { sourceRoots, stripComments, walkFiles, + relativeFile, } from './source-scan.ts'; const SignalEntrySchema = v.object({ @@ -77,7 +77,7 @@ function scanSignals(cwd: string): SignalEntry[] { walkFiles(root, (full, item) => { if (!item.endsWith('.ts') || item.endsWith('.spec.ts') || item.endsWith('.d.ts')) return; try { - entries.push(...signalsIn(readFileSync(full, 'utf-8'), relative(cwd, full))); + entries.push(...signalsIn(readFileSync(full, 'utf-8'), relativeFile(cwd, full))); } catch { // skip } diff --git a/packages/devtools/src/rpc/pipe-lint.ts b/packages/devtools/src/rpc/pipe-lint.ts index 1702d2fa..f2e41989 100644 --- a/packages/devtools/src/rpc/pipe-lint.ts +++ b/packages/devtools/src/rpc/pipe-lint.ts @@ -1,5 +1,4 @@ import { readFileSync } from 'node:fs'; -import { relative } from 'node:path'; import { BUILTIN_PIPES, pipeUsesIn, templatesIn, type TemplateSource } from './get-pipes.ts'; import { classScopes, @@ -10,6 +9,7 @@ import { sourceRoots, stripComments, walkFiles, + relativeFile, } from './source-scan.ts'; export type LintSeverity = 'error' | 'warning' | 'info'; @@ -57,7 +57,7 @@ export function lintPipes( try { files.push({ content: readFileSync(full, 'utf-8'), - relPath: relative(cwd, full), + relPath: relativeFile(cwd, full), fullPath: full, }); } catch { diff --git a/packages/devtools/src/rpc/server-routes.ts b/packages/devtools/src/rpc/server-routes.ts index 5567874d..1668fd41 100644 --- a/packages/devtools/src/rpc/server-routes.ts +++ b/packages/devtools/src/rpc/server-routes.ts @@ -1,5 +1,4 @@ import { readFileSync } from 'node:fs'; -import { relative } from 'node:path'; import { matchDelimiter, maskStrings, @@ -7,6 +6,7 @@ import { sourceRoots, stripComments, walkFiles, + relativeFile, } from './source-scan.ts'; export interface ServerRouteEntry { @@ -75,7 +75,7 @@ export function scanServerRoutes(cwd: string): ServerRouteEntry[] { } return files.flatMap((file) => { try { - return parseServerRoutes(readFileSync(file, 'utf-8'), relative(cwd, file)); + return parseServerRoutes(readFileSync(file, 'utf-8'), relativeFile(cwd, file)); } catch { return []; } diff --git a/packages/devtools/src/rpc/source-scan.ts b/packages/devtools/src/rpc/source-scan.ts index 619073c1..854bb249 100644 --- a/packages/devtools/src/rpc/source-scan.ts +++ b/packages/devtools/src/rpc/source-scan.ts @@ -551,6 +551,20 @@ export function escapes(rel: string): boolean { return rel === '..' || rel.startsWith('..' + sep); } +/** + * `path` with `/` between its parts. `relative()` joins with `\` on Windows, + * and a file name sent to the panel or an agent should read the same on every + * platform, as the page and the route scans already report it. + */ +export function toPosixPath(path: string, separator: string = sep): string { + return separator === '/' ? path : path.split(separator).join('/'); +} + +/** `to` relative to `from`, with `/` between its parts: the file name a scan reports. */ +export function relativeFile(from: string, to: string): string { + return toPosixPath(relative(from, to)); +} + /** The path with symlinks resolved, or the path itself when it does not exist. */ export function realPath(path: string): string { try { diff --git a/packages/devtools/src/rpc/template-lint.ts b/packages/devtools/src/rpc/template-lint.ts index 4441352a..f7ec10ac 100644 --- a/packages/devtools/src/rpc/template-lint.ts +++ b/packages/devtools/src/rpc/template-lint.ts @@ -3,9 +3,9 @@ // read from Angular's AST, not guessed with a regex or loaded from the project. import { readFileSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join } from 'node:path'; import { templatesIn, type TemplateSource } from './get-pipes.ts'; -import { sourceRoots, walkFiles } from './source-scan.ts'; +import { sourceRoots, walkFiles, relativeFile } from './source-scan.ts'; import type { LintSeverity } from './pipe-lint.ts'; import { redactText } from '../text-redact.ts'; @@ -158,7 +158,7 @@ export async function lintTemplates( return; } if (!content.includes('@Component')) return; - const relPath = relative(cwd, full); + const relPath = relativeFile(cwd, full); for (const template of templatesIn(content, relPath, full, cwd)) { const text = templateText(template, content, relPath); if (text === undefined) continue; diff --git a/packages/devtools/vitest.config.ts b/packages/devtools/vitest.config.ts index 745aefe3..2a6cbe55 100644 --- a/packages/devtools/vitest.config.ts +++ b/packages/devtools/vitest.config.ts @@ -6,5 +6,14 @@ export default defineConfig({ // `--localstorage-file` and hides the jsdom one the tests rely on. execArgv: ['--no-experimental-webstorage'], setupFiles: ['src/__tests__/test-env.ts'], + // Used only with `--coverage` (`pnpm test:coverage`): a report, with no threshold to meet. + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/__tests__/**', 'src/**/*.test.ts', 'src/**/*.d.ts'], + reporter: ['text-summary', 'html', 'json-summary', 'lcov'], + reportOnFailure: true, + reportsDirectory: '../../dist/coverage/devtools', + }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ca61b01..f6cb0f4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,7 +60,7 @@ importers: devDependencies: '@analogjs/vite-plugin-angular': specifier: ^2.7.2 - version: 2.7.2(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + version: 2.7.2(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@angular-devkit/core': specifier: ^22.1.8 version: 22.1.8(chokidar@5.0.0) @@ -69,7 +69,7 @@ importers: version: 22.1.8(chokidar@5.0.0) '@angular/build': specifier: ^22.1.8 - version: 22.1.8(7bf82a545fa1df14b49b61d6ca208405) + version: 22.1.8(b8df9d91e323be2b1d1126d8569063c0) '@angular/cli': specifier: ^22.1.8 version: 22.1.8(@types/node@24.13.6)(chokidar@5.0.0) @@ -93,13 +93,13 @@ importers: version: 22.0.1(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2) '@nx/angular': specifier: 23.2.1 - version: 23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@babel/traverse@7.29.8)(@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))))(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3) + version: 23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@babel/traverse@7.29.8)(@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11))(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3) '@nx/vite': specifier: 23.2.1 - version: 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))) + version: 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11) '@nx/vitest': specifier: 23.2.1 - version: 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))) + version: 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11) '@nx/workspace': specifier: 23.2.1 version: 23.2.1 @@ -112,6 +112,9 @@ importers: '@types/node': specifier: ^24.0.0 version: 24.13.6 + '@vitest/coverage-v8': + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) jsdom: specifier: ^28.0.0 version: 28.1.0 @@ -135,7 +138,7 @@ importers: version: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) vitest: specifier: ^4.0.8 - version: 4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + version: 4.1.11(@types/node@24.13.6)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) apps/docs: dependencies: @@ -229,16 +232,16 @@ importers: devDependencies: '@analogjs/platform': specifier: ^2.7.5 - version: 2.7.5(a3860d79e1549666b815c2e99bd3cbc3) + version: 2.7.5(27468854559020cf9ccd22629f9b0d79) '@analogjs/vite-plugin-angular': specifier: ^2.7.5 - version: 2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + version: 2.7.5(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@analogjs/vitest-angular': specifier: ^2.7.5 - version: 2.7.5(@analogjs/vite-plugin-angular@2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))(@angular-devkit/architect@0.2201.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))(zone.js@0.16.3) + version: 2.7.5(@analogjs/vite-plugin-angular@2.7.5(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))(@angular-devkit/architect@0.2201.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(vitest@4.1.11)(zone.js@0.16.3) '@angular/build': specifier: ^22.1.8 - version: 22.1.8(7bf82a545fa1df14b49b61d6ca208405) + version: 22.1.8(b8df9d91e323be2b1d1126d8569063c0) '@angular/cli': specifier: ^22.1.8 version: 22.1.8(@types/node@24.13.6)(chokidar@5.0.0) @@ -259,7 +262,7 @@ importers: version: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) vitest: specifier: ^4.0.8 - version: 4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + version: 4.1.11(@types/node@24.13.6)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) wrangler: specifier: ^4.147.0 version: 4.147.0(@types/node@24.13.6) @@ -323,13 +326,13 @@ importers: devDependencies: '@analogjs/platform': specifier: 2.7.5 - version: 2.7.5(a3860d79e1549666b815c2e99bd3cbc3) + version: 2.7.5(27468854559020cf9ccd22629f9b0d79) '@analogjs/vite-plugin-angular': specifier: 2.7.5 - version: 2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + version: 2.7.5(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@angular/build': specifier: ^22.1.8 - version: 22.1.8(7bf82a545fa1df14b49b61d6ca208405) + version: 22.1.8(b8df9d91e323be2b1d1126d8569063c0) '@angular/compiler-cli': specifier: ^22.1.0 version: 22.1.7(@angular/compiler@22.1.7)(typescript@6.0.3) @@ -1349,6 +1352,10 @@ packages: resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} engines: {node: ^22.18.0 || >=24.11.0} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -4134,6 +4141,15 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} @@ -4428,6 +4444,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + async-sema@3.1.1: resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} @@ -5524,6 +5543,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -5716,6 +5738,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -6019,6 +6053,10 @@ packages: magicast@0.5.5: resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + marked-gfm-heading-id@4.1.4: resolution: {integrity: sha512-CspnvVfHSkb/znqdPS4jUR8HtCjq3M/DnrsJCrfLBLvdrgbemmoINKpeWKQYkBiXAoBGejw0cV7xzqrPdup3WA==} peerDependencies: @@ -7770,9 +7808,9 @@ snapshots: optionalDependencies: '@nx/devkit': 23.2.1(nx@23.2.1) - '@analogjs/platform@2.7.5(a3860d79e1549666b815c2e99bd3cbc3)': + '@analogjs/platform@2.7.5(27468854559020cf9ccd22629f9b0d79)': dependencies: - '@analogjs/vite-plugin-angular': 2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + '@analogjs/vite-plugin-angular': 2.7.5(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@analogjs/vite-plugin-nitro': 2.7.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@parcel/watcher@2.6.0)(rolldown@1.2.9)(srvx@1.0.8)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) marked: 15.0.12 marked-gfm-heading-id: 4.1.4(marked@15.0.12) @@ -7782,9 +7820,9 @@ snapshots: vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) vitefu: 1.1.3(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) optionalDependencies: - '@nx/angular': 23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@babel/traverse@7.29.8)(@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))))(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3) + '@nx/angular': 23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@babel/traverse@7.29.8)(@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11))(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3) '@nx/devkit': 23.2.1(nx@23.2.1) - '@nx/vite': 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))) + '@nx/vite': 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11) marked-highlight: 2.2.4(marked@15.0.12) marked-shiki: 1.2.1(marked@15.0.12)(shiki@1.29.2) prismjs: 1.30.0 @@ -7840,27 +7878,27 @@ snapshots: '@angular/router': 22.1.7(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.7(@angular/animations@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) tslib: 2.8.1 - '@analogjs/vite-plugin-angular@2.7.2(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': + '@analogjs/vite-plugin-angular@2.7.2(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: magic-string: 0.30.21 obug: 2.2.1 oxc-parser: 0.121.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3) tinyglobby: 0.2.17 optionalDependencies: - '@angular/build': 22.1.8(7bf82a545fa1df14b49b61d6ca208405) + '@angular/build': 22.1.8(b8df9d91e323be2b1d1126d8569063c0) vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - '@analogjs/vite-plugin-angular@2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': + '@analogjs/vite-plugin-angular@2.7.5(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))': dependencies: magic-string: 0.30.21 obug: 2.2.1 oxc-parser: 0.121.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3) tinyglobby: 0.2.17 optionalDependencies: - '@angular/build': 22.1.8(7bf82a545fa1df14b49b61d6ca208405) + '@angular/build': 22.1.8(b8df9d91e323be2b1d1126d8569063c0) vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) transitivePeerDependencies: - '@emnapi/core' @@ -7919,12 +7957,12 @@ snapshots: - webpack - xml2js - '@analogjs/vitest-angular@2.7.5(@analogjs/vite-plugin-angular@2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))(@angular-devkit/architect@0.2201.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))(zone.js@0.16.3)': + '@analogjs/vitest-angular@2.7.5(@analogjs/vite-plugin-angular@2.7.5(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))(@angular-devkit/architect@0.2201.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(vitest@4.1.11)(zone.js@0.16.3)': dependencies: - '@analogjs/vite-plugin-angular': 2.7.5(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + '@analogjs/vite-plugin-angular': 2.7.5(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) '@angular-devkit/architect': 0.2201.8(chokidar@5.0.0) '@angular-devkit/schematics': 22.1.8(chokidar@5.0.0) - vitest: 4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@24.13.6)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) optionalDependencies: zone.js: 0.16.3 @@ -7962,7 +8000,7 @@ snapshots: tslib: 2.8.1 optional: true - '@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405)': + '@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2201.8(chokidar@5.0.0) @@ -8003,7 +8041,7 @@ snapshots: postcss: 8.5.28 rollup: 4.63.5 tailwindcss: 4.3.3 - vitest: 4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@24.13.6)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -8958,6 +8996,8 @@ snapshots: '@babel/helper-string-parser': 8.0.6 '@babel/helper-validator-identifier': 8.0.6 + '@bcoe/v8-coverage@1.0.2': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -10050,14 +10090,14 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 - '@nx/angular@23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(7bf82a545fa1df14b49b61d6ca208405))(@babel/traverse@7.29.8)(@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))))(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3)': + '@nx/angular@23.2.1(@angular-devkit/core@22.1.8(chokidar@5.0.0))(@angular-devkit/schematics@22.1.8(chokidar@5.0.0))(@angular/build@22.1.8(b8df9d91e323be2b1d1126d8569063c0))(@babel/traverse@7.29.8)(@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11))(@schematics/angular@22.1.8(chokidar@5.0.0))(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1)(rxjs@7.8.2)(typescript@6.0.3)': dependencies: '@angular-devkit/core': 22.1.8(chokidar@5.0.0) '@angular-devkit/schematics': 22.1.8(chokidar@5.0.0) '@nx/devkit': 23.2.1(nx@23.2.1) '@nx/eslint': 23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1) '@nx/js': 23.2.1(@babel/traverse@7.29.8)(nx@23.2.1) - '@nx/web': 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))))(nx@23.2.1) + '@nx/web': 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11))(nx@23.2.1) '@phenomnomnominal/tsquery': 6.2.0(typescript@6.0.3) '@schematics/angular': 22.1.8(chokidar@5.0.0) '@typescript-eslint/type-utils': 8.71.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) @@ -10069,7 +10109,7 @@ snapshots: semver: 7.8.5 tslib: 2.8.1 optionalDependencies: - '@angular/build': 22.1.8(7bf82a545fa1df14b49b61d6ca208405) + '@angular/build': 22.1.8(b8df9d91e323be2b1d1126d8569063c0) transitivePeerDependencies: - '@babel/traverse' - '@nx/jest' @@ -10181,11 +10221,11 @@ snapshots: '@nx/nx-win32-x64-msvc@23.2.1': optional: true - '@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))': + '@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11)': dependencies: '@nx/devkit': 23.2.1(nx@23.2.1) '@nx/js': 23.2.1(@babel/traverse@7.29.8)(nx@23.2.1) - '@nx/vitest': 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))) + '@nx/vitest': 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11) '@phenomnomnominal/tsquery': 6.2.0(typescript@6.0.3) ajv: 8.20.0 picomatch: 4.0.4 @@ -10205,7 +10245,7 @@ snapshots: - verdaccio - vitest - '@nx/vitest@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)))': + '@nx/vitest@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11)': dependencies: '@nx/devkit': 23.2.1(nx@23.2.1) '@nx/js': 23.2.1(@babel/traverse@7.29.8)(nx@23.2.1) @@ -10215,7 +10255,7 @@ snapshots: optionalDependencies: '@nx/eslint': 23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1) vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) - vitest: 4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@24.13.6)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -10226,7 +10266,7 @@ snapshots: - typescript - verdaccio - '@nx/web@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))))(nx@23.2.1)': + '@nx/web@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(@nx/vite@23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11))(nx@23.2.1)': dependencies: '@nx/devkit': 23.2.1(nx@23.2.1) '@nx/js': 23.2.1(@babel/traverse@7.29.8)(nx@23.2.1) @@ -10236,7 +10276,7 @@ snapshots: tslib: 2.8.1 optionalDependencies: '@nx/eslint': 23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1) - '@nx/vite': 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))) + '@nx/vite': 23.2.1(@babel/traverse@7.29.8)(@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@zkochan/js-yaml@0.0.7)(eslint@10.11.0(jiti@2.7.0))(nx@23.2.1))(nx@23.2.1)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -11105,6 +11145,20 @@ snapshots: dependencies: vite: 8.1.5(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.51.2)(yaml@2.9.0) + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.7 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.5 + obug: 2.2.1 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@24.13.6)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 @@ -11328,6 +11382,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-sema@3.1.1: {} async@3.2.6: {} @@ -12463,6 +12523,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} htmlparser2@10.1.0: @@ -12652,6 +12714,19 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -12945,6 +13020,10 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.1 + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + marked-gfm-heading-id@4.1.4(marked@15.0.12): dependencies: github-slugger: 2.0.0 @@ -14643,7 +14722,7 @@ snapshots: optionalDependencies: vite: 8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0) - vitest@4.1.11(@types/node@24.13.6)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)): + vitest@4.1.11(@types/node@24.13.6)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.105.0)(terser@5.51.2)(yaml@2.9.0)) @@ -14667,6 +14746,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.6 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) jsdom: 28.1.0 transitivePeerDependencies: - msw diff --git a/scripts/e2e-extension.mjs b/scripts/e2e-extension.mjs new file mode 100644 index 00000000..cb660829 --- /dev/null +++ b/scripts/e2e-extension.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node +// Extension e2e: loads the unpacked extension (extension/) into Playwright's Chromium, opens +// DevTools on a running app, selects the Pangular Inspector panel and checks that it finds the +// devtools server, loads extension/ui in its frame for the inspected page and shows the app. +// +// node scripts/e2e-extension.mjs --base http://localhost:4000 [--out dir] [--component App] +// +// The app must run with PANGULAR_AUTH=false, as .github/workflows/e2e.yml starts the SSR demo: +// the panel bridge does not answer the one-time code prompt. Chromium runs headless (the new +// headless mode, which loads extensions); set HEADED=1 to watch it, CHROMIUM_PATH to use another +// Chromium. +// +// Playwright has no handle on DevTools windows, so the script talks to the DevTools front end and +// the panel page over the Chrome DevTools Protocol, through the inspected page's CDP session. +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { chromium } from 'playwright'; + +const args = process.argv.slice(2); +const option = (name, fallback) => { + const at = args.indexOf(`--${name}`); + return at >= 0 ? args[at + 1] : fallback; +}; +const base = option('base', 'http://localhost:4000').replace(/\/$/, ''); +const out = resolve(option('out', 'dist/e2e-extension')); +// The root component of the app, which the Components tab must list. +const component = option('component', 'App'); +const extensionDir = resolve(import.meta.dirname, '../extension'); +// The ID the `key` in extension/manifest.json gives, which the devtools server trusts. +const EXTENSION_ID = 'dcogniffeelebaolkkfbopmjcblhblfk'; +const extensionOrigin = `chrome-extension://${EXTENSION_ID}`; +const TIMEOUT_MS = 30_000; + +const steps = []; +function step(name, ok, detail = '') { + steps.push({ name, ok, detail }); + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`); + return ok; +} + +/** Polls `read` until `done` holds for its value, or gives the last value after the timeout. */ +async function until(read, done, timeoutMs = TIMEOUT_MS) { + const end = Date.now() + timeoutMs; + let value; + for (;;) { + value = await read().catch((error) => ({ error: error.message })); + if (done(value) || Date.now() > end) return value; + await new Promise((r) => setTimeout(r, 250)); + } +} + +const profile = mkdtempSync(join(tmpdir(), 'pangular-extension-e2e-')); +const context = await chromium.launchPersistentContext(profile, { + // `chromium` is the new headless mode, the one that loads extensions. + channel: process.env.CHROMIUM_PATH ? undefined : 'chromium', + executablePath: process.env.CHROMIUM_PATH || undefined, + headless: !process.env.HEADED, + viewport: { width: 1280, height: 800 }, + args: [ + `--disable-extensions-except=${extensionDir}`, + `--load-extension=${extensionDir}`, + '--auto-open-devtools-for-tabs', + ], +}); + +let failed = false; +try { + // One tab, so the one DevTools front end is the one for the app. + const page = context.pages()[0] ?? (await context.newPage()); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.goto(`${base}/`); + await page.waitForLoadState('networkidle').catch(() => {}); + + const cdp = await context.newCDPSession(page); + const targets = async () => (await cdp.send('Target.getTargets')).targetInfos; + + // Commands to a target attached without flattening go through sendMessageToTarget. + let nextId = 0; + const replies = new Map(); + cdp.on('Target.receivedMessageFromTarget', ({ sessionId, message }) => { + const reply = JSON.parse(message); + replies.get(`${sessionId}:${reply.id}`)?.(reply); + }); + async function attach(targetId) { + const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: false }); + return async (expression) => { + const id = ++nextId; + const reply = new Promise((done) => replies.set(`${sessionId}:${id}`, done)); + await cdp.send('Target.sendMessageToTarget', { + sessionId, + message: JSON.stringify({ + id, + method: 'Runtime.evaluate', + params: { expression, awaitPromise: true, returnByValue: true }, + }), + }); + const { result, error } = await reply; + if (error) throw new Error(error.message); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.text); + return result.result.value; + }; + } + + const front = await until( + async () => (await targets()).find((t) => t.url.startsWith('devtools://')), + Boolean, + ); + if (!step('DevTools opens on the app', !!front?.targetId)) throw new Error('no DevTools'); + const inDevtools = await attach(front.targetId); + + // devtools.js adds the panel once it sees Angular on the page. + const panelId = await until( + () => + inDevtools(`(async () => { + const UI = await import('./ui/legacy/legacy.js'); + return UI.InspectorView.InspectorView.instance() + .tabbedPane.tabIds().find((id) => id.startsWith(${JSON.stringify(extensionOrigin)})) ?? null; + })()`), + (id) => typeof id === 'string', + ); + if (!step('the extension adds its panel', typeof panelId === 'string', JSON.stringify(panelId))) { + throw new Error('no panel'); + } + await inDevtools(`(async () => { + const UI = await import('./ui/legacy/legacy.js'); + await UI.InspectorView.InspectorView.instance().showPanel(${JSON.stringify(panelId)}); + })()`); + + const panelTarget = await until( + async () => (await targets()).find((t) => t.url === `${extensionOrigin}/panel.html`), + Boolean, + ); + if (!step('the panel page loads', !!panelTarget)) throw new Error('no panel page'); + const inPanel = await attach(panelTarget.targetId); + + // panel.html and extension/ui share the extension's origin, so the panel reads its frame. + const STATE = `(() => { + const frame = document.getElementById('devtools-frame'); + const ui = frame?.style.display === 'block' ? frame.contentDocument : null; + return { + status: document.getElementById('status-message')?.textContent ?? '', + src: frame?.getAttribute('src') ?? '', + connected: !!ui?.querySelector('.status.connected'), + tabs: [...(ui?.querySelectorAll('nav[aria-label="Inspectors"] button') ?? [])].map((b) => + b.textContent.trim(), + ), + }; + })()`; + const state = await until( + () => inPanel(STATE), + (s) => s?.connected && s.tabs?.length > 0, + ); + const src = state?.src ? new URL(state.src) : null; + step( + 'the bridge loads extension/ui for the inspected page', + src?.href.startsWith(`${extensionOrigin}/ui/index.html?`) && + new URL(src.searchParams.get('baseURL') ?? 'x:', base).origin === new URL(base).origin && + !!src.searchParams.get('pageId'), + state?.src || state?.status || JSON.stringify(state), + ); + step('the panel connects to the devtools server', !!state?.connected, state?.status); + step('the panel shows the inspector tabs', state?.tabs?.length > 0, state?.tabs?.join(', ')); + + const components = await until( + () => + inPanel(`(() => { + const ui = document.getElementById('devtools-frame').contentDocument; + const tab = [...ui.querySelectorAll('nav[aria-label="Inspectors"] button')].find( + (b) => b.textContent.trim() === 'Components', + ); + if (tab && tab.getAttribute('aria-current') !== 'page') tab.click(); + // The first word of each row of the component tree is the component's name. + return [...ui.querySelectorAll('main [role="treeitem"]')].map((row) => + row.textContent.trim().match(/^[A-Za-z0-9_$]+/)?.[0], + ).filter(Boolean); + })()`), + (names) => Array.isArray(names) && names.includes(component), + ); + step( + `the Components tab lists ${component} from the inspected page`, + Array.isArray(components) && components.includes(component), + Array.isArray(components) ? components.slice(0, 8).join(', ') : JSON.stringify(components), + ); + step('the app logs no page errors', pageErrors.length === 0, pageErrors.slice(0, 3).join('; ')); +} catch (error) { + step('the run finishes', false, error.message.split('\n')[0]); +} finally { + failed = steps.some((s) => !s.ok); + await context.close(); + rmSync(profile, { recursive: true, force: true }); +} + +mkdirSync(out, { recursive: true }); +writeFileSync(join(out, 'results.json'), JSON.stringify(steps, null, 2)); +console.log(`\n${steps.length} steps, ${steps.filter((s) => !s.ok).length} failed.`); +process.exit(failed ? 1 : 0); diff --git a/scripts/extension-axe.mjs b/scripts/extension-axe.mjs new file mode 100644 index 00000000..59170d97 --- /dev/null +++ b/scripts/extension-axe.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +// axe check of the Chrome extension panel (extension/panel.html), loaded over http with a stand-in +// for the `chrome` APIs. It covers every status the panel bridge shows before it connects, then +// the connected panel: the committed extension/ui build in the panel's frame, reading a static +// report served from the same origin. Both color schemes, at 1280 and 360 px. +// +// node scripts/extension-axe.mjs [report dir] +// +// The report dir defaults to dist/panel-axe, which `pnpm test:axe` writes. Build one with +// `node bin.mjs build --outDir dist/panel-axe` after `pnpm devtools:build-pkg`. +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join, normalize, resolve, sep } from 'node:path'; +import AxeBuilder from '@axe-core/playwright'; +import { chromium } from 'playwright'; + +const root = resolve(import.meta.dirname, '..'); +const extensionDir = join(root, 'extension'); +const reportDir = resolve(process.argv[2] ?? join(root, 'dist/panel-axe')); +if (!existsSync(join(reportDir, '__connection.json'))) { + console.error( + `${reportDir} is not a Pangular Inspector report. Run pnpm test:axe, or: node bin.mjs build --outDir ${reportDir}`, + ); + process.exit(1); +} +if (!existsSync(join(extensionDir, 'ui/index.html'))) { + console.error('extension/ui is missing. Run: pnpm extension:build'); + process.exit(1); +} + +const TYPES = { + '.html': 'text/html', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.woff2': 'font/woff2', +}; +const REPORT_PREFIX = '/__pangular/'; + +function sendFile(res, base, path) { + let file = join(base, path); + if (!(file === base || file.startsWith(base + sep)) || !existsSync(file)) { + res.writeHead(404).end(); + return; + } + if (statSync(file).isDirectory()) file = join(file, 'index.html'); + if (!existsSync(file)) { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' }); + createReadStream(file).pipe(res); +} + +/** Starts a server on a free port and gives its origin. */ +async function listen(handler) { + const server = createServer(handler); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + return { server, origin: `http://127.0.0.1:${server.address().port}` }; +} + +// The extension's files at `/`, standing in for chrome-extension:///, and the static report +// at /__pangular/, where the bridge finds its __connection.json. One origin, so the panel takes +// the `baseURL` the bridge passes, as it takes the extension's own in Chrome. +const extension = await listen((req, res) => { + const path = normalize(decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)); + if (path.startsWith(REPORT_PREFIX)) sendFile(res, reportDir, path.slice(REPORT_PREFIX.length)); + else sendFile(res, extensionDir, path.slice(1)); +}); +// Devtools servers the bridge probes from another origin. CORS headers let it read the status, +// as host permissions let the real extension. +const cors = { 'access-control-allow-origin': '*' }; +const missing = await listen((_req, res) => res.writeHead(404, cors).end()); +const refusing = await listen((_req, res) => + res.writeHead(403, { ...cors, 'content-type': 'text/plain' }).end('Host not allowed'), +); + +/** + * Runs in the panel page before its scripts: a `chrome` with what panel-bridge.js and + * panel-actions.js call. `origin` is what the inspected page answers for `location.origin`; + * `error` and `silent` stand for a page that throws or never answers. + */ +function fakeChrome({ theme, origin, granted, eval: mode }) { + const listeners = () => ({ addListener() {} }); + // Only the panel page itself: the frame it loads reads the theme from its URL. + if (window.top !== window) return; + window.chrome = { + devtools: { + panels: { + themeName: theme, + setThemeChangeHandler() {}, + elements: { onSelectionChanged: listeners() }, + openResource: (_url, _line, done) => done?.({}), + }, + inspectedWindow: { + eval(expression, done) { + if (mode === 'silent') return; + if (mode === 'error') return done(undefined, { isException: true }); + if (expression === 'location.origin') return done(origin); + done(null); + }, + getResources: (done) => done([]), + }, + network: { onNavigated: listeners() }, + }, + permissions: { + contains: async () => granted, + request: async () => false, + }, + runtime: { getURL: (path = '') => `${location.origin}/${path}` }, + }; +} + +// Each status the bridge can show, the text that marks it, and the inspected page behind it. +const STATES = [ + { name: 'page not answering', text: 'not answering', page: { eval: 'silent' } }, + { name: 'page unreadable', text: "Couldn't read", page: { eval: 'error' } }, + { name: 'not http', text: 'served over http', page: { origin: 'null' } }, + { + name: 'access not granted', + text: 'Allow Pangular Inspector', + page: { origin: missing.origin, granted: false }, + visible: '#status-allow', + }, + { + name: 'no server', + text: 'No devtools server answered', + page: { origin: missing.origin }, + visible: '#status-tried', + }, + { + name: 'server refused', + text: 'refused the request (403)', + page: { origin: refusing.origin }, + visible: '#status-tried', + }, +]; + +const failures = []; +const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || undefined }); + +async function analyze(page, label) { + const { violations } = await new AxeBuilder({ page }).analyze(); + for (const v of violations) { + const targets = v.nodes.map((n) => n.target.join(' ')).slice(0, 3); + failures.push(`${label}: ${v.id} (${v.impact}) ${v.help} at ${targets.join(', ')}`); + } + console.log(`${violations.length ? 'FAIL' : 'ok '} ${label}`); +} + +async function openPanel(scheme, width, page) { + const context = await browser.newContext({ + colorScheme: scheme, + reducedMotion: 'reduce', + viewport: { width, height: 800 }, + }); + await context.addInitScript(fakeChrome, { + theme: scheme === 'dark' ? 'dark' : 'default', + origin: extension.origin, + granted: true, + eval: 'answer', + ...page, + }); + const tab = await context.newPage(); + tab.on('pageerror', (error) => failures.push(`${scheme} ${width}: page error ${error.message}`)); + await tab.goto(`${extension.origin}/panel.html`); + return { context, tab }; +} + +try { + for (const scheme of ['dark', 'light']) { + for (const width of [1280, 360]) { + for (const state of STATES) { + const label = `${scheme} ${width} ${state.name}`; + const { context, tab } = await openPanel(scheme, width, state.page); + try { + // The bridge reads the page for a few seconds before it gives up on it. + await tab + .locator('#status-message', { hasText: state.text }) + .waitFor({ timeout: 20_000 }); + if (state.visible) await tab.locator(state.visible).waitFor({ timeout: 5_000 }); + await analyze(tab, label); + } catch (error) { + failures.push(`${label}: the status did not show (${error.message.split('\n')[0]})`); + } finally { + await context.close(); + } + } + + // Connected: the bridge finds the report and loads extension/ui in its frame. + const label = `${scheme} ${width} connected`; + const { context, tab } = await openPanel(scheme, width, {}); + try { + await tab.locator('#devtools-frame').waitFor({ state: 'visible', timeout: 20_000 }); + const panel = tab.frameLocator('#devtools-frame'); + await panel.locator('.status.connected').waitFor({ timeout: 20_000 }); + await panel.locator('nav[aria-label="Inspectors"] button').first().waitFor(); + const src = await tab.locator('#devtools-frame').getAttribute('src'); + if (!src?.startsWith(`${extension.origin}/ui/index.html?`)) { + failures.push(`${label}: the frame loaded ${src}, not extension/ui`); + } + await tab.waitForTimeout(700); + // AxeBuilder checks the frame too. + await analyze(tab, label); + } catch (error) { + failures.push(`${label}: the panel did not load (${error.message.split('\n')[0]})`); + } finally { + await context.close(); + } + } + } +} finally { + await browser.close(); + for (const { server } of [extension, missing, refusing]) server.close(); +} + +if (failures.length) { + console.error(`\n${failures.length} problem(s) in the extension panel:`); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} +console.log('\nThe extension panel passes axe in every state.');