Skip to content

Commit 9a16a81

Browse files
Merge branch 'search-sk-sdd' into chore/sc-46034/search-results-analtyics
2 parents b6908b2 + 804e275 commit 9a16a81

42 files changed

Lines changed: 1703 additions & 204 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/arch_docs/search_improvements_arch.md

Lines changed: 107 additions & 41 deletions
Large diffs are not rendered by default.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
import { goToPageWithLang, hideAllModalsAndPopups } from '../utils';
3+
import { LANGUAGES, t } from '../globals';
4+
import { PageManager } from '../pages/pageManager';
5+
import { MODULE_URLS } from '../constants';
6+
7+
/**
8+
* Link Explorer (`/explore`) hover, tooltip, and drill-down behavior.
9+
*
10+
* Regression coverage for the Chrome 144 breakage of `moveToFront()` in
11+
* `static/js/explore.js`. Chrome 144 shipped "boundary event dispatch tracks
12+
* node removal" (Interop 2025): once the element under the pointer is removed
13+
* from the DOM, the browser stops delivering mouseout / mousemove / click to
14+
* it. The old `moveToFront()` raised the hovered arc with
15+
* `parentNode.appendChild(this)` — a remove-and-reinsert — so every hovered arc
16+
* went deaf, leaving highlights stuck on, the tooltip parked at its default
17+
* (0,0) position, and clicks doing nothing.
18+
*
19+
* The four tests split into two kinds, and the difference matters when reading
20+
* a failure:
21+
*
22+
* LEX-001 asserts the underlying invariant — the hovered arc is never
23+
* detached. It fails against unfixed code on ANY Chromium version, because
24+
* the detach is a DOM fact rather than an event-dispatch consequence.
25+
*
26+
* LEX-002 / LEX-003 / LEX-004 assert the user-visible symptoms, and they do
27+
* NOT catch this defect — measured, not assumed. Replayed against the
28+
* known-unfixed production bundle they fail under playwright-core 1.56 with
29+
* Chromium 145, but pass under 1.62 with Chromium 145 or 151, at every
30+
* `mouse.move` step count tried (1, 5, 25). The detach still happens there
31+
* (LEX-001 still fails); Playwright's synthetic input simply re-establishes
32+
* the hover chain that a real pointer does not, so the symptom never
33+
* surfaces. Treat them as coverage of ordinary hover / tooltip / drill-down
34+
* behavior — they would catch a highlight that never applies or a click
35+
* handler that vanishes — and never as a reason to weaken LEX-001.
36+
*
37+
* Filed upstream as microsoft/playwright#42270, with a minimal repro.
38+
*/
39+
test.describe('Library Link Explorer — English', () => {
40+
let page: Page;
41+
let pm: PageManager;
42+
43+
test.beforeEach(async ({ context }) => {
44+
// The explorer fetches every book-to-book link count and renders ~1,000 SVG
45+
// arcs before anything is hoverable, which outruns the default budget on a
46+
// cold cauldron.
47+
test.setTimeout(t(120000));
48+
49+
page = await goToPageWithLang(context, `${MODULE_URLS.EN.LIBRARY}/explore`, LANGUAGES.EN);
50+
pm = new PageManager(page, LANGUAGES.EN);
51+
await hideAllModalsAndPopups(page);
52+
await pm.onLinkExplorer().waitForArcsRendered();
53+
});
54+
55+
test('LEX-001: hovering an arc never detaches it from the DOM', async () => {
56+
const detachments = await pm.onLinkExplorer().detachmentsWhileHoveringAnArc();
57+
58+
expect(
59+
detachments,
60+
'the hovered arc was removed from #links; a browser stops sending ' +
61+
'mouseout/mousemove/click to a node detached under the pointer, which ' +
62+
'strands the highlight and the tooltip and kills the click',
63+
).toBe(0);
64+
});
65+
66+
test('LEX-002: highlight clears when the pointer leaves the arc', async () => {
67+
await pm.onLinkExplorer().hoverAnArc();
68+
expect(await pm.onLinkExplorer().activeArcCount()).toBeGreaterThan(0);
69+
70+
await pm.onLinkExplorer().movePointerClearOfArcs();
71+
72+
await expect
73+
.poll(() => pm.onLinkExplorer().activeArcCount(), { timeout: t(10000) })
74+
.toBe(0);
75+
});
76+
77+
test('LEX-003: tooltip hides when the pointer leaves the arc', async () => {
78+
await pm.onLinkExplorer().hoverAnArc();
79+
await expect
80+
.poll(() => pm.onLinkExplorer().tooltipIsShowing(), { timeout: t(10000) })
81+
.toBe(true);
82+
83+
await pm.onLinkExplorer().movePointerClearOfArcs();
84+
85+
await expect
86+
.poll(() => pm.onLinkExplorer().tooltipIsShowing(), { timeout: t(10000) })
87+
.toBe(false);
88+
});
89+
90+
test('LEX-004: clicking an arc drills into the two books it joins', async () => {
91+
await pm.onLinkExplorer().hoverAnArc();
92+
await pm.onLinkExplorer().clickHoveredArc();
93+
94+
// _getHistory() appends one path segment per opened book (explore.js:1139),
95+
// so drilling into an arc yields /explore/<Book>/<Book>.
96+
await expect(page).toHaveURL(/\/explore\/[^/]+\/[^/]+/, { timeout: t(15000) });
97+
});
98+
});
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { expect, Page, Locator } from '@playwright/test';
2+
import { HelperBase } from './helperBase';
3+
import { hideAllModalsAndPopups } from '../utils';
4+
import { t } from '../globals';
5+
6+
/**
7+
* Link Explorer POM — `/explore` and `/explore-<Cat>-and-<Cat>`.
8+
*
9+
* Source: `static/js/explore.js` (D3 v3, webpack entry `exploreConfig`). The
10+
* page is one generated `<svg>` with no ARIA roles, so the locators below are
11+
* anchored on the class/id names the D3 code assigns:
12+
* `.link` book-to-book arcs (explore.js:661)
13+
* `#links` arc group; starts `display:none` and is revealed by a 1s
14+
* transition in `showBookLinks()` (explore.js:721)
15+
* `#main-toolip` the shared tooltip group (explore.js:216) — the id is
16+
* misspelled in the source; match it, don't "fix" it here
17+
*
18+
* Two constraints shape how this POM drives the mouse:
19+
*
20+
* 1. `locator.hover()` cannot reach an arc. The arcs are thin curved strokes,
21+
* so the bounding-box centre Playwright would aim at usually falls off the
22+
* stroke and hits whatever is behind it. Instead we ask the browser for a
23+
* point that lies ON the path (`getPointAtLength` + `getScreenCTM`) and
24+
* confirm with `elementFromPoint` before moving.
25+
* 2. The hover must be a real pointer movement, never a synthetic
26+
* `dispatchEvent`. `moveToFront()` branches on `:hover`, which only a real
27+
* pointer sets — a synthetic event would silently exercise the wrong path.
28+
*/
29+
export class LinkExplorerPage extends HelperBase {
30+
constructor(page: Page, language: string) {
31+
super(page, language);
32+
}
33+
34+
// --- Locators ---
35+
36+
private get arcs(): Locator {
37+
return this.page.locator('.link');
38+
}
39+
40+
private get activeArcs(): Locator {
41+
return this.page.locator('.link.active');
42+
}
43+
44+
private get tooltip(): Locator {
45+
return this.page.locator('#main-toolip');
46+
}
47+
48+
// --- Navigation ---
49+
50+
/** Visit the explorer, dismiss overlays, and wait for the arcs to render. */
51+
async open(baseUrl: string, path: string = '/explore'): Promise<void> {
52+
await this.page.goto(`${baseUrl}${path}`);
53+
await hideAllModalsAndPopups(this.page);
54+
await this.waitForArcsRendered();
55+
}
56+
57+
/**
58+
* Gate on the arcs actually being on screen — `#links` flips to
59+
* `display="inline"` only after `showBookLinks()`'s transition, and the arc
60+
* data arrives from `/api/counts/links/...`.
61+
*/
62+
async waitForArcsRendered(): Promise<void> {
63+
try {
64+
await this.page.waitForFunction(
65+
() =>
66+
document.querySelector('#links')?.getAttribute('display') === 'inline' &&
67+
document.querySelectorAll('.link').length > 0,
68+
undefined,
69+
{ timeout: t(60000) },
70+
);
71+
} catch {
72+
// The most common cause is an environment whose persistent link-count
73+
// cache was never warmed, which the API reports rather than the DOM.
74+
// Keep this diagnostic best-effort: if the wait blew the test budget the
75+
// page is already closing, and the original failure is the useful one.
76+
let apiSaid: string;
77+
try {
78+
apiSaid = await this.page.evaluate(async () => {
79+
const r = await fetch('/api/counts/links/Tanakh/Bavli');
80+
return (await r.text()).slice(0, 200);
81+
});
82+
} catch (e) {
83+
apiSaid = `could not be read (${(e as Error).message})`;
84+
}
85+
throw new Error(
86+
`Link Explorer arcs never rendered.\n` +
87+
`GET /api/counts/links/Tanakh/Bavli returned: ${apiSaid}\n` +
88+
`If that reads {"error": "No data available"}, this environment's link-count ` +
89+
`cache is empty. link_count_api is served only from the persistent cache ` +
90+
`(reader/views.py, @django_cache(default_on_miss=True)) and is warmed by the ` +
91+
`weekly {deployEnv}-regenerate CronJob. On a fresh cauldron, run it once:\n` +
92+
` kubectl create job --from=cronjob/<deployEnv>-regenerate <deployEnv>-regen-now -n default`,
93+
);
94+
}
95+
}
96+
97+
// --- Arc geometry ---
98+
99+
/**
100+
* Pick an arc the mouse can actually land on and remember it page-side as
101+
* `window.__lexTarget`, so later assertions can talk about that exact node.
102+
*
103+
* Widest arcs first (stroke-width runs 1px–70px, explore.js:1056), sampling a
104+
* few points along each, and accepting only a point that is inside the
105+
* viewport AND where `elementFromPoint` returns that same arc — which
106+
* guarantees the pointer will hit it rather than an overlay or a sibling arc.
107+
*/
108+
private async selectHoverableArc(): Promise<{ x: number; y: number }> {
109+
const point = await this.page.evaluate(() => {
110+
const arcs = Array.from(document.querySelectorAll('.link')) as SVGPathElement[];
111+
const widestFirst = arcs.sort(
112+
(a, b) =>
113+
parseFloat(b.getAttribute('stroke-width') || '0') -
114+
parseFloat(a.getAttribute('stroke-width') || '0'),
115+
);
116+
117+
for (const arc of widestFirst.slice(0, 25)) {
118+
const matrix = arc.getScreenCTM();
119+
if (!matrix) continue;
120+
const total = arc.getTotalLength();
121+
122+
for (const fraction of [0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75]) {
123+
const p = arc.getPointAtLength(total * fraction);
124+
const x = matrix.a * p.x + matrix.c * p.y + matrix.e;
125+
const y = matrix.b * p.x + matrix.d * p.y + matrix.f;
126+
const onScreen =
127+
x > 0 && y > 0 && x < window.innerWidth - 1 && y < window.innerHeight - 1;
128+
if (!onScreen) continue;
129+
if (document.elementFromPoint(x, y) !== arc) continue;
130+
131+
(window as any).__lexTarget = arc;
132+
return { x, y };
133+
}
134+
}
135+
return null;
136+
});
137+
138+
expect(
139+
point,
140+
'no rendered arc exposed a hoverable point inside the viewport',
141+
).not.toBeNull();
142+
return point as { x: number; y: number };
143+
}
144+
145+
/** A point inside the visualization with no arc under it (above the book bars). */
146+
private async pointClearOfArcs(): Promise<{ x: number; y: number }> {
147+
const point = await this.page.evaluate(() => {
148+
const svg = document.querySelector('#linkExplorerPage svg');
149+
const box = svg?.getBoundingClientRect();
150+
const candidates: Array<[number, number]> = box
151+
? [
152+
[box.left + box.width / 2, box.top + 6],
153+
[box.left + 6, box.top + 6],
154+
[box.right - 6, box.top + 6],
155+
[4, 4],
156+
]
157+
: [[4, 4]];
158+
159+
for (const [x, y] of candidates) {
160+
const el = document.elementFromPoint(x, y);
161+
if (el && !el.classList.contains('link') && !el.classList.contains('preciseLink')) {
162+
return { x, y };
163+
}
164+
}
165+
return null;
166+
});
167+
168+
expect(point, 'could not find a pointer position clear of the arcs').not.toBeNull();
169+
return point as { x: number; y: number };
170+
}
171+
172+
// --- Actions ---
173+
174+
/** Move the real mouse onto the widest reachable arc and wait for it to light up. */
175+
async hoverAnArc(): Promise<void> {
176+
const point = await this.selectHoverableArc();
177+
await this.page.mouse.move(point.x, point.y);
178+
await this.page.waitForFunction(
179+
() => (window as any).__lexTarget?.classList.contains('active') === true,
180+
undefined,
181+
{ timeout: t(10000) },
182+
);
183+
}
184+
185+
/** Move the pointer away from every arc (and off the tooltip). */
186+
async movePointerClearOfArcs(): Promise<void> {
187+
const point = await this.pointClearOfArcs();
188+
await this.page.mouse.move(point.x, point.y);
189+
}
190+
191+
/** Click the arc currently under the pointer, drilling into the two books it joins. */
192+
async clickHoveredArc(): Promise<void> {
193+
const box = await this.page.evaluate(() => {
194+
const arc = (window as any).__lexTarget as SVGPathElement | undefined;
195+
if (!arc) return null;
196+
const m = arc.getScreenCTM();
197+
if (!m) return null;
198+
const p = arc.getPointAtLength(arc.getTotalLength() / 2);
199+
return { x: m.a * p.x + m.c * p.y + m.e, y: m.b * p.x + m.d * p.y + m.f };
200+
});
201+
expect(box, 'hoverAnArc() must run before clickHoveredArc()').not.toBeNull();
202+
await this.page.mouse.click(box!.x, box!.y);
203+
}
204+
205+
// --- Observations ---
206+
207+
async activeArcCount(): Promise<number> {
208+
return this.activeArcs.count();
209+
}
210+
211+
async tooltipIsShowing(): Promise<boolean> {
212+
return this.tooltip.evaluate(el => getComputedStyle(el).display !== 'none');
213+
}
214+
215+
/**
216+
* Hover an arc while watching whether the DOM *detaches that same arc*, and
217+
* return how many times it was removed from `#links`.
218+
*
219+
* This is the version-independent statement of the invariant behind the
220+
* Chrome 144 fix: raising the hovered arc must not take it out of the DOM,
221+
* because a browser stops delivering mouseout/mousemove/click to a node that
222+
* was removed while under the pointer. Sibling arcs legitimately move (the
223+
* fix reorders them), so only the identity of the hovered node is watched.
224+
*/
225+
async detachmentsWhileHoveringAnArc(): Promise<number> {
226+
const point = await this.selectHoverableArc();
227+
228+
await this.page.evaluate(() => {
229+
(window as any).__lexDetachments = 0;
230+
const target = (window as any).__lexTarget as Node;
231+
const group = document.querySelector('#links') as Node;
232+
const observer = new MutationObserver(records => {
233+
for (const record of records) {
234+
for (const removed of Array.from(record.removedNodes)) {
235+
if (removed === target) (window as any).__lexDetachments++;
236+
}
237+
}
238+
});
239+
observer.observe(group, { childList: true });
240+
(window as any).__lexObserver = observer;
241+
});
242+
243+
await this.page.mouse.move(point.x, point.y);
244+
await this.page.waitForFunction(
245+
() => (window as any).__lexTarget?.classList.contains('active') === true,
246+
undefined,
247+
{ timeout: t(10000) },
248+
);
249+
250+
return this.page.evaluate(() => {
251+
(window as any).__lexObserver?.disconnect();
252+
return (window as any).__lexDetachments as number;
253+
});
254+
}
255+
}

