diff --git a/api/get-fetch.js b/api/get-fetch.js new file mode 100644 index 0000000..00d09ad --- /dev/null +++ b/api/get-fetch.js @@ -0,0 +1,45 @@ +/* global btoa */ +const isomorphicFetch = require('isomorphic-fetch') + +function getFetch ({url, username, password, fetch = isomorphicFetch}) { + if (!url) { + throw new Error(`getFetch URL required`) + } + + async function fetcher (endpoint, params) { + const config = { + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + ...params + } + if (username && password) { + config.headers['Authorization'] = `Basic ${btoa(`${username}:${password}`)}` + delete config.credentials + } + + const urlWithSlash = url.endsWith('/') + ? url + : `${url}/` + + const response = await fetch(`${urlWithSlash}${endpoint}`, config) + let body + try { + body = await response.json() + } catch (e) { + console.warn('error caught in fetch parsing body') + } + if (!response.ok) { + const error = new Error() + Object.assign(error, {status: response.status, statusText: response.statusText}, body) + throw error + } + return body + } + + return fetcher +} + +module.exports = {getFetch} diff --git a/api/index.js b/api/index.js new file mode 100644 index 0000000..3071c07 --- /dev/null +++ b/api/index.js @@ -0,0 +1,3 @@ +const {RemoteCouchApi} = require('./remote-couch-api') + +module.exports = {RemoteCouchApi} diff --git a/api/pouchdb.js b/api/pouchdb.js new file mode 100644 index 0000000..a45ed6d --- /dev/null +++ b/api/pouchdb.js @@ -0,0 +1,25 @@ +// see note in webpack config about this goofy movie. +const PouchDB = pickModule(require('pouchdb-core')) +const idbAdapter = pickModule(require('pouchdb-adapter-idb')) +const memoryAdapter = pickModule(require('pouchdb-adapter-memory')) +const httpAdapter = pickModule(require('pouchdb-adapter-http')) +const replication = pickModule(require('pouchdb-replication')) +const find = pickModule(require('pouchdb-find')) +const mapReduce = pickModule(require('pouchdb-mapreduce')) + +PouchDB + .plugin(idbAdapter) + .plugin(httpAdapter) + .plugin(replication) + .plugin(mapReduce) + .plugin(memoryAdapter) + .plugin(find) + +// pouch exports modules in a way that you cannot simply "require" these in +// node as well as in webpack, which is why we need this workaround: +// https://github.com/pouchdb-community/pouchdb-authentication/issues/164#issuecomment-357697828 +function pickModule (mod) { + return 'default' in mod ? mod.default : mod +} + +module.exports = {PouchDB} diff --git a/api/remote-couch-api.js b/api/remote-couch-api.js new file mode 100644 index 0000000..eb50ed6 --- /dev/null +++ b/api/remote-couch-api.js @@ -0,0 +1,115 @@ +const {getFetch} = require('./get-fetch') +const {PouchDB} = require('./pouchdb') + +class RemoteCouchApi { + constructor (url) { + url = url.endsWith('/') ? url : `${url}/` + + this.url = url + this.fetcher = getFetch({url}) + // this will probably change to support node + // will have to deal with setDatabase & username/passwords + this.PouchDBConstructor = PouchDB.defaults({prefix: url}) + this.GenericPouchDB = PouchDB + this.databases = {} + } + + async logout () { + await this.fetcher('_session', {method: 'DELETE'}) + this.user = null + } + + async getCurrentUser () { + const session = await this.getCurrentSession() + if (!session) return null + + return this.getUserFromSession(session) + } + + async getCurrentSession () { + const {userCtx} = await this.fetcher('_session') + // this is couch telling us there's no session + if (!userCtx.name) return null + return userCtx + } + + async getUserFromSession (session) { + // couchdb's way of saying admin user, usually does not have a user doc + if (session.roles && session.roles.includes('_admin')) { + delete session.ok + this.user = session + return session + } + + const user = await this.fetcher(`_users/org.couchdb.user:${session.name}`) + this.user = user + return user + } + + async login (username = '', password = '') { + const session = await this.fetcher( + '_session', {method: 'POST', body: JSON.stringify({username, password})} + ) + return this.getUserFromSession(session) + } + + getConfigUrl () { + const isLocal = this.url.includes('://localhost:') || this.url.includes('://127.0.0.1:') + return isLocal + ? `_node/couchdb@localhost/_config` + : `_node/nonode@nohost/_config` + } + + async getConfig () { + return this.fetcher(`${this.getConfigUrl()}`) + } + + async getAdminConfig () { + return this.fetcher(`${this.getConfigUrl()}/admins`) + } + + async updateAdmin (username, password) { + const url = `${this.getConfigUrl()}/admins/${username}` + return this.fetcher(url, {method: 'PUT', body: `"${password}"`}) + } + + getPouchInstance (databaseName) { + if (!this.databases[databaseName]) { + this.databases[databaseName] = new this.PouchDBConstructor(databaseName) + } + + return this.databases[databaseName] + } + + async listDatabases () { + return this.fetcher('_all_dbs') + } + + async listInfos (keys) { + try { + const response = await this.fetcher('_dbs_info', {method: 'POST', body: JSON.stringify({keys})}) + return response + } catch (error) { + if (error.status !== 404) throw error + + const promises = keys.map(dbName => this.getDatabase(dbName)) + const response = await Promise.all(promises) + // make same shape as _dbs_info + return response.map((info, index) => ({key: keys[index], info})) + } + } + + async createDatabase (databaseName) { + return this.fetcher(databaseName, {method: 'PUT'}) + } + + async getDatabase (databaseName) { + return this.fetcher(databaseName) + } + + async destroyDatabase (databaseName) { + return this.fetcher(databaseName, {method: 'DELETE'}) + } +} + +module.exports = {RemoteCouchApi} diff --git a/app/app.js b/app/app.js index 526ca9b..c92d0a5 100644 --- a/app/app.js +++ b/app/app.js @@ -10,76 +10,195 @@ import ConfigContainer from './containers/ConfigContainer' import AdminContainer from './containers/AdminContainer' import EditDocContainer from './containers/EditDocContainer' import QueryContainer from './containers/QueryContainer' -import Nav from './components/Nav' +import Footer from './components/Footer' import Login from './components/Login' import Loading from './components/Loading' -import withParams from './containers/withParams' -import { parseUrl } from './utils/utils' -import fetcher from 'utils/fetcher' +import { parseUrl, getParams } from './utils/utils' +import {RemoteCouchApi} from '../api/' import 'app-classes.css' import 'app-tags.css' -const Databases = withParams(DatabasesContainer) -const LIMIT = 100 +// 1. App = if no couch in the URL, return SetupCouchContainer +// 2. CouchRoutes = routes for "we have couch URL but not a specific database." +// 3. OnDatabaseRoutes = we have a couch url and a specific datbase + +class OnDatabaseRoutes extends Component { + constructor (props) { + super(props) + this.state = { + loading: true + } + } + + async componentDidMount () { + this.setupDatabase(this.props.match.params.dbName) + } + + componentDidUpdate (prevProps) { + if (prevProps.match.params.dbName !== this.props.match.params.dbName) { + this.setupDatabase(this.props.match.params.dbName) + } + } + + setupDatabase = async (dbName) => { + this.pouchDB = this.props.api.getPouchInstance(dbName) + window.pouchDB = this.pouchDB + console.log(`${dbName} available in console as window.pouchDB`) + this.setState({loading: false}) + } + + render () { + const { dbName } = this.props.match.params + const {couchUrl} = this.props + + const commonProps = { + dbUrl: `${couchUrl}${dbName}/`, + dbName, + pouchDB: this.pouchDB, + ...this.props + } + + if (!commonProps.pouchDB) return null + + return ( +
+
+ + ()} + /> + ()} + /> + ()} + /> + ()} + /> + ()} + /> + ()} + /> + ()} + /> + +
+
+ ) + } +} class CouchRoutes extends Component { constructor (props) { super(props) this.state = { - userCtx: null, - loading: true, - couchUrl: parseUrl(props.match.params.couch), - dbs: null + user: null, + loading: true } } async componentDidMount () { - const { couchUrl } = this.state - const { userCtx } = await fetcher.checkSession(couchUrl) - const authenticated = userCtx.name || userCtx.roles.includes('_admin') + this.setupCouch(this.props.match.params.couch) + } - if (!authenticated) { - this.setState({loading: false}) - return + componentDidUpdate (prevProps) { + if (prevProps.match.params.couch !== this.props.match.params.couch) { + this.setupCouch(this.props.match.params.couch) } + } - this.onUser(userCtx) + login = (username, password) => { + return this.api.login(username, password).then(user => { + this.setState({user}) + }) } - // TODO: this cache object was never used much - onUser = async (userCtx) => { - const { couchUrl } = this.state - const dbs = await fetcher.get(`${couchUrl}_all_dbs`, { limit: LIMIT }) - this.setState({ loading: false, dbs, userCtx }) + setupCouch = async (couch) => { + const couchUrl = parseUrl(couch) + this.api = new RemoteCouchApi(couchUrl) + window.api = this.api + console.log(` + remote couch api on ${couchUrl} available in console as window.api, + api.PouchDBConstructor is pouch constructor with couchUrl prefix + api.GenericPouchDB is Pouch constructor without prefix + `) + + const user = await this.api.getCurrentUser() + this.setState({loading: false, user}) } render () { - const { userCtx, loading, couchUrl, dbs } = this.state + const { couch } = this.props.match.params + const couchUrl = parseUrl(couch) + const searchParams = getParams(this.props.location.search) + + const { user, loading } = this.state if (loading) return - if (!userCtx) return + if (!user) { + return ( + + ) + } + + const commonProps = { + couch, + couchUrl, + searchParams, + user, + api: this.api + } return (
- - - - - - - - - - - - } /> + ()} + /> + ()} + /> + ()} + /> + ()} + /> + ()} + />
- (
) } diff --git a/app/containers/DeleteDatabaseContainer.js b/app/components/DeleteDatabaseModal.js similarity index 56% rename from app/containers/DeleteDatabaseContainer.js rename to app/components/DeleteDatabaseModal.js index 3389585..b65cc5d 100644 --- a/app/containers/DeleteDatabaseContainer.js +++ b/app/components/DeleteDatabaseModal.js @@ -1,46 +1,29 @@ import React from 'react' import Modal from 'components/Modal' -import fetcher from 'utils/fetcher' -export default class DeleteDatabaseContainer extends React.Component { - state = { dbNameConfirmed: false, loading: false, inputText: '', error: null } +export default class DeleteDatabaseModal extends React.Component { + state = { dbNameConfirmed: false, inputText: '' } onInputChange = (e) => { const { value } = e.target const { dbName } = this.props - this.setState({ dbNameConfirmed: (value === dbName), inputText: value, error: null }) - } - - maybeEscape = (e) => { - if (e.keyCode === 27) { - this.setState({ inputText: '' }) - this.props.onClose() - } + this.setState({ dbNameConfirmed: (value === dbName), inputText: value }) } onSubmit = async (e) => { e.preventDefault() - const { inputText, dbNameConfirmed } = this.state - const { couchUrl, history, couch } = this.props - const id = inputText - if (dbNameConfirmed) { - this.setState({ loading: true }) - try { - await fetcher.destroy(couchUrl + id) - history.push(`/${couch}/`) - } catch (error) { - this.setState({ error, loading: false }) - } + if (this.state.dbNameConfirmed) { + this.props.onConfirm() } } render () { const { show, onClose, couchUrl, dbName } = this.props - const { dbNameConfirmed, loading, error, inputText } = this.state + const { dbNameConfirmed, inputText } = this.state return (
@@ -58,10 +41,9 @@ export default class DeleteDatabaseContainer extends React.Component { onKeyUp={this.maybeEscape} /> - {error && (
{error}
)} - {error && (
{error}
)} + {error && (
{JSON.stringify(error, null, 2)}
)} { @@ -56,8 +65,11 @@ export default class QueryContainer extends React.Component { } const { fetchParams, parse } = parsedInput + const {url} = fetchParams + delete fetchParams.url + fetchParams.body = JSON.stringify(fetchParams.body) try { - const response = await fetcher.fetch(fetchParams) + const response = await this.props.api.fetcher(url, fetchParams) const result = parse(response) this.setState({ response, result, loading: false }) } catch (error) { @@ -85,6 +97,7 @@ export default class QueryContainer extends React.Component { copyTextToClipboard(`${currUrl}/query/custom/${param}`) } + // TODO: move to api or smth handleConfirmDelete = async () => { this.download() const {response} = this.state @@ -99,10 +112,11 @@ export default class QueryContainer extends React.Component { `Docs, ids, or revs not found in response.docs, aborting delete. ${docs}` ) } - const {dbUrl} = this.props - const deleteResponse = await fetcher.post(`${dbUrl}_bulk_docs`, {docs}) + const {dbName} = this.props + const body = JSON.stringify({docs}) + const deleteResponse = await this.props.api.fetcher(`${dbName}/_bulk_docs`, {method: 'POST', body}) console.log(`deleted docs response`, deleteResponse) - const errorsFound = deleteResponse.filter(r => !r.ok) + const errorsFound = deleteResponse.filter(r => !r.ok || r.error) if (errorsFound.length) { console.error(`Errors found when trying to delete docs!`, errorsFound) } @@ -110,7 +124,9 @@ export default class QueryContainer extends React.Component { render () { const { dbName, couch, couchUrl } = this.props - const { query, queryId, queries, error, input, valid, loading, result } = this.state + const { query, queryId, queries, error, input, valid, loading, result, setup } = this.state + + if (!setup) return null const links = Object.keys(queries).map(query => ( diff --git a/app/containers/SearchContainer.js b/app/containers/SearchContainer.js index 111487f..7b924b3 100644 --- a/app/containers/SearchContainer.js +++ b/app/containers/SearchContainer.js @@ -1,5 +1,4 @@ import React from 'react' -import fetcher from 'utils/fetcher' import { debounce } from 'utils/utils' import Loading from 'components/Loading' @@ -10,6 +9,8 @@ import './search-container.css' // TODO: move to utils const isExcluded = db => ['_global_changes', '_metadata', '_replicator'].indexOf(db) === -1 +// TODO: multi db search broken after refactor to fix +// 3.x database list const SingleView = (props = {}) => { const docs = props.docs || [] let url = id => props.dbUrl ? `${props.dbUrl}/${id}` : id @@ -39,7 +40,7 @@ const MultipleView = (props = {}) => { ) } -export default class extends React.Component { +export default class SearchContainer extends React.Component { state = { result: {}, searching: false @@ -48,7 +49,6 @@ export default class extends React.Component { debouncedSearch = debounce(string => this.search(string)) fetcher (db, text, limit = 10) { - const {couchUrl} = this.props const body = { selector: { _id: { @@ -58,7 +58,12 @@ export default class extends React.Component { limit } - return fetcher.post(`${couchUrl}${db}/_find`, body) + const params = { + method: 'POSt', + body: JSON.stringify(body) + } + + return this.props.api.fetcher(`${db}/_find`, params) .then(result => ({db, docs: result.docs})) // catch incase of _user db permission .catch(() => Promise.resolve({db, docs: []})) diff --git a/app/containers/SetupCouchContainer.js b/app/containers/SetupCouchContainer.js index a4a7e1e..0d50961 100644 --- a/app/containers/SetupCouchContainer.js +++ b/app/containers/SetupCouchContainer.js @@ -1,6 +1,6 @@ import React from 'react' -import fetcher from 'utils/fetcher' import localStorager from 'utils/localstorager' +import {RemoteCouchApi} from '../../api/' import {parseUrl} from 'utils/utils' export default class SetupCouchContainer extends React.Component { @@ -16,13 +16,18 @@ export default class SetupCouchContainer extends React.Component { } } - async tryACouch (inputUrl) { + tryACouch = async (inputUrl) => { inputUrl = parseUrl(inputUrl) - this.setState({inputUrl}) - this.setState({ loading: true }) + this.setState({inputUrl, loading: true}) + console.log(this.props) + // is the couch reachable? (using session because '/' is sometimes nginxed + // to a non-couch resource) try { - // is the couch reachable? - await fetcher.get(inputUrl) + // instantiating api here & in the main app + // is weird, but trying to do it in the app + // ran into problems in passing router props around + const api = new RemoteCouchApi(inputUrl) + await api.getCurrentUser() localStorager.saveRecent('couchurls', inputUrl) this.props.history.push(inputUrl.split('//')[1]) } catch (error) { diff --git a/app/containers/TestContainer.js b/app/containers/TestContainer.js deleted file mode 100644 index 1e6bef5..0000000 --- a/app/containers/TestContainer.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react' - -export default class extends React.Component { - render () { - return ( -
-

Test

-
- ) - } -} diff --git a/app/containers/withParams.js b/app/containers/withParams.js deleted file mode 100644 index 573c108..0000000 --- a/app/containers/withParams.js +++ /dev/null @@ -1,29 +0,0 @@ -import React, { Component } from 'react' -import { parseUrl, getParams } from 'utils/utils' - -export default function withParams (WrappedComponent) { - class withParamsWrapper extends Component { - render () { - const { params: { dbName, couch } } = this.props.match - const couchUrl = parseUrl(this.props.match.params.couch) - const { location: { search } } = this.props - const searchParams = getParams(search) - - return ( - - ) - } - } - - withParamsWrapper.displayName = `withDB(${WrappedComponent.displayName || WrappedComponent.name})` - withParamsWrapper.WrappedComponent = WrappedComponent - - return withParamsWrapper -} diff --git a/app/utils/download.js b/app/utils/download.js index 1d88214..deef360 100644 --- a/app/utils/download.js +++ b/app/utils/download.js @@ -1,6 +1,7 @@ /* global URL, Blob */ export function downloadJSON (json, fileName) { const a = document.createElement('a') + fileName = `${fileName}-${new Date().toJSON().replace(/\.|:/g, '-')}` a.download = fileName.replace(/[\/:*?"<>|]/g, '') + '.json' // eslint-disable-line no-useless-escape const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'text/plain' }) a.href = URL.createObjectURL(blob) diff --git a/app/utils/fetcher.js b/app/utils/fetcher.js deleted file mode 100644 index ee65c28..0000000 --- a/app/utils/fetcher.js +++ /dev/null @@ -1,142 +0,0 @@ -/* global TextDecoder */ - -const fetch = window.fetch - -const defaultOptions = { - headers: { 'Content-Type': 'application/json' }, - credentials: 'include' -} - -export default { - fetch (options) { - if (options.method && options.method.toLowerCase() === 'get') { - return this.get(options.url, options.params) - } - return fetch(options.url, { - ...defaultOptions, - ...options, - body: JSON.stringify(options.body) - }) - .then(parseJSON) - .catch(throwParsedError) - }, - - get (url, params) { - const urlMaybeWithParams = (params) ? `${url}?${getParams(params)}` : url - return fetch(urlMaybeWithParams, defaultOptions) - .then(parseJSON) - .catch(throwParsedError) - }, - - dbGet (couchUrl, dbName, resource, params) { - const url = `${couchUrl}${dbName}/${resource}` - return this.get(url, params) - }, - - post (url, data = {}, method = 'POST') { - const options = { - ...defaultOptions, - method, - body: JSON.stringify(data) - } - return fetch(url, options) - .then(parseJSON) - .catch(throwParsedError) - }, - - put (resource, data = {}) { - return this.post(resource, data, 'PUT') - }, - - dbPut (couchUrl, dbName, resource, doc) { - const url = `${couchUrl}${dbName}/${resource}` - return this.put(url, doc) - }, - - checkSession (url) { - return fetch(url + '_session', { ...defaultOptions, method: 'GET' }) - .then(parseJSON) - .catch(throwParsedError) - }, - - login (coucuUrl, username, password) { - return this.post(coucuUrl + '_session', {username, password}) - }, - - destroy (url) { - return fetch(url, { - ...defaultOptions, - method: 'DELETE' - }).then(parseJSON) - }, - - destroySession (couchUrl) { - return this.destroy(couchUrl + '_session').then(() => { - window.location.reload() - }) - }, - - getDesignDoc (dbName, designDocName) { - const searchParams = getParams({ - reduce: false, - descending: true - }) - return this.get(`${dbName}/_design/${designDocName}/_view/${designDocName}?${searchParams}`) - }, - - getMultipart (url, params) { - if (params) url = `${url}?${getParams(params)}` - return fetch(url, defaultOptions).then(response => { - const reader = response.body.getReader() - const decoder = new TextDecoder() - let text = '' - return getLine() - function getLine () { - return reader.read().then(({value, done}) => { - if (value) { - text += decoder.decode(value, {stream: !done}) - return getLine() - } else { - // Split on the newline, each fourth line is our JSON, e.g. - // --4b08b42f8ccb77cba04ddfe410ae8b15 - // Content-Type: application/json - // [empty line] - // [our json] - const lines = text.split('\n') - const revs = [] - lines.forEach((line, i) => { - if ((i + 1) % 4 === 0) { - const jsonRev = JSON.parse(line) - revs.push(jsonRev) - } - }) - return revs - } - }) - } - }).catch(throwParsedError) - } -} - -async function parseJSON (resp) { - let json = await resp.json() - if (resp.status >= 200 && resp.status < 400) { - return json - } else { - return Promise.reject(json) - } -} - -function throwParsedError (throwParsedError) { - let errorString - if (Object.keys(throwParsedError).length) { - errorString = Object.keys(throwParsedError).map(key => (` ${key}: ${throwParsedError[key]}`)).join(' ') - } else { - errorString = throwParsedError.toString() - } - return Promise.reject(new Error(errorString)) -} - -function getParams (data) { - return Object.keys(data).map(key => [key, data[key]].map(encodeURIComponent).join('=')).join('&') -} diff --git a/app/utils/queries.js b/app/utils/queries.js index 6980ea1..56108c6 100644 --- a/app/utils/queries.js +++ b/app/utils/queries.js @@ -1,11 +1,11 @@ const DEFAULT_LIMIT = 2000 const DEFAULT_LARGE_LIMIT = 50000000 -export function getAllQueries (dbUrl) { +export function getAllQueries (dbName) { return { 'id-regex': { fetchParams: { - url: `${dbUrl}_find`, + url: `${dbName}/_find`, method: 'POST', body: { selector: { _id: { '$regex': '' } }, @@ -25,7 +25,7 @@ export function getAllQueries (dbUrl) { }, 'id-regex-console': { fetchParams: { - url: `${dbUrl}_find`, + url: `${dbName}/_find`, method: 'POST', body: { selector: { _id: { '$regex': '' } }, @@ -42,7 +42,7 @@ export function getAllQueries (dbUrl) { }, 'all-docs': { fetchParams: { - url: `${dbUrl}_all_docs`, + url: `${dbName}/_all_docs`, method: 'GET', params: { limit: DEFAULT_LIMIT, @@ -62,7 +62,7 @@ export function getAllQueries (dbUrl) { }, '_changes': { fetchParams: { - url: `${dbUrl}_changes`, + url: `${dbName}/_changes`, method: 'GET', params: { limit: DEFAULT_LIMIT, @@ -83,7 +83,7 @@ export function getAllQueries (dbUrl) { }, 'conflicts': { fetchParams: { - url: `${dbUrl}_find`, + url: `${dbName}/_find`, method: 'POST', body: { selector: { _conflicts: { '$exists': true } }, @@ -105,7 +105,7 @@ export function getAllQueries (dbUrl) { }, 'keys-search': { fetchParams: { - url: `${dbUrl}_all_docs?include_docs=true&limit=${DEFAULT_LIMIT}`, + url: `${dbName}/_all_docs?include_docs=true&limit=${DEFAULT_LIMIT}`, method: 'POST', body: { keys: [] @@ -124,7 +124,7 @@ export function getAllQueries (dbUrl) { }, 'bulk-docs': { fetchParams: { - url: `${dbUrl}_bulk_docs`, + url: `${dbName}/_bulk_docs`, method: 'POST', body: { docs: [] @@ -142,7 +142,7 @@ export function getAllQueries (dbUrl) { }, 'and': { fetchParams: { - url: `${dbUrl}_find`, + url: `${dbName}/_find`, method: 'POST', body: { selector: { @@ -176,8 +176,8 @@ export function getAllQueries (dbUrl) { } } -export function getQuery (dbUrl, queryName) { - const queries = getAllQueries(dbUrl) +export function getQuery (dbName, queryName) { + const queries = getAllQueries(dbName) if (!queries[queryName]) { return `${queryName} not found` } diff --git a/package.json b/package.json index d8917b6..1b68124 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,25 @@ "copy:index": "cp ./index.html dist/index.html && cp ./package.json dist/package.json", "build": "webpack && npm run copy:index", "test": "standard", + "test-api": "webpack-dev-server --config ./webpack.test.config.js --openPage tests.html", "travis-deploy-once": "travis-deploy-once", "deploy": "node ./scripts/deploy-to-couch", "semantic-release": "semantic-release" }, "license": "MIT", "dependencies": { - "axios": "^0.18.0", + "isomorphic-fetch": "^2.2.1", + "lodash": "^4.17.15", + "pouchdb-adapter-http": "^7.2.1", + "pouchdb-adapter-idb": "^7.2.1", + "pouchdb-adapter-memory": "^7.2.1", + "pouchdb-checkpointer": "^7.2.1", + "pouchdb-core": "^7.2.1", + "pouchdb-find": "^7.2.1", + "pouchdb-generate-replication-id": "^7.2.1", + "pouchdb-mapreduce": "^7.2.1", + "pouchdb-replication": "^7.2.1", + "pouchdb-utils": "^7.2.1", "protobufjs": "^6.8.8", "react": "^16.4.1", "react-ace": "^6.1.0", diff --git a/scripts/deploy-to-couch.js b/scripts/deploy-to-couch.js deleted file mode 100644 index 5cddc4b..0000000 --- a/scripts/deploy-to-couch.js +++ /dev/null @@ -1,72 +0,0 @@ -const fs = require('fs') -const parseArgs = require('minimist') -const axios = require('axios') - -const BUILD_PATH = './dist/' - -postDocs(parseArgs(process.argv.slice(2))) - -async function postDocs ({ - couchUrl = 'http://localhost:5984/', - databaseName = 'lookout', - username = process.env.SCRIPT_USER || 'admin', - password = process.env.SCRIPT_PASS || 'admin' -}) { - const dbUrl = `${couchUrl}${databaseName}` - const docUrl = `${dbUrl}/lookout` - const auth = {username, password} - await createDB(dbUrl, auth) - const _rev = await getRev(docUrl, auth) - const doc = createDoc(_rev, BUILD_PATH) - try { - const {data} = await axios.put(docUrl, doc, {auth}) - console.log(data) - console.log(`\nlookout should be accessible at \n\n${docUrl}/index.html#/\n\n`) - } catch (error) { - const {status, statusText, data} = error.response - console.log(status, statusText, data) - } -} - -async function createDB (url, auth) { - try { - await axios.put(url, {}, {auth}) - } catch (error) { - const {status, statusText, data} = error.response - // this is fine - if (data.error.includes('exists')) return - - console.log('error creating DB', status, statusText, data) - } -} - -async function getRev (url, auth) { - try { - const {data: {_rev}} = await axios.get(url, {auth}) - return _rev - } catch (error) { - console.log('no existing doc found.') - } -} - -function createDoc (_rev, path) { - return { - _id: 'lookout', - _rev, - _attachments: { - 'index.html': { - content_type: `text\/html`, // eslint-disable-line - data: getBase64File(`${path}index.html`) - }, - 'lookout.js': { - content_type: `text\/javascript`, // eslint-disable-line - data: getBase64File(`${path}lookout.js`) - } - } - } -} - -function getBase64File (filePath) { - const file = fs.readFileSync(filePath) - return file.toString('base64') -} diff --git a/tests.html b/tests.html new file mode 100644 index 0000000..0a7a88a --- /dev/null +++ b/tests.html @@ -0,0 +1,24 @@ + + + lookout api tests + + + + + +
+

tests

+ run all tests | + only browser | + only remote | + api + +
+
+ + + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..c436a57 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,61 @@ +# tests + +## local setup +export TEST_URL=[couchdburl] +export TEST_USERNAME=[couchdburl] +export TEST_PASSWORD=[couchdburl] +export TEST_ADMIN_USERNAME=[couchdburl] +export TEST_ADMIN_PASSWORD=[couchdburl] +npm run test-api + +## src & browser +run-tests kind of dynamically includes all test files in `src`. When they were +in the root directory webpack would require the readme as well + +## tape note + +tape used to run in both node & the browser using a utility like this: +``` +const tape = require('tape') +const _test = require('tape-promise').default +const test = _test(tape) +// module.exports = {lol: true} + +module.exports = test +``` + +and this in webpack: +``` +// so tape can run in browser +node: { + fs: 'empty' +} +``` + +which was really helpful for developing using devtools in the browser. +node with --inspect-brk refreshing is a long process. + +But after npm installing something, local environment started getting this error: +``` +ERROR in ./node_modules/stream-browserify/index.js +Module not found: Error: Can't resolve 'readable-stream/duplex.js' in '/Users/kevin/code/lookout-quatre/node_modules/stream-browserify' + @ ./node_modules/stream-browserify/index.js 30:16-52 + @ ./node_modules/through/index.js + @ ./node_modules/tape/index.js + @ ./tests/tape-util.js + @ ./tests/smalltest.js + @ ./tests/browser/pouch-adapter-test.js + @ ./tests/browser sync -test\.js$ + @ ./tests/run-tests.js +``` + +tried these steps found on the internet: +- blow away node modules & clear cache +- npm i stream +- npm i readable-stream + +didn't yet try: +- re-installing node yet. +- figuring out which other dependency messes up readable-stream via tape + +So using smalltest to pretend to be tape for a while diff --git a/tests/run-tests.js b/tests/run-tests.js new file mode 100644 index 0000000..a0dddf6 --- /dev/null +++ b/tests/run-tests.js @@ -0,0 +1,94 @@ +const page = getPage() + +function getPage () { + const {search} = window.location + + if (!search || search === '?all') { + return 'all' + } + + if (search === '?remote') { + return 'remote' + } + if (search === '?browser') { + return 'browser' + } + + if (search.startsWith('?testFile=')) { + return search.replace('?testFile=', '') + } + + return 'api' +} + +const header = document.getElementById('page-header') +header.innerHTML = (page === 'api') ? 'api' : `Tests: ${page}` + +function requireAll (r) { r.keys().forEach(r) } + +if (page === 'all') { + console.log('Running all tests') + requireAll(require.context('./src/remote/', true, /-test\.js$/)) + requireAll(require.context('./src/browser/', true, /-test\.js$/)) +} else if (page === 'remote') { + console.log('Running just remote tests') + requireAll(require.context('./src/remote/', true, /-test\.js$/)) +} else if (page === 'browser') { + console.log('Running just browser tests') + requireAll(require.context('./src/browser/', true, /-test\.js$/)) +} else if (page.startsWith('/')) { + console.log('Running specific test: ', page) + require(`./src${page}`) +} else { + throw new Error('run tests could not figure out what to do with url') +} + +// TODO: setup an api playground +// if (page == 'api') { +// const {PouchDB, initApi} = require('./src') +// window.PouchDB = PouchDB +// window.api = initApi({ +// username: 'browser-testing', +// entities: [ +// {entityType: 'form', databaseName: 'forms'} +// ], +// databaseConfigs: [ +// {databaseName: 'forms'} +// ] +// }) +// +// window.initApi = initApi +// console.log(` +// This page is for playing around with things in the console. +// window.PouchDB is available, e.g. new PouchDB('test-db-name') +// +// window.api is an initialized api instance, so things like await api.form.list() should work +// `) +// } + +const browserTests = require + .context('./src/browser', true, /-test\.js$/).keys().map(key => `/browser${key}`) +const remoteTests = require.context('./src/remote', true, /-test\.js$/).keys().map(key => `/remote${key}`) + +document.getElementById('content').innerHTML = ` +

run specific tests

+
+ browser tests +
    + ${displayTests(browserTests)} +
+
+
+ remote tests +
    + ${displayTests(remoteTests)} +
+
+` + +function displayTests (testList) { + return testList + .map(tp => tp.replace('.', '')) + .map(testPath => `
  • ${testPath}
  • `) + .join('') +} diff --git a/tests/smalltest.js b/tests/smalltest.js new file mode 100644 index 0000000..bfd8751 --- /dev/null +++ b/tests/smalltest.js @@ -0,0 +1,55 @@ +const isEqual = require('lodash/isEqual') +/* +smalltest('entity: list on empty', async t => { + const comments = await api.findAll('comment') + t.equals(comments.length, 0, 'finds no comments') +}) +*/ + +const testsToRun = [] +let started + +function smalltest (name, func) { + testsToRun.push({name, func}) + + if (!started) { + started = setTimeout(async () => { + for (let i = 0; i < testsToRun.length; i++) { + const {name, func} = testsToRun[i] + await runTest(name, func) + } + console.log('done running tests?') + }, 10) + } +} + +function runTest (name, func) { + const testTypes = { + equals: (a, b, message = '') => { + if (typeof a === 'object' || typeof b === 'object') { + throw new Error('use deepEquals for objects') + } + console.assert(a === b, `${name} equal ${message}`) + }, + deepEquals: (a, b, message = '') => { + console.assert(isEqual(a, b), `${name} deepEquals ${message}`) + }, + ok: (a, message = '') => console.assert(a, `${name} ok ${message}`), + notOk: (a, message = '') => console.assert(!a, `${name} not ok ${message}`), + fail: (message = '') => console.assert(false, `${name} fail ${message}`) + } + + const result = func(testTypes) + + if (!result.then) { + console.log(name) + return + } + + return result.then((fin) => { + console.log(name) + return fin + }) +} + +module.exports = smalltest diff --git a/tests/src/remote/databases-test.js b/tests/src/remote/databases-test.js new file mode 100644 index 0000000..d65f378 --- /dev/null +++ b/tests/src/remote/databases-test.js @@ -0,0 +1,35 @@ +const test = require('../../smalltest') +const {RemoteCouchApi} = require('../../../api') + +const api = new RemoteCouchApi(process.env.TEST_URL) + +test('api databases setup', async t => { + await api.login(process.env.TEST_ADMIN_USERNAME, process.env.TEST_ADMIN_PASSWORD) + try { + await api.destroyDatabase(process.env.TEST_DATABASE_NAME) + console.warn(`test database ${process.env.TEST_DATABASE_NAME} already exists`) + } catch (error) { + // pass + } +}) + +test('api databases list', async t => { + const databases = await api.listDatabases() + t.ok(databases.length, 'returns databases') +}) + +test('api databases list', async t => { + await api.createDatabase(process.env.TEST_DATABASE_NAME) + const response = await api.getDatabase(process.env.TEST_DATABASE_NAME) + t.ok(response, 'creates and returns a database') +}) + +test('api databases teardown / destroy', async t => { + await api.destroyDatabase(process.env.TEST_DATABASE_NAME) + try { + await api.getDatabase(process.env.TEST_DATABASE_NAME) + t.fail() + } catch (error) { + t.ok(error, 'destroys database') + } +}) diff --git a/tests/src/remote/get-pouch-test.js b/tests/src/remote/get-pouch-test.js new file mode 100644 index 0000000..4a5f355 --- /dev/null +++ b/tests/src/remote/get-pouch-test.js @@ -0,0 +1,30 @@ +const test = require('../../smalltest') +const {RemoteCouchApi} = require('../../../api') + +const api = new RemoteCouchApi(process.env.TEST_URL) + +test('api databases setup', async t => { + await api.login(process.env.TEST_ADMIN_USERNAME, process.env.TEST_ADMIN_PASSWORD) + try { + await api.destroyDatabase(process.env.TEST_DATABASE_NAME) + console.warn(`test database ${process.env.TEST_DATABASE_NAME} already exists`) + } catch (error) { + // pass + } +}) + +test('api get pouch test', async t => { + const pouch = api.getPouchInstance(process.env.TEST_DATABASE_NAME) + console.log(pouch) + const response = await pouch.allDocs() + t.ok(Array.isArray(response.rows), 'runs a pouch call') + await pouch.destroy() +}) + +test('api databases teardown / destroy', async t => { + try { + await api.destroyDatabase(process.env.TEST_DATABASE_NAME) + } catch (error) { + + } +}) diff --git a/tests/src/remote/user-test.js b/tests/src/remote/user-test.js new file mode 100644 index 0000000..d8d746c --- /dev/null +++ b/tests/src/remote/user-test.js @@ -0,0 +1,42 @@ +const test = require('../../smalltest') +const {RemoteCouchApi} = require('../../../api') + +const api = new RemoteCouchApi(process.env.TEST_URL) + +test('api test getCurrentUser on empty', async t => { + await api.logout() + let user = await api.getCurrentUser() + t.notOk(user, 'sees no user') +}) + +test('api admin login', async t => { + try { + await api.login() + t.fail() + } catch (error) { + t.ok(error, 'fails without credentials') + } + + const user = await api.login(process.env.TEST_ADMIN_USERNAME, process.env.TEST_ADMIN_PASSWORD) + t.ok(user, 'login works') + const currentUser = await api.getCurrentUser() + t.deepEquals(user, currentUser, 'returns current user') +}) + +test('api constructor non admin login', async t => { + try { + await api.login() + t.fail() + } catch (error) { + t.ok(error, 'fails without credentials') + } + + const user = await api.login(process.env.TEST_USERNAME, process.env.TEST_PASSWORD) + t.ok(user, 'login on normal user works') + const currentUser = await api.getCurrentUser() + t.deepEquals(user, currentUser, 'returns current user') +}) + +test('api user teardown', async t => { + await api.logout() +}) diff --git a/webpack.config.js b/webpack.config.js index e335c6a..0ce91e7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -41,7 +41,8 @@ module.exports = { devServer: { hot: true, port: 8080, - clientLogLevel: 'none' + clientLogLevel: 'none', + open: true }, plugins: [ new webpack.HotModuleReplacementPlugin() diff --git a/webpack.test.config.js b/webpack.test.config.js new file mode 100644 index 0000000..2ef9127 --- /dev/null +++ b/webpack.test.config.js @@ -0,0 +1,34 @@ +const path = require('path') +const webpack = require('webpack') + +module.exports = { + entry: './tests/run-tests.js', + output: { + filename: 'tests.js', + path: path.resolve(__dirname, './dist'), + publicPath: '/' + }, + mode: 'development', + devtool: 'source-map', + resolve: { + modules: [ + path.resolve(__dirname, './node_modules') + ] + }, + devServer: { + hot: true, + port: 5555, + liveReload: true, + clientLogLevel: 'none' + }, + plugins: [ + new webpack.DefinePlugin({ + 'process.env.TEST_URL': JSON.stringify(process.env.TEST_URL || 'http://localhost:5984'), + 'process.env.TEST_ADMIN_USERNAME': JSON.stringify(process.env.TEST_USERNAME || 'admin'), + 'process.env.TEST_ADMIN_PASSWORD': JSON.stringify(process.env.TEST_PASSWORD || 'admin'), + 'process.env.TEST_USERNAME': JSON.stringify(process.env.TEST_USERNAME || 'test'), + 'process.env.TEST_PASSWORD': JSON.stringify(process.env.TEST_PASSWORD || 'test'), + 'process.env.TEST_DATABASE_NAME': JSON.stringify(process.env.TEST_DATABASE_NAME || 'lookout-integration-test') + }) + ] +}