Save articles, follow topics, and get recommendations shaped by your interests.
+
Create account
diff --git a/apps/web/tests/browser/feed-onboarding.mjs b/apps/web/tests/browser/feed-onboarding.mjs
new file mode 100644
index 000000000..bb585eb4b
--- /dev/null
+++ b/apps/web/tests/browser/feed-onboarding.mjs
@@ -0,0 +1,111 @@
+import assert from "node:assert/strict";
+import { mkdir } from "node:fs/promises";
+import { promptFeatured, promptFeaturedPath } from "./signup-prompts.mjs";
+
+// Run the same modal journey against the website and both built extensions.
+export async function checkFeedOnboarding(page, home, output) {
+ await mkdir(output, { recursive: true });
+ await page.bringToFront();
+ const profilePath = "**/api/v1/user/settings/profile";
+ const unclaimedProfile = (route) =>
+ route.fulfill({
+ json: { display_name: "Reader", avatar_url: null, username: null, stack: [] },
+ });
+ await page.route(profilePath, unclaimedProfile);
+ await page.route(promptFeaturedPath, promptFeatured);
+ // Earlier account journeys can already have declined this invitation.
+ if (page.url() !== "about:blank")
+ await page.evaluate(() => {
+ for (const key of Object.keys(sessionStorage))
+ if (key.startsWith("devfeed:dev-card-promo-dismissed:")) sessionStorage.removeItem(key);
+ });
+ try {
+ await page.goto(home);
+ if (page.url().startsWith("chrome-extension:")) await page.reload();
+ const dialog = page.getByRole("dialog", { name: "Choose your topics" });
+ await dialog.waitFor();
+ await page.locator('dialog[aria-label="Your dev card preview"]').waitFor({ state: "attached" });
+ await page.clock.fastForward(35_000);
+ const card = page.getByRole("dialog", { name: "Your dev card preview" });
+ assert.equal(await card.isVisible(), false, "topic onboarding takes priority over Dev Cards");
+ assert.equal(await page.getByRole("button", { name: "Not now" }).count(), 0);
+ await page.getByRole("region", { name: "Feed controls" }).waitFor();
+ const search = dialog.getByRole("searchbox", { name: "Search topics" });
+ const save = dialog.getByRole("button", { name: "Save", exact: true });
+ await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).waitFor();
+ assert.deepEqual(
+ await dialog
+ .locator("label")
+ .allTextContents()
+ .then((names) => names.slice(0, 3)),
+ ["TypeScript", "Python", "React"],
+ );
+ assert.ok(await save.isDisabled());
+ await dialog.locator("div.pagination[data-has-more]").scrollIntoViewIfNeeded();
+ await dialog.getByText("Couldn’t load more topics.").waitFor();
+ await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).check();
+ await dialog.getByRole("button", { name: "Try again", exact: true }).click();
+ await dialog.getByText("Couldn’t load more topics.").waitFor({ state: "detached" });
+ assert.ok(await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).isChecked());
+ await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).uncheck();
+ await page.screenshot({ path: `${output}/onboarding-desktop.png` });
+ await page.keyboard.press("Escape");
+ await dialog.waitFor({ state: "detached" });
+ await page.clock.fastForward(29_000);
+ assert.equal(await card.isVisible(), false, "closing onboarding leaves time to read");
+ await page.clock.fastForward(1500);
+ await card.waitFor();
+ await card.getByRole("button", { name: "Dismiss dev card preview" }).click();
+ // Allow another invitation after saving, so the completion pause is exercised too.
+ await page.evaluate(() => {
+ for (const key of Object.keys(sessionStorage))
+ if (key.startsWith("devfeed:dev-card-promo-dismissed:")) sessionStorage.removeItem(key);
+ });
+ await page.reload();
+ await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).waitFor();
+ await page.setViewportSize({ width: 390, height: 844 });
+ await search.fill("no matching topic");
+ await dialog.getByText("No topics match your search.").waitFor();
+ await search.fill("typescript");
+ const choice = dialog.getByRole("checkbox", { name: "TypeScript", exact: true });
+ await choice.focus();
+ await page.keyboard.press("Space");
+ assert.ok(await choice.isChecked());
+ await search.fill("");
+ await dialog.getByRole("checkbox", { name: "Python", exact: true }).check();
+ assert.ok(await save.isDisabled());
+ await dialog.getByRole("checkbox", { name: "React", exact: true }).check();
+ assert.equal(await save.isDisabled(), false);
+ await dialog.getByRole("checkbox", { name: "Docker", exact: true }).check();
+ await dialog.getByText("4 selected", { exact: true }).waitFor();
+ await dialog.getByRole("checkbox", { name: "Docker", exact: true }).uncheck();
+ await page.screenshot({ path: `${output}/onboarding-mobile.png` });
+ const bounds = await dialog.boundingBox();
+ const buttonBounds = await save.boundingBox();
+ assert.ok(bounds && bounds.x >= 0 && bounds.y >= 0 && bounds.y + bounds.height <= 844);
+ assert.ok(buttonBounds && buttonBounds.y + buttonBounds.height <= 844);
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
+ await save.focus();
+ await page.evaluate(() => document.querySelector(".sidebar a")?.focus());
+ assert.ok(await dialog.evaluate((element) => element.contains(document.activeElement)));
+ await save.click();
+ await dialog.getByText("Couldn’t save your topics. Please try again.").waitFor();
+ assert.equal(await dialog.getByRole("checkbox", { checked: true }).count(), 3);
+ await save.click();
+ await dialog.waitFor({ state: "detached" });
+ await page.clock.fastForward(29_000);
+ assert.equal(await card.isVisible(), false, "saving onboarding also leaves time to read");
+ await page.clock.fastForward(1500);
+ await card.waitFor();
+ await card.getByRole("button", { name: "Dismiss dev card preview" }).click();
+ await page.locator(".article-card").first().waitFor({ timeout: 15000 });
+ await page.reload();
+ await page.locator(".article-card").first().waitFor();
+ assert.equal(await dialog.count(), 0);
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ console.log("Topic onboarding: priority and the pause after closing/saving passed.");
+ } finally {
+ await page.unroute(profilePath, unclaimedProfile);
+ await page.unroute(promptFeaturedPath, promptFeatured);
+ }
+}
diff --git a/apps/web/tests/browser/feed.mjs b/apps/web/tests/browser/feed.mjs
index a282c54ac..97dd2b907 100644
--- a/apps/web/tests/browser/feed.mjs
+++ b/apps/web/tests/browser/feed.mjs
@@ -42,7 +42,8 @@ import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { chromium } from "playwright";
-import { checkFeedOnboarding } from "../../../../scripts/testing/feed-onboarding.mjs";
+import { checkFeedOnboarding } from "./feed-onboarding.mjs";
+import { checkSignupPrompts, signupPromptArticle } from "./signup-prompts.mjs";
import { checkTopicFollow } from "../../../../scripts/testing/topic-follow.mjs";
import {
onboardingTopics,
@@ -84,6 +85,14 @@ const fixture = createServer(async (req, res) => {
res.end(JSON.stringify({ detail: "Feed unavailable" }));
return;
}
+ const promptArticle = path.startsWith("/v1/articles/")
+ ? signupPromptArticle(article, path.slice("/v1/articles/".length))
+ : null;
+ if (promptArticle) {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(promptArticle));
+ return;
+ }
if (path.startsWith("/v1/user/must-reads")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(await mustReadsFixture.response(path.endsWith("/presentation"))));
@@ -451,6 +460,8 @@ try {
await page.setViewportSize({ width: 1440, height: 1000 });
await checkLeaderboard(page, `${root}/reports/reader-feed/leaderboard-guest`);
await checkDevCardPromo(page, `${root}/reports/reader-feed/dev-card-promo`);
+ await checkSignupPrompts(context, origin, `${root}/reports/reader-feed/signup`);
+ await page.bringToFront();
await checkSidebarGitHub(page, `${root}/reports/reader-feed/sidebar-github.png`);
await checkMcp(page, `${root}/reports/reader-feed/mcp`);
const whatsNew = page
@@ -561,6 +572,7 @@ try {
savedTopicIds = [];
// The earlier background-refresh page deliberately overrides hasFocus on every navigation.
const onboardingPage = await context.newPage();
+ await onboardingPage.clock.install();
await checkFeedOnboarding(onboardingPage, origin, `${output}/web`);
await onboardingPage.close();
await page.bringToFront();
diff --git a/apps/web/tests/browser/signup-prompts.mjs b/apps/web/tests/browser/signup-prompts.mjs
new file mode 100644
index 000000000..b95f6d344
--- /dev/null
+++ b/apps/web/tests/browser/signup-prompts.mjs
@@ -0,0 +1,124 @@
+import assert from "node:assert/strict";
+import { mkdir } from "node:fs/promises";
+
+const slugs = ["signup-prompt-one", "signup-prompt-two", "signup-prompt-three"];
+const invitationKey = "devfeed:dev-card-promo-dismissed";
+export const promptFeaturedPath = /\/api\/v1\/users\/asaharan(?:\?.*)?$/;
+export const promptFeatured = (route) =>
+ route.fulfill({
+ json: {
+ profile: {
+ username: "asaharan",
+ display_name: "Featured Reader",
+ avatar_url: null,
+ stack: [],
+ },
+ },
+ });
+
+export function signupPromptArticle(article, slug) {
+ const index = slugs.indexOf(slug);
+ return index < 0
+ ? null
+ : {
+ ...article,
+ id: article.id.slice(0, -1) + (index + 1),
+ slug,
+ title: `Signup prompt article ${index + 1}`,
+ };
+}
+
+// One browser journey runs against the website and the actual Chrome/Edge packages.
+export async function checkSignupPrompts(context, home, output, { extension = false } = {}) {
+ await mkdir(output, { recursive: true });
+ const page = await context.newPage();
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ const href = (path) => (extension ? `${home.split("#")[0]}#${path}` : new URL(path, home).href);
+ const banner = page.getByRole("complementary", { name: "Create a DevFeed account" });
+ const preview = page.getByRole("dialog", { name: "Your dev card preview" });
+ await page.route(promptFeaturedPath, promptFeatured);
+ await page.clock.install();
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ try {
+ for (const [index, slug] of slugs.entries()) {
+ await page.goto(href(`/articles/${slug}`));
+ const article = page.getByRole("dialog", { name: "Article preview" });
+ await article.getByRole("heading", { name: `Signup prompt article ${index + 1}` }).waitFor();
+ assert.equal(await banner.count(), 0, "reading an article takes priority over signup");
+ await article.getByRole("button", { name: "Close preview" }).click();
+ await article.waitFor({ state: "detached" });
+ if (index < 2) assert.equal(await banner.count(), 0, "wait for three distinct articles");
+ }
+ await banner.waitFor();
+ await page.clock.fastForward(35_000);
+ assert.equal(await preview.isVisible(), false, "the banner holds back automatic Dev Cards");
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.clock.runFor(100);
+ await page.locator(".article-card").first().waitFor();
+ await banner.waitFor();
+ await page.screenshot({ path: `${output}/signup-desktop.png`, animations: "disabled" });
+ await page.setViewportSize({ width: 390, height: 844 });
+ const dismiss = banner.getByRole("button", { name: "Not now" });
+ const bounds = await dismiss.boundingBox();
+ assert.ok(bounds && bounds.height >= 44, "dismissal has a touch-sized target");
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
+ const bannerBounds = await banner.boundingBox();
+ const navigationBounds = await page.locator(".mobile-nav").boundingBox();
+ assert.ok(
+ bannerBounds &&
+ navigationBounds &&
+ bannerBounds.y + bannerBounds.height <= navigationBounds.y - 8,
+ "the invitation leaves mobile navigation accessible",
+ );
+ const signup = new URL(
+ await banner.getByRole("link", { name: "Create account" }).getAttribute("href"),
+ page.url(),
+ );
+ assert.equal(signup.pathname, "/login");
+ assert.equal(
+ new URL(signup.searchParams.get("return_to"), home).pathname,
+ extension ? "/extension/login-complete" : "/latest",
+ );
+ await page.screenshot({ path: `${output}/signup-mobile.png`, animations: "disabled" });
+ await dismiss.focus();
+ await page.keyboard.press("Enter");
+ await banner.waitFor({ state: "detached" });
+ assert.equal(await page.evaluate((key) => sessionStorage.getItem(key), invitationKey), "true");
+ await page.goto(href(`/articles/${slugs[0]}`));
+ await page.getByRole("button", { name: "Close preview" }).click();
+ await page.reload();
+ await page.clock.fastForward(40_000);
+ assert.equal(await banner.count(), 0, "dismissal survives navigation and reload");
+ assert.equal(await preview.count(), 0, "the same refusal silences the automatic Dev Card");
+
+ await page.goto(href("/dev-card"));
+ await page.getByRole("button", { name: "Preview your card" }).click();
+ await preview.waitFor();
+ assert.equal(await banner.count(), 0);
+ await preview.getByRole("button", { name: "Dismiss dev card preview" }).click();
+
+ // Exercise the reverse order in a fresh reader session.
+ await page.evaluate(() => sessionStorage.clear());
+ await page.goto(href("/latest"));
+ await page.reload();
+ await page.locator('dialog[aria-label="Your dev card preview"]').waitFor({ state: "attached" });
+ await page.clock.fastForward(35_000);
+ await preview.waitFor();
+ await preview.getByRole("button", { name: "Dismiss dev card preview" }).click();
+ await page.evaluate(
+ (seen) => sessionStorage.setItem("devfeed:signup-nudge-articles", JSON.stringify(seen)),
+ slugs,
+ );
+ await page.reload();
+ await page.clock.fastForward(40_000);
+ assert.equal(await banner.count(), 0, "dismissing the card also silences signup");
+ assert.equal(await preview.count(), 0);
+ assert.deepEqual(errors, []);
+ console.log(
+ "Signup invitations: mutual dismissal, dialog priority, and explicit preview passed.",
+ );
+ } finally {
+ await page.close();
+ }
+}
diff --git a/apps/web/tests/reader-prompts.test.tsx b/apps/web/tests/reader-prompts.test.tsx
new file mode 100644
index 000000000..b3c13bb28
--- /dev/null
+++ b/apps/web/tests/reader-prompts.test.tsx
@@ -0,0 +1,189 @@
+// @vitest-environment jsdom
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { StrictMode } from "react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { ReaderPromptsProvider, promptPauseMs } from "@/components/reader-prompts";
+import { SignupNudge } from "@/components/signup-nudge";
+import { DevCardPromo } from "@/components/dev-card-promo";
+import { FeedOnboarding } from "@/components/feed-onboarding";
+import type { UserProfile } from "@/lib/user";
+
+const state = vi.hoisted(() => ({
+ user: null as { user_id: string } | null,
+ loading: false,
+ unavailable: false,
+ profile: null as UserProfile | null,
+ profileUnavailable: false,
+}));
+const follows = vi.hoisted(() => ({
+ ids: [] as string[],
+ loading: false,
+ unavailable: false,
+ refresh: vi.fn(),
+ save: vi.fn(),
+}));
+vi.mock("@/components/user-account", () => ({ useUser: () => state }));
+vi.mock("@/components/topic-follows", () => ({ useTopicFollows: () => follows }));
+vi.mock("@/components/infinite-choices", () => ({ InfiniteChoices: () => null }));
+vi.mock("@/components/dev-card-artwork", () => ({ DevCardArtwork: () =>
Card artwork
}));
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.spyOn(performance, "now").mockReturnValue(30_000);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () =>
+ Response.json({
+ profile: { username: "asaharan", display_name: "Featured Reader", stack: [] },
+ }),
+ ),
+ );
+ HTMLDialogElement.prototype.showModal = function () {
+ this.setAttribute("open", "");
+ };
+ HTMLDialogElement.prototype.close = function () {
+ this.removeAttribute("open");
+ };
+ sessionStorage.clear();
+ sessionStorage.setItem("devfeed:signup-nudge-articles", JSON.stringify(["one", "two", "three"]));
+ state.user = null;
+ state.loading = false;
+ state.unavailable = false;
+ state.profile = null;
+ state.profileUnavailable = false;
+ follows.ids = [];
+ follows.loading = false;
+ follows.unavailable = false;
+});
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ expect(document.body.style.overflow).toBe("");
+});
+
+async function advance(ms = 1500) {
+ await act(() => vi.advanceTimersByTimeAsync(ms));
+}
+function invites({ signup = true, requested = false, onboarding = false } = {}) {
+ return (
+
+ {onboarding && }
+
+ {signup && }
+
+ );
+}
+
+it.each([false, true])(
+ "lets the banner finish and silences automatic Dev Card invitations on dismissal (strict=%s)",
+ async (strict) => {
+ render(strict ?
{invites()} : invites());
+ await advance();
+ expect(screen.getByRole("button", { name: "Not now" })).toBeTruthy();
+ expect(screen.queryByRole("dialog", { name: "Your dev card preview" })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Not now" }));
+ await advance(40_000);
+ expect(screen.queryByRole("link", { name: "Create account" })).toBeNull();
+ expect(screen.queryByRole("dialog", { name: "Your dev card preview" })).toBeNull();
+ },
+);
+
+it("silences signup after an anonymous Dev Card dismissal", async () => {
+ const reader = render(invites({ signup: false }));
+ await advance();
+ await advance(1200);
+ fireEvent.click(screen.getByRole("button", { name: "Dismiss dev card preview" }));
+ reader.rerender(invites());
+ await advance();
+ expect(screen.queryByRole("link", { name: "Create account" })).toBeNull();
+});
+
+it("prioritizes topic onboarding, including preference loading, and pauses after closing it", async () => {
+ state.user = { user_id: "reader" };
+ state.profile = { display_name: "Reader", avatar_url: null, username: null };
+ follows.loading = true;
+ const reader = render(invites({ onboarding: true }));
+ await advance(40_000);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ follows.loading = false;
+ reader.rerender(invites({ onboarding: true }));
+ await advance();
+ expect(screen.getByRole("dialog", { name: "Choose your topics" })).toBeTruthy();
+ expect(screen.queryByRole("dialog", { name: "Your dev card preview" })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ await advance(promptPauseMs - 1);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ await advance(251);
+ expect(screen.getByRole("dialog", { name: "Your dev card preview" })).toBeTruthy();
+});
+
+it("allows an explicitly requested preview after dismissing signup", async () => {
+ const reader = render(invites());
+ await advance();
+ fireEvent.click(screen.getByRole("button", { name: "Not now" }));
+ reader.rerender(invites({ requested: true }));
+ await advance();
+ expect(screen.getByRole("dialog", { name: "Your dev card preview" })).toBeTruthy();
+ expect(screen.queryByRole("link", { name: "Create account" })).toBeNull();
+});
+
+it("gives an explicit preview priority over a visible signup banner", async () => {
+ const reader = render(invites());
+ await advance();
+ expect(screen.getByRole("button", { name: "Not now" })).toBeTruthy();
+ reader.rerender(invites({ requested: true }));
+ await advance();
+ expect(screen.getByRole("dialog", { name: "Your dev card preview" })).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Not now" })).toBeNull();
+});
+
+it("hides signup while a native article dialog is open and resumes after it closes", async () => {
+ render(
+
+
+ ,
+ );
+ await advance();
+ const article = document.createElement("dialog");
+ await act(async () => {
+ document.body.append(article);
+ article.showModal();
+ });
+ expect(screen.queryByRole("button", { name: "Not now" })).toBeNull();
+ await act(async () => {
+ article.close();
+ article.remove();
+ });
+ expect(screen.getByRole("button", { name: "Not now" })).toBeTruthy();
+});
+
+it("retains refusal when storage writes fail and invites remount under the same reader", async () => {
+ const reader = render(invites());
+ await advance();
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("blocked");
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Not now" }));
+ reader.rerender(
+
+ Reader route
+ ,
+ );
+ reader.rerender(invites());
+ await advance(40_000);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Not now" })).toBeNull();
+});
+
+it("keeps signed-in Dev Card completion separate from a guest refusal", async () => {
+ const reader = render(invites());
+ await advance();
+ fireEvent.click(screen.getByRole("button", { name: "Not now" }));
+ state.user = { user_id: "reader" };
+ state.profile = { display_name: "Reader", avatar_url: null, username: null };
+ reader.rerender(invites({ signup: false }));
+ await advance();
+ expect(screen.getByRole("link", { name: /Finish your dev card/ })).toBeTruthy();
+});
diff --git a/apps/web/tests/signup-nudge.test.tsx b/apps/web/tests/signup-nudge.test.tsx
index 0e2e72ded..d0c009b4d 100644
--- a/apps/web/tests/signup-nudge.test.tsx
+++ b/apps/web/tests/signup-nudge.test.tsx
@@ -1,7 +1,9 @@
// @vitest-environment jsdom
-import { cleanup, render, screen } from "@testing-library/react";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { SignupNudge } from "@/components/signup-nudge";
+import { anonymousInvitationDismissedKey } from "@/components/reader-prompts";
const userState = vi.hoisted(() => ({
user: null as { user_id: string } | null,
@@ -22,9 +24,90 @@ beforeEach(() => {
afterEach(() => {
cleanup();
+ vi.restoreAllMocks();
window.history.replaceState({}, "", "/");
});
+function qualify() {
+ sessionStorage.setItem("devfeed:signup-nudge-articles", JSON.stringify(["one", "two", "three"]));
+}
+
+it("remembers keyboard dismissal across routes and remounts for the session", async () => {
+ qualify();
+ const reader = view("/latest");
+ const keyboard = userEvent.setup();
+ screen.getByRole("button", { name: "Not now" }).focus();
+ await keyboard.keyboard("{Enter}");
+ expect(screen.queryByRole("complementary", { name: "Create a DevFeed account" })).toBeNull();
+ expect(sessionStorage.getItem(anonymousInvitationDismissedKey)).toBe("true");
+ reader.rerender(
);
+ expect(screen.queryByRole("button", { name: "Not now" })).toBeNull();
+ reader.unmount();
+ view("/latest");
+ expect(screen.queryByRole("button", { name: "Not now" })).toBeNull();
+});
+
+it("respects existing anonymous Dev Card dismissal", () => {
+ qualify();
+ sessionStorage.setItem(anonymousInvitationDismissedKey, "true");
+ view("/latest");
+ expect(screen.queryByRole("link", { name: "Create account" })).toBeNull();
+});
+
+it("does not apply another account's Dev Card dismissal to a guest", () => {
+ qualify();
+ sessionStorage.setItem(`${anonymousInvitationDismissedKey}:other-reader`, "true");
+ view("/latest");
+ expect(screen.getByRole("link", { name: "Create account" })).toBeTruthy();
+});
+
+it.each(["loading", "unavailable"] as const)(
+ "stays hidden while authentication is %s",
+ (status) => {
+ qualify();
+ userState[status] = true;
+ view("/articles/four");
+ expect(screen.queryByRole("link", { name: "Create account" })).toBeNull();
+ expect(JSON.parse(sessionStorage.getItem("devfeed:signup-nudge-articles")!)).toHaveLength(3);
+ },
+);
+
+it("counts distinct valid article routes and ignores malformed stored values", () => {
+ sessionStorage.setItem("devfeed:signup-nudge-articles", JSON.stringify([null, {}, "bad/slug"]));
+ const reader = view("/articles/one");
+ reader.rerender(
);
+ reader.rerender(
);
+ reader.rerender(
);
+ expect(screen.queryByRole("link", { name: "Create account" })).toBeNull();
+ reader.rerender(
);
+ expect(screen.getByRole("link", { name: "Create account" })).toBeTruthy();
+});
+
+it("retains article progress and dismissal in memory when storage is blocked", () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("blocked");
+ });
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("blocked");
+ });
+ const reader = view("/articles/one");
+ reader.rerender(
);
+ reader.rerender(
);
+ fireEvent.click(screen.getByRole("button", { name: "Not now" }));
+ reader.rerender(
);
+ expect(screen.queryByRole("link", { name: "Create account" })).toBeNull();
+});
+
+it("keeps the current article and filters in the sign-in return link", () => {
+ qualify();
+ window.history.replaceState({}, "", "/articles/three?topic=python&sort=latest");
+ view("/articles/three");
+ const href = screen.getByRole("link", { name: "Create account" }).getAttribute("href")!;
+ expect(new URL(href, window.location.origin).searchParams.get("return_to")).toBe(
+ "/articles/three?topic=python&sort=latest",
+ );
+});
+
function view(pathname: string) {
return render(
);
}
diff --git a/scripts/testing/feed-onboarding.mjs b/scripts/testing/feed-onboarding.mjs
deleted file mode 100644
index 97c768f0f..000000000
--- a/scripts/testing/feed-onboarding.mjs
+++ /dev/null
@@ -1,70 +0,0 @@
-import assert from "node:assert/strict";
-import { mkdir } from "node:fs/promises";
-
-// Run the same modal journey against the website and both built extensions.
-export async function checkFeedOnboarding(page, home, output) {
- await mkdir(output, { recursive: true });
- await page.bringToFront();
- await page.goto(home);
- const dialog = page.getByRole("dialog", { name: "Choose your topics" });
- await dialog.waitFor();
- await page.getByRole("region", { name: "Feed controls" }).waitFor();
- const search = dialog.getByRole("searchbox", { name: "Search topics" });
- const save = dialog.getByRole("button", { name: "Save", exact: true });
- await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).waitFor();
- assert.deepEqual(
- await dialog
- .locator("label")
- .allTextContents()
- .then((names) => names.slice(0, 3)),
- ["TypeScript", "Python", "React"],
- );
- assert.ok(await save.isDisabled());
- await dialog.locator("div.pagination[data-has-more]").scrollIntoViewIfNeeded();
- await dialog.getByText("Couldn’t load more topics.").waitFor();
- await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).check();
- await dialog.getByRole("button", { name: "Try again", exact: true }).click();
- await dialog.getByText("Couldn’t load more topics.").waitFor({ state: "detached" });
- assert.ok(await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).isChecked());
- await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).uncheck();
- await page.screenshot({ path: `${output}/onboarding-desktop.png` });
- await page.keyboard.press("Escape");
- await dialog.waitFor({ state: "detached" });
- await page.reload();
- await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).waitFor();
- await page.setViewportSize({ width: 390, height: 844 });
- await search.fill("no matching topic");
- await dialog.getByText("No topics match your search.").waitFor();
- await search.fill("typescript");
- const choice = dialog.getByRole("checkbox", { name: "TypeScript", exact: true });
- await choice.focus();
- await page.keyboard.press("Space");
- assert.ok(await choice.isChecked());
- await search.fill("");
- await dialog.getByRole("checkbox", { name: "Python", exact: true }).check();
- assert.ok(await save.isDisabled());
- await dialog.getByRole("checkbox", { name: "React", exact: true }).check();
- assert.equal(await save.isDisabled(), false);
- await dialog.getByRole("checkbox", { name: "Docker", exact: true }).check();
- await dialog.getByText("4 selected", { exact: true }).waitFor();
- await dialog.getByRole("checkbox", { name: "Docker", exact: true }).uncheck();
- await page.screenshot({ path: `${output}/onboarding-mobile.png` });
- const bounds = await dialog.boundingBox();
- const buttonBounds = await save.boundingBox();
- assert.ok(bounds && bounds.x >= 0 && bounds.y >= 0 && bounds.y + bounds.height <= 844);
- assert.ok(buttonBounds && buttonBounds.y + buttonBounds.height <= 844);
- assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
- await save.focus();
- await page.evaluate(() => document.querySelector(".sidebar a")?.focus());
- assert.ok(await dialog.evaluate((element) => element.contains(document.activeElement)));
- await save.click();
- await dialog.getByText("Couldn’t save your topics. Please try again.").waitFor();
- assert.equal(await dialog.getByRole("checkbox", { checked: true }).count(), 3);
- await save.click();
- await dialog.waitFor({ state: "detached" });
- await page.locator(".article-card").first().waitFor({ timeout: 15000 });
- await page.reload();
- await page.locator(".article-card").first().waitFor();
- assert.equal(await dialog.count(), 0);
- await page.setViewportSize({ width: 1440, height: 1000 });
-}