Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,25 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ['18.18.2', '20.5.1']
node-version: ['18.18.2', '20.5.1']
steps:
- uses: actions/checkout@v3
- uses: volta-cli/action@v4
with:
node-version: '${{ matrix.node-version }}'
yarn-version: '1.22.19'
- uses: actions/cache@v3
id: cache
- name: Cache puppeteer
uses: actions/cache@v3
id: cache-puppeteer
with:
path: .cache
key: ${{ matrix.node-version }}-puppeteer
- name: Cache node_modules
uses: actions/cache@v3
id: cache-node_modules
with:
path: node_modules
key: ${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
- if: steps.cache.outputs.cache-hit != 'true'
- if: steps.cache-node_modules.outputs.cache-hit != 'true'
run: yarn install
- run: yarn test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ Thumbs.db
.nyc_output
test-coverage
coverage.shield.badge.md
.cache
12 changes: 12 additions & 0 deletions .puppeteerrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const {join} = require('path');

const CI = !!process.env.CI
console.log(process.env.CI, CI)

/**
* @type {import("puppeteer").Configuration}
*/
module.exports = {
// On CI, cache puppeteer in a Github-cache-able location.
...(CI ? {cacheDirectory: join(__dirname, '.cache', 'puppeteer')} : {})
};
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"main": "src/validator.js",
"bin": "./validate",
"scripts": {
"test": "mocha test/spec/*.spec.js --exit --timeout 9000 && npm run style-check",
"test": "mocha test/spec/*.spec.js --exit --timeout 59000 --bail && npm run style-check",
"build-docs": "rimraf docs && ./node_modules/.bin/jsdoc -c jsdoc.config.js",
"prepare": "rollup --config && rollup --config rollup.utils.config.mjs",
"style-fix": "eslint *.js src/**/*.js test/**/*.js --fix",
Expand Down Expand Up @@ -69,5 +69,6 @@
"volta": {
"node": "20.5.1",
"yarn": "1.22.19"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
74 changes: 39 additions & 35 deletions src/headless-browser.js
Original file line number Diff line number Diff line change
@@ -1,49 +1,53 @@
const puppeteer = require( 'puppeteer' );

const CI = !!process.env.CI
console.log(process.env.CI, CI)

/**
* This class approach makes it easy to open multiple browser instances with
* different arguments in case that is ever required.
*/
class BrowserHandler {
constructor() {
const launchBrowser = async() => {
this.browser = false;
this.browser = await puppeteer.launch( {
headless: 'new',
devtools: false
} );
this.browser.on( 'disconnected', launchBrowser );
};

this.exit = ()=>{
this.browser.off( 'disconnected', launchBrowser );
this.browser.close();
};

( async() => {
await launchBrowser();
} )();
}
}
/**
* @type Promise<import( 'puppeteer' ).Browser | null>
*/
_instance = Promise.resolve(null)

const handler = new BrowserHandler();
get instance() {
return this._instance
}

const getBrowser = ( ) =>
new Promise( ( resolve ) => {
const browserCheck = setInterval( () => {
if ( handler.browser !== false ) {
clearInterval( browserCheck );
resolve( handler.browser );
}
}, 100 );
} );
async setup() {
console.log('BrowserHandler.setup', CI)
const instance = await this._instance
if(instance) return instance

const newInstance = puppeteer.launch( {
// Disable Chrome sandbox on CI. For running tests locally, it should work or you *should* configure it!
// See https://pptr.dev/troubleshooting#setting-up-chrome-linux-sandbox
...(CI ? {args: ['--no-sandbox']} : {}),
headless: 'new',
devtools: false
} ).then((pupeteerInstance) => {
pupeteerInstance.on( 'disconnected', this.setup.bind(this) );
return pupeteerInstance
})
this._instance = newInstance
return await newInstance
}

async teardown() {
const instance = await this._instance
if(!instance) return
this._instance = Promise.resolve(null)

// TODO: it's weird that after calling this function there is no way to
// get a browser any more. If getBrowser() is called again a new BrowserHandler instance should be created
const closeBrowser = ( ) => {
instance.off( 'disconnected', this.setup.bind(this) );
instance.close()
}
}

return handler.exit();
};
const handler = new BrowserHandler();

const getBrowser = ( ) => handler.setup()
const closeBrowser = ( ) => handler.teardown()
module.exports = { getBrowser, closeBrowser };
22 changes: 21 additions & 1 deletion src/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,19 @@ const { version } = require( '../package' );
* @property {boolean} openclinica - Run validator in OpenClinica mode.
*/

let incCounter = 0
const inc = () => incCounter++

/**
* The validate function. Relies heavily on the {@link XForm} class.
*
* @static
* @param {string} xformStr - XForm content.
* @param {ValidationOptions} [options] - Validation options.
* @return {ValidateResult} validation results.
* @return {Promise<ValidateResult>} validation results.
*/
const validate = async( xformStr, options = {} ) => {
console.log(`validate a`)
const start = Date.now();
let warnings = [];
let errors = [];
Expand All @@ -46,12 +50,14 @@ const validate = async( xformStr, options = {} ) => {
} catch ( e ) {
errors.push( e );
}
console.log(`validate b`)

if ( !xform ){
const duration = Date.now() - start;

return Promise.resolve( { warnings, errors, version, duration } );
}
console.log(`validate b`)

result = xform.checkStructure();
warnings = warnings.concat( result.warnings );
Expand All @@ -70,17 +76,20 @@ const validate = async( xformStr, options = {} ) => {
warnings = warnings.concat( result.warnings );
errors = errors.concat( result.errors );
}
console.log(`validate d`)

try{
await xform.parseModel();
} catch ( e ) {
let ers = Array.isArray( e ) ? e : [ e ];
errors = errors.concat( ers );
}
console.log(`validate e`)

// Check logic

for( const el of xform.binds.concat( xform.setvalues ) ){
console.log(`validate inner f ${String(el).slice(0, 99)}`)
const type = el.nodeName.toLowerCase();
const props = type === 'bind' ? { path: 'nodeset', logic: [ 'calculate', 'constraint', 'relevant', 'required', 'readonly' ] } : { path: 'ref', logic: [ 'value' ] };
const path = el.getAttribute( props.path );
Expand All @@ -91,32 +100,39 @@ const validate = async( xformStr, options = {} ) => {
continue;
}

console.log(`validate inner g`)
const nodeName = xform._nodeName( path );
// Note: using enketoEvaluate here, would be much slower
const nodeExists = await xform.nodeExists( path );
console.log(`validate inner h`)

if ( !nodeExists ) {
errors.push( `Found ${type} for "${nodeName}" that does not exist in the model.` );

continue;
}
console.log(`validate inner h`)

for ( const logicName of props.logic ){
console.log(`validate inner logicName=${logicName}`)
const logicExpr = el.getAttribute( logicName );
const calculation = logicName === 'calculate';

if ( logicExpr ) {
let friendlyLogicName = logicName[ 0 ].toUpperCase() + logicName.substring( 1 );
if ( calculation ){
console.log(`validate inner logic i`)
friendlyLogicName = 'Calculation';
} else if ( type === 'setvalue' ){
console.log(`validate inner logic j`)
const event = el.getAttribute( 'event' );
if ( !event ){
errors.push( 'Found ${type} without event attribute.' );
continue;
}
friendlyLogicName = event.split( ' ' ).includes( 'xforms-value-changed' ) ? 'Triggered calculation' : 'Dynamic default';
} else {
console.log(`validate inner logic k`)
// e.g. the results for accidentally writing "ues" instead of "yes", putting an appearance in a logic column, etc
// and accidentally writing 'true' or 'false' or 'yes' or 'no' in the constraint or relevant column in XLSForm
if ( likelyNonSyntaxError( logicExpr )
Expand All @@ -126,9 +142,11 @@ const validate = async( xformStr, options = {} ) => {
}

try {
console.log(`validate inner logic l`)
await xform.enketoEvaluate( logicExpr, ( calculation ? 'string' : 'boolean' ), path );
}
catch( e ){
console.log(`validate inner logic m`)
errors.push( `${friendlyLogicName} formula for "${nodeName}": ${e}` );
}
// TODO: check for cyclic dependencies within single expression and between calculations, e.g. triangular calculation dependencies
Expand All @@ -137,7 +155,9 @@ const validate = async( xformStr, options = {} ) => {
}
const duration = Date.now() - start;

console.log(`validate y`)
await xform.exit();
console.log(`validate z`)

return { warnings, errors, version, duration };
};
Expand Down
24 changes: 21 additions & 3 deletions src/xform.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,21 @@ class XForm {

this.loadBrowserPage = getBrowser( )
.then( browser =>{
console.log('loadBrowserPage browser', browser)
this.browser = browser;

return browser.newPage();
} )
.then( page => {
console.log('loadBrowserPage page', page)

return page.addScriptTag( { path: path.join( __dirname, '../build/FormModel-bundle.js' ) } )
.then( () => page );
} );
} )
.catch( error => {
console.error(error)
process.exit(1) // TODO: crash or something, because a sync constructor can't propogate async error.
})
}

/*
Expand Down Expand Up @@ -234,6 +240,7 @@ class XForm {

return this.loadBrowserPage
.then( p => {
console.log('parseModel p', p)
page = p;
// Get a serialized model with namespaces in locations that Enketo can deal with.
const modelStr = this._extractModelStr().root().get( '*' ).toString( false );
Expand Down Expand Up @@ -268,20 +275,31 @@ class XForm {
}, modelStr, externalArr, !!this.options.openclinica );
} )
.then( modelHandle => {
console.log('parseModel modelHandle', modelHandle)

this.modelHandle = modelHandle;

return page.evaluateHandle( model => model.init(), modelHandle );
const z = page.evaluateHandle( model => model.init(), modelHandle );
console.log('parseModel modelHandle end')
return z
} )
.then( loadErrorsHandle => loadErrorsHandle.jsonValue() )
.then( loadErrorsHandle => {
console.log('parseModel loadErrorsHandle', loadErrorsHandle)
const json = loadErrorsHandle.jsonValue()

console.log('parseModel loadErrorsHandle json')
return json
})
.then( loadErrors => {
console.log('parseModel loadErrors', loadErrors)
if ( loadErrors.length ) {
throw loadErrors;
}

return page;
} )
.catch( e => {
console.log('parseModel catch')
throw e;
} );
}
Expand Down
Loading