e2e-tests/pages/pageManager.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { VoicesTopicPage } from "./voicesTopicPage"
2424
import { LibraryTopicPage } from "./libraryTopicPage"
2525
import { VoicesBookmarksPage } from "./voicesBookmarksPage"
2626
import { InterfaceStringsPage } from "./interfaceStringsPage"
27+
import { LinkExplorerPage } from "./linkExplorerPage"
2728

2829

2930
export class PageManager {
@@ -51,6 +52,7 @@ export class PageManager {
5152
private readonly libraryTopicPage: LibraryTopicPage
5253
private readonly voicesBookmarksPage: VoicesBookmarksPage
5354
private readonly interfaceStringsPage: InterfaceStringsPage
55+
private readonly linkExplorerPage: LinkExplorerPage
5456

5557

5658

@@ -79,6 +81,7 @@ export class PageManager {
7981
this.libraryTopicPage = new LibraryTopicPage(page, language)
8082
this.voicesBookmarksPage = new VoicesBookmarksPage(page, language)
8183
this.interfaceStringsPage = new InterfaceStringsPage(page, language)
84+
this.linkExplorerPage = new LinkExplorerPage(page, language)
8285

8386
}
8487

@@ -175,5 +178,9 @@ export class PageManager {
175178
return this.interfaceStringsPage
176179
}
177180

181+
onLinkExplorer() {
182+
return this.linkExplorerPage
183+
}
184+
178185

179186
}

0 commit comments

Comments
 (0)