Prefilter the mergefeed to ensure a better mix of following and custom feeds (#1498)

* Prefilter the mergefeed to ensure a better mix of following and custom feeds

* Test suite improvements & tests for the mergefeed (#1499)

* Disable invite codes test for now

* Update test sim to latest iphone

* Introduce TestCtrls driver

* Add mergefeed tests
zio/stable
Paul Frazee 2023-09-20 19:47:56 -07:00 committed by GitHub
parent 68dd3210d1
commit 5a945c2024
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 518 additions and 164 deletions

View File

@ -41,7 +41,7 @@ module.exports = {
simulator: { simulator: {
type: 'ios.simulator', type: 'ios.simulator',
device: { device: {
type: 'iPhone 14', type: 'iPhone 15',
}, },
}, },
attached: { attached: {

View File

@ -55,7 +55,7 @@ async function main() {
} }
if ('feeds' in url.query) { if ('feeds' in url.query) {
console.log('Generating mock feed') console.log('Generating mock feed')
await server.mocker.createFeed('alice') await server.mocker.createFeed('alice', 'alice-favs', [])
} }
if ('thread' in url.query) { if ('thread' in url.query) {
console.log('Generating mock posts') console.log('Generating mock posts')
@ -70,6 +70,82 @@ async function main() {
}, },
}) })
} }
if ('mergefeed' 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.createUser('dan')
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',
}))
await server.mocker.users.dan.agent.upsertProfile(() => ({
displayName: 'Dan',
description: 'Test user 4',
}))
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
console.log('Generating mock posts')
let posts: Record<string, any[]> = {
alice: [],
bob: [],
carla: [],
dan: [],
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
posts[user].push(
await server.mocker.createPost(user, `Post ${i}`),
)
}
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
if (i % 5 === 0) {
await server.mocker.createReply(user, 'Self reply', {
cid: posts[user][i].cid,
uri: posts[user][i].uri,
})
}
if (i % 5 === 1) {
await server.mocker.createReply(user, 'Reply to bob', {
cid: posts.bob[i].cid,
uri: posts.bob[i].uri,
})
}
if (i % 5 === 2) {
await server.mocker.createReply(user, 'Reply to dan', {
cid: posts.dan[i].cid,
uri: posts.dan[i].uri,
})
}
await server.mocker.users[user].agent.post({text: `Post ${i}`})
}
}
console.log('Generating mock feeds')
await server.mocker.createFeed(
'alice',
'alice-favs',
posts.dan.map(p => p.uri),
)
await server.mocker.createFeed(
'alice',
'alice-favs2',
posts.dan.map(p => p.uri),
)
}
if ('labels' in url.query) { if ('labels' in url.query) {
console.log('Generating naughty users with labels') console.log('Generating naughty users with labels')

View File

@ -1,18 +1,17 @@
/* eslint-env detox/detox */ /* eslint-env detox/detox */
import {openApp, login, createServer, sleep} from '../util' import {openApp, loginAsAlice, createServer, sleep} from '../util'
describe('Composer', () => { describe('Composer', () => {
let service: string
beforeAll(async () => { beforeAll(async () => {
service = await createServer('?users') await createServer('?users')
await openApp({ await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
}) })
}) })
it('Login', async () => { it('Login', async () => {
await login(service, 'alice', 'hunter2') await loginAsAlice()
await element(by.id('homeScreenFeedTabs-Following')).tap() await element(by.id('homeScreenFeedTabs-Following')).tap()
}) })

View File

@ -1,16 +1,15 @@
/* eslint-env detox/detox */ /* eslint-env detox/detox */
import {openApp, login, createServer} from '../util' import {openApp, loginAsAlice, createServer} from '../util'
describe('Home screen', () => { describe('Home screen', () => {
let service: string
beforeAll(async () => { beforeAll(async () => {
service = await createServer('?users&follows&posts') await createServer('?users&follows&posts')
await openApp({permissions: {notifications: 'YES'}}) await openApp({permissions: {notifications: 'YES'}})
}) })
it('Login', async () => { it('Login', async () => {
await login(service, 'alice', 'hunter2') await loginAsAlice()
await element(by.id('homeScreenFeedTabs-Following')).tap() await element(by.id('homeScreenFeedTabs-Following')).tap()
}) })

View File

@ -1,6 +1,11 @@
/* eslint-env detox/detox */ /* eslint-env detox/detox */
import {openApp, login, createServer} from '../util' /**
* This test is being skipped until we can resolve the detox crash issue
* with the side drawer.
*/
import {openApp, loginAsAlice, createServer} from '../util'
describe('invite-codes', () => { describe('invite-codes', () => {
let service: string let service: string
@ -12,7 +17,7 @@ describe('invite-codes', () => {
it('I can fetch invite codes', async () => { it('I can fetch invite codes', async () => {
await expect(element(by.id('signInButton'))).toBeVisible() await expect(element(by.id('signInButton'))).toBeVisible()
await login(service, 'alice', 'hunter2') await loginAsAlice()
await element(by.id('viewHeaderDrawerBtn')).tap() await element(by.id('viewHeaderDrawerBtn')).tap()
await expect(element(by.id('drawer'))).toBeVisible() await expect(element(by.id('drawer'))).toBeVisible()
await element(by.id('menuItemInviteCodes')).tap() await element(by.id('menuItemInviteCodes')).tap()
@ -47,15 +52,10 @@ describe('invite-codes', () => {
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible() await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap() await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible() await expect(element(by.id('homeScreen'))).toBeVisible()
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
}) })
it('I get a notification for the new user', async () => { it('I get a notification for the new user', async () => {
await expect(element(by.id('signInButton'))).toBeVisible() await loginAsAlice()
await login(service, 'alice', 'hunter2')
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Notifications')).tap() await element(by.id('menuItemButton-Notifications')).tap()
await expect(element(by.id('invitedUser'))).toBeVisible() await expect(element(by.id('invitedUser'))).toBeVisible()
}) })

View File

@ -0,0 +1,157 @@
/* eslint-env detox/detox */
import {openApp, loginAsAlice, createServer} from '../util'
describe('Mergefeed', () => {
beforeAll(async () => {
await createServer('?mergefeed')
await openApp({permissions: {notifications: 'YES'}})
})
it('Login', async () => {
await loginAsAlice()
await element(by.id('e2eToggleMergefeed')).tap()
})
it('Sees the expected mix of posts with default filters', async () => {
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'slow',
1,
0.5,
0.5,
)
// followed users
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-carla.test')),
).atIndex(0),
).toHaveText('Post 9')
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-bob.test')),
).atIndex(0),
).toHaveText('Post 9')
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'up',
'fast',
1,
0.5,
0.5,
)
// feed users
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-dan.test')),
).atIndex(0),
).toHaveText('Post 0')
})
it('Sees the expected mix of posts with replies disabled', async () => {
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'fast',
1,
0.5,
0.5,
)
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'fast',
1,
0.5,
0.5,
)
await element(by.id('viewHeaderHomeFeedPrefsBtn')).tap()
await element(by.id('toggleRepliesBtn')).tap()
await element(by.id('confirmBtn')).tap()
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'slow',
1,
0.5,
0.5,
)
// followed users
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-carla.test')),
).atIndex(0),
).toHaveText('Post 9')
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-bob.test')),
).atIndex(0),
).toHaveText('Post 9')
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'up',
'fast',
1,
0.5,
0.5,
)
// feed users
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-dan.test')),
).atIndex(0),
).toHaveText('Post 0')
})
it('Sees the expected mix of posts with no follows', async () => {
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'fast',
1,
0.5,
0.5,
)
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()
await element(by.id('unfollowBtn')).tap()
await element(by.id('profileHeaderBackBtn')).tap()
// have to wait for the toast to clear
await waitFor(element(by.id('searchTextInputClearBtn')))
.toBeVisible()
.withTimeout(5000)
await element(by.id('searchTextInputClearBtn')).tap()
await element(by.id('searchTextInput')).typeText('carla')
await element(by.id('searchAutoCompleteResult-carla.test')).tap()
await expect(element(by.id('profileView'))).toBeVisible()
await element(by.id('unfollowBtn')).tap()
await element(by.id('profileHeaderBackBtn')).tap()
await element(by.id('bottomBarHomeBtn')).tap()
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'slow',
1,
0.5,
0.5,
)
await element(by.id('followingFeedPage-feed-flatlist')).swipe(
'down',
'slow',
1,
0.5,
0.5,
)
// followed users NOT present
await expect(element(by.id('feedItem-by-carla.test'))).not.toExist()
await expect(element(by.id('feedItem-by-bob.test'))).not.toExist()
// feed users
await expect(
element(
by.id('postText').withAncestor(by.id('feedItem-by-dan.test')),
).atIndex(0),
).toHaveText('Post 0')
})
})

