Commit 0962a4b8 authored by Hubert SABLONNIERE's avatar Hubert SABLONNIERE

apply playbook architecture and acceptance tests

parent 0b5ff2ae
// require/define our software components
const PlaybookBuilder = require('./packages/playbook/builder')
const buildPlaybook = require('../packages/playbook/lib/playbook-builder')
// ...
// run the pipeline
const playbook = PlaybookBuilder.load(process.argv, process.env)
// (Boolean) PlaybookBuilder.validateSpecFile(path)
// (Playbook) PlaybookBuilder.loadSpecFile(path)
const playbook = buildPlaybook()
// test
console.log(playbook.site.title)
......@@ -16,6 +16,9 @@
"chai": "^4.1.2",
"chai-as-promised": "^7.1.1",
"chai-spies": "^0.7.1",
"convict": "^4.0.0",
"cson-parser": "^2.0.0",
"deep-freeze": "^0.0.1"
"dirty-chai": "^2.0.1",
"eslint": "^4.7.2",
"eslint-config-standard": "^10.2.1",
......
const fs = require('fs')
const path = require('path')
const convict = require('convict')
const yaml = require('js-yaml')
const cson = require('cson-parser')
const Playbook = require('./model/playbook')
// Load the configuration schema for convict.
function loadConfigSchema () {
const schema = require('./config/schema')
// dispatch an event to allow plugins to contribute to this schema
return schema
}
function readConfig (schema) {
return convict(schema)
}
// TODO support both yaml and cson; automatically detect matching files
function loadPlaybookSpec (config, specPath) {
let extname = path.extname(specPath)
if (extname.length === 0) {
if (fs.existsSync(specPath + '.yml')) {
specPath += (extname = '.yml')
} else if (fs.existsSync(specPath + '.cson')) {
specPath += (extname = '.cson')
}
}
// QUESTION should we raise exception if playbook spec doesn't exist?
if (extname === '.yml') {
config.load(yaml.safeLoad(fs.readFileSync(specPath, 'utf8')))
config.set('playbook', specPath)
} else if (extname === '.cson') {
config.load(cson.parse(fs.readFileSync(specPath, 'utf8')))
config.set('playbook', specPath)
}
}
// Convert the convict config into the playbook model
function buildPlaybook (config) {
const playbook = new Playbook(config)
// dispatch an event to allow plugins to contribute to the model
return playbook
}
module.exports = {
load: (argv, env) => {
// pretend like convict supports custom argv and env args; perhaps move to a module "solitaryConvict"
const prevArgv = process.argv
const prevEnv = process.env
process.argv = argv
process.env = env
const config = readConfig(loadConfigSchema())
process.argv = prevArgv
process.env = prevEnv
const playbookSpecPath = config.get('playbook')
if (playbookSpecPath) {
loadPlaybookSpec(config, playbookSpecPath)
}
return buildPlaybook(config)
}
}
......@@ -4,117 +4,130 @@ module.exports = {
format: String,
default: 'site.yml',
env: 'PLAYBOOK',
arg: 'playbook'
},
quiet: {
doc: 'Do not write any messages to stdout.',
format: Boolean,
default: false,
arg: 'quiet'
},
silent: {
doc: 'Suppress all messages.',
format: Boolean,
default: false,
arg: 'silent'
arg: 'playbook',
},
site: {
url: {
doc: 'The base URL of the published site (optional). Should not include a trailing slash.',
doc:
'The base URL of the published site (optional). Should not include a trailing slash.',
format: String,
default: undefined,
env: 'URL',
arg: 'url'
arg: 'url',
},
title: {
doc: 'The title of the site (optional).',
format: String,
default: undefined,
arg: 'title'
arg: 'title',
},
root: {
doc: 'The name of the component to use as the root of the site (optional).',
doc:
'The name of the component to use as the root of the site (optional).',
format: String,
default: undefined
default: undefined,
},
aspect: {
doc: 'The name of the aspect navigation to make available on every page in the site.',
doc:
'The name of the aspect navigation to make available on every page in the site.',
format: String,
default: undefined
default: undefined,
},
nav: {
doc: 'The list of descriptors which define the aspect navigation domains.',
doc:
'The list of descriptors which define the aspect navigation domains.',
format: Array,
default: undefined
default: undefined,
},
keys: {
google_analytics: {
doc: 'The Google Analytics account key.',
format: String,
default: undefined,
arg: 'google-analytics-key'
arg: 'google-analytics-key',
},
swiftype: {
doc: 'The key to activate the SwiftType widget.',
format: String,
default: undefined,
arg: 'swiftype-key'
}
}
arg: 'swiftype-key',
},
},
},
content: {
sources: {
doc: 'The list of git repositories + branch patterns to use.',
format: Array,
default: [],
env: 'CONTENT_SOURCES'
}
env: 'CONTENT_SOURCES',
},
},
ui: {
location: {
doc: 'The repository that hosts the UI.',
format: String,
default: undefined
default: undefined,
},
name: {
doc: 'The name of the UI bundle. Defaults to the repository name.',
format: String,
default: undefined
default: undefined,
},
ref: {
doc: 'The reference (or version) of the theme bundle to use.',
format: String,
default: undefined
default: undefined,
},
archive: {
doc: 'A local theme archive. If specified, used in place of the UI bundle from the repository.',
doc:
'A local theme archive. If specified, used in place of the UI bundle from the repository.',
format: String,
default: undefined,
arg: 'ui-archive'
arg: 'ui-archive',
},
skip_cache: {
doc: 'Skip the local bundle cache and always fetch the UI bundle from the repository.',
doc:
'Skip the local bundle cache and always fetch the UI bundle from the repository.',
format: Boolean,
default: false,
arg: 'skip-ui-cache'
}
arg: 'skip-ui-cache',
},
},
html_url_extension_strategy: {
doc: 'Controls how the URL extension for HTML pages is handled (default, drop, or indexify).',
format: String,
default: 'default',
arg: 'html-url-extension-strategy'
runtime: {
quiet: {
doc: 'Do not write any messages to stdout.',
format: Boolean,
default: false,
arg: 'quiet',
},
silent: {
doc: 'Suppress all messages.',
format: Boolean,
default: false,
arg: 'silent',
},
},
aspect_page_url_strategy: {
doc: 'Controls how links to pages in aspect domains are generated (path or query).',
format: String,
default: 'path',
arg: 'aspect-page-url-strategy'
urls: {
htmlExtensionStyle: {
doc:
'Controls how the URL extension for HTML pages is handled (default, drop, or indexify).',
format: ['default', 'drop', 'indexify'],
default: 'default',
arg: 'html-url-extension-style',
},
aspectPageStrategy: {
doc:
'Controls how links to pages in aspect domains are generated (path or query).',
format: String,
default: 'path',
arg: 'aspect-page-url-strategy',
},
},
redirects: {
doc: 'Generate nginx config file containing URL redirects for page aliases.',
doc:
'Generate nginx config file containing URL redirects for page aliases.',
format: Boolean,
default: false,
arg: 'redirects'
}
arg: 'redirects',
},
}
class Playbook {
constructor (config) {
this.location = config.get('playbook')
this.site = new Site(config)
// this.content = ...
// this.ui = ...
// ...
}
}
class Site {
constructor (config) {
this.url = config.get('site.url')
this.title = config.get('site.title')
// ...
}
}
// use temporarily to avoid eslint error
Playbook({})
Playbook
site: SiteConfig
title: String
url: String
root: String
aspect: String
nav: String[]
keys: Hash
content: ContentConfig
branches: String
sources: ContentSourceConfig[]
location: String
type: String
branches: String | String[]
asciidoctor: AsciidoctorConfig
attributes: String[] | Hash
ui: UIBundleConfig
location: String
name: String
version: String
output: OutputConfig (may have type per provider)
provider: String
on: OnConfig
ci: Boolean
LocalOutputConfig:
directory:
S3OutputConfig:
bucket:
prefix:
api_key:
secret_key:
const fs = require('fs')
const path = require('path')
const convict = require('convict')
const deepFreeze = require('deep-freeze')
const defaultSchema = require('./config/schema')
const yaml = require('js-yaml')
const cson = require('cson-parser')
function getConvictConfig (customSchema) {
if (customSchema != null) {
return convict(customSchema)
}
return convict(defaultSchema)
}
function loadSpecFile (specPath) {
const specExtname = path.extname(specPath)
const fileContents = fs.readFileSync(specPath, 'utf8')
if (specExtname === '.yml') {
return yaml.safeLoad(fileContents)
}
if (specExtname === '.json') {
return JSON.parse(fileContents)
}
if (specExtname === '.cson') {
return cson.parse(fileContents)
}
throw new Error('Unknown file type')
}
module.exports = (customSchema) => {
const config = getConvictConfig(customSchema)
const specRelativePath = config.get('playbook')
if (specRelativePath == null) {
throw new Error('Playbook spec file cannot be found')
}
let specPath = path.resolve(process.cwd(), specRelativePath)
// assume implicit .yml extension
if (path.extname(specPath) === '') {
specPath += '.yml'
}
const spec = loadSpecFile(specPath)
config.load(spec)
config.validate({ allowed: 'strict' })
const playbook = config.getProperties()
// playbook path property should not leak
delete playbook.playbook
const frozenPlaybook = deepFreeze(playbook)
return frozenPlaybook
}
site:
url: https://example.com/docs
title: Documentation
root: general
keys:
google_analytics: UA-XXX
swiftype: XDxyz
nav:
- nav/tasks.adoc
content:
sources:
- location: https://github.com/example/docs-general.git
- location: https://github.com/example/docs-client.git
branches: v3*
- location: https://github.com/example/docs-server.git
branches: v3*
asciidoctor:
attributes:
attribute-missing: warn
ui:
location: https://github.com/example/docs-ui-default.git
name: default-ui
ref: v100
dest:
- provider: local
path: build/site
on:
ci: false
#- provider: s3
# bucket: foobar
# on:
# ci: true
one:
one: yml-spec-value-one
two: 42
three: false
four:
- name: John
lastname: Lennon
- name: Paul
lastname: McCartney
five: Hello World!
site:
url: https://example.com
title: Example site
one:
one: 'cson-spec-value-one'
two: 42
three: false
four: [
{ name: 'John', lastname: 'Lennon' }
{ name: 'Paul', lastname: 'McCartney' }
]
{
"one": {
"one": "json-spec-value-one"
},
"two": 42,
"three": false,
"four": [
{
"name": "John",
"lastname": "Lennon"
},
{
"name": "Paul",
"lastname": "McCartney"
}
]
}
one:
one: yml-spec-value-one
two: 42
three: false
four:
- name: John
lastname: Lennon
- name: Paul
lastname: McCartney
/* eslint-env mocha */
'use strict'
const { expect } = require('../../../test/test-utils')
const buildPlaybook = require('../lib/playbook-builder')
const path = require('path')
describe('buildPlaybook()', () => {
let originalEnv
let originalArgv
let schema
let expectedPlaybook
beforeEach(() => {
originalArgv = process.argv
originalEnv = process.env
process.argv = ['/path/to/node', '/path/to/script.js']
process.env = {}
schema = {
playbook: {
format: String,
default: null,
env: 'PLAYBOOK',
},
one: {
one: {
format: String,
default: null,
arg: 'oneone',
env: 'ANTORA_ONEONE',
},
two: {
format: String,
default: 'default-value',
},
},
two: {
format: Number,
default: null,
arg: 'two',
env: 'ANTORA_TWO',
},
three: {
format: Boolean,
default: null,
arg: 'three',
env: 'ANTORA_THREE',
},
four: {
format: Array,
default: null,
},
}
expectedPlaybook = {
one: {
two: 'default-value',
},
two: 42,
three: false,
four: [
{ lastname: 'Lennon', name: 'John' },
{ lastname: 'McCartney', name: 'Paul' },
],
}
})
afterEach(() => {
process.argv = originalArgv
process.env = originalEnv
})
const ymlSpec = path.resolve(__dirname, 'fixtures', 'spec-sample.yml')
const extensionLessSpec = path.resolve(__dirname, 'fixtures', 'spec-sample')
const jsonSpec = path.resolve(__dirname, 'fixtures', 'spec-sample.json')
const csonSpec = path.resolve(__dirname, 'fixtures', 'spec-sample.cson')
const iniSpec = path.resolve(__dirname, 'fixtures', 'spec-sample.ini')
const badSpec = path.resolve(__dirname, 'fixtures', 'bad-spec-sample.yml')
const defaultSchemaSpec = path.resolve(
__dirname,
'fixtures',
'default-schema-spec-sample.yml'
)
it('should throw if no playbook spec file can be loaded', () => {
expect(() => buildPlaybook(schema)).to.throw()
})
it('should load YML playbook spec file', () => {
process.env.PLAYBOOK = ymlSpec
const playbook = buildPlaybook(schema)
expectedPlaybook.one.one = 'yml-spec-value-one'
expect(playbook).to.eql(expectedPlaybook)
})
it('should load YML playbook spec file (if not extension was defined)', () => {
process.env.PLAYBOOK = extensionLessSpec
const playbook = buildPlaybook(schema)
expectedPlaybook.one.one = 'yml-spec-value-one'
expect(playbook).to.eql(expectedPlaybook)
})
it('should load JSON playbook spec file', () => {
process.env.PLAYBOOK = jsonSpec
const playbook = buildPlaybook(schema)
expectedPlaybook.one.one = 'json-spec-value-one'
expect(playbook).to.eql(expectedPlaybook)
})
it('should load CSON playbook spec file', () => {
process.env.PLAYBOOK = csonSpec
const playbook = buildPlaybook(schema)
expectedPlaybook.one.one = 'cson-spec-value-one'
expect(playbook).to.eql(expectedPlaybook)
})
it('should throw when loading unkown type file', () => {
process.env.PLAYBOOK = iniSpec
expect(() => buildPlaybook(schema)).to.throw()
})
it('should throw if spec file cannot be found', () => {
process.env.PLAYBOOK = 'file/not/found.yml'
expect(() => buildPlaybook(schema)).to.throw()
})
it('should use default value (if nothing is set in spec file)', () => {
process.env.PLAYBOOK = ymlSpec
const playbook = buildPlaybook(schema)
expect(playbook.one.two).to.equal('default-value')
})
it('should use env value over spec file value', () => {
process.env.PLAYBOOK = ymlSpec
process.env.ANTORA_ONEONE = 'the-env-value'
const playbook = buildPlaybook(schema)
expect(playbook.one.one).to.equal('the-env-value')
})
it('should use argv value over spec file value or env value', () => {
process.env.PLAYBOOK = ymlSpec
process.argv.push('--oneone', 'the-argv-value')
process.env.ANTORA_ONEONE = 'the-env-value'
const playbook = buildPlaybook(schema)
expect(playbook.one.one).to.equal('the-argv-value')
})
it('should coerce Number values', () => {
process.env.PLAYBOOK = ymlSpec
const playbook = buildPlaybook(schema)
expect(playbook.two).to.equal(42)
})
it('should coerce Number values (via env)', () => {
process.env.PLAYBOOK = ymlSpec
process.env.ANTORA_TWO = '777'
const playbook = buildPlaybook(schema)
expect(playbook.two).to.equal(777)
})
it('should coerce Number values (via argv)', () => {
process.env.PLAYBOOK = ymlSpec
process.argv.push('--two', '777')
const playbook = buildPlaybook(schema)
expect(playbook.two).to.equal(777)
})
it('should coerce Boolean values', () => {
process.env.PLAYBOOK = ymlSpec
const playbook = buildPlaybook(schema)
expect(playbook.three).to.be.false()
})
it('should coerce Boolean values (via env)', () => {
process.env.PLAYBOOK = ymlSpec
process.env.ANTORA_THREE = 'true'
const playbook = buildPlaybook(schema)
expect(playbook.three).to.be.true()
})
it('should coerce Boolean values (via argv)', () => {
process.env.PLAYBOOK = ymlSpec
process.argv.push('--three')
const playbook = buildPlaybook(schema)
expect(playbook.three).to.be.true()
})
it('should throw when trying to load values that are not declared in the schema', () => {
process.env.PLAYBOOK = badSpec
expect(() => buildPlaybook(schema)).to.throw()
})
it('should throw when spec file used values of the wrong format', () => {
process.env.PLAYBOOK = ymlSpec
schema.two.format = String
expect(() => buildPlaybook(schema)).to.throw()
})
it('should return an immutable playbook', () => {
process.env.PLAYBOOK = ymlSpec
const playbook = buildPlaybook(schema)
expect(() => {
playbook.one.two = 'override'
}).to.throw()
})
it('should use default schema if not specified', () => {
process.env.PLAYBOOK = defaultSchemaSpec
const playbook = buildPlaybook()
expect(playbook.site.url).to.equal('https://example.com')
expect(playbook.site.title).to.equal('Example site')
})
})
/* eslint-env mocha */
'use strict'
const { expect } = require('../../../test/test-utils')
describe('playbook', () => {
it('should meet all requirements', () => {
expect('so far, so good!').to.include('good')
})
})
......@@ -392,6 +392,10 @@ code-point-at@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
coffee-script@^1.10.0:
version "1.12.7"