|
| 1 | +import { TestBed } from '@angular/core/testing'; |
| 2 | +import type { DevframeRpcClient } from 'devframe/client'; |
| 3 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 4 | +import { ComponentTree } from '../pages/component-tree'; |
| 5 | +import { |
| 6 | + countText, |
| 7 | + directiveHostLabel, |
| 8 | + filterAnnouncement, |
| 9 | + filterTree, |
| 10 | + withoutDirectiveHosts, |
| 11 | + type TreeNode, |
| 12 | +} from '../pages/component-tree-state'; |
| 13 | + |
| 14 | +function client(pages: Record<string, unknown>) { |
| 15 | + const calls: { name: string; args: unknown[] }[] = []; |
| 16 | + const rpc = { |
| 17 | + call: (name: string, ...args: unknown[]) => { |
| 18 | + calls.push({ name, args }); |
| 19 | + return Promise.resolve([]); |
| 20 | + }, |
| 21 | + callEvent: () => Promise.resolve(), |
| 22 | + sharedState: (name: string) => |
| 23 | + Promise.resolve({ |
| 24 | + value: () => (name === 'component-tree' ? { pages } : null), |
| 25 | + on: () => () => {}, |
| 26 | + }), |
| 27 | + }; |
| 28 | + return { |
| 29 | + calls, |
| 30 | + client: { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient, |
| 31 | + }; |
| 32 | +} |
| 33 | + |
| 34 | +const roots: TreeNode[] = [ |
| 35 | + { |
| 36 | + id: 'c1', |
| 37 | + name: 'App', |
| 38 | + tag: 'app-root', |
| 39 | + hydration: 'hydrated', |
| 40 | + children: [ |
| 41 | + { |
| 42 | + id: 'c2', |
| 43 | + name: 'RouterLink', |
| 44 | + tag: 'a', |
| 45 | + kind: 'directive', |
| 46 | + directives: ['RouterLink'], |
| 47 | + attributes: ['routerLink'], |
| 48 | + children: [ |
| 49 | + { id: 'c3', name: 'Badge', tag: 'app-badge', hydration: 'hydrated', children: [] }, |
| 50 | + ], |
| 51 | + }, |
| 52 | + { |
| 53 | + id: 'c4', |
| 54 | + name: 'TripMap', |
| 55 | + tag: 'app-map', |
| 56 | + hydration: 'skipped', |
| 57 | + children: [ |
| 58 | + { |
| 59 | + id: 'c5', |
| 60 | + name: 'MatTooltip', |
| 61 | + tag: 'button', |
| 62 | + kind: 'directive', |
| 63 | + directives: ['MatTooltip'], |
| 64 | + attributes: ['matTooltip'], |
| 65 | + children: [], |
| 66 | + }, |
| 67 | + ], |
| 68 | + }, |
| 69 | + { id: 'c6', name: 'Price', tag: 'app-price', hydration: 'mismatched', children: [] }, |
| 70 | + ], |
| 71 | + }, |
| 72 | +]; |
| 73 | + |
| 74 | +const hydration = { |
| 75 | + hydrated: true, |
| 76 | + counts: { hydrated: 2, skipped: 1, mismatched: 1, client: 0, dehydratedBlocks: 1 }, |
| 77 | + mismatches: [{ id: 'c6', name: 'Price', tag: 'app-price', expected: '<div>', actual: '<span>' }], |
| 78 | +}; |
| 79 | + |
| 80 | +const page = (extra: Record<string, unknown> = {}) => ({ |
| 81 | + pageId: 'p1', |
| 82 | + roots, |
| 83 | + count: 4, |
| 84 | + directiveHostCount: 2, |
| 85 | + detail: null, |
| 86 | + reportedAt: 1000, |
| 87 | + hydration, |
| 88 | + ...extra, |
| 89 | +}); |
| 90 | + |
| 91 | +async function render(value: unknown) { |
| 92 | + const fixture = TestBed.createComponent(ComponentTree); |
| 93 | + const rpc = client({ p1: value }); |
| 94 | + fixture.componentRef.setInput('rpc', rpc.client); |
| 95 | + for (let i = 0; i < 3; i++) { |
| 96 | + await new Promise((resolve) => setTimeout(resolve)); |
| 97 | + await fixture.whenStable(); |
| 98 | + } |
| 99 | + const el = fixture.nativeElement as HTMLElement; |
| 100 | + const settle = async () => { |
| 101 | + fixture.detectChanges(); |
| 102 | + await fixture.whenStable(); |
| 103 | + }; |
| 104 | + return { fixture, el, calls: rpc.calls, settle }; |
| 105 | +} |
| 106 | + |
| 107 | +const rowIds = (el: HTMLElement) => |
| 108 | + Array.from(el.querySelectorAll<HTMLElement>('.row')).map((row) => row.dataset['id']); |
| 109 | + |
| 110 | +describe('tree search and directive hosts (state)', () => { |
| 111 | + it('filters with tag, directive and text tokens, keeping ancestors', () => { |
| 112 | + const { nodes, matches } = filterTree(roots, '<button> [matTooltip]'); |
| 113 | + expect(matches).toBe(1); |
| 114 | + expect(nodes[0].children.map((n) => n.id)).toEqual(['c4']); |
| 115 | + expect(nodes[0].children[0].children.map((n) => n.id)).toEqual(['c5']); |
| 116 | + expect(filterTree(roots, '<app-map>').matches).toBe(1); |
| 117 | + expect(filterTree(roots, '[routerLink] badge').matches).toBe(0); |
| 118 | + expect(filterTree(roots, 'price').matches).toBe(1); |
| 119 | + }); |
| 120 | + |
| 121 | + it('lifts the children of hidden directive hosts to their parent', () => { |
| 122 | + const shown = withoutDirectiveHosts(roots); |
| 123 | + expect(shown[0].children.map((n) => n.id)).toEqual(['c3', 'c4', 'c6']); |
| 124 | + expect(shown[0].children[1].children).toEqual([]); |
| 125 | + }); |
| 126 | + |
| 127 | + it('labels a directive host by its attributes, else its classes, and counts hosts apart', () => { |
| 128 | + expect(directiveHostLabel(roots[0].children[0])).toBe('[routerLink]'); |
| 129 | + expect( |
| 130 | + directiveHostLabel({ |
| 131 | + id: 'x', |
| 132 | + name: 'Foo', |
| 133 | + tag: 'div', |
| 134 | + directives: ['Highlight'], |
| 135 | + children: [], |
| 136 | + }), |
| 137 | + ).toBe('[Highlight]'); |
| 138 | + expect(countText(4, 0, false, false, 2)).toBe('4 instances · 2 directive hosts'); |
| 139 | + expect(countText(4, 1, true, false, 2)).toBe('1 of 6'); |
| 140 | + // With directive hosts shown, the filter result counts rows, not instances. |
| 141 | + expect(filterAnnouncement(4, 1, 'tip', false, 2)).toBe('1 of 6 rows match.'); |
| 142 | + expect(filterAnnouncement(4, 1, 'tip')).toBe('1 of 4 instances match.'); |
| 143 | + }); |
| 144 | +}); |
| 145 | + |
| 146 | +describe('component tree hydration and directive hosts', () => { |
| 147 | + beforeEach(() => { |
| 148 | + vi.stubGlobal('CSS', { escape: (value: string) => value }); |
| 149 | + Element.prototype.scrollIntoView ??= () => {}; |
| 150 | + try { |
| 151 | + localStorage.clear(); |
| 152 | + } catch { |
| 153 | + // no storage in this environment |
| 154 | + } |
| 155 | + }); |
| 156 | + afterEach(() => { |
| 157 | + vi.unstubAllGlobals(); |
| 158 | + document.body.innerHTML = ''; |
| 159 | + }); |
| 160 | + |
| 161 | + it('shows a hydration badge with text on each component row', async () => { |
| 162 | + const { el } = await render(page()); |
| 163 | + const badges = Array.from(el.querySelectorAll('.row .hyd')).map((b) => |
| 164 | + b.textContent!.replace(/\s+/g, ' ').trim(), |
| 165 | + ); |
| 166 | + expect(badges).toEqual([ |
| 167 | + 'Hydration: Hydrated', |
| 168 | + 'Hydration: Hydrated', |
| 169 | + 'Hydration: Skipped', |
| 170 | + 'Hydration: Mismatch', |
| 171 | + ]); |
| 172 | + expect(el.querySelector('.hyd-mismatched')).not.toBeNull(); |
| 173 | + }); |
| 174 | + |
| 175 | + it('shows directive hosts as their own rows, hidden by the toolbar toggle', async () => { |
| 176 | + const { el, settle } = await render(page()); |
| 177 | + const host = el.querySelector('.row[data-id="c2"]')!; |
| 178 | + expect(host.querySelector('.host-tag')!.textContent).toBe('<a>'); |
| 179 | + expect(host.querySelector('.host-dirs')!.textContent!.trim()).toBe('[routerLink]'); |
| 180 | + expect(host.querySelector('.sr-only')!.textContent).toBe('directive host'); |
| 181 | + expect(el.querySelectorAll('.row.dir-host')).toHaveLength(2); |
| 182 | + expect(el.querySelector('.count')!.textContent).toContain('4 instances · 2 directive hosts'); |
| 183 | + |
| 184 | + const toggle = Array.from(el.querySelectorAll<HTMLButtonElement>('.tree-action')).find((b) => |
| 185 | + b.textContent!.includes('Directive hosts'), |
| 186 | + )!; |
| 187 | + expect(toggle.getAttribute('aria-pressed')).toBe('true'); |
| 188 | + toggle.click(); |
| 189 | + await settle(); |
| 190 | + expect(toggle.getAttribute('aria-pressed')).toBe('false'); |
| 191 | + expect(el.querySelectorAll('.row.dir-host')).toHaveLength(0); |
| 192 | + expect(rowIds(el)).toEqual(['c1', 'c3', 'c4', 'c6']); |
| 193 | + expect(localStorage.getItem('pangular:components:directive-hosts')).toBe('hidden'); |
| 194 | + }); |
| 195 | + |
| 196 | + it('filters the tree with the search syntax', async () => { |
| 197 | + const { el, fixture, settle } = await render(page()); |
| 198 | + fixture.componentInstance.setFilter('<button> [matTooltip]'); |
| 199 | + await settle(); |
| 200 | + expect(rowIds(el)).toEqual(['c1', 'c4', 'c5']); |
| 201 | + expect(el.querySelector('.count')!.textContent!.trim()).toBe('1 of 6'); |
| 202 | + expect(fixture.componentInstance.announcement()).toBe('1 of 6 rows match.'); |
| 203 | + }); |
| 204 | + |
| 205 | + it('selects a directive host locally and offers its owning component', async () => { |
| 206 | + const { el, calls, settle } = await render(page()); |
| 207 | + const row = el.querySelector<HTMLElement>('.row[data-id="c5"]')!; |
| 208 | + row.click(); |
| 209 | + await settle(); |
| 210 | + expect(calls.some((c) => c.name === 'select-component')).toBe(false); |
| 211 | + const detail = el.querySelector('.detail')!; |
| 212 | + expect(detail.querySelector('.badge')!.textContent).toBe('directive host'); |
| 213 | + expect(detail.textContent).toContain('MatTooltip'); |
| 214 | + const owner = detail.querySelector<HTMLButtonElement>('.owner-action button')!; |
| 215 | + expect(owner.textContent!.trim()).toBe('Show TripMap'); |
| 216 | + owner.focus(); |
| 217 | + owner.click(); |
| 218 | + await settle(); |
| 219 | + expect(el.querySelector('.row.selected')!.getAttribute('data-id')).toBe('c4'); |
| 220 | + // The button goes away with the host's detail, so focus moves to the owner's row. |
| 221 | + expect(document.activeElement).not.toBe(document.body); |
| 222 | + expect(document.activeElement?.getAttribute('data-id')).toBe('c4'); |
| 223 | + expect(document.activeElement?.getAttribute('tabindex')).toBe('0'); |
| 224 | + }); |
| 225 | + |
| 226 | + it('reaches directive host rows with the arrow keys', async () => { |
| 227 | + const { el, settle } = await render(page()); |
| 228 | + const tree = el.querySelector<HTMLElement>('[role="tree"]')!; |
| 229 | + el.querySelector<HTMLElement>('.row[data-id="c1"]')!.focus(); |
| 230 | + tree.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); |
| 231 | + await new Promise((resolve) => queueMicrotask(() => resolve(null))); |
| 232 | + await settle(); |
| 233 | + expect(document.activeElement?.getAttribute('data-id')).toBe('c2'); |
| 234 | + expect(document.activeElement?.getAttribute('role')).toBe('treeitem'); |
| 235 | + }); |
| 236 | + |
| 237 | + it('summarizes hydration and asks the page to outline it', async () => { |
| 238 | + const { el, calls, settle } = await render(page()); |
| 239 | + const section = el.querySelector('app-hydration-section')!; |
| 240 | + const counts = Array.from(section.querySelectorAll('.counts li')).map((li) => |
| 241 | + li.textContent!.replace(/\s+/g, ' ').trim(), |
| 242 | + ); |
| 243 | + expect(counts).toEqual([ |
| 244 | + '2 hydrated', |
| 245 | + '1 skipped', |
| 246 | + '1 mismatched', |
| 247 | + '0 created on the client', |
| 248 | + '1 dehydrated @defer block', |
| 249 | + ]); |
| 250 | + expect(section.textContent).toContain('Expected <div>'); |
| 251 | + const outline = section.querySelector<HTMLButtonElement>('.outline')!; |
| 252 | + expect(outline.getAttribute('aria-pressed')).toBe('false'); |
| 253 | + outline.click(); |
| 254 | + await settle(); |
| 255 | + expect(outline.getAttribute('aria-pressed')).toBe('true'); |
| 256 | + const call = calls.find((c) => c.name === 'request-hydration-overlay')!; |
| 257 | + expect(call.args[0]).toEqual({ pageId: 'p1', on: true }); |
| 258 | + |
| 259 | + section.querySelector<HTMLButtonElement>('.mismatch')!.click(); |
| 260 | + await settle(); |
| 261 | + expect(el.querySelector('.row.selected')!.getAttribute('data-id')).toBe('c6'); |
| 262 | + }); |
| 263 | + |
| 264 | + it('says hydration does not apply to a page rendered in the browser only', async () => { |
| 265 | + const plain = (nodes: TreeNode[]): TreeNode[] => |
| 266 | + nodes.map(({ hydration: _, ...node }) => ({ ...node, children: plain(node.children) })); |
| 267 | + const { el } = await render(page({ hydration: undefined, roots: plain(roots) })); |
| 268 | + const section = el.querySelector('app-hydration-section')!; |
| 269 | + expect(section.textContent).toContain('not rendered on the server'); |
| 270 | + expect(section.querySelector('.outline')).toBeNull(); |
| 271 | + expect(el.querySelector('.row .hyd')).toBeNull(); |
| 272 | + }); |
| 273 | + |
| 274 | + it('says client hydration is off when the server HTML was not hydrated', async () => { |
| 275 | + const { el } = await render(page({ hydration: { ...hydration, hydrated: false } })); |
| 276 | + expect(el.querySelector('app-hydration-section')!.textContent).toContain( |
| 277 | + 'no component was hydrated', |
| 278 | + ); |
| 279 | + }); |
| 280 | +}); |
0 commit comments