Skip to content

Commit 0e4bf46

Browse files
authored
feat: expose window.__tolgee API for Playwright and console (#3499)
## Problem When taking screenshots with Playwright, there is no way to programmatically discover which translation keys are visible on screen and where they are positioned. This makes it impossible to automatically associate screenshots with translation keys in the Tolgee platform — users have to manually tag each screenshot with the relevant keys. ## Solution Exposes a `window.__tolgee` object (automatically set when `InContextTools`/`DevTools` plugin is active) with methods to query visible keys and their positions: - `getVisibleKeys()` — returns all keys visible in the current viewport with their bounding rects - `highlight(keyName?, ns?)` — highlights matching DOM elements (returns an unhighlight handle) - `isRunning()` — checks if the tolgee observer is active ### Usage from Playwright ```js const keys = await page.evaluate(() => window.__tolgee?.getVisibleKeys()); // → [{ keyName: "hello", keyNamespace: "", position: { x, y, width, height } }, ...] ``` ## Test plan - [x] Unit tests: 12 tests covering API setup, viewport filtering, highlight handle, and cleanup on stop - [x] Manual: verified on dev app via Playwright MCP — `getVisibleKeys()` returns correct keys with positions, viewport filtering works (9 keys full viewport → 6 keys with 400px viewport) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added project conventions guide covering branch naming, pre-commit checks, and commit/PR standards. * **New Features** * Introduced Window API exposing lightweight browser integration with capabilities to retrieve visible translation keys, highlight specific keys, and check running status. * **Tests** * Added comprehensive test coverage for Window API functionality across multiple scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent af6508b commit 0e4bf46

File tree

4 files changed

+242
-0
lines changed

4 files changed

+242
-0
lines changed

CLAUDE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Project conventions
2+
3+
## Branch naming
4+
5+
Branches follow the pattern `jancizmar/<short-description>` (e.g., `jancizmar/window-tolgee-api`).
6+
7+
## Before committing
8+
9+
Always run these checks before creating a commit:
10+
11+
1. **Prettier** — format changed files: `npx prettier --write <files>`
12+
2. **Unit tests** — run tests for the affected package, e.g.: `cd packages/web && npx jest --no-coverage`
13+
14+
## Commits and PRs
15+
16+
- Use [Conventional Commits](https://www.conventionalcommits.org/) (e.g., `feat:`, `fix:`, `chore:`, `refactor:`).
17+
- Do NOT add `Co-Authored-By` or any AI author attribution to commits.
18+
- PR descriptions must start with the user pain / problem being solved, then explain the solution.

packages/web/src/package/InContextTools.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ContextUi } from './ContextUi';
33
import { DevBackend } from './DevBackend';
44
import { InContextOptions } from './types';
55
import { ObserverPlugin } from './ObserverPlugin';
6+
import { setupWindowApi } from './WindowApi';
67

78
export const InContextTools =
89
(props?: InContextOptions): TolgeePlugin =>
@@ -18,5 +19,15 @@ export const InContextTools =
1819
if (credentials) {
1920
tolgee.overrideCredentials(credentials);
2021
}
22+
23+
let teardownWindowApi = setupWindowApi(tolgee);
24+
25+
tolgee.on('running', ({ value: isRunning }) => {
26+
teardownWindowApi();
27+
if (isRunning) {
28+
teardownWindowApi = setupWindowApi(tolgee);
29+
}
30+
});
31+
2132
return tolgee;
2233
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { TolgeeInstance, KeyPosition, Highlighter } from '@tolgee/core';
2+
import { isSSR } from './tools/isSSR';
3+
4+
export interface TolgeeWindowApi {
5+
getVisibleKeys(): KeyPosition[];
6+
highlight(keyName?: string, ns?: string): Highlighter;
7+
isRunning(): boolean;
8+
}
9+
10+
declare global {
11+
interface Window {
12+
__tolgee?: TolgeeWindowApi;
13+
}
14+
}
15+
16+
export function setupWindowApi(tolgee: TolgeeInstance): () => void {
17+
if (isSSR()) {
18+
return () => {};
19+
}
20+
21+
window.__tolgee = {
22+
getVisibleKeys() {
23+
const vw = window.innerWidth;
24+
const vh = window.innerHeight;
25+
return tolgee.findPositions().filter(({ position: p }) => {
26+
return p.x + p.width > 0 && p.y + p.height > 0 && p.x < vw && p.y < vh;
27+
});
28+
},
29+
highlight(keyName?: string, ns?: string) {
30+
return tolgee.highlight(keyName, ns);
31+
},
32+
isRunning() {
33+
return tolgee.isRunning();
34+
},
35+
};
36+
37+
return () => {
38+
delete window.__tolgee;
39+
};
40+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { TolgeeCore, TolgeeInstance } from '@tolgee/core';
2+
import { waitFor } from '@testing-library/dom';
3+
import { InContextTools } from '../InContextTools';
4+
import { setupWindowApi } from '../WindowApi';
5+
import { TOLGEE_ATTRIBUTE_NAME } from '../constants';
6+
7+
(['invisible', 'text'] as const).forEach((observerType) => {
8+
describe(`window.__tolgee API (${observerType})`, () => {
9+
let tolgee: TolgeeInstance;
10+
11+
afterEach(() => {
12+
tolgee.stop();
13+
document.body.innerHTML = '';
14+
});
15+
16+
it('is set when InContextTools is used', async () => {
17+
tolgee = TolgeeCore()
18+
.use(InContextTools())
19+
.init({
20+
language: 'en',
21+
staticData: { en: { hello: 'world' } },
22+
observerType,
23+
});
24+
await tolgee.run();
25+
expect(window.__tolgee).toBeDefined();
26+
expect(typeof window.__tolgee!.getVisibleKeys).toBe('function');
27+
expect(typeof window.__tolgee!.highlight).toBe('function');
28+
expect(typeof window.__tolgee!.isRunning).toBe('function');
29+
});
30+
31+
it('isRunning() returns true when running', async () => {
32+
tolgee = TolgeeCore()
33+
.use(InContextTools())
34+
.init({
35+
language: 'en',
36+
staticData: { en: { hello: 'world' } },
37+
observerType,
38+
});
39+
await tolgee.run();
40+
expect(window.__tolgee!.isRunning()).toBe(true);
41+
});
42+
43+
it('getVisibleKeys() returns key positions for rendered translations', async () => {
44+
tolgee = TolgeeCore()
45+
.use(InContextTools())
46+
.init({
47+
language: 'en',
48+
staticData: { en: { hello: 'world' } },
49+
observerType,
50+
});
51+
await tolgee.run();
52+
53+
document.body.innerHTML = `
54+
<span data-testid="translation">${tolgee.t({ key: 'hello' })}</span>
55+
`;
56+
57+
await waitFor(() => {
58+
expect(
59+
document
60+
.querySelector('[data-testid="translation"]')
61+
?.getAttribute(TOLGEE_ATTRIBUTE_NAME)
62+
).not.toBeFalsy();
63+
});
64+
65+
// findPositions finds the key in the DOM (jsdom returns zero-size rects)
66+
const allPositions = tolgee.findPositions();
67+
expect(allPositions.length).toBeGreaterThan(0);
68+
expect(allPositions[0]).toMatchObject({
69+
keyName: 'hello',
70+
keyNamespace: '',
71+
});
72+
expect(allPositions[0].position).toBeDefined();
73+
});
74+
75+
it('highlight() returns an unhighlight handle', async () => {
76+
tolgee = TolgeeCore()
77+
.use(InContextTools())
78+
.init({
79+
language: 'en',
80+
staticData: { en: { hello: 'world' } },
81+
observerType,
82+
});
83+
await tolgee.run();
84+
85+
const result = window.__tolgee!.highlight('hello');
86+
expect(result).toBeDefined();
87+
expect(typeof result.unhighlight).toBe('function');
88+
result.unhighlight();
89+
});
90+
91+
it('getVisibleKeys() filters out keys outside the viewport', () => {
92+
const vw = window.innerWidth;
93+
const vh = window.innerHeight;
94+
95+
const mockTolgee = {
96+
findPositions: () => [
97+
// visible: fully inside viewport
98+
{
99+
keyName: 'visible-key',
100+
keyNamespace: '',
101+
position: { x: 10, y: 10, width: 100, height: 20 },
102+
},
103+
// off-screen: below viewport
104+
{
105+
keyName: 'below-viewport',
106+
keyNamespace: '',
107+
position: { x: 10, y: vh + 10, width: 100, height: 20 },
108+
},
109+
// off-screen: to the right of viewport
110+
{
111+
keyName: 'right-of-viewport',
112+
keyNamespace: '',
113+
position: { x: vw + 10, y: 10, width: 100, height: 20 },
114+
},
115+
// off-screen: above viewport
116+
{
117+
keyName: 'above-viewport',
118+
keyNamespace: '',
119+
position: { x: 10, y: -100, width: 100, height: 20 },
120+
},
121+
// off-screen: to the left of viewport
122+
{
123+
keyName: 'left-of-viewport',
124+
keyNamespace: '',
125+
position: { x: -200, y: 10, width: 100, height: 20 },
126+
},
127+
// partially visible: overlapping bottom edge
128+
{
129+
keyName: 'partial-bottom',
130+
keyNamespace: '',
131+
position: { x: 10, y: vh - 10, width: 100, height: 30 },
132+
},
133+
],
134+
highlight: () => ({ unhighlight() {} }),
135+
isRunning: () => true,
136+
};
137+
138+
// setupWindowApi works with any object matching the TolgeeInstance shape
139+
const teardown = setupWindowApi(mockTolgee as any);
140+
141+
// need to use a separate tolgee instance for afterEach cleanup
142+
tolgee = TolgeeCore().init({ language: 'en' });
143+
144+
const keys = window.__tolgee!.getVisibleKeys();
145+
const keyNames = keys.map((k) => k.keyName);
146+
147+
expect(keyNames).toContain('visible-key');
148+
expect(keyNames).toContain('partial-bottom');
149+
expect(keyNames).not.toContain('below-viewport');
150+
expect(keyNames).not.toContain('right-of-viewport');
151+
expect(keyNames).not.toContain('above-viewport');
152+
expect(keyNames).not.toContain('left-of-viewport');
153+
expect(keys).toHaveLength(2);
154+
155+
teardown();
156+
});
157+
158+
it('is removed on tolgee.stop()', async () => {
159+
tolgee = TolgeeCore()
160+
.use(InContextTools())
161+
.init({
162+
language: 'en',
163+
staticData: { en: { hello: 'world' } },
164+
observerType,
165+
});
166+
await tolgee.run();
167+
expect(window.__tolgee).toBeDefined();
168+
169+
tolgee.stop();
170+
expect(window.__tolgee).toBeUndefined();
171+
});
172+
});
173+
});

0 commit comments

Comments
 (0)