OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / test / parser.test.js

244 lines · javascript ref:
import { test } from 'brittle'
import { readFileSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { parseFile } from '../lib/parsers/js.js'

const HERE = dirname(fileURLToPath(import.meta.url))

// These tests used to read ../hyper-orgs, ../hyper-sync and ../pear-git-v3 —
// live repos outside this one. hyper-orgs went 0.2.0 -> 0.2.2 and dropped a
// field, and eight assertions here went red for a change that had nothing to do
// with codetree. Invariant I1 (docs/DOC-DEDUP-LAYER.md) forbids exactly that.
// fixtures/workspace reproduces the shapes; see its README for the mapping.
const FIXTURE = resolve(HERE, 'fixtures/workspace')

function readSource (rel) {
  return readFileSync(resolve(FIXTURE, rel), 'utf-8')
}

test('parser: a barrel of re-exports', (t) => {
  const source = readSource('widget-store/index.js')
  const result = parseFile(source, 'widget-store/index.js')

  t.absent(result.error, 'no parse error')
  t.is(result.exports.length, 4, '4 re-exports')

  const names = result.exports.map(e => e.name)
  t.ok(names.includes('WidgetStore'), 'exports WidgetStore')
  t.ok(names.includes('storeSchema'), 'exports storeSchema')
  t.ok(names.includes('RESERVED_NAMES'), 'exports RESERVED_NAMES')
  t.ok(names.includes('validateName'), 'exports validateName')

  // Every one is a re-export, carrying the file it came from.
  const storeExport = result.exports.find(e => e.name === 'WidgetStore')
  t.is(storeExport.from, './lib/widget-store.js', 'WidgetStore from lib/widget-store.js')

  const schemaExports = result.exports.filter(e => e.from === './lib/schema.js')
  t.is(schemaExports.length, 3, '3 exports from schema.js')
})

test('parser: a facade class — constructor, methods, getters, state, delegation', (t) => {
  const source = readSource('widget-store/lib/widget-store.js')
  const result = parseFile(source, 'widget-store/lib/widget-store.js')

  t.absent(result.error, 'no parse error')

  // Imports
  t.is(result.imports.length, 2, '2 imports')
  const roomImport = result.imports.find(i => i.specifier === 'widget-rooms')
  t.ok(roomImport, 'imports from widget-rooms')
  t.ok(roomImport.names.includes('Room'), 'imports Room')

  const schemaImport = result.imports.find(i => i.specifier === './schema.js')
  t.ok(schemaImport, 'imports from ./schema.js')

  // Exports
  t.is(result.exports.length, 1, '1 export')
  t.is(result.exports[0].name, 'WidgetStore', 'exports WidgetStore')
  t.is(result.exports[0].type, 'class', 'export type is class')

  // Class
  t.is(result.classes.length, 1, '1 class')
  const cls = result.classes[0]
  t.is(cls.name, 'WidgetStore', 'class name')
  t.absent(cls.extends, 'does not extend anything')

  // Constructor
  t.ok(cls.constructor, 'has constructor')
  t.is(cls.constructor.params.length, 2, 'constructor has 2 params')
  t.is(cls.constructor.params[0], 'corestore', 'first param is corestore')
  t.is(cls.constructor.params[1], 'options', 'second param is options')
  t.ok(cls.constructor.jsdoc, 'constructor has JSDoc')

  // Methods
  const methodNames = cls.methods.map(m => m.name)
  t.is(cls.methods.length, 13, '13 methods')
  for (const name of ['ready', 'close', 'create', 'get', 'update', 'delete', 'list',
    'checkAccess', 'addMember', 'addResource', 'createGrant', 'createInvite', 'addWriter']) {
    t.ok(methodNames.includes(name), 'has ' + name + '()')
  }

  // Async methods keep their signature
  const createMethod = cls.methods.find(m => m.name === 'create')
  t.ok(createMethod.async, 'create is async')
  t.alike(createMethod.params, ['name', 'type', 'data'], 'create params')

  // Getters
  t.alike(cls.getters.map(g => g.name), ['key', 'keyHex', 'writable', 'view', 'base', 'room'],
    'all six getters, in source order')

  // State — `this._field = ...`
  t.alike(result.state, ['_corestore', '_options', '_room', '_opened'], 'state fields')

  // Delegation — `this._field.method()`
  const roomDelegate = result.delegates.find(d => d.field === '_room')
  t.ok(roomDelegate, 'delegates to _room')
  t.ok(roomDelegate.methods.includes('ready'), 'delegates ready to _room')
  t.ok(roomDelegate.methods.includes('close'), 'delegates close to _room')

  // Error contracts, including the template-literal form
  t.is(result.errorContracts.length, 2, '2 error contracts')
  t.ok(result.errorContracts.some(e => e.includes('Unknown type')), 'template error captured')
})

test('parser: the handler-factory pattern', (t) => {
  const source = readSource('widget-store/lib/handlers/widgets.js')
  const result = parseFile(source, 'widget-store/lib/handlers/widgets.js')

  t.absent(result.error, 'no parse error')
  const exportedFns = result.exports.filter(e => e.type === 'function')
  t.is(exportedFns.length, 1, '1 exported factory')
  t.is(exportedFns[0].name, 'createWidgetHandlers', 'named createWidgetHandlers')
})

test('parser: a CJS module', (t) => {
  const source = readSource('legacy-sync/index.js')
  const result = parseFile(source, 'legacy-sync/index.js')

  t.absent(result.error, 'no parse error — script mode fallback handled it')
  t.ok(result.exports.length > 0, 'detected CJS exports')
  t.ok(result.exports[0].cjs, 'marked as CJS')
  t.is(result.classes.length, 1, 'found the class')
  t.is(result.classes[0].name, 'Syncer', 'class is Syncer')
})

test('parser: JSDoc comment attachment', (t) => {
  const source = `
/**
 * Does something useful.
 * @param {string} name - the name
 * @returns {boolean}
 */
export function doSomething (name) {
  return true
}

/**
 * A useful class.
 */
export class MyClass {
  /**
   * Create one.
   * @param {object} opts
   */
  constructor (opts) {
    this._opts = opts
  }

  /**
   * Run it.
   * @param {number} n
   */
  async run (n) {
    return n
  }
}
`
  const result = parseFile(source, 'test.js')

  t.absent(result.error, 'no parse error')

  const fn = result.functions.find(f => f.name === 'doSomething')
  t.ok(fn, 'found doSomething')
  t.ok(fn.jsdoc, 'function has JSDoc')
  t.ok(fn.jsdoc.includes('@param'), 'JSDoc has @param')

  const cls = result.classes.find(c => c.name === 'MyClass')
  t.ok(cls, 'found MyClass')
  t.ok(cls.jsdoc, 'class has JSDoc')
  t.ok(cls.constructor.jsdoc, 'constructor has JSDoc')

  const runMethod = cls.methods.find(m => m.name === 'run')
  t.ok(runMethod, 'found run method')
  t.ok(runMethod.jsdoc, 'method has JSDoc')
})

test('parser: destructured params', (t) => {
  const source = `
export function handler ({ name, type, ...rest }) {
  return { name, type }
}

export function multi (a, [b, c], { d }) {
  return a
}
`
  const result = parseFile(source, 'test.js')

  const handler = result.functions.find(f => f.name === 'handler')
  t.ok(handler, 'found handler')
  t.is(handler.params[0], '{ name, type, ...rest }', 'destructured params preserved')

  const multi = result.functions.find(f => f.name === 'multi')
  t.ok(multi, 'found multi')
  t.is(multi.params[0], 'a', 'simple param')
  t.is(multi.params[1], '[b, c]', 'array destructured')
  t.is(multi.params[2], '{ d }', 'object destructured')
})

test('parser: arrow functions as const', (t) => {
  const source = `
export const add = (a, b) => a + b
export const fetchData = async (url) => {
  return fetch(url)
}
`
  const result = parseFile(source, 'test.js')

  t.is(result.functions.length, 2, '2 functions')

  const add = result.functions.find(f => f.name === 'add')
  t.ok(add, 'found add')
  t.ok(add.arrow, 'is arrow function')
  t.is(add.params.length, 2, '2 params')

  const fetchData = result.functions.find(f => f.name === 'fetchData')
  t.ok(fetchData, 'found fetchData')
  t.ok(fetchData.async, 'is async')
  t.ok(fetchData.arrow, 'is arrow function')
})

test('parser: error contracts from template literals', (t) => {
  const source = `
export function validate (name) {
  if (!name) throw new Error('Name is required')
  if (name.length > 64) throw new Error(\`Name too long: \${name.length}\`)
}
`
  const result = parseFile(source, 'test.js')

  t.is(result.errorContracts.length, 2, '2 error contracts')
  t.is(result.errorContracts[0], 'Name is required', 'literal error')
  t.ok(result.errorContracts[1].includes('Name too long'), 'template error')
})

test('parser: export all (*)', (t) => {
  const source = `export * from './utils.js'`
  const result = parseFile(source, 'test.js')

  t.is(result.exports.length, 1, '1 export')
  t.is(result.exports[0].name, '*', 'star export')
  t.is(result.exports[0].from, './utils.js', 'from source')
})