import { matchDiagnostic } from './diagnostics' ; import type { StylableMeta } from '@stylable/core' ; import type * as postcss from 'postcss' ; interface Test { type : TestScopes ; expectation : string ; errors : string [ ] ; } type AST = postcss . Rule | postcss . AtRule | postcss . Declaration ; const tests = { '@check' : checkTest , '@rule' : ruleTest , '@atrule' : atRuleTest , '@decl' : declTest , '@analyze' : analyzeTest , '@transform' : transformTest , } as const ; type TestScopes = keyof typeof tests ; const testScopes = Object . keys ( tests ) as TestScopes [ ] ; const testScopesRegex = ( ) => testScopes . join ( `|` ) ; interface Context { meta : Pick < StylableMeta , 'outputAst' | 'rawAst' | 'diagnostics' | 'transformDiagnostics' > ; } const isRoot = ( val ) : val is postcss . Root => val . type === `root` ; check check check check check check check check atrule check export function testInlineExpects ( result , expectedTestInput ? ) { const isDeprecatedInput = isRoot ( result ) ; const context = isDeprecatedInput ? { meta : { outputAst : result , rawAst : null as unknown as StylableMeta [ 'rawAst' ] , diagnostics : null as unknown as StylableMeta [ 'diagnostics' ] , transformDiagnostics : null as unknown as StylableMeta [ 'transformDiagnostics' ] , } , } : result ; const rootAst = context . meta . outputAst ! ; const expectedTestAmount = expectedTestInput ?? ( rootAst . toString ( ) . match ( new RegExp ( `${ testScopesRegex ( ) }` , `gm` ) ) ?. length || 0 ) ; const checks = [ ] ; const errors = [ ] ; rootAst . walkComments ( ( comment ) => { const input = comment . text . split ( /@/gm ) ; const testCommentTarget = comment ; const testCommentSrc = isDeprecatedInput ? comment : getSourceComment ( context . meta , comment ) || comment ; const nodeTarget = testCommentTarget . next ( ) as AST ; const nodeSrc = testCommentSrc . next ( ) as AST ; const isRemoved = isRemovedFromTarget ( nodeTarget , nodeSrc ) ; if ( nodeTarget || nodeSrc ) { while ( input . length ) { const next = `@` + input . shift ( ) ! ; const testMatch = next . match ( new RegExp ( `^(${ testScopesRegex ( ) })` , `g` ) ) ; if ( testMatch ) { const testScope = testMatch [ 0 ] as TestScopes ; let testInput = next . replace ( testScope , `` ) ; while ( input . length && ! ( `@` + input [ 0 ] ) . match ( new RegExp ( `^(${ testScopesRegex ( ) })` , `g` ) ) ) { testInput += `@` + input . shift ( ) ; } if ( testInput ) { if ( isDeprecatedInput && ( testScope === `@analyze` || testScope === `@transform` ) ) { const result = { type : testScope , expectation : testInput . trim ( ) , errors : [ testInlineExpectsErrors . deprecatedRootInputNotSupported ( testScope + testInput ) , ] , } ; errors . push ( ... result . errors ) ; checks . push ( result ) ; } else { const result = tests [ testScope ] ( context , testInput . trim ( ) , isRemoved ? undefined : nodeTarget , nodeSrc ) ; result . type = testScope ; errors . push ( ... result . errors ) ; checks . push ( result ) ; } } } } } } ) ; if ( errors . length ) { throw new Error ( testInlineExpectsErrors . combine ( errors ) ) ; } if ( expectedTestAmount !== checks . length ) { throw new Error ( testInlineExpectsErrors . matchAmount ( expectedTestAmount , checks . length ) ) ; } } function checkTest ( context , expectation , targetNode , srcNode ) { const type = srcNode ?. type || targetNode ?. type ; switch ( type ) { case `rule` : { return tests [ `@rule` ] ( context , expectation , targetNode , srcNode ) ; } case `atrule` : { return tests [ `@atrule` ] ( context , expectation , targetNode , srcNode ) ; } default : return { type : `@check` , expectation , errors : [ testInlineExpectsErrors . unsupportedNode ( `@check` , type ) ] , } ; } } function ruleTest ( context , expectation , targetNode , srcNode ) { const result = { type : `@rule` , expectation , errors : [ ] , } ; const { msg , ruleIndex , expectedSelector , expectedBody } = expectation . match ( /(?<msg>\([^)]*\))*(\[(?<ruleIndex>\d+)\])*(?<expectedSelector>[^{}]*)\s*(?<expectedBody>.*)/s ) ! . groups ! ; const prefix = msg ? msg + `: ` : `` ; if ( ! targetNode ) { result . errors . push ( testInlineExpectsErrors . removedNode ( srcNode . type , prefix ) ) ; return result ; } let testNode = targetNode ; if ( ruleIndex ) { if ( targetNode ?. type !== `rule` ) { result . errors . push ( `mixed-in expectation is only supported for CSS Rule, not ${ targetNode ?. type }` ) ; return result ; } else { const actualTarget = getNextMixinRule ( targetNode , Number ( ruleIndex ) ) ; if ( ! actualTarget ) { result . errors . push ( testInlineExpectsErrors . unfoundMixin ( expectation ) ) ; return result ; } testNode = actualTarget as AST ; } } const nodeType = testNode ?. type ; if ( nodeType === `rule` ) { const expectedDeclarations = [ ] ; const declsInput = expectedBody . trim ( ) . match ( /^{(.*)}$/s ) ; const declarationCheck = declsInput ? `full` : `none` ; if ( declsInput && declsInput [ 1 ] ?. includes ( `:` ) ) { for ( const decl of declsInput [ 1 ] . split ( `;` ) ) { if ( decl . trim ( ) !== `` ) { const [ prop , value ] = decl . split ( ':' ) ; if ( prop && value ) { expectedDeclarations . push ( [ prop . trim ( ) , value . trim ( ) ] ) ; } else { result . errors . push ( testInlineExpectsErrors . ruleMalformedDecl ( decl , expectation ) ) ; } } } } if ( testNode . selector !== expectedSelector . trim ( ) ) { result . errors . push ( testInlineExpectsErrors . selector ( expectedSelector . trim ( ) , testNode . selector , prefix ) ) ; } if ( declarationCheck === `full` ) { const actualDecl = testNode . nodes . map ( ( x ) => x . toString ( ) ) . join ( `; ` ) ; const expectedDecl = expectedDeclarations . map ( ( [ prop , value ] ) => `${ prop }: ${ value }` ) . join ( `; ` ) ; if ( actualDecl !== expectedDecl ) { result . errors . push ( testInlineExpectsErrors . declarations ( expectedDecl , actualDecl , testNode . selector , prefix ) ) ; } } } else if ( nodeType === `atrule` ) { return atRuleTest ( context , expectation . replace ( `[${ ruleIndex }]` , `` ) , testNode , null as unknown as AST ) ; } else { result . errors . push ( testInlineExpectsErrors . unsupportedMixinNode ( testNode . type ) ) ; } return result ; } function atRuleTest ( _context , expectation , targetNode , srcNode ) { const result = { type : `@atrule` , expectation , errors : [ ] , } ; const { msg , expectedParams } = expectation . match ( /(?<msg>\([^)]*\))*(?<expectedParams>.*)/ ) ! . groups ! ; if ( expectedParams . match ( /^\[\d+\]/ ) ) { result . errors . push ( testInlineExpectsErrors . atRuleMultiTest ( expectation ) ) ; return result ; } const prefix = msg ? msg + `: ` : `` ; if ( ! targetNode ) { result . errors . push ( testInlineExpectsErrors . removedNode ( srcNode . type , prefix ) ) ; } else if ( targetNode . type === `atrule` ) { if ( targetNode . params !== expectedParams . trim ( ) ) { result . errors . push ( testInlineExpectsErrors . atruleParams ( expectedParams . trim ( ) , targetNode . params , prefix ) ) ; } } else { result . errors . push ( testInlineExpectsErrors . unsupportedNode ( `@atrule` , targetNode . type ) ) ; } return result ; } function declTest ( _context , expectation , targetNode , srcNode ) { const result = { type : `@decl` , expectation , errors : [ ] , } ; let { label , prop , value } = expectation . match ( /(?<label>\([^)]*\))*(?<prop>[^:]*)\s*:?\s*(?<value>.*)/ ) ! . groups ! ; label = label ? label + `: ` : `` ; prop = prop . trim ( ) ; value = value . trim ( ) ; if ( ! targetNode ) { result . errors . push ( testInlineExpectsErrors . removedNode ( srcNode . type , label ) ) ; } else if ( ! prop || ! value ) { result . errors . push ( testInlineExpectsErrors . declMalformed ( prop , value , label ) ) ; } else if ( targetNode . type === `decl` ) { if ( targetNode . prop !== prop . trim ( ) || targetNode . value !== value ) { const expected = prop . trim ( ) + `: ` + value . trim ( ) ; const actual = targetNode . prop + `: ` + targetNode . value ; result . errors . push ( testInlineExpectsErrors . decl ( expected , actual , label ) ) ; } } else { result . errors . push ( testInlineExpectsErrors . unsupportedNode ( `@decl` , targetNode . type , label ) ) ; } return result ; } function analyzeTest ( context , expectation , targetNode , srcNode ) { return diagnosticTest ( `analyze` , context , expectation , targetNode , srcNode ) ; } function transformTest ( context , expectation , targetNode , srcNode ) { const matchResult = expectation . match ( /-remove(?<label>\([^)]*\))?/ ) ; if ( matchResult ) { const node = srcNode ; let { label } = matchResult . groups ! ; label = label ? label + `: ` : `` ; const isRemoved = ! targetNode || targetNode . source ?. start !== srcNode . source ?. start || targetNode . source ?. end !== srcNode . source ?. end ; return { type : `@transform` , expectation , errors : isRemoved ? [ ] : [ testInlineExpectsErrors . transformRemoved ( node . type , label ) ] , } ; } return diagnosticTest ( `transform` , context , expectation , targetNode , srcNode ) ; } function diagnosticTest ( type , { meta } , expectation , _targetNode , srcNode ) { const result = { type : `@${ type }` , expectation , errors : [ ] , } ; const matchResult = expectation . match ( /-(?<severity>\w+)(?<label>\([^)]*\))?\s?(?:word\((?<word>[^)]*)\))?\s?(?<message>.*)/ ) ; if ( ! matchResult ) { result . errors . push ( testInlineExpectsErrors . diagnosticsMalformed ( type , expectation ) ) ; return result ; } let { label , severity , message , word } = matchResult . groups ! ; label = label ? label + `: ` : `` ; severity = severity ?. trim ( ) || `` ; message = message ?. trim ( ) || `` ; word = word ?. trim ( ) || `` ; if ( ! message ) { result . errors . push ( testInlineExpectsErrors . diagnosticsMalformed ( type , expectation , label ) ) ; return result ; } const error = matchDiagnostic ( type , meta , { label , message , severity , location : { start : srcNode . source ?. start , end : srcNode . source ?. end , word , css : `` , } , } , { diagnosticsNotFound : testInlineExpectsErrors . diagnosticsNotFound , unsupportedSeverity : testInlineExpectsErrors . diagnosticsUnsupportedSeverity , locationMismatch : testInlineExpectsErrors . diagnosticsLocationMismatch , wordMismatch : testInlineExpectsErrors . diagnosticsWordMismatch , severityMismatch : testInlineExpectsErrors . diagnosticsSeverityMismatch , expectedNotFound : testInlineExpectsErrors . diagnosticExpectedNotFound , } ) ; if ( error ) { result . errors . push ( error ) ; } return result ; } function getSourceComment ( meta , { source } ) { let match = undefined ; meta . rawAst . walkComments ( ( srcComment ) => { if ( srcComment . source ?. start ?. offset === source ?. start ?. offset && srcComment . source ?. end ?. offset === source ?. end ?. offset ) { match = srcComment ; return false ; } return ; } ) ; return match ; } function isRemovedFromTarget ( target , source ) { return ( ! target || target . source ?. start !== source . source ?. start || target . source ?. end !== source . source ?. end ) ; } function getNextMixinRule ( originRule , count ) { let current = originRule ; while ( current && count > 0 ) { current = current . next ( ) ; if ( current ?. type !== `comment` ) { count -- ; } } return current && count === 0 ? current : undefined ; } export const testInlineExpectsErrors = { matchAmount : ( expectedAmount , actualAmount ) => `Expected "${ expectedAmount }" checks to run but "${ actualAmount }" were found` , unsupportedNode : ( testType , nodeType , label = `` ) => `${ label }unsupported type "${ testType }" for "${ nodeType }"` , removedNode : ( nodeType , label = `` ) => `${ label }fail to check transformation on removed node with type "${ nodeType }"` , selector : ( expectedSelector , actualSelector , label = `` ) => `${ label }expected "${ actualSelector }" to transform to "${ expectedSelector }"` , declarations : ( expectedDecl , actualDecl , selector , label = `` ) => `${ label }expected ${ selector } to have declaration {${ expectedDecl }}, but got {${ actualDecl }}` , unfoundMixin : ( expectInput ) => `cannot locate mixed-in rule for "${ expectInput }"` , unsupportedMixinNode : ( type ) => `unsupported mixin expectation of type "${ type }"` , ruleMalformedDecl : ( decl , expectInput ) => `error in expectation "${ decl }" of "${ expectInput }"` , atruleParams : ( expectedParams , actualParams , label = `` ) => `${ label }expected "${ actualParams }" to transform to ${ expectedParams }` , atRuleMultiTest : ( comment ) => `atrule mixin is not supported: (${ comment })` , decl : ( expected , actual , label = `` ) => `${ label }expected "${ actual }" to transform to "${ expected }"` , declMalformed : ( expectedProp , expectedLabel , label = `` ) => { if ( ! expectedProp && ! expectedLabel ) { return `${ label }malformed declaration expectation, format should be: "prop: value"` ; } else if ( ! expectedProp ) { return `${ label }malformed declaration expectation missing prop: "???: ${ expectedLabel }"` ; } else { return `${ label }malformed declaration expectation missing value: "${ expectedProp }: ???"` ; } } , deprecatedRootInputNotSupported : ( expectation ) => `"${ expectation }" is not supported for with the used input, try calling testInlineExpects(generateStylableResults())` , transformRemoved : ( nodeType , label = `` ) => `${ label } expected ${ nodeType } to be removed, but it was kept after transform` , diagnosticsMalformed : ( type , expectation , label = `` ) => `${ label }malformed @${ type } expectation "@${ type }${ expectation }". format should be: "@${ type }-[severity] diagnostic message"` , diagnosticsNotFound : ( type , message , label = `` ) => `${ label }${ type } diagnostics not found for "${ message }"` , diagnosticsUnsupportedSeverity : ( type , severity , label = `` ) => `${ label }unsupported @${ type }-[severity]: "${ severity }"` , diagnosticsLocationMismatch : ( type , message , label = `` ) => `${ label }expected "@${ type }-[severity] "${ message }" to be reported in this location, but got it somewhere else` , diagnosticsWordMismatch : ( type , expectedWord , message , label = `` ) => `${ label }expected word in "@${ type }-[severity] word(${ expectedWord }) ${ message }" was not found` , diagnosticsSeverityMismatch : ( type , expectedSeverity , actualSeverity , message , label = `` ) => `${ label }expected ${ type } diagnostic "${ message }" to be reported with "${ expectedSeverity }", but it was reported with "${ actualSeverity }"` , diagnosticExpectedNotFound : ( type , message , label = `` ) => `${ label }no "${ type }" diagnostic found for "${ message }"` , combine : ( errors ) => `\n${ errors . join ( `\n` ) }` , } ;