Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ work. Here’s mine:
you follow, with recommendations based on those choices.
- **Choose how to read.** Switch between cards and a compact list, and use light,
dark, or system appearance.
- **See your reading rank.** Compare longest streaks and total reading days among
readers with a public profile, claimed username, and recorded reading activity.
Each leaderboard shows your rank in the list, or below it when you are outside
the displayed ten readers.
- **Bring a source with you.** Suggest an RSS or Atom feed for review from the web
app or browser extensions.

Expand Down
2 changes: 1 addition & 1 deletion apps/extensions/tests/auth.browser.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { checkArticleViews } from "../../web/tests/browser/article-views.mjs";
import { dailyFixture, checkMustReads } from "../../../scripts/testing/must-reads.mjs";
import { checkAvatarUploads } from "../../../scripts/testing/avatar-uploads.mjs";
import { checkLeaderboard } from "../../../scripts/testing/leaderboard.mjs";
import { checkLeaderboard } from "../../web/tests/browser/leaderboard.mjs";
import { checkReadingStreak } from "../../../scripts/testing/reading-streak.mjs";
import {
checkReaderInteractions,
Expand Down
100 changes: 66 additions & 34 deletions apps/extensions/tests/reader-touch.browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,49 +3,81 @@ 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(`
for (const replaceAfterPreflight of [false, true]) {
test(
replaceAfterPreflight
? "reader touch retries bounds when the target remounts after preflight"
: "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(`
<style>button { margin-top: 1500px; width: 180px; height: 60px; }</style>
<button id="target">Open article</button>
<script>
window.result = { replacements: 0, touches: 0, clicks: 0 };
const target = document.querySelector("button");
const observer = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
observer.disconnect();
const replacement = target.cloneNode(true);
window.replaceTarget = () => {
const current = document.querySelector("button");
const replacement = current.cloneNode(true);
replacement.addEventListener("touchstart", () => window.result.touches++);
replacement.addEventListener("click", () => window.result.clicks++);
target.replaceWith(replacement);
current.replaceWith(replacement);
window.result.replacements++;
};
const observer = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
observer.disconnect();
window.replaceTarget();
});
observer.observe(target);
</script>
`);
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();
}
});
const target = page.getByRole("button", { name: "Open article" });
let boundsReads = 0;
const locator = replaceAfterPreflight
? {
click: (options) => target.click(options),
boundingBox: async () => {
boundsReads++;
if (boundsReads === 1) {
await page.waitForFunction(() => window.result.replacements === 1);
const previous = await target.elementHandle();
await page.evaluate(() => window.replaceTarget());
const bounds = await previous.boundingBox();
await previous.dispose();
assert.equal(bounds, null, "the remounted target has detached bounds");
return bounds;
}
return target.boundingBox();
},
}
: target;
await touchReaderTarget(cdp, locator);
if (replaceAfterPreflight) assert.ok(boundsReads >= 2);
await page.waitForFunction(() => window.result.clicks === 1);
assert.deepEqual(await page.evaluate(() => window.result), {
replacements: replaceAfterPreflight ? 2 : 1,
touches: 1,
clicks: 1,
});
} finally {
await browser.close();
}
},
);
}
67 changes: 35 additions & 32 deletions apps/web/src/components/leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function Leaderboard() {
{boards.map(({ key, title, Icon }) => {
const entries = current.value![key];
const mine = own?.value?.[key];
const ownRankListed = entries.some((entry) => entry.username === mine?.username);
return (
<section key={key} className={styles.board} aria-label={title} data-metric={key}>
<header>
Expand All @@ -118,38 +119,40 @@ export function Leaderboard() {
) : (
<p className={styles.empty}>No rankings yet.</p>
)}
<footer
className={styles.personal}
aria-label={`Your ${title.toLowerCase()} ranking`}
>
{loading ? (
<p role="status">Checking your account…</p>
) : !user ? (
<a className={styles.action} {...readerLoginLink("/leaderboard")}>
Sign in to join
</a>
) : own?.error ? (
<button className={styles.action} onClick={retry}>
Retry your ranking
</button>
) : !own?.value ? (
<p role="status">Loading your ranking…</p>
) : mine ? (
<RankingRow entry={mine} own />
) : profile?.username && profile.visibility?.public ? (
<Link className={styles.action} href="/latest">
Start reading
</Link>
) : (
<Link className={styles.action} href="/settings/profile">
{profileUnavailable
? "Review your profile"
: profile?.username
? "Make your profile public"
: "Claim your username"}
</Link>
)}
</footer>
{!ownRankListed && (
<footer
className={styles.personal}
aria-label={`Your ${title.toLowerCase()} ranking`}
>
{loading ? (
<p role="status">Checking your account…</p>
) : !user ? (
<a className={styles.action} {...readerLoginLink("/leaderboard")}>
Sign in to join
</a>
) : own?.error ? (
<button className={styles.action} onClick={retry}>
Retry your ranking
</button>
) : !own?.value ? (
<p role="status">Loading your ranking…</p>
) : mine ? (
<RankingRow entry={mine} own />
) : profile?.username && profile.visibility?.public ? (
<Link className={styles.action} href="/latest">
Start reading
</Link>
) : (
<Link className={styles.action} href="/settings/profile">
{profileUnavailable
? "Review your profile"
: profile?.username
? "Make your profile public"
: "Claim your username"}
</Link>
)}
</footer>
)}
</section>
);
})}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/tests/browser/feed.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { checkArticleGrid } from "../../../../scripts/testing/article-grid.mjs";
import { checkArticleViews } from "./article-views.mjs";
import { dailyFixture, checkMustReads } from "../../../../scripts/testing/must-reads.mjs";
import { checkLeaderboard, leaderboardProfile } from "../../../../scripts/testing/leaderboard.mjs";
import { checkLeaderboard, leaderboardProfile } from "./leaderboard.mjs";
import { checkMcp, testMcpEndpoint } from "../../../../scripts/testing/mcp.mjs";
import { checkReadingStreak } from "../../../../scripts/testing/reading-streak.mjs";
import { checkReaderInteractions, notificationFixture } from "./reader-interactions.mjs";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,33 +34,62 @@ export async function checkLeaderboard(page, prefix, { signedIn = false } = {})
const base = extension ? previousUrl.split("#")[0] + "#" : new URL(previousUrl).origin;
let mode = "normal";
let ownReads = 0;
const sparseBoards = {
longest_streak: [boards.longest_streak[0]],
reading_days: [boards.reading_days[0]],
};
const mixedBoards = {
...boards,
reading_days: boards.reading_days.map((row) => ({ ...row, rank: 1, days: 465 })),
};
const publicRoute = async (route) =>
route.fulfill({
status: mode === "public-error" ? 503 : 200,
json: mode === "empty" ? { longest_streak: [], reading_days: [] } : boards,
json:
mode === "empty"
? { longest_streak: [], reading_days: [] }
: mode === "self-only"
? sparseBoards
: mode === "mixed"
? mixedBoards
: boards,
});
const ownRoute = async (route) => {
ownReads++;
return route.fulfill({
status: mode === "own-error" ? 503 : 200,
json: ["unclaimed", "empty"].includes(mode)
? { longest_streak: null, reading_days: null }
: {
longest_streak: {
...rows[0],
username: "own-reader",
display_name: "Your Reader",
rank: 1521,
days: 12,
},
reading_days: {
...rows[0],
username: "own-reader",
display_name: "Your Reader",
rank: 1420,
days: 24,
},
},
: mode === "self-only"
? {
longest_streak: sparseBoards.longest_streak[0],
reading_days: sparseBoards.reading_days[0],
}
: mode === "mixed"
? {
longest_streak: boards.longest_streak[9],
reading_days: {
...mixedBoards.reading_days[0],
username: "own-reader",
display_name: "Your Reader",
},
}
: {
longest_streak: {
...rows[0],
username: "own-reader",
display_name: "Your Reader",
rank: 1521,
days: 12,
},
reading_days: {
...rows[0],
username: "own-reader",
display_name: "Your Reader",
rank: 1420,
days: 24,
},
},
});
};
const profileRoute = (route) =>
Expand Down Expand Up @@ -151,6 +180,46 @@ export async function checkLeaderboard(page, prefix, { signedIn = false } = {})
await page.getByRole("heading", { name: "Leading Reader", exact: true }).waitFor();
await page.goto(`${base}/leaderboard`);
await streaks.getByRole("listitem").first().waitFor();
if (signedIn) {
mode = "self-only";
await page.reload();
await streaks
.getByRole("link", { name: "Leading Reader, rank 1, 365 days, you", exact: true })
.waitFor();
await days
.getByRole("link", { name: "Leading Reader, rank 1, 465 days, you", exact: true })
.waitFor();
for (const board of [streaks, days]) {
assert.equal(await board.getByRole("listitem").count(), 1);
assert.equal(await board.locator('a[data-own="true"]').count(), 1);
assert.equal(await board.locator("footer").count(), 0);
}
await page.screenshot({ path: `${prefix}-self-only-desktop.png`, fullPage: true });
await page.setViewportSize({ width: 320, height: 844 });
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth > innerWidth),
false,
);
await page.screenshot({ path: `${prefix}-self-only-320.png`, fullPage: true });
await page.setViewportSize({ width: 1440, height: 1100 });
mode = "mixed";
await page.reload();
await streaks
.getByRole("link", {
name: "A reader with a very long display name, rank 10, 275 days, you",
exact: true,
})
.waitFor();
await days
.getByRole("link", { name: "Your Reader, rank 1, 465 days, you", exact: true })
.waitFor();
assert.equal(await streaks.getByRole("listitem").count(), 10);
assert.equal(await days.getByRole("listitem").count(), 10);
assert.equal(await streaks.locator('a[data-own="true"]').count(), 1);
assert.equal(await streaks.locator("footer").count(), 0);
assert.equal(await days.locator('footer a[data-own="true"]').count(), 1);
assert.equal(await days.locator('ol a[data-own="true"]').count(), 0);
}
mode = "public-error";
await page.reload();
// Restoring the account replaces the guest subtree. Wait for that remount
Expand Down
10 changes: 8 additions & 2 deletions apps/web/tests/browser/reader-interactions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ 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();
const deadline = Date.now() + 5000;
let box;
do {
await locator.click({ trial: true, timeout: Math.max(1, deadline - Date.now()) });
box = await locator.boundingBox();
// Rendering may replace the element between preflight and bounds lookup.
// Retry preparation only; never replay a dispatched touch.
} while (!box && Date.now() < deadline);
assert.ok(box, "touch target has visible bounds");
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
Expand Down
Loading