import { test, expect } from "../../base.js"; import { login } from "../../helpers.js"; import { MockServer } from "../../mockServer.js"; import { createPost } from "../../../shared/factories.js"; const postUri = "at://did:plc:author1/app.bsky.feed.post/abc123"; const mainPost = createPost({ uri: postUri, text: "This is the main post", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", }); // Build a thread with a deep parent chain so the large (main) post starts // below the fold and loading it triggers the pin-under-header scroll. async function setupDeepParentThread(page, { delayMs = null } = {}) { const NUM_PARENTS = 12; const parents = Array.from({ length: NUM_PARENTS }).map((_, index) => createPost({ uri: `at://did:plc:parent${index}/app.bsky.feed.post/parent${index}`, text: `Parent post number ${index}`, authorHandle: `parent${index}.bsky.social`, authorDisplayName: `Parent ${index}`, }), ); const rootParent = parents[0]; const immediateParent = parents[NUM_PARENTS - 1]; const childPost = createPost({ uri: postUri, text: "This is the main post we should scroll to", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", reply: { parent: { uri: immediateParent.uri, cid: immediateParent.cid }, root: { uri: rootParent.uri, cid: rootParent.cid }, }, }); // Nest the parents so the root ends up deepest in the `parent` chain. let parentNode = null; for (const parentPost of parents) { parentNode = { $type: "app.bsky.feed.defs#threadViewPost", post: parentPost, parent: parentNode, replies: [], }; } const mockServer = new MockServer(); mockServer.addPosts([childPost, ...parents]); mockServer.setPostThread( postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: childPost, parent: parentNode, replies: [], }, delayMs === null ? undefined : { delayMs }, ); await mockServer.setup(page); } test.describe("Post thread view", () => { test("should display header and the main post", async ({ page }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view.locator('[data-testid="header-title"]')).toContainText( "Post", { timeout: 10000 }, ); await expect(view.locator('[data-testid="large-post"]')).toBeVisible({ timeout: 10000, }); await expect(view).toContainText("This is the main post"); }); test("should show a follow button on the main post and follow on click", async ({ page, }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const followButton = page.locator( '[data-testid="large-post"] [data-testid="follow-button"]', ); await expect(followButton).toBeVisible({ timeout: 10000 }); await expect(followButton).toHaveAttribute("data-teststate", "follow"); await followButton.click(); // The button stays visible after following so it can be undone await expect(followButton).toHaveAttribute("data-teststate", "following"); await expect(followButton).toBeVisible(); }); test("should not show a follow button when already following the author", async ({ page, }) => { const followedAuthorPost = createPost({ uri: postUri, text: "Post by a followed author", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", authorViewer: { following: "at://did:plc:testuser123/follow/1" }, }); const mockServer = new MockServer(); mockServer.addPosts([followedAuthorPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const largePost = page.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await expect( largePost.locator('[data-testid="follow-button"]'), ).toHaveCount(0); }); test("should not show a follow button on the user's own post", async ({ page, }) => { const ownPost = createPost({ uri: "at://did:plc:testuser123/app.bsky.feed.post/own1", text: "My own post", authorHandle: "testuser.bsky.social", authorDisplayName: "Test User", }); const mockServer = new MockServer(); mockServer.addPosts([ownPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/testuser.bsky.social/post/own1"); const largePost = page.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await expect( largePost.locator('[data-testid="follow-button"]'), ).toHaveCount(0); }); test("should not show a follow button on a root post with a followers-only threadgate", async ({ page, }) => { const gatedPost = createPost({ uri: postUri, text: "Followers-only thread", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", threadgate: { uri: postUri.replace("feed.post", "feed.threadgate"), cid: "bafytgate1", record: { allow: [{ $type: "app.bsky.feed.threadgate#followerRule" }], }, lists: [], }, }); const mockServer = new MockServer(); mockServer.addPosts([gatedPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const largePost = page.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await expect( largePost.locator('[data-testid="follow-button"]'), ).toHaveCount(0); }); test("should show a follow button on a reply in someone else's follower-gated thread", async ({ page, }) => { const rootUri = "at://did:plc:gatekeeper/app.bsky.feed.post/root1"; const rootPost = createPost({ uri: rootUri, text: "Followers-only thread root", authorHandle: "gatekeeper.bsky.social", authorDisplayName: "Gate Keeper", threadgate: { uri: rootUri.replace("feed.post", "feed.threadgate"), cid: "bafytgate2", record: { allow: [{ $type: "app.bsky.feed.threadgate#followerRule" }], }, lists: [], }, }); const replyPost = createPost({ uri: postUri, text: "A reply by someone else", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", reply: { parent: { uri: rootPost.uri, cid: rootPost.cid }, root: { uri: rootPost.uri, cid: rootPost.cid }, }, }); const mockServer = new MockServer(); mockServer.addPosts([replyPost, rootPost]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: replyPost, parent: { $type: "app.bsky.feed.defs#threadViewPost", post: rootPost, parent: null, replies: [], }, replies: [], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const followButton = page.locator( '[data-testid="large-post"] [data-testid="follow-button"]', ); await expect(followButton).toBeVisible({ timeout: 10000 }); }); test("should not show a follow button on the root author's reply in their own follower-gated thread", async ({ page, }) => { const rootUri = "at://did:plc:author1/app.bsky.feed.post/root1"; const rootPost = createPost({ uri: rootUri, text: "Followers-only thread root", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", threadgate: { uri: rootUri.replace("feed.post", "feed.threadgate"), cid: "bafytgate3", record: { allow: [{ $type: "app.bsky.feed.threadgate#followerRule" }], }, lists: [], }, }); const replyPost = createPost({ uri: postUri, text: "Continuing my own gated thread", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", reply: { parent: { uri: rootPost.uri, cid: rootPost.cid }, root: { uri: rootPost.uri, cid: rootPost.cid }, }, }); const mockServer = new MockServer(); mockServer.addPosts([replyPost, rootPost]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: replyPost, parent: { $type: "app.bsky.feed.defs#threadViewPost", post: rootPost, parent: null, replies: [], }, replies: [], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const largePost = page.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await expect( largePost.locator('[data-testid="follow-button"]'), ).toHaveCount(0); }); test("should render an emoji-only post enlarged", async ({ page }) => { const mockServer = new MockServer(); const emojiPost = createPost({ uri: postUri, text: "😀", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", }); mockServer.addPosts([emojiPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const richText = view.locator( '[data-testid="large-post"] .rich-text-emoji-only', ); await expect(richText).toBeVisible({ timeout: 10000 }); // 1.85x the surface's inherited size, asserted as a ratio (not a px // literal) so the post text size can change without breaking the test. const ratio = await richText.evaluate( (element) => parseFloat(getComputedStyle(element).fontSize) / parseFloat(getComputedStyle(element.parentElement).fontSize), ); expect(ratio).toBeCloseTo(1.85, 2); }); test("should display parent post in thread context", async ({ page }) => { const parentPost = createPost({ uri: "at://did:plc:parent1/app.bsky.feed.post/parent1", text: "This is the parent post", authorHandle: "parent1.bsky.social", authorDisplayName: "Parent Author", }); const childPost = createPost({ uri: postUri, text: "This is a reply", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", reply: { parent: { uri: parentPost.uri, cid: parentPost.cid }, root: { uri: parentPost.uri, cid: parentPost.cid }, }, }); const mockServer = new MockServer(); mockServer.addPosts([childPost, parentPost]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: childPost, parent: { $type: "app.bsky.feed.defs#threadViewPost", post: parentPost, parent: null, replies: [], }, replies: [], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view).toContainText("This is the parent post", { timeout: 10000, }); await expect(view).toContainText("This is a reply"); await expect(view.locator('[data-testid="large-post"]')).toBeVisible(); // Parent shown as small post above the main post await expect(view.locator('[data-testid="small-post"]')).toHaveCount(1, { timeout: 10000, }); }); test("should scroll the main post into view on load when it has parents", async ({ page, }) => { await setupDeepParentThread(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); // The page should have scrolled down past the parents... await expect .poll(() => page.evaluate(() => window.scrollY), { timeout: 10000 }) .toBeGreaterThan(0); // ...landing the main post just below the header rather than off-screen. const headerHeight = await view .locator("header") .evaluate((el) => el.offsetHeight); const postTop = await largePost.evaluate( (el) => el.getBoundingClientRect().top, ); expect(postTop).toBeGreaterThanOrEqual(headerHeight - 8); expect(postTop).toBeLessThanOrEqual(headerHeight + 8); }); test("should pin the main post under the header when the thread is shorter than the viewport", async ({ page, }) => { const parentPost = createPost({ uri: "at://did:plc:parent1/app.bsky.feed.post/parent1", text: "Short parent", authorHandle: "parent1.bsky.social", authorDisplayName: "Parent One", }); const childPost = createPost({ uri: postUri, text: "Short main post", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", reply: { parent: { uri: parentPost.uri, cid: parentPost.cid }, root: { uri: parentPost.uri, cid: parentPost.cid }, }, }); const mockServer = new MockServer(); mockServer.addPosts([childPost, parentPost]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: childPost, parent: { $type: "app.bsky.feed.defs#threadViewPost", post: parentPost, parent: null, replies: [], }, replies: [], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); // The whole thread fits well within the viewport, so the pin depends on // the stretched scroll runway below the main post. await expect .poll(() => page.evaluate(() => window.scrollY), { timeout: 10000 }) .toBeGreaterThan(0); const headerHeight = await view .locator("header") .evaluate((el) => el.offsetHeight); const postTop = await largePost.evaluate( (el) => el.getBoundingClientRect().top, ); expect(postTop).toBeGreaterThanOrEqual(headerHeight - 8); expect(postTop).toBeLessThanOrEqual(headerHeight + 8); }); test("should not add scroll runway below a thread without parents", async ({ page, }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view.locator('[data-testid="large-post"]')).toBeVisible({ timeout: 10000, }); // Nothing above the main post means no pinning scroll is needed, so the // page should not be scrollable beyond its natural content. const { scrollHeight, innerHeight } = await page.evaluate(() => ({ scrollHeight: document.documentElement.scrollHeight, innerHeight: window.innerHeight, })); expect(scrollHeight - innerHeight).toBeLessThanOrEqual(1); }); test("should pin the main post under the header even if a programmatic scroll happens while loading", async ({ page, }) => { // Delay the thread response so the skeleton is shown long enough to // scroll before the full thread (with parents) loads. await setupDeepParentThread(page, { delayMs: 1500 }); await login(page); // A short viewport guarantees the loading skeleton is tall enough to scroll. await page.setViewportSize({ width: 400, height: 200 }); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); // Wait until the loading skeleton has rendered (the router resets scroll to // the top as part of navigation, so we must scroll after that happens), // then scroll down while the full thread is still loading. window.scrollTo // fires no input events, mirroring iOS Safari's async history scroll // restore - the case the pin exists to override. await expect( view.locator('[data-testid="post-skeleton"]').first(), ).toBeVisible(); await expect(view.locator('[data-testid="large-post"]')).toHaveCount(0); const extraScroll = 80; await page.evaluate((y) => window.scrollTo(0, y), extraScroll); const actualExtraScroll = await page.evaluate(() => window.scrollY); expect(actualExtraScroll).toBeGreaterThan(0); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); // The stray scroll is overridden, not preserved - the main post pins to // just below the header once the full thread (with parents) loads in. const headerHeight = await view .locator("header") .evaluate((el) => el.offsetHeight); await expect .poll(() => largePost.evaluate((el) => el.getBoundingClientRect().top), { timeout: 10000, }) .toBeLessThanOrEqual(headerHeight + 8); const postTop = await largePost.evaluate( (el) => el.getBoundingClientRect().top, ); expect(postTop).toBeGreaterThanOrEqual(headerHeight - 8); }); test("should not pin the main post when the user scrolls while loading", async ({ page, }) => { await setupDeepParentThread(page, { delayMs: 1500 }); await login(page); await page.setViewportSize({ width: 400, height: 200 }); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); // Scroll with real wheel input while the thread is still loading - unlike // a programmatic scroll, this marks the user as having taken over. await expect( view.locator('[data-testid="post-skeleton"]').first(), ).toBeVisible(); await expect(view.locator('[data-testid="large-post"]')).toHaveCount(0); await page.mouse.move(200, 100); await page.mouse.wheel(0, 80); await expect .poll(() => page.evaluate(() => window.scrollY)) .toBeGreaterThan(0); const userScrollY = await page.evaluate(() => window.scrollY); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); // Give a would-be pin (1000ms duration) time to fire, then check the // user's scroll position was left alone. await page.waitForTimeout(1200); const scrollYAfterLoad = await page.evaluate(() => window.scrollY); expect(Math.abs(scrollYAfterLoad - userScrollY)).toBeLessThanOrEqual(1); const headerHeight = await view .locator("header") .evaluate((el) => el.offsetHeight); const postTop = await largePost.evaluate( (el) => el.getBoundingClientRect().top, ); expect(postTop).toBeGreaterThan(headerHeight + 8); }); test("should show 'Load parent post' link when reply ref is broken", async ({ page, }) => { const parentUri = "at://did:plc:parentbroken/app.bsky.feed.post/parent1"; // Child post has reply refs but the thread tree omits the parent const childPost = createPost({ uri: postUri, text: "Reply with broken parent ref", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", reply: { parent: { uri: parentUri, cid: "bafyparentbroken" }, root: { uri: parentUri, cid: "bafyparentbroken" }, }, }); const mockServer = new MockServer(); mockServer.addPosts([childPost]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: childPost, parent: null, replies: [], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view.locator('[data-testid="large-post"]')).toBeVisible({ timeout: 10000, }); const loadParentLink = view.locator( '[data-testid="post-thread-load-parent"]', ); await expect(loadParentLink).toBeVisible(); await expect(loadParentLink).toHaveAttribute( "href", "/profile/did:plc:parentbroken/post/parent1", ); }); test("should display replies", async ({ page }) => { const postWithReplies = createPost({ uri: postUri, text: "Post with replies", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 2, }); const reply1 = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "First reply", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", likeCount: 10, }); const reply2 = createPost({ uri: "at://did:plc:replier2/app.bsky.feed.post/reply2", text: "Second reply", authorHandle: "replier2.bsky.social", authorDisplayName: "Replier Two", likeCount: 5, }); const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, reply1, reply2]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: reply1, replies: [], }, { $type: "app.bsky.feed.defs#threadViewPost", post: reply2, replies: [], }, ], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view.locator('[data-testid="large-post"]')).toBeVisible({ timeout: 10000, }); await expect(view).toContainText("First reply", { timeout: 10000 }); await expect(view).toContainText("Second reply"); }); test("should display reply prompt for authenticated user", async ({ page, }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view.locator(".post-thread-reply-prompt")).toContainText( "Write your reply", { timeout: 10000 }, ); }); test("should display post action counts on the main post", async ({ page, }) => { const postWithCounts = createPost({ uri: postUri, text: "Popular post", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", likeCount: 42, repostCount: 10, quoteCount: 3, }); const mockServer = new MockServer(); mockServer.addPosts([postWithCounts]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await expect(largePost).toContainText("42"); await expect(largePost).toContainText("likes"); await expect(largePost).toContainText("10"); await expect(largePost).toContainText("reposts"); }); test("should like the main post when clicking the like button", async ({ page, }) => { const post = createPost({ uri: postUri, text: "Post to like", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", likeCount: 3, }); const mockServer = new MockServer(); mockServer.addPosts([post]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await largePost.locator('[data-testid="like-button"]').click(); await expect( largePost.locator('[data-testid="like-button"].active'), ).toBeVisible({ timeout: 10000, }); }); test("should repost the main post when clicking repost in the context menu", async ({ page, }) => { const post = createPost({ uri: postUri, text: "Post to repost", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", repostCount: 2, }); const mockServer = new MockServer(); mockServer.addPosts([post]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await largePost.locator('[data-testid="repost-button"]').click(); await page.locator('[data-testid="menu-action-repost"]').click(); await expect( largePost.locator('[data-testid="repost-button"].reposted'), ).toBeVisible({ timeout: 10000 }); }); test("should bookmark the main post when clicking the bookmark button", async ({ page, }) => { const post = createPost({ uri: postUri, text: "Post to bookmark", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", }); const mockServer = new MockServer(); mockServer.addPosts([post]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await largePost.locator('[data-testid="bookmark-button"]').click(); await expect( largePost.locator('[data-testid="bookmark-button"].active'), ).toBeVisible({ timeout: 10000 }); }); test("should navigate to reply thread when clicking a reply", async ({ page, }) => { const postWithReplies = createPost({ uri: postUri, text: "Main post", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 1, }); const reply = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "A reply to click", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", }); const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, reply]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: reply, replies: [], }, ], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view).toContainText("A reply to click", { timeout: 10000 }); await view.locator('[data-testid="small-post"]').click(); await expect(page).toHaveURL( /\/profile\/replier1\.bsky\.social\/post\/reply1/, { timeout: 10000, }, ); }); test("should post a reply via the reply prompt", async ({ page }) => { const post = createPost({ uri: postUri, text: "Post to reply to", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", }); const mockServer = new MockServer(); mockServer.addPosts([post]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view.locator(".post-thread-reply-prompt")).toContainText( "Write your reply", { timeout: 10000 }, ); // Click the reply prompt to open the composer await view.locator(".post-thread-reply-prompt").click(); // Type into the composer's rich text input const composer = page.locator("post-composer"); await expect(composer.locator("dialog.post-composer")).toBeVisible({ timeout: 10000, }); await composer .locator("rich-text-input [contenteditable]") .fill("My reply text"); // Click the Reply button to send await composer.locator('[data-testid="composer-submit-button"]').click(); // The composer should close and the reply should appear in the thread await expect(composer.locator("dialog.post-composer")).not.toBeVisible({ timeout: 10000, }); await expect(view).toContainText("My reply text", { timeout: 10000 }); }); test("should display 'Post not found' for 404 errors", async ({ page }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); // Override getPostThread to return 400 NotFound await page.route("**/xrpc/app.bsky.feed.getPostThread*", (route) => route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "NotFound", message: "Post not found" }), }), ); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view.locator('[data-testid="post-not-found"]')).toBeVisible({ timeout: 10000, }); }); test("should display 'Error loading thread' for server errors", async ({ page, }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); // Override getPostThread to return 500 await page.route("**/xrpc/app.bsky.feed.getPostThread*", (route) => route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "InternalServerError" }), }), ); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view.locator('[data-testid="thread-error"]')).toBeVisible({ timeout: 10000, }); }); test.describe("Logged-out behavior", () => { test("should render thread and replies publicly", async ({ page }) => { const postWithReplies = createPost({ uri: postUri, text: "This is a public post", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 1, }); const reply = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "A public reply", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", }); const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, reply]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: reply, replies: [], }, ], }); await mockServer.setup(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view.locator('[data-testid="large-post"]')).toBeVisible({ timeout: 10000, }); await expect(view).toContainText("This is a public post"); await expect(view).toContainText("A public reply", { timeout: 10000 }); }); test("should hide reply composer and interaction buttons", async ({ page, }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view.locator('[data-testid="large-post"]')).toBeVisible({ timeout: 10000, }); // Reply composer should be hidden for logged-out users await expect(view.locator(".post-thread-reply-prompt")).not.toBeVisible(); }); test("should show lock message for !no-unauthenticated anchor post", async ({ page, }) => { const restrictedPost = createPost({ uri: postUri, text: "This post is restricted", authorHandle: "restricted.bsky.social", authorDisplayName: "Restricted Author", }); restrictedPost.author.labels = [ { val: "!no-unauthenticated", src: restrictedPost.author.did }, ]; const mockServer = new MockServer(); mockServer.addPosts([restrictedPost]); await mockServer.setup(page); await page.goto("/profile/restricted.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view).toContainText( "This author has chosen to make their posts visible only to people who are signed in.", { timeout: 10000 }, ); await expect(view).not.toContainText("This post is restricted"); }); test("should show lock message for !no-unauthenticated parent post", async ({ page, }) => { const restrictedParent = createPost({ uri: "at://did:plc:restricted/app.bsky.feed.post/parent1", text: "Restricted parent", authorHandle: "restricted.bsky.social", authorDisplayName: "Restricted Author", }); restrictedParent.author.labels = [ { val: "!no-unauthenticated", src: restrictedParent.author.did }, ]; const childPost = createPost({ uri: postUri, text: "This is a reply to restricted parent", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", reply: { parent: { uri: restrictedParent.uri, cid: restrictedParent.cid }, root: { uri: restrictedParent.uri, cid: restrictedParent.cid }, }, }); const mockServer = new MockServer(); mockServer.addPosts([childPost, restrictedParent]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: childPost, parent: { $type: "app.bsky.feed.defs#threadViewPost", post: restrictedParent, parent: null, replies: [], }, replies: [], }); await mockServer.setup(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view).toContainText( "This author has chosen to make their posts visible only to people who are signed in.", { timeout: 10000 }, ); await expect(view).not.toContainText("Restricted parent"); await expect(view).toContainText("This is a reply to restricted parent"); }); test("should hide !no-unauthenticated replies but show other replies when logged out", async ({ page, }) => { const postWithReplies = createPost({ uri: postUri, text: "Post with restricted reply", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 2, }); const normalReply = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "Normal visible reply", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", }); const restrictedReply = createPost({ uri: "at://did:plc:restricted/app.bsky.feed.post/reply2", text: "This reply should be hidden", authorHandle: "restricted.bsky.social", authorDisplayName: "Restricted Author", }); restrictedReply.author.labels = [ { val: "!no-unauthenticated", src: restrictedReply.author.did }, ]; const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, normalReply, restrictedReply]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: normalReply, replies: [], }, { $type: "app.bsky.feed.defs#threadViewPost", post: restrictedReply, replies: [], }, ], }); await mockServer.setup(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view.locator('[data-testid="large-post"]')).toBeVisible({ timeout: 10000, }); await expect(view).toContainText("Normal visible reply", { timeout: 10000, }); await expect(view).not.toContainText("This reply should be hidden"); }); }); test.describe("Hidden replies section", () => { test("should place muted account replies in the hidden section", async ({ page, }) => { const postWithReplies = createPost({ uri: postUri, text: "Post with muted reply", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 2, }); const normalReply = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "Normal reply", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", }); const mutedReply = createPost({ uri: "at://did:plc:muted1/app.bsky.feed.post/mutedreply", text: "Reply from muted user", authorHandle: "muted1.bsky.social", authorDisplayName: "Muted User", }); mutedReply.author.viewer.muted = true; const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, normalReply, mutedReply]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: normalReply, replies: [], }, { $type: "app.bsky.feed.defs#threadViewPost", post: mutedReply, replies: [], }, ], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view).toContainText("Normal reply", { timeout: 10000 }); // Muted reply should not be in the main reply chains const replyChains = view.locator(".post-thread-reply-chains"); await expect(replyChains).not.toContainText("Reply from muted user"); // Hidden section should be present with the toggle button const hiddenSection = view.locator("hidden-replies-section"); await expect(hiddenSection).toBeVisible(); await expect( hiddenSection.locator(".hidden-replies-button"), ).toContainText("Show more replies"); }); test("should place content-labeled replies in the hidden section", async ({ page, }) => { const postWithReplies = createPost({ uri: postUri, text: "Post with labeled reply", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 2, }); const normalReply = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "Normal reply", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", }); const labeledReply = createPost({ uri: "at://did:plc:labeled1/app.bsky.feed.post/labeledreply", text: "Labeled reply content", authorHandle: "labeled1.bsky.social", authorDisplayName: "Labeled User", labels: [ { val: "!warn", src: "did:plc:ar7c4by46qjdydhdevvrndac", uri: "at://did:plc:labeled1/app.bsky.feed.post/labeledreply", cts: "2025-01-01T00:00:00.000Z", }, ], }); const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, normalReply, labeledReply]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: normalReply, replies: [], }, { $type: "app.bsky.feed.defs#threadViewPost", post: labeledReply, replies: [], }, ], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view).toContainText("Normal reply", { timeout: 10000 }); // Labeled reply should not be in the main reply chains const replyChains = view.locator(".post-thread-reply-chains"); await expect(replyChains).not.toContainText("Labeled reply content"); // Hidden section should be present const hiddenSection = view.locator("hidden-replies-section"); await expect(hiddenSection).toBeVisible(); }); test("should reveal hidden replies when clicking 'Show more replies'", async ({ page, }) => { const postWithReplies = createPost({ uri: postUri, text: "Post with hidden replies", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 2, }); const normalReply = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "Normal reply", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", }); const mutedReply = createPost({ uri: "at://did:plc:muted1/app.bsky.feed.post/mutedreply", text: "Reply from muted user", authorHandle: "muted1.bsky.social", authorDisplayName: "Muted User", }); mutedReply.author.viewer.muted = true; const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, normalReply, mutedReply]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: normalReply, replies: [], }, { $type: "app.bsky.feed.defs#threadViewPost", post: mutedReply, replies: [], }, ], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const hiddenSection = view.locator("hidden-replies-section"); await expect(hiddenSection.locator(".hidden-replies-button")).toBeVisible( { timeout: 10000 }, ); // Hidden content should not be visible initially await expect(hiddenSection.locator(".toggle-content")).not.toBeVisible(); // Click to expand await hiddenSection.locator(".hidden-replies-button").click(); // The toggle content should now be visible with the muted reply await expect(hiddenSection.locator(".toggle-content")).toBeVisible(); await expect(hiddenSection).toContainText("Reply from muted user"); // The button should be hidden after expanding await expect( hiddenSection.locator(".hidden-replies-button"), ).not.toBeVisible(); }); test("should not show hidden section when all replies are normal", async ({ page, }) => { const postWithReplies = createPost({ uri: postUri, text: "Post with normal replies only", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 1, }); const normalReply = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "Normal reply", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", }); const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, normalReply]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: normalReply, replies: [], }, ], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view).toContainText("Normal reply", { timeout: 10000 }); // Hidden section should not exist await expect(view.locator("hidden-replies-section")).not.toBeAttached(); }); test("should place sentiment-hidden replies in the hidden section", async ({ page, }) => { const postWithReplies = createPost({ uri: postUri, text: "Post with sentiment-hidden reply", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 2, }); const normalReply = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "Normal reply", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", }); const hiddenReply = createPost({ uri: "at://did:plc:replier2/app.bsky.feed.post/hiddenreply", text: "Sentiment-hidden reply content", authorHandle: "replier2.bsky.social", authorDisplayName: "Replier Two", }); const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, normalReply, hiddenReply]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: normalReply, replies: [], }, { $type: "app.bsky.feed.defs#threadViewPost", post: hiddenReply, replies: [], }, ], }); mockServer.setPostThreadOther(postUri, [{ uri: hiddenReply.uri }]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view).toContainText("Normal reply", { timeout: 10000 }); // Sentiment-hidden reply should not be in the main reply chains const replyChains = view.locator(".post-thread-reply-chains"); await expect(replyChains).not.toContainText( "Sentiment-hidden reply content", ); // Hidden section should be present const hiddenSection = view.locator("hidden-replies-section"); await expect(hiddenSection).toBeVisible(); await expect( hiddenSection.locator(".hidden-replies-button"), ).toContainText("Show more replies"); }); test("should not affect replies when getPostThreadOtherV2 returns empty", async ({ page, }) => { const postWithReplies = createPost({ uri: postUri, text: "Post with all normal replies", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 2, }); const reply1 = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "First normal reply", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", }); const reply2 = createPost({ uri: "at://did:plc:replier2/app.bsky.feed.post/reply2", text: "Second normal reply", authorHandle: "replier2.bsky.social", authorDisplayName: "Replier Two", }); const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, reply1, reply2]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: reply1, replies: [], }, { $type: "app.bsky.feed.defs#threadViewPost", post: reply2, replies: [], }, ], }); // Empty postThreadOther — no replies hidden by sentiment await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view).toContainText("First normal reply", { timeout: 10000, }); await expect(view).toContainText("Second normal reply"); // No hidden section await expect(view.locator("hidden-replies-section")).not.toBeAttached(); }); test("should reveal sentiment-hidden replies when clicking 'Show more replies'", async ({ page, }) => { const postWithReplies = createPost({ uri: postUri, text: "Post with sentiment-hidden reply", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 2, }); const normalReply = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "Normal reply", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", }); const hiddenReply = createPost({ uri: "at://did:plc:replier2/app.bsky.feed.post/hiddenreply", text: "Sentiment-hidden reply revealed", authorHandle: "replier2.bsky.social", authorDisplayName: "Replier Two", }); const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, normalReply, hiddenReply]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: normalReply, replies: [], }, { $type: "app.bsky.feed.defs#threadViewPost", post: hiddenReply, replies: [], }, ], }); mockServer.setPostThreadOther(postUri, [{ uri: hiddenReply.uri }]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const hiddenSection = view.locator("hidden-replies-section"); await expect(hiddenSection.locator(".hidden-replies-button")).toBeVisible( { timeout: 10000 }, ); // Click to expand await hiddenSection.locator(".hidden-replies-button").click(); // The hidden reply should now be visible await expect(hiddenSection.locator(".toggle-content")).toBeVisible(); await expect(hiddenSection).toContainText( "Sentiment-hidden reply revealed", ); }); test("should place muted word replies in the hidden section (via getPreferences)", async ({ page, }) => { const postWithReplies = createPost({ uri: postUri, text: "Post with muted word reply", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", replyCount: 2, }); const normalReply = createPost({ uri: "at://did:plc:replier1/app.bsky.feed.post/reply1", text: "Normal reply", authorHandle: "replier1.bsky.social", authorDisplayName: "Replier One", }); const mutedWordReply = createPost({ uri: "at://did:plc:replier2/app.bsky.feed.post/mutedwordreply", text: "This contains spoilers for the movie", authorHandle: "replier2.bsky.social", authorDisplayName: "Replier Two", }); const mockServer = new MockServer(); mockServer.addPosts([postWithReplies, normalReply, mutedWordReply]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: postWithReplies, parent: null, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: normalReply, replies: [], }, { $type: "app.bsky.feed.defs#threadViewPost", post: mutedWordReply, replies: [], }, ], }); await mockServer.setup(page); // Override preferences to include muted words await page.route("**/xrpc/app.bsky.actor.getPreferences*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ preferences: [ { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [], }, { $type: "app.bsky.actor.defs#mutedWordsPref", items: [ { value: "spoilers", targets: ["content"], actorTarget: "all", }, ], }, ], }), }), ); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect(view).toContainText("Normal reply", { timeout: 10000 }); // Muted word reply should not be in the main reply chains const replyChains = view.locator(".post-thread-reply-chains"); await expect(replyChains).not.toContainText("spoilers"); // Hidden section should be present const hiddenSection = view.locator("hidden-replies-section"); await expect(hiddenSection).toBeVisible(); }); }); test.describe("Muted parents", () => { test("should expand muted parent when clicking the toggle", async ({ page, }) => { const parentPost = createPost({ uri: "at://did:plc:mutedparent/app.bsky.feed.post/parent1", text: "This is a muted parent post", authorHandle: "mutedparent.bsky.social", authorDisplayName: "Muted Parent", }); parentPost.author.viewer.muted = true; const childPost = createPost({ uri: postUri, text: "Reply to muted parent", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", reply: { parent: { uri: parentPost.uri, cid: parentPost.cid }, root: { uri: parentPost.uri, cid: parentPost.cid }, }, }); const mockServer = new MockServer(); mockServer.addPosts([childPost, parentPost]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: childPost, parent: { $type: "app.bsky.feed.defs#threadViewPost", post: parentPost, parent: null, replies: [], }, replies: [], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const toggle = view.locator("muted-parent-toggle"); await expect(toggle).toBeVisible({ timeout: 10000 }); // Content should be hidden initially await expect(toggle.locator(".toggle-content")).toBeHidden(); // Click to expand await toggle.locator(".muted-parent-toggle-button").click(); await expect(toggle.locator(".toggle-content")).toBeVisible(); await expect(toggle).toContainText("This is a muted parent post"); }); test("should show 'Hidden by muted word' label for parent with viewer.hasMutedWord", async ({ page, }) => { const parentPost = createPost({ uri: "at://did:plc:mutedwordparent/app.bsky.feed.post/parent1", text: "Parent with muted word", authorHandle: "mutedwordparent.bsky.social", authorDisplayName: "Muted Word Parent", viewer: { hasMutedWord: true }, }); const childPost = createPost({ uri: postUri, text: "Reply to muted word parent", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", reply: { parent: { uri: parentPost.uri, cid: parentPost.cid }, root: { uri: parentPost.uri, cid: parentPost.cid }, }, }); const mockServer = new MockServer(); mockServer.addPosts([childPost, parentPost]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: childPost, parent: { $type: "app.bsky.feed.defs#threadViewPost", post: parentPost, parent: null, replies: [], }, replies: [], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const toggle = view.locator("muted-parent-toggle"); await expect(toggle).toBeVisible({ timeout: 10000 }); await expect(toggle).toContainText("Hidden by muted word"); }); test("should show 'Post hidden by you' label for parent with viewer.isHidden", async ({ page, }) => { const parentPost = createPost({ uri: "at://did:plc:hiddenparent/app.bsky.feed.post/parent1", text: "Parent hidden by user", authorHandle: "hiddenparent.bsky.social", authorDisplayName: "Hidden Parent", viewer: { isHidden: true }, }); const childPost = createPost({ uri: postUri, text: "Reply to hidden parent", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", reply: { parent: { uri: parentPost.uri, cid: parentPost.cid }, root: { uri: parentPost.uri, cid: parentPost.cid }, }, }); const mockServer = new MockServer(); mockServer.addPosts([childPost, parentPost]); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#threadViewPost", post: childPost, parent: { $type: "app.bsky.feed.defs#threadViewPost", post: parentPost, parent: null, replies: [], }, replies: [], }); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const toggle = view.locator("muted-parent-toggle"); await expect(toggle).toBeVisible({ timeout: 10000 }); await expect(toggle).toContainText("Post hidden by you"); }); }); test.describe("Translate post", () => { test("should open Google Translate in a new tab with the post text", async ({ page, }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await largePost.locator('[data-testid="post-action-more"]').click(); const translateItem = page.locator( '[data-testid="menu-action-post-translate"]', ); await expect(translateItem).toBeVisible(); const popupPromise = page.waitForEvent("popup"); await translateItem.click(); const popup = await popupPromise; const url = new URL(popup.url()); expect(url.origin + url.pathname).toBe("https://translate.google.com/"); expect(url.searchParams.get("sl")).toBe("auto"); expect(url.searchParams.get("tl")).toBeTruthy(); expect(url.searchParams.get("text")).toBe("This is the main post"); }); test("should not show 'Translate post' for posts with no text", async ({ page, }) => { const emptyTextPost = createPost({ uri: postUri, text: "", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", }); const mockServer = new MockServer(); mockServer.addPosts([emptyTextPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await largePost.locator('[data-testid="post-action-more"]').click(); await expect( page.locator('[data-testid="menu-action-post-translate"]'), ).toHaveCount(0); }); }); test.describe("Moderation actions", () => { test("should show 'Hide post for me' in context menu for non-user posts", async ({ page, }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); // Open context menu await largePost.locator('[data-testid="post-action-more"]').click(); await expect( page.locator('[data-testid="menu-action-post-hide"]'), ).toBeVisible(); }); test("should show 'Mute account' for non-muted authors", async ({ page, }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await largePost.locator('[data-testid="post-action-more"]').click(); await expect( page.locator( '[data-testid="menu-action-post-mute"][data-teststate="unmuted"]', ), ).toBeVisible(); }); test("should show 'Unmute account' for muted authors", async ({ page }) => { const mutedPost = createPost({ uri: postUri, text: "Post by muted author", authorHandle: "mutedauthor.bsky.social", authorDisplayName: "Muted Author", }); mutedPost.author.viewer.muted = true; const mockServer = new MockServer(); mockServer.addPosts([mutedPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/mutedauthor.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await largePost.locator('[data-testid="post-action-more"]').click(); await expect( page.locator( '[data-testid="menu-action-post-mute"][data-teststate="muted"]', ), ).toBeVisible(); }); test("should show 'Block account' for non-blocked authors", async ({ page, }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await largePost.locator('[data-testid="post-action-more"]').click(); await expect( page.locator( '[data-testid="menu-action-post-block"][data-teststate="not-blocking"]', ), ).toBeVisible(); }); test("should render a blocked tombstone for posts from blocked authors", async ({ page, }) => { const blockedPost = createPost({ uri: postUri, text: "Post by blocked author", authorHandle: "blockedauthor.bsky.social", authorDisplayName: "Blocked Author", }); blockedPost.author.viewer.blocking = "at://did:plc:testuser123/app.bsky.graph.block/existing-block"; const mockServer = new MockServer(); mockServer.addPosts([blockedPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/blockedauthor.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); await expect( view.locator('[data-testid="post-tombstone-blocked"]'), ).toBeVisible({ timeout: 10000 }); await expect(view.locator('[data-testid="large-post"]')).toHaveCount(0); await expect(view).not.toContainText("Post by blocked author"); }); test("should show 'Report post' in context menu", async ({ page }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await largePost.locator('[data-testid="post-action-more"]').click(); await expect( page.locator('[data-testid="menu-action-post-report"]'), ).toBeVisible(); }); test("should not show moderation actions on current user's own posts", async ({ page, }) => { const ownPost = createPost({ uri: "at://did:plc:testuser123/app.bsky.feed.post/abc123", text: "My own post", authorHandle: "testuser.bsky.social", authorDisplayName: "Test User", }); const mockServer = new MockServer(); mockServer.addPosts([ownPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/testuser.bsky.social/post/abc123"); const view = page.locator("#post-detail-view"); const largePost = view.locator('[data-testid="large-post"]'); await expect(largePost).toBeVisible({ timeout: 10000 }); await largePost.locator('[data-testid="post-action-more"]').click(); // Moderation actions should not be present on own post await expect( page.locator('[data-testid="menu-action-post-mute"]'), ).not.toBeAttached(); await expect( page.locator('[data-testid="menu-action-post-block"]'), ).not.toBeAttached(); await expect( page.locator('[data-testid="menu-action-post-report"]'), ).not.toBeAttached(); // Delete post should be available on own post await expect( page.locator('[data-testid="menu-action-post-delete"]'), ).toBeVisible(); }); }); test.describe("threadgate badge", () => { test("shows 'Everybody can reply' when post has no threadgate", async ({ page, }) => { const mockServer = new MockServer(); mockServer.addPosts([mainPost]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); await expect(page.locator('[data-testid="large-post"]')).toBeVisible({ timeout: 10000, }); const badge = page.locator('[data-testid="who-can-reply-badge"]'); await expect(badge).toHaveText("Everybody can reply"); await expect( badge.locator('app-icon[icon="globe-grid-line"]'), ).toBeVisible(); }); test("shows 'Replies disabled' and modal for empty allow", async ({ page, }) => { const post = createPost({ uri: postUri, text: "Locked post", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", threadgate: { uri: postUri.replace("feed.post", "feed.threadgate"), cid: "bafyt1", record: { allow: [] }, lists: [], }, }); const mockServer = new MockServer(); mockServer.addPosts([post]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const badge = page.locator('[data-testid="who-can-reply-badge"]'); await expect(badge).toHaveText("Replies disabled", { timeout: 10000 }); await badge.click(); const modal = page.locator('[data-testid="who-can-reply-modal"]'); await expect(modal).toContainText("Replies to this post are disabled."); await modal.locator(".primary-button").click(); await expect(modal).not.toBeAttached(); }); test("shows 'Some people can reply' and lists rules in modal", async ({ page, }) => { const listUri = "at://did:plc:author1/app.bsky.graph.list/list1"; const post = createPost({ uri: postUri, text: "Restricted post", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", threadgate: { uri: postUri.replace("feed.post", "feed.threadgate"), cid: "bafyt2", record: { allow: [ { $type: "app.bsky.feed.threadgate#mentionRule" }, { $type: "app.bsky.feed.threadgate#followingRule" }, { $type: "app.bsky.feed.threadgate#listRule", list: listUri }, ], }, lists: [ { uri: listUri, cid: "bafyl1", name: "Cool people", purpose: "app.bsky.graph.defs#curatelist", }, ], }, }); const mockServer = new MockServer(); mockServer.addPosts([post]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const badge = page.locator('[data-testid="who-can-reply-badge"]'); await expect(badge).toHaveText("Some people can reply", { timeout: 10000, }); await badge.click(); const modal = page.locator('[data-testid="who-can-reply-modal"]'); await expect(modal).toContainText("mentioned users"); await expect(modal).toContainText( "users followed by @author1.bsky.social", ); await expect(modal).toContainText("Cool people members"); }); test("shows quote-disabled note in modal when embeddingDisabled", async ({ page, }) => { const post = createPost({ uri: postUri, text: "No quotes please", authorHandle: "author1.bsky.social", authorDisplayName: "Author One", viewer: { embeddingDisabled: true }, }); const mockServer = new MockServer(); mockServer.addPosts([post]); await mockServer.setup(page); await login(page); await page.goto("/profile/author1.bsky.social/post/abc123"); const badge = page.locator('[data-testid="who-can-reply-badge"]'); await expect(badge).toHaveText("Everybody can reply", { timeout: 10000 }); await badge.click(); const modal = page.locator('[data-testid="who-can-reply-modal"]'); await expect(modal).toContainText("Everybody can reply to this post."); await expect(modal).toContainText( "No one but the author can quote this post.", ); }); }); test.describe("Tombstone thread root", () => { test("should render without crashing when thread root is a notFoundPost", async ({ page, }) => { const mockServer = new MockServer(); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#notFoundPost", uri: postUri, }); await mockServer.setup(page); await login(page); await page.goto("/profile/did:plc:author1/post/abc123"); const view = page.locator("#post-detail-view"); await expect( view.locator('[data-testid="post-tombstone-not-found"]'), ).toBeVisible({ timeout: 10000 }); // The view should NOT fall into the generic thread-error template await expect( view.locator('[data-testid="thread-error"]'), ).not.toBeAttached(); // No reply prompt should be shown for a tombstone root await expect(view.locator(".post-thread-reply-prompt")).not.toBeVisible(); }); test("should render without crashing when thread root is a blockedPost", async ({ page, }) => { const mockServer = new MockServer(); mockServer.setPostThread(postUri, { $type: "app.bsky.feed.defs#blockedPost", uri: postUri, author: { did: "did:plc:blockedauthor", viewer: { blocking: "at://did:plc:testuser123/app.bsky.graph.block/existing-block", }, }, }); await mockServer.setup(page); await login(page); await page.goto("/profile/did:plc:author1/post/abc123"); const view = page.locator("#post-detail-view"); await expect( view.locator('[data-testid="post-tombstone-blocked"]'), ).toBeVisible({ timeout: 10000 }); await expect( view.locator('[data-testid="thread-error"]'), ).not.toBeAttached(); await expect(view.locator(".post-thread-reply-prompt")).not.toBeVisible(); }); }); });