diff --git a/.github/workflows/android-appium.yml b/.github/workflows/android-appium.yml new file mode 100644 index 0000000000..ae135995d7 --- /dev/null +++ b/.github/workflows/android-appium.yml @@ -0,0 +1,99 @@ +name: Android Appium + +on: + pull_request: + push: + branches: + - develop + - master + paths: + - '.github/workflows/android-appium.yml' + - 'android/**' + - 'gulpfile.js' + - 'html/**' + - 'package.json' + - 'package-lock.json' + - 'src/**' + - 'test/**' + - 'webpack*' + +concurrency: + group: floccus-android-appium-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + android-appium: + runs-on: ubuntu-latest + timeout-minutes: 60 + + strategy: + matrix: + node-version: [20.x] + java-version: [21] + + steps: + - uses: actions/checkout@v4 + + - name: Set up node ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Set up Java ${{ matrix.java-version }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.java-version }} + cache: gradle + + - name: Install dependencies + run: npm ci + + - name: Build web assets and sync Capacitor + run: npm run build + + - name: Build Android debug APK + run: | + cd android + chmod +x gradlew + ./gradlew assembleDebug + + - name: Install Appium and Android driver + run: | + npm install -g appium + appium driver install uiautomator2 + + - name: Enable KVM access + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run Android Appium smoke tests + uses: ReactiveCircus/android-emulator-runner@v2 + env: + CI: true + FLOCCUS_TEST: '^(?=.*fake test root Account)(?=.*(should create local bookmarks on the server|should create empty local folders on the server|should update the server on local changes|should update the server on local removals)).*$' + FLOCCUS_TEST_SEED: ${{ github.sha }} + with: + api-level: 35 + arch: x86_64 + profile: pixel_7 + target: google_apis + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim + script: | + adb install -r android/app/build/outputs/apk/debug/app-debug.apk + appium --log-no-colors --allow-insecure uiautomator2:chromedriver_autodownload > appium.log 2>&1 & + until curl -sf http://127.0.0.1:4723/status > /dev/null; do echo 'Waiting for Appium to start'; sleep 1; done + npm run test:appium + + - name: Upload Appium log + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-appium-log + path: appium.log + if-no-files-found: ignore + retention-days: 7 diff --git a/package.json b/package.json index 08a49f974c..006c4f3d9c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "watch": "NODE_OPTIONS=--max-old-space-size=4096 gulp watch", "watch-win": "SET NODE_OPTIONS=--max-old-space-size=4096 & gulp watch", "test": "node --unhandled-rejections=strict test/selenium-runner.js", + "test:appium": "node --unhandled-rejections=strict test/appium-runner.js", "lint": "eslint --ext .js,.vue src", "lint:fix": "eslint --ext .js,.vue src --fix", "typecheck": "tsc --noEmit", diff --git a/src/lib/native/NativeAccount.ts b/src/lib/native/NativeAccount.ts index d680d59b5e..a72b046f34 100644 --- a/src/lib/native/NativeAccount.ts +++ b/src/lib/native/NativeAccount.ts @@ -17,6 +17,30 @@ import Logger from '../Logger' import { i18n } from './I18n' export default class NativeAccount extends Account { + static getDefaultRootId(id:string): number { + const digits = String(id).replace(/\D/g, '') + const parsed = parseInt(digits.slice(-12) || Date.now().toString(), 10) + return Number.isNaN(parsed) ? Date.now() : parsed + } + + async ensureLocalRoot(): Promise { + const accountData = this.getData() + if (accountData.localRoot === 'tabs') { + return + } + + const tree = new NativeTree(this.storage) + await tree.load() + + const rootId = accountData.localRoot || NativeAccount.getDefaultRootId(this.id) + await tree.ensureRoot(rootId) + this.localTree = tree + + if (String(accountData.localRoot) !== String(rootId) || accountData.rootPath !== '') { + await this.setData({ localRoot: String(rootId), rootPath: '' }) + } + } + static async get(id:string):Promise { const storage = new NativeAccountStorage(id) const data = await storage.getAccountData(null) @@ -46,10 +70,12 @@ export default class NativeAccount extends Account { async init(): Promise { console.log('initializing account ' + this.id) + await this.ensureLocalRoot() await this.storage.initMappings() await this.storage.initCache() const nativeTree = new NativeTree(this.storage) await nativeTree.load() + await nativeTree.ensureRoot(this.getData().localRoot) this.localTree = nativeTree } diff --git a/src/lib/native/NativeTree.ts b/src/lib/native/NativeTree.ts index e18fb9dda2..9e33507f69 100644 --- a/src/lib/native/NativeTree.ts +++ b/src/lib/native/NativeTree.ts @@ -61,6 +61,26 @@ export default class NativeTree extends CachingAdapter implements BulkImportReso }, 500) } + async ensureRoot(rootId:string|number):Promise { + const previousRootId = this.bookmarksCache.id + this.bookmarksCache.id = rootId + this.bookmarksCache.isRoot = true + this.bookmarksCache.parentId = undefined + this.bookmarksCache.children.forEach(child => { + if (String(child.parentId) === String(previousRootId)) { + child.parentId = rootId + } + }) + + const parsedRootId = parseInt(String(rootId), 10) + if (!Number.isNaN(parsedRootId)) { + this.highestId = Math.max(this.highestId, parsedRootId) + } + + this.bookmarksCache.createIndex() + await this.save() + } + async getBookmarksTree(): Promise> { const tree = await super.getBookmarksTree() tree.createIndex() diff --git a/src/ui/NativeRouter.js b/src/ui/NativeRouter.js index 85e64fd4f5..2a13459645 100644 --- a/src/ui/NativeRouter.js +++ b/src/ui/NativeRouter.js @@ -9,6 +9,7 @@ Vue.use(Router) export const routes = { HOME: 'HOME', TREE: 'TREE', + TEST: 'TEST', ACCOUNT_OPTIONS: 'ACCOUNT_OPTIONS', NEW_ACCOUNT: 'NEW_ACCOUNT', ADD_BOOKMARK: 'ADD_BOOKMARK', @@ -29,6 +30,11 @@ export const router = new Router({ name: routes.HOME, component: Home, }, + { + path: '/test', + name: routes.TEST, + component: () => import(/* webpackPrefetch: true */ './views/native/TestRunner.vue') + }, { path: '/tree/:accountId', name: routes.TREE, diff --git a/src/ui/native-tests/browser-api.js b/src/ui/native-tests/browser-api.js new file mode 100644 index 0000000000..c410590156 --- /dev/null +++ b/src/ui/native-tests/browser-api.js @@ -0,0 +1,285 @@ +import Account from '../../lib/Account' +import { Bookmark, Folder, ItemLocation, ItemType } from '../../lib/Tree' + +function serializeItem(item) { + const node = { + id: item.id, + parentId: item.parentId, + title: item.title, + type: item.type, + } + + if (item.type === ItemType.BOOKMARK) { + node.url = item.url + return node + } + + node.children = item.children.map(serializeItem) + return node +} + +async function getNativeBookmarkAccounts() { + const accountClass = await Account.getAccountClass() + const accounts = await accountClass.getAllAccounts() + return accounts.filter(account => account.getData().localRoot && account.getData().localRoot !== 'tabs') +} + +async function resolveFolder(id) { + for (const account of await getNativeBookmarkAccounts()) { + const resource = await account.getResource() + const tree = await resource.getBookmarksTree() + const folder = tree.findFolder(id) + if (folder) { + return { account, resource, tree, folder } + } + } + return null +} + +async function resolveItem(id) { + for (const account of await getNativeBookmarkAccounts()) { + const resource = await account.getResource() + const tree = await resource.getBookmarksTree() + const bookmark = tree.findBookmark(id) + if (bookmark) { + return { account, resource, tree, item: bookmark } + } + const folder = tree.findFolder(id) + if (folder) { + return { account, resource, tree, item: folder } + } + } + return null +} + +async function orderChildren(resource, parentId, movedId, index) { + if (typeof index !== 'number') { + return + } + + const tree = await resource.getBookmarksTree() + const parent = tree.findFolder(parentId) + if (!parent) { + throw new Error('Unknown parent folder: ' + parentId) + } + + const movedItem = parent.children.find(child => String(child.id) === String(movedId)) + if (!movedItem) { + throw new Error('Unknown child item: ' + movedId) + } + + const otherItems = parent.children.filter(child => String(child.id) !== String(movedId)) + otherItems.splice(index, 0, movedItem) + + await resource.orderFolder(parentId, otherItems.map(child => ({ + id: child.id, + type: child.type, + }))) +} + +async function clearFolder(resource, folder) { + for (const child of [...folder.children].reverse()) { + if (child.type === ItemType.FOLDER) { + await clearFolder(resource, child) + await resource.removeFolder(child) + } else { + await resource.removeBookmark(child) + } + } +} + +function createUnsupportedApi(name) { + return new Proxy({}, { + get() { + return () => { + throw new Error(name + ' is not supported by the native test route') + } + } + }) +} + +export function installNativeBrowserApi() { + const root = typeof window !== 'undefined' ? window : self + const browser = { + bookmarks: { + async create(details) { + const resolvedParent = await resolveFolder(details.parentId) + if (!resolvedParent) { + throw new Error('Unknown parent folder: ' + details.parentId) + } + + const { resource } = resolvedParent + let id + if (details.url || details.type === ItemType.BOOKMARK) { + id = await resource.createBookmark(new Bookmark({ + id: null, + parentId: details.parentId, + title: details.title || '', + url: details.url, + location: ItemLocation.LOCAL, + })) + } else { + id = await resource.createFolder(new Folder({ + id: null, + parentId: details.parentId, + title: details.title || '', + location: ItemLocation.LOCAL, + })) + } + + await orderChildren(resource, details.parentId, id, details.index) + + const resolvedItem = await resolveItem(id) + return serializeItem(resolvedItem.item) + }, + + async update(id, changes) { + const resolvedItem = await resolveItem(id) + if (!resolvedItem) { + throw new Error('Unknown bookmark item: ' + id) + } + + const { resource, item } = resolvedItem + const parentId = changes.parentId || item.parentId + + if (item.type === ItemType.FOLDER) { + await resource.updateFolder(new Folder({ + id: item.id, + parentId, + title: changes.title || item.title, + children: item.children, + location: ItemLocation.LOCAL, + })) + } else { + await resource.updateBookmark(new Bookmark({ + id: item.id, + parentId, + title: changes.title || item.title, + url: changes.url || item.url, + location: ItemLocation.LOCAL, + })) + } + + const updatedItem = await resolveItem(id) + return serializeItem(updatedItem.item) + }, + + async move(id, destination) { + const resolvedItem = await resolveItem(id) + if (!resolvedItem) { + throw new Error('Unknown bookmark item: ' + id) + } + + const { resource, item } = resolvedItem + const parentId = destination.parentId || item.parentId + + if (item.type === ItemType.FOLDER) { + await resource.updateFolder(new Folder({ + id: item.id, + parentId, + title: item.title, + children: item.children, + location: ItemLocation.LOCAL, + })) + } else { + await resource.updateBookmark(new Bookmark({ + id: item.id, + parentId, + title: item.title, + url: item.url, + location: ItemLocation.LOCAL, + })) + } + + await orderChildren(resource, parentId, id, destination.index) + + const movedItem = await resolveItem(id) + return serializeItem(movedItem.item) + }, + + async remove(id) { + const resolvedItem = await resolveItem(id) + if (!resolvedItem) { + return + } + + const { resource, item } = resolvedItem + if (item.type === ItemType.FOLDER) { + await clearFolder(resource, item) + await resource.removeFolder(item) + } else { + await resource.removeBookmark(item) + } + }, + + async removeTree(id) { + const resolvedFolder = await resolveFolder(id) + if (!resolvedFolder) { + return + } + + const { account, resource, folder } = resolvedFolder + await clearFolder(resource, folder) + + if (String(account.getData().localRoot) !== String(id)) { + await resource.removeFolder(folder) + } + }, + + async get(id) { + const resolvedItem = await resolveItem(id) + if (!resolvedItem) { + throw new Error('Unknown bookmark item: ' + id) + } + return [serializeItem(resolvedItem.item)] + }, + + async getChildren(id) { + const resolvedFolder = await resolveFolder(id) + if (!resolvedFolder) { + throw new Error('Unknown folder: ' + id) + } + return resolvedFolder.folder.children.map(serializeItem) + }, + + async getSubTree(id) { + const resolvedFolder = await resolveFolder(id) + if (!resolvedFolder) { + throw new Error('Unknown folder: ' + id) + } + return [serializeItem(resolvedFolder.folder)] + }, + + async getTree() { + const accounts = await getNativeBookmarkAccounts() + if (accounts.length === 1) { + const resource = await accounts[0].getResource() + return [serializeItem(await resource.getBookmarksTree())] + } + + const children = [] + for (const account of accounts) { + const resource = await account.getResource() + children.push(serializeItem(await resource.getBookmarksTree())) + } + + return [{ + id: 'native-root', + title: 'root', + type: ItemType.FOLDER, + children, + }] + }, + }, + tabs: createUnsupportedApi('browser.tabs'), + windows: createUnsupportedApi('browser.windows'), + } + + Object.defineProperty(root, 'browser', { + configurable: true, + writable: true, + value: browser, + }) + + return browser +} diff --git a/src/ui/native-tests/run.js b/src/ui/native-tests/run.js new file mode 100644 index 0000000000..572632819a --- /dev/null +++ b/src/ui/native-tests/run.js @@ -0,0 +1,117 @@ +import util from 'util' +import { installNativeBrowserApi } from './browser-api' + +function getAbsoluteAssetUrl(path) { + return window.location.origin + path +} + +function loadAsset(tagName, attributes) { + return new Promise((resolve, reject) => { + const existing = document.querySelector(`${tagName}[data-native-test-asset="${attributes['data-native-test-asset']}"]`) + if (existing) { + resolve(existing) + return + } + + const element = document.createElement(tagName) + Object.entries(attributes).forEach(([name, value]) => { + element.setAttribute(name, value) + }) + element.addEventListener('load', () => resolve(element), { once: true }) + element.addEventListener('error', () => reject(new Error('Failed to load test asset ' + attributes['data-native-test-asset'])), { once: true }) + document.head.appendChild(element) + }) +} + +function installConsoleBridge() { + window.floccusTestLogs = [] + window.floccusTestLogsLength = 0 + + if (!console.__floccusNativeOriginalLog) { + console.__floccusNativeOriginalLog = console.log.bind(console) + console.log = function() { + console.__floccusNativeOriginalLog.apply(console, arguments) + window.floccusTestLogs.push(util.format.apply(util, arguments)) + } + } +} + +function syncSearchParams(routeQuery) { + const params = new URLSearchParams() + Object.entries(routeQuery || {}).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(entry => params.append(key, entry)) + return + } + if (value !== null && typeof value !== 'undefined') { + params.set(key, value) + } + }) + + const search = params.toString() + history.replaceState( + history.state, + '', + `${window.location.pathname}${search ? '?' + search : ''}${window.location.hash}` + ) +} + +function installErrorBridge() { + window.addEventListener('error', event => { + if (event.error) { + console.log(event.error.stack || event.error.message) + return + } + console.log(event.message) + }) + + window.addEventListener('unhandledrejection', event => { + const reason = event.reason + console.log((reason && (reason.stack || reason.message)) || reason) + }) +} + +let hasInstalledErrorBridge = false + +export async function runNativeTests(routeQuery) { + window.__floccusNativeTestReady = false + window.__floccusNativeTestFinished = false + + installConsoleBridge() + if (!hasInstalledErrorBridge) { + installErrorBridge() + hasInstalledErrorBridge = true + } + + syncSearchParams(routeQuery) + installNativeBrowserApi() + + await loadAsset('link', { + rel: 'stylesheet', + href: getAbsoluteAssetUrl('/css/mocha.css'), + 'data-native-test-asset': 'mocha.css', + }) + await loadAsset('script', { + src: getAbsoluteAssetUrl('/js/mocha.js'), + 'data-native-test-asset': 'mocha.js', + }) + + const { createWebdriverAndHtmlReporter } = await import('../../test/reporter') + + const params = new URL(window.location.href).searchParams + mocha.setup('bdd') + if (params.get('grep')) { + mocha.grep(params.get('grep')) + } + + await import('../../test/test') + + return new Promise((resolve) => { + mocha.reporter(createWebdriverAndHtmlReporter(mocha._reporter)) + window.__floccusNativeTestReady = true + mocha.run(() => { + window.__floccusNativeTestFinished = true + resolve() + }) + }) +} diff --git a/src/ui/views/native/TestRunner.vue b/src/ui/views/native/TestRunner.vue new file mode 100644 index 0000000000..0658916016 --- /dev/null +++ b/src/ui/views/native/TestRunner.vue @@ -0,0 +1,33 @@ + + + + + + diff --git a/test/appium-runner.js b/test/appium-runner.js new file mode 100644 index 0000000000..0e7a96e35d --- /dev/null +++ b/test/appium-runner.js @@ -0,0 +1,287 @@ +const fetch = require('node-fetch') + +const APPIUM_SERVER = process.env.APPIUM_SERVER || 'http://127.0.0.1:4723' +const APPIUM_TIMEOUT = parseInt(process.env.APPIUM_TIMEOUT || '180000', 10) +const APPIUM_POLL_INTERVAL = parseInt(process.env.APPIUM_POLL_INTERVAL || '3000', 10) + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +async function request(method, path, body) { + const response = await fetch(`${APPIUM_SERVER}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: typeof body === 'undefined' ? undefined : JSON.stringify(body), + }) + + let payload = null + const text = await response.text() + if (text) { + payload = JSON.parse(text) + } + + if (!response.ok) { + const error = new Error(`Appium request failed: ${method} ${path} -> ${response.status}`) + error.payload = payload + throw error + } + + if (payload && payload.value && payload.value.error) { + const error = new Error(payload.value.message || payload.value.error) + error.payload = payload + throw error + } + + return payload ? payload.value : null +} + +async function waitForAppium() { + const startedAt = Date.now() + while (Date.now() - startedAt < APPIUM_TIMEOUT) { + try { + const status = await request('GET', '/status') + if (status && status.ready !== false) { + return + } + } catch (error) { + console.log('Waiting for Appium server:', error.message) + } + await sleep(1000) + } + + throw new Error('Timed out while waiting for the Appium server') +} + +function getSessionCapabilities() { + const capabilities = { + platformName: 'Android', + 'appium:automationName': 'UiAutomator2', + 'appium:deviceName': process.env.APPIUM_DEVICE_NAME || 'Android Emulator', + 'appium:autoGrantPermissions': true, + 'appium:newCommandTimeout': 600, + 'appium:noReset': false, + 'appium:chromedriverAutodownload': true, + } + + if (process.env.APPIUM_APP) { + capabilities['appium:app'] = process.env.APPIUM_APP + } else { + capabilities['appium:appPackage'] = process.env.APPIUM_APP_PACKAGE || 'org.handmadeideas.floccus' + capabilities['appium:appActivity'] = process.env.APPIUM_APP_ACTIVITY || 'org.handmadeideas.floccus.MainActivity' + } + + return { + capabilities: { + alwaysMatch: capabilities, + firstMatch: [{}], + }, + } +} + +async function createSession() { + const value = await request('POST', '/session', getSessionCapabilities()) + if (value.sessionId) { + return value.sessionId + } + throw new Error('Appium did not return a session id') +} + +async function deleteSession(sessionId) { + if (!sessionId) { + return + } + + try { + await request('DELETE', `/session/${sessionId}`) + } catch (error) { + console.log('Failed to delete Appium session:', error.message) + } +} + +async function waitForWebViewContext(sessionId) { + const startedAt = Date.now() + while (Date.now() - startedAt < APPIUM_TIMEOUT) { + const contexts = await request('GET', `/session/${sessionId}/contexts`) + const webViewContext = (contexts || []).find(context => context.startsWith('WEBVIEW')) + if (webViewContext) { + return webViewContext + } + await sleep(1000) + } + + throw new Error('Timed out while waiting for a WEBVIEW context') +} + +async function switchContext(sessionId, context) { + await request('POST', `/session/${sessionId}/context`, { name: context }) +} + +async function executeScript(sessionId, script, args = []) { + return request('POST', `/session/${sessionId}/execute/sync`, { + script, + args, + }) +} + +function getServerUrl() { + let server = `http://${process.env.TEST_HOST || 'localhost'}` + + if ((process.env.FLOCCUS_TEST || '').includes('linkwarden')) { + server = 'https://cloud.linkwarden.app' + } + if ((process.env.FLOCCUS_TEST || '').includes('karakeep')) { + server = `http://${process.env.KARAKEEP_TEST_HOST}` + } + + return server +} + +async function appendKarakeepCredentials(server, params) { + const createUserResp = await fetch(`${server}/api/trpc/users.create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + json: { + name: 'floccus', + email: 'floccus@example.com', + password: '12345678', + confirmPassword: '12345678', + } + }), + }) + + console.log('Created karakeep user', await createUserResp.json()) + + const apiKeyResp = await fetch(`${server}/api/trpc/apiKeys.exchange`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + json: { + keyName: 'karakeep', + email: 'floccus@example.com', + password: '12345678', + } + }), + }) + const apiKey = await apiKeyResp.json() + params.set('password', apiKey.result.data.json.key) +} + +async function buildTestRoute() { + const params = new URLSearchParams() + const server = getServerUrl() + + params.set('grep', process.env.FLOCCUS_TEST || '') + params.set('server', server) + params.set('app_version', process.env.APP_VERSION || 'native') + params.set('browser', 'android') + params.set('test_url', process.env.TEST_URL || 'http://nextcloud/') + params.set('ci', 'true') + + if (process.env.GOOGLE_API_REFRESH_TOKEN && (process.env.FLOCCUS_TEST || '').includes('google-drive')) { + params.set('password', process.env.GOOGLE_API_REFRESH_TOKEN) + } + + if (process.env.DROPBOX_API_REFRESH_TOKEN && (process.env.FLOCCUS_TEST || '').includes('dropbox')) { + params.set('password', process.env.DROPBOX_API_REFRESH_TOKEN) + } + + if ((process.env.FLOCCUS_TEST || '').includes('linkwarden')) { + params.set('username', 'mk') + params.set('password', process.env.LINKWARDEN_TOKEN || '') + } + + if (process.env.FLOCCUS_TEST_SEED) { + params.set('seed', process.env.FLOCCUS_TEST_SEED) + } + + if ((process.env.FLOCCUS_TEST || '').includes('karakeep')) { + await appendKarakeepCredentials(server, params) + } + + return `#/test?${params.toString()}` +} + +async function waitForTestBoot(sessionId) { + const startedAt = Date.now() + while (Date.now() - startedAt < APPIUM_TIMEOUT) { + try { + const state = await executeScript(sessionId, ` + return { + href: window.location.href, + ready: Boolean(window.__floccusNativeTestReady), + logs: window.floccusTestLogs ? window.floccusTestLogs.slice(window.floccusTestLogsLength || 0) : [] + } + `) + + if (Array.isArray(state.logs)) { + state.logs.forEach(entry => console.log(entry)) + await executeScript(sessionId, 'window.floccusTestLogsLength = window.floccusTestLogs ? window.floccusTestLogs.length : 0') + } + + if (state.ready) { + return + } + } catch (error) { + console.log('Waiting for native test route:', error.message) + } + + await sleep(1000) + } + + throw new Error('Timed out while waiting for the native test route to boot') +} + +async function streamLogsUntilFinished(sessionId) { + let finishedLog = null + + while (!finishedLog) { + await sleep(APPIUM_POLL_INTERVAL) + const logs = await executeScript(sessionId, ` + var logs = window.floccusTestLogs ? window.floccusTestLogs.slice(window.floccusTestLogsLength || 0) : [] + window.floccusTestLogsLength = window.floccusTestLogs ? window.floccusTestLogs.length : 0 + return logs + `) + + logs.forEach(entry => console.log(entry)) + finishedLog = logs.find(entry => entry.includes('FINISHED')) + } + + if (finishedLog.includes('FAILED')) { + throw new Error(finishedLog) + } +} + +;(async function() { + let sessionId + try { + await waitForAppium() + sessionId = await createSession() + console.log('Created Appium session', sessionId) + + const webViewContext = await waitForWebViewContext(sessionId) + console.log('Switching to context', webViewContext) + await switchContext(sessionId, webViewContext) + + const route = await buildTestRoute() + await executeScript(sessionId, 'window.location.hash = arguments[0]; return window.location.href', [route]) + console.log('Opened native test route', route) + + await waitForTestBoot(sessionId) + await streamLogsUntilFinished(sessionId) + + await deleteSession(sessionId) + } catch (error) { + console.log(error) + await deleteSession(sessionId) + process.exit(1) + } +})() +