Skip to content

Commit

Permalink
Unify tool to test stylable core (#2255)
Browse files Browse the repository at this point in the history
feat(core-test-kit) single test function to test core
feat(core-test-kit) support `@transform-remove` 
fix(core-test-kit) fix parenthesis bug in @rule declaration expectation
  • Loading branch information
idoros committed Jan 16, 2022
1 parent f42c52c commit 25e3cfe
Show file tree
Hide file tree
Showing 6 changed files with 523 additions and 13 deletions.
75 changes: 75 additions & 0 deletions packages/core-test-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,75 @@

[![npm version](https://img.shields.io/npm/v/@stylable/core-test-kit.svg)](https://www.npmjs.com/package/stylable/core-test-kit)

## `testStylableCore`

Use `import {testStylableCore} from '@stylable/core-test-kit'` to test core analysis, transformation, diagnostics and symbols. All stylable files are checked for [inline expectations](#inline-expectations-syntax):

**single entry**
```js
// source + inline expectations
const { sheets } = testStylableCore(`
/* @rule .entry__root */
.root {}
`);
// single entry is mapped to `/entry.st.css`
const { meta, exports } = sheets[`/entry.st.css`];
```

**multiple files**
```js
// source + inline expectations
const { sheets } = testStylableCore({
'/entry.st.css': `
@st-import Comp from './comp.st.css';
/* @rule .entry__root .comp__root */
.root Comp {}
`,
'/comp.st.css': `
/* @rule .comp__root */
.root {}
`
});
// sheets results ({meta, exports})
const entryResults = sheets[`/entry.st.css`];
const compResults = sheets[`/comp.st.css`];
```

**stylable config**
```js
testStylableCore({
'/a.st.css': ``,
'/b.st.css': ``,
'/c.st.css': ``,
}, {
entries: [`/b.st.css`, `/c.st.css`] // list of entries to transform (in order)
stylableConfig: {
projectRoot: string, // defaults to `/`
resolveNamespace: (ns: string) => string, // defaults to no change
requireModule: (path: string) => any // defaults to naive CJS eval
filesystem: IFileSystem, // @file-services/types
// ...other stylable configurations
}
});
```

**expose infra**
```js
const { stylable, fs } = testStylableCore(``);

// add a file
fs.writeFileSync(
`/new.st.css`,
`
@st-import [part] from './entry.st.css';
.part {}
`
);
// transform new file
const { meta, exports } = stylable.transform(stylable.process(`/new.st.css`));
```

## Inline expectations syntax

The inline expectation syntax can be used with `testInlineExpects` for testing stylesheets transformation and diagnostics.
Expand Down Expand Up @@ -123,6 +192,12 @@ Label - `@analyze(LABEL) MESSAGE` / `@transform(LABEL) MESSAGE`
@keyframes unknown {}
```

Removed in transformation - `@transform-remove`
```css
/* @transform-remove */
@import X from './x.st.css';
```

## License

Copyright (c) 2019 Wix.com Ltd. All Rights Reserved. Use of this source code is governed by a [MIT license](./LICENSE).
1 change: 1 addition & 0 deletions packages/core-test-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export { matchCSSMatchers } from './matchers/match-css';
export { mediaQuery, styleRules } from './matchers/results';
export { matchAllRulesAndDeclarations, matchRuleAndDeclaration } from './match-rules';
export { testInlineExpects, testInlineExpectsErrors } from './inline-expectation';
export { testStylableCore } from './test-stylable-core';
97 changes: 84 additions & 13 deletions packages/core-test-kit/src/inline-expectation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function testInlineExpects(result: postcss.Root | Context, expectedTestIn
: 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()!;
Expand Down Expand Up @@ -123,7 +124,7 @@ export function testInlineExpects(result: postcss.Root | Context, expectedTestIn
const result = tests[testScope](
context,
testInput.trim(),
nodeTarget,
isRemoved ? undefined : nodeTarget,
nodeSrc
);
result.type = testScope;
Expand All @@ -144,8 +145,13 @@ export function testInlineExpects(result: postcss.Root | Context, expectedTestIn
}
}

function checkTest(context: Context, expectation: string, targetNode: AST, srcNode: AST): Test {
const type = targetNode?.type;
function checkTest(
context: Context,
expectation: string,
targetNode: AST | undefined,
srcNode: AST
): Test {
const type = srcNode?.type || targetNode?.type;
switch (type) {
case `rule`: {
return tests[`@rule`](context, expectation, targetNode, srcNode);
Expand All @@ -161,15 +167,26 @@ function checkTest(context: Context, expectation: string, targetNode: AST, srcNo
};
}
}
function ruleTest(context: Context, expectation: string, targetNode: AST, _srcNode: AST): Test {
function ruleTest(
context: Context,
expectation: string,
targetNode: AST | undefined,
srcNode: AST
): Test {
const result: Test = {
type: `@rule`,
expectation,
errors: [],
};
const { msg, ruleIndex, expectedSelector, expectedBody } = expectation.match(
/(?<msg>\(.*\))*(\[(?<ruleIndex>\d+)\])*(?<expectedSelector>[^{}]*)\s*(?<expectedBody>.*)/s
/(?<msg>\([^)]*\))*(\[(?<ruleIndex>\d+)\])*(?<expectedSelector>[^{}]*)\s*(?<expectedBody>.*)/s
)!.groups!;
const prefix = msg ? msg + `: ` : ``;
if (!targetNode) {
// ToDo: maybe support nodes that are removed from target and leaves mixins
result.errors.push(testInlineExpectsErrors.removedNode(srcNode.type, prefix));
return result;
}
let testNode: AST = targetNode;
// get mixed-in rule
if (ruleIndex) {
Expand Down Expand Up @@ -207,7 +224,7 @@ function ruleTest(context: Context, expectation: string, targetNode: AST, _srcNo
}
}
}
const prefix = msg ? msg + `: ` : ``;

if (testNode.selector !== expectedSelector.trim()) {
result.errors.push(
testInlineExpectsErrors.selector(expectedSelector.trim(), testNode.selector, prefix)
Expand Down Expand Up @@ -244,7 +261,12 @@ function ruleTest(context: Context, expectation: string, targetNode: AST, _srcNo
}
return result;
}
function atRuleTest(_context: Context, expectation: string, targetNode: AST, _srcNode: AST): Test {
function atRuleTest(
_context: Context,
expectation: string,
targetNode: AST | undefined,
srcNode: AST
): Test {
const result: Test = {
type: `@atrule`,
expectation,
Expand All @@ -257,7 +279,10 @@ function atRuleTest(_context: Context, expectation: string, targetNode: AST, _sr
return result;
}
const prefix = msg ? msg + `: ` : ``;
if (targetNode.type === `atrule`) {
if (!targetNode) {
// ToDo: maybe support nodes that are removed from target and leaves mixins
result.errors.push(testInlineExpectsErrors.removedNode(srcNode.type, prefix));
} else if (targetNode.type === `atrule`) {
if (targetNode.params !== expectedParams.trim()) {
result.errors.push(
testInlineExpectsErrors.atruleParams(
Expand All @@ -272,7 +297,12 @@ function atRuleTest(_context: Context, expectation: string, targetNode: AST, _sr
}
return result;
}
function declTest(_context: Context, expectation: string, targetNode: AST, _srcNode: AST): Test {
function declTest(
_context: Context,
expectation: string,
targetNode: AST | undefined,
srcNode: AST
): Test {
const result: Test = {
type: `@decl`,
expectation,
Expand All @@ -284,7 +314,9 @@ function declTest(_context: Context, expectation: string, targetNode: AST, _srcN
label = label ? label + `: ` : ``;
prop = prop.trim();
value = value.trim();
if (!prop || !value) {
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) {
Expand All @@ -299,17 +331,44 @@ function declTest(_context: Context, expectation: string, targetNode: AST, _srcN
}
return result;
}
function analyzeTest(context: Context, expectation: string, targetNode: AST, srcNode: AST): Test {
function analyzeTest(
context: Context,
expectation: string,
targetNode: AST | undefined,
srcNode: AST
): Test {
return diagnosticTest(`analyze`, context, expectation, targetNode, srcNode);
}
function transformTest(context: Context, expectation: string, targetNode: AST, srcNode: AST): Test {
function transformTest(
context: Context,
expectation: string,
targetNode: AST | undefined,
srcNode: AST
): Test {
// check node is removed in transformation
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)],
};
}
// check transform diagnostics
return diagnosticTest(`transform`, context, expectation, targetNode, srcNode);
}
function diagnosticTest(
type: `analyze` | `transform`,
{ meta }: Context,
expectation: string,
_targetNode: AST,
_targetNode: AST | undefined,
srcNode: AST
): Test {
const result: Test = {
Expand Down Expand Up @@ -379,6 +438,14 @@ function getSourceComment(meta: Context['meta'], { source }: postcss.Comment) {
return match;
}

function isRemovedFromTarget(target: AST, source: AST) {
return (
!target ||
target.source?.start !== source.source?.start ||
target.source?.end !== source.source?.end
);
}

function getNextMixinRule(originRule: postcss.Rule, count: number) {
let current: postcss.Node | undefined = originRule;
while (current && count > 0) {
Expand All @@ -395,6 +462,8 @@ export const testInlineExpectsErrors = {
`Expected "${expectedAmount}" checks to run but "${actualAmount}" were found`,
unsupportedNode: (testType: string, nodeType: string, label = ``) =>
`${label}unsupported type "${testType}" for "${nodeType}"`,
removedNode: (nodeType: string, label = ``) =>
`${label}fail to check transformation on removed node with type "${nodeType}"`,
selector: (expectedSelector: string, actualSelector: string, label = ``) =>
`${label}expected "${actualSelector}" to transform to "${expectedSelector}"`,
declarations: (expectedDecl: string, actualDecl: string, selector: string, label = ``) =>
Expand All @@ -419,6 +488,8 @@ export const testInlineExpectsErrors = {
},
deprecatedRootInputNotSupported: (expectation: string) =>
`"${expectation}" is not supported for with the used input, try calling testInlineExpects(generateStylableResults())`,
transformRemoved: (nodeType: string, label = ``) =>
`${label} expected ${nodeType} to be removed, but it was kept after transform`,
diagnosticsMalformed: (type: string, expectation: string, label = ``) =>
`${label}malformed @${type} expectation "@${type}${expectation}". format should be: "@${type}-[severity] diagnostic message"`,
diagnosticsNotFound: (type: string, message: string, label = ``) =>
Expand Down
101 changes: 101 additions & 0 deletions packages/core-test-kit/src/test-stylable-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { testInlineExpects } from './inline-expectation';
import { Stylable, StylableConfig, StylableResults } from '@stylable/core';
import { createMemoryFs } from '@file-services/memory';
import type { IDirectoryContents, IFileSystem } from '@file-services/types';
import { isAbsolute } from 'path';

export interface TestOptions {
entries: string[];
stylableConfig: TestStylableConfig;
}

export type TestStylableConfig = Omit<
StylableConfig,
'fileSystem' | `projectRoot` | `resolveNamespace`
> & {
filesystem?: IFileSystem;
projectRoot?: StylableConfig['projectRoot'];
resolveNamespace?: StylableConfig['resolveNamespace'];
};

/**
* The test function takes in a single '/entry.st.css' stylesheet string
* or a directory structure and then runs 2 phases
* 1. build stylesheets from entries in a configurable order
* 2. run inline test on all '.st.css' files found
* @param input single '/entry.st.css' string or file system structure
*/
export function testStylableCore(
input: string | IDirectoryContents,
options: Partial<TestOptions> = {}
) {
// infra
const fs =
options.stylableConfig?.filesystem ||
createMemoryFs(typeof input === `string` ? { '/entry.st.css': input } : input);
const stylable = Stylable.create({
fileSystem: fs,
projectRoot: '/',
resolveNamespace: (ns) => ns,
requireModule: createJavascriptRequireModule(fs),
...(options.stylableConfig || {}),
});

// collect sheets
const allSheets = fs.findFilesSync(`/`, { filterFile: ({ path }) => path.endsWith(`.st.css`) });
const entries = options.entries || allSheets;

// transform entries - run build in requested order
const sheets: Record<string, StylableResults> = {};
for (const path of entries) {
if (!isAbsolute(path || '')) {
throw new Error(testStylableCore.errors.absoluteEntry(path));
}
const meta = stylable.process(path);
const { exports } = stylable.transform(meta);
sheets[path] = { meta, exports };
}

// inline test - build all and test
for (const path of allSheets) {
const meta = stylable.process(path);
if (!meta.outputAst) {
// ToDo: test
stylable.transform(meta);
}
testInlineExpects({ meta });
}

// expose infra and entry sheets
return { sheets, stylable, fs };
}
testStylableCore.errors = {
absoluteEntry: (entry: string) => `entry must be absolute path got: ${entry}`,
};

// copied from memory-minimal
function createJavascriptRequireModule(fs: IFileSystem) {
const requireModule = (id: string): any => {
const _module = {
id,
exports: {},
};
try {
if (!id.match(/\.js$/)) {
id += '.js';
}
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const fn = new Function(
'module',
'exports',
'require',
fs.readFileSync(id, { encoding: 'utf8', flag: 'r' })
);
fn(_module, _module.exports, requireModule);
} catch (e) {
throw new Error('Cannot require file: ' + id);
}
return _module.exports;
};
return requireModule;
}
Loading

0 comments on commit 25e3cfe

Please sign in to comment.