diff --git a/app/src/__tests__/router-panels.test.ts b/app/src/__tests__/router-panels.test.ts index cbf5ba40..68000637 100644 --- a/app/src/__tests__/router-panels.test.ts +++ b/app/src/__tests__/router-panels.test.ts @@ -319,6 +319,69 @@ describe('RouteTree edge cases', () => { ); }); + function message(fixture: ComponentFixture) { + return el(fixture).querySelector('p.message[role="status"]')?.textContent?.trim() ?? null; + } + + const oneRoute = page({ + config: [{ id: 'r1', path: 'a', fullPath: 'a', kind: 'component', component: 'A' }], + }); + const matchedA = { matched: true, chain: oneRoute.config!, params: {}, notes: [], nearest: [] }; + + it('asks for a URL when Predict or Probe is used with an empty box', async () => { + const calls: string[] = []; + const fixture = mount(RouteTree, oneRoute, (name) => { + calls.push(name); + return Promise.resolve(null); + }); + await settle(fixture); + await submitUrl(fixture, ' '); + expect(message(fixture)).toBe('Type a URL to predict, for example /users/42.'); + expect(document.activeElement?.id).toBe('test-url'); + + button(fixture, 'Probe in app').click(); + await settle(fixture); + expect(message(fixture)).toBe('Type a URL to probe, for example /users/42.'); + expect(calls).toEqual([]); + }); + + it('labels a Predict result with its URL and clears it when the URL changes', async () => { + const fixture = mount(RouteTree, oneRoute, () => Promise.resolve(matchedA)); + await settle(fixture); + await submitUrl(fixture, '/a'); + const result = el(fixture).querySelector('.result'); + expect(result?.textContent?.replace(/\s+/g, ' ')).toContain('Result for /a:'); + + const input = el(fixture).querySelector('#test-url')!; + input.value = '/b'; + input.dispatchEvent(new Event('input')); + await settle(fixture); + expect(el(fixture).querySelector('.result')).toBeNull(); + }); + + it('drops Predict and Probe answers for a URL that was edited since', async () => { + const answers: ((value: unknown) => void)[] = []; + const fixture = mount( + RouteTree, + oneRoute, + () => new Promise((resolve) => answers.push(resolve)), + ); + await settle(fixture); + await submitUrl(fixture, '/a'); + button(fixture, 'Probe in app').click(); + await settle(fixture); + expect(answers).toHaveLength(2); + + const input = el(fixture).querySelector('#test-url')!; + input.value = '/b'; + input.dispatchEvent(new Event('input')); + answers[0](matchedA); + answers[1]({ matched: true }); + await settle(fixture); + expect(el(fixture).querySelector('.result')).toBeNull(); + expect(message(fixture)).toBeNull(); + }); + it('offers no Go button for a custom matcher route', async () => { const fixture = mount( RouteTree, @@ -406,6 +469,40 @@ describe('RouteTimeline guard recording', () => { }); }); +describe('RouteTimeline guard recording across pages', () => { + function checkbox(fixture: ComponentFixture) { + return Array.from(el(fixture).querySelectorAll('input[type=checkbox]')).find( + (input) => input.parentElement?.textContent?.includes('Record each guard'), + )!; + } + + it('does not let a late answer from the page left behind undo a newer toggle', async () => { + const answers: Array<(value: unknown) => void> = []; + const fixture = mount( + RouteTimeline, + page({ pageId: 'a', instrumented: false }), + () => new Promise((resolve) => answers.push(resolve)), + ); + await settle(fixture); + checkbox(fixture).click(); + + fixture.componentRef.setInput('page', page({ pageId: 'b', instrumented: false })); + await settle(fixture); + const input = checkbox(fixture); + if (input.checked) input.click(); + input.click(); + expect(input.checked).toBe(true); + + answers[0]({ ok: true }); + await settle(fixture); + expect(input.checked).toBe(true); + + for (const answer of answers.slice(1)) answer({ ok: true }); + await settle(fixture); + expect(input.checked).toBe(true); + }); +}); + describe('RouteTimeline replay', () => { it('turns Replay off for redacted and absolute URLs, with a note', async () => { const calls: unknown[] = []; @@ -439,6 +536,91 @@ describe('RouteTimeline replay', () => { }); }); +describe('RouteTimeline page switch', () => { + const pageA = page({ pageId: 'a', navigations: [nav(7, '/users/1'), nav(8, '/x')] }); + const pageB = page({ pageId: 'b', navigations: [nav(2, '/users/2')] }); + + it("drops page A's message and filters after switching to page B", async () => { + const fixture = mount(RouteTimeline, pageA, () => + Promise.resolve({ replay: { outcome: 'succeeded' }, same: true }), + ); + await settle(fixture); + button(fixture, 'Replay navigation 7').click(); + const filter = el(fixture).querySelector('input[aria-label^="Filter"]')!; + filter.value = 'users'; + filter.dispatchEvent(new Event('input')); + const only = Array.from( + el(fixture).querySelectorAll('input[type=checkbox]'), + ).find((input) => input.parentElement?.textContent?.includes('Only problems'))!; + only.click(); + await settle(fixture); + expect(el(fixture).textContent).toContain('Replay of #7: succeeded'); + + fixture.componentRef.setInput('page', pageB); + await settle(fixture); + expect(el(fixture).querySelector('p.message[role="status"]')).toBeNull(); + expect(filter.value).toBe(''); + expect(only.checked).toBe(false); + expect(fixture.componentInstance.filter()).toBe(''); + expect(fixture.componentInstance.onlyProblems()).toBe(false); + }); + + it('ignores a replay of page A that finishes after the switch', async () => { + const answers: ((value: unknown) => void)[] = []; + const fixture = mount( + RouteTimeline, + pageA, + () => new Promise((resolve) => answers.push(resolve)), + ); + await settle(fixture); + button(fixture, 'Replay navigation 7').click(); + await settle(fixture); + expect(el(fixture).textContent).toContain('Replaying #7'); + + fixture.componentRef.setInput('page', pageB); + await settle(fixture); + expect(el(fixture).textContent).not.toContain('Replaying #7'); + answers[0]({ replay: { outcome: 'succeeded' }, same: true }); + await settle(fixture); + expect(el(fixture).textContent).not.toContain('Replay of #7'); + expect(el(fixture).querySelector('p.message[role="status"]')).toBeNull(); + }); +}); + +describe('RouteCurrent page switch', () => { + const pending = (pageId: string) => + page({ pageId, snapshot: { ...page().snapshot!, pending: { id: 4, url: '/slow' } } }); + + it("drops page A's Abort message after switching to page B", async () => { + const fixture = mount(RouteCurrent, pending('a'), () => Promise.resolve({ aborted: 4 })); + await settle(fixture); + button(fixture, 'Abort').click(); + await settle(fixture); + expect(el(fixture).textContent).toContain('Aborted navigation #4.'); + + fixture.componentRef.setInput('page', pending('b')); + await settle(fixture); + expect(el(fixture).textContent).not.toContain('Aborted navigation #4.'); + }); + + it('ignores an Abort answer for page A that arrives after the switch', async () => { + const answers: ((value: unknown) => void)[] = []; + const fixture = mount( + RouteCurrent, + pending('a'), + () => new Promise((resolve) => answers.push(resolve)), + ); + await settle(fixture); + button(fixture, 'Abort').click(); + await settle(fixture); + fixture.componentRef.setInput('page', pending('b')); + await settle(fixture); + answers[0]({ aborted: 4 }); + await settle(fixture); + expect(el(fixture).textContent).not.toContain('Aborted navigation #4.'); + }); +}); + describe('RouteCurrent', () => { it('reports an unreachable page on Abort instead of #undefined', async () => { const fixture = mount( diff --git a/app/src/pages/route-current.ts b/app/src/pages/route-current.ts index 85eac2cd..2fcc0f2c 100644 --- a/app/src/pages/route-current.ts +++ b/app/src/pages/route-current.ts @@ -1,4 +1,4 @@ -import { Component, computed, input, output, signal } from '@angular/core'; +import { Component, computed, input, linkedSignal, output } from '@angular/core'; import { JsonPipe } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; import { actionAllowed, actionBlockedMessage, panelConfig } from '../devtools-config'; @@ -343,7 +343,10 @@ export class RouteCurrent { /** A routed component, and the page it is on, to select in the Components tab. */ readonly showComponent = output(); - readonly message = signal(''); + private readonly pageId = computed(() => this.page().pageId); + // The component stays mounted when the page picker switches pages, so the message of one page + // starts over on the next. + readonly message = linkedSignal({ source: this.pageId, computation: () => '' }); readonly rows = computed(() => { const rows: RouteRow[] = []; @@ -387,7 +390,9 @@ export class RouteCurrent { } async abort() { - const result = await routerAction(this.rpc(), this.page().pageId, { action: 'abort' }); + const pageId = this.pageId(); + const result = await routerAction(this.rpc(), pageId, { action: 'abort' }); + if (pageId !== this.pageId()) return; this.message.set( result['error'] ? String(result['error']) : `Aborted navigation #${result['aborted']}.`, ); diff --git a/app/src/pages/route-timeline.ts b/app/src/pages/route-timeline.ts index ef21bd69..98d6352a 100644 --- a/app/src/pages/route-timeline.ts +++ b/app/src/pages/route-timeline.ts @@ -1,4 +1,4 @@ -import { Component, computed, input, signal } from '@angular/core'; +import { Component, computed, input, linkedSignal } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; import { actionAllowed, actionBlockedMessage, panelConfig } from '../devtools-config'; import { LimitNote } from '../ui/limit-note'; @@ -577,9 +577,15 @@ export class RouteTimeline { protected readonly navigationOff = actionBlockedMessage('router'); readonly phases = PHASES; - readonly filter = signal(''); - readonly onlyProblems = signal(false); - readonly message = signal(''); + private readonly pageId = computed(() => this.page().pageId); + // The component stays mounted when the page picker switches pages, so filters and messages + // start over whenever the pageId changes. + readonly filter = linkedSignal({ source: this.pageId, computation: () => '' }); + readonly onlyProblems = linkedSignal({ + source: this.pageId, + computation: () => false, + }); + readonly message = linkedSignal({ source: this.pageId, computation: () => '' }); readonly loops = computed(() => this.page().loops ?? []); @@ -678,10 +684,22 @@ export class RouteTimeline { readonly time = time; + /** Counts instrument toggles so only the latest answer touches the checkbox. */ + private instrumentSeq = 0; + async toggleInstrument(event: Event) { const checkbox = event.target as HTMLInputElement; const on = checkbox.checked; - const result = await routerAction(this.rpc(), this.page().pageId, { action: 'instrument', on }); + const pageId = this.pageId(); + const seq = ++this.instrumentSeq; + const result = await routerAction(this.rpc(), pageId, { action: 'instrument', on }); + // A newer toggle owns the checkbox now; this late answer must not undo it. + if (seq !== this.instrumentSeq) return; + if (pageId !== this.pageId()) { + // The answer is for the page the user left; show this page's state instead. + checkbox.checked = !!this.page().instrumented; + return; + } if (result['error']) { checkbox.checked = !!this.page().instrumented; this.message.set(String(result['error'])); @@ -703,10 +721,12 @@ export class RouteTimeline { async replay(nav: NavigationRecord) { this.message.set(`Replaying #${nav.id}…`); - const result = await routerAction(this.rpc(), this.page().pageId, { + const pageId = this.pageId(); + const result = await routerAction(this.rpc(), pageId, { action: 'replay', id: nav.id, }); + if (pageId !== this.pageId()) return; if (result['error']) { this.message.set(String(result['error'])); return; @@ -718,19 +738,18 @@ export class RouteTimeline { } async copy(nav: NavigationRecord) { - const text = await routerCall(this.rpc(), 'router-export', { - pageId: this.page().pageId, - id: nav.id, - }); + const pageId = this.pageId(); + const text = await routerCall(this.rpc(), 'router-export', { pageId, id: nav.id }); + if (pageId !== this.pageId()) return; if (!text) { this.message.set('Could not build the repro.'); return; } try { await navigator.clipboard.writeText(text); - this.message.set(`Copied a markdown repro of #${nav.id}.`); + if (pageId === this.pageId()) this.message.set(`Copied a markdown repro of #${nav.id}.`); } catch { - this.message.set('The clipboard is not available here.'); + if (pageId === this.pageId()) this.message.set('The clipboard is not available here.'); } } diff --git a/app/src/pages/route-tree.ts b/app/src/pages/route-tree.ts index d00c3198..32d60025 100644 --- a/app/src/pages/route-tree.ts +++ b/app/src/pages/route-tree.ts @@ -1,4 +1,12 @@ -import { Component, computed, input, linkedSignal, signal } from '@angular/core'; +import { + Component, + ElementRef, + computed, + input, + linkedSignal, + signal, + viewChild, +} from '@angular/core'; import { JsonPipe } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; import { actionAllowed, actionBlockedMessage } from '../devtools-config'; @@ -28,6 +36,12 @@ interface MatchResult { nearest: string[]; } +/** A Predict answer and the URL it was asked for. */ +interface TestedUrl { + url: string; + result: MatchResult; +} + @Component({ selector: 'app-route-tree', imports: [JsonPipe], @@ -35,12 +49,13 @@ interface MatchResult {