Skip to content

Commit 103cb26

Browse files
committed
test(device): cover the link-popover flow through real on-screen input
The toolbar/link suite on both device targets: the keyboard opening and resizing the viewport, toolbar taps registering, the popover holding focus through the IME reconfigure, and submission by pressing the on-screen keyboard's actual Enter/action key. The IME-action-key test is the former manual release-checklist item: Android's IME only offers a submitting action inside a real <form> — with a lone field it picks Next, the original create-link bug. Pairs with the emulated linkSubmit.test.tsx.
1 parent e736b32 commit 103cb26

2 files changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test";
2+
3+
import { activeDevices } from "./devices.js";
4+
import { tapElement } from "./lib/gestures.js";
5+
import {
6+
docState,
7+
MOBILE_TOOLBAR,
8+
openExample,
9+
startEditing,
10+
viewportHeight,
11+
} from "./lib/editorPage.js";
12+
import {
13+
LINK_POPOVER,
14+
openLinkPopover,
15+
selectFirstWord,
16+
typeAndSubmit,
17+
} from "./linkPopover.js";
18+
import type { DeviceSession } from "./lib/session.js";
19+
20+
const KEYBOARD_MIN_HEIGHT = 150;
21+
22+
function sleep(ms: number) {
23+
return new Promise((resolve) => setTimeout(resolve, ms));
24+
}
25+
26+
for (const device of await activeDevices()) {
27+
describe(`mobile formatting toolbar on ${device.id}`, () => {
28+
let session: DeviceSession;
29+
let baselineHeight: number;
30+
31+
beforeAll(async () => {
32+
session = await device.createSession();
33+
await openExample(session, "/ui-components/mobile-formatting-toolbar");
34+
baselineHeight = await viewportHeight(session);
35+
});
36+
37+
afterAll(async () => {
38+
if (session) {
39+
await session.screenshot(`formatting-toolbar-final`);
40+
await session.close();
41+
}
42+
});
43+
44+
test("tapping the editor opens the keyboard and shows the mobile toolbar", async () => {
45+
await startEditing(session);
46+
47+
// The toolbar only renders while `useVirtualKeyboard` sees the
48+
// keyboard, so its presence + the viewport drop prove the real
49+
// on-screen keyboard opened.
50+
expect(await viewportHeight(session)).toBeLessThan(
51+
baselineHeight - KEYBOARD_MIN_HEIGHT,
52+
);
53+
});
54+
55+
test("toolbar buttons apply reliably", async () => {
56+
await startEditing(session);
57+
await selectFirstWord(session);
58+
// Three bold toggles; every tap must register (covers the reported
59+
// "buttons sometimes don't work", which traced back to a lingering
60+
// popover overlaying the toolbar).
61+
for (const expected of [true, false, true]) {
62+
await tapElement(session, `${MOBILE_TOOLBAR} [data-test="bold"]`, {
63+
keyboard: "open",
64+
verify: `return { ok: ${expected} === !!document.querySelector('.bn-editor strong') };`,
65+
});
66+
}
67+
});
68+
69+
test("link popover holds focus through the IME and creates a link", async () => {
70+
// Captured before the popover opens: iOS Safari auto-zooms the page
71+
// when an input with a computed font-size under 16px takes focus, and
72+
// that zoom perturbs the visual viewport the mobile toolbar positions
73+
// itself from. The `pointer: coarse` rule in blocknoteStyles.css
74+
// prevents it; this pins the behaviour rather than the rule.
75+
const scaleBefore = await session.exec<number>(
76+
`return window.visualViewport ? window.visualViewport.scale : 1;`,
77+
);
78+
79+
await openLinkPopover(session);
80+
81+
// Focusing an input makes the IME reconfigure (on Android this
82+
// resizes the viewport), which historically hid the popover and
83+
// collapsed the keyboard/toolbar (the Mantine `hideDetached` bug).
84+
// The input must still hold focus once that settles.
85+
await sleep(2_500);
86+
const survival = await session.exec<{
87+
focused: boolean;
88+
popover: boolean;
89+
toolbar: boolean;
90+
}>(`
91+
const active = document.activeElement;
92+
return {
93+
focused: !!(active && active.tagName === 'INPUT' && active.getAttribute('name') === 'url'),
94+
popover: !!document.querySelector(${JSON.stringify(LINK_POPOVER)}),
95+
toolbar: !!document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)}),
96+
};`);
97+
await session.screenshot("link-popover-open");
98+
99+
// Focusing the URL input must not have zoomed the page.
100+
const scaleAfter = await session.exec<number>(
101+
`return window.visualViewport ? window.visualViewport.scale : 1;`,
102+
);
103+
expect(
104+
scaleAfter,
105+
`focusing the URL input zoomed the page (${scaleBefore} -> ${scaleAfter}); ` +
106+
`check the pointer:coarse font-size rule for .bn-form-popover inputs`,
107+
).toBeLessThanOrEqual(scaleBefore + 0.01);
108+
109+
expect(survival).toEqual({
110+
focused: true,
111+
popover: true,
112+
toolbar: true,
113+
});
114+
115+
await typeAndSubmit(
116+
session,
117+
`${LINK_POPOVER} input`,
118+
"example.com",
119+
`return {
120+
ok: !!document.querySelector('.bn-editor a[href="https://example.com"]')
121+
&& !document.querySelector(${JSON.stringify(LINK_POPOVER)}),
122+
link: !!document.querySelector('.bn-editor a[href="https://example.com"]'),
123+
popoverGone: !document.querySelector(${JSON.stringify(LINK_POPOVER)}),
124+
};`,
125+
);
126+
127+
expect((await docState(session)).links).toContain("https://example.com");
128+
// Submitting must not dismiss the keyboard — but Appium's typing can
129+
// itself hide the keyboard as an automation side effect (observed on
130+
// Android), which the product can't distinguish from the user closing
131+
// it. So only assert the toolbar survived while the keyboard is
132+
// actually still up; the emulation suite covers this invariant
133+
// deterministically.
134+
if (
135+
(await viewportHeight(session)) <
136+
baselineHeight - KEYBOARD_MIN_HEIGHT
137+
) {
138+
expect(
139+
await session.exec<boolean>(
140+
`return !!document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)});`,
141+
),
142+
).toBe(true);
143+
}
144+
});
145+
146+
// The flow that used to be a manual release-checklist item: Android's
147+
// IME decides what its action key does — with a lone text field outside
148+
// a <form> it picks "Next" (advance focus, no key event at all), the
149+
// original create-link bug. Only a backend that can press the on-screen
150+
// keyboard can test the IME's actual choice.
151+
test.skipIf(device.kind !== "local-android")(
152+
"the IME action key submits the link popover",
153+
async () => {
154+
// Fresh document — the earlier tests linked the first word, and a
155+
// linked selection opens the *edit* popover (pre-filled URL) instead
156+
// of the create popover this flow is about.
157+
await openExample(session, "/ui-components/mobile-formatting-toolbar");
158+
await startEditing(session);
159+
await openLinkPopover(session);
160+
161+
await session.elementValue(`${LINK_POPOVER} input`, "example.com");
162+
163+
if (!session.pressImeActionKey) {
164+
throw new Error("this target must expose the IME action key");
165+
}
166+
await session.pressImeActionKey(
167+
`return {
168+
ok: !!document.querySelector('.bn-editor a[href="https://example.com"]')
169+
&& !document.querySelector(${JSON.stringify(LINK_POPOVER)}),
170+
link: !!document.querySelector('.bn-editor a[href="https://example.com"]'),
171+
popoverGone: !document.querySelector(${JSON.stringify(LINK_POPOVER)}),
172+
};`,
173+
);
174+
175+
// The action must not have advanced focus out of the editor — that
176+
// was the original bug's symptom (focus jumping to the next editor).
177+
const state = await session.exec<{ inFirstEditor: boolean }>(
178+
`const editors = [...document.querySelectorAll(".bn-editor")];
179+
return { inFirstEditor: editors[0].contains(document.activeElement) };`,
180+
);
181+
expect(state.inFirstEditor).toBe(true);
182+
},
183+
);
184+
});
185+
}

