about summary refs log tree commit diff
path: root/__e2e__
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-03-31 13:17:26 -0500
committerGitHub <noreply@github.com>2023-03-31 13:17:26 -0500
commita3334a01a221877d3e06e02f960fda441f3460bd (patch)
tree64cdbb1232d1a3c00750c346b6e3ae529b51d1b0 /__e2e__
parent19f3a2fa92a61ddb785fc4e42d73792c1d0e772c (diff)
downloadvoidsky-a3334a01a221877d3e06e02f960fda441f3460bd.tar.zst
Lex refactor (#362)
* Remove the hackcheck for upgrades

* Rename the PostEmbeds folder to match the codebase style

* Updates to latest lex refactor

* Update to use new bsky agent

* Update to use api package's richtext library

* Switch to upsertProfile

* Add TextEncoder/TextDecoder polyfill

* Add Intl.Segmenter polyfill

* Update composer to calculate lengths by grapheme

* Fix detox

* Fix login in e2e

* Create account e2e passing

* Implement an e2e mocking framework

* Don't use private methods on mobx models as mobx can't track them

* Add tooling for e2e-specific builds and add e2e media-picker mock

* Add some tests and fix some bugs around profile editing

* Add shell tests

* Add home screen tests

* Add thread screen tests

* Add tests for other user profile screens

* Add search screen tests

* Implement profile imagery change tools and tests

* Update to new embed behaviors

* Add post tests

* Fix to profile-screen test

* Fix session resumption

* Update web composer to new api

* 1.11.0

* Fix pagination cursor parameters

* Add quote posts to notifications

* Fix embed layouts

* Remove youtube inline player and improve tap handling on link cards

* Reset minimal shell mode on all screen loads and feed swipes (close #299)

* Update podfile.lock

* Improve post notfound UI (close #366)

* Bump atproto packages
Diffstat (limited to '__e2e__')
-rw-r--r--__e2e__/jest.config.js12
-rw-r--r--__e2e__/mock-server.ts75
-rw-r--r--__e2e__/tests/composer.test.ts108
-rw-r--r--__e2e__/tests/create-account.test.ts31
-rw-r--r--__e2e__/tests/home-screen.test.ts92
-rw-r--r--__e2e__/tests/login.test.ts19
-rw-r--r--__e2e__/tests/profile-screen.test.ts173
-rw-r--r--__e2e__/tests/search-screen.test.ts24
-rw-r--r--__e2e__/tests/shell.test.ts34
-rw-r--r--__e2e__/tests/thread-screen.test.ts123
-rw-r--r--__e2e__/util.ts96
11 files changed, 787 insertions, 0 deletions
diff --git a/__e2e__/jest.config.js b/__e2e__/jest.config.js
new file mode 100644
index 000000000..80c2ad5b3
--- /dev/null
+++ b/__e2e__/jest.config.js
@@ -0,0 +1,12 @@
+/** @type {import('@jest/types').Config.InitialOptions} */
+module.exports = {
+  rootDir: '..',
+  testMatch: ['<rootDir>/__e2e__/**/*.test.ts'],
+  testTimeout: 120000,
+  maxWorkers: 1,
+  globalSetup: 'detox/runners/jest/globalSetup',
+  globalTeardown: 'detox/runners/jest/globalTeardown',
+  reporters: ['detox/runners/jest/reporter'],
+  testEnvironment: 'detox/runners/jest/testEnvironment',
+  verbose: true,
+}
diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts
new file mode 100644
index 000000000..7a2be6060
--- /dev/null
+++ b/__e2e__/mock-server.ts
@@ -0,0 +1,75 @@
+import {createServer as createHTTPServer} from 'node:http'
+import {parse} from 'node:url'
+import {createServer, TestPDS} from '../jest/test-pds'
+
+async function main() {
+  let server: TestPDS
+  createHTTPServer(async (req, res) => {
+    const url = parse(req.url || '/', true)
+    if (req.method !== 'POST') {
+      return res.writeHead(200).end()
+    }
+    try {
+      console.log('Closing old server')
+      await server?.close()
+      console.log('Starting new server')
+      server = await createServer()
+      console.log('Listening at', server.pdsUrl)
+      if (url?.query) {
+        if ('users' in url.query) {
+          console.log('Generating mock users')
+          await server.mocker.createUser('alice')
+          await server.mocker.createUser('bob')
+          await server.mocker.createUser('carla')
+          await server.mocker.users.alice.agent.upsertProfile(() => ({
+            displayName: 'Alice',
+            description: 'Test user 1',
+          }))
+          await server.mocker.users.bob.agent.upsertProfile(() => ({
+            displayName: 'Bob',
+            description: 'Test user 2',
+          }))
+          await server.mocker.users.carla.agent.upsertProfile(() => ({
+            displayName: 'Carla',
+            description: 'Test user 3',
+          }))
+        }
+        if ('follows' in url.query) {
+          console.log('Generating mock follows')
+          await server.mocker.follow('alice', 'bob')
+          await server.mocker.follow('alice', 'carla')
+          await server.mocker.follow('bob', 'alice')
+          await server.mocker.follow('bob', 'carla')
+          await server.mocker.follow('carla', 'alice')
+          await server.mocker.follow('carla', 'bob')
+        }
+        if ('posts' in url.query) {
+          console.log('Generating mock posts')
+          for (let user in server.mocker.users) {
+            await server.mocker.users[user].agent.post({text: 'Post'})
+          }
+        }
+        if ('thread' in url.query) {
+          console.log('Generating mock posts')
+          const res = await server.mocker.users.bob.agent.post({
+            text: 'Thread root',
+          })
+          await server.mocker.users.carla.agent.post({
+            text: 'Thread reply',
+            reply: {
+              parent: {cid: res.cid, uri: res.uri},
+              root: {cid: res.cid, uri: res.uri},
+            },
+          })
+        }
+      }
+      console.log('Ready')
+      return res.writeHead(200).end(server.pdsUrl)
+    } catch (e) {
+      console.error('Error!', e)
+      return res.writeHead(500).end()
+    }
+  }).listen(1986)
+  console.log('Mock server manager listening on 1986')
+}
+main()
diff --git a/__e2e__/tests/composer.test.ts b/__e2e__/tests/composer.test.ts
new file mode 100644
index 000000000..afc23cc13
--- /dev/null
+++ b/__e2e__/tests/composer.test.ts
@@ -0,0 +1,108 @@
+/* eslint-env detox/detox */
+
+import {openApp, login, createServer, sleep} from '../util'
+
+describe('Composer', () => {
+  let service: string
+  beforeAll(async () => {
+    service = await createServer('?users')
+    await openApp({
+      permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
+    })
+  })
+
+  it('Login', async () => {
+    await login(service, 'alice', 'hunter2')
+    await element(by.id('homeScreenFeedTabs-Following')).tap()
+  })
+
+  it('Post text only', async () => {
+    await element(by.id('composeFAB')).tap()
+    await device.takeScreenshot('1- opened composer')
+    await element(by.id('composerTextInput')).typeText('Post text only')
+    await device.takeScreenshot('2- entered text')
+    await element(by.id('composerPublishBtn')).tap()
+    await device.takeScreenshot('3- opened general section')
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+  })
+
+  it('Post with an image', async () => {
+    await element(by.id('composeFAB')).tap()
+    await element(by.id('composerTextInput')).typeText('Post with an image')
+    await element(by.id('openGalleryBtn')).tap()
+    await sleep(1e3)
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+  })
+
+  it('Post with a link card', async () => {
+    await element(by.id('composeFAB')).tap()
+    await element(by.id('composerTextInput')).typeText(
+      'Post with a https://example.com link card',
+    )
+    await element(by.id('addLinkCardBtn')).tap()
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+  })
+
+  it('Reply text only', async () => {
+    const post = by.id('feedItem-by-alice.test')
+    await element(by.id('replyBtn').withAncestor(post)).atIndex(0).tap()
+    await element(by.id('composerTextInput')).typeText('Reply text only')
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+  })
+
+  it('Reply with an image', async () => {
+    const post = by.id('feedItem-by-alice.test')
+    await element(by.id('replyBtn').withAncestor(post)).atIndex(0).tap()
+    await element(by.id('composerTextInput')).typeText('Reply with an image')
+    await element(by.id('openGalleryBtn')).tap()
+    await sleep(1e3)
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+  })
+
+  it('Reply with a link card', async () => {
+    const post = by.id('feedItem-by-alice.test')
+    await element(by.id('replyBtn').withAncestor(post)).atIndex(0).tap()
+    await element(by.id('composerTextInput')).typeText(
+      'Reply with a https://example.com link card',
+    )
+    await element(by.id('addLinkCardBtn')).tap()
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+  })
+
+  it('QP text only', async () => {
+    const post = by.id('feedItem-by-alice.test')
+    await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
+    await element(by.id('quoteBtn').withAncestor(by.id('repostModal'))).tap()
+    await element(by.id('composerTextInput')).typeText('QP text only')
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+  })
+
+  it('QP with an image', async () => {
+    const post = by.id('feedItem-by-alice.test')
+    await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
+    await element(by.id('quoteBtn').withAncestor(by.id('repostModal'))).tap()
+    await element(by.id('composerTextInput')).typeText('QP with an image')
+    await element(by.id('openGalleryBtn')).tap()
+    await sleep(1e3)
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+  })
+
+  it('QP with a link card', async () => {
+    const post = by.id('feedItem-by-alice.test')
+    await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
+    await element(by.id('quoteBtn').withAncestor(by.id('repostModal'))).tap()
+    await element(by.id('composerTextInput')).typeText(
+      'QP with a https://example.com link card',
+    )
+    await element(by.id('addLinkCardBtn')).tap()
+    await element(by.id('composerPublishBtn')).tap()
+    await expect(element(by.id('composeFAB'))).toBeVisible()
+  })
+})
diff --git a/__e2e__/tests/create-account.test.ts b/__e2e__/tests/create-account.test.ts
new file mode 100644
index 000000000..7b2e00fb5
--- /dev/null
+++ b/__e2e__/tests/create-account.test.ts
@@ -0,0 +1,31 @@
+/* eslint-env detox/detox */
+
+import {openApp, createServer} from '../util'
+
+describe('Create account', () => {
+  let service: string
+  beforeAll(async () => {
+    service = await createServer('mock0')
+    await openApp({permissions: {notifications: 'YES'}})
+  })
+
+  it('I can create a new account', async () => {
+    await element(by.id('createAccountButton')).tap()
+    await device.takeScreenshot('1- opened create account screen')
+    await element(by.id('otherServerBtn')).tap()
+    await device.takeScreenshot('2- selected other server')
+    await element(by.id('customServerInput')).clearText()
+    await element(by.id('customServerInput')).typeText(service)
+    await device.takeScreenshot('3- input test server URL')
+    await element(by.id('nextBtn')).tap()
+    await element(by.id('emailInput')).typeText('example@test.com')
+    await element(by.id('passwordInput')).typeText('hunter2')
+    await element(by.id('is13Input')).tap()
+    await device.takeScreenshot('4- entered account details')
+    await element(by.id('nextBtn')).tap()
+    await element(by.id('handleInput')).typeText('e2e-test')
+    await device.takeScreenshot('4- entered handle')
+    await element(by.id('nextBtn')).tap()
+    await expect(element(by.id('homeScreen'))).toBeVisible()
+  })
+})
diff --git a/__e2e__/tests/home-screen.test.ts b/__e2e__/tests/home-screen.test.ts
new file mode 100644
index 000000000..1ec1774f3
--- /dev/null
+++ b/__e2e__/tests/home-screen.test.ts
@@ -0,0 +1,92 @@
+/* eslint-env detox/detox */
+
+import {openApp, login, createServer} from '../util'
+
+describe('Home screen', () => {
+  let service: string
+  beforeAll(async () => {
+    service = await createServer('?users&follows&posts')
+    await openApp({permissions: {notifications: 'YES'}})
+  })
+
+  it('Login', async () => {
+    await login(service, 'alice', 'hunter2')
+    await element(by.id('homeScreenFeedTabs-Following')).tap()
+  })
+
+  it('Can like posts', async () => {
+    const carlaPosts = by.id('feedItem-by-carla.test')
+    await expect(
+      element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
+    ).toHaveText('0')
+    await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
+    await expect(
+      element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
+    ).toHaveText('1')
+    await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
+    await expect(
+      element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
+    ).toHaveText('0')
+  })
+
+  it('Can repost posts', async () => {
+    const carlaPosts = by.id('feedItem-by-carla.test')
+    await expect(
+      element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
+    ).toHaveText('0')
+    await element(by.id('repostBtn').withAncestor(carlaPosts)).atIndex(0).tap()
+    await expect(element(by.id('repostModal'))).toBeVisible()
+    await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
+    await expect(element(by.id('repostModal'))).not.toBeVisible()
+    await expect(
+      element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
+    ).toHaveText('1')
+    await element(by.id('repostBtn').withAncestor(carlaPosts)).atIndex(0).tap()
+    await expect(element(by.id('repostModal'))).toBeVisible()
+    await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
+    await expect(element(by.id('repostModal'))).not.toBeVisible()
+    await expect(
+      element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
+    ).toHaveText('0')
+  })
+
+  it('Can report posts', async () => {
+    const carlaPosts = by.id('feedItem-by-carla.test')
+    await element(by.id('postDropdownBtn').withAncestor(carlaPosts))
+      .atIndex(0)
+      .tap()
+    await element(by.id('postDropdownReportBtn')).tap()
+    await expect(element(by.id('reportPostModal'))).toBeVisible()
+    await element(by.id('reportPostRadios-spam')).tap()
+    await element(by.id('sendReportBtn')).tap()
+    await expect(element(by.id('reportPostModal'))).not.toBeVisible()
+  })
+
+  it('Can swipe between feeds', async () => {
+    await element(by.id('homeScreen')).swipe('left', 'fast', 0.75)
+    await expect(element(by.id('whatshotFeedPage'))).toBeVisible()
+    await element(by.id('homeScreen')).swipe('right', 'fast', 0.75)
+    await expect(element(by.id('followingFeedPage'))).toBeVisible()
+  })
+
+  it('Can tap between feeds', async () => {
+    await element(by.id("homeScreenFeedTabs-What's hot")).tap()
+    await expect(element(by.id('whatshotFeedPage'))).toBeVisible()
+    await element(by.id('homeScreenFeedTabs-Following')).tap()
+    await expect(element(by.id('followingFeedPage'))).toBeVisible()
+  })
+
+  it('Can delete posts', async () => {
+    const alicePosts = by.id('feedItem-by-alice.test')
+    await expect(element(alicePosts.withDescendant(by.text('Post')))).toExist()
+    await element(by.id('postDropdownBtn').withAncestor(alicePosts))
+      .atIndex(0)
+      .tap()
+    await element(by.id('postDropdownDeleteBtn')).tap()
+    await expect(element(by.id('confirmModal'))).toBeVisible()
+    await element(by.id('confirmBtn')).tap()
+    await expect(
+      element(alicePosts.withDescendant(by.text('Post'))),
+    ).not.toExist()
+  })
+})
diff --git a/__e2e__/tests/login.test.ts b/__e2e__/tests/login.test.ts
new file mode 100644
index 000000000..788016db6
--- /dev/null
+++ b/__e2e__/tests/login.test.ts
@@ -0,0 +1,19 @@
+/* eslint-env detox/detox */
+
+import {openApp, login, createServer} from '../util'
+
+describe('Login', () => {
+  let service: string
+  beforeAll(async () => {
+    service = await createServer('?users')
+    await openApp({permissions: {notifications: 'YES'}})
+  })
+
+  it('As Alice, I can login', async () => {
+    await expect(element(by.id('signInButton'))).toBeVisible()
+    await login(service, 'alice', 'hunter2', {
+      takeScreenshots: true,
+    })
+    await device.takeScreenshot('5- opened home screen')
+  })
+})
diff --git a/__e2e__/tests/profile-screen.test.ts b/__e2e__/tests/profile-screen.test.ts
new file mode 100644
index 000000000..e1b6dcaf4
--- /dev/null
+++ b/__e2e__/tests/profile-screen.test.ts
@@ -0,0 +1,173 @@
+/* eslint-env detox/detox */
+
+import {openApp, login, createServer, sleep} from '../util'
+
+describe('Profile screen', () => {
+  let service: string
+  beforeAll(async () => {
+    service = await createServer('?users&posts')
+    await openApp({
+      permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
+    })
+  })
+
+  it('Login and navigate to my profile', async () => {
+    await expect(element(by.id('signInButton'))).toBeVisible()
+    await login(service, 'alice', 'hunter2')
+    await element(by.id('bottomBarProfileBtn')).tap()
+  })
+
+  it('Open and close edit profile modal', async () => {
+    await element(by.id('profileHeaderEditProfileButton')).tap()
+    await expect(element(by.id('editProfileModal'))).toBeVisible()
+    await element(by.id('editProfileCancelBtn')).tap()
+    await expect(element(by.id('editProfileModal'))).not.toBeVisible()
+  })
+
+  it('Edit display name and description via the edit profile modal', async () => {
+    await element(by.id('profileHeaderEditProfileButton')).tap()
+    await expect(element(by.id('editProfileModal'))).toBeVisible()
+    await element(by.id('editProfileDisplayNameInput')).clearText()
+    await element(by.id('editProfileDisplayNameInput')).typeText('Alicia')
+    await element(by.id('editProfileDescriptionInput')).clearText()
+    await element(by.id('editProfileDescriptionInput')).typeText(
+      'One cool hacker',
+    )
+    await element(by.id('editProfileSaveBtn')).tap()
+    await expect(element(by.id('editProfileModal'))).not.toBeVisible()
+    await expect(element(by.id('profileHeaderDisplayName'))).toHaveText(
+      'Alicia',
+    )
+    await expect(element(by.id('profileHeaderDescription'))).toHaveText(
+      'One cool hacker',
+    )
+  })
+
+  it('Remove display name and description via the edit profile modal', async () => {
+    await element(by.id('profileHeaderEditProfileButton')).tap()
+    await expect(element(by.id('editProfileModal'))).toBeVisible()
+    await element(by.id('editProfileDisplayNameInput')).clearText()
+    await element(by.id('editProfileDescriptionInput')).clearText()
+    await element(by.id('editProfileSaveBtn')).tap()
+    await expect(element(by.id('editProfileModal'))).not.toBeVisible()
+    await expect(element(by.id('profileHeaderDisplayName'))).toHaveText(
+      'alice.test',
+    )
+    await expect(element(by.id('profileHeaderDescription'))).toHaveText('')
+  })
+
+  it('Set avi and banner via the edit profile modal', async () => {
+    await expect(element(by.id('userBannerFallback'))).toExist()
+    await expect(element(by.id('userAvatarFallback'))).toExist()
+    await element(by.id('profileHeaderEditProfileButton')).tap()
+    await expect(element(by.id('editProfileModal'))).toBeVisible()
+    await element(by.id('changeBannerBtn')).tap()
+    await element(by.id('changeBannerLibraryBtn')).tap()
+    await sleep(3e3)
+    await element(by.id('changeAvatarBtn')).tap()
+    await element(by.id('changeAvatarLibraryBtn')).tap()
+    await sleep(3e3)
+    await element(by.id('editProfileSaveBtn')).tap()
+    await expect(element(by.id('editProfileModal'))).not.toBeVisible()
+    await expect(element(by.id('userBannerImage'))).toExist()
+    await expect(element(by.id('userAvatarImage'))).toExist()
+  })
+
+  it('Remove avi and banner via the edit profile modal', async () => {
+    await expect(element(by.id('userBannerImage'))).toExist()
+    await expect(element(by.id('userAvatarImage'))).toExist()
+    await element(by.id('profileHeaderEditProfileButton')).tap()
+    await expect(element(by.id('editProfileModal'))).toBeVisible()
+    await element(by.id('changeBannerBtn')).tap()
+    await element(by.id('changeBannerRemoveBtn')).tap()
+    await element(by.id('changeAvatarBtn')).tap()
+    await element(by.id('changeAvatarRemoveBtn')).tap()
+    await element(by.id('editProfileSaveBtn')).tap()
+    await expect(element(by.id('editProfileModal'))).not.toBeVisible()
+    await expect(element(by.id('userBannerFallback'))).toExist()
+    await expect(element(by.id('userAvatarFallback'))).toExist()
+  })
+
+  it('Navigate to another user profile', async () => {
+    await element(by.id('bottomBarSearchBtn')).tap()
+    // have to wait for the toast to clear
+    await waitFor(element(by.id('searchTextInput')))
+      .toBeVisible()
+      .withTimeout(2000)
+    await element(by.id('searchTextInput')).typeText('bob')
+    await element(by.id('searchAutoCompleteResult-bob.test')).tap()
+    await expect(element(by.id('profileView'))).toBeVisible()
+  })
+
+  it('Can follow/unfollow another user', async () => {
+    await element(by.id('followBtn')).tap()
+    await expect(element(by.id('unfollowBtn'))).toBeVisible()
+    await element(by.id('unfollowBtn')).tap()
+    await expect(element(by.id('followBtn'))).toBeVisible()
+  })
+
+  it('Can mute/unmute another user', async () => {
+    await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist()
+    await element(by.id('profileHeaderDropdownBtn')).tap()
+    await element(by.id('profileHeaderDropdownMuteBtn')).tap()
+    await expect(element(by.id('profileHeaderMutedNotice'))).toBeVisible()
+    await element(by.id('profileHeaderDropdownBtn')).tap()
+    await element(by.id('profileHeaderDropdownMuteBtn')).tap()
+    await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist()
+  })
+
+  it('Can report another user', async () => {
+    await element(by.id('profileHeaderDropdownBtn')).tap()
+    await element(by.id('profileHeaderDropdownReportBtn')).tap()
+    await expect(element(by.id('reportAccountModal'))).toBeVisible()
+    await element(by.id('reportAccountRadios-spam')).tap()
+    await element(by.id('sendReportBtn')).tap()
+    await expect(element(by.id('reportAccountModal'))).not.toBeVisible()
+  })
+
+  it('Can like posts', async () => {
+    const posts = by.id('feedItem-by-bob.test')
+    await expect(
+      element(by.id('likeCount').withAncestor(posts)).atIndex(0),
+    ).toHaveText('0')
+    await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
+    await expect(
+      element(by.id('likeCount').withAncestor(posts)).atIndex(0),
+    ).toHaveText('1')
+    await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
+    await expect(
+      element(by.id('likeCount').withAncestor(posts)).atIndex(0),
+    ).toHaveText('0')
+  })
+
+  it('Can repost posts', async () => {
+    const posts = by.id('feedItem-by-bob.test')
+    await expect(
+      element(by.id('repostCount').withAncestor(posts)).atIndex(0),
+    ).toHaveText('0')
+    await element(by.id('repostBtn').withAncestor(posts)).atIndex(0).tap()
+    await expect(element(by.id('repostModal'))).toBeVisible()
+    await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
+    await expect(element(by.id('repostModal'))).not.toBeVisible()
+    await expect(
+      element(by.id('repostCount').withAncestor(posts)).atIndex(0),
+    ).toHaveText('1')
+    await element(by.id('repostBtn').withAncestor(posts)).atIndex(0).tap()
+    await expect(element(by.id('repostModal'))).toBeVisible()
+    await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
+    await expect(element(by.id('repostModal'))).not.toBeVisible()
+    await expect(
+      element(by.id('repostCount').withAncestor(posts)).atIndex(0),
+    ).toHaveText('0')
+  })
+
+  it('Can report posts', async () => {
+    const posts = by.id('feedItem-by-bob.test')
+    await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap()
+    await element(by.id('postDropdownReportBtn')).tap()
+    await expect(element(by.id('reportPostModal'))).toBeVisible()
+    await element(by.id('reportPostRadios-spam')).tap()
+    await element(by.id('sendReportBtn')).tap()
+    await expect(element(by.id('reportPostModal'))).not.toBeVisible()
+  })
+})
diff --git a/__e2e__/tests/search-screen.test.ts b/__e2e__/tests/search-screen.test.ts
new file mode 100644
index 000000000..093d97c89
--- /dev/null
+++ b/__e2e__/tests/search-screen.test.ts
@@ -0,0 +1,24 @@
+/* eslint-env detox/detox */
+
+import {openApp, login, createServer} from '../util'
+
+describe('Search screen', () => {
+  let service: string
+  beforeAll(async () => {
+    service = await createServer('?users')
+    await openApp({
+      permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
+    })
+  })
+
+  it('Login', async () => {
+    await login(service, 'alice', 'hunter2')
+  })
+
+  it('Navigate to another user profile via autocomplete', async () => {
+    await element(by.id('bottomBarSearchBtn')).tap()
+    await element(by.id('searchTextInput')).typeText('bob')
+    await element(by.id('searchAutoCompleteResult-bob.test')).tap()
+    await expect(element(by.id('profileView'))).toBeVisible()
+  })
+})
diff --git a/__e2e__/tests/shell.test.ts b/__e2e__/tests/shell.test.ts
new file mode 100644
index 000000000..5cfd4277f
--- /dev/null
+++ b/__e2e__/tests/shell.test.ts
@@ -0,0 +1,34 @@
+/* eslint-env detox/detox */
+
+import {openApp, login, createServer} from '../util'
+
+describe('Shell', () => {
+  let service: string
+  beforeAll(async () => {
+    service = await createServer('?users')
+    await openApp({permissions: {notifications: 'YES'}})
+  })
+
+  it('Login', async () => {
+    await login(service, 'alice', 'hunter2')
+    await element(by.id('homeScreenFeedTabs-Following')).tap()
+  })
+
+  it('Can swipe the shelf open', async () => {
+    await element(by.id('homeScreen')).swipe('right', 'fast', 0.75)
+    await expect(element(by.id('drawer'))).toBeVisible()
+    await element(by.id('drawer')).swipe('left', 'fast', 0.75)
+    await expect(element(by.id('drawer'))).not.toBeVisible()
+  })
+
+  it('Can open the shelf by pressing the header avi', async () => {
+    await element(by.id('viewHeaderDrawerBtn')).tap()
+    await expect(element(by.id('drawer'))).toBeVisible()
+  })
+
+  it('Can navigate using the shelf', async () => {
+    await element(by.id('menuItemButton-Notifications')).tap()
+    await expect(element(by.id('drawer'))).not.toBeVisible()
+    await expect(element(by.id('notificationsScreen'))).toBeVisible()
+  })
+})
diff --git a/__e2e__/tests/thread-screen.test.ts b/__e2e__/tests/thread-screen.test.ts
new file mode 100644
index 000000000..f84c339ce
--- /dev/null
+++ b/__e2e__/tests/thread-screen.test.ts
@@ -0,0 +1,123 @@
+/* eslint-env detox/detox */
+
+import {openApp, login, createServer} from '../util'
+
+describe('Thread screen', () => {
+  let service: string
+  beforeAll(async () => {
+    service = await createServer('?users&follows&thread')
+    await openApp({permissions: {notifications: 'YES'}})
+  })
+
+  it('Login & navigate to thread', async () => {
+    await login(service, 'alice', 'hunter2')
+    await element(by.id('homeScreenFeedTabs-Following')).tap()
+    await element(by.id('feedItem-by-bob.test')).atIndex(0).tap()
+    await expect(
+      element(
+        by
+          .id('postThreadItem-by-bob.test')
+          .withDescendant(by.text('Thread root')),
+      ),
+    ).toBeVisible()
+    await expect(
+      element(
+        by
+          .id('postThreadItem-by-carla.test')
+          .withDescendant(by.text('Thread reply')),
+      ),
+    ).toBeVisible()
+  })
+
+  it('Can like the root post', async () => {
+    const post = by.id('postThreadItem-by-bob.test')
+    await expect(
+      element(by.id('likeCount').withAncestor(post)).atIndex(0),
+    ).not.toExist()
+    await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
+    await expect(
+      element(by.id('likeCount').withAncestor(post)).atIndex(0),
+    ).toHaveText('1 like')
+    await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
+    await expect(
+      element(by.id('likeCount').withAncestor(post)).atIndex(0),
+    ).not.toExist()
+  })
+
+  it('Can like a reply post', async () => {
+    const post = by.id('postThreadItem-by-carla.test')
+    await expect(
+      element(by.id('likeCount').withAncestor(post)).atIndex(0),
+    ).toHaveText('0')
+    await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
+    await expect(
+      element(by.id('likeCount').withAncestor(post)).atIndex(0),
+    ).toHaveText('1')
+    await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
+    await expect(
+      element(by.id('likeCount').withAncestor(post)).atIndex(0),
+    ).toHaveText('0')
+  })
+
+  it('Can repost the root post', async () => {
+    const post = by.id('postThreadItem-by-bob.test')
+    await expect(
+      element(by.id('repostCount').withAncestor(post)).atIndex(0),
+    ).not.toExist()
+    await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
+    await expect(element(by.id('repostModal'))).toBeVisible()
+    await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
+    await expect(element(by.id('repostModal'))).not.toBeVisible()
+    await expect(
+      element(by.id('repostCount').withAncestor(post)).atIndex(0),
+    ).toHaveText('1 repost')
+    await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
+    await expect(element(by.id('repostModal'))).toBeVisible()
+    await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
+    await expect(element(by.id('repostModal'))).not.toBeVisible()
+    await expect(
+      element(by.id('repostCount').withAncestor(post)).atIndex(0),
+    ).not.toExist()
+  })
+
+  it('Can repost a reply post', async () => {
+    const post = by.id('postThreadItem-by-carla.test')
+    await expect(
+      element(by.id('repostCount').withAncestor(post)).atIndex(0),
+    ).toHaveText('0')
+    await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
+    await expect(element(by.id('repostModal'))).toBeVisible()
+    await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
+    await expect(element(by.id('repostModal'))).not.toBeVisible()
+    await expect(
+      element(by.id('repostCount').withAncestor(post)).atIndex(0),
+    ).toHaveText('1')
+    await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
+    await expect(element(by.id('repostModal'))).toBeVisible()
+    await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
+    await expect(element(by.id('repostModal'))).not.toBeVisible()
+    await expect(
+      element(by.id('repostCount').withAncestor(post)).atIndex(0),
+    ).toHaveText('0')
+  })
+
+  it('Can report the root post', async () => {
+    const post = by.id('postThreadItem-by-bob.test')
+    await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
+    await element(by.id('postDropdownReportBtn')).tap()
+    await expect(element(by.id('reportPostModal'))).toBeVisible()
+    await element(by.id('reportPostRadios-spam')).tap()
+    await element(by.id('sendReportBtn')).tap()
+    await expect(element(by.id('reportPostModal'))).not.toBeVisible()
+  })
+
+  it('Can report a reply post', async () => {
+    const post = by.id('postThreadItem-by-carla.test')
+    await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
+    await element(by.id('postDropdownReportBtn')).tap()
+    await expect(element(by.id('reportPostModal'))).toBeVisible()
+    await element(by.id('reportPostRadios-spam')).tap()
+    await element(by.id('sendReportBtn')).tap()
+    await expect(element(by.id('reportPostModal'))).not.toBeVisible()
+  })
+})
diff --git a/__e2e__/util.ts b/__e2e__/util.ts
new file mode 100644
index 000000000..78d9f9f5d
--- /dev/null
+++ b/__e2e__/util.ts
@@ -0,0 +1,96 @@
+import {resolveConfig} from 'detox/internals'
+
+const platform = device.getPlatform()
+
+export async function openApp(opts: any) {
+  opts = opts || {}
+  const config = await resolveConfig()
+  if (config.configurationName.split('.').includes('debug')) {
+    return await openAppForDebugBuild(platform, opts)
+  } else {
+    return await device.launchApp({
+      ...opts,
+      newInstance: true,
+    })
+  }
+}
+
+export async function isVisible(id: string) {
+  try {
+    await expect(element(by.id(id))).toBeVisible()
+    return true
+  } catch (e) {
+    return false
+  }
+}
+
+export async function login(
+  service: string,
+  username: string,
+  password: string,
+  {takeScreenshots} = {takeScreenshots: false},
+) {
+  await element(by.id('signInButton')).tap()
+  if (takeScreenshots) {
+    await device.takeScreenshot('1- opened sign-in screen')
+  }
+  if (await isVisible('chooseAccountForm')) {
+    await element(by.id('chooseNewAccountBtn')).tap()
+  }
+  await element(by.id('loginSelectServiceButton')).tap()
+  if (takeScreenshots) {
+    await device.takeScreenshot('2- opened service selector')
+  }
+  await element(by.id('customServerTextInput')).typeText(service)
+  await element(by.id('customServerSelectBtn')).tap()
+  if (takeScreenshots) {
+    await device.takeScreenshot('3- input custom service')
+  }
+  await element(by.id('loginUsernameInput')).typeText(username)
+  await element(by.id('loginPasswordInput')).typeText(password)
+  if (takeScreenshots) {
+    await device.takeScreenshot('4- entered username and password')
+  }
+  await element(by.id('loginNextButton')).tap()
+}
+
+async function openAppForDebugBuild(platform: string, opts: any) {
+  const deepLinkUrl = // Local testing with packager
+    /*process.env.EXPO_USE_UPDATES
+    ? // Testing latest published EAS update for the test_debug channel
+      getDeepLinkUrl(getLatestUpdateUrl())
+    : */ getDeepLinkUrl(getDevLauncherPackagerUrl(platform))
+
+  if (platform === 'ios') {
+    await device.launchApp({
+      ...opts,
+      newInstance: true,
+    })
+    sleep(3000)
+    await device.openURL({
+      url: deepLinkUrl,
+    })
+  } else {
+    await device.launchApp({
+      ...opts,
+      newInstance: true,
+      url: deepLinkUrl,
+    })
+  }
+
+  await sleep(3000)
+}
+
+export async function createServer(path = '') {
+  const res = await fetch(`http://localhost:1986/${path}`, {method: 'POST'})
+  const resBody = await res.text()
+  return resBody
+}
+
+const getDeepLinkUrl = (url: string) =>
+  `expo+bluesky://expo-development-client/?url=${encodeURIComponent(url)}`
+
+const getDevLauncherPackagerUrl = (platform: string) =>
+  `http://localhost:8081/index.bundle?platform=${platform}&dev=true&minify=false&disableOnboarding=1`
+
+export const sleep = (t: number) => new Promise(res => setTimeout(res, t))