Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262import { 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(); }); });});