import { spawn , ChildProcess } from 'child_process' ; import kill from 'tree-kill' ; import { v4 as uuidv4 } from 'uuid' ; import * as core from '@actions/core' ; import { setCheckRunOutput } from './output' ; import * as os from 'os' ; import chalk from 'chalk' ; const color = new chalk . Instance ( { level : 1 } ) ; export type TestComparison = 'exact' | 'included' | 'regex' ; export interface Test { readonly name : string ; readonly setup : string ; readonly run : string ; readonly input ? : string ; readonly output ? : string ; readonly timeout : number ; readonly points ? : number ; readonly comparison : TestComparison ; } export class TestError extends Error { constructor ( message ) { super ( message ) ; Error . captureStackTrace ( this , TestError ) ; } } export class TestTimeoutError extends TestError { constructor ( message ) { super ( message ) ; Error . captureStackTrace ( this , TestTimeoutError ) ; } } export class TestOutputError extends TestError { expected : string ; actual : string ; constructor ( message , expected , actual ) { super ( `${ message }\nExpected:\n${ expected }\nActual:\n${ actual }` ) ; this . expected = expected ; this . actual = actual ; Error . captureStackTrace ( this , TestOutputError ) ; } } const log = ( text ) : void => { process . stdout . write ( text + os . EOL ) ; } ; const normalizeLineEndings = ( text ) : string => { return text . replace ( /\r\n/gi , '\n' ) . trim ( ) ; } ; const indent = ( text ) : string => { let str = '' + new String ( text ) ; str = str . replace ( /\r\n/gim , '\n' ) . replace ( /\n/gim , '\n  ' ) ; return str ; } ; const waitForExit = async ( child , timeout ) : Promise < void > => { return new Promise ( ( resolve , reject ) => { let timedOut = false ; const exitTimeout = setTimeout ( ( ) => { timedOut = true ; reject ( new TestTimeoutError ( `Setup timed out in ${ timeout } milliseconds` ) ) ; kill ( child . pid ) ; } , timeout ) ; child . once ( 'exit' , ( code , signal ) => { if ( timedOut ) return ; clearTimeout ( exitTimeout ) ; if ( code === 0 ) { resolve ( undefined ) ; } else { reject ( new TestError ( `Error: Exit with code: ${ code } and signal: ${ signal }` ) ) ; } } ) ; child . once ( 'error' , ( error ) => { if ( timedOut ) return ; clearTimeout ( exitTimeout ) ; reject ( error ) ; } ) ; } ) ; } ; const runSetup = async ( test , cwd , timeout ) : Promise < void > => { if ( ! test . setup || test . setup === '' ) { return ; } const setup = spawn ( test . setup , { cwd , shell : true , env : { PATH : process . env [ 'PATH' ] , FORCE_COLOR : 'true' , } , } ) ; process . stdout . write ( indent ( '\n' ) ) ; setup . stdout . on ( 'data' , chunk => { process . stdout . write ( indent ( chunk ) ) ; } ) ; setup . stderr . on ( 'data' , chunk => { process . stderr . write ( indent ( chunk ) ) ; } ) ; await waitForExit ( setup , timeout ) ; } ; const runCommand = async ( test , cwd , timeout ) : Promise < void > => { const child = spawn ( test . run , { cwd , shell : true , env : { PATH : process . env [ 'PATH' ] , FORCE_COLOR : 'true' , } , } ) ; let output = '' ; process . stdout . write ( indent ( '\n' ) ) ; child . stdout . on ( 'data' , chunk => { process . stdout . write ( indent ( chunk ) ) ; output += chunk ; } ) ; child . stderr . on ( 'data' , chunk => { process . stderr . write ( indent ( chunk ) ) ; } ) ; if ( test . input && test . input !== '' ) { child . stdin . write ( test . input ) ; child . stdin . end ( ) ; } await waitForExit ( child , timeout ) ; if ( ( ! test . output || test . output == '' ) && ( ! test . input || test . input == '' ) ) { return ; } const expected = normalizeLineEndings ( test . output || '' ) ; const actual = normalizeLineEndings ( output ) ; switch ( test . comparison ) { case 'exact' : if ( actual != expected ) { throw new TestOutputError ( `The output for test ${ test . name } did not match` , expected , actual ) ; } break ; case 'regex' : if ( ! actual . match ( new RegExp ( test . output || '' ) ) ) { throw new TestOutputError ( `The output for test ${ test . name } did not match` , test . output || '' , actual ) ; } break ; default : if ( ! actual . includes ( expected ) ) { throw new TestOutputError ( `The output for test ${ test . name } did not match` , expected , actual ) ; } break ; } } ; export const run = async ( test , cwd ) : Promise < void > => { let timeout = ( test . timeout || 1 ) * 60 * 1000 || 30000 ; const start = process . hrtime ( ) ; await runSetup ( test , cwd , timeout ) ; const elapsed = process . hrtime ( start ) ; timeout -= Math . floor ( elapsed [ 0 ] * 1000 + elapsed [ 1 ] / 1000000 ) ; await runCommand ( test , cwd , timeout ) ; } ; export const runAll = async ( tests , cwd ) : Promise < void > => { let points = 0 ; let availablePoints = 0 ; let hasPoints = false ; const token = uuidv4 ( ) ; log ( '' ) ; log ( `::stop-commands::${ token }` ) ; log ( '' ) ; let failed = false ; for ( const test of tests ) { try { if ( test . points ) { hasPoints = true ; availablePoints += test . points ; } log ( color . cyan ( `📝 ${ test . name }` ) ) ; log ( '' ) ; await run ( test , cwd ) ; log ( '' ) ; log ( color . green ( `✅ ${ test . name }` ) ) ; log ( `` ) ; if ( test . points ) { points += test . points ; } } catch ( error ) { failed = true ; log ( '' ) ; log ( color . red ( `❌ ${ test . name }` ) ) ; core . setFailed ( error . message ) ; } } log ( '' ) ; log ( `::${ token }::` ) ; if ( failed ) { } else { log ( '' ) ; log ( color . green ( 'All tests passed' ) ) ; log ( '' ) ; log ( '✨🌟💖💎🦄💎💖🌟✨🌟💖💎🦄💎💖🌟✨' ) ; log ( '' ) ; } if ( hasPoints ) { const text = `Points ${ points }/${ availablePoints }` ; log ( color . bold . bgCyan . black ( text ) ) ; core . setOutput ( 'Points' , `${ points }/${ availablePoints }` ) ; await setCheckRunOutput ( text ) ; } } ;