tests/device/linkPopover.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Helpers for the create-link flow on real devices — next to the tests that
3+
* use them, since only the link tests speak these concepts.
4+
*/
5+
import { MOBILE_TOOLBAR, PARAGRAPH, startEditing } from "./lib/editorPage.js";
6+
import { pressSoftKeyboardEnter, tapElement } from "./lib/gestures.js";
7+
import type { DeviceSession } from "./lib/session.js";
8+
9+
export const LINK_BUTTON = `${MOBILE_TOOLBAR} [data-test="createLink"]`;
10+
export const LINK_POPOVER = ".bn-form-popover";
11+
12+
/**
13+
* Selects the first word of the first paragraph via a DOM range (ProseMirror
14+
* syncs its selection from `selectionchange`, so no editor handle is needed).
15+
* iOS intermittently collapses programmatic selections, so the wait re-applies
16+
* the range on every poll until the toolbar's link button confirms the editor
17+
* sees a non-empty selection.
18+
*/
19+
export async function selectFirstWord(session: DeviceSession): Promise<void> {
20+
const applyAndCheck = `
21+
if (getSelection().isCollapsed) {
22+
const p = document.querySelector(${JSON.stringify(PARAGRAPH)});
23+
const textNode = [...p.childNodes].find((n) => n.nodeType === 3) || p.firstChild;
24+
const range = document.createRange();
25+
range.setStart(textNode, 0);
26+
range.setEnd(textNode, Math.min(7, textNode.textContent.length));
27+
const selection = getSelection();
28+
selection.removeAllRanges();
29+
selection.addRange(range);
30+
}
31+
return {
32+
ok: !getSelection().isCollapsed
33+
&& !!document.querySelector(${JSON.stringify(LINK_BUTTON)}),
34+
};`;
35+
await session.waitFor("selection + link button", applyAndCheck, 25_000);
36+
}
37+
38+
/**
39+
* Opens the create-link popover from the mobile toolbar and waits for its URL
40+
* input to hold focus. A mis-aimed tap (iOS chrome-offset guessing) can hit
41+
* the keyboard's accessory bar and collapse the whole editing state, so each
42+
* attempt rebuilds editing + selection from scratch before tapping.
43+
*/
44+
export async function openLinkPopover(session: DeviceSession): Promise<void> {
45+
let lastError: Error | undefined;
46+
for (let attempt = 0; attempt < 4; attempt++) {
47+
await startEditing(session);
48+
await selectFirstWord(session);
49+
await session.exec(`
50+
const toolbar = document.querySelector(${JSON.stringify(MOBILE_TOOLBAR)});
51+
toolbar.querySelectorAll('*').forEach((el) => {
52+
if (el.scrollWidth > el.clientWidth + 5) el.scrollLeft = el.scrollWidth;
53+
});`);
54+
try {
55+
await tapElement(session, LINK_BUTTON, {
56+
keyboard: "open",
57+
verify: `
58+
const active = document.activeElement;
59+
return {
60+
ok: !!document.querySelector(${JSON.stringify(LINK_POPOVER)})
61+
&& active && active.tagName === 'INPUT'
62+
&& active.getAttribute('name') === 'url',
63+
};`,
64+
});
65+
return;
66+
} catch (error) {
67+
lastError = error as Error;
68+
}
69+
}
70+
throw new Error(`Could not open the link popover: ${lastError?.message}`);
71+
}
72+
73+
/**
74+
* Types into a popover field and submits it by pressing the on-screen
75+
* keyboard's Enter/action key — the real user gesture on both platforms (see
76+
* `pressSoftKeyboardEnter`), driving the real submission path: key press ->
77+
* implicit form submission -> the popover's `submit` handling.
78+
*
79+
* `verify` is a page script returning `{ ok: boolean }` observing the
80+
* submission's effect — the tap ladders need it to know a tap landed.
81+
*/
82+
export async function typeAndSubmit(
83+
session: DeviceSession,
84+
css: string,
85+
text: string,
86+
verify: string,
87+
): Promise<void> {
88+
await session.elementValue(css, text);
89+
await pressSoftKeyboardEnter(session, verify);
90+
}

0 commit comments

Comments
 (0)