diff --git a/playwright.config.ts b/playwright.config.ts index a1dc17f4..8f746aa6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,7 +13,7 @@ import { devices } from '@playwright/test' const config: PlaywrightTestConfig = { testDir: './tests/e2e', /* Maximum time one test can run for. */ - timeout: 45 * 1000, + timeout: 60 * 1000, expect: { /** * Maximum time expect() should wait for the condition to be met. @@ -70,7 +70,7 @@ const config: PlaywrightTestConfig = { }, }, - // /* Test against mobile viewports. */ + /* Test against mobile viewports. */ { name: 'Mobile Chrome', use: { diff --git a/src/app/Application.ts b/src/app/Application.ts index 63aed7c1..5d9ae73f 100644 --- a/src/app/Application.ts +++ b/src/app/Application.ts @@ -1,4 +1,5 @@ import { InMemoryRepository } from '@akdasa-studios/framework' +import { SyncRepository } from '@akdasa-studios/framework-sync' import { Application, InboxCard, Repositories, ReviewCard, Verse, VerseStatus } from "@akdasa-studios/shlokas-core" import { Capacitor } from '@capacitor/core' import { @@ -18,24 +19,25 @@ export async function createShlokasApplication() { const repositories = new Repositories( new InMemoryRepository(), + // @ts-ignore new PouchRepository( couchDB, "verseStatus", new VerseStatusSerializer(), new VerseStatusDeserializer() ), - new PouchRepository( + new SyncRepository(new PouchRepository( couchDB, "inbox", new InboxCardSerializer(), new InboxCardDeserializer() - ), - new PouchRepository( + )), + new SyncRepository(new PouchRepository( couchDB, "review", new ReviewCardSerializer(), new ReviewCardDeserializer() - ), + )), ) return [ new Application(repositories), diff --git a/src/app/decks/inbox/controllers/InboxDeckCardsController.ts b/src/app/decks/inbox/controllers/InboxDeckCardsController.ts index 11784e61..5b4dc1eb 100644 --- a/src/app/decks/inbox/controllers/InboxDeckCardsController.ts +++ b/src/app/decks/inbox/controllers/InboxDeckCardsController.ts @@ -22,6 +22,7 @@ export class InboxDeckCardsController { if (e instanceof AddVerseToInboxDeck) { await this.addCardsToDeck() } }) emitter.on('syncCompleted', async () => { await this.addCardsToDeck() }) + emitter.on('appOpened', async () => { await this.addCardsToDeck() }) } /** @@ -29,6 +30,7 @@ export class InboxDeckCardsController { */ async addCardsToDeck() { const cards = await this._app.inboxDeck.cards() + console.log('addCardsToDeck', JSON.stringify(cards)) for (const card of cards) { const isAlreadyInDeck = this._inboxDeckStore.hasCard(card.id.value) diff --git a/src/app/settings/components/account/AccountPage.vue b/src/app/settings/components/account/AccountPage.vue index 569897cc..50c29f69 100644 --- a/src/app/settings/components/account/AccountPage.vue +++ b/src/app/settings/components/account/AccountPage.vue @@ -105,16 +105,19 @@ import { mail } from 'ionicons/icons' import { computed, inject, ref } from 'vue' import { storeToRefs } from 'pinia' import { Emitter } from 'mitt' +import { Application } from '@akdasa-studios/shlokas-core' import { useAccountStore } from '@/app/settings' import { couchDB } from '@/app/Application' import { AuthService } from '@/services/AuthService' import { AUTH_HOST } from '@/app/Env' import { Events } from '@/app/Events' +import { createRepositories } from '@/app/utils/sync' import LogInViaEmailPage from './email/LogInViaEmailPage.vue' import SignUpViaEmailPage from './email/SignUpViaEmailPage.vue' const inProgress = ref(false) const emitter = inject("emitter") as Emitter +const app = inject("app") as Application const account = useAccountStore() const { isAuthenticated, syncHost, token, email } = storeToRefs(account) const { logOut } = account @@ -128,7 +131,8 @@ async function openModal(component: any) { async function onSync() { inProgress.value = true - await couchDB.sync(syncHost.value) + const remoteRepos = createRepositories(syncHost.value as string) + await app.sync(remoteRepos) emitter.emit('syncCompleted') inProgress.value = false } diff --git a/src/app/utils/sync.ts b/src/app/utils/sync.ts new file mode 100644 index 00000000..33f4d88f --- /dev/null +++ b/src/app/utils/sync.ts @@ -0,0 +1,34 @@ +import { InMemoryRepository } from '@akdasa-studios/framework' +import { SyncRepository } from '@akdasa-studios/framework-sync' +import { InboxCard, Repositories, ReviewCard, Verse, VerseStatus } from "@akdasa-studios/shlokas-core" +import { + CouchDB, InboxCardDeserializer, + InboxCardSerializer, PouchRepository, ReviewCardDeserializer, + ReviewCardSerializer, VerseStatusDeserializer, VerseStatusSerializer +} from '@/services/persistence' + + +export function createRepositories(remote: string) { + const couchDB = new CouchDB(remote) + return new Repositories( + new InMemoryRepository(), + new SyncRepository(new PouchRepository( + couchDB, + "verseStatus", + new VerseStatusSerializer(), + new VerseStatusDeserializer() + )), + new SyncRepository(new PouchRepository( + couchDB, + "inbox", + new InboxCardSerializer(), + new InboxCardDeserializer() + )), + new SyncRepository(new PouchRepository( + couchDB, + "review", + new ReviewCardSerializer(), + new ReviewCardDeserializer() + )), + ) +} \ No newline at end of file diff --git a/src/init/index.ts b/src/init/index.ts index 93cc9e97..b602736c 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -1,5 +1,6 @@ // stage 0: infrastructure import { initSentry } from "./stage-0/initSentry" +import { initLogging } from "./stage-0/initLogging" import { initDeviceStorage } from "./stage-0/initDeviceStorage" import { initI18n } from "./stage-0/initI18n" import { initPinia } from "./stage-0/initPinia" @@ -17,6 +18,7 @@ import { initSyncTask } from "./stage-1/initSyncTask" const initStages = [ + initLogging, initSentry, initDeviceStorage, initPinia, diff --git a/src/init/stage-0/initLogging.ts b/src/init/stage-0/initLogging.ts new file mode 100644 index 00000000..407cd068 --- /dev/null +++ b/src/init/stage-0/initLogging.ts @@ -0,0 +1,15 @@ +import { LogRecord, Logs, LogTransport } from '@akdasa-studios/framework' + + +class ConsoleLogTransport implements LogTransport { + log(record: LogRecord): void { + console.log(record.context, record.message, JSON.stringify(record.data)) + } +} + +/** + * Initialize logging system + */ +export async function initLogging() { + Logs.register(new ConsoleLogTransport()) +} diff --git a/src/services/persistence/InboxCardSerializer.ts b/src/services/persistence/InboxCardSerializer.ts index d73e8318..aa7686fa 100644 --- a/src/services/persistence/InboxCardSerializer.ts +++ b/src/services/persistence/InboxCardSerializer.ts @@ -10,17 +10,22 @@ export class InboxCardSerializer implements ObjectMapper { "@type": "inbox", "verseId": from.verseId.value, "type": from.type, - "addedAt": from.addedAt + "addedAt": from.addedAt, + "memorizedAt": from.memorizedAt, + "version": from.version, }) } } export class InboxCardDeserializer implements ObjectMapper { map(from: any): Result { - return Result.ok(new InboxCard( + const card = new InboxCard( new VerseId(from["verseId"]), from['type'] as InboxCardType, - new Date(from['addedAt']) - )) + new Date(from['addedAt']), + from['memorizedAt'] ? new Date(from['memorizedAt']) : undefined + ) + card.version = from['version'] + return Result.ok(card) } } \ No newline at end of file diff --git a/src/services/persistence/PouchRepository.ts b/src/services/persistence/PouchRepository.ts index f715010f..59bcbee7 100644 --- a/src/services/persistence/PouchRepository.ts +++ b/src/services/persistence/PouchRepository.ts @@ -1,4 +1,4 @@ -import { Aggregate, AnyIdentity, Expression, Identity, Operators, Predicate, Query, QueryBuilder, Repository, Result } from '@akdasa-studios/framework' +import { Aggregate, AnyIdentity, Expression, Identity, Operators, Predicate, Query, QueryBuilder, Repository, Result, LogicalOperators } from '@akdasa-studios/framework' import PouchDB from 'pouchdb' import PouchdbFind from 'pouchdb-find' import PouchDBUpsert from 'pouchdb-upsert' @@ -127,19 +127,25 @@ class QueryConverter { } convert(query: Query): any { - return { - "selector": this._visit(query) - } + return { "selector": this._visit(query) } } _visit(query: Query): any { if (query instanceof Predicate) { + if (query.operator === Operators.Equal && query.value === undefined) { + return { [query.field]: { "$exists": false } } + } + return { [query.field]: { [this.operatorsMap[query.operator]] : this.getValue(query.value) } } } else if (query instanceof Expression) { + if (query.operator === LogicalOperators.Not) { + return { "$not": deepMerge({}, ...query.query.map(x => this._visit(x)) ) } + } + return deepMerge( {}, ...query.query.map(x => this._visit(x)) diff --git a/src/services/persistence/ReviewCardSerializer.ts b/src/services/persistence/ReviewCardSerializer.ts index de4dc98f..6937e3b8 100644 --- a/src/services/persistence/ReviewCardSerializer.ts +++ b/src/services/persistence/ReviewCardSerializer.ts @@ -11,18 +11,21 @@ export class ReviewCardSerializer implements ObjectMapper { "verseId": from.verseId.value, "type": from.type, "addedAt": from.addedAt, - "dueTo": from.dueTo + "dueTo": from.dueTo, + "version": from.version, }) } } export class ReviewCardDeserializer implements ObjectMapper { map(from: any): Result { - return Result.ok(new ReviewCard( + const ob = new ReviewCard( new VerseId(from["verseId"]), from['type'] as ReviewCardType, new Date(from['addedAt']), new Date(from['dueTo']), - )) + ) + ob.version = from['version'] + return Result.ok(ob) } } \ No newline at end of file diff --git a/src/services/persistence/VerseStatusSerializer.ts b/src/services/persistence/VerseStatusSerializer.ts index b892f40d..3b809322 100644 --- a/src/services/persistence/VerseStatusSerializer.ts +++ b/src/services/persistence/VerseStatusSerializer.ts @@ -1,5 +1,5 @@ import { Result } from "@akdasa-studios/framework" -import { Decks, VerseId, VerseStatus, VerseStatusId } from "@akdasa-studios/shlokas-core" +import { Decks, VerseId, VerseStatus } from "@akdasa-studios/shlokas-core" import { ObjectMapper } from "./ObjectMapper" @@ -10,15 +10,18 @@ export class VerseStatusSerializer implements ObjectMapper { "@type": "verseStatus", "verseId": from.verseId.value, "inDeck": from.inDeck, + "version": from.version, }) } } export class VerseStatusDeserializer implements ObjectMapper { map(from: any): Result { - return Result.ok(new VerseStatus( + const ob = new VerseStatus( new VerseId(from["verseId"]), from['inDeck'] as Decks - )) + ) + ob.version = from['version'] + return Result.ok(ob) } } \ No newline at end of file diff --git a/tests/e2e/scenarios/accounts.ts b/tests/e2e/scenarios/accounts.ts index 451ffa89..1b77ec76 100644 --- a/tests/e2e/scenarios/accounts.ts +++ b/tests/e2e/scenarios/accounts.ts @@ -6,7 +6,7 @@ export async function signUp( appPage: Page, email: string ) { - const account = new Account(appPage) + const account = new Account(appPage) // act await account.open() @@ -20,8 +20,15 @@ export async function signUp( const mailPage = await context.newPage() await mailPage.goto('http://localhost:1080/') await mailPage.getByRole('cell', { name: `<${email}>` }).first().click() + await mailPage.frameLocator('iframe').getByRole('link', { name: 'Confirm email' }).click() - // await mailPage.frameLocator('iframe').getByText('Email has been confirmed!').waitFor() + await mailPage.waitForTimeout(4000) // code below doesn't work for some reason + await mailPage.close() + // const [popup] = await Promise.all([ + // mailPage.waitForEvent('popup'), + // mailPage.frameLocator('iframe').getByRole('link', { name: 'Confirm email' }).click() + // ]) + // popup.getByText('Email has been confirmed!').waitFor() } export async function logIn( diff --git a/tests/e2e/settings/Registration.spec.ts b/tests/e2e/settings/Registration.spec.ts index 3c78210f..87aa38a2 100644 --- a/tests/e2e/settings/Registration.spec.ts +++ b/tests/e2e/settings/Registration.spec.ts @@ -27,7 +27,7 @@ test.describe('Settings › Account › Email', () => { const [context2, page2] = await logInNewDevice(browser, email) await expect(page2.getByText("Welcome back!")).toBeVisible() - page2.close() - context2.close() + await page2.close() + await context2.close() }) }) \ No newline at end of file diff --git a/tests/e2e/settings/Sync.spec.ts b/tests/e2e/settings/Sync.spec.ts index 42e88549..f128f772 100644 --- a/tests/e2e/settings/Sync.spec.ts +++ b/tests/e2e/settings/Sync.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test' import { Account, Application, LibraryPage, Settings, TabsBar } from '../components' -import { addCardsToReview } from '../scenarios' +import { addCardsToInbox, addCardsToReview } from '../scenarios' import { logIn, logInNewDevice, signUp, sync } from '../scenarios/accounts' @@ -24,15 +24,12 @@ test.describe('Settings › Account › Sync', () => { // device2: login const [context2, page2] = await logInNewDevice(browser, email) - try { - const account2 = new Account(page2) - const tabs2 = new TabsBar(page2) - await account2.sync.click() - await expect(tabs2.reviewBadge).toHaveText("1") - } finally { - page2.close() - context2.close() - } + const account2 = new Account(page2) + const tabs2 = new TabsBar(page2) + await account2.sync.click() + await expect(tabs2.reviewBadge).toHaveText("1") + + await context2.close() }) test('Sync verse status', async ({ page, context, browser }) => { @@ -50,16 +47,12 @@ test.describe('Settings › Account › Sync', () => { const [context2, page2] = await logInNewDevice(browser, email) await sync(page2) await page2.waitForTimeout(1000) // wait sync to complete - try { - const tabs2 = new TabsBar(page2) - const library2 = new LibraryPage(page2) - await tabs2.libraryTab.click() - - await expect(library2.verseBadge("BG 1.1")).toHaveText("REVIEW") - } finally { - page2.close() - context2.close() - } + const tabs2 = new TabsBar(page2) + const library2 = new LibraryPage(page2) + await tabs2.libraryTab.click() + + await expect(library2.verseBadge("BG 1.1")).toHaveText("REVIEW") + await context2.close() }) test('Sync conflict', async ({ page, context, browser }) => { @@ -75,58 +68,48 @@ test.describe('Settings › Account › Sync', () => { // device2: login const [context2, page2] = await logInNewDevice(browser, email) - try { - const tabs2 = new TabsBar(page2) - const account2 = new Account(page2) - const settings2 = new Settings(page2) - - // device2: add same verse - await tabs2.libraryTab.click() - await addCardsToReview(page2, ["BG 1.1"]) - - // device2: sync - await tabs2.settingsTab.click() - await settings2.account.click() - await account2.sync.click() - await page2.waitForTimeout(1000) // wait sync to complete - await expect(tabs2.reviewBadge).toHaveText("1") - } finally { - page2.close() - context2.close() - } - + const tabs2 = new TabsBar(page2) + const account2 = new Account(page2) + const settings2 = new Settings(page2) + + // device2: add same verse + await tabs2.libraryTab.click() + await addCardsToReview(page2, ["BG 1.1"]) + + // device2: sync + await tabs2.settingsTab.click() + await settings2.account.click() + await account2.sync.click() + await page2.waitForTimeout(1000) // wait sync to complete + await expect(tabs2.reviewBadge).toHaveText("1") + await context2.close() }) - // test('Sync verse twice', async ({ page, context, browser }) => { - // const uniqueEmail = Math.random().toString(36) - // const email = `${uniqueEmail}@test.rs` - - // // device1: register and login - // const account1 = new Account(page) - // await addCardsToReview(page, ["BG 1.1"]) - // await signUp(context, page, email) - // await logIn(page, email) - // await account1.sync.click() - - // // device2: login - // const [context2, page2] = await logInNewDevice(browser, email) - // try { - // const tabs = new TabsBar(page2) - // const settings = new Settings(page2) - - // await tabs.libraryTab.click() - // await addCardsToInbox(page2, ['BG 1.1']) - - // await tabs.settingsTab.click() - // await settings.account.click() - // await sync(page2) - // await page2.waitForTimeout(10000) // wait sync to complete - - // await expect(tabs.inboxBadge).toBeHidden() // already removed on device1. But still on device2 - // await expect(tabs.reviewBadge).toHaveText("1") - // } finally { - // page2.close() - // context2.close() - // } - // }) + test('Sync verse twice', async ({ page, context, browser }) => { + const uniqueEmail = Math.random().toString(36) + const email = `${uniqueEmail}@test.rs` + + // device1: register and login + const account1 = new Account(page) + await addCardsToReview(page, ["BG 1.1"]) + await signUp(context, page, email) + await logIn(page, email) + await account1.sync.click() + + // device2: login + const [context2, page2] = await logInNewDevice(browser, email) + const tabs2 = new TabsBar(page2) + const settings2 = new Settings(page2) + await tabs2.libraryTab.click() + await addCardsToInbox(page2, ['BG 1.1']) + await tabs2.settingsTab.click() + await settings2.account.click() + await sync(page2) + await page2.waitForTimeout(2000) // wait sync to complete + + await expect(tabs2.inboxBadge).toBeHidden() // already removed on device1. But still on device2 + await expect(tabs2.reviewBadge).toHaveText("1") + + await context2.close() + }) }) \ No newline at end of file