diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index 2561a2de..8bdd044c 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -102,3 +102,31 @@ jobs: - name: Test the page side run: pnpm exec vitest run --root packages/devtools --config vitest.compat.config.ts + + vite: + name: Vite ${{ matrix.vite }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # The `vite` peer range is `>=6`; the workspace installs the newest major. + vite: ['6', '7'] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + - name: Install Vite ${{ matrix.vite }} + env: + VITE_MAJOR: ${{ matrix.vite }} + # Only this job's copy of the workspace changes; the committed lockfile stays as it is. + run: | + printf "\noverrides:\n vite: '^%s.0.0'\n" "$VITE_MAJOR" >> pnpm-workspace.yaml + pnpm install --no-frozen-lockfile + echo "- vite $(node -p "require('vite/package.json').version")" | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Test the Vite plugin + run: pnpm exec vitest run --root packages/devtools src/__tests__/vite- src/__tests__/lan-address.test.ts diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index fc737b77..74fec154 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -37,6 +37,7 @@ jobs: elif [ "$NO_DOCS" = "true" ]; then echo "Code changed without docs, marked no-docs." else - echo "::warning title=Docs not updated::This pull request changes code but no page in apps/docs/src/content. Update the docs in this pull request, or add the no-docs label and say why in the description." + echo "::error title=Docs not updated::This pull request changes code but no page in apps/docs/src/content. Update the docs in this pull request, or add the no-docs label and say why in the description." printf '%s\n' "$code" + exit 1 fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd618f3f..1b6fb7eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ By taking part you agree to the [Code of Conduct](./CODE_OF_CONDUCT.md). Report - **A new tab, RPC function or agent tool:** follow the steps in [Development setup](./apps/docs/src/content/contributing/development.md). Describe what an agent tool returns and when it is empty, and add tests. - **UI changes:** follow the [UI guidelines](docs/contributing/ui-guidelines.md). Use the theme variables, the SCSS mixins and the shared dropdown. - **Docs changes:** follow the [writing guide](./apps/docs/src/content/contributing/writing-docs.md). Run the docs site with `pnpm docs:dev`. -- **Keep the docs in step with the code:** when a pull request changes behaviour, an option, a UI label or an agent tool, update the matching page in `apps/docs` in the same pull request. If no docs change is needed, add the `no-docs` label and say why in the description. The Docs check workflow warns when code changes without docs. +- **Keep the docs in step with the code:** when a pull request changes behaviour, an option, a UI label or an agent tool, update the matching page in `apps/docs` in the same pull request. If no docs change is needed, add the `no-docs` label and say why in the description. The Docs check workflow fails when code changes without docs. ## Open an issue diff --git a/app/src/__tests__/analog-views.test.ts b/app/src/__tests__/analog-views.test.ts index 9b8f2373..7e06ba75 100644 --- a/app/src/__tests__/analog-views.test.ts +++ b/app/src/__tests__/analog-views.test.ts @@ -317,6 +317,38 @@ describe('Analog Nitro tasks', () => { expect((fixture.nativeElement as HTMLElement).textContent).toContain('[redacted]'); }); + it('keeps the confirm panel and payload open when a run fails, and closes it after a run that works', async () => { + let ok = false; + const { rpcClient } = client({ + 'analog-runtime-config': () => ({ env: [], runtimeConfig: [], options: [] }), + 'analog-nitro': () => ({ + plugins: [], + tasks: [{ name: 'db:migrate', file: '/src/server/tasks/db/migrate.ts' }], + storage: [], + cachedRules: [], + cacheDirs: [], + entries: [], + notes: [], + }), + 'analog-run-nitro-task': () => + ok + ? { ok: true, name: 'db:migrate', status: 200, ms: 1, output: '' } + : { ok: false, name: 'db:migrate', error: 'Task failed.' }, + }); + const fixture = TestBed.createComponent(AnalogRuntime); + fixture.componentRef.setInput('rpc', rpcClient); + await settle(fixture); + const runtime = fixture.componentInstance; + runtime.askTask('db:migrate'); + runtime.taskPayload.set('{"dry":true}'); + await runtime.runTask('db:migrate'); + expect(runtime.taskConfirm()).toBe('db:migrate'); + expect(runtime.taskPayload()).toBe('{"dry":true}'); + ok = true; + await runtime.runTask('db:migrate'); + expect(runtime.taskConfirm()).toBeNull(); + }); + it('starts a task once, even when Run task is used again while it runs', async () => { let answer!: (value: unknown) => void; const { rpcClient, calls } = client({ diff --git a/app/src/__tests__/app-shell.test.ts b/app/src/__tests__/app-shell.test.ts index 5d6592ab..cf30f631 100644 --- a/app/src/__tests__/app-shell.test.ts +++ b/app/src/__tests__/app-shell.test.ts @@ -404,6 +404,22 @@ describe('App scroll memory while content loads', () => { }); }); +describe('App connection error', () => { + it('links to the troubleshooting docs when it cannot reach the server', async () => { + const fixture = TestBed.createComponent(App); + document.body.appendChild(host(fixture)); + await settle(fixture); + fixture.componentInstance.connectionFailed.set(true); + await settle(fixture); + const link = host(fixture).querySelector('.connection-error a')!; + expect(link.getAttribute('href')).toBe( + 'https://pangular-inspector.dev/getting-started/troubleshooting/#the-panel-says-disconnected', + ); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + fixture.destroy(); + }); +}); + describe('App counts launcher setting', () => { it('sends the stored counts launcher to the popup once the panel connects', async () => { localStorage.setItem('pangular-pref:pill', 'true'); diff --git a/app/src/__tests__/component-pick-keys.test.ts b/app/src/__tests__/component-pick-keys.test.ts index 816f97e1..d393e77c 100644 --- a/app/src/__tests__/component-pick-keys.test.ts +++ b/app/src/__tests__/component-pick-keys.test.ts @@ -65,6 +65,13 @@ describe('keyboard pick', () => { expect(fixture.componentInstance.announcement()).toBe( 'App . Press Enter to pick it.', ); + // The same component again changes the live text, so a screen reader repeats it. + press('ArrowDown'); + await settle(fixture); + expect(fixture.componentInstance.announcement()).toBe( + 'App . Press Enter to pick it.\u00a0', + ); + expect(fixture.componentInstance.pickMessage()).toBe('App . Press Enter to pick it.'); expect(press('Enter').defaultPrevented).toBe(true); expect(calls.at(-1)).toEqual({ name: 'request-component-pick-move', diff --git a/app/src/__tests__/page-conventions.test.ts b/app/src/__tests__/page-conventions.test.ts index f7376534..e9f56b1f 100644 --- a/app/src/__tests__/page-conventions.test.ts +++ b/app/src/__tests__/page-conventions.test.ts @@ -110,3 +110,31 @@ describe('pages use the shared state component', () => { for (const { file, tag } of states) expect(tag, file).toMatch(/\(clear\)=/); }); }); + +describe('pages with a keyboard list use the shared roving list', () => { + it('has no hand-written arrow key list handler left in a page', async () => { + // Trees, charts and the graph move in two dimensions and keep their own keys. + const handWritten = [...(await pageSources())] + .filter(([, source]) => /\['ArrowDown',\s*'ArrowUp',\s*'Home',\s*'End'\]/.test(source)) + .map(([file]) => file); + expect(handWritten).toEqual([]); + }); + + it('puts appRovingList on the lists that moved to it', async () => { + const sources = await pageSources(); + const lists = { + 'capacitor-inspector.ts': '[data-cap-call]', + 'pipes-inspector.ts': '[data-pipe-row]', + 'network-inspector.ts': '', + 'query-inspector.ts': '', + 'signal-inspector.ts': '', + 'store-inspector.ts': '', + 'timeline.ts': '', + }; + for (const [file, selector] of Object.entries(lists)) { + const source = sources.get(file) ?? ''; + expect(source, file).toContain('appRovingList="'); + if (selector) expect(source, file).toContain(`appRovingList="${selector}"`); + } + }); +}); diff --git a/app/src/__tests__/pipes-record-page.test.ts b/app/src/__tests__/pipes-record-page.test.ts index ad69a988..daf7a1ad 100644 --- a/app/src/__tests__/pipes-record-page.test.ts +++ b/app/src/__tests__/pipes-record-page.test.ts @@ -166,6 +166,32 @@ describe('pipes live data per page', () => { expect(fixture.componentInstance.liveFor('price')?.instanceCount).toBe(2); }); + it('shows the page another tab asked for over the page the panel belongs to', async () => { + scopeToPage('A'); + const fixture = TestBed.createComponent(PipesInspector); + fixture.componentRef.setInput('pageId', 'B'); + fixture.componentRef.setInput( + 'rpc', + withPages({ + pipes: [live(5)], + async: [], + reportedAt: 0, + instrumented: [], + pages: [ + { pageId: 'A', pipes: [live(3)], async: [] }, + { pageId: 'B', pipes: [live(2)], async: [] }, + ], + }), + ); + for (let i = 0; i < 4; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } + expect(fixture.componentInstance.liveFor('price')?.instanceCount).toBe(2); + fixture.componentRef.setInput('pageId', null); + expect(fixture.componentInstance.liveFor('price')?.instanceCount).toBe(3); + }); + it('lists the template usages of a custom pipe', async () => { const fixture = await render( withPages({ pipes: [], async: [], reportedAt: 0, instrumented: [] }), diff --git a/app/src/__tests__/popout-window.test.ts b/app/src/__tests__/popout-window.test.ts index 51ad4f65..4f3a5ba9 100644 --- a/app/src/__tests__/popout-window.test.ts +++ b/app/src/__tests__/popout-window.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { popoutOpener, returnFromPopout } from '../shell-prefs'; +import { popoutOpener, postToOpener, returnFromPopout } from '../shell-prefs'; /** A stand-in for a window, with the fields the helpers read. */ function fakeWindow(name: string, opener: unknown, referrer = '') { @@ -42,4 +42,13 @@ describe('the pop-out window', () => { ); expect(returnFromPopout(fakeWindow('pangular-inspector-k3x9', opener))).toBe(false); }); + + it('sends a setting to the app page only from the window the popup opened', () => { + const opener = { postMessage: vi.fn(), location: { origin: 'http://localhost:4200' } }; + const message = { type: 'pangular:popup-pill', pill: true }; + expect(postToOpener(message, fakeWindow('pangular-inspector-k3x9', opener))).toBe(true); + expect(opener.postMessage).toHaveBeenCalledWith(message, 'http://localhost:4200'); + expect(postToOpener(message, fakeWindow('other', opener))).toBe(false); + expect(opener.postMessage).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/src/__tests__/roving-list.test.ts b/app/src/__tests__/roving-list.test.ts index ced8127a..a9aea5fb 100644 --- a/app/src/__tests__/roving-list.test.ts +++ b/app/src/__tests__/roving-list.test.ts @@ -59,6 +59,31 @@ describe('RovingList', () => { expect(tabStops(fixture)).toEqual(['c']); }); + it('moves the Tab stop to a row that becomes selected without focus', async () => { + const fixture = mount(); + await settle(fixture); + expect(tabStops(fixture)).toEqual(['a']); + // A click in Safari selects a row without focusing it. + fixture.componentInstance.selected.set('c'); + await settle(fixture); + expect(tabStops(fixture)).toEqual(['c']); + }); + + it('keeps the Tab stop when a row that stays selected changes another class', async () => { + const fixture = mount(); + const outside = document.createElement('button'); + document.body.appendChild(outside); + fixture.componentInstance.selected.set('c'); + await settle(fixture); + rows(fixture)[0].focus(); + outside.focus(); + await settle(fixture); + expect(tabStops(fixture)).toEqual(['a']); + rows(fixture)[2].classList.add('hovered'); + await settle(fixture); + expect(tabStops(fixture)).toEqual(['a']); + }); + it('moves focus with the arrow keys, Home and End, and stops at the ends', async () => { const fixture = mount(); await settle(fixture); diff --git a/app/src/__tests__/signal-focus.test.ts b/app/src/__tests__/signal-focus.test.ts index 5c800e38..f0f000aa 100644 --- a/app/src/__tests__/signal-focus.test.ts +++ b/app/src/__tests__/signal-focus.test.ts @@ -3,8 +3,8 @@ import type { DevframeRpcClient } from 'devframe/client'; import { afterEach, describe, expect, it } from 'vitest'; import { SignalInspector } from '../pages/signal-inspector'; -const graphOf = (componentId: string) => ({ - pageId: 'p1', +const graphOf = (componentId: string, pageId = 'p1') => ({ + pageId, component: { id: componentId, name: 'Cart', tag: 'app-cart', path: 'app-cart' }, nodes: [ { @@ -78,4 +78,36 @@ describe('SignalInspector focus', () => { await settle(fixture); expect(fixture.componentInstance.selectedId()).toBe('1'); }); + + it('forgets the focused signal when another component is picked', async () => { + const fixture = TestBed.createComponent(SignalInspector); + const { client, push } = fakeClient(); + fixture.componentRef.setInput('rpc', client); + document.body.append(fixture.nativeElement); + await settle(fixture); + + fixture.componentRef.setInput('focus', { componentId: 'c1', pageId: 'p1', key: 'count' }); + await settle(fixture); + fixture.componentInstance.pickComponent('c2'); + push({ graph: graphOf('c1'), pages: { p1: graphOf('c1') } }); + await settle(fixture); + expect(fixture.componentInstance.selectedId()).toBeNull(); + }); + + it('shows the page a focus names once that page reports its graph', async () => { + const fixture = TestBed.createComponent(SignalInspector); + const { client, push } = fakeClient(); + fixture.componentRef.setInput('rpc', client); + document.body.append(fixture.nativeElement); + await settle(fixture); + + fixture.componentRef.setInput('focus', { componentId: 'c5', pageId: 'p2' }); + await settle(fixture); + expect(fixture.componentInstance.shownPageId()).toBe('p1'); + + // p1 reports last, but the focus asked for p2. + push({ graph: graphOf('c0'), pages: { p1: graphOf('c0'), p2: graphOf('c5', 'p2') } }); + await settle(fixture); + expect(fixture.componentInstance.shownPageId()).toBe('p2'); + }); }); diff --git a/app/src/__tests__/store-state-tree.test.ts b/app/src/__tests__/store-state-tree.test.ts index 2ba6d814..27ae9a20 100644 --- a/app/src/__tests__/store-state-tree.test.ts +++ b/app/src/__tests__/store-state-tree.test.ts @@ -18,6 +18,14 @@ describe('stateRows', () => { expect(childPath('cart', 'items')).toBe('cart.items'); expect(childPath('cart.items', 0)).toBe('cart.items[0]'); expect(childPath('user', 'first name')).toBe('user["first name"]'); + // The collector writes a top-level key that needs quotes the same way. + expect(childPath('', 'display name')).toBe('"display name"'); + }); + + it('marks a changed key that needs quotes', () => { + const rows = stateRows(state, { changed: ['user["first name"]'] }).rows; + expect(rows.find((r) => r.path === 'user["first name"]')!.changed).toBe(true); + expect(rows.find((r) => r.path === 'user')!.holdsChange).toBe(true); }); it('opens the first level, nodes holding a change, and what the user opened', () => { diff --git a/app/src/app.ts b/app/src/app.ts index 123934dd..7de874a8 100644 --- a/app/src/app.ts +++ b/app/src/app.ts @@ -27,6 +27,7 @@ import { PipesInspector } from './pages/pipes-inspector'; import type { Tab, Tabs } from './types/tab.types'; import type { ComponentFocus } from './types/component-focus.types'; import type { InjectorFocus } from './types/injector-focus.types'; +import type { PipeFocus } from './types/pipe-focus.types'; import type { SignalFocus } from './types/signal-focus.types'; import { AnalogInspector } from './pages/analog-inspector'; import { NetworkInspector } from './pages/network-inspector'; @@ -51,6 +52,7 @@ import { initialTab, storeTab, storedTab } from './tab-memory'; import { detectBaseURL } from './base-url'; import { panelAuth } from './auth-token'; import { AuthCode } from './ui/auth-code'; +import { ConnectionError } from './ui/connection-error'; import { clearHighlightsOnHide, forgetDevframeConnection, panelCacheOptions, rpcTry } from './rpc'; import { EditorOpener } from './ui/source-link'; import { ViewMemory, restoreScroll } from './view-memory'; @@ -78,6 +80,7 @@ import { inPopup, isMacPlatform, popoutOpener, + postToOpener, returnFromPopout, storePassive, storePill, @@ -234,6 +237,7 @@ function readView(): View | null { Settings, CommandPalette, AuthCode, + ConnectionError, ], template: `
@@ -509,13 +513,7 @@ function readView(): View | null { } @else if (pairing(); as client) { } @else if (connectionFailed()) { - + } @else if (!rpc()) {

Connecting to the devtools server…

} @else if (comingSoon(); as info) { @@ -599,6 +597,7 @@ function readView(): View | null { } @@ -1023,38 +1022,6 @@ function readView(): View | null { .turned-off code { font-family: var(--font-mono); } - .connection-error { - max-width: 60ch; - margin: 0; - padding: 12px 14px; - border: 1px solid color-mix(in srgb, var(--danger) 36%, transparent); - border-radius: var(--radius); - background: var(--surface); - color: var(--text); - p { - margin: 0 0 10px; - } - button { - display: inline-flex; - align-items: center; - height: var(--control-h); - padding: 0 14px; - border: 1px solid var(--control-border); - border-radius: var(--radius-sm); - background: var(--surface-2); - color: var(--text); - font: inherit; - font-weight: 500; - cursor: pointer; - &:hover { - background: var(--surface-3); - border-color: var(--accent-line); - } - &:focus-visible { - @include m.focus-ring; - } - } - } @media (max-width: 720px) { header { flex-wrap: wrap; @@ -1274,7 +1241,7 @@ export class App implements OnInit, OnDestroy { // The popup only hears the pill choice from the panel, so the restored one goes out on connect. effect(() => { if (!this.connected()) return; - untracked(() => this.postToPopup({ type: 'pangular:popup-pill', pill: this.pill() })); + untracked(() => this.sendPill(this.pill())); }); // Opening a dock clears the count of unseen messages on it. effect(() => { @@ -1665,7 +1632,14 @@ export class App implements OnInit, OnDestroy { setPill(pill: boolean) { this.pill.set(pill); storePill(pill); - this.postToPopup({ type: 'pangular:popup-pill', pill }); + this.sendPill(pill); + } + + /** Sends the counts launcher setting to the popup, in the page around the panel or, from a popped-out panel, in the page that opened it. */ + private sendPill(pill: boolean) { + const message = { type: 'pangular:popup-pill', pill }; + this.postToPopup(message); + postToOpener(message); } /** Sends a message to the popup on the app's page, which checks it came from its panel. */ @@ -1779,11 +1753,18 @@ export class App implements OnInit, OnDestroy { computation: () => null, }); - showPipe(name: string) { + // The page a link into the Pipes tab asked for; kept after the focus is handled. + readonly pipePage = linkedSignal({ + source: this.tab, + computation: () => null, + }); + + showPipe(focus: PipeFocus) { if (!this.config().inspectors.pipes) return; // Switch first: the focus belongs to the tab, so the switch would clear it. this.switchTab('pipes'); - this.pipeFocus.set(name); + this.pipeFocus.set(focus.name); + this.pipePage.set(focus.pageId ?? null); } showForm(formId: string) { diff --git a/app/src/pages/analog-runtime.ts b/app/src/pages/analog-runtime.ts index 7abed2ed..5b9251b4 100644 --- a/app/src/pages/analog-runtime.ts +++ b/app/src/pages/analog-runtime.ts @@ -448,7 +448,6 @@ export class AnalogRuntime { return; } } - this.taskConfirm.set(null); this.runningTask.set(name); try { const result = await call(this.rpc(), 'analog-run-nitro-task', { @@ -460,6 +459,8 @@ export class AnalogRuntime { ...all, [name]: result ?? { ok: false, name, error: 'No answer from the devtools server.' }, })); + // A failed run keeps the panel and its payload, so it can be fixed and run again. + if (result?.ok && this.taskConfirm() === name) this.taskConfirm.set(null); } finally { this.runningTask.set(null); } diff --git a/app/src/pages/capacitor-inspector.ts b/app/src/pages/capacitor-inspector.ts index 1ab973e6..0c6d71c3 100644 --- a/app/src/pages/capacitor-inspector.ts +++ b/app/src/pages/capacitor-inspector.ts @@ -40,6 +40,7 @@ import { type CapacitorPage, type CapacitorSummary, } from './capacitor-types'; +import { RovingList } from '../ui/roving-list'; import { SearchField } from '../ui/search-field'; import { rememberSelection } from '../view-memory'; import { AppState } from '../ui/app-state'; @@ -65,6 +66,7 @@ const FILTER_LABELS: Record = { CapacitorCallDetail, CapacitorIonic, CapacitorState, + RovingList, ], template: ` @if (!loaded()) { @@ -239,7 +241,7 @@ const FILTER_LABELS: Record = {
    @for (call of shownCalls(); track call.id) {
  1. @@ -249,8 +251,6 @@ const FILTER_LABELS: Record = { data-cap-call [class.selected]="selected()?.id === call.id" [attr.aria-pressed]="selected()?.id === call.id" - [attr.tabindex]="tabStop() === call.id ? 0 : -1" - (focus)="focusedCallId.set(call.id)" (click)="select(call.id)" > {{ call.plugin }}.{{ call.method }}() @@ -831,8 +831,6 @@ export class CapacitorInspector { : defaultCapacitorPage(pages, this.hostPageId), }); readonly selectedCallId = signal(null); - /** The row that last had focus, so the list keeps one tab stop on it. */ - readonly focusedCallId = signal(null); readonly filter = signal(''); readonly callFilter = signal('all'); /** Ticks every second, for pending ages and "3 s ago". */ @@ -875,14 +873,6 @@ export class CapacitorInspector { const calls = this.shownCalls(); return calls.find((call) => call.id === this.selectedCallId()) ?? null; }); - /** The one row Tab reaches: the focused one, else the selected one, else the newest. */ - readonly tabStop = computed(() => { - const calls = this.shownCalls(); - for (const id of [this.focusedCallId(), this.selectedCallId()]) { - if (id !== null && calls.some((call) => call.id === id)) return id; - } - return calls[0]?.id ?? null; - }); /** The slowest visible call, which the duration bars are scaled to. */ readonly slowest = computed(() => { const now = this.now(); @@ -959,24 +949,6 @@ export class CapacitorInspector { ); } - /** Arrow keys, Home and End move between calls. */ - onListKey(event: KeyboardEvent) { - const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End']; - if (!keys.includes(event.key)) return; - const rows = Array.from( - this.host.nativeElement.querySelectorAll('[data-cap-call]'), - ); - if (!rows.length) return; - const at = rows.indexOf(document.activeElement as HTMLButtonElement); - let next = at; - if (event.key === 'ArrowDown') next = Math.min(at + 1, rows.length - 1); - else if (event.key === 'ArrowUp') next = Math.max(at - 1, 0); - else if (event.key === 'Home') next = 0; - else next = rows.length - 1; - event.preventDefault(); - rows[next]?.focus(); - } - setSearch(text: string) { if (text === this.filter()) return; this.filter.set(text); diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index 7c91ef80..94826039 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -833,7 +833,7 @@ function storedDirectiveHosts(): boolean { [pageId]="page()?.pageId" [component]="bareName(sel.name)" [canShowPipes]="!staticReport()" - (showPipe)="showPipe.emit($event)" + (showPipe)="showPipe.emit({ name: $event, pageId: page()?.pageId })" /> } } @else { @@ -1884,8 +1884,8 @@ export class ComponentTree { readonly canShowInjector = input(false); /** Whether the Pipes inspector is on, so the detail lists the template's pipes. */ readonly canShowPipes = input(false); - /** Opens the Pipes tab on the pipe with this name. */ - readonly showPipe = output(); + /** Opens the Pipes tab on the pipe with this name, for the page the tree shows. */ + readonly showPipe = output<{ name: string; pageId?: string }>(); readonly showInjector = output(); /** Whether the Signals tab is on, so "Show in Signals" can open it. */ readonly canShowSignals = input(false); @@ -2687,7 +2687,7 @@ export class ComponentTree { private say(message: string) { this.pickMessage.set(message); - this.announcement.set(message); + this.announce(message); } private sendSelection(id: string | null) { diff --git a/app/src/pages/pipes-inspector.ts b/app/src/pages/pipes-inspector.ts index 022de698..4168b42d 100644 --- a/app/src/pages/pipes-inspector.ts +++ b/app/src/pages/pipes-inspector.ts @@ -18,6 +18,7 @@ import { hostPageId } from '../page-id'; import { Select, type SelectOption } from '../ui/select'; import { SourceLink } from '../ui/source-link'; import { plural } from '../format'; +import { RovingList } from '../ui/roving-list'; import { SearchField } from '../ui/search-field'; interface UsageSite { @@ -156,7 +157,7 @@ const SORT_OPTIONS: readonly SelectOption[] = [ @Component({ selector: 'app-pipes-inspector', - imports: [SearchField, Select, SourceLink], + imports: [RovingList, SearchField, Select, SourceLink], template: `

    Pipes declared in your source, the built-in pipes your templates use and library pipes seen on @@ -256,7 +257,7 @@ const SORT_OPTIONS: readonly SelectOption[] = [

    @if (filtered().length) { -
      +
        @for (p of filtered(); track p.file + p.name) { @let live = liveFor(p.name);
      • @@ -1289,6 +1290,9 @@ export class PipesInspector { /** A pipe name to select, from another tab such as Components. */ readonly focus = input(null); readonly focusHandled = output(); + /** The page to show live data for, such as the page picked in Components. + * Without one, the tab shows the page the panel belongs to. */ + readonly pageId = input(null); protected readonly kindOptions = KIND_OPTIONS; protected readonly sortOptions = SORT_OPTIONS; @@ -1301,10 +1305,19 @@ export class PipesInspector { loadFailed = signal(false); selected = signal(null); - live = signal([]); - async = signal([]); + private readonly snapshot = signal(undefined); + /** The live data of the page this tab shows, or every page merged. */ + private readonly shown = computed(() => { + const snapshot = this.snapshot(); + // With several tabs reporting, show the one asked for or the one this panel belongs to. + const pageId = this.livePageId(); + const own = pageId ? snapshot?.pages?.find((page) => page.pageId === pageId) : undefined; + return own ?? snapshot; + }); + readonly live = computed(() => this.shown()?.pipes ?? []); + readonly async = computed(() => this.shown()?.async ?? []); /** Every `| async` usage, set only when the list was cut short. */ - asyncTotal = signal(undefined); + readonly asyncTotal = computed(() => this.shown()?.asyncTotal); lint = signal(null); lintFailed = signal(false); instrumentedPages = signal([]); @@ -1430,16 +1443,10 @@ export class PipesInspector { if (this.destroyRef.destroyed) return; const apply = (value: unknown) => { const snapshot = value as PipesSnapshot | undefined; - // With several tabs reporting, show the one this panel belongs to. - const pageId = hostPageId(); - const own = pageId ? snapshot?.pages?.find((page) => page.pageId === pageId) : undefined; - const source = own ?? snapshot; - this.live.set(source?.pipes ?? []); + this.snapshot.set(snapshot); // A runtime-only pipe has nothing left to show once the page stops using it. const sel = this.selected(); if (sel?.runtime && !this.liveByName().has(sel.name)) this.selected.set(null); - this.async.set(source?.async ?? []); - this.asyncTotal.set(source?.asyncTotal); const pages = snapshot?.instrumented ?? []; this.instrumentedPages.set(pages); if (this.pendingRecord() === pages.length > 0) this.settleRecord(null); @@ -1452,6 +1459,10 @@ export class PipesInspector { } } + private livePageId(): string | null { + return this.pageId() ?? hostPageId(); + } + async toggleInstrument() { const client = this.rpc(); if (!client || this.pendingRecord() !== null) return; @@ -1461,7 +1472,7 @@ export class PipesInspector { let result: { pages?: number } | undefined; try { // Start on this panel's page (or the latest page); stop every page that records. - const request = on ? { pageId: hostPageId() ?? undefined, on } : { on }; + const request = on ? { pageId: this.livePageId() ?? undefined, on } : { on }; result = (await client.scope('pangular').rpc.call('request-instrument-pipes', request)) as { pages?: number } | undefined; } catch { @@ -1591,21 +1602,4 @@ export class PipesInspector { ) .catch(() => {}); } - - onListKey(event: KeyboardEvent) { - const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End']; - if (!keys.includes(event.key)) return; - const rows = Array.from( - this.host.nativeElement.querySelectorAll('[data-pipe-row]'), - ); - if (!rows.length) return; - const at = rows.indexOf(document.activeElement as HTMLButtonElement); - let next = at; - if (event.key === 'ArrowDown') next = Math.min(at + 1, rows.length - 1); - else if (event.key === 'ArrowUp') next = Math.max(at - 1, 0); - else if (event.key === 'Home') next = 0; - else next = rows.length - 1; - event.preventDefault(); - rows[next]?.focus(); - } } diff --git a/app/src/pages/signal-inspector.ts b/app/src/pages/signal-inspector.ts index c516dba8..926ae0c8 100644 --- a/app/src/pages/signal-inspector.ts +++ b/app/src/pages/signal-inspector.ts @@ -30,11 +30,8 @@ import type { SignalFocus } from '../types/signal-focus.types'; import { SignalGraphView } from './signal-graph-view'; import { SearchField } from '../ui/search-field'; import { jsonDiff, type JsonDiffEntry } from './json-diff'; -import type { - SignalChange, - SignalGraphElement, - SignalGraphNode, -} from '@pangular-inspector/devtools/internal/types'; +import type { GraphInjector, SignalGraph, SignalResource } from '../types/signal-graph.types'; +import type { SignalChange, SignalGraphNode } from '@pangular-inspector/devtools/internal/types'; type SignalNode = SignalGraphNode; @@ -44,49 +41,6 @@ interface RestoreResult { error?: string; } -interface SignalEdge { - consumer: number; - producer: number; -} - -interface SignalResource { - id: string; - name: string; - named: boolean; - status?: string; - isLoading?: boolean; - params?: unknown; - value?: unknown; - error?: unknown; - statusCode?: number; - epoch: number; - changes?: number; - nodeIds: string[]; -} - -interface GraphInjector { - id: string; - name: string; -} - -interface SignalGraph { - nodes: SignalNode[]; - edges: SignalEdge[]; - componentSelector?: string; - component?: { id: string; name: string; tag: string; path: string }; - injector?: GraphInjector; - element?: SignalGraphElement; - environments?: GraphInjector[]; - resources?: SignalResource[]; - nodeCount?: number; - unsupported?: boolean; - writeHook?: false; - source?: 'selected' | 'routed' | 'root'; - pageId?: string; - history?: Record; - watch?: { ids: string[]; stacks: boolean }; -} - interface LiveNode { id: string; name: string; @@ -1815,6 +1769,8 @@ export class SignalInspector { readonly focus = input(null); /** The signal to select once the focused component's graph arrives. */ private readonly focusedSignal = signal<{ componentId: string; key: string } | null>(null); + /** The page a focus asked for before it reported a graph; chosen once it does. */ + private pendingPageId: string | null = null; private readonly destroyRef = inject(DestroyRef); private readonly host = inject>(ElementRef); @@ -2282,6 +2238,11 @@ export class SignalInspector { const state = { graph: next?.graph ?? null, pages }; this.notePageGone(graphPagesOf(state)); this.graphState.set(state); + const pending = this.pendingPageId; + if (pending && this.graphPages()[pending]) { + this.pendingPageId = null; + this.chosenPageId.set(pending); + } // A state the client hasn't loaded from the server yet says nothing. if (value !== undefined) this.graphKnown.set(true); }; @@ -2325,6 +2286,7 @@ export class SignalInspector { selectPage(pageId: string | null) { if (!pageId || pageId === this.shownPageId()) return; + this.pendingPageId = null; this.chosenPageId.set(pageId); const label = this.pageOptions().find((o) => o.value === pageId)?.label ?? pageId; this.announcement.set(`Showing the signal graph of ${label}.`); @@ -2332,8 +2294,11 @@ export class SignalInspector { /** Shows the graph of the component a focus names, on its page when it names one. */ private showComponent(focus: SignalFocus) { - if (focus.pageId && !this.pageId && this.graphPages()[focus.pageId]) { - this.chosenPageId.set(focus.pageId); + this.pendingPageId = null; + if (focus.pageId && !this.pageId) { + // A page that has not reported its graph yet is chosen when it does. + if (this.graphPages()[focus.pageId]) this.chosenPageId.set(focus.pageId); + else this.pendingPageId = focus.pageId; } this.focusedSignal.set(focus.key ? { componentId: focus.componentId, key: focus.key } : null); this.pickComponent(focus.componentId, this.pageId ?? focus.pageId); @@ -2341,6 +2306,8 @@ export class SignalInspector { pickComponent(value: string | null, forPage?: string) { const picked = value && value !== FOLLOW ? value : null; + // Another component replaces a focus that still waits for its signal. + if (this.focusedSignal()?.componentId !== picked) this.focusedSignal.set(null); const pageId = forPage ?? this.targetPageId() ?? undefined; this.pickedByPage.update((all) => { const next = { ...all }; diff --git a/app/src/pages/store-state-tree.ts b/app/src/pages/store-state-tree.ts index 1e0d59e8..c8dfb060 100644 --- a/app/src/pages/store-state-tree.ts +++ b/app/src/pages/store-state-tree.ts @@ -76,7 +76,8 @@ export function stateRows( const changed = options.changed ?? []; const rows: StateRow[] = []; let matches = 0; - const walk = (val: unknown, path: string, key: string, depth: number): StateRow[] => { + // Pushes the row and its shown children into `rows`; a closed node's children are not walked. + const walk = (val: unknown, path: string, key: string, depth: number): void => { const node = isNode(val); const isChanged = changed.some((c) => c === path || inside(c, path)); const holdsChange = changed.some((c) => inside(path, c)); @@ -85,23 +86,23 @@ export function stateRows( !!query && (key.toLowerCase().includes(query) || (!node && text.toLowerCase().includes(query))); if (own) matches++; - const children: StateRow[] = []; - if (node) - for (const [k, v] of entriesOf(val)) - children.push(...walk(v, childPath(path, k), String(k), depth + 1)); - if (query && !own && !children.length) return []; const open = node ? query ? true : !options.closed?.has(path) && (depth < 1 || holdsChange || !!options.opened?.has(path)) : false; - const row: StateRow = { path, key, depth, node, open, text, changed: isChanged, holdsChange }; - return [row, ...(open ? children : [])]; + const at = rows.length; + rows.push({ path, key, depth, node, open, text, changed: isChanged, holdsChange }); + if (open && node) { + for (const [k, v] of entriesOf(val)) walk(v, childPath(path, k), String(k), depth + 1); + } + // With a query, a row shows only when it or a row inside it matches. + if (query && !own && rows.length === at + 1) rows.length = at; }; if (isNode(value)) { - for (const [k, v] of entriesOf(value)) rows.push(...walk(v, childPath('', k), String(k), 0)); + for (const [k, v] of entriesOf(value)) walk(v, childPath('', k), String(k), 0); } else if (value !== undefined) { - rows.push(...walk(value, '(root)', '(root)', 0)); + walk(value, '(root)', '(root)', 0); } return { rows, matches }; } diff --git a/app/src/shell-prefs.ts b/app/src/shell-prefs.ts index 4fe70d57..5bcb4232 100644 --- a/app/src/shell-prefs.ts +++ b/app/src/shell-prefs.ts @@ -214,6 +214,15 @@ export function popoutOpener(win: Window = window): Window | null { /** Asks the popup on the app's page to take the panel back from its own window. */ export function returnFromPopout(win: Window = window): boolean { + return postToOpener({ type: 'pangular:popup-return' }, win); +} + +/** + * Sends `message` to the popup on the app's page when the panel runs in the + * window the popup popped it out into, on the app page's origin. The popup + * accepts it only from that window. Returns whether it was sent. + */ +export function postToOpener(message: Record, win: Window = window): boolean { const opener = popoutOpener(win); if (!opener) return false; let origin: string | undefined; @@ -228,7 +237,7 @@ export function returnFromPopout(win: Window = window): boolean { } if (!origin || origin === 'null') return false; try { - opener.postMessage({ type: 'pangular:popup-return' }, origin); + opener.postMessage(message, origin); return true; } catch { return false; diff --git a/app/src/types/pipe-focus.types.ts b/app/src/types/pipe-focus.types.ts new file mode 100644 index 00000000..0dbf172c --- /dev/null +++ b/app/src/types/pipe-focus.types.ts @@ -0,0 +1,9 @@ +/** + * A pipe to select in the Pipes tab, by name. `pageId` names the page whose + * live data the tab shows; without it the tab shows the page the panel + * belongs to. + */ +export interface PipeFocus { + name: string; + pageId?: string; +} diff --git a/app/src/types/signal-graph.types.ts b/app/src/types/signal-graph.types.ts new file mode 100644 index 00000000..c8fbb3a4 --- /dev/null +++ b/app/src/types/signal-graph.types.ts @@ -0,0 +1,49 @@ +import type { + SignalChange, + SignalGraphElement, + SignalGraphNode, +} from '@pangular-inspector/devtools/internal/types'; + +export interface SignalEdge { + consumer: number; + producer: number; +} + +export interface SignalResource { + id: string; + name: string; + named: boolean; + status?: string; + isLoading?: boolean; + params?: unknown; + value?: unknown; + error?: unknown; + statusCode?: number; + epoch: number; + changes?: number; + nodeIds: string[]; +} + +export interface GraphInjector { + id: string; + name: string; +} + +/** The signal graph of one page, as the Signals tab reads it from the `signal-graph` state. */ +export interface SignalGraph { + nodes: SignalGraphNode[]; + edges: SignalEdge[]; + componentSelector?: string; + component?: { id: string; name: string; tag: string; path: string }; + injector?: GraphInjector; + element?: SignalGraphElement; + environments?: GraphInjector[]; + resources?: SignalResource[]; + nodeCount?: number; + unsupported?: boolean; + writeHook?: false; + source?: 'selected' | 'routed' | 'root'; + pageId?: string; + history?: Record; + watch?: { ids: string[]; stacks: boolean }; +} diff --git a/app/src/ui/connection-error.ts b/app/src/ui/connection-error.ts new file mode 100644 index 00000000..4ccf247b --- /dev/null +++ b/app/src/ui/connection-error.ts @@ -0,0 +1,76 @@ +import { Component, output } from '@angular/core'; + +/** The docs on a panel that can't reach its server. */ +export const TROUBLESHOOTING_DOCS_URL = + 'https://pangular-inspector.dev/getting-started/troubleshooting/#the-panel-says-disconnected'; + +/** What the panel shows while it can't reach the devtools server. */ +@Component({ + selector: 'app-connection-error', + template: ` + + `, + styles: ` + @use 'mixins' as m; + + .connection-error { + max-width: 60ch; + margin: 0; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--danger) 36%, transparent); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + p { + margin: 0 0 10px; + } + .connection-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + } + a { + color: var(--accent); + &:focus-visible { + @include m.focus-ring; + } + } + button { + display: inline-flex; + align-items: center; + height: var(--control-h); + padding: 0 14px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-weight: 500; + cursor: pointer; + &:hover { + background: var(--surface-3); + border-color: var(--accent-line); + } + &:focus-visible { + @include m.focus-ring; + } + } + } + `, +}) +export class ConnectionError { + readonly retry = output(); + protected readonly docsUrl = TROUBLESHOOTING_DOCS_URL; +} diff --git a/app/src/ui/roving-list.ts b/app/src/ui/roving-list.ts index 357f448e..396ae55c 100644 --- a/app/src/ui/roving-list.ts +++ b/app/src/ui/roving-list.ts @@ -10,8 +10,9 @@ const SELECTED = /** * Makes a long list one Tab stop. Only the current item is in the Tab order, * and ArrowUp, ArrowDown, Home and End move focus between the items that - * `appRovingList` selects. The current item is the one last focused, or else - * the selected one, or else the first. + * `appRovingList` selects. The current item is the one last focused or + * selected, or else the first. A click selects without focusing in some + * browsers (Safari), so an item that becomes selected becomes current too. */ @Directive({ selector: '[appRovingList]', @@ -35,15 +36,36 @@ export class RovingList { private current: HTMLElement | null = null; /** Whether focus is in the list, kept when the focused item is removed rather than left. */ private focusInside = false; + /** The items that were selected when the list last synced, to tell a newly selected one. */ + private readonly wasSelected = new WeakSet(); constructor() { const destroyRef = inject(DestroyRef); afterNextRender(() => { this.sync(); if (typeof MutationObserver === 'undefined') return; - // Rows come and go as data arrives; only added or removed nodes matter. - const observer = new MutationObserver(() => this.sync()); - observer.observe(this.host, { childList: true, subtree: true }); + // Rows come and go as data arrives, and the selection moves between them. + const observer = new MutationObserver((records) => { + for (const record of records) { + const target = record.target as HTMLElement; + // Only a row that becomes selected takes the stop; another class change on it doesn't. + if ( + record.type === 'attributes' && + this.isItem(target) && + target.matches(SELECTED) && + !this.wasSelected.has(target) + ) { + this.current = target; + } + } + this.sync(); + }); + observer.observe(this.host, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['aria-pressed', 'aria-expanded', 'aria-current', 'class'], + }); destroyRef.onDestroy(() => observer.disconnect()); }); } @@ -73,6 +95,8 @@ export class RovingList { this.current = items.find((item) => item.matches(SELECTED)) ?? items[0] ?? null; } for (const item of items) { + if (item.matches(SELECTED)) this.wasSelected.add(item); + else this.wasSelected.delete(item); const tabIndex = item === this.current ? '0' : '-1'; if (item.getAttribute('tabindex') !== tabIndex) item.setAttribute('tabindex', tabIndex); } diff --git a/apps/docs/api-reference.spec.ts b/apps/docs/api-reference.spec.ts index c59d73ae..7fb3dc54 100644 --- a/apps/docs/api-reference.spec.ts +++ b/apps/docs/api-reference.spec.ts @@ -2,6 +2,7 @@ import {readFileSync, readdirSync} from 'node:fs'; import {join, resolve} from 'node:path'; import * as prettier from 'prettier'; import {API_DIR, readApiEntries, renderApiReference} from './api-reference'; +import {readDebugApi} from './debug-api-compat'; const docsRoot = import.meta.dirname; const repoRoot = resolve(docsRoot, '../..'); @@ -27,6 +28,27 @@ describe('API reference', () => { expect(entries.some((entry) => entry.slug.startsWith('internal-'))).toBe(false); }, 60_000); + it('lists every debug API function with the version its @since tag names', () => { + const fns = readDebugApi(repoRoot); + expect(fns.find((fn) => fn.name === 'getComponent')).toMatchObject({ + since: '12.0', + required: true, + }); + expect(fns.find((fn) => fn.name === 'ɵgetSignalGraph')).toMatchObject({ + since: '19.1', + required: false, + }); + expect(fns.find((fn) => fn.name === 'ɵgetTransferState')?.since).toBeUndefined(); + expect(fns.every((fn) => !fn.summary.includes('@since'))).toBe(true); + }, 60_000); + + it('names the package root as an alias of the entry point it loads', () => { + const entries = readApiEntries(repoRoot); + const devframe = entries.find((entry) => entry.slug === 'devframe'); + expect(devframe?.aliases).toEqual(['@pangular-inspector/devtools']); + expect(entries.filter((entry) => entry.aliases?.length)).toHaveLength(1); + }, 60_000); + it('shows inferred return types and symbols re-exported from a dependency', () => { const devframe = readApiEntries(repoRoot).find((entry) => entry.slug === 'devframe'); const create = devframe?.symbols.find((s) => s.name === 'createPangular'); diff --git a/apps/docs/api-reference.ts b/apps/docs/api-reference.ts index d98d1aeb..313a5a9d 100644 --- a/apps/docs/api-reference.ts +++ b/apps/docs/api-reference.ts @@ -1,6 +1,7 @@ import {readFileSync} from 'node:fs'; import {join, posix} from 'node:path'; import {Node, Project, ts, type JSDoc} from 'ts-morph'; +import {DEBUG_API_PAGE, renderDebugApiCompat} from './debug-api-compat.ts'; /** * Builds the API reference pages in `src/content/api/` from the public entry @@ -47,6 +48,8 @@ export interface ApiEntry { slug: string; source: string; symbols: ApiSymbol[]; + /** Other import paths that load the same file, such as the package root. */ + aliases?: string[]; } function kindOf(node: Node): Kind | null { @@ -111,10 +114,13 @@ export function readApiEntries(repoRoot: string): ApiEntry[] { skipAddingFilesFromTsConfig: true, }); const entries: ApiEntry[] = []; + const targetOf = (target: string | {default?: string; import?: string}) => + typeof target === 'string' ? target : (target.import ?? target.default); + const root = pkg.exports['.']; for (const [key, target] of Object.entries(pkg.exports)) { // `./internal/*` entries serve the panel and the extension, not apps. if (SKIPPED_EXPORTS.has(key) || key.startsWith('./internal/')) continue; - const rel = typeof target === 'string' ? target : (target.import ?? target.default); + const rel = targetOf(target); if (!rel?.endsWith('.ts')) continue; const sourceFile = project.addSourceFileAtPath(join(pkgDir, rel)); const symbols: ApiSymbol[] = []; @@ -148,6 +154,7 @@ export function readApiEntries(repoRoot: string): ApiEntry[] { slug, source: posix.join('packages/devtools', rel.replace(/^\.\//, '')), symbols, + ...(root && targetOf(root) === rel ? {aliases: [pkg.name]} : {}), }); } return entries; @@ -185,6 +192,12 @@ export function renderApiPage(entry: ApiEntry, githubUrl: string): string { '', `Import from \`${entry.specifier}\`. This page is generated from [\`${entry.source}\`](${githubUrl}/blob/main/${entry.source}) and the files it re-exports. Run \`pnpm docs:api\` after you change the public API.`, '', + ...(entry.aliases?.length + ? [ + `${entry.aliases.map((alias) => `\`${alias}\``).join(', ')} loads the same file and exports the same symbols.`, + '', + ] + : []), ]; if (!entry.symbols.length) { out.push('This entry point exports no symbols. Importing it runs its side effect.', ''); @@ -217,7 +230,7 @@ function escapeEnv(text: string): string { export function renderApiIndex(entries: ApiEntry[]): string { const rows = entries.map( (entry) => - `| [\`${entry.specifier}\`](./${entry.slug}.md) | ${entry.symbols.length ? entry.symbols.map((s) => `\`${s.name}\``).join(', ') : 'None, it runs on import'} |`, + `| [\`${entry.specifier}\`](./${entry.slug}.md)${entry.aliases?.length ? ` (also ${entry.aliases.map((alias) => `\`${alias}\``).join(', ')})` : ''} | ${entry.symbols.length ? entry.symbols.map((s) => `\`${s.name}\``).join(', ') : 'None, it runs on import'} |`, ); return [ '---', @@ -231,7 +244,7 @@ export function renderApiIndex(entries: ApiEntry[]): string { '', '# API reference', '', - 'Each entry point of `@pangular-inspector/devtools` has a page with every symbol it exports, its declaration and its JSDoc. The pages are generated from the package source, so names and types match the published code. [Installation](../getting-started/installation.md#entry-points) says what each entry point is for, and [Configuration](../getting-started/configuration.md) explains the options.', + 'Each entry point of `@pangular-inspector/devtools` has a page with every symbol it exports, its declaration and its JSDoc. The pages are generated from the package source, so names and types match the published code. [Installation](../getting-started/installation.md#entry-points) says what each entry point is for, and [Configuration](../getting-started/configuration.md) explains the options. [Angular debug API](./angular-debug-api.md) lists the `window.ng` functions the overlay reads and the Angular version each one needs.', '', '| Entry point | Exports |', '| ----------- | ------- |', @@ -245,5 +258,6 @@ export function renderApiReference(repoRoot: string, githubUrl: string): Map([['index.md', renderApiIndex(entries)]]); for (const entry of entries) pages.set(`${entry.slug}.md`, renderApiPage(entry, githubUrl)); + pages.set(DEBUG_API_PAGE, renderDebugApiCompat(repoRoot, githubUrl)); return pages; } diff --git a/apps/docs/debug-api-compat.ts b/apps/docs/debug-api-compat.ts new file mode 100644 index 00000000..bf0cdacf --- /dev/null +++ b/apps/docs/debug-api-compat.ts @@ -0,0 +1,79 @@ +/** + * The Angular debug API page: every `window.ng` function the overlay reads, + * from the `NgDebugApi` interface in `packages/devtools/src/debug-api.ts`, + * with the first Angular version its `@since` tag names. `pnpm docs:api` + * writes it with the API reference pages. + */ +import {join} from 'node:path'; +import {Node, Project} from 'ts-morph'; + +export const DEBUG_API_PAGE = 'angular-debug-api.md'; +const SOURCE = 'packages/devtools/src/debug-api.ts'; + +export interface DebugApiFunction { + name: string; + /** The first Angular version that publishes it, from `@since`. */ + since?: string; + /** Every supported version publishes it, so the overlay needs it. */ + required: boolean; + /** The doc comment without its `@since` tag. */ + summary: string; +} + +/** The functions of `NgDebugApi`, in the order the interface declares them. */ +export function readDebugApi(repoRoot: string): DebugApiFunction[] { + const project = new Project({skipAddingFilesFromTsConfig: true}); + const file = project.addSourceFileAtPath(join(repoRoot, SOURCE)); + const api = file.getInterfaceOrThrow('NgDebugApi'); + return api.getMembers().flatMap((member) => { + if (!Node.isMethodSignature(member) && !Node.isPropertySignature(member)) return []; + // A one-line doc comment keeps `@since` inline, where the parser sees no tag. + const text = member + .getJsDocs() + .map((doc) => doc.getInnerText()) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + const since = text.match(/@since\s+(\d+(?:\.\d+)*)/)?.[1]; + return [ + { + name: member.getName(), + ...(since ? {since} : {}), + required: !member.hasQuestionToken(), + summary: text.replace(/\s*@since\s+\S+/, '').trim(), + }, + ]; + }); +} + +function cell(text: string): string { + return text.replace(/\|/g, '\\|'); +} + +export function renderDebugApiCompat(repoRoot: string, githubUrl: string): string { + const rows = readDebugApi(repoRoot).map( + (fn) => + `| \`${fn.name}\` | ${fn.since ?? 'See note'} | ${fn.required ? 'Yes' : 'No'} | ${cell(fn.summary)} |`, + ); + return [ + '---', + 'title: Angular debug API', + "description: 'The window.ng functions the overlay reads and the first Angular version that publishes each one, generated from the package source.'", + '---', + '', + '', + ' The window.ng functions the overlay reads, and the Angular version each one needs.', + '', + '', + '# Angular debug API', + '', + `The overlay reads live data through the debug API Angular publishes on \`window.ng\` in development builds. This page is generated from \`NgDebugApi\` in [\`${SOURCE}\`](${githubUrl}/blob/main/${SOURCE}). Run \`pnpm docs:api\` after you change it.`, + '', + 'A required function is one every supported Angular version publishes, so the overlay needs it. The overlay checks the others on the page at runtime, and a tab whose feature needs a missing one says which Angular version it needs. **See note** marks a function with no single first version; its description says when the overlay uses it. See [Tested versions](../getting-started/installation.md#tested-versions).', + '', + '| Function | Since | Required | What it does |', + '| -------- | ----- | -------- | ------------ |', + ...rows, + '', + ].join('\n'); +} diff --git a/apps/docs/src/content/agents/recipes.md b/apps/docs/src/content/agents/recipes.md index 1a58c404..8a3e9888 100644 --- a/apps/docs/src/content/agents/recipes.md +++ b/apps/docs/src/content/agents/recipes.md @@ -24,17 +24,17 @@ The checkout form won't submit. Use the Pangular tools to find which fields make invalid and why, fix the cause in the code, then show me the form is valid. ``` -| Step | Tool | -| --------------------------- | ----------------------------------------------------------------------------------- | -| Record the starting point | `inspect-forms` with `form` for its status, `list-form-changes` for a marker | -| Find the invalid fields | `explain-invalid-forms` | -| Look at one field | `explain-field` with `form` and `path` | -| See why submit does nothing | `explain-submit` | -| Reproduce after the fix | Enter the original input again, then `wait-for` with `condition: "form"` and `form` | -| See what changed | `explain-form-changes` with `form` and the marker as `after` | -| Prove the form is valid | `inspect-forms` with `form`, compared with the status recorded first | - -A code change can reload the page and reset the form, so enter the same input as before the fix and wait until the form is settled before you compare. `explain-form-changes` reads the change events recorded since the marker, not a saved copy of the form, so the status from `inspect-forms` is the proof. Check that the answer names the failing validator and its message for each field, not only that the form is invalid. Values that look secret stay redacted. See [Forms](./tools.md#forms). +| Step | Tool | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Record the starting point | `inspect-forms` with `form` for its status, `list-form-changes` for a marker | +| Find the invalid fields | `explain-invalid-forms` | +| Look at one field | `explain-field` with `form` and `path` | +| See why submit does nothing | `explain-submit` | +| Reproduce after the fix | Enter the original input again and leave the field (or submit), then `wait-for` with `condition: "form"` and `form` | +| See what changed | `explain-form-changes` with `form` and the marker as `after` | +| Prove the form is valid | `inspect-forms` with `form`, compared with the status recorded first | + +A code change can reload the page and reset the form, so enter the same input as before the fix and wait until the form is settled before you compare. Leave the field before you wait, since a control with `updateOn: 'blur'` keeps its old value and validity until then. For a control with `updateOn: 'submit'`, submit the form first: leaving the field does not update it. `explain-form-changes` reads the change events recorded since the marker, not a saved copy of the form, so the status from `inspect-forms` is the proof. Check that the answer names the failing validator and its message for each field, not only that the form is invalid. Values that look secret stay redacted. See [Forms](./tools.md#forms). ## Explain a failed navigation @@ -59,13 +59,13 @@ The trip details page throws NG0201 for TripService. Use the Pangular tools to s Angular looked for it and why no injector provided it, then fix the providers. ``` -| Step | Tool | -| ----------------------------- | --------------------------------------------------------------------------------------------- | -| Find the error and its owner | `list-errors`, then `explain-error` with its `id` | -| Walk the lookup path | `explain-injection` with the owner as `injector`, the token and the failing injection's flags | -| Read the providers at a level | `inspect-providers` | +| Step | Tool | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Find the error and its owner | `list-errors`, then `explain-error` with its `id` | +| Walk the lookup path | `explain-injection` with the injector that made the failing request as `injector`, the token and the failing injection's flags | +| Read the providers at a level | `inspect-providers` | -`explain-error` adds the missing provider explainer, and its owner id works as the `injector` of `explain-injection`. Pass the flags the failing injection used, such as `self: true`, `skipSelf`, `host` or `optional`, so the lookup follows the same path. If it says **Component: not found**, for example when a routed component fails before it is in the tree, `explain-injection` has no injector to start from and answers **No injector on page**. Read the stack of the error for the class that injects the token, then call `scan-providers` to see where the source provides the token. A lookup from an injector that is on the page, such as the parent route's component or `Root`, is a separate lookup, not part of the failing one: it can resolve differently, for example when the failing injection used `self`. When an owner is found, check that the answer marks each injector on the path as **provides it**, **not provided**, **hidden**, **skipped**, **not asked** or **unknown**. See [explain-injection](./tools.md#explain-injection). +`explain-error` adds the missing provider explainer. Start the lookup from the injector that made the failing request. The error message and stack name the class that injects the token. If that class is a component or directive, pass its class name, or the owner id from `explain-error` when the owner is that class. If it is a service, its class name is not an injector: find the injector that provides the service, such as `Root`, `Route: admin` or the component that lists it in `providers`, and pass that. Pass the flags the failing injection used, such as `self: true`, `skipSelf`, `host` or `optional`, so the lookup follows the same path. If it says **Component: not found**, no component owns the error, but the requesting injector can still be on the page: a service that fails in `Root` has no owner, and `explain-injection` can still start at `Root`. Only when the requesting injector is missing from the tree, for example when a routed component fails before it is in the tree, does `explain-injection` answer **No injector on page**. Then read the stack of the error for the class that injects the token, then call `scan-providers` to see where the source provides the token. A lookup from an injector that is on the page, such as the parent route's component or `Root`, is a separate lookup, not part of the failing one: it can resolve differently, for example when the failing injection used `self`. When an owner is found, check that the answer marks each injector on the path as **provides it**, **not provided**, **hidden**, **skipped**, **not asked** or **unknown**. See [explain-injection](./tools.md#explain-injection). ## Find wasted change detection diff --git a/apps/docs/src/content/agents/resources.md b/apps/docs/src/content/agents/resources.md index 2e464744..5673a6d2 100644 --- a/apps/docs/src/content/agents/resources.md +++ b/apps/docs/src/content/agents/resources.md @@ -51,10 +51,18 @@ When `component-tree`, `signal-graph`, `ngrx-store` or `pipe-usages` runs past 1 ### component-tree -The component instances of each page, under `pages[pageId].roots`. Each node has an instance id, class name, host tag, the directives on its host, and `attributes`, the selector attribute names of those directives. In a page rendered on the server, each node also has its `hydration` status: `hydrated`, `skipped`, `mismatched` or `client`. An element that hosts directives but no component is a node too, with `kind: "directive"`, all of its directives in `directives`, and the first one as `name`. `platform` is `angular-native` for an Angular Native app, and missing for a browser page. `count` counts the component instances and `directiveHostCount` the directive hosts. `hydration` reports the page's hydration: counts by status, dehydrated `@defer` blocks, mismatches between what Angular expected and found, and whether the hydration overlay is on. It is missing when the page was not rendered on the server. `detail` holds the live inputs, outputs, other properties, listeners, change detection, encapsulation and injected dependencies of the selected instance, and `source`, the project-relative `file` and 1-based `line` of its class when a dev build has the debug info of Angular. `detail.properties` lists the other own fields, with signals and resources unwrapped. Each value shows its first level, with nested objects, arrays, `Map`s and `Set`s as `{"$collapsed": type, "size": n}`; read them with `inspect-component` and `path`. `detail.expanded` holds the nested levels opened in the panel. The selection is the one made in the panel, by clicking in the page with **Pick component on page**, or with the `highlight` or `inspect-component` tool. `truncated` is `true` when the page has more instances than it lists, and `truncatedBy` names the limit it hit: `components` (2000 instances), `depth` (256 levels of DOM nesting) or `directiveHosts` (1000 elements that host directives but no component). `detail.selector` is a CSS selector that finds the host with `document.querySelector`, on a browser page. It is missing for a host inside a shadow root, which `document.querySelector` cannot reach. `deferBlocks` lists the page's `@defer` blocks, at most 500, and is missing when the page has no util to read them. `deferTruncated` is `true` when the page had more. A block's `transitions` lists at most the last 6 states the page saw, oldest first, with `state`, `hydration` and `at`, the time the page collected it on its own clock. The first entry is the state the page found when it started watching, and the list is set only once the block changed. `nodes` repeats the roots of the most recent page. +The component instances of each page, under `pages[pageId].roots`. Each node has an instance id, class name, host tag, the directives on its host, and `attributes`, the selector attribute names of those directives. In a page rendered on the server, each node also has its `hydration` status: `hydrated`, `skipped`, `mismatched` or `client`. An element that hosts directives but no component is a node too, with `kind: "directive"`, all of its directives in `directives`, and the first one as `name`. `platform` is `angular-native` for an Angular Native app, and missing for a browser page. `count` counts the component instances and `directiveHostCount` the directive hosts. `hydration` reports the page's hydration: counts by status, dehydrated `@defer` blocks, mismatches between what Angular expected and found, and whether the hydration overlay is on. It is missing when the page was not rendered on the server. `nodes` repeats the roots of the most recent page. + +`detail` holds the live inputs, outputs, other properties, listeners, change detection, encapsulation and injected dependencies of the selected instance, and `source`, the project-relative `file` and 1-based `line` of its class when a dev build has the debug info of Angular. `detail.properties` lists the other own fields, with signals and resources unwrapped. Each value shows its first level, with nested objects, arrays, `Map`s and `Set`s as `{"$collapsed": type, "size": n}`; read them with `inspect-component` and `path`. `detail.expanded` holds the nested levels opened in the panel. The selection is the one made in the panel, by clicking in the page with **Pick component on page**, or with the `highlight` or `inspect-component` tool. `detail.selector` is a CSS selector that finds the host with `document.querySelector`, on a browser page. It is missing for a host inside a shadow root, which `document.querySelector` cannot reach, and in an Angular Native or NativeScript app, which has no DOM. + +`truncated` is `true` when the page has more instances than it lists, and `truncatedBy` names the limit it hit: `components` (2000 instances), `depth` (256 levels of DOM nesting) or `directiveHosts` (1000 elements that host directives but no component). The instance ids here are what `highlight` and `inspect-component` accept. +#### Defer blocks + +`deferBlocks` lists the page's `@defer` blocks, at most 500, and is missing when the page has no util to read them. `deferTruncated` is `true` when the page had more. A block's `transitions` lists at most the last 6 states the page saw, oldest first, with `state`, `hydration` and `at`, the time the page collected it on its own clock. The first entry is the state the page found when it started watching, and the list is set only once the block changed. + ### signal-graph The signal graph of each page, under `pages[pageId]`. `graph` is the latest one. It holds the nodes (`signal`, `computed`, `effect`, `linkedSignal`), edges (`{ consumer, producer }`, where `consumer` reads `producer`, both node ids), the component or injector it belongs to, the resources folded into one entry each, and recent value history per node and status history per resource. Only signals a template or an effect has read appear. diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index efd25c8e..2b2d9448 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -276,7 +276,7 @@ Makes one `@defer` block fetch its dependencies and show its main content now, a | `block` | Yes | A block id from [`defer-blocks`](#defer-blocks), such as `d3`. | | `page` | No | The page id. Required when more than one tab reports a block with that id. | -The page loads the block the way `DeferBlockFixture.render` does in Angular's testing package, then runs change detection with `ng.applyChanges`. When the dependencies fail to load, the block shows its `@error` block and the answer says so. A block without an `@error` block is left on its placeholder. The block's own triggers do not fire, so a load does not prove they work. +The page loads the block the way `DeferBlockFixture.render` does in Angular's testing package, then runs change detection with `ng.applyChanges`. If change detection throws while the block renders, the answer quotes the error. When the dependencies fail to load, the block shows its `@error` block and the answer says so. A block without an `@error` block is left on its placeholder. The block's own triggers do not fire, so a load does not prove they work. It refuses, and loads nothing, when: @@ -468,15 +468,15 @@ An [Angular Native](../getting-started/angular-native.md) or [NativeScript](../g The signal graph the page reported: nodes (`signal`, `computed`, `linkedSignal`, `effect`), dependency edges (`{ consumer, producer }`, where `consumer` reads `producer`, both node ids), the component or injector they belong to, and recent value history per node. Reads: page. -| Argument | Required | Value | -| ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `component` | one of | Host tag, class name or instance id of the component, like `app-root`. Also accepts `root`, a route path like `/admin` or `Route: admin`, an element injector id like `inj-12` from `inspect-providers`, or a directive class name. A host that has directives but no component reads as a directive host. | -| `selector` | one of | A CSS selector for a component host, like `app-card.featured`. The first match is read. At most 500 characters. | -| `node` | no | A node id or label. The answer holds only that node, or every node with that label, with its direct producers and consumers and their resources and history. | -| `history` | no | `false` leaves out the value history. Defaults to `true`. | -| `framework` | no | `false` leaves out the nodes Angular itself created (`framework: true`). Defaults to `true`. | +| Argument | Required | Value | +| ----------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `component` | one of | Host tag, class name or instance id of the component, like `app-root`. Also accepts `root`, a route path like `/admin` or `Route: admin`, an element injector id like `inj-12` from `inspect-providers`, or the instance id of a directive host from `list-components`. | +| `selector` | one of | A CSS selector for a component host, like `app-card.featured`. The first match is read. At most 500 characters. | +| `node` | no | A node id or label. The answer holds only that node, or every node with that label, with its direct producers and consumers and their resources and history. | +| `history` | no | `false` leaves out the value history. Defaults to `true`. | +| `framework` | no | `false` leaves out the nodes Angular itself created (`framework: true`). Defaults to `true`. | -Pass `component` or `selector`, not both. When the graph the page last reported belongs to another component or injector, the tool asks the page for the one you named, once, and the Signals page keeps showing what it showed. A `component` the component tree knows is read by its first instance. A `component` it doesn't know answers with the graph the page reported and says what that graph covers. A `selector` that matches no component host is refused. +Pass `component` or `selector`, not both. A directive class name matches only the directive host the page already reports a graph for. For another directive host, pass its id or its element injector id. A host that has directives but no component reads as a directive host. When the graph the page last reported belongs to another component or injector, the tool asks the page for the one you named, once, and the Signals page keeps showing what it showed. A `component` the component tree knows is read by its first instance. A `component` it doesn't know answers with the graph the page reported and says what that graph covers. A `selector` that matches no component host is refused. The answer also holds: @@ -649,7 +649,7 @@ Why a state value changed. With `path`, the newest entries in the kept log (up t | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `path` | without seq | A state path as the diff writes it, from the state root (`cart.items`, `items[0].done`) or a signal store field (`filter`). **Copy path** in the state tree copies one. | | `seq` | without path | An entry number, to explain that entry instead. | -| `store` | no | Only entries of that store, by id or name. `store` is the `@ngrx/store` Store. | +| `store` | no | Only entries of that store, by id or name. `store` is the `@ngrx/store` Store. With `seq`, an entry of another store is not explained, and the answer names its store. | | `page` | no | The tab to read. Defaults to the newest page that reports NgRx. | ### redux-inspect-stores @@ -877,7 +877,7 @@ When a write goes through a native `'), {}).autocomplete).toBe('address-level2'); }); + it('counts a submit button inside a disabled fieldset as disabled', () => { + const form = input( + '
        ', + ); + expect(submitDom(form)).toMatchObject({ buttons: 1, disabledButtons: 1 }); + }); + + it('reads a control inside a disabled fieldset as disabled', () => { + const fieldset = input('
        '); + const field = fieldset.querySelector('input')!; + expect(domFacts(field, { disabled: false }).disabled).toBe(true); + expect(domFacts(field, { disabled: true }).disabled).toBeUndefined(); + }); + it('flags a personal field without autocomplete, unless it is hidden or disabled', () => { expect( rules( diff --git a/packages/devtools/src/__tests__/ngrx-collector.test.ts b/packages/devtools/src/__tests__/ngrx-collector.test.ts index 6d85240e..3ff3df3c 100644 --- a/packages/devtools/src/__tests__/ngrx-collector.test.ts +++ b/packages/devtools/src/__tests__/ngrx-collector.test.ts @@ -175,6 +175,23 @@ describe('ngrx collector', () => { expect(collector.collect().stores[0].methods.find((m) => m.name === 'isSaved')?.calls).toBe(2); }); + it('writes diff paths with keys that need quotes the way the state tree does', async () => { + const { store, collector } = setup(); + const source = (store as unknown as Record>)[STATE_SOURCE]; + source['display name'] = writable('Ada'); + source.profile = writable({ 'first name': 'Ada', city: 'Rome' }); + collector.collect(); + patchState(store, { + 'display name': 'Grace', + profile: { 'first name': 'Grace', city: 'Rome' }, + }); + await Promise.resolve(); + expect(collector.logSince(0)[0].diff.map((row) => row.path)).toEqual([ + '"display name"', + 'profile["first name"]', + ]); + }); + it('logs writes made outside a method as patchState', async () => { const { store, collector } = setup(); collector.collect(); diff --git a/packages/devtools/src/__tests__/popup.test.ts b/packages/devtools/src/__tests__/popup.test.ts index 6265f886..7d87ef74 100644 --- a/packages/devtools/src/__tests__/popup.test.ts +++ b/packages/devtools/src/__tests__/popup.test.ts @@ -297,6 +297,13 @@ describe.sequential('devtools popup', () => { expect(missing.querySelector('a')!.getAttribute('href')).toMatch(/^https:/); expect(missing.querySelector('h2')!.id).toBe(missing.getAttribute('aria-labelledby')); expect(missing.querySelector('a')!.textContent).toContain('opens in a new tab'); + const trouble = [...missing.querySelectorAll('a')].find((a) => + a.textContent?.startsWith('Troubleshooting'), + )!; + expect(trouble.getAttribute('href')).toBe( + 'https://pangular-inspector.dev/getting-started/troubleshooting/#no-devtools-server-found', + ); + expect(trouble.textContent).toContain('opens in a new tab'); expect(shadow.querySelector('.sr-only[role="status"]')!.textContent).toBe( 'No devtools server found', ); @@ -962,6 +969,32 @@ describe.sequential('devtools popup', () => { expect(win.close).toHaveBeenCalled(); }); + it('takes the counts launcher setting from that window only', async () => { + const win = fakeWindow(); + vi.spyOn(window, 'open').mockReturnValue(win as unknown as Window); + await loadPopup(true); + await openedSrc(); + popoutButton().click(); + const { panel, fab } = parts(); + await vi.waitFor(() => expect(panel.classList.contains('open')).toBe(false)); + const send = (source: unknown, origin = location.origin) => + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'pangular:popup-pill', pill: true }, + source: source as Window, + origin, + }), + ); + send(win, 'https://evil.test'); + send(fakeWindow()); + expect(fab.classList.contains('pill')).toBe(false); + send(win); + expect(fab.classList.contains('pill')).toBe(true); + expect(stored().pill).toBe(true); + // The window keeps the panel; only the setting changed. + expect(panel.classList.contains('open')).toBe(false); + }); + it('notices the window was closed by hand', async () => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); try { diff --git a/packages/devtools/src/__tests__/router-preview.test.ts b/packages/devtools/src/__tests__/router-preview.test.ts index fc8d0733..c0850991 100644 --- a/packages/devtools/src/__tests__/router-preview.test.ts +++ b/packages/devtools/src/__tests__/router-preview.test.ts @@ -91,6 +91,21 @@ describe('redirectTarget', () => { it('drops the query and fragment of the URL for an absolute redirect with none of its own', () => { expect(redirectTarget('/a?tab=x#top', [config[4]], {})).toBe('/b'); }); + + it("copies the URL's query parameter into a :name value of the redirect's query", () => { + const node: RouteNode = { + id: '9', + path: 'old', + fullPath: '/old', + kind: 'redirect', + redirectTo: '/new?tab=:section&from=old&missing=:gone&tags=:tag#top', + pathMatch: 'full', + }; + // Angular Router 22.1 writes a missing parameter as `undefined` and repeats a repeated one. + expect(redirectTarget('/old?section=a%20b&tag=x&tag=y', [node], {})).toBe( + '/new?tab=a%20b&from=old&missing=undefined&tags=x&tags=y#top', + ); + }); }); describe('previewNavigation', () => { diff --git a/packages/devtools/src/defer-load.ts b/packages/devtools/src/defer-load.ts index 819674d2..01eb84f8 100644 --- a/packages/devtools/src/defer-load.ts +++ b/packages/devtools/src/defer-load.ts @@ -1,3 +1,4 @@ +import { redactText } from './serialize.ts'; import * as angularCore from '@angular/core'; import { actionBlockedMessage } from './config.ts'; import type { DeferBlockInfo } from './types.ts'; @@ -44,6 +45,8 @@ export interface DeferLoadResult { state?: string; /** The block already showed its main content, so nothing was loaded. */ already?: true; + /** What change detection threw while it rendered the loaded block. */ + renderError?: string; error?: string; } @@ -166,15 +169,29 @@ export async function loadDeferBlock( }; } internals.renderDeferBlockState(failed ? ERROR : COMPLETE, found.tNode, found.lContainer, true); + let renderError: string | undefined; for (const root of roots) { + let component: unknown; try { - const component = ng.getComponent?.(root); - if (component) ng.applyChanges?.(component); + component = ng.getComponent?.(root); } catch { // A root that is gone; the next tick of the app renders the block all the same. + continue; + } + if (!component) continue; + try { + ng.applyChanges?.(component); + } catch (error) { + // The block's own template can throw once it renders; the agent is told, + // with the secrets a message can carry masked like every other page text. + renderError ??= redactText(error instanceof Error ? error.message : String(error)); } } - return { ok: true, state: failed ? 'error' : 'complete' }; + return { + ok: true, + state: failed ? 'error' : 'complete', + ...(renderError !== undefined ? { renderError: renderError.slice(0, 500) } : {}), + }; } interface LoadRpc { diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 6d2c7eeb..997a2065 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -2664,15 +2664,13 @@ const pangular = defineDevframe({ 'Pipes as JSON: `source` has each pipe with purity and template uses; `pipes`/`async` the live page usage, with churn and stale findings while recording. ' + LIVE_RESOURCE_NOTE, mimeType: 'application/json', - read: () => { - const live = currentPipes(pipePages); - return { - text: liveResourceText( - { source: scanPipes(ctx.cwd), ...live }, - 'pangular:scan-pipes and pangular:explain-pipe', - ), - }; - }, + read: () => ({ + text: liveResourceText( + { source: scanPipes(ctx.cwd), ...currentPipes(pipePages) }, + 'pangular:scan-pipes and pangular:explain-pipe', + [...pipePages.keys()], + ), + }), }); // The same states one page at a time, for the MCP resource templates (`agent-mcp.ts`). diff --git a/packages/devtools/src/forms-dom.ts b/packages/devtools/src/forms-dom.ts index 945e7609..344f70cf 100644 --- a/packages/devtools/src/forms-dom.ts +++ b/packages/devtools/src/forms-dom.ts @@ -197,8 +197,10 @@ export function domFacts( ]; if (describedBy.length) facts.describedBy = describedBy.some((d) => !!d.textContent?.trim()); if (model.hasErrors) facts.errorShown = errorElements(el).some(isShown); - const disabled = (el as HTMLInputElement).disabled; - if (typeof disabled === 'boolean' && disabled !== !!model.disabled) facts.disabled = disabled; + // `:disabled` also covers a control inside a disabled `
        `, which its own `disabled` misses. + const disabled = + typeof (el as HTMLInputElement).disabled === 'boolean' ? el.matches(':disabled') : undefined; + if (disabled !== undefined && disabled !== !!model.disabled) facts.disabled = disabled; const dom = domValue(el); const same = el instanceof HTMLSelectElement && typeof dom === 'string' @@ -245,7 +247,8 @@ export function submitDom(el: Element, context: SubmitContext = {}): SubmitDom { return owner === el || (owner === undefined && inside.includes(b)); }); const submitters = candidates.filter(isSubmitter); - const disabledButtons = submitters.filter((b) => (b as HTMLButtonElement).disabled).length; + // `:disabled` also counts a button inside a disabled `
        `. + const disabledButtons = submitters.filter((b) => b.matches(':disabled')).length; const typed = candidates.filter((b) => b instanceof HTMLButtonElement && !isSubmitter(b)); if (!submitters.length) { reasons.push( diff --git a/packages/devtools/src/forms-privacy.ts b/packages/devtools/src/forms-privacy.ts index 7074f72e..9d09d259 100644 --- a/packages/devtools/src/forms-privacy.ts +++ b/packages/devtools/src/forms-privacy.ts @@ -1,7 +1,11 @@ -import { redactCredentials } from './credential-redact.ts'; -import { redactHighEntropy, setEntropyRedaction, type EntropyOptions } from './entropy-redact.ts'; +import { setEntropyRedaction, type EntropyOptions } from './entropy-redact.ts'; +import { redactMessage } from './redaction.ts'; import { RedactionScope } from './redaction-scope.ts'; +// The message redaction lives with the other helpers in `redaction.ts`; these +// names stay here so the collectors that import them from this module keep working. +export { longestFirst, redactMessage, redactMessageWith, redactTokens } from './redaction.ts'; + export const REDACTED = '[redacted]'; export type RedactReason = 'key' | 'input-type' | 'autocomplete' | 'marker' | 'parent' | 'config'; @@ -157,7 +161,6 @@ export function redactJwt(text: string): string { } return parts.length ? parts.join('') + text.slice(last) : text; } -const BEARER = /\bBearer\s+[\w.~+/=-]+/gi; interface PrivacyConfig { mask?: string[]; @@ -327,32 +330,6 @@ function safeQuery(element: Element, selector: string): boolean { } } -export function redactMessage(text: string, secrets: Iterable = []): string { - return redactMessageWith(text, longestFirst(secrets)); -} - -/** - * Credentials inside text (`credential-redact.ts`), JWTs and bearer tokens: - * the value-shape rules every message and log line goes through. - */ -export function redactTokens(text: string): string { - return redactJwt(redactCredentials(text, REDACTED)).replace(BEARER, `Bearer ${REDACTED}`); -} - -/** `secrets` as `redactMessageWith` takes them: 3 characters or more, longest first. */ -export function longestFirst(secrets: Iterable): string[] { - const out = [...secrets].filter((secret) => secret.length >= 3); - out.sort((a, b) => b.length - a.length); - return out; -} - -/** `redactMessage` with secrets `longestFirst` already prepared, for many texts in a row. */ -export function redactMessageWith(text: string, ordered: readonly string[]): string { - let out = redactTokens(text); - for (const secret of ordered) out = out.split(secret).join(REDACTED); - return redactHighEntropy(out, REDACTED); -} - /** Most earlier secret values a `SecretSet` keeps hiding after they changed. */ export const MAX_RETIRED_SECRETS = 50; diff --git a/packages/devtools/src/ngrx-collector.ts b/packages/devtools/src/ngrx-collector.ts index 225422a4..87f10b21 100644 --- a/packages/devtools/src/ngrx-collector.ts +++ b/packages/devtools/src/ngrx-collector.ts @@ -6,6 +6,7 @@ import { className, tokenName } from './injector-tree.ts'; import { devtoolsProblem, diff, + joinStatePath, dispatchProblem, NGRX_BURST_WINDOW_MS, NGRX_DIFF_MORE_CAP, @@ -256,7 +257,8 @@ function snapshotDiff(before: Snapshot, after: Snapshot, keys: PropertyKey[]): C for (const key of keys) { if (out.length >= MAX_DIFF && overflow.more >= NGRX_DIFF_MORE_CAP) break; if (before[key] === after[key]) continue; - const root = String(key); + // Quoted the way the state tree writes a child path, as in `["first name"]`. + const root = typeof key === 'string' ? joinStatePath('', key) : String(key); if (typeof key === 'string' && isRedactedKey(key)) { if (out.length < MAX_DIFF) { out.push({ path: root, op: 'change', before: REDACTED, after: REDACTED }); @@ -270,6 +272,7 @@ function snapshotDiff(before: Snapshot, after: Snapshot, keys: PropertyKey[]): C MAX_DIFF - out.length, undefined, overflow, + root, ); if (!found.length && overflow.more === counted) { out.push( @@ -286,15 +289,8 @@ function snapshotDiff(before: Snapshot, after: Snapshot, keys: PropertyKey[]): C continue; } for (const entry of found) { - const path = - entry.path === '(root)' - ? root - : entry.path.startsWith('[') - ? `${root}${entry.path}` - : `${root}.${entry.path}`; out.push({ ...entry, - path, ...('before' in entry ? { before: serialize(entry.before, SMALL) } : {}), ...('after' in entry ? { after: serialize(entry.after, SMALL) } : {}), }); diff --git a/packages/devtools/src/ngrx-shared.ts b/packages/devtools/src/ngrx-shared.ts index e10a12bf..99b01b6a 100644 --- a/packages/devtools/src/ngrx-shared.ts +++ b/packages/devtools/src/ngrx-shared.ts @@ -466,19 +466,22 @@ function same(a: unknown, b: unknown): boolean { return keys.every((key) => Object.hasOwn(y, key) && same(x[key], y[key])); } -function join(path: string, key: string | number): string { +/** A child path as the change log writes it: `a.b`, `a[0]`, `a["first name"]`. */ +export function joinStatePath(path: string, key: string | number): string { if (typeof key === 'number') return `${path}[${key}]`; const safe = /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key); if (!path) return safe; return safe.startsWith('"') ? `${path}[${safe}]` : `${path}.${safe}`; } +/** Changes from `before` to `after`, with paths under `base` (`(root)` when it is empty). */ export function diff( before: unknown, after: unknown, limit = 50, maxDepth = 6, overflow?: DiffOverflow, + base = '', ): NgrxDiffEntry[] { const out: NgrxDiffEntry[] = []; const full = () => out.length >= limit && (!overflow || overflow.more >= NGRX_DIFF_MORE_CAP); @@ -498,9 +501,9 @@ export function diff( const x = a as unknown[]; const y = b as unknown[]; for (let i = 0; i < Math.max(x.length, y.length); i++) { - if (i >= x.length) emit({ path: join(path, i), op: 'add', after: y[i] }); - else if (i >= y.length) emit({ path: join(path, i), op: 'remove', before: x[i] }); - else walk(x[i], y[i], join(path, i), depth + 1); + if (i >= x.length) emit({ path: joinStatePath(path, i), op: 'add', after: y[i] }); + else if (i >= y.length) emit({ path: joinStatePath(path, i), op: 'remove', before: x[i] }); + else walk(x[i], y[i], joinStatePath(path, i), depth + 1); if (full()) return; } return; @@ -508,13 +511,13 @@ export function diff( const x = a as Record; const y = b as Record; for (const key of new Set([...Object.keys(x), ...Object.keys(y)])) { - if (!(key in y)) emit({ path: join(path, key), op: 'remove', before: x[key] }); - else if (!(key in x)) emit({ path: join(path, key), op: 'add', after: y[key] }); - else walk(x[key], y[key], join(path, key), depth + 1); + if (!(key in y)) emit({ path: joinStatePath(path, key), op: 'remove', before: x[key] }); + else if (!(key in x)) emit({ path: joinStatePath(path, key), op: 'add', after: y[key] }); + else walk(x[key], y[key], joinStatePath(path, key), depth + 1); if (full()) return; } }; - walk(before, after, '', 0); + walk(before, after, base, 0); return out; } @@ -565,10 +568,10 @@ export function referenceDiff( const y = b as unknown[]; for (let i = 0; i < Math.max(x.length, y.length) && !full(); i++) { if (i >= x.length) { - emit(() => ({ path: join(at, i), op: 'add', after: show(field(y, i)) })); + emit(() => ({ path: joinStatePath(at, i), op: 'add', after: show(field(y, i)) })); } else if (i >= y.length) { - emit(() => ({ path: join(at, i), op: 'remove', before: show(field(x, i)) })); - } else walk(field(x, i), field(y, i), join(at, i), depth + 1); + emit(() => ({ path: joinStatePath(at, i), op: 'remove', before: show(field(x, i)) })); + } else walk(field(x, i), field(y, i), joinStatePath(at, i), depth + 1); } } else { const x = a as Record; @@ -578,21 +581,26 @@ export function referenceDiff( const secret = isRedactedKey(key); if (!(key in y)) { emit(() => ({ - path: join(at, key), + path: joinStatePath(at, key), op: 'remove', before: secret ? REDACTED : show(field(x, key)), })); } else if (!(key in x)) { emit(() => ({ - path: join(at, key), + path: joinStatePath(at, key), op: 'add', after: secret ? REDACTED : show(field(y, key)), })); } else if (secret) { if (!Object.is(field(x, key), field(y, key))) { - emit(() => ({ path: join(at, key), op: 'change', before: REDACTED, after: REDACTED })); + emit(() => ({ + path: joinStatePath(at, key), + op: 'change', + before: REDACTED, + after: REDACTED, + })); } - } else walk(field(x, key), field(y, key), join(at, key), depth + 1); + } else walk(field(x, key), field(y, key), joinStatePath(at, key), depth + 1); } } if (found === start) { diff --git a/packages/devtools/src/popup-links.ts b/packages/devtools/src/popup-links.ts new file mode 100644 index 00000000..0de36800 --- /dev/null +++ b/packages/devtools/src/popup-links.ts @@ -0,0 +1,17 @@ +/** The docs on finding the server, linked when the popup finds none. */ +export const TROUBLESHOOTING_URL = + 'https://pangular-inspector.dev/getting-started/troubleshooting/#no-devtools-server-found'; + +/** A link that opens `href` in a new tab and says so to screen readers. */ +export function newTabLink(href: string, text: string): HTMLAnchorElement { + const link = document.createElement('a'); + link.href = href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = text; + const newTab = document.createElement('span'); + newTab.classList.add('sr-only'); + newTab.textContent = ' (opens in a new tab)'; + link.append(newTab); + return link; +} diff --git a/packages/devtools/src/popup.ts b/packages/devtools/src/popup.ts index 17a41d4d..8b3e1783 100644 --- a/packages/devtools/src/popup.ts +++ b/packages/devtools/src/popup.ts @@ -1,4 +1,5 @@ import { DEVTOOLS_BASES, POPUP_ROOT_ID, SETUP_URL, insideDevtoolsPanel } from './panel-frame.ts'; +import { TROUBLESHOOTING_URL, newTabLink } from './popup-links.ts'; import { DEFAULT_POPUP_HOTKEY, matchesHotkey, normalizeHotkey } from './config.ts'; // In-page floating devtools popup. Renders an iframe pointing at the devtools SPA. @@ -422,15 +423,8 @@ export function createDevtoolsPopup(options: { src?: string; hotkey?: string | f missingHint.textContent = 'Mount the Pangular Inspector hub or the Vite plugin in your dev server, before the SSR handler. ' + 'If it is mounted on a custom path, pass that path to initOverlay({baseURL}).'; - const setupLink = document.createElement('a'); - setupLink.href = SETUP_URL; - setupLink.target = '_blank'; - setupLink.rel = 'noopener noreferrer'; - setupLink.textContent = 'How to set up Pangular Inspector'; - const newTab = document.createElement('span'); - newTab.classList.add('sr-only'); - newTab.textContent = ' (opens in a new tab)'; - setupLink.append(newTab); + const setupLink = newTabLink(SETUP_URL, 'How to set up Pangular Inspector'); + const troubleLink = newTabLink(TROUBLESHOOTING_URL, 'Troubleshooting'); const retryBtn = document.createElement('button'); retryBtn.type = 'button'; retryBtn.classList.add('retry'); @@ -442,7 +436,7 @@ export function createDevtoolsPopup(options: { src?: string; hotkey?: string | f }); const missingActions = document.createElement('p'); missingActions.classList.add('missing-actions'); - missingActions.append(retryBtn, setupLink); + missingActions.append(retryBtn, setupLink, troubleLink); missing.append(missingTitle, missingHint, missingActions); // The splitter sits on the docked edge. It is a focusable separator, so the @@ -1454,8 +1448,14 @@ export function createDevtoolsPopup(options: { src?: string; hotkey?: string | f return null; }; const onOutsideMessage = (e: MessageEvent) => { - const type = (e.data as { type?: unknown } | null)?.type; - if (type !== 'pangular:popup-return') return; + const msg = e.data as { type?: unknown; pill?: unknown } | null; + // The counts launcher setting, changed in the panel's own window. + if (msg?.type === 'pangular:popup-pill' && typeof msg.pill === 'boolean' && fromOutside(e)) { + state.pill = msg.pill || undefined; + saveState(state); + applyPill(); + } + if (msg?.type !== 'pangular:popup-return') return; const win = fromOutside(e); if (!win) return; outside = win; diff --git a/packages/devtools/src/redaction.ts b/packages/devtools/src/redaction.ts index ace9431b..74c9bb74 100644 --- a/packages/devtools/src/redaction.ts +++ b/packages/devtools/src/redaction.ts @@ -10,7 +10,9 @@ * masks they left, for the counts the panel and agent answers show. */ import type { AnalogRuntimeReport } from './analog-runtime.ts'; -import { REDACTED, isRedactedKey } from './forms-privacy.ts'; +import { redactCredentials } from './credential-redact.ts'; +import { redactHighEntropy } from './entropy-redact.ts'; +import { REDACTED, isRedactedKey, redactJwt } from './forms-privacy.ts'; import { MAX_HEADERS, MAX_HEADER_VALUE, type HeaderRecord } from './http-headers.ts'; import type { HttpCall } from './http-rules.ts'; import { redactText, redactUrl } from './router.ts'; @@ -19,9 +21,37 @@ import { clip } from './text.ts'; export { countRedacted, countRedactedText, redactionNote } from './redaction-count.ts'; -// JSON text +// Messages and log lines + +const BEARER = /\bBearer\s+[\w.~+/=-]+/gi; + +export function redactMessage(text: string, secrets: Iterable = []): string { + return redactMessageWith(text, longestFirst(secrets)); +} + +/** + * Credentials inside text (`credential-redact.ts`), JWTs and bearer tokens: + * the value-shape rules every message and log line goes through. + */ +export function redactTokens(text: string): string { + return redactJwt(redactCredentials(text, REDACTED)).replace(BEARER, `Bearer ${REDACTED}`); +} + +/** `secrets` as `redactMessageWith` takes them: 3 characters or more, longest first. */ +export function longestFirst(secrets: Iterable): string[] { + const out = [...secrets].filter((secret) => secret.length >= 3); + out.sort((a, b) => b.length - a.length); + return out; +} -const REDACTED_VALUE = JSON.stringify(REDACTED); +/** `redactMessage` with secrets `longestFirst` already prepared, for many texts in a row. */ +export function redactMessageWith(text: string, ordered: readonly string[]): string { + let out = redactTokens(text); + for (const secret of ordered) out = out.split(secret).join(REDACTED); + return redactHighEntropy(out, REDACTED); +} + +// JSON text function stringEnd(text: string, start: number): number { for (let i = start + 1; i < text.length; i++) { @@ -71,7 +101,7 @@ export function redactJsonText(text: string, isSecret: (key: string) => boolean) key = token.slice(1, -1); } if (!isSecret(key)) continue; - out += `${sep[0]}${REDACTED_VALUE}`; + out += `${sep[0]}${JSON.stringify(REDACTED)}`; i = valueEnd(text, i + sep[0].length); } return out; diff --git a/packages/devtools/src/rpc/__tests__/ngrx-devtools-tools.test.ts b/packages/devtools/src/rpc/__tests__/ngrx-devtools-tools.test.ts index 57a1969b..554ce86c 100644 --- a/packages/devtools/src/rpc/__tests__/ngrx-devtools-tools.test.ts +++ b/packages/devtools/src/rpc/__tests__/ngrx-devtools-tools.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { NgrxLogEntry, NgrxRequest } from '../../ngrx-shared.ts'; import { createNgrxDevtoolsTools, explainChangeText, touchesPath } from '../ngrx-devtools-tools.ts'; +import { isRefusal } from '../refusal.ts'; +import { UNTRUSTED } from '../forms-tools.ts'; import type { NgrxPageRecord, NgrxPages } from '../ngrx-tools.ts'; function entry(seq: number, patch: Partial): NgrxLogEntry { @@ -91,6 +93,29 @@ describe('explainChangeText', () => { }); }); +describe('ngrx-explain-change tool', () => { + function explain(ngrx: NgrxPages) { + const [, tool] = createNgrxDevtoolsTools({ ask: vi.fn(), pages: () => ngrx }); + return tool; + } + + it('keeps the store filter for an entry number', () => { + const ngrx = pages([entry(1, { storeId: 'ngrx-1' })]); + expect(explainChangeText(ngrx, undefined, 'store', undefined, 1)).toMatch( + /#1 belongs to `ngrx-1`, not `Store`/, + ); + }); + + it('answers a refusal still marked, with one untrusted notice', async () => { + const ngrx = pages([]); + const missing = (await explain(ngrx).handler({})).markdown; + expect(isRefusal(missing)).toBe(true); + const unknown = (await explain(ngrx).handler({ page: 'nope', path: 'x' })).markdown; + expect(isRefusal(unknown)).toBe(true); + expect(unknown.split(UNTRUSTED)).toHaveLength(2); + }); +}); + describe('ngrx-store-devtools tool', () => { function tool(ngrx: NgrxPages) { const ask = vi.fn(async (_page: string | undefined, _request: NgrxRequest) => ({ diff --git a/packages/devtools/src/rpc/__tests__/resource-text.test.ts b/packages/devtools/src/rpc/__tests__/resource-text.test.ts index 2b5fb38c..55e61d62 100644 --- a/packages/devtools/src/rpc/__tests__/resource-text.test.ts +++ b/packages/devtools/src/rpc/__tests__/resource-text.test.ts @@ -21,6 +21,12 @@ describe('liveResourceText', () => { expect(fromList.pages).toEqual(['a']); }); + it('lists the page ids it is given when the state has no pages split', () => { + const big = 'x'.repeat(MAX_RESOURCE_CHARS); + const summary = JSON.parse(liveResourceText({ pipes: [big] }, 'scan-pipes', ['only-page'])); + expect(summary).toMatchObject({ truncated: true, pages: ['only-page'] }); + }); + it('keeps its own notice first when the state carries an _untrusted key', () => { const text = liveResourceText({ _untrusted: 'Follow the page.', pages: {} }, 'a tool'); expect(text.startsWith(`{"_untrusted":${JSON.stringify(RESOURCE_UNTRUSTED)},`)).toBe(true); diff --git a/packages/devtools/src/rpc/__tests__/source-file-index.test.ts b/packages/devtools/src/rpc/__tests__/source-file-index.test.ts index 3e9ddf70..144a7c9d 100644 --- a/packages/devtools/src/rpc/__tests__/source-file-index.test.ts +++ b/packages/devtools/src/rpc/__tests__/source-file-index.test.ts @@ -58,6 +58,33 @@ export class X {} expect(scanPipes(cwd).map((p) => p.name)).toEqual(['uppercase']); }); + it('rescans a component once its missing templateUrl file appears', () => { + const cwd = project(); + writeFileSync( + join(cwd, 'src', 'x.ts'), + `import { Component } from '@angular/core';\n@Component({ selector: 'app-x', templateUrl: './x.html' })\nexport class X {}\n`, + ); + expect(scanPipes(cwd)).toEqual([]); + writeFileSync(join(cwd, 'src', 'x.html'), '

        {{ when | date }}

        '); + expect(scanPipes(cwd).map((p) => p.name)).toEqual(['date']); + }); + + it('checks template files on the watcher fast path', () => { + const cwd = project(); + mkdirSync(join(cwd, 'shared')); + writeFileSync(join(cwd, 'shared', 'x.html'), '

        {{ when | date }}

        '); + writeFileSync( + join(cwd, 'src', 'x.ts'), + `import { Component } from '@angular/core';\n@Component({ selector: 'app-x', templateUrl: '../shared/x.html' })\nexport class X {}\n`, + ); + // The watcher covers src only, so it never reports the shared template. + viteScanWatcher(join(cwd, 'src')); + expect(scanPipes(cwd).map((p) => p.name)).toEqual(['date']); + writeFileSync(join(cwd, 'shared', 'x.html'), '

        {{ name | uppercase }}

        '); + touch(join(cwd, 'shared', 'x.html'), 1_000_000); + expect(scanPipes(cwd).map((p) => p.name)).toEqual(['uppercase']); + }); + it('skips the walk while the Vite watcher covers the sources and saw no change', () => { const cwd = project(); const onChange = viteScanWatcher(cwd); diff --git a/packages/devtools/src/rpc/component-report-guard.ts b/packages/devtools/src/rpc/component-report-guard.ts index c241238e..73f652aa 100644 --- a/packages/devtools/src/rpc/component-report-guard.ts +++ b/packages/devtools/src/rpc/component-report-guard.ts @@ -99,8 +99,11 @@ function guardDependencies(value: unknown): DependencyInfo[] { export function guardComponentDetail(value: unknown): ComponentDetail | null { if (!isRecord(value)) return null; if (!str(value['id']) || !str(value['name']) || !str(value['tag'])) return null; + const { selector, ...rest } = value; return { - ...value, + ...rest, + // The agent tools match and print it as text; anything else is left out. + ...(str(selector) ? { selector } : {}), id: value['id'], name: value['name'], tag: value['tag'], diff --git a/packages/devtools/src/rpc/defer-loads.ts b/packages/devtools/src/rpc/defer-loads.ts index 3ee9f032..e99cfdda 100644 --- a/packages/devtools/src/rpc/defer-loads.ts +++ b/packages/devtools/src/rpc/defer-loads.ts @@ -38,6 +38,9 @@ function toResult(result: unknown): DeferLoadResult { ok: true, state: r['state'] === 'error' ? 'error' : 'complete', ...(r['already'] === true ? { already: true as const } : {}), + ...(typeof r['renderError'] === 'string' + ? { renderError: r['renderError'].slice(0, 500) } + : {}), }; } return { @@ -56,13 +59,16 @@ export function deferLoadText( if (result.already) { return untrusted(`Defer block ${where} already shows its main content, so nothing was loaded.`); } + const threw = result.renderError + ? ` Change detection threw while it rendered: ${result.renderError}. Check pangular:list-errors.` + : ''; if (result.state === 'error') { return untrusted( - `Loaded defer block ${where}, but its dependencies failed, so it shows its @error block. Check pangular:list-errors and the browser's network panel for the failed chunk.`, + `Loaded defer block ${where}, but its dependencies failed, so it shows its @error block. Check pangular:list-errors and the browser's network panel for the failed chunk.${threw}`, ); } return untrusted( - `Loaded defer block ${where}: its dependencies were fetched and it now shows its main content. Its own triggers did not fire, so this does not prove they work. Read pangular:defer-blocks again to see its new state.`, + `Loaded defer block ${where}: its dependencies were fetched and it now shows its main content. Its own triggers did not fire, so this does not prove they work. Read pangular:defer-blocks again to see its new state.${threw}`, ); } diff --git a/packages/devtools/src/rpc/get-pipes.ts b/packages/devtools/src/rpc/get-pipes.ts index 69db7693..d58c3e7d 100644 --- a/packages/devtools/src/rpc/get-pipes.ts +++ b/packages/devtools/src/rpc/get-pipes.ts @@ -93,11 +93,12 @@ export function scanPipes(cwd: string): PipeInfo[] { const major = angularMajor(cwd); const files = scanSourceFiles(cwd, `pipes:${major ?? '?'}`, (file) => { const usages = new Map(); - const templates = templatesIn(file.content, file.relPath, file.full, cwd); + const missing: string[] = []; + const templates = templatesIn(file.content, file.relPath, file.full, cwd, missing); for (const t of templates) recordUsages(t.text, t.file, t.lineAt, t.baseOffset, usages); return { value: { pipes: pipesIn(file.content, file.relPath, major), usages: [...usages] }, - deps: templateDeps(templates, file.relPath, cwd), + deps: templateDeps(templates, file.relPath, cwd, missing), }; }); // The kept results are shared between calls, so the answer is built from copies. @@ -156,17 +157,22 @@ function pipesIn(content: string, relPath: string, major: number | undefined): P return pipes; } -/** The `templateUrl` files among `templates`, by absolute path, so a kept scan of - * their component file is redone when one of them changes. */ +/** The `templateUrl` files among `templates`, by absolute path, plus the + * `missing` ones `templatesIn` could not read, so a kept scan of their + * component file is redone when one of them changes or appears. */ export function templateDeps( templates: readonly TemplateSource[], relPath: string, cwd: string, + missing: readonly string[] = [], ): string[] { const root = realPath(cwd); - return templates - .filter((template) => template.baseOffset === 0 && template.file !== relPath) - .map((template) => join(root, template.file)); + return [ + ...templates + .filter((template) => template.baseOffset === 0 && template.file !== relPath) + .map((template) => join(root, template.file)), + ...missing, + ]; } const TEMPLATE_KEY = /\btemplate\s*:\s*/; @@ -185,13 +191,16 @@ export interface TemplateSource { * Finds every `@Component(...)` in `content` and its template — inline, or * read from `templateUrl` relative to `fullPath` — for any scanner that needs * the template's text alongside a way to turn an offset into it back into a - * line number. Shared by the pipe usage scan and the pipe linter. + * line number. Shared by the pipe usage scan and the pipe linter. A + * `templateUrl` inside the workspace that cannot be read is added to + * `missing` by absolute path, for `templateDeps`. */ export function templatesIn( content: string, relPath: string, fullPath: string, cwd: string, + missing?: string[], ): TemplateSource[] { const source = stripComments(content); const code = maskStrings(source); @@ -210,7 +219,7 @@ export function templatesIn( out.push({ text: inline.value, file: relPath, lineAt, baseOffset: inline.offset }); } else { const url = valueOf(args, source, open, TEMPLATE_URL_KEY); - const external = url && templateFromUrl(url.value, fullPath, cwd); + const external = url && templateFromUrl(url.value, fullPath, cwd, missing); if (external) out.push(external); } decorator.lastIndex = close; @@ -246,13 +255,18 @@ function valueOf( * otherwise) is refused rather than read, the same containment rule * `sourceRoots` applies to every scanned directory. */ -function templateFromUrl(url: string, fullPath: string, cwd: string): TemplateSource | null { +function templateFromUrl( + url: string, + fullPath: string, + cwd: string, + missing?: string[], +): TemplateSource | null { + const templatePath = resolve(dirname(fullPath), url); + const root = realPath(cwd); + const real = realPath(templatePath); + const inside = relative(root, real); + if (escapes(inside) || isAbsolute(inside)) return null; try { - const templatePath = resolve(dirname(fullPath), url); - const root = realPath(cwd); - const real = realPath(templatePath); - const inside = relative(root, real); - if (escapes(inside) || isAbsolute(inside)) return null; const text = readFileSync(real, 'utf-8'); // `inside` is already relative(root, real): both real, so it matches // `cwd`-relative paths reported elsewhere without a second `relative()` @@ -261,7 +275,8 @@ function templateFromUrl(url: string, fullPath: string, cwd: string): TemplateSo // (e.g. macOS's /var -> /private/var). return { text, file: toPosixPath(inside), lineAt: lineCounter(text), baseOffset: 0 }; } catch { - // templateUrl does not resolve to a readable file + // templateUrl does not resolve to a readable file yet: watch for it. + missing?.push(real); return null; } } diff --git a/packages/devtools/src/rpc/ngrx-devtools-tools.ts b/packages/devtools/src/rpc/ngrx-devtools-tools.ts index 22ed2753..bc42157b 100644 --- a/packages/devtools/src/rpc/ngrx-devtools-tools.ts +++ b/packages/devtools/src/rpc/ngrx-devtools-tools.ts @@ -12,7 +12,7 @@ import { UNTRUSTED, untrusted } from './forms-tools.ts'; import type { NgrxPageRecord, NgrxPages } from './ngrx-tools.ts'; import { storeIdNamed } from './ngrx-live-tools.ts'; import { pageArgument, unknownPageText } from './pages.ts'; -import { refusal } from './refusal.ts'; +import { isRefusal, refusal } from './refusal.ts'; import { rankPages } from './default-page.ts'; /** @@ -170,7 +170,7 @@ const NO_TRACE = * Why a state path changed, as markdown: the newest log entries on the * matching page(s) whose diff touches `path`, newest first, each with what * ran, its arguments, origin, causing event and call site. With `seq`, that - * one entry with its whole diff. + * one entry with its whole diff, when it belongs to `storeId` (if given). */ export function explainChangeText( pages: NgrxPages, @@ -200,6 +200,9 @@ export function explainChangeText( if (!entry) { return `No log entry #${seq}${where || ` on page ${code(page.pageId)}`}. It may be older than the kept log (limits.changeLog).`; } + if (storeId && entry.storeId !== storeId) { + return `Log entry #${seq}${where} belongs to ${code(label(entry.storeId))}, not ${code(label(storeId))}. Leave \`store\` out to explain it.`; + } const body = entryText(entry, entry.diff, label(entry.storeId)); return `${body}${entry.trace?.length ? '' : `\n\n${NO_TRACE}`}`; } @@ -274,17 +277,15 @@ export function createNgrxDevtoolsTools(deps: Deps) { const page = pageArgument(args); const store = typeof args?.['store'] === 'string' ? args['store'] : undefined; const path = typeof args?.['path'] === 'string' ? args['path'].slice(0, 500) : undefined; - return { - markdown: untrusted( - explainChangeText( - pages, - page, - storeIdNamed(pages, page, store), - path, - whole(args?.['seq']), - ), - ), - }; + const text = explainChangeText( + pages, + page, + storeIdNamed(pages, page, store), + path, + whole(args?.['seq']), + ); + // A refusal stays as built, so it keeps its marking and its own notice. + return { markdown: isRefusal(text) ? text : untrusted(text) }; }, }, ]; diff --git a/packages/devtools/src/rpc/pipe-lint.ts b/packages/devtools/src/rpc/pipe-lint.ts index e31edf8c..dc0aa0de 100644 --- a/packages/devtools/src/rpc/pipe-lint.ts +++ b/packages/devtools/src/rpc/pipe-lint.ts @@ -62,7 +62,8 @@ export function lintPipes( declared.push([scope.pipeName, !/\bpure\s*:\s*false\b/.test(scope.decoratorArgs ?? '')]); } } - const templates = templatesIn(file.content, file.relPath, file.full, cwd); + const missing: string[] = []; + const templates = templatesIn(file.content, file.relPath, file.full, cwd, missing); const signals = templates.length ? fieldsMatching(SIGNAL_FIELD, code) : new Set(); const own: PipeLintFinding[] = []; for (const template of templates) { @@ -72,7 +73,7 @@ export function lintPipes( own.push(...signalInPurePipeFindings(file.content, file.relPath)); return { value: { declared, templates, own }, - deps: templateDeps(templates, file.relPath, cwd), + deps: templateDeps(templates, file.relPath, cwd, missing), }; }); diff --git a/packages/devtools/src/rpc/resource-text.ts b/packages/devtools/src/rpc/resource-text.ts index 1ba2c38c..ee19fc8a 100644 --- a/packages/devtools/src/rpc/resource-text.ts +++ b/packages/devtools/src/rpc/resource-text.ts @@ -29,15 +29,17 @@ function pageIdsOf(pages: unknown): string[] { /** * Compact JSON for a live-state resource, led by an `_untrusted` notice. When * the state runs past {@link MAX_RESOURCE_CHARS}, it returns the connected page - * ids and a note that names the tools to read the data with instead. + * ids and a note that names the tools to read the data with instead. Pass + * `pageIds` when `state.pages` does not list every connected page (the pipes + * state only splits by page when more than one page reports). */ -export function liveResourceText(state: object, instead: string): string { +export function liveResourceText(state: object, instead: string, pageIds?: string[]): string { const json = JSON.stringify(withUntrustedNotice(state)); if (json.length <= MAX_RESOURCE_CHARS) return json; return JSON.stringify({ _untrusted: RESOURCE_UNTRUSTED, truncated: true, note: `This state is too large for the resource. Use ${instead} instead.`, - pages: pageIdsOf((state as { pages?: unknown }).pages).slice(0, 50), + pages: (pageIds ?? pageIdsOf((state as { pages?: unknown }).pages)).slice(0, 50), }); } diff --git a/packages/devtools/src/rpc/router-preview.ts b/packages/devtools/src/rpc/router-preview.ts index e718795e..b28c0a98 100644 --- a/packages/devtools/src/rpc/router-preview.ts +++ b/packages/devtools/src/rpc/router-preview.ts @@ -53,7 +53,8 @@ function fill(path: string, params: Record): string { * Where a `redirectTo` route sends `url`, the way the default redirect does: * an absolute target replaces the URL, a relative one replaces the matched * segments under the parent route; `:params` are filled in, and the rest of - * the URL and its query stay. Undefined for a function redirect. + * the URL and its query stay. A `:name` query value in `redirectTo` copies the + * URL's query parameter `name`. Undefined for a function redirect. */ export function redirectTarget( url: string, @@ -66,7 +67,7 @@ export function redirectTarget( const { path, rest } = splitUrl(url); const consumed = parts(node.fullPath).length; const remaining = node.pathMatch === 'full' ? [] : parts(path).slice(consumed); - const [targetPath, targetRest = ''] = redirect.split(/(?=[?#])/); + const { path: targetPath, rest: targetRest } = splitUrl(redirect); const parent = chain.length > 1 ? fill(chain[chain.length - 2].fullPath, params) : ''; const base = targetPath.startsWith('/') ? fill(targetPath, params) @@ -74,7 +75,33 @@ export function redirectTarget( const joined = `/${[...parts(base), ...remaining].join('/')}`; // Angular keeps the query and fragment of the URL only for a relative redirect; // an absolute one takes them from `redirectTo` alone. - return `${joined}${targetRest || (targetPath.startsWith('/') ? '' : rest)}`; + return `${joined}${targetRest ? redirectQuery(targetRest, rest) : targetPath.startsWith('/') ? '' : rest}`; +} + +/** + * The query and fragment of `redirectTo` (`target`, from `?` or `#`), with each + * `:name` value copied from the query parameter `name` of the URL (`rest`), as + * Angular's redirect does: every value of a repeated parameter becomes its own + * pair, and a parameter the URL lacks becomes `undefined`, as Angular writes it. + */ +function redirectQuery(target: string, rest: string): string { + if (!target.startsWith('?')) return target; + const hash = target.indexOf('#'); + const query = hash < 0 ? target.slice(1) : target.slice(1, hash); + const fragment = hash < 0 ? '' : target.slice(hash); + const actual = new URLSearchParams(rest.split('#')[0]); + const pairs: string[] = []; + for (const pair of query.split('&')) { + const [key, value = ''] = pair.split(/=(.*)/s); + if (!value.startsWith(':')) { + pairs.push(pair); + continue; + } + const copied = actual.getAll(value.slice(1)); + if (!copied.length) pairs.push(`${key}=undefined`); + for (const one of copied) pairs.push(`${key}=${encodeURIComponent(one)}`); + } + return `${pairs.length ? `?${pairs.join('&')}` : ''}${fragment}`; } function findNodes(nodes: RouteNode[] | undefined, ids: Set, out: RouteNode[] = []) { diff --git a/packages/devtools/src/rpc/source-file-index.ts b/packages/devtools/src/rpc/source-file-index.ts index 731ae9ae..58f2f1f7 100644 --- a/packages/devtools/src/rpc/source-file-index.ts +++ b/packages/devtools/src/rpc/source-file-index.ts @@ -61,6 +61,14 @@ function watcherCovers(roots: readonly string[]): boolean { }); } +/** Whether every file the kept results were read from besides their own still has the same stats. */ +function depsUnchanged(scan: Scan): boolean { + for (const entry of scan.files.values()) { + if (!entry.deps.every((dep) => statKey(dep.path) === dep.key)) return false; + } + return true; +} + /** * Every `.ts` source file (not `.spec.ts`) under the workspace's source roots, * parsed by `parse`, in walk order. `kind` names the parse, so two scans with @@ -81,7 +89,10 @@ export function scanSourceFiles( const roots = sourceRoots(cwd); const shared = viteWatchVersion(); const covered = watcherCovers(roots); - if (covered && scan.last && scan.version === shared.version) return scan.last; + // A template can sit outside what the watcher sees, so its deps are still checked. + if (covered && scan.last && scan.version === shared.version && depsUnchanged(scan)) { + return scan.last; + } const version = shared.version; const out: T[] = []; diff --git a/scripts/docs-agent-tools.mjs b/scripts/docs-agent-tools.mjs index 1f711697..36113255 100644 --- a/scripts/docs-agent-tools.mjs +++ b/scripts/docs-agent-tools.mjs @@ -6,9 +6,9 @@ * * Usage: node scripts/docs-agent-tools.mjs */ -import { execFileSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; +import * as prettier from 'prettier'; import { renderToolIndex, TOOL_INDEX_PATH } from '../apps/docs/agent-tool-index.ts'; const docsRoot = path.resolve(import.meta.dirname, '../apps/docs'); @@ -19,9 +19,7 @@ if (problems.length > 0) { process.exit(1); } const file = path.join(docsRoot, TOOL_INDEX_PATH); -writeFileSync(file, markdown); -execFileSync('pnpm', ['exec', 'prettier', '--write', file], { - cwd: path.resolve(import.meta.dirname, '..'), - stdio: 'inherit', -}); +// Formatted the way the docs test formats it, so the two compare equal. +const options = (await prettier.resolveConfig(file)) ?? {}; +writeFileSync(file, await prettier.format(markdown, { ...options, filepath: file })); console.log(`Wrote ${path.relative(process.cwd(), file)}`); diff --git a/scripts/docs-api.mjs b/scripts/docs-api.mjs index f4d4f3fb..77a5ec8b 100644 --- a/scripts/docs-api.mjs +++ b/scripts/docs-api.mjs @@ -6,9 +6,9 @@ * * Usage: node scripts/docs-api.mjs */ -import { execFileSync } from 'node:child_process'; import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; +import * as prettier from 'prettier'; import { API_DIR, renderApiReference } from '../apps/docs/api-reference.ts'; const repoRoot = path.resolve(import.meta.dirname, '..'); @@ -18,6 +18,10 @@ mkdirSync(dir, { recursive: true }); for (const file of readdirSync(dir)) { if (file.endsWith('.md') && !pages.has(file)) rmSync(path.join(dir, file)); } -for (const [file, markdown] of pages) writeFileSync(path.join(dir, file), markdown); -execFileSync('pnpm', ['exec', 'prettier', '--write', dir], { cwd: repoRoot, stdio: 'inherit' }); +// Formatted the way the docs test formats them, so the two compare equal. +for (const [file, markdown] of pages) { + const filepath = path.join(dir, file); + const options = (await prettier.resolveConfig(filepath)) ?? {}; + writeFileSync(filepath, await prettier.format(markdown, { ...options, filepath })); +} console.log(`Wrote ${pages.size} pages to ${path.relative(process.cwd(), dir)}`);