mirror of
https://github.com/remoteintech/remote-jobs
synced 2025-01-12 20:48:42 +00:00
Refactor validation logic into a short script and a larger library file (#444)
* Refactor validation logic into a short script and a larger library file * Add unit tests for utility functions
This commit is contained in:
parent
19c4d82dbb
commit
5cce269df4
8 changed files with 678 additions and 425 deletions
390
bin/validate.js
390
bin/validate.js
|
@ -1,396 +1,42 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { parseFromDirectory } = require( '../lib' );
|
||||||
|
|
||||||
const fs = require( 'fs' );
|
const fs = require( 'fs' );
|
||||||
const path = require( 'path' );
|
const path = require( 'path' );
|
||||||
const util = require( 'util' );
|
|
||||||
|
|
||||||
const cheerio = require( 'cheerio' );
|
// Accept an optional directory name where the content files live.
|
||||||
const marked = require( 'marked' );
|
|
||||||
|
|
||||||
let errorCount = 0;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accept an optional directory name where the content files live.
|
|
||||||
*/
|
|
||||||
const contentPath = (
|
const contentPath = (
|
||||||
process.argv[ 2 ]
|
process.argv[ 2 ]
|
||||||
? path.resolve( process.argv[ 2 ] )
|
? path.resolve( process.argv[ 2 ] )
|
||||||
: path.join( __dirname, '..' )
|
: path.join( __dirname, '..' )
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Parse the content from the directory.
|
||||||
|
const result = parseFromDirectory( contentPath );
|
||||||
|
|
||||||
/**
|
// Report any errors.
|
||||||
* Define the heading names expected in company profiles.
|
const errorCount = result.errors ? result.errors.length : 0;
|
||||||
*/
|
|
||||||
const headingsRequired = [
|
|
||||||
'Company blurb',
|
|
||||||
];
|
|
||||||
const headingsOptional = [
|
|
||||||
'Company size',
|
|
||||||
'Remote status',
|
|
||||||
'Region',
|
|
||||||
'Company technologies',
|
|
||||||
'Office locations',
|
|
||||||
'How to apply',
|
|
||||||
];
|
|
||||||
|
|
||||||
|
( result.errors || [] ).forEach( err => {
|
||||||
/**
|
err.message.split( '\n' ).forEach( line => {
|
||||||
* Utility functions
|
console.log( '%s: %s', err.filename, line );
|
||||||
*/
|
|
||||||
|
|
||||||
function error( filename, msg, ...params ) {
|
|
||||||
errorCount++;
|
|
||||||
const msgFormatted = util.format( msg, ...params );
|
|
||||||
msgFormatted.split( '\n' ).forEach( line => {
|
|
||||||
console.log( '%s: %s', filename, line );
|
|
||||||
} );
|
} );
|
||||||
}
|
|
||||||
|
|
||||||
function companyNameToProfileFilename( companyName ) {
|
|
||||||
return companyName.toLowerCase()
|
|
||||||
.replace( /&/g, ' and ' )
|
|
||||||
.replace( /'/g, '' )
|
|
||||||
.replace( /[^a-z0-9]+/gi, '-' )
|
|
||||||
.replace( /^-|-$/g, '' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// adapted from https://gist.github.com/RandomEtc/2657669
|
|
||||||
function jsonStringifyUnicodeEscaped( obj ) {
|
|
||||||
return JSON.stringify( obj ).replace( /[\u007f-\uffff]/g, c => {
|
|
||||||
return '\\u' + ( '0000' + c.charCodeAt( 0 ).toString( 16 ) ).slice( -4 );
|
|
||||||
} );
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build list of Markdown files containing company profiles.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const profilesPath = path.join( contentPath, 'company-profiles' );
|
|
||||||
const profileFilenames = fs.readdirSync( profilesPath );
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan the company table in the readme.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const readmeCompanies = [];
|
|
||||||
|
|
||||||
const readmeMarkdown = fs.readFileSync(
|
|
||||||
path.join( contentPath, 'README.md' ),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
const $ = cheerio.load( marked( readmeMarkdown ) );
|
|
||||||
|
|
||||||
function readmeError( msg, ...params ) {
|
|
||||||
error( 'README.md', msg, ...params );
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastCompanyName = null;
|
|
||||||
|
|
||||||
$( 'tr' ).each( ( i, tr ) => {
|
|
||||||
if ( i === 0 ) {
|
|
||||||
// Skip the table header row.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const $td = $( tr ).children( 'td' );
|
|
||||||
if ( $td.length !== 3 ) {
|
|
||||||
readmeError(
|
|
||||||
'Expected 3 table cells but found %d: %s',
|
|
||||||
$td.length,
|
|
||||||
$( tr ).html().replace( /\n/g, '' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const readmeEntry = {
|
|
||||||
// Strip out warning emoji indicating that this profile is incomplete,
|
|
||||||
// and any following unicode chars
|
|
||||||
name: $td.eq( 0 ).text().replace( /\u26a0\ufe0f*/, '' ).trim(),
|
|
||||||
// Detect warning emoji next to company name
|
|
||||||
isIncomplete: /\u26a0/.test( $td.eq( 0 ).text() ),
|
|
||||||
website: $td.eq( 1 ).text(),
|
|
||||||
shortRegion: $td.eq( 2 ).text(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if ( ! readmeEntry.name ) {
|
|
||||||
readmeError(
|
|
||||||
'Missing company name: %s',
|
|
||||||
$( tr ).html().replace( /\n/g, '' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
lastCompanyName &&
|
|
||||||
readmeEntry.name.toLowerCase() < lastCompanyName.toLowerCase()
|
|
||||||
) {
|
|
||||||
readmeError(
|
|
||||||
'Company is listed out of order: "%s" (should be before "%s")',
|
|
||||||
readmeEntry.name,
|
|
||||||
lastCompanyName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
lastCompanyName = readmeEntry.name;
|
|
||||||
|
|
||||||
const $profileLink = $td.eq( 0 ).find( 'a' );
|
|
||||||
|
|
||||||
if ( $profileLink.length === 1 ) {
|
|
||||||
const match = $profileLink.attr( 'href' ).match( /^\/company-profiles\/(.*\.md)$/ );
|
|
||||||
|
|
||||||
if ( match ) {
|
|
||||||
readmeEntry.linkedFilename = match[ 1 ];
|
|
||||||
if ( profileFilenames.indexOf( readmeEntry.linkedFilename ) === -1 ) {
|
|
||||||
readmeError(
|
|
||||||
'Broken link to company "%s": "%s"',
|
|
||||||
readmeEntry.name,
|
|
||||||
$profileLink.attr( 'href' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameCheck = $profileLink.text().trim();
|
|
||||||
if ( nameCheck !== readmeEntry.name ) {
|
|
||||||
readmeError(
|
|
||||||
'Extra text in company name: %s, %s',
|
|
||||||
jsonStringifyUnicodeEscaped( nameCheck ),
|
|
||||||
jsonStringifyUnicodeEscaped( readmeEntry.name )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
readmeError(
|
|
||||||
'Invalid link to company "%s": "%s"',
|
|
||||||
readmeEntry.name,
|
|
||||||
$profileLink.attr( 'href' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
readmeError(
|
|
||||||
'Company "%s" has no linked Markdown profile ("%s.md")',
|
|
||||||
readmeEntry.name,
|
|
||||||
companyNameToProfileFilename( readmeEntry.name )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
readmeCompanies.push( readmeEntry );
|
|
||||||
} );
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan the individual Markdown files containing the company profiles.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const allProfileHeadings = {};
|
|
||||||
|
|
||||||
profileFilenames.forEach( filename => {
|
|
||||||
function profileError( msg, ...params ) {
|
|
||||||
error( filename, msg, ...params );
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileMarkdown = fs.readFileSync(
|
|
||||||
path.join( profilesPath, filename ),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
const $ = cheerio.load( marked( profileMarkdown ) );
|
|
||||||
|
|
||||||
let hasTitleError = false;
|
|
||||||
|
|
||||||
if ( $( 'h1' ).length !== 1 ) {
|
|
||||||
profileError(
|
|
||||||
'Expected 1 first-level heading but found %d',
|
|
||||||
$( 'h1' ).length
|
|
||||||
);
|
|
||||||
hasTitleError = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! $( 'h1' ).parent().is( 'body' ) ) {
|
|
||||||
profileError(
|
|
||||||
'The main title is wrapped inside of another element.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const companyName = $( 'h1' ).text();
|
|
||||||
|
|
||||||
if ( ! /[a-z]/i.test( companyName ) ) {
|
|
||||||
profileError(
|
|
||||||
'Company name looks wrong: "%s"',
|
|
||||||
companyName
|
|
||||||
);
|
|
||||||
hasTitleError = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filenameBase = filename.replace( /\.md$/, '' );
|
|
||||||
const filenameExpected = companyNameToProfileFilename( companyName );
|
|
||||||
if (
|
|
||||||
! hasTitleError &&
|
|
||||||
filenameBase !== filenameExpected &&
|
|
||||||
// Some profile files just have shorter names than the company name,
|
|
||||||
// which is fine.
|
|
||||||
filenameExpected.substring( 0, filenameBase.length + 1 ) !== filenameBase + '-'
|
|
||||||
) {
|
|
||||||
profileError(
|
|
||||||
'Expected filename "%s.md" for company "%s"',
|
|
||||||
filenameExpected,
|
|
||||||
companyName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const readmeEntry = readmeCompanies.find(
|
|
||||||
readmeEntry => readmeEntry.linkedFilename === filename
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( filename !== 'example.md' && ! readmeEntry ) {
|
|
||||||
profileError( 'No link to company profile from readme' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build and validate list of headings contained in this Markdown profile.
|
|
||||||
|
|
||||||
const profileHeadings = [];
|
|
||||||
|
|
||||||
$( 'h2' ).each( ( i, el ) => {
|
|
||||||
const headingName = $( el ).html();
|
|
||||||
|
|
||||||
if ( ! $( el ).parent().is( 'body' ) ) {
|
|
||||||
profileError(
|
|
||||||
'The section heading for "%s" is wrapped inside of another element.',
|
|
||||||
headingName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( profileHeadings.indexOf( headingName ) >= 0 ) {
|
|
||||||
profileError(
|
|
||||||
'Duplicate section: "%s".',
|
|
||||||
headingName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
headingsRequired.indexOf( headingName ) === -1 &&
|
|
||||||
headingsOptional.indexOf( headingName ) === -1
|
|
||||||
) {
|
|
||||||
profileError(
|
|
||||||
'Invalid section: "%s". Expected one of: %s',
|
|
||||||
headingName,
|
|
||||||
JSON.stringify( headingsRequired.concat( headingsOptional ) )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track headings for this profile
|
|
||||||
profileHeadings.push( headingName );
|
|
||||||
|
|
||||||
// Track headings across all profiles
|
|
||||||
if ( ! allProfileHeadings[ headingName ] ) {
|
|
||||||
allProfileHeadings[ headingName ] = [];
|
|
||||||
}
|
|
||||||
allProfileHeadings[ headingName ].push( filename );
|
|
||||||
} );
|
|
||||||
|
|
||||||
headingsRequired.forEach( headingName => {
|
|
||||||
if ( profileHeadings.indexOf( headingName ) === -1 ) {
|
|
||||||
profileError(
|
|
||||||
'Required section "%s" not found.',
|
|
||||||
headingName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} );
|
|
||||||
|
|
||||||
// Build and validate the content of each section in this profile.
|
|
||||||
|
|
||||||
const profileContent = {};
|
|
||||||
let currentHeading = null;
|
|
||||||
|
|
||||||
$( 'body' ).children().each( ( i, el ) => {
|
|
||||||
const $el = $( el );
|
|
||||||
|
|
||||||
if ( $el.is( 'h1' ) ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( $el.is( 'h2' ) ) {
|
|
||||||
currentHeading = $el.html();
|
|
||||||
profileContent[ currentHeading ] = '';
|
|
||||||
} else if ( currentHeading ) {
|
|
||||||
profileContent[ currentHeading ] = (
|
|
||||||
profileContent[ currentHeading ]
|
|
||||||
+ '\n' + $.html( el )
|
|
||||||
).trim();
|
|
||||||
} else {
|
|
||||||
profileError(
|
|
||||||
'Content is not part of any section: %s',
|
|
||||||
$.html( el ).replace( /\n/g, '' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} );
|
|
||||||
|
|
||||||
Object.keys( profileContent ).forEach( heading => {
|
|
||||||
const sectionText = profileContent[ heading ]
|
|
||||||
.replace( /<[^>]+>/g, '' )
|
|
||||||
.trim();
|
|
||||||
if ( ! sectionText ) {
|
|
||||||
profileError(
|
|
||||||
'Empty section: "%s". Leave it out instead.',
|
|
||||||
heading
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} );
|
|
||||||
|
|
||||||
if ( readmeEntry ) {
|
|
||||||
// Check for company profiles that were filled in, but the "incomplete"
|
|
||||||
// mark was left in the readme, or vice versa.
|
|
||||||
const isIncomplete = {
|
|
||||||
readme: readmeEntry.isIncomplete,
|
|
||||||
sections: (
|
|
||||||
profileHeadings.length === 1 &&
|
|
||||||
profileHeadings[ 0 ] === 'Company blurb'
|
|
||||||
),
|
|
||||||
content: /⚠/.test( profileContent[ 'Company blurb' ] ),
|
|
||||||
};
|
|
||||||
const incompleteCount = Object.values( isIncomplete )
|
|
||||||
.reduce( ( sum, v ) => sum + ( v ? 1 : 0 ), 0 );
|
|
||||||
|
|
||||||
// incompleteCount === 0: Profile is incomplete; all 3 indicators are consistent
|
|
||||||
// incompleteCount === 3: Profile is "complete"; all 3 indicators are consistent
|
|
||||||
if ( incompleteCount === 1 ) {
|
|
||||||
if ( isIncomplete.readme ) {
|
|
||||||
profileError(
|
|
||||||
'Profile looks complete, but the main readme contains a warning emoji.'
|
|
||||||
);
|
|
||||||
} else if ( isIncomplete.sections ) {
|
|
||||||
profileError(
|
|
||||||
'Profile is marked as complete, but it only contains a "Company blurb" heading.'
|
|
||||||
)
|
|
||||||
} else { // isIncomplete.content
|
|
||||||
profileError(
|
|
||||||
'Profile looks complete, but the "Company blurb" contains a warning emoji.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if ( incompleteCount === 2 ) {
|
|
||||||
if ( ! isIncomplete.readme ) {
|
|
||||||
profileError(
|
|
||||||
'Profile looks incomplete, but the main readme does not contain a warning emoji.'
|
|
||||||
);
|
|
||||||
} else if ( ! isIncomplete.sections ) {
|
|
||||||
profileError(
|
|
||||||
'Profile is marked as incomplete, but it contains multiple sections.'
|
|
||||||
+ '\nPlease remove the warning emoji from the "Company blurb" section and the main readme.'
|
|
||||||
)
|
|
||||||
} else { // ! isIncomplete.content
|
|
||||||
profileError(
|
|
||||||
'Profile looks incomplete, but the "Company blurb" does not contain a warning emoji.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
// Count all profile headings, if requested.
|
||||||
if ( process.env.REPORT_PROFILE_HEADINGS ) {
|
if ( process.env.REPORT_PROFILE_HEADINGS ) {
|
||||||
console.log();
|
console.log();
|
||||||
console.log(
|
console.log(
|
||||||
'Profile headings by count (%d total profiles):',
|
'Profile headings by count (%d total profiles):',
|
||||||
profileFilenames.length
|
result.profileFilenames.length
|
||||||
);
|
);
|
||||||
Object.keys( allProfileHeadings ).forEach( heading => {
|
Object.keys( result.profileHeadingCounts ).forEach( heading => {
|
||||||
console.log( '%s: %d', heading, allProfileHeadings[ heading ].length )
|
console.log(
|
||||||
|
'%s: %d',
|
||||||
|
heading,
|
||||||
|
result.profileHeadingCounts[ heading ]
|
||||||
|
);
|
||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
395
lib/index.js
Executable file
395
lib/index.js
Executable file
|
@ -0,0 +1,395 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require( 'fs' );
|
||||||
|
const path = require( 'path' );
|
||||||
|
const util = require( 'util' );
|
||||||
|
|
||||||
|
const cheerio = require( 'cheerio' );
|
||||||
|
const marked = require( 'marked' );
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants
|
||||||
|
*/
|
||||||
|
|
||||||
|
const headingsRequired = [
|
||||||
|
'Company blurb',
|
||||||
|
];
|
||||||
|
const headingsOptional = [
|
||||||
|
'Company size',
|
||||||
|
'Remote status',
|
||||||
|
'Region',
|
||||||
|
'Company technologies',
|
||||||
|
'Office locations',
|
||||||
|
'How to apply',
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
function companyNameToProfileFilename( companyName ) {
|
||||||
|
return companyName.toLowerCase()
|
||||||
|
.replace( /&/g, ' and ' )
|
||||||
|
.replace( /'/g, '' )
|
||||||
|
.replace( /[^a-z0-9]+/gi, '-' )
|
||||||
|
.replace( /^-|-$/g, '' );
|
||||||
|
}
|
||||||
|
exports.companyNameToProfileFilename = companyNameToProfileFilename;
|
||||||
|
|
||||||
|
// adapted from https://gist.github.com/RandomEtc/2657669
|
||||||
|
function jsonStringifyUnicodeEscaped( obj ) {
|
||||||
|
return JSON.stringify( obj ).replace( /[\u007f-\uffff]/g, c => {
|
||||||
|
return '\\u' + ( '0000' + c.charCodeAt( 0 ).toString( 16 ) ).slice( -4 );
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
exports.jsonStringifyUnicodeEscaped = jsonStringifyUnicodeEscaped;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main exported function
|
||||||
|
*
|
||||||
|
* Start with a directory including a README.md and company-profiles/*.md
|
||||||
|
* files, and validate and parse the content of the Markdown files.
|
||||||
|
*/
|
||||||
|
exports.parseFromDirectory = contentPath => {
|
||||||
|
let errors = [];
|
||||||
|
|
||||||
|
function error( filename, msg, ...params ) {
|
||||||
|
errors.push( {
|
||||||
|
filename,
|
||||||
|
message: util.format( msg, ...params ),
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build list of Markdown files containing company profiles.
|
||||||
|
const profilesPath = path.join( contentPath, 'company-profiles' );
|
||||||
|
const profileFilenames = fs.readdirSync( profilesPath );
|
||||||
|
|
||||||
|
// Scan the company table in the readme.
|
||||||
|
|
||||||
|
const readmeCompanies = [];
|
||||||
|
|
||||||
|
const readmeMarkdown = fs.readFileSync(
|
||||||
|
path.join( contentPath, 'README.md' ),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const $ = cheerio.load( marked( readmeMarkdown ) );
|
||||||
|
|
||||||
|
function readmeError( msg, ...params ) {
|
||||||
|
error( 'README.md', msg, ...params );
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastCompanyName = null;
|
||||||
|
|
||||||
|
$( 'tr' ).each( ( i, tr ) => {
|
||||||
|
if ( i === 0 ) {
|
||||||
|
// Skip the table header row.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const $td = $( tr ).children( 'td' );
|
||||||
|
if ( $td.length !== 3 ) {
|
||||||
|
readmeError(
|
||||||
|
'Expected 3 table cells but found %d: %s',
|
||||||
|
$td.length,
|
||||||
|
$( tr ).html().replace( /\n/g, '' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readmeEntry = {
|
||||||
|
// Strip out warning emoji indicating that this profile is incomplete,
|
||||||
|
// and any following unicode chars
|
||||||
|
name: $td.eq( 0 ).text().replace( /\u26a0\ufe0f*/, '' ).trim(),
|
||||||
|
// Detect warning emoji next to company name
|
||||||
|
isIncomplete: /\u26a0/.test( $td.eq( 0 ).text() ),
|
||||||
|
website: $td.eq( 1 ).text(),
|
||||||
|
shortRegion: $td.eq( 2 ).text(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ( ! readmeEntry.name ) {
|
||||||
|
readmeError(
|
||||||
|
'Missing company name: %s',
|
||||||
|
$( tr ).html().replace( /\n/g, '' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastCompanyName &&
|
||||||
|
readmeEntry.name.toLowerCase() < lastCompanyName.toLowerCase()
|
||||||
|
) {
|
||||||
|
readmeError(
|
||||||
|
'Company is listed out of order: "%s" (should be before "%s")',
|
||||||
|
readmeEntry.name,
|
||||||
|
lastCompanyName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lastCompanyName = readmeEntry.name;
|
||||||
|
|
||||||
|
const $profileLink = $td.eq( 0 ).find( 'a' );
|
||||||
|
|
||||||
|
if ( $profileLink.length === 1 ) {
|
||||||
|
const match = $profileLink.attr( 'href' ).match( /^\/company-profiles\/(.*\.md)$/ );
|
||||||
|
|
||||||
|
if ( match ) {
|
||||||
|
readmeEntry.linkedFilename = match[ 1 ];
|
||||||
|
if ( profileFilenames.indexOf( readmeEntry.linkedFilename ) === -1 ) {
|
||||||
|
readmeError(
|
||||||
|
'Broken link to company "%s": "%s"',
|
||||||
|
readmeEntry.name,
|
||||||
|
$profileLink.attr( 'href' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameCheck = $profileLink.text().trim();
|
||||||
|
if ( nameCheck !== readmeEntry.name ) {
|
||||||
|
readmeError(
|
||||||
|
'Extra text in company name: %s, %s',
|
||||||
|
jsonStringifyUnicodeEscaped( nameCheck ),
|
||||||
|
jsonStringifyUnicodeEscaped( readmeEntry.name )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
readmeError(
|
||||||
|
'Invalid link to company "%s": "%s"',
|
||||||
|
readmeEntry.name,
|
||||||
|
$profileLink.attr( 'href' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
readmeError(
|
||||||
|
'Company "%s" has no linked Markdown profile ("%s.md")',
|
||||||
|
readmeEntry.name,
|
||||||
|
companyNameToProfileFilename( readmeEntry.name )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
readmeCompanies.push( readmeEntry );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Scan the individual Markdown files containing the company profiles.
|
||||||
|
|
||||||
|
const allProfileHeadings = {};
|
||||||
|
|
||||||
|
profileFilenames.forEach( filename => {
|
||||||
|
function profileError( msg, ...params ) {
|
||||||
|
error( filename, msg, ...params );
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileMarkdown = fs.readFileSync(
|
||||||
|
path.join( profilesPath, filename ),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const $ = cheerio.load( marked( profileMarkdown ) );
|
||||||
|
|
||||||
|
let hasTitleError = false;
|
||||||
|
|
||||||
|
if ( $( 'h1' ).length !== 1 ) {
|
||||||
|
profileError(
|
||||||
|
'Expected 1 first-level heading but found %d',
|
||||||
|
$( 'h1' ).length
|
||||||
|
);
|
||||||
|
hasTitleError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! $( 'h1' ).parent().is( 'body' ) ) {
|
||||||
|
profileError(
|
||||||
|
'The main title is wrapped inside of another element.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyName = $( 'h1' ).text();
|
||||||
|
|
||||||
|
if ( ! /[a-z]/i.test( companyName ) ) {
|
||||||
|
profileError(
|
||||||
|
'Company name looks wrong: "%s"',
|
||||||
|
companyName
|
||||||
|
);
|
||||||
|
hasTitleError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filenameBase = filename.replace( /\.md$/, '' );
|
||||||
|
const filenameExpected = companyNameToProfileFilename( companyName );
|
||||||
|
if (
|
||||||
|
! hasTitleError &&
|
||||||
|
filenameBase !== filenameExpected &&
|
||||||
|
// Some profile files just have shorter names than the company name,
|
||||||
|
// which is fine.
|
||||||
|
filenameExpected.substring( 0, filenameBase.length + 1 ) !== filenameBase + '-'
|
||||||
|
) {
|
||||||
|
profileError(
|
||||||
|
'Expected filename "%s.md" for company "%s"',
|
||||||
|
filenameExpected,
|
||||||
|
companyName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readmeEntry = readmeCompanies.find(
|
||||||
|
readmeEntry => readmeEntry.linkedFilename === filename
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( filename !== 'example.md' && ! readmeEntry ) {
|
||||||
|
profileError( 'No link to company profile from readme' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and validate list of headings contained in this Markdown profile.
|
||||||
|
|
||||||
|
const profileHeadings = [];
|
||||||
|
|
||||||
|
$( 'h2' ).each( ( i, el ) => {
|
||||||
|
const headingName = $( el ).html();
|
||||||
|
|
||||||
|
if ( ! $( el ).parent().is( 'body' ) ) {
|
||||||
|
profileError(
|
||||||
|
'The section heading for "%s" is wrapped inside of another element.',
|
||||||
|
headingName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( profileHeadings.indexOf( headingName ) >= 0 ) {
|
||||||
|
profileError(
|
||||||
|
'Duplicate section: "%s".',
|
||||||
|
headingName
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Track headings for this profile
|
||||||
|
profileHeadings.push( headingName );
|
||||||
|
|
||||||
|
// Track heading counts across all profiles
|
||||||
|
if ( ! allProfileHeadings[ headingName ] ) {
|
||||||
|
allProfileHeadings[ headingName ] = [];
|
||||||
|
}
|
||||||
|
allProfileHeadings[ headingName ].push( filename );
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
headingsRequired.indexOf( headingName ) === -1 &&
|
||||||
|
headingsOptional.indexOf( headingName ) === -1
|
||||||
|
) {
|
||||||
|
profileError(
|
||||||
|
'Invalid section: "%s". Expected one of: %s',
|
||||||
|
headingName,
|
||||||
|
JSON.stringify( headingsRequired.concat( headingsOptional ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
headingsRequired.forEach( headingName => {
|
||||||
|
if ( profileHeadings.indexOf( headingName ) === -1 ) {
|
||||||
|
profileError(
|
||||||
|
'Required section "%s" not found.',
|
||||||
|
headingName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Build and validate the content of each section in this profile.
|
||||||
|
|
||||||
|
const profileContent = {};
|
||||||
|
let currentHeading = null;
|
||||||
|
|
||||||
|
$( 'body' ).children().each( ( i, el ) => {
|
||||||
|
const $el = $( el );
|
||||||
|
|
||||||
|
if ( $el.is( 'h1' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $el.is( 'h2' ) ) {
|
||||||
|
currentHeading = $el.html();
|
||||||
|
profileContent[ currentHeading ] = '';
|
||||||
|
} else if ( currentHeading ) {
|
||||||
|
profileContent[ currentHeading ] = (
|
||||||
|
profileContent[ currentHeading ]
|
||||||
|
+ '\n' + $.html( el )
|
||||||
|
).trim();
|
||||||
|
} else {
|
||||||
|
profileError(
|
||||||
|
'Content is not part of any section: %s',
|
||||||
|
$.html( el ).replace( /\n/g, '' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
Object.keys( profileContent ).forEach( heading => {
|
||||||
|
const sectionText = profileContent[ heading ]
|
||||||
|
.replace( /<[^>]+>/g, '' )
|
||||||
|
.trim();
|
||||||
|
if ( ! sectionText ) {
|
||||||
|
profileError(
|
||||||
|
'Empty section: "%s". Leave it out instead.',
|
||||||
|
heading
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( readmeEntry ) {
|
||||||
|
// Check for company profiles that were filled in, but the "incomplete"
|
||||||
|
// mark was left in the readme, or vice versa.
|
||||||
|
const isIncomplete = {
|
||||||
|
readme: readmeEntry.isIncomplete,
|
||||||
|
sections: (
|
||||||
|
profileHeadings.length === 1 &&
|
||||||
|
profileHeadings[ 0 ] === 'Company blurb'
|
||||||
|
),
|
||||||
|
content: /⚠/.test( profileContent[ 'Company blurb' ] ),
|
||||||
|
};
|
||||||
|
const incompleteCount = Object.values( isIncomplete )
|
||||||
|
.reduce( ( sum, v ) => sum + ( v ? 1 : 0 ), 0 );
|
||||||
|
|
||||||
|
// incompleteCount === 0: Profile is incomplete; all 3 indicators are consistent
|
||||||
|
// incompleteCount === 3: Profile is "complete"; all 3 indicators are consistent
|
||||||
|
if ( incompleteCount === 1 ) {
|
||||||
|
if ( isIncomplete.readme ) {
|
||||||
|
profileError(
|
||||||
|
'Profile looks complete, but the main readme contains a warning emoji.'
|
||||||
|
);
|
||||||
|
} else if ( isIncomplete.sections ) {
|
||||||
|
profileError(
|
||||||
|
'Profile is marked as complete, but it only contains a "Company blurb" heading.'
|
||||||
|
)
|
||||||
|
} else { // isIncomplete.content
|
||||||
|
profileError(
|
||||||
|
'Profile looks complete, but the "Company blurb" contains a warning emoji.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if ( incompleteCount === 2 ) {
|
||||||
|
if ( ! isIncomplete.readme ) {
|
||||||
|
profileError(
|
||||||
|
'Profile looks incomplete, but the main readme does not contain a warning emoji.'
|
||||||
|
);
|
||||||
|
} else if ( ! isIncomplete.sections ) {
|
||||||
|
profileError(
|
||||||
|
'Profile is marked as incomplete, but it contains multiple sections.'
|
||||||
|
+ '\nPlease remove the warning emoji from the "Company blurb" section and the main readme.'
|
||||||
|
)
|
||||||
|
} else { // ! isIncomplete.content
|
||||||
|
profileError(
|
||||||
|
'Profile looks incomplete, but the "Company blurb" does not contain a warning emoji.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
const profileHeadingCounts = {};
|
||||||
|
Object.keys( allProfileHeadings ).forEach( heading => {
|
||||||
|
profileHeadingCounts[ heading ] = allProfileHeadings[ heading ].length;
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( errors.length > 0 ) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
errors,
|
||||||
|
profileFilenames,
|
||||||
|
profileHeadingCounts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
profileFilenames,
|
||||||
|
profileHeadingCounts,
|
||||||
|
};
|
||||||
|
};
|
67
test/functions.js
Normal file
67
test/functions.js
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
const { assert } = require( 'chai' );
|
||||||
|
|
||||||
|
const {
|
||||||
|
companyNameToProfileFilename,
|
||||||
|
jsonStringifyUnicodeEscaped,
|
||||||
|
} = require( '../lib' );
|
||||||
|
|
||||||
|
describe( 'companyNameToProfileFilename function', () => {
|
||||||
|
it( 'should lowercase and convert spaces to dashes', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
companyNameToProfileFilename( 'My Company-Name' ),
|
||||||
|
'my-company-name'
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should expand ampersands', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
companyNameToProfileFilename( '&text&stuff&' ),
|
||||||
|
'and-text-and-stuff-and'
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should remove apostrophes', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
companyNameToProfileFilename( 'Let\'s Encrypt' ),
|
||||||
|
'lets-encrypt'
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should lowercase and strip non-alphanumeric characters', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
companyNameToProfileFilename( 'My@123 *Markdown* _Company@Name_' ),
|
||||||
|
'my-123-markdown-company-name'
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should trim leading and trailing whitespace etc', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
companyNameToProfileFilename( ' My WEIRD company name \'$$$$' ),
|
||||||
|
'my-weird-company-name'
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
describe( 'jsonStringifyUnicodeEscaped function', () => {
|
||||||
|
it( 'should equal JSON.stringify for non-Unicode-expanded inputs', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
jsonStringifyUnicodeEscaped( 'abc def' ),
|
||||||
|
'"abc def"'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
jsonStringifyUnicodeEscaped( '\'\\"\nabc\ndef@#$\'' ),
|
||||||
|
'"\'\\\\\\"\\nabc\\ndef@#$\'"'
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should escape Unicode-expanded characters', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
jsonStringifyUnicodeEscaped( '\u26a0\ufe0f*' ),
|
||||||
|
'"\\u26a0\\ufe0f*"'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
jsonStringifyUnicodeEscaped( '\u26a0 Emoji \u26a0\ufe0f and\nother \\text' ),
|
||||||
|
'"\\u26a0 Emoji \\u26a0\\ufe0f and\\nother \\\\text"'
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
} );
|
|
@ -4,10 +4,42 @@ const { spawnSync } = require( 'child_process' );
|
||||||
|
|
||||||
const { expect } = require( 'chai' );
|
const { expect } = require( 'chai' );
|
||||||
|
|
||||||
|
const { parseFromDirectory } = require( '../../lib' );
|
||||||
|
|
||||||
const fixturesPath = path.join( __dirname, '..', 'fixtures' );
|
const fixturesPath = path.join( __dirname, '..', 'fixtures' );
|
||||||
const validatePath = path.join( __dirname, '..', '..', 'bin', 'validate.js' );
|
const validatePath = path.join( __dirname, '..', '..', 'bin', 'validate.js' );
|
||||||
|
|
||||||
exports.runValidationWithFixtures = ( dirName, env = {} ) => {
|
/**
|
||||||
|
* Parse a content tree from a set of fixture files.
|
||||||
|
*/
|
||||||
|
exports.parseFixtures = dirName => {
|
||||||
|
return parseFromDirectory( path.join( fixturesPath, dirName ) );
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a content tree from a set of fixture files and verify that the
|
||||||
|
* resulting errors are as expected.
|
||||||
|
*/
|
||||||
|
exports.expectValidateFixturesResult = ( dirName, expected ) => {
|
||||||
|
const result = exports.parseFixtures( dirName );
|
||||||
|
if ( result.ok ) {
|
||||||
|
expect( result ).not.to.have.key( 'errors' );
|
||||||
|
} else {
|
||||||
|
expect( result.errors ).to.be.an( 'array' );
|
||||||
|
expect( result.errors.length ).to.be.greaterThan( 0 );
|
||||||
|
}
|
||||||
|
expect( expected.errorCount ).to.equal( ( result.errors || [] ).length );
|
||||||
|
expect( expected.output ).to.eql( ( result.errors || [] ).map( err => {
|
||||||
|
return err.filename + ': ' + err.message;
|
||||||
|
} ) );
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a content tree from a set of fixture files by running the validation
|
||||||
|
* script in a separate process, and return the resulting output.
|
||||||
|
*/
|
||||||
|
exports.runValidationScriptWithFixtures = ( dirName, env = {} ) => {
|
||||||
const result = spawnSync( process.execPath, [
|
const result = spawnSync( process.execPath, [
|
||||||
validatePath,
|
validatePath,
|
||||||
path.join( fixturesPath, dirName ),
|
path.join( fixturesPath, dirName ),
|
||||||
|
|
84
test/parsing.js
Normal file
84
test/parsing.js
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
const { expect } = require( 'chai' );
|
||||||
|
|
||||||
|
const { parseFixtures } = require( './lib' );
|
||||||
|
|
||||||
|
describe( 'content parsing and metadata', () => {
|
||||||
|
it( 'should return content metadata for valid data', () => {
|
||||||
|
expect( parseFixtures( 'valid' ) ).to.eql( {
|
||||||
|
ok: true,
|
||||||
|
profileFilenames: [
|
||||||
|
'10up.md',
|
||||||
|
'17hats.md',
|
||||||
|
'18f.md',
|
||||||
|
'and-yet.md'
|
||||||
|
],
|
||||||
|
profileHeadingCounts: {
|
||||||
|
'Company blurb': 4,
|
||||||
|
'Company size': 4,
|
||||||
|
'Remote status': 4,
|
||||||
|
'Region': 4,
|
||||||
|
'Company technologies': 4,
|
||||||
|
'Office locations': 3,
|
||||||
|
'How to apply': 4,
|
||||||
|
},
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should return content metadata for unsorted company names', () => {
|
||||||
|
expect( parseFixtures( 'unsorted' ) ).to.eql( {
|
||||||
|
ok: false,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
filename: 'README.md',
|
||||||
|
message: 'Company is listed out of order: "17hats" (should be before "18F")',
|
||||||
|
}, {
|
||||||
|
filename: 'README.md',
|
||||||
|
message: 'Company is listed out of order: "&yet" (should be before "17hats")',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
profileFilenames: [
|
||||||
|
'10up.md',
|
||||||
|
'17hats.md',
|
||||||
|
'18f.md',
|
||||||
|
'and-yet.md'
|
||||||
|
],
|
||||||
|
profileHeadingCounts: {
|
||||||
|
'Company blurb': 4,
|
||||||
|
'Company size': 4,
|
||||||
|
'Remote status': 4,
|
||||||
|
'Region': 4,
|
||||||
|
'Company technologies': 4,
|
||||||
|
'Office locations': 3,
|
||||||
|
'How to apply': 4
|
||||||
|
},
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should return content metadata for orphaned company profiles', () => {
|
||||||
|
expect( parseFixtures( 'orphaned-profiles' ) ).to.eql( {
|
||||||
|
ok: false,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
filename: '18f.md',
|
||||||
|
message: 'No link to company profile from readme',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
profileFilenames: [
|
||||||
|
'10up.md',
|
||||||
|
'17hats.md',
|
||||||
|
'18f.md',
|
||||||
|
'and-yet.md'
|
||||||
|
],
|
||||||
|
profileHeadingCounts: {
|
||||||
|
'Company blurb': 4,
|
||||||
|
'Company size': 4,
|
||||||
|
'Remote status': 4,
|
||||||
|
'Region': 4,
|
||||||
|
'Company technologies': 4,
|
||||||
|
'Office locations': 3,
|
||||||
|
'How to apply': 4
|
||||||
|
},
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
const { expect } = require( 'chai' );
|
const { expectValidateFixturesResult } = require( './lib' );
|
||||||
|
|
||||||
const { runValidationWithFixtures } = require( './lib' );
|
describe( 'validation success', () => {
|
||||||
|
it( 'should succeed with valid data', () => {
|
||||||
|
expectValidateFixturesResult( 'valid', {
|
||||||
|
errorCount: 0,
|
||||||
|
output: [],
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
describe( 'validation script errors', () => {
|
describe( 'validation errors', () => {
|
||||||
it( 'should catch invalid table rows', () => {
|
it( 'should catch invalid table rows', () => {
|
||||||
expect( runValidationWithFixtures( 'bad-table-rows' ) ).to.eql( {
|
expectValidateFixturesResult( 'bad-table-rows', {
|
||||||
exitCode: 2,
|
errorCount: 2,
|
||||||
output: [
|
output: [
|
||||||
'README.md: Expected 3 table cells but found 2: <td><a href="/company-profiles/10up.md">10up</a></td><td><a href="https://10up.com/">https://10up.com/</a></td>',
|
'README.md: Expected 3 table cells but found 2: <td><a href="/company-profiles/10up.md">10up</a></td><td><a href="https://10up.com/">https://10up.com/</a></td>',
|
||||||
'README.md: Expected 3 table cells but found 4: <td><a href="/company-profiles/18f.md">18F</a></td><td><a href="https://18f.gsa.gov/">https://18f.gsa.gov/</a></td><td>USA</td><td>something else</td>',
|
'README.md: Expected 3 table cells but found 4: <td><a href="/company-profiles/18f.md">18F</a></td><td><a href="https://18f.gsa.gov/">https://18f.gsa.gov/</a></td><td>USA</td><td>something else</td>',
|
||||||
|
@ -14,8 +21,8 @@ describe( 'validation script errors', () => {
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should catch missing company names', () => {
|
it( 'should catch missing company names', () => {
|
||||||
expect( runValidationWithFixtures( 'missing-company-names' ) ).to.eql( {
|
expectValidateFixturesResult( 'missing-company-names', {
|
||||||
exitCode: 11,
|
errorCount: 11,
|
||||||
output: [
|
output: [
|
||||||
'README.md: Company "⚠⚠⚠" has no linked Markdown profile (".md")',
|
'README.md: Company "⚠⚠⚠" has no linked Markdown profile (".md")',
|
||||||
'README.md: Missing company name: <td></td><td><a href="https://andyet.com">https://andyet.com</a></td><td>Worldwide</td>',
|
'README.md: Missing company name: <td></td><td><a href="https://andyet.com">https://andyet.com</a></td><td>Worldwide</td>',
|
||||||
|
@ -33,8 +40,8 @@ describe( 'validation script errors', () => {
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should catch unsorted company names', () => {
|
it( 'should catch unsorted company names', () => {
|
||||||
expect( runValidationWithFixtures( 'unsorted' ) ).to.eql( {
|
expectValidateFixturesResult( 'unsorted', {
|
||||||
exitCode: 2,
|
errorCount: 2,
|
||||||
output: [
|
output: [
|
||||||
'README.md: Company is listed out of order: "17hats" (should be before "18F")',
|
'README.md: Company is listed out of order: "17hats" (should be before "18F")',
|
||||||
'README.md: Company is listed out of order: "&yet" (should be before "17hats")',
|
'README.md: Company is listed out of order: "&yet" (should be before "17hats")',
|
||||||
|
@ -43,8 +50,8 @@ describe( 'validation script errors', () => {
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should catch invalid profile links and missing profiles', () => {
|
it( 'should catch invalid profile links and missing profiles', () => {
|
||||||
expect( runValidationWithFixtures( 'bad-profile-links' ) ).to.eql( {
|
expectValidateFixturesResult( 'bad-profile-links', {
|
||||||
exitCode: 4,
|
errorCount: 4,
|
||||||
output: [
|
output: [
|
||||||
'README.md: Invalid link to company "&yet": "company-profiles/and-yet.md"',
|
'README.md: Invalid link to company "&yet": "company-profiles/and-yet.md"',
|
||||||
'README.md: Broken link to company "17hats": "/company-profiles/17hats-nonexistent.md"',
|
'README.md: Broken link to company "17hats": "/company-profiles/17hats-nonexistent.md"',
|
||||||
|
@ -55,8 +62,8 @@ describe( 'validation script errors', () => {
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should catch invalid titles in company profiles', () => {
|
it( 'should catch invalid titles in company profiles', () => {
|
||||||
expect( runValidationWithFixtures( 'bad-profile-titles' ) ).to.eql( {
|
expectValidateFixturesResult( 'bad-profile-titles', {
|
||||||
exitCode: 7,
|
errorCount: 7,
|
||||||
output: [
|
output: [
|
||||||
'10up.md: Expected 1 first-level heading but found 0',
|
'10up.md: Expected 1 first-level heading but found 0',
|
||||||
'10up.md: The main title is wrapped inside of another element.',
|
'10up.md: The main title is wrapped inside of another element.',
|
||||||
|
@ -70,8 +77,8 @@ describe( 'validation script errors', () => {
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should catch orphaned company profiles', () => {
|
it( 'should catch orphaned company profiles', () => {
|
||||||
expect( runValidationWithFixtures( 'orphaned-profiles' ) ).to.eql( {
|
expectValidateFixturesResult( 'orphaned-profiles', {
|
||||||
exitCode: 1,
|
errorCount: 1,
|
||||||
output: [
|
output: [
|
||||||
'18f.md: No link to company profile from readme',
|
'18f.md: No link to company profile from readme',
|
||||||
],
|
],
|
||||||
|
@ -79,8 +86,8 @@ describe( 'validation script errors', () => {
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should catch invalid section headings', () => {
|
it( 'should catch invalid section headings', () => {
|
||||||
expect( runValidationWithFixtures( 'bad-profile-headings' ) ).to.eql( {
|
expectValidateFixturesResult( 'bad-profile-headings', {
|
||||||
exitCode: 10,
|
errorCount: 10,
|
||||||
output: [
|
output: [
|
||||||
'10up.md: Required section "Company blurb" not found.',
|
'10up.md: Required section "Company blurb" not found.',
|
||||||
'17hats.md: Invalid section: "A thing I made up". Expected one of: ["Company blurb","Company size","Remote status","Region","Company technologies","Office locations","How to apply"]',
|
'17hats.md: Invalid section: "A thing I made up". Expected one of: ["Company blurb","Company size","Remote status","Region","Company technologies","Office locations","How to apply"]',
|
||||||
|
@ -97,8 +104,8 @@ describe( 'validation script errors', () => {
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should catch text outside of links in readme', () => {
|
it( 'should catch text outside of links in readme', () => {
|
||||||
expect( runValidationWithFixtures( 'name-outside-link' ) ).to.eql( {
|
expectValidateFixturesResult( 'name-outside-link', {
|
||||||
exitCode: 3,
|
errorCount: 3,
|
||||||
output: [
|
output: [
|
||||||
'README.md: Extra text in company name: "10up", "10up agency"',
|
'README.md: Extra text in company name: "10up", "10up agency"',
|
||||||
'README.md: Extra text in company name: "Aerolab", "Aerolab more text"',
|
'README.md: Extra text in company name: "Aerolab", "Aerolab more text"',
|
||||||
|
@ -108,14 +115,16 @@ describe( 'validation script errors', () => {
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should catch mismatched "incomplete profile" indicators', () => {
|
it( 'should catch mismatched "incomplete profile" indicators', () => {
|
||||||
expect( runValidationWithFixtures( 'mismatched-incomplete-indicators' ) ).to.eql( {
|
expectValidateFixturesResult( 'mismatched-incomplete-indicators', {
|
||||||
exitCode: 7,
|
errorCount: 7,
|
||||||
output: [
|
output: [
|
||||||
'10up.md: Profile is marked as complete, but it only contains a "Company blurb" heading.',
|
'10up.md: Profile is marked as complete, but it only contains a "Company blurb" heading.',
|
||||||
'17hats.md: Profile looks complete, but the "Company blurb" contains a warning emoji.',
|
'17hats.md: Profile looks complete, but the "Company blurb" contains a warning emoji.',
|
||||||
'18f.md: Profile looks incomplete, but the main readme does not contain a warning emoji.',
|
'18f.md: Profile looks incomplete, but the main readme does not contain a warning emoji.',
|
||||||
'45royale.md: Profile is marked as incomplete, but it contains multiple sections.',
|
(
|
||||||
'45royale.md: Please remove the warning emoji from the "Company blurb" section and the main readme.',
|
'45royale.md: Profile is marked as incomplete, but it contains multiple sections.'
|
||||||
|
+ '\nPlease remove the warning emoji from the "Company blurb" section and the main readme.'
|
||||||
|
),
|
||||||
'aerolab.md: Profile looks incomplete, but the "Company blurb" does not contain a warning emoji.',
|
'aerolab.md: Profile looks incomplete, but the "Company blurb" does not contain a warning emoji.',
|
||||||
'and-yet.md: Profile looks complete, but the main readme contains a warning emoji.',
|
'and-yet.md: Profile looks complete, but the main readme contains a warning emoji.',
|
||||||
'angularclass.md: Profile looks incomplete, but the "Company blurb" does not contain a warning emoji.',
|
'angularclass.md: Profile looks incomplete, but the "Company blurb" does not contain a warning emoji.',
|
||||||
|
|
|
@ -1,29 +0,0 @@
|
||||||
const { expect } = require( 'chai' );
|
|
||||||
|
|
||||||
const { runValidationWithFixtures } = require( './lib' );
|
|
||||||
|
|
||||||
describe( 'validation script ok', () => {
|
|
||||||
it( 'should pass with valid data', () => {
|
|
||||||
expect( runValidationWithFixtures( 'valid' ) ).to.eql( {
|
|
||||||
exitCode: 0,
|
|
||||||
output: [],
|
|
||||||
} );
|
|
||||||
} );
|
|
||||||
|
|
||||||
it( 'should pass with valid data and incomplete profiles, and count headings', () => {
|
|
||||||
const env = { REPORT_PROFILE_HEADINGS: 'y' };
|
|
||||||
expect( runValidationWithFixtures( 'valid-incomplete', env ) ).to.eql( {
|
|
||||||
exitCode: 0,
|
|
||||||
output: [
|
|
||||||
'Profile headings by count (7 total profiles):',
|
|
||||||
'Company blurb: 7',
|
|
||||||
'Company size: 4',
|
|
||||||
'Remote status: 4',
|
|
||||||
'Region: 4',
|
|
||||||
'Company technologies: 3',
|
|
||||||
'How to apply: 3',
|
|
||||||
'Office locations: 1',
|
|
||||||
],
|
|
||||||
} );
|
|
||||||
} );
|
|
||||||
} );
|
|
49
test/validation-script.js
Normal file
49
test/validation-script.js
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
const { expect } = require( 'chai' );
|
||||||
|
|
||||||
|
const { runValidationScriptWithFixtures } = require( './lib' );
|
||||||
|
|
||||||
|
describe( 'validation script (integration tests)', () => {
|
||||||
|
it( 'should pass with valid data', () => {
|
||||||
|
expect( runValidationScriptWithFixtures( 'valid' ) ).to.eql( {
|
||||||
|
exitCode: 0,
|
||||||
|
output: [],
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should pass with valid data and incomplete profiles, and count headings', () => {
|
||||||
|
const env = { REPORT_PROFILE_HEADINGS: 'y' };
|
||||||
|
expect( runValidationScriptWithFixtures( 'valid-incomplete', env ) ).to.eql( {
|
||||||
|
exitCode: 0,
|
||||||
|
output: [
|
||||||
|
'Profile headings by count (7 total profiles):',
|
||||||
|
'Company blurb: 7',
|
||||||
|
'Company size: 4',
|
||||||
|
'Remote status: 4',
|
||||||
|
'Region: 4',
|
||||||
|
'Company technologies: 3',
|
||||||
|
'How to apply: 3',
|
||||||
|
'Office locations: 1',
|
||||||
|
],
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should catch unsorted company names, and count headings', () => {
|
||||||
|
const env = { REPORT_PROFILE_HEADINGS: 'y' };
|
||||||
|
expect( runValidationScriptWithFixtures( 'unsorted', env ) ).to.eql( {
|
||||||
|
exitCode: 2,
|
||||||
|
output: [
|
||||||
|
'README.md: Company is listed out of order: "17hats" (should be before "18F")',
|
||||||
|
'README.md: Company is listed out of order: "&yet" (should be before "17hats")',
|
||||||
|
'',
|
||||||
|
'Profile headings by count (4 total profiles):',
|
||||||
|
'Company blurb: 4',
|
||||||
|
'Company size: 4',
|
||||||
|
'Remote status: 4',
|
||||||
|
'Region: 4',
|
||||||
|
'Company technologies: 4',
|
||||||
|
'Office locations: 3',
|
||||||
|
'How to apply: 4',
|
||||||
|
],
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
Loading…
Reference in a new issue