diff --git a/packages/core-test-kit/README.md b/packages/core-test-kit/README.md index b2b10bd8b..e56e4d911 100644 --- a/packages/core-test-kit/README.md +++ b/packages/core-test-kit/README.md @@ -2,117 +2,126 @@ [![npm version](https://img.shields.io/npm/v/@stylable/core-test-kit.svg)](https://www.npmjs.com/package/stylable/core-test-kit) -`@stylable/core-test-kit` is a collection of utilities aimed at making testing Stylable core behavior and functionality easier. +## Inline expectations syntax -## What's in this test-kit? +The inline expectation syntax can be used with `testInlineExpects` for testing stylesheets transformation and diagnostics. -### Matchers +An expectation is written as a comment just before the code it checks on. All expectations support `label` that will be thrown as part of an expectation fail message. -An assortment of `Chai` matchers used by Stylable. +### `@rule` - check rule transformation including selector and nested declarations: -- `flat-match` - flattens and matches passed arguments -- `results` - test Stylable transpiled style rules output - -### Diagnostics tooling - -A collection of tools used for testing Stylable diagnostics messages (warnings and errors). - -- `expectAnalyzeDiagnostics` - processes a Stylable input and checks for diagnostics during processing -- `expectTransformDiagnostics` - checks for diagnostics after a full transformation -- `shouldReportNoDiagnostics` - helper to check no diagnostics were reported - -### Testing infrastructure - -Used for setting up Stylable instances (`processor`/`transformer`) and their infrastructure: +Selector - `@rule SELECTOR` +```css +/* @rule .entry__root::before */ +.root::before {} +``` -- `generateInfra` - create Stylable basic in memory infrastructure (`resolver`, `requireModule`, `fileProcessor`) -- `generateStylableResult` - genetare transformation results from in memory configuration -- `generateStylableRoot` - helper over `generateStylableResult` that returns the `outputAst` -- `generateStylableExports` - helper over `generateStylableResult` that returns the `exports` mapping +Declarations - `@rule SELECTOR { decl: val; }` +```css +/* @rule .entry__root { color: red } */ +.root { color: red; } -### `testInlineExpects` utility +/* @rule .entry__root { + color: red; + background: green; +}*/ +.root { + color: red; + background: green; +} +``` -Exposes `testInlineExpects` for testing transformed stylesheets that include inline expectation comments. These are the most common type of core tests and the recommended way of testing the core functionality. +Target generated rules (mixin) - ` @rule[OFFSET] SELECTOR` +```css +.mix { + color: red; +} +.mix:hover { + color: green; +} +/* + @rule .entry__root {color: red;} + @rule[1] .entry__root:hover {color: green;} +*/ +.root { + -st-mixin: mix; +} +``` -#### Supported checks: +Label - `@rule(LABEL) SELECTOR` +```css +/* @rule(expect 1) .entry__root */ +.root {} -Rule checking (place just before rule) supporting multi-line declarations and multiple `@checks` statements +/* @rule(expect 2) .entry__part */ +.part {} +``` -##### Terminilogy -- `LABEL: ` - label for the test expectation -- `OFFEST: ` - offest for the tested rule after the `@check` -- `SELECTOR: ` - output selector -- `DECL: ` - declaration name -- `VALUE: ` - declaration value +### `@atrule` - check at-rule transformation of params: -Full options: +AtRule params - `@atrule PARAMS`: ```css -/* @check(LABEL)[OFFEST] SELECTOR {DECL: VALUE} */ +/* @atrule screen and (min-width: 900px) */ +@media value(smallScreen) {} ``` -Basic - `@check SELECTOR` -```css -/* @check header::before */ -header::before {} +Label - `@atrule(LABEL) PARAMS` +```css +/* @atrule(jump keyframes) entry__jump */ +@keyframes jump {} ``` -With declarations - ` @check SELECTOR {DECL1: VALUE1; DECL2: VALUE2;}` +### `@decl` - check declaration transformation -This will check full match and order. -```css -.my-mixin { - color: red; +Prop & value - `@decl PROP: VALUE` +```css +.root { + /* @decl color: red */ + color: red } +``` -/* @check .entry__container {color: red;} */ -.container { - -st-mixin: my-mixin; +Label - `@decl(LABEL) PROP: VALUE` +```css +.root { + /* @decl(color is red) color: red */ + color: red; } ``` -Target generated rules (mixin) - ` @check[OFFEST] SELECTOR` +### `@analyze` & `@transform` - check single file (analyze) and multiple files (transform) diagnostics: + +Severity - `@analyze-SEVERITY MESSAGE` / `@transform-SEVERITY MESSAGE` ```css -.my-mixin { - color: blue; -} -/* - @check[1] .entry__container:hover {color: blue;} -*/ -.container { - -st-mixin: my-mixin; +/* @analyze-info found deprecated usage */ +@st-global-custom-property --x; + +/* @analyze-warn missing keyframes name */ +@keyframes {} + +/* @analyze-error invalid functional id */ +#id() {} + +.root { + /* @transform-error unresolved "unknown" build variable */ + color: value(unknown); } ``` -Support atrule params (anything between the @atrule and body or semicolon): +Word - `@analyze-SEVERITY word(TEXT) MESSAGE` / `@transform-SEVERITY word(TEXT) MESSAGE` ```css -/* @check screen and (min-width: 900px) */ -@media value(smallScreen) {} -``` -#### Example -Here we are generating a Stylable AST which lncludes the `/* @check SELECTOR */` comment to test the root class selector target. - -The `testInlineExpects` function performs that actual assertions to perform the test. - -```ts -it('...', ()=>{ - const root = generateStylableRoot({ - entry: `/style.st.css`, - files: { - '/style.st.css': { - namespace: 'ns', - content: ` - /* @check .ns__root */ - .root {} - ` - }, - }); - testInlineExpects(root, 1); -}) +/* @transform-warn word(unknown) unknown pseudo element */ +.root::unknown {} ``` -### Match rules +Label - `@analyze(LABEL) MESSAGE` / `@transform(LABEL) MESSAGE` +```css +/* @analyze-warn(local keyframes) missing keyframes name */ +@keyframes {} -Exposes two utility functions (`matchRuleAndDeclaration` and `matchAllRulesAndDeclarations`) used for testing Stylable generated AST representing CSS rules and declarations. +/* @transform-warn(imported keyframes) unresolved keyframes "unknown" */ +@keyframes unknown {} +``` ## License diff --git a/packages/core-test-kit/src/diagnostics.ts b/packages/core-test-kit/src/diagnostics.ts index 492f83f3c..8cf7d95c6 100644 --- a/packages/core-test-kit/src/diagnostics.ts +++ b/packages/core-test-kit/src/diagnostics.ts @@ -26,6 +26,117 @@ export interface Location { css: string; } +interface MatchState { + matches: number; + location: string; + word: string; + severity: string; +} +const createMatchDiagnosticState = (): MatchState => ({ + matches: 0, + location: ``, + word: ``, + severity: ``, +}); +const isSupportedSeverity = (val: string): val is DiagnosticType => !!val.match(/info|warn|error/); +export function matchDiagnostic( + type: `analyze` | `transform`, + meta: Pick, + expected: { + label?: string; + message: string; + severity: string; + location: Location; + }, + errors: { + diagnosticsNotFound: (type: string, message: string, label?: string) => string; + unsupportedSeverity: (type: string, severity: string, label?: string) => string; + locationMismatch: (type: string, message: string, label?: string) => string; + wordMismatch: ( + type: string, + expectedWord: string, + message: string, + label?: string + ) => string; + severityMismatch: ( + type: string, + expectedSeverity: string, + actualSeverity: string, + message: string, + label?: string + ) => string; + expectedNotFound: (type: string, message: string, label?: string) => string; + } +): string { + const diagnostics = type === `analyze` ? meta.diagnostics : meta.transformDiagnostics; + if (!diagnostics) { + return errors.diagnosticsNotFound(type, expected.message, expected.label); + } + const expectedSeverity = + (expected.severity as any) === `warn` ? `warning` : expected.severity || ``; + if (!isSupportedSeverity(expectedSeverity)) { + return errors.unsupportedSeverity(type, expected.severity || ``, expected.label); + } + let closestMatchState = createMatchDiagnosticState(); + const foundPartialMatch = (newState: MatchState) => { + if (newState.matches >= closestMatchState.matches) { + closestMatchState = newState; + } + }; + for (const report of diagnostics.reports.values()) { + const matchState = createMatchDiagnosticState(); + if (report.message !== expected.message) { + foundPartialMatch(matchState); + continue; + } + matchState.matches++; + // if (!expected.skipLocationCheck) { + // ToDo: test all range + if (report.node.source!.start!.offset !== expected.location.start!.offset) { + matchState.location = errors.locationMismatch(type, expected.message, expected.label); + foundPartialMatch(matchState); + continue; + } + matchState.matches++; + // } + if (expected.location.word) { + if (report.options.word !== expected.location.word) { + matchState.word = errors.wordMismatch( + type, + expected.location.word, + expected.message, + expected.label + ); + foundPartialMatch(matchState); + continue; + } + matchState.matches++; + } + if (expected.severity) { + if (report.type !== expectedSeverity) { + matchState.location = errors.severityMismatch( + type, + expectedSeverity, + report.type, + expected.message, + expected.label + ); + foundPartialMatch(matchState); + continue; + } + matchState.matches++; + } + // expected matched! + return ``; + } + return ( + closestMatchState.location || + closestMatchState.word || + closestMatchState.severity || + errors.expectedNotFound(type, expected.message, expected.label) + ); +} + export function findTestLocations(css: string) { let line = 1; let column = 1; diff --git a/packages/core-test-kit/src/inline-expectation.ts b/packages/core-test-kit/src/inline-expectation.ts index 820b3dd9a..5026bc897 100644 --- a/packages/core-test-kit/src/inline-expectation.ts +++ b/packages/core-test-kit/src/inline-expectation.ts @@ -1,19 +1,31 @@ +import { matchDiagnostic } from './diagnostics'; +import type { StylableMeta } from '@stylable/core'; import type * as postcss from 'postcss'; -interface RuleCheck { - kind: `rule`; - rule: postcss.Rule; - msg?: string; - expectedSelector: string; - expectedDeclarations: [string, string][]; - declarationCheck: 'full' | 'none'; +interface Test { + type: TestScopes; + expectation: string; + errors: string[]; } -interface AtRuleCheck { - kind: `atrule`; - rule: postcss.AtRule; - msg?: string; - expectedParams: 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; } +const isRoot = (val: any): val is postcss.Root => val.type === `root`; /** * Test transformed stylesheets inline expectation comments @@ -47,161 +59,385 @@ interface AtRuleCheck { * support atrule params (anything between the @atrule and body or semicolon) * @check screen and (min-width: 900px) */ -export function testInlineExpects( - result: postcss.Root, - expectedTestsCount = result.toString().match(/@check/gm)!.length -) { - if (expectedTestsCount === 0) { - throw new Error('no tests found try to add @check comments before any selector'); - } - const checks: Array = []; +export function testInlineExpects(result: postcss.Root | Context, expectedTestInput?: number) { + // backward compatibility (no diagnostic checks) + 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; + // ToDo: support analyze mode + const rootAst = context.meta.outputAst!; + const expectedTestAmount = + expectedTestInput ?? + (rootAst.toString().match(new RegExp(`${testScopesRegex()}`, `gm`))?.length || 0); + const checks: Test[] = []; const errors: string[] = []; - // collect checks - result.walkComments((comment) => { - const checksInput = comment.text.split(`@check`); - const rule = comment.next(); - if (checksInput.length > 1 && rule) { - if (rule.type === `rule`) { - for (const checkInput of checksInput) { - if (checkInput.trim()) { - const check = createRuleCheck(rule, checkInput, errors); - if (check) { - checks.push(check); + 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; + 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, ``); + // collect expectation inner `@` fragments + while ( + input.length && + !(`@` + input[0]).match(new RegExp(`^(${testScopesRegex()})`, `g`)) + ) { + testInput += `@` + input.shift(); + } + if (testInput) { + if ( + isDeprecatedInput && + (testScope === `@analyze` || testScope === `@transform`) + ) { + // not possible with just AST root + const result: Test = { + 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(), + nodeTarget, + nodeSrc + ); + result.type = testScope; + errors.push(...result.errors); + checks.push(result); } } } } - if (rule.type === `atrule`) { - if (checksInput.length > 2) { - errors.push(testInlineExpectsErrors.atRuleMultiTest(comment.text)); - } - const check = createAtRuleCheck(rule, checksInput[1]); - if (check) { - checks.push(check); - } - } - } - }); - // check - checks.forEach((check) => { - if (check.kind === `rule`) { - const { msg, rule, expectedSelector, expectedDeclarations, declarationCheck } = check; - const prefix = msg ? msg + `: ` : ``; - if (rule.selector !== expectedSelector) { - errors.push( - testInlineExpectsErrors.selector(expectedSelector, rule.selector, prefix) - ); - } - if (declarationCheck === `full`) { - const actualDecl = rule.nodes.map((x) => x.toString()).join(`; `); - const expectedDecl = expectedDeclarations - .map(([prop, value]) => `${prop}: ${value}`) - .join(`; `); - if (actualDecl !== expectedDecl) { - errors.push( - testInlineExpectsErrors.declarations( - expectedDecl, - actualDecl, - rule.selector, - prefix - ) - ); - } - } - } else if (check.kind === `atrule`) { - const { msg, rule, expectedParams } = check; - const prefix = msg ? msg + `: ` : ``; - if (rule.params !== expectedParams) { - errors.push( - testInlineExpectsErrors.atruleParams(expectedParams, rule.params, prefix) - ); - } } }); // report errors if (errors.length) { throw new Error(testInlineExpectsErrors.combine(errors)); } - if (expectedTestsCount !== checks.length) { - throw new Error(testInlineExpectsErrors.matchAmount(expectedTestsCount, checks.length)); + if (expectedTestAmount !== checks.length) { + throw new Error(testInlineExpectsErrors.matchAmount(expectedTestAmount, checks.length)); } } -function createRuleCheck( - rule: postcss.Rule, - expectInput: string, - errors: string[] -): RuleCheck | undefined { - const { msg, ruleIndex, expectedSelector, expectedBody } = expectInput.match( +function checkTest(context: Context, expectation: string, targetNode: AST, srcNode: AST): Test { + const 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: Context, expectation: string, targetNode: AST, _srcNode: AST): Test { + const result: Test = { + type: `@rule`, + expectation, + errors: [], + }; + const { msg, ruleIndex, expectedSelector, expectedBody } = expectation.match( /(?\(.*\))*(\[(?\d+)\])*(?[^{}]*)\s*(?.*)/s )!.groups!; - const targetRule = ruleIndex ? getNextMixinRule(rule, Number(ruleIndex)) : rule; - if (!targetRule) { - errors.push(testInlineExpectsErrors.unfoundMixin(expectInput)); - return; + let testNode: AST = targetNode; + // get mixed-in rule + 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 expectedDeclarations: RuleCheck[`expectedDeclarations`] = []; - const declsInput = expectedBody.trim().match(/^{(.*)}$/s); - const declarationCheck: RuleCheck[`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 { - errors.push(testInlineExpectsErrors.malformedDecl(decl, expectInput)); + // test by target node type + const nodeType = testNode?.type; + if (nodeType === `rule`) { + const expectedDeclarations: [string, string][] = []; + const declsInput = expectedBody.trim().match(/^{(.*)}$/s); + const declarationCheck: 'full' | 'none' = 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) + ); + } } } } + const prefix = msg ? msg + `: ` : ``; + 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`) { + // passing null to srcNode as atruleTest doesn't actually requires it. + // if it would at some point, then its just a matter of searching the rawAst for it. + return atRuleTest( + context, + expectation.replace(`[${ruleIndex}]`, ``), + testNode, + null as unknown as AST + ); + } else { + // unsupported mixed-in node test + result.errors.push(testInlineExpectsErrors.unsupportedMixinNode(testNode.type)); } - return { - kind: `rule`, - msg, - rule: targetRule, - expectedSelector: expectedSelector.trim(), - expectedDeclarations, - declarationCheck, - }; + return result; } -function createAtRuleCheck(rule: postcss.AtRule, expectInput: string): AtRuleCheck | undefined { - const { msg, expectedParams } = expectInput.match(/(?\([^)]*\))*(?.*)/)! +function atRuleTest(_context: Context, expectation: string, targetNode: AST, _srcNode: AST): Test { + const result: Test = { + type: `@atrule`, + expectation, + errors: [], + }; + const { msg, expectedParams } = expectation.match(/(?\([^)]*\))*(?.*)/)! .groups!; - return { - kind: `atrule`, - msg, - rule, - expectedParams: expectedParams.trim(), + if (expectedParams.match(/^\[\d+\]/)) { + result.errors.push(testInlineExpectsErrors.atRuleMultiTest(expectation)); + return result; + } + const prefix = msg ? msg + `: ` : ``; + 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: Context, expectation: string, targetNode: AST, _srcNode: AST): Test { + const result: Test = { + type: `@decl`, + expectation, + errors: [], + }; + let { label, prop, value } = expectation.match( + /(?