diff --git a/apps/extensions/tests/auth.browser.mjs b/apps/extensions/tests/auth.browser.mjs
index aa8518432..95b0e76da 100644
--- a/apps/extensions/tests/auth.browser.mjs
+++ b/apps/extensions/tests/auth.browser.mjs
@@ -5,7 +5,7 @@ import { checkReadingStreak } from "../../../scripts/testing/reading-streak.mjs"
import {
checkReaderInteractions,
notificationFixture,
-} from "../../../scripts/testing/reader-interactions.mjs";
+} from "../../web/tests/browser/reader-interactions.mjs";
import {
checkDevCardPromo,
checkUnclaimedDevCardPromo,
diff --git a/apps/extensions/tests/reader-touch.browser.mjs b/apps/extensions/tests/reader-touch.browser.mjs
new file mode 100644
index 000000000..84f53e738
--- /dev/null
+++ b/apps/extensions/tests/reader-touch.browser.mjs
@@ -0,0 +1,51 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { chromium } from "playwright";
+import { touchReaderTarget } from "../../web/tests/browser/reader-interactions.mjs";
+
+test("reader touch re-resolves a target replaced during scrolling", async () => {
+ const browser = await chromium.launch({
+ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
+ channel: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
+ ? undefined
+ : process.env.DEVFEED_EXTENSION_BROWSER === "edge"
+ ? "msedge"
+ : "chromium",
+ headless: true,
+ });
+ try {
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send("Emulation.setTouchEmulationEnabled", {
+ enabled: true,
+ maxTouchPoints: 1,
+ });
+ await page.setContent(`
+
+
+
+ `);
+ await touchReaderTarget(cdp, page.getByRole("button", { name: "Open article" }));
+ await page.waitForFunction(() => window.result.clicks === 1);
+ assert.deepEqual(await page.evaluate(() => window.result), {
+ replacements: 1,
+ touches: 1,
+ clicks: 1,
+ });
+ } finally {
+ await browser.close();
+ }
+});
diff --git a/apps/web/tests/browser/feed.mjs b/apps/web/tests/browser/feed.mjs
index a1a4083c0..a282c54ac 100644
--- a/apps/web/tests/browser/feed.mjs
+++ b/apps/web/tests/browser/feed.mjs
@@ -3,10 +3,7 @@ import { dailyFixture, checkMustReads } from "../../../../scripts/testing/must-r
import { checkLeaderboard, leaderboardProfile } from "../../../../scripts/testing/leaderboard.mjs";
import { checkMcp, testMcpEndpoint } from "../../../../scripts/testing/mcp.mjs";
import { checkReadingStreak } from "../../../../scripts/testing/reading-streak.mjs";
-import {
- checkReaderInteractions,
- notificationFixture,
-} from "../../../../scripts/testing/reader-interactions.mjs";
+import { checkReaderInteractions, notificationFixture } from "./reader-interactions.mjs";
import { checkSidebarGitHub } from "../../../../scripts/testing/sidebar-github.mjs";
import {
engagementFeed,
diff --git a/scripts/testing/reader-interactions.mjs b/apps/web/tests/browser/reader-interactions.mjs
similarity index 89%
rename from scripts/testing/reader-interactions.mjs
rename to apps/web/tests/browser/reader-interactions.mjs
index d74677656..e495f1888 100644
--- a/scripts/testing/reader-interactions.mjs
+++ b/apps/web/tests/browser/reader-interactions.mjs
@@ -17,6 +17,19 @@ export function notificationFixture() {
};
}
+/** Wait for a stable target before dispatching native touch events. */
+export async function touchReaderTarget(cdp, locator) {
+ // Trial actions re-resolve a detached locator without sending a desktop click.
+ await locator.click({ trial: true });
+ const box = await locator.boundingBox();
+ assert.ok(box, "touch target has visible bounds");
+ await cdp.send("Input.dispatchTouchEvent", {
+ type: "touchStart",
+ touchPoints: [{ x: box.x + box.width / 2, y: box.y + box.height / 2 }],
+ });
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
+}
+
/** Exercise actual touch events, not desktop clicks in a narrow viewport. */
export async function checkReaderInteractions(
page,
@@ -29,17 +42,7 @@ export async function checkReaderInteractions(
await page.setViewportSize({ width: 390, height: 844 });
await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
await page.emulateMedia({ reducedMotion: "no-preference" });
- async function tap(locator) {
- await locator.waitFor();
- await locator.scrollIntoViewIfNeeded();
- const box = await locator.boundingBox();
- assert.ok(box, "touch target has visible bounds");
- await cdp.send("Input.dispatchTouchEvent", {
- type: "touchStart",
- touchPoints: [{ x: box.x + box.width / 2, y: box.y + box.height / 2 }],
- });
- await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
- }
+ const tap = (locator) => touchReaderTarget(cdp, locator);
const nav = page.locator(".mobile-nav");
const free = async () => {
assert.notEqual(