View File

@ -1,11 +1,10 @@
/* eslint-env detox/detox */ /* eslint-env detox/detox */
import {openApp, login, createServer, sleep} from '../util' import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
describe('Mute lists', () => { describe('Mute lists', () => {
let service: string
beforeAll(async () => { beforeAll(async () => {
service = await createServer('?users&follows&labels') await createServer('?users&follows&labels')
await openApp({ await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
}) })
@ -13,10 +12,8 @@ describe('Mute lists', () => {
it('Login and view my mutelists', async () => { it('Login and view my mutelists', async () => {
await expect(element(by.id('signInButton'))).toBeVisible() await expect(element(by.id('signInButton'))).toBeVisible()
await login(service, 'alice', 'hunter2') await loginAsAlice()
await element(by.id('viewHeaderDrawerBtn')).tap() await element(by.id('e2eGotoModeration')).tap()
await expect(element(by.id('drawer'))).toBeVisible()
await element(by.id('menuItemButton-Moderation')).tap()
await element(by.id('mutelistsBtn')).tap() await element(by.id('mutelistsBtn')).tap()
await expect(element(by.id('list-Muted Users'))).toBeVisible() await expect(element(by.id('list-Muted Users'))).toBeVisible()
await element(by.id('list-Muted Users')).tap() await element(by.id('list-Muted Users')).tap()
@ -141,19 +138,9 @@ describe('Mute lists', () => {
}) })
it('Can report a mute list', async () => { it('Can report a mute list', async () => {
await element(by.id('bottomBarHomeBtn')).tap() await element(by.id('e2eGotoSettings')).tap()
// Last test leaves us in the list view so we are going back 1 screen to the lists list screen
await element(by.id('viewHeaderDrawerBtn')).tap()
// then to the moderation screen
await element(by.id('viewHeaderDrawerBtn')).tap()
// then to the home screen
await element(by.id('viewHeaderDrawerBtn')).tap()
// then open the drawer to go to settings
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap() await element(by.id('signOutBtn')).tap()
await expect(element(by.id('signInButton'))).toBeVisible() await loginAsBob()
await login(service, 'bob.test', 'hunter2')
await element(by.id('bottomBarSearchBtn')).tap() await element(by.id('bottomBarSearchBtn')).tap()
await element(by.id('searchTextInput')).typeText('alice') await element(by.id('searchTextInput')).typeText('alice')
await element(by.id('searchAutoCompleteResult-alice.test')).tap() await element(by.id('searchAutoCompleteResult-alice.test')).tap()

View File

@ -1,11 +1,10 @@
/* eslint-env detox/detox */ /* eslint-env detox/detox */
import {openApp, login, createServer, sleep} from '../util' import {openApp, loginAsAlice, createServer, sleep} from '../util'
describe('Profile screen', () => { describe('Profile screen', () => {
let service: string
beforeAll(async () => { beforeAll(async () => {
service = await createServer('?users&posts&feeds') await createServer('?users&posts&feeds')
await openApp({ await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
}) })
@ -13,7 +12,7 @@ describe('Profile screen', () => {
it('Login and navigate to my profile', async () => { it('Login and navigate to my profile', async () => {
await expect(element(by.id('signInButton'))).toBeVisible() await expect(element(by.id('signInButton'))).toBeVisible()
await login(service, 'alice', 'hunter2') await loginAsAlice()
await element(by.id('bottomBarProfileBtn')).tap() await element(by.id('bottomBarProfileBtn')).tap()
}) })

View File

@ -1,18 +1,17 @@
/* eslint-env detox/detox */ /* eslint-env detox/detox */
import {openApp, login, createServer} from '../util' import {openApp, loginAsAlice, createServer} from '../util'
describe('Search screen', () => { describe('Search screen', () => {
let service: string
beforeAll(async () => { beforeAll(async () => {
service = await createServer('?users') await createServer('?users')
await openApp({ await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
}) })
}) })
it('Login', async () => { it('Login', async () => {
await login(service, 'alice', 'hunter2') await loginAsAlice()
}) })
it('Navigate to another user profile via autocomplete', async () => { it('Navigate to another user profile via autocomplete', async () => {

View File

@ -1,18 +1,17 @@
/* eslint-env detox/detox */ /* eslint-env detox/detox */
import {openApp, login, createServer, sleep} from '../util' import {openApp, loginAsAlice, createServer, sleep} from '../util'
describe('Self-labeling', () => { describe('Self-labeling', () => {
let service: string
beforeAll(async () => { beforeAll(async () => {
service = await createServer('?users') await createServer('?users')
await openApp({ await openApp({
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
}) })
}) })
it('Login', async () => { it('Login', async () => {
await login(service, 'alice', 'hunter2') await loginAsAlice()
await element(by.id('homeScreenFeedTabs-Following')).tap() await element(by.id('homeScreenFeedTabs-Following')).tap()
}) })

View File

@ -1,16 +1,15 @@
/* eslint-env detox/detox */ /* eslint-env detox/detox */
import {openApp, login, createServer} from '../util' import {openApp, loginAsAlice, createServer} from '../util'
describe('Shell', () => { describe('Shell', () => {
let service: string
beforeAll(async () => { beforeAll(async () => {
service = await createServer('?users') await createServer('?users')
await openApp({permissions: {notifications: 'YES'}}) await openApp({permissions: {notifications: 'YES'}})
}) })
it('Login', async () => { it('Login', async () => {
await login(service, 'alice', 'hunter2') await loginAsAlice()
await element(by.id('homeScreenFeedTabs-Following')).tap() await element(by.id('homeScreenFeedTabs-Following')).tap()
}) })

View File

@ -1,42 +1,34 @@
/* eslint-env detox/detox */ /* eslint-env detox/detox */
import {openApp, login, createServer} from '../util' import {openApp, loginAsAlice, loginAsBob, createServer} from '../util'
describe('Thread muting', () => { describe('Thread muting', () => {
let service: string
beforeAll(async () => { beforeAll(async () => {
service = await createServer('?users&follows') await createServer('?users&follows')
await openApp({permissions: {notifications: 'YES'}}) await openApp({permissions: {notifications: 'YES'}})
}) })
it('Login, create a thread, and log out', async () => { it('Login, create a thread, and log out', async () => {
await login(service, 'alice', 'hunter2') await loginAsAlice()
await element(by.id('homeScreenFeedTabs-Following')).tap() await element(by.id('homeScreenFeedTabs-Following')).tap()
await element(by.id('composeFAB')).tap() await element(by.id('composeFAB')).tap()
await element(by.id('composerTextInput')).typeText('Test thread') await element(by.id('composerTextInput')).typeText('Test thread')
await element(by.id('composerPublishBtn')).tap() await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible() await expect(element(by.id('composeFAB'))).toBeVisible()
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
}) })
it('Login, reply to the thread, and log out', async () => { it('Login, reply to the thread, and log out', async () => {
await login(service, 'bob', 'hunter2') await loginAsBob()
await element(by.id('homeScreenFeedTabs-Following')).tap() await element(by.id('homeScreenFeedTabs-Following')).tap()
const alicePosts = by.id('feedItem-by-alice.test') const alicePosts = by.id('feedItem-by-alice.test')
await element(by.id('replyBtn').withAncestor(alicePosts)).atIndex(0).tap() await element(by.id('replyBtn').withAncestor(alicePosts)).atIndex(0).tap()
await element(by.id('composerTextInput')).typeText('Reply 1') await element(by.id('composerTextInput')).typeText('Reply 1')
await element(by.id('composerPublishBtn')).tap() await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible() await expect(element(by.id('composeFAB'))).toBeVisible()
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
}) })
it('Login, confirm notification exists, mute thread, and log out', async () => { it('Login, confirm notification exists, mute thread, and log out', async () => {
await login(service, 'alice', 'hunter2') await loginAsAlice()
await element(by.id('bottomBarNotificationsBtn')).tap() await element(by.id('bottomBarNotificationsBtn')).tap()
const bobNotifs = by.id('feedItem-by-bob.test') const bobNotifs = by.id('feedItem-by-bob.test')
await expect( await expect(
@ -50,14 +42,10 @@ describe('Thread muting', () => {
await waitFor(element(by.id('viewHeaderDrawerBtn'))) await waitFor(element(by.id('viewHeaderDrawerBtn')))
.toBeVisible() .toBeVisible()
.withTimeout(5000) .withTimeout(5000)
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
}) })
it('Login, reply to the thread twice, and log out', async () => { it('Login, reply to the thread twice, and log out', async () => {
await login(service, 'bob', 'hunter2') await loginAsBob()
await element(by.id('bottomBarProfileBtn')).tap() await element(by.id('bottomBarProfileBtn')).tap()
await element(by.id('selector-1')).tap() await element(by.id('selector-1')).tap()
@ -74,13 +62,10 @@ describe('Thread muting', () => {
await expect(element(by.id('composeFAB'))).toBeVisible() await expect(element(by.id('composeFAB'))).toBeVisible()
await element(by.id('bottomBarHomeBtn')).tap() await element(by.id('bottomBarHomeBtn')).tap()
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
await element(by.id('signOutBtn')).tap()
}) })
it('Login, confirm notifications dont exist, unmute the thread, confirm notifications exist', async () => { it('Login, confirm notifications dont exist, unmute the thread, confirm notifications exist', async () => {
await login(service, 'alice', 'hunter2') await loginAsAlice()
await element(by.id('bottomBarNotificationsBtn')).tap() await element(by.id('bottomBarNotificationsBtn')).tap()
const bobNotifs = by.id('feedItem-by-bob.test') const bobNotifs = by.id('feedItem-by-bob.test')
@ -93,7 +78,7 @@ describe('Thread muting', () => {
await element(by.id('postDropdownBtn').withAncestor(alicePosts)) await element(by.id('postDropdownBtn').withAncestor(alicePosts))
.atIndex(0) .atIndex(0)
.tap() .tap()
await element(by.text('Mute thread')).tap() await element(by.text('Unmute thread')).tap()
// TODO // TODO
// the swipe down to trigger PTR isnt working and I dont want to block on this // the swipe down to trigger PTR isnt working and I dont want to block on this

View File

@ -1,16 +1,15 @@
/* eslint-env detox/detox */ /* eslint-env detox/detox */
import {openApp, login, createServer} from '../util' import {openApp, loginAsAlice, createServer} from '../util'
describe('Thread screen', () => { describe('Thread screen', () => {
let service: string
beforeAll(async () => { beforeAll(async () => {
service = await createServer('?users&follows&thread') await createServer('?users&follows&thread')
await openApp({permissions: {notifications: 'YES'}}) await openApp({permissions: {notifications: 'YES'}})
}) })
it('Login & navigate to thread', async () => { it('Login & navigate to thread', async () => {
await login(service, 'alice', 'hunter2') await loginAsAlice()
await element(by.id('homeScreenFeedTabs-Following')).tap() await element(by.id('homeScreenFeedTabs-Following')).tap()
await element(by.id('feedItem-by-bob.test')).atIndex(0).tap() await element(by.id('feedItem-by-bob.test')).atIndex(0).tap()
await expect( await expect(

View File

@ -69,6 +69,14 @@ export async function login(
await element(by.id('loginNextButton')).tap() await element(by.id('loginNextButton')).tap()
} }
export async function loginAsAlice() {
await element(by.id('e2eSignInAlice')).tap()
}
export async function loginAsBob() {
await element(by.id('e2eSignInBob')).tap()
}
async function openAppForDebugBuild(platform: string, opts: any) { async function openAppForDebugBuild(platform: string, opts: any) {
const deepLinkUrl = // Local testing with packager const deepLinkUrl = // Local testing with packager
/*process.env.EXPO_USE_UPDATES /*process.env.EXPO_USE_UPDATES

View File

@ -1,7 +1,7 @@
import net from 'net' import net from 'net'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import {TestPds as DevEnvTestPDS, TestNetworkNoAppView} from '@atproto/dev-env' import {TestNetworkNoAppView} from '@atproto/dev-env'
import {AtUri, BskyAgent} from '@atproto/api' import {AtUri, BskyAgent} from '@atproto/api'
export interface TestUser { export interface TestUser {
@ -24,7 +24,7 @@ export async function createServer(
const port = await getPort() const port = await getPort()
const port2 = await getPort(port + 1) const port2 = await getPort(port + 1)
const pdsUrl = `http://localhost:${port}` const pdsUrl = `http://localhost:${port}`
const {pds, plc} = await TestNetworkNoAppView.create({ const testNet = await TestNetworkNoAppView.create({
pds: {port, publicUrl: pdsUrl, inviteRequired}, pds: {port, publicUrl: pdsUrl, inviteRequired},
plc: {port: port2}, plc: {port: port2},
}) })
@ -35,10 +35,10 @@ export async function createServer(
return { return {
pdsUrl, pdsUrl,
mocker: new Mocker(pds, pdsUrl, pic), mocker: new Mocker(testNet, pdsUrl, pic),
async close() { async close() {
await pds.server.destroy() await testNet.pds.server.destroy()
await plc.server.destroy() await testNet.plc.server.destroy()
}, },
} }
} }
@ -48,13 +48,21 @@ class Mocker {
users: Record<string, TestUser> = {} users: Record<string, TestUser> = {}
constructor( constructor(
public pds: DevEnvTestPDS, public testNet: TestNetworkNoAppView,
public service: string, public service: string,
public pic: Uint8Array, public pic: Uint8Array,
) { ) {
this.agent = new BskyAgent({service}) this.agent = new BskyAgent({service})
} }
get pds() {
return this.testNet.pds
}
get plc() {
return this.testNet.plc
}
// NOTE // NOTE
// deterministic date generator // deterministic date generator
// we use this to ensure the mock dataset is always the same // we use this to ensure the mock dataset is always the same
@ -212,24 +220,34 @@ class Mocker {
return await agent.like(uri, cid) return await agent.like(uri, cid)
} }
async createFeed(user: string) { async createFeed(user: string, rkey: string, posts: string[]) {
const agent = this.users[user]?.agent const agent = this.users[user]?.agent
if (!agent) { if (!agent) {
throw new Error(`Not a user: ${user}`) throw new Error(`Not a user: ${user}`)
} }
const fg1Uri = AtUri.make( const fgUri = AtUri.make(
this.users[user].did, this.users[user].did,
'app.bsky.feed.generator', 'app.bsky.feed.generator',
'alice-favs', rkey,
) )
const fg1 = await this.testNet.createFeedGen({
[fgUri.toString()]: async () => {
return {
encoding: 'application/json',
body: {
feed: posts.slice(0, 30).map(uri => ({post: uri})),
},
}
},
})
const avatarRes = await agent.api.com.atproto.repo.uploadBlob(this.pic, { const avatarRes = await agent.api.com.atproto.repo.uploadBlob(this.pic, {
encoding: 'image/png', encoding: 'image/png',
}) })
return await agent.api.app.bsky.feed.generator.create( return await agent.api.app.bsky.feed.generator.create(
{repo: this.users[user].did, rkey: fg1Uri.rkey}, {repo: this.users[user].did, rkey},
{ {
did: 'did:web:fake.com', did: fg1.did,
displayName: 'alices feed', displayName: rkey,
description: 'all my fav stuff', description: 'all my fav stuff',
avatar: avatarRes.data.blob, avatar: avatarRes.data.blob,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),

View File

@ -18,6 +18,7 @@ import * as Toast from './view/com/util/Toast'
import {handleLink} from './Navigation' import {handleLink} from './Navigation'
import {QueryClientProvider} from '@tanstack/react-query' import {QueryClientProvider} from '@tanstack/react-query'
import {queryClient} from 'lib/react-query' import {queryClient} from 'lib/react-query'
import {TestCtrls} from 'view/com/testing/TestCtrls'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
@ -59,6 +60,7 @@ const App = observer(function AppImpl() {
<analytics.Provider> <analytics.Provider>
<RootStoreProvider value={rootStore}> <RootStoreProvider value={rootStore}>
<GestureHandlerRootView style={s.h100pct}> <GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell /> <Shell />
</GestureHandlerRootView> </GestureHandlerRootView>
</RootStoreProvider> </RootStoreProvider>

View File

@ -128,17 +128,25 @@ export class FeedTuner {
tune( tune(
feed: FeedViewPost[], feed: FeedViewPost[],
tunerFns: FeedTunerFn[] = [], tunerFns: FeedTunerFn[] = [],
{dryRun}: {dryRun: boolean} = {dryRun: false}, {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
dryRun: false,
maintainOrder: false,
},
): FeedViewPostsSlice[] { ): FeedViewPostsSlice[] {
let slices: FeedViewPostsSlice[] = [] let slices: FeedViewPostsSlice[] = []
if (maintainOrder) {
slices = feed.map(item => new FeedViewPostsSlice([item]))
} else {
// arrange the posts into thread slices // arrange the posts into thread slices
for (let i = feed.length - 1; i >= 0; i--) { for (let i = feed.length - 1; i >= 0; i--) {
const item = feed[i] const item = feed[i]
const selfReplyUri = getSelfReplyUri(item) const selfReplyUri = getSelfReplyUri(item)
if (selfReplyUri) { if (selfReplyUri) {
const parent = slices.find(item2 => item2.isNextInThread(selfReplyUri)) const parent = slices.find(item2 =>
item2.isNextInThread(selfReplyUri),
)
if (parent) { if (parent) {
parent.insert(item) parent.insert(item)
continue continue
@ -146,6 +154,7 @@ export class FeedTuner {
} }
slices.unshift(new FeedViewPostsSlice([item])) slices.unshift(new FeedViewPostsSlice([item]))
} }
}
// run the custom tuners // run the custom tuners
for (const tunerFn of tunerFns) { for (const tunerFn of tunerFns) {

View File

@ -4,6 +4,7 @@ import {RootStoreModel} from 'state/index'
import {timeout} from 'lib/async/timeout' import {timeout} from 'lib/async/timeout'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {feedUriToHref} from 'lib/strings/url-helpers' import {feedUriToHref} from 'lib/strings/url-helpers'
import {FeedTuner} from '../feed-manip'
import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types' import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
const REQUEST_WAIT_MS = 500 // 500ms const REQUEST_WAIT_MS = 500 // 500ms
@ -43,7 +44,7 @@ export class MergeFeedAPI implements FeedAPI {
// always keep following topped up // always keep following topped up
if (this.following.numReady < limit) { if (this.following.numReady < limit) {
promises.push(this.following.fetchNext(30)) promises.push(this.following.fetchNext(60))
} }
// pick the next feeds to sample from // pick the next feeds to sample from
@ -84,7 +85,8 @@ export class MergeFeedAPI implements FeedAPI {
const i = this.itemCursor++ const i = this.itemCursor++
const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0) const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0)
const canSample = candidateFeeds.length > 0 const canSample = candidateFeeds.length > 0
const hasFollows = this.following.numReady > 0 const hasFollows = this.following.hasMore
const hasFollowsReady = this.following.numReady > 0
// this condition establishes the frequency that custom feeds are woven into follows // this condition establishes the frequency that custom feeds are woven into follows
const shouldSample = const shouldSample =
@ -98,7 +100,11 @@ export class MergeFeedAPI implements FeedAPI {
// time to sample, or the user isnt following anybody // time to sample, or the user isnt following anybody
return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1) return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1)
} }
// not time to sample if (!hasFollowsReady) {
// stop here so more follows can be fetched
return []
}
// provide follow
return this.following.take(1) return this.following.take(1)
} }
@ -174,6 +180,13 @@ class MergeFeedSource {
} }
class MergeFeedSource_Following extends MergeFeedSource { class MergeFeedSource_Following extends MergeFeedSource {
tuner = new FeedTuner()
reset() {
super.reset()
this.tuner.reset()
}
async fetchNext(n: number) { async fetchNext(n: number) {
return this._fetchNextInner(n) return this._fetchNextInner(n)
} }
@ -183,10 +196,16 @@ class MergeFeedSource_Following extends MergeFeedSource {
limit: number, limit: number,
): Promise<AppBskyFeedGetTimeline.Response> { ): Promise<AppBskyFeedGetTimeline.Response> {
const res = await this.rootStore.agent.getTimeline({cursor, limit}) const res = await this.rootStore.agent.getTimeline({cursor, limit})
// filter out mutes pre-emptively to ensure better mixing // run the tuner pre-emptively to ensure better mixing
res.data.feed = res.data.feed.filter( const slices = this.tuner.tune(
post => !post.post.author.viewer?.muted, res.data.feed,
this.rootStore.preferences.getFeedTuners('home'),
{
dryRun: false,
maintainOrder: true,
},
) )
res.data.feed = slices.map(slice => slice.rootItem)
return res return res
} }
} }

View File

@ -83,8 +83,14 @@ export async function DEFAULT_FEEDS(
// local dev // local dev
const aliceDid = await resolveHandle('alice.test') const aliceDid = await resolveHandle('alice.test')
return { return {
pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], pinned: [
saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], `at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
],
saved: [
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
],
} }
} else if (IS_STAGING(serviceUrl)) { } else if (IS_STAGING(serviceUrl)) {
// staging // staging

View File

@ -139,53 +139,6 @@ export class PostsFeedModel {
this.tuner.reset() this.tuner.reset()
} }
get feedTuners() {
const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled
const areRepliesByFollowedOnlyEnabled =
this.rootStore.preferences.homeFeedRepliesByFollowedOnlyEnabled
const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold
const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled
const areQuotePostsEnabled =
this.rootStore.preferences.homeFeedQuotePostsEnabled
if (this.feedType === 'custom') {
return [
FeedTuner.dedupReposts,
FeedTuner.preferredLangOnly(
this.rootStore.preferences.contentLanguages,
),
]
}
if (this.feedType === 'home' || this.feedType === 'following') {
const feedTuners = []
if (areRepostsEnabled) {
feedTuners.push(FeedTuner.dedupReposts)
} else {
feedTuners.push(FeedTuner.removeReposts)
}
if (areRepliesEnabled) {
feedTuners.push(
FeedTuner.thresholdRepliesOnly({
userDid: this.rootStore.session.data?.did || '',
minLikes: repliesThreshold,
followedOnly: areRepliesByFollowedOnlyEnabled,
}),
)
} else {
feedTuners.push(FeedTuner.removeReplies)
}
if (!areQuotePostsEnabled) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
return feedTuners
}
return []
}
/** /**
* Load for first render * Load for first render
*/ */
@ -275,9 +228,14 @@ export class PostsFeedModel {
} }
const post = await this.api.peekLatest() const post = await this.api.peekLatest()
if (post) { if (post) {
const slices = this.tuner.tune([post], this.feedTuners, { const slices = this.tuner.tune(
[post],
this.rootStore.preferences.getFeedTuners(this.feedType),
{
dryRun: true, dryRun: true,
}) maintainOrder: true,
},
)
if (slices[0]) { if (slices[0]) {
const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0]) const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0])
if (sliceModel.moderation.content.filter) { if (sliceModel.moderation.content.filter) {
@ -363,7 +321,10 @@ export class PostsFeedModel {
const slices = this.options.isSimpleFeed const slices = this.options.isSimpleFeed
? res.feed.map(item => new FeedViewPostsSlice([item])) ? res.feed.map(item => new FeedViewPostsSlice([item]))
: this.tuner.tune(res.feed, this.feedTuners) : this.tuner.tune(
res.feed,
this.rootStore.preferences.getFeedTuners(this.feedType),
)
const toAppend: PostsFeedSliceModel[] = [] const toAppend: PostsFeedSliceModel[] = []
for (const slice of slices) { for (const slice of slices) {

View File

@ -8,6 +8,7 @@ import {ModerationOpts} from '@atproto/api'
import {DEFAULT_FEEDS} from 'lib/constants' import {DEFAULT_FEEDS} from 'lib/constants'
import {deviceLocales} from 'platform/detection' import {deviceLocales} from 'platform/detection'
import {getAge} from 'lib/strings/time' import {getAge} from 'lib/strings/time'
import {FeedTuner} from 'lib/api/feed-manip'
import {LANGUAGES} from '../../../locale/languages' import {LANGUAGES} from '../../../locale/languages'
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
@ -540,6 +541,52 @@ export class PreferencesModel {
toggleRequireAltTextEnabled() { toggleRequireAltTextEnabled() {
this.requireAltTextEnabled = !this.requireAltTextEnabled this.requireAltTextEnabled = !this.requireAltTextEnabled
} }
getFeedTuners(
feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
) {
const areRepliesEnabled = this.homeFeedRepliesEnabled
const areRepliesByFollowedOnlyEnabled =
this.homeFeedRepliesByFollowedOnlyEnabled
const repliesThreshold = this.homeFeedRepliesThreshold
const areRepostsEnabled = this.homeFeedRepostsEnabled
const areQuotePostsEnabled = this.homeFeedQuotePostsEnabled
if (feedType === 'custom') {
return [
FeedTuner.dedupReposts,
FeedTuner.preferredLangOnly(this.contentLanguages),
]
}
if (feedType === 'home' || feedType === 'following') {
const feedTuners = []
if (areRepostsEnabled) {
feedTuners.push(FeedTuner.dedupReposts)
} else {
feedTuners.push(FeedTuner.removeReposts)
}
if (areRepliesEnabled) {
feedTuners.push(
FeedTuner.thresholdRepliesOnly({
userDid: this.rootStore.session.data?.did || '',
minLikes: repliesThreshold,
followedOnly: areRepliesByFollowedOnlyEnabled,
}),
)
} else {
feedTuners.push(FeedTuner.removeReplies)
}
if (!areQuotePostsEnabled) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
return feedTuners
}
return []
}
} }
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf

View File

@ -35,7 +35,7 @@ export const Component = observer(function ProfilePreviewImpl({
}, [model, screen]) }, [model, screen])
return ( return (
<View style={[pal.view, s.flex1]}> <View testID="profilePreview" style={[pal.view, s.flex1]}>
<View <View
style={[ style={[
styles.headerWrapper, styles.headerWrapper,

View File

@ -67,6 +67,7 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
</Text> </Text>
<View style={[pal.view]}> <View style={[pal.view]}>
<Link <Link
testID="viewHeaderHomeFeedPrefsBtn"
href="/settings/home-feed" href="/settings/home-feed"
hitSlop={HITSLOP_10} hitSlop={HITSLOP_10}
accessibilityRole="button" accessibilityRole="button"

View File

@ -299,6 +299,7 @@ export const FeedItem = observer(function FeedItemImpl({
{item.richText?.text ? ( {item.richText?.text ? (
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>
<RichText <RichText
testID="postText"
type="post-text" type="post-text"
richText={item.richText} richText={item.richText}
lineHeight={1.3} lineHeight={1.3}

View File

@ -556,6 +556,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
{!isDesktop && !hideBackButton && ( {!isDesktop && !hideBackButton && (
<TouchableWithoutFeedback <TouchableWithoutFeedback
testID="profileHeaderBackBtn"
onPress={onPressBack} onPress={onPressBack}
hitSlop={BACK_HITSLOP} hitSlop={BACK_HITSLOP}
accessibilityRole="button" accessibilityRole="button"

View File

@ -102,6 +102,7 @@ export function HeaderWithInput({
/> />
{query ? ( {query ? (
<TouchableOpacity <TouchableOpacity
testID="searchTextInputClearBtn"
onPress={onPressClearQuery} onPress={onPressClearQuery}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Clear search query" accessibilityLabel="Clear search query"

View File

@ -0,0 +1,76 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {useStores} from 'state/index'
import {navigate} from '../../../Navigation'
/**
* This utility component is only included in the test simulator
* build. It gives some quick triggers which help improve the pace
* of the tests dramatically.
*/
const BTN = {height: 1, width: 1, backgroundColor: 'red'}
export function TestCtrls() {
const store = useStores()
const onPressSignInAlice = async () => {
await store.session.login({
service: 'http://localhost:3000',
identifier: 'alice.test',
password: 'hunter2',
})
}
const onPressSignInBob = async () => {
await store.session.login({
service: 'http://localhost:3000',
identifier: 'bob.test',
password: 'hunter2',
})
}
return (
<View style={{position: 'absolute', top: 100, right: 0, zIndex: 100}}>
<Pressable
testID="e2eSignInAlice"
onPress={onPressSignInAlice}
accessibilityRole="button"
style={BTN}
/>
<Pressable
testID="e2eSignInBob"
onPress={onPressSignInBob}
accessibilityRole="button"
style={BTN}
/>
<Pressable
testID="e2eGotoHome"
onPress={() => navigate('Home')}
accessibilityRole="button"
style={BTN}
/>
<Pressable
testID="e2eGotoSettings"
onPress={() => navigate('Settings')}
accessibilityRole="button"
style={BTN}
/>
<Pressable
testID="e2eGotoModeration"
onPress={() => navigate('Moderation')}
accessibilityRole="button"
style={BTN}
/>
<Pressable
testID="e2eToggleMergefeed"
onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()}
accessibilityRole="button"
style={BTN}
/>
<Pressable
testID="e2eRefreshHome"
onPress={() => store.me.mainFeed.refresh()}
accessibilityRole="button"
style={BTN}
/>
</View>
)
}

View File

@ -0,0 +1,3 @@
export function TestCtrls() {
return null
}

View File

@ -8,6 +8,7 @@ import {colors} from 'lib/styles'
import {TypographyVariant} from 'lib/ThemeContext' import {TypographyVariant} from 'lib/ThemeContext'
export function ToggleButton({ export function ToggleButton({
testID,
type = 'default-light', type = 'default-light',
label, label,
isSelected, isSelected,
@ -15,6 +16,7 @@ export function ToggleButton({
labelType, labelType,
onPress, onPress,
}: { }: {
testID?: string
type?: ButtonType type?: ButtonType
label: string label: string
isSelected: boolean isSelected: boolean
@ -134,7 +136,7 @@ export function ToggleButton({
}, },
}) })
return ( return (
<Button type={type} onPress={onPress} style={style}> <Button testID={testID} type={type} onPress={onPress} style={style}>
<View style={styles.outer}> <View style={styles.outer}>
<View style={[circleStyle, styles.circle]}> <View style={[circleStyle, styles.circle]}>
<View <View

View File

@ -86,6 +86,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
Set this setting to "No" to hide all replies from your feed. Set this setting to "No" to hide all replies from your feed.
</Text> </Text>
<ToggleButton <ToggleButton
testID="toggleRepliesBtn"
type="default-light" type="default-light"
label={store.preferences.homeFeedRepliesEnabled ? 'Yes' : 'No'} label={store.preferences.homeFeedRepliesEnabled ? 'Yes' : 'No'}
isSelected={store.preferences.homeFeedRepliesEnabled} isSelected={store.preferences.homeFeedRepliesEnabled}