├── .prettierrc ├── _index.scss ├── sass ├── test │ ├── _index.scss │ ├── _utils.scss │ └── _test.scss ├── _throw.scss ├── module │ ├── _index.scss │ ├── _utils.scss │ └── _modules.scss ├── report │ ├── _index.scss │ ├── _utils.scss │ └── _report.scss ├── assert │ ├── _index.scss │ ├── _utils.scss │ ├── _values.scss │ └── _output.scss ├── config │ ├── _index.scss │ ├── _terms.scss │ ├── _settings.scss │ ├── _messages.scss │ └── _throw.scss ├── data │ ├── _index.scss │ ├── _utilities.scss │ ├── _stats.scss │ ├── _results.scss │ ├── _details.scss │ └── _context.scss ├── _true.scss └── _true.import.scss ├── test ├── scss │ ├── config │ │ ├── _index.scss │ │ └── _messages.scss │ ├── tests │ │ ├── _index.scss │ │ └── _tests.scss │ ├── report │ │ ├── _index.scss │ │ ├── _report.scss │ │ ├── _utils.scss │ │ └── _stub.scss │ ├── module │ │ ├── _index.scss │ │ ├── _modules.scss │ │ └── _utils.scss │ ├── assert │ │ ├── _index.scss │ │ ├── _utils.scss │ │ ├── _values.scss │ │ └── _output.scss │ ├── errors │ │ ├── _index.scss │ │ ├── _throw.scss │ │ ├── _report.scss │ │ └── _context.scss │ ├── includes │ │ └── _include.scss │ ├── data │ │ ├── _index.scss │ │ ├── _utilities.scss │ │ ├── _stats.scss │ │ ├── _details.scss │ │ ├── _context.scss │ │ └── _results.scss │ ├── test.scss │ └── test-errors.scss ├── sass.test.js └── css │ ├── test-errors.css │ └── test.css ├── sache.json ├── .stylelintignore ├── .prettierignore ├── babel.config.js ├── .yarnrc.yml ├── .mailmap ├── tsconfig.json ├── .gitignore ├── .sassdocrc ├── vitest.config.js ├── .github ├── dependabot.yml └── workflows │ ├── test.yml │ └── publish-docs.yml ├── src ├── utils.ts ├── constants.ts └── index.ts ├── CONTRIBUTING.md ├── .stylelintrc.yml ├── LICENSE.txt ├── eslint.config.js ├── package.json ├── README.md └── CHANGELOG.md /.prettierrc: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | -------------------------------------------------------------------------------- /_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'sass/true'; 2 | -------------------------------------------------------------------------------- /sass/test/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'test'; 2 | -------------------------------------------------------------------------------- /sass/_throw.scss: -------------------------------------------------------------------------------- 1 | @forward 'config/throw'; 2 | -------------------------------------------------------------------------------- /sass/module/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'modules'; 2 | -------------------------------------------------------------------------------- /sass/report/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'report'; 2 | -------------------------------------------------------------------------------- /test/scss/config/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'messages'; 2 | -------------------------------------------------------------------------------- /test/scss/tests/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'tests'; 2 | -------------------------------------------------------------------------------- /sass/assert/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'values'; 2 | @forward 'output'; 3 | -------------------------------------------------------------------------------- /test/scss/report/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'report'; 2 | @forward 'utils'; 3 | -------------------------------------------------------------------------------- /test/scss/module/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'utils'; 2 | @forward 'modules'; 3 | -------------------------------------------------------------------------------- /test/scss/assert/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'utils'; 2 | @forward 'values'; 3 | @forward 'output'; 4 | -------------------------------------------------------------------------------- /test/scss/errors/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'context'; 2 | @forward 'report'; 3 | @forward 'throw'; 4 | -------------------------------------------------------------------------------- /sass/config/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'terms'; 2 | @forward 'settings'; 3 | @forward 'messages'; 4 | @forward 'throw'; 5 | -------------------------------------------------------------------------------- /test/scss/includes/_include.scss: -------------------------------------------------------------------------------- 1 | // required for JS tests… 2 | @mixin included-mixin() { 3 | -property: value; 4 | } 5 | -------------------------------------------------------------------------------- /sass/data/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'utilities'; 2 | @forward 'context'; 3 | @forward 'details'; 4 | @forward 'results'; 5 | @forward 'stats'; 6 | -------------------------------------------------------------------------------- /test/scss/data/_index.scss: -------------------------------------------------------------------------------- 1 | @forward 'utilities'; 2 | @forward 'context'; 3 | @forward 'details'; 4 | @forward 'results'; 5 | @forward 'stats'; 6 | -------------------------------------------------------------------------------- /sache.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "true", 3 | "description": "Unit testing for Sass.", 4 | "tags": ["unit-test", "test", "sass", "libsass", "TDD"] 5 | } 6 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | !.* 2 | .git/ 3 | .nyc_output/ 4 | .vscode/ 5 | .yarn/ 6 | ENV/ 7 | coverage/ 8 | dist/ 9 | docs/ 10 | env/ 11 | jscache/ 12 | lib/ 13 | node_modules/ 14 | venv/ 15 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | !.* 2 | .git/ 3 | .nyc_output/ 4 | .vscode/ 5 | .yarn/ 6 | .yarnrc.yml 7 | ENV/ 8 | coverage/ 9 | dist/ 10 | docs/ 11 | env/ 12 | jscache/ 13 | lib/ 14 | node_modules/ 15 | venv/ 16 | -------------------------------------------------------------------------------- /sass/_true.scss: -------------------------------------------------------------------------------- 1 | // config 2 | @forward 'config/settings'; 3 | @forward 'config/throw'; 4 | 5 | // api 6 | @forward 'module'; 7 | @forward 'test'; 8 | @forward 'assert'; 9 | @forward 'report'; 10 | -------------------------------------------------------------------------------- /sass/_true.import.scss: -------------------------------------------------------------------------------- 1 | // config 2 | @forward 'config/settings' as true-*; 3 | @forward 'config/throw'; 4 | 5 | // api 6 | @forward 'module'; 7 | @forward 'test'; 8 | @forward 'assert'; 9 | @forward 'report'; 10 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | test: { 4 | presets: [ 5 | ['@babel/preset-env', { targets: { node: 'current' } }], 6 | '@babel/preset-typescript', 7 | ], 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | nodeLinker: node-modules 6 | 7 | packageExtensions: 8 | markdown-it-anchor@*: 9 | peerDependenciesMeta: 10 | "@types/markdown-it": 11 | optional: true 12 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Miriam Suzanne Eric Meyer 2 | Miriam Suzanne Eric M Suzanne 3 | Miriam Suzanne Eric M. Suzanne 4 | Miriam Suzanne E. Miriam Suzanne 5 | -------------------------------------------------------------------------------- /sass/config/_terms.scss: -------------------------------------------------------------------------------- 1 | /// Internal mapping of valid contexts, 2 | /// with their expected comment-block output. 3 | /// @access private 4 | /// @group private-utils 5 | /// @type bool 6 | $output: ( 7 | 'assert': 'ASSERT', 8 | 'output': 'OUTPUT', 9 | 'expect': 'EXPECTED', 10 | 'contains': 'CONTAINED', 11 | 'contains-string': 'CONTAINS_STRING', 12 | ); 13 | -------------------------------------------------------------------------------- /test/scss/test.scss: -------------------------------------------------------------------------------- 1 | @use '../../sass/config' as init with ( 2 | $terminal-output: false 3 | ); 4 | @use '../../sass/true'; 5 | 6 | @use 'config'; 7 | @use 'data'; 8 | @use 'assert'; 9 | @use 'module'; 10 | @use 'tests'; 11 | @use 'report'; 12 | 13 | // Ignore Non-Test Output 14 | .not-a-test { 15 | break: please-no; 16 | } 17 | 18 | // report 19 | 20 | @include true.report; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2019", 5 | "lib": ["es2019"], 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "sourceMap": true, 11 | "outDir": "lib", 12 | "declaration": true 13 | }, 14 | "include": ["src/**/*.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | *.css.map 3 | .bundle 4 | .nvmrc 5 | .nyc_output/ 6 | .pages/ 7 | .sass-cache/ 8 | .vscode/ 9 | Gemfile.lock 10 | coverage/ 11 | docs/ 12 | lib/ 13 | node_modules/ 14 | pkg/* 15 | test/.sass-cache/ 16 | true-*.gem 17 | 18 | # Yarn 19 | yarn-error.log 20 | .pnp.* 21 | .yarn/* 22 | !.yarn/patches 23 | !.yarn/plugins 24 | !.yarn/releases 25 | !.yarn/sdks 26 | !.yarn/versions 27 | -------------------------------------------------------------------------------- /sass/config/_settings.scss: -------------------------------------------------------------------------------- 1 | // True Settings 2 | // ============= 3 | 4 | /// # Optional Configuration 5 | /// @group api-settings 6 | 7 | // Terminal Output 8 | // --------------- 9 | /// While JS test-runners will always report to the terminal, 10 | /// this setting allows you to get terminal reports 11 | /// even when compiling True manually in Sass. 12 | /// @since v6.0 – Renamed from `$true-terminal-output` 13 | /// @group api-settings 14 | /// @type bool 15 | $terminal-output: true !default; 16 | -------------------------------------------------------------------------------- /test/scss/tests/_tests.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/data/context'; 3 | 4 | @include describe('Tests') { 5 | @include test('Test') { 6 | @include assert-equal( 7 | context.context(test), 8 | 'Test', 9 | 'Changes the current test context' 10 | ); 11 | } 12 | 13 | @include it('It [alias]') { 14 | @include assert-equal( 15 | context.context(test), 16 | 'It [alias]', 17 | 'Changes the current test context' 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/scss/report/_report.scss: -------------------------------------------------------------------------------- 1 | @use 'stub' as *; 2 | @use '../../../index' as *; 3 | @use '../../../sass/report'; 4 | @use '../../../sass/config'; 5 | 6 | // Report Tests 7 | // ============ 8 | 9 | @include describe('Report') { 10 | @include it('Output Message') { 11 | @include assert { 12 | @include output { 13 | @include report.report(false, false, $fake-results, $fake-stats); 14 | } 15 | @include expect { 16 | @include config.message($multi, 'comments'); 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/scss/report/_utils.scss: -------------------------------------------------------------------------------- 1 | @use 'stub' as *; 2 | @use '../../../index' as *; 3 | @use '../../../sass/report/utils'; 4 | 5 | @include describe('Report Message') { 6 | @include it('Single Line') { 7 | @include assert-equal( 8 | utils.report-message(not 'linebreaks', $fake-results, $fake-stats), 9 | $single 10 | ); 11 | } 12 | 13 | @include it('Linebreaks') { 14 | @include assert-equal( 15 | utils.report-message('linebreaks', $fake-results, $fake-stats), 16 | $multi 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/sass.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | 3 | const path = require('node:path'); 4 | 5 | const sassFile = path.join(__dirname, 'scss', 'test.scss'); 6 | const sassErrorFile = path.join(__dirname, 'scss', 'test-errors.scss'); 7 | let runSass; 8 | 9 | if (process.env.USE_BUILT) { 10 | runSass = require('../lib').runSass; 11 | } else { 12 | runSass = require('../src').runSass; 13 | } 14 | runSass({ describe, it }, sassFile); 15 | runSass({ describe, it }, sassErrorFile, { silenceDeprecations: ['import'] }); 16 | -------------------------------------------------------------------------------- /test/scss/test-errors.scss: -------------------------------------------------------------------------------- 1 | @use '../../sass/config' as init with ( 2 | $catch-errors: true, 3 | $terminal-output: false 4 | ); 5 | @use '../../sass/true'; 6 | @use 'sass:meta'; 7 | 8 | @use 'errors'; 9 | 10 | // Sass import legacy test 11 | 12 | @import '../../sass/true'; 13 | 14 | @include true.describe('Imported $terminal-output setting') { 15 | @include true.it('Is prefixed as $true-terminal-output') { 16 | @include true.assert-true( 17 | meta.global-variable-exists('true-terminal-output') 18 | ); 19 | } 20 | } 21 | 22 | // report 23 | 24 | @include true.report; 25 | -------------------------------------------------------------------------------- /test/scss/module/_modules.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/data/context'; 3 | 4 | @include test-module('Test Module') { 5 | @include test('Changes the current module context') { 6 | @include assert-equal(context.context('module'), 'Test Module'); 7 | } 8 | 9 | @include test-module(Nested & Unquoted Module Name) { 10 | @include test(Don’t barf on unquoted names) { 11 | @include assert-true(true, Please don’t barf on me); 12 | } 13 | } 14 | } 15 | 16 | @include describe('Describe') { 17 | @include it('Changes the current module context') { 18 | @include assert-equal(context.context('module'), 'Describe'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/scss/report/_stub.scss: -------------------------------------------------------------------------------- 1 | @use '../../../sass/data'; 2 | 3 | $fake-stats: ( 4 | 'modules': 4, 5 | 'tests': 6, 6 | 'assertions': 25, 7 | ); 8 | 9 | $single-stats: '4 Modules, 6 Tests, 25 Assertions'; 10 | $multi-stats: ('Stats:', '- 4 Modules', '- 6 Tests', '- 25 Assertions'); 11 | 12 | $fake-results: ( 13 | 'run': 6, 14 | 'pass': 5, 15 | 'fail': 1, 16 | 'output-to-css': 0, 17 | ); 18 | 19 | $single-results: '6 Tests, 5 Passed, 1 Failed'; 20 | $multi-results: ('6 Tests:', '- 5 Passed', '- 1 Failed'); 21 | 22 | $single: ($single-results, $single-stats); 23 | $multi: data.join-multiple( 24 | '# SUMMARY ----------', 25 | $multi-results, 26 | $multi-stats, 27 | '--------------------' 28 | ); 29 | -------------------------------------------------------------------------------- /sass/test/_utils.scss: -------------------------------------------------------------------------------- 1 | @use '../config'; 2 | @use '../data'; 3 | 4 | // Test Start 5 | // ---------- 6 | /// Test start helper 7 | /// @access private 8 | /// @group private-test 9 | /// @param {string} $name - 10 | /// Describe what is being tested 11 | @mixin test-start($name) { 12 | @include data.context('test', $name); 13 | @include config.message('Test: #{$name}', 'comments'); 14 | } 15 | 16 | // Test Stop 17 | // --------- 18 | /// Test stop helper 19 | /// @access private 20 | /// @group private-test 21 | @mixin test-stop { 22 | @include data.update(data.$test-result); 23 | @include data.update-stats-count('tests'); 24 | @include data.context-pop; 25 | @include config.message('', 'comments'); 26 | } 27 | -------------------------------------------------------------------------------- /.sassdocrc: -------------------------------------------------------------------------------- 1 | theme: herman 2 | dest: docs/ 3 | src: sass/ 4 | 5 | display: 6 | access: public 7 | 8 | herman: 9 | sass: 10 | sassOptions: 11 | loadPaths: ['sass'] 12 | use: ['true'] 13 | extraDocs: 14 | - name: 'Changelog' 15 | path: CHANGELOG.md 16 | - name: 'Contributing' 17 | path: CONTRIBUTING.md 18 | - name: 'BSD3 License' 19 | path: LICENSE.txt 20 | 21 | groups: 22 | Structure: 23 | api-settings: 'Optional Configuration' 24 | api-test: 'Describing Tests' 25 | api-report: 'Reporting Results' 26 | Assertions: 27 | api-assert-values: 'Testing Return Values' 28 | api-assert-output: 'Testing CSS Output' 29 | api-errors: 'Catching & Testing Errors' 30 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | 3 | const { defineConfig } = require('vitest/config'); 4 | 5 | module.exports = defineConfig({ 6 | test: { 7 | // Make describe, it, expect, etc. available globally 8 | globals: true, 9 | 10 | forceRerunTriggers: ['**/*.scss'], 11 | 12 | // Automatically clear mock calls, instances and results before every test 13 | clearMocks: true, 14 | 15 | // Coverage configuration 16 | coverage: { 17 | enabled: true, 18 | reportOnFailure: true, 19 | reporter: ['text-summary', 'html'], 20 | thresholds: { 21 | branches: 95, 22 | functions: 95, 23 | lines: 95, 24 | statements: 95, 25 | }, 26 | }, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: '/' 6 | schedule: 7 | interval: monthly 8 | time: '04:00' 9 | timezone: America/New_York 10 | cooldown: 11 | default-days: 7 12 | 13 | - package-ecosystem: npm 14 | directory: '/' 15 | versioning-strategy: increase 16 | schedule: 17 | interval: monthly 18 | time: '04:00' 19 | timezone: America/New_York 20 | groups: 21 | npm-major-upgrades: 22 | update-types: 23 | - 'major' 24 | npm-minor-upgrades: 25 | update-types: 26 | - 'minor' 27 | - 'patch' 28 | ignore: 29 | - dependency-name: 'chai' 30 | update-types: 31 | - 'version-update:semver-major' 32 | -------------------------------------------------------------------------------- /test/scss/config/_messages.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/config/messages'; 3 | 4 | @include describe('Message') { 5 | @include it('Renders messages as CSS comments') { 6 | @include assert() { 7 | @include output(false) { 8 | @include messages.message('This is a simple message', 'comments'); 9 | } 10 | 11 | @include expect(false) { 12 | /* This is a simple message */ 13 | } 14 | } 15 | } 16 | 17 | @include it('Renders lists as multiple CSS comments') { 18 | @include assert() { 19 | @include output(false) { 20 | @include messages.message('This is a' 'multiline message', 'comments'); 21 | } 22 | 23 | @include expect(false) { 24 | /* This is a */ 25 | /* multiline message */ 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | test: 8 | name: Node ${{ matrix.node }} 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | node: ['20', '22', 'lts/*'] 14 | steps: 15 | - uses: actions/checkout@v6 16 | - run: corepack enable 17 | - uses: actions/setup-node@v6 18 | with: 19 | node-version: ${{ matrix.node }} 20 | cache: yarn 21 | - run: yarn install --immutable 22 | - run: yarn test 23 | 24 | lint: 25 | name: Lint 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v6 29 | - run: corepack enable 30 | - uses: actions/setup-node@v6 31 | with: 32 | node-version: 'lts/*' 33 | cache: yarn 34 | - run: yarn install --immutable 35 | - run: yarn lint:ci 36 | -------------------------------------------------------------------------------- /test/scss/errors/_throw.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/config/messages'; 3 | @use '../../../sass/config/throw'; 4 | 5 | @include describe('True Error [function]') { 6 | @include it('Allow errors to return without blocking compilation') { 7 | @include assert-equal( 8 | throw.error('This is a test error message', 'error test'), 9 | 'ERROR [error test]: This is a test error message' 10 | ); 11 | } 12 | } 13 | 14 | @include describe('True Error [mixin]') { 15 | @include it('Allow errors to output without blocking compilation') { 16 | @include assert { 17 | @include output { 18 | @include throw.error('This is a test error message', 'error test'); 19 | } 20 | @include expect { 21 | @include messages.message( 22 | 'ERROR [error test]:' ' This is a test error message', 23 | 'comments' 24 | ); 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /sass/config/_messages.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:list'; 2 | 3 | // True Message 4 | // ------------ 5 | /// Output a message to CSS comments, 6 | /// or command line terminal (via debug/warn) 7 | /// @access private 8 | /// @group private-message 9 | /// @param {String} $message - 10 | /// Message to output 11 | /// @param {String} $output [comments] - 12 | /// Type of output, either `comments`, `terminal`, `debug` or `warn` 13 | @mixin message($message, $output: 'comments', $comment-padding: 0) { 14 | $pad: ''; 15 | 16 | @if ($comment-padding > 0) { 17 | @for $i from 0 to $comment-padding { 18 | $pad: $pad + ' '; 19 | } 20 | } 21 | 22 | @each $line in $message { 23 | @if list.index($output, 'comments') { 24 | // sass-lint:disable-line no-empty-rulesets 25 | 26 | /* #{$pad + $line} */ // sass-lint:disable-line no-css-comments 27 | } 28 | 29 | @if list.index($output, 'debug') or list.index($output, 'terminal') { 30 | @debug $line; // sass-lint:disable-line no-debug 31 | } 32 | 33 | @if list.index($output, 'warn') { 34 | @warn $line; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { format } from '@prettier/sync'; 2 | import { type ChildNode, type Comment } from 'postcss'; 3 | 4 | import { type Rule } from '.'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | export const truthyValues = (item?: any) => Boolean(item); 8 | 9 | export const isCommentNode = (node: ChildNode): node is Comment => 10 | node.type === 'comment'; 11 | 12 | export const removeNewLines = (cssString: string) => 13 | cssString.replace(/\n/g, ''); 14 | 15 | export const splitSelectorAndProperties = (blocks: string[]) => 16 | blocks.map((block) => { 17 | const temp = block.split('{'); 18 | const selector = temp[0]; 19 | const output = temp[1]; 20 | const splitBlock = { selector, output }; 21 | return splitBlock; 22 | }); 23 | 24 | export const cssStringToArrayOfRules = (cssString: string) => 25 | removeNewLines(cssString) 26 | .split(/\s*}(?![\s]*["',}])/g) 27 | .filter(truthyValues); 28 | 29 | export const generateCss = (rules: Rule[]) => 30 | format(rules.map((rule) => rule.toString()).join('\n'), { 31 | parser: 'css', 32 | }).trim(); 33 | -------------------------------------------------------------------------------- /sass/report/_utils.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @use '../config'; 3 | @use '../data'; 4 | 5 | // Report Message 6 | // -------------- 7 | /// Report results summary to CSS 8 | /// and (optionally) the command line 9 | /// @access private 10 | /// @group private-message 11 | /// @param {bool} $linebreak [false] - 12 | /// Return single-line config for results/stats, 13 | /// or optionally break into multi-line lists. 14 | /// @param {map} $results [$results] - 15 | /// A map of run, pass, fail, and output-to-css results 16 | /// @param {map} $stats [$stats] - 17 | /// A map of module, test, and assertion-counts in your project 18 | @function report-message( 19 | $linebreak: false, 20 | $results: data.$results, 21 | $stats: data.$stats 22 | ) { 23 | @if $linebreak { 24 | @return data.join-multiple( 25 | '# SUMMARY ----------', 26 | data.results-message('linebreak', $results), 27 | data.stats-message('linebreak', $stats), 28 | '--------------------' 29 | ); 30 | } 31 | 32 | $report: ( 33 | data.results-message(null, $results), 34 | data.stats-message(null, $stats) 35 | ); 36 | 37 | @return $report; 38 | } 39 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // Tokens defining the True CSS output language. 2 | export const MODULE_TOKEN = '# Module: '; 3 | export const MODULE_NESTING_TOKEN = ' :: '; 4 | export const SUMMARY_TOKEN = '# SUMMARY '; 5 | export const END_SUMMARY_TOKEN = '----------'; 6 | export const TEST_TOKEN = 'Test: '; 7 | export const PASS_TOKEN = '✔'; 8 | export const FAIL_TOKEN = '✖ FAILED: ['; 9 | export const END_FAIL_TOKEN = ']'; 10 | export const ASSERT_TOKEN = 'ASSERT:'; 11 | export const FAILURE_DETAIL_TOKEN = '- '; 12 | export const FAILURE_TYPE_START_TOKEN = '['; 13 | export const FAILURE_TYPE_END_TOKEN = ']'; 14 | export const OUTPUT_TOKEN = 'Output: '; 15 | export const EXPECTED_TOKEN = 'Expected: '; 16 | export const DETAILS_SEPARATOR_TOKEN = ': '; 17 | export const OUTPUT_START_TOKEN = 'OUTPUT'; 18 | export const OUTPUT_END_TOKEN = 'END_OUTPUT'; 19 | export const EXPECTED_START_TOKEN = 'EXPECTED'; 20 | export const EXPECTED_END_TOKEN = 'END_EXPECTED'; 21 | export const CONTAINS_STRING_START_TOKEN = 'CONTAINS_STRING'; 22 | export const CONTAINS_STRING_END_TOKEN = 'END_CONTAINS_STRING'; 23 | export const CONTAINED_START_TOKEN = 'CONTAINED'; 24 | export const CONTAINED_END_TOKEN = 'END_CONTAINED'; 25 | export const ASSERT_END_TOKEN = 'END_ASSERT'; 26 | -------------------------------------------------------------------------------- /test/scss/data/_utilities.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/data/utilities'; 3 | @use 'sass:list'; 4 | 5 | @include describe('Map Increment') { 6 | @include it('Returns a map with the sum-values of two numeric maps') { 7 | $base: ( 8 | one: 1, 9 | two: 1, 10 | three: 1, 11 | ); 12 | $add: ( 13 | one: 1, 14 | two: 2, 15 | three: -1, 16 | ); 17 | 18 | $expect: ( 19 | one: 2, 20 | two: 3, 21 | three: 0, 22 | ); 23 | @include assert-equal(utilities.map-increment($base, $add), $expect); 24 | } 25 | } 26 | 27 | @include describe('Join Multiple') { 28 | $one: ('one', 'two', 'three'); 29 | $two: ('four' 'five' 'six'); 30 | $three: ('seven', 'eight', 'nine'); 31 | $joined: ( 32 | 'one', 33 | 'two', 34 | 'three', 35 | 'four', 36 | 'five', 37 | 'six', 38 | 'seven', 39 | 'eight', 40 | 'nine' 41 | ); 42 | 43 | @include it('Combines multiple lists') { 44 | @include assert-equal(utilities.join-multiple($one, $two, $three), $joined); 45 | } 46 | 47 | @include it('Sets new list-separator') { 48 | @include assert-equal( 49 | utilities.join-multiple($one, $two, $three, 'space'), 50 | list.join((), $joined, 'space') 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/scss/module/_utils.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/module/utils'; 3 | @use 'sass:string'; 4 | 5 | @include describe('Module Title') { 6 | @include it('Returns the current module name, prefixed') { 7 | @include assert-equal(utils.module-title(), '# Module: Module Title'); 8 | } 9 | 10 | @include describe('Module Title [Nested]') { 11 | @include it('Returns a concatenated title of current modules') { 12 | @include assert-equal( 13 | utils.module-title(), 14 | '# Module: Module Title :: Module Title [Nested]' 15 | ); 16 | } 17 | } 18 | } 19 | 20 | @include describe('Underline') { 21 | @include it('Returns a string of dashes, the same length as the input') { 22 | $quote: "As long as my people don't have their rights across America, there's no reason for celebration."; 23 | $cite: 'Marsha P. Johnson'; 24 | 25 | @include assert-equal( 26 | string.length($quote), 27 | string.length(utils.underline($quote)) 28 | ); 29 | 30 | $under-cite: utils.underline($cite); 31 | 32 | @include assert-equal(string.length($cite), string.length($under-cite)); 33 | 34 | @for $i from 1 through string.length($under-cite) { 35 | @include assert-equal('-', string.slice($under-cite, $i, $i)); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/scss/data/_stats.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/data/stats'; 3 | @use 'sass:map'; 4 | 5 | @include describe('Update Stats Count') { 6 | $before: stats.$stats; 7 | @include stats.update-stats-count('assertions'); 8 | $actual: stats.$stats; 9 | stats.$stats: $before; 10 | 11 | @include it('Assertions counts are updated') { 12 | @include assert-equal( 13 | map.get($actual, 'assertions'), 14 | map.get($before, 'assertions') + 1 15 | ); 16 | } 17 | 18 | @include it('Modules counts are left as-is') { 19 | @include assert-equal( 20 | map.get($actual, 'modules'), 21 | map.get($before, 'modules') 22 | ); 23 | } 24 | 25 | @include it('Tests counts are left as-is') { 26 | @include assert-equal(map.get($actual, 'tests'), map.get($before, 'tests')); 27 | } 28 | } 29 | 30 | @include describe('Stats Message') { 31 | $test-map: ( 32 | 'modules': 4, 33 | 'tests': 6, 34 | 'assertions': 25, 35 | ); 36 | 37 | @include it('Single Line') { 38 | @include assert-equal( 39 | stats.stats-message($stats: $test-map), 40 | '4 Modules, 6 Tests, 25 Assertions' 41 | ); 42 | } 43 | 44 | @include it('Linebreaks') { 45 | $message: ('Stats:', '- 4 Modules', '- 6 Tests', '- 25 Assertions'); 46 | @include assert-equal( 47 | stats.stats-message('linebreaks', $test-map), 48 | $message 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to True 2 | 3 | True exists because of your contributions. 4 | Bug reports and feature requests are welcome, 5 | but code is even better! 6 | 7 | In all cases, 8 | we ask you to follow the 9 | [Sass community guidelines](https://sass-lang.com/community-guidelines). 10 | 11 | ## Pull Requests 12 | 13 | We use the `main` branch for production-ready code, 14 | and side-branches for everything in-progress 15 | or up-for-debate. 16 | 17 | When submitting a patch via pull request: 18 | 19 | - Write a clear, descriptive commit message 20 | - Include any appropriate unit tests, 21 | and make sure all tests are passing (`yarn test`) 22 | - Add your changes to the 23 | [changelog](https://github.com/oddbird/true/blob/main/CHANGELOG.md) 24 | - Update or write appropriate [SassDoc](http://sassdoc.com/) 25 | inline documentation for your changes 26 | - Keep it simple: one bug fix or feature per pull request 27 | 28 | ## Development 29 | 30 | Set up your dev environment 31 | with the appropriate dependencies: 32 | 33 | ``` 34 | yarn 35 | ``` 36 | 37 | ## Committing 38 | 39 | Linting and testing 40 | should be done before every commit: 41 | 42 | ``` 43 | yarn commit 44 | ``` 45 | 46 | They can also be triggered individually: 47 | 48 | ``` 49 | # lint 50 | yarn lint 51 | 52 | # test with jest and true 53 | yarn test 54 | 55 | # compile sass tests 56 | yarn build:sass 57 | ``` 58 | 59 | Once you've fixed any final errors or typos, 60 | commit your changes, and submit a pull request! 61 | -------------------------------------------------------------------------------- /test/scss/errors/_report.scss: -------------------------------------------------------------------------------- 1 | @use '../report/stub' as *; 2 | @use '../../../index' as *; 3 | @use '../../../sass/report'; 4 | @use '../../../sass/config'; 5 | @use '../../../sass/data'; 6 | @use 'sass:map'; 7 | 8 | // Report Tests 9 | // ============ 10 | 11 | @include describe('Report') { 12 | @include it('Fail on Error') { 13 | @include assert { 14 | @include output { 15 | @include report.report( 16 | false, 17 | 'fail on error', 18 | $fake-results, 19 | $fake-stats 20 | ); 21 | } 22 | @include expect { 23 | @include config.message($multi, 'comments'); 24 | @include config.error('1 test failed', 'report'); 25 | } 26 | } 27 | } 28 | 29 | @include it('Bad results') { 30 | $bad-results: map.merge( 31 | $fake-results, 32 | ( 33 | 'pass': 4, 34 | ) 35 | ); 36 | $bad-lines: ('6 Tests:', '- 4 Passed', '- 1 Failed'); 37 | $bad: data.join-multiple( 38 | '# SUMMARY ----------', 39 | $bad-lines, 40 | $multi-stats, 41 | '--------------------' 42 | ); 43 | 44 | @include assert { 45 | @include output { 46 | @include report.report(false, false, $bad-results, $fake-stats); 47 | } 48 | @include expect { 49 | @include config.message($bad, 'comments'); 50 | @include config.error( 51 | 'The results don’t add up. Are all your tests properly structured?', 52 | 'report' 53 | ); 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.stylelintrc.yml: -------------------------------------------------------------------------------- 1 | # See https://stylelint.io/user-guide/rules/list 2 | # Also https://github.com/kristerkari/stylelint-scss#list-of-rules 3 | 4 | extends: 5 | - stylelint-config-standard-scss 6 | 7 | ignoreFiles: 8 | - '**/*.js' 9 | - 'test/**/*.*' 10 | 11 | rules: 12 | # Default rules: 13 | # - https://github.com/stylelint-scss/stylelint-config-standard-scss/blob/main/index.js 14 | # - https://github.com/stylelint-scss/stylelint-config-recommended-scss/blob/master/index.js 15 | # - https://github.com/stylelint/stylelint-config-standard/blob/main/index.js 16 | # - https://github.com/stylelint/stylelint-config-recommended/blob/main/index.js 17 | 18 | # possible errors (these are all on by default) 19 | no-descending-specificity: null 20 | 21 | # limit language features 22 | color-function-notation: null 23 | color-named: always-where-possible 24 | custom-property-pattern: null 25 | declaration-no-important: true 26 | function-url-no-scheme-relative: true 27 | number-max-precision: null 28 | selector-class-pattern: null 29 | 30 | # Sass 31 | scss/at-function-pattern: 32 | - '^([_|-]*[a-z][a-z0-9]*)(-[a-z0-9]+)*-*$' 33 | - message: 'Expected function to be kebab-case' 34 | scss/at-mixin-pattern: 35 | - '^([_|-]*[a-z][a-z0-9]*)(-[a-z0-9]+)*-*$' 36 | - message: 'Expected mixin to be kebab-case' 37 | scss/at-rule-conditional-no-parentheses: null 38 | scss/dollar-variable-pattern: 39 | - '^([_|-]*[a-z][a-z0-9]*)(-[a-z0-9]+)*-*$' 40 | - message: 'Expected variable to be kebab-case' 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015–2026, OddBird LLC. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | * Neither the name of the author nor the names of other 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /sass/test/_test.scss: -------------------------------------------------------------------------------- 1 | @use 'utils'; 2 | 3 | // Test 4 | // ---- 5 | /// The `test()` wrapper-mixin groups related assertions, 6 | /// to describe the behavior they are testing. 7 | /// Tests should always contain one or more assertions. 8 | /// @group api-test 9 | /// @param {string} $name - 10 | /// Describe what is being tested 11 | /// @content Include any assertions that are part of this test 12 | /// @example scss - 13 | /// @use 'sass:list'; 14 | /// @include true.test('Returns lists zipped together') { 15 | /// @include true.assert-equal( 16 | /// list.zip(a b c, 1 2 3), 17 | /// (a 1, b 2, c 3)); 18 | /// @include true.assert-equal( 19 | /// list.zip(1px 1px 3px, solid dashed solid, red green blue), 20 | /// (1px solid red, 1px dashed green, 3px solid blue)); 21 | /// } 22 | @mixin test($name) { 23 | @include utils.test-start($name); 24 | @content; 25 | @include utils.test-stop; 26 | } 27 | 28 | // Test 29 | // ---- 30 | /// Describe the behavior being tested. 31 | /// This works just like `test()`, 32 | /// providing a wrapper for one or more assertions. 33 | /// @alias test 34 | /// @group api-test 35 | /// @param {string} $name - 36 | /// Describe the behavior being tested 37 | /// @content Include any assertions that are part of testing this behavior 38 | /// @example scss - 39 | /// @use 'sass:list'; 40 | /// @include true.it('Returns lists zipped together') { 41 | /// @include true.assert-equal( 42 | /// list.zip(a b c, 1 2 3), 43 | /// (a 1, b 2, c 3)); 44 | /// @include true.assert-equal( 45 | /// list.zip(1px 1px 3px, solid dashed solid, red green blue), 46 | /// (1px solid red, 1px dashed green, 3px solid blue)); 47 | /// } 48 | @mixin it($name) { 49 | @include test($name) { 50 | @content; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /sass/data/_utilities.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:list'; 2 | @use 'sass:map'; 3 | 4 | // Map Increment 5 | // ------------- 6 | /// Add map values together 7 | /// @access private 8 | /// @group private-utils 9 | /// @param {map} $base - 10 | /// Initial map to add values to 11 | /// @param {map} $add - 12 | /// Map of values to be added 13 | /// @return {map} Map of values after incrementing 14 | @function map-increment($base, $add) { 15 | @each $key in map.keys($add) { 16 | $value: map.get($add, $key); 17 | 18 | @if $value { 19 | $base-value: map.get($base, $key) or 0; 20 | $base: map.merge( 21 | $base, 22 | ( 23 | $key: $base-value + $value, 24 | ) 25 | ); 26 | } 27 | } 28 | 29 | @return $base; 30 | } 31 | 32 | // Join Multiple 33 | // ------------- 34 | /// Extends the Sass `join()` function 35 | /// to accept and combine any number of lists 36 | /// @access private 37 | /// @group private-utils 38 | /// @param {list | 'space' | 'comma'} $lists... - 39 | /// Any number of lists to be joined, 40 | /// with an optional final argument describing 41 | /// the desired list-separator ('space' or 'comma') 42 | /// @return {list} Joined items in a single list 43 | @function join-multiple($lists...) { 44 | $return: list.nth($lists, 1); 45 | $type: list.separator($return); 46 | $last: list.nth($lists, -1); 47 | $length: list.length($lists); 48 | 49 | @if ($last == 'space') or ($last == 'comma') { 50 | $length: $length - 1; 51 | $type: $last; 52 | } 53 | 54 | @if ($length < 2) { 55 | $error: 'Must provide at least 2 lists'; 56 | 57 | @return error($error, 'join-multiple'); 58 | } 59 | 60 | @for $i from 2 through $length { 61 | $return: list.join($return, list.nth($lists, $i), $type); 62 | } 63 | 64 | @return $return; 65 | } 66 | -------------------------------------------------------------------------------- /test/scss/assert/_utils.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/assert/utils'; 3 | @use '../../../sass/data'; 4 | @use 'sass:list'; 5 | 6 | // I'm not sure how to write tests for several of the wrapper mixins: 7 | // - content() doesn't allow generating a "fake" context for testing… 8 | // - strike() might be possible, but contains very little internal logic… 9 | // - result() contains strike() which would cause difficult side-effects 10 | 11 | @include describe('Setup') { 12 | @include it('Updates context based on current assertions') { 13 | $initial: data.$context; 14 | $name: 'fake'; 15 | $description: 'this is not a real assertion'; 16 | 17 | @include utils.setup($name, $description); 18 | 19 | $test: data.$context; 20 | $expect: list.append($initial, ('assert', '[#{$name}] #{$description}')); 21 | data.$context: $initial; 22 | 23 | @include assert-equal($test, $expect); 24 | } 25 | } 26 | 27 | @include describe('Is Truthy') { 28 | @include it('True is truthy') { 29 | @include assert-equal(utils.is-truthy(true), true); 30 | } 31 | 32 | @include it('String is truthy') { 33 | @include assert-equal(utils.is-truthy('string'), true); 34 | } 35 | 36 | @include it('List is truthy') { 37 | @include assert-equal(utils.is-truthy('one' 'two' 'three'), true); 38 | } 39 | 40 | @include it('False is not truthy') { 41 | @include assert-equal(utils.is-truthy(false), false); 42 | } 43 | 44 | @include it('Null is not truthy') { 45 | @include assert-equal(utils.is-truthy(null), false); 46 | } 47 | 48 | @include it('Empty string is not truthy') { 49 | @include assert-equal(utils.is-truthy(''), false); 50 | } 51 | 52 | @include it('Empty list is not truthy') { 53 | $list: (); 54 | 55 | @include assert-equal(utils.is-truthy($list), false); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish documentation 2 | on: 3 | release: # Run when stable releases are published 4 | types: [released] 5 | workflow_dispatch: # Run on-demand 6 | inputs: 7 | ref: 8 | description: Git ref to build docs from 9 | required: true 10 | default: main 11 | type: string 12 | 13 | jobs: 14 | push-branch: 15 | name: Build & push docs 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: write 19 | concurrency: 20 | group: ${{ github.workflow }}-${{ github.ref }} 21 | steps: 22 | - name: Check out from release 23 | if: github.event_name == 'release' 24 | uses: actions/checkout@v6 25 | - name: Check out from manual input 26 | if: github.event_name == 'workflow_dispatch' 27 | uses: actions/checkout@v6 28 | with: 29 | ref: ${{ inputs.ref }} 30 | - run: corepack enable 31 | - uses: actions/setup-node@v6 32 | with: 33 | node-version: 'lts/*' 34 | cache: yarn 35 | - run: yarn install 36 | - run: yarn docs 37 | - name: Clone docs branch 38 | uses: actions/checkout@v6 39 | with: 40 | path: docs-branch 41 | ref: oddleventy-docs 42 | - name: Commit & push to docs branch 43 | run: | 44 | SHA=$(git rev-parse HEAD) 45 | cd docs-branch 46 | rm -rf true/docs 47 | mkdir -p true/docs 48 | cp -r ${{ github.workspace }}/docs/ true/ 49 | git config user.name github-actions 50 | git config user.email github-actions@github.com 51 | git add -A . 52 | git commit --allow-empty \ 53 | -m "Update from https://github.com/${{ github.repository }}/commit/$SHA" \ 54 | -m "Full log: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" 55 | git push origin oddleventy-docs 56 | -------------------------------------------------------------------------------- /sass/module/_utils.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:list'; 2 | @use 'sass:meta'; 3 | @use 'sass:string'; 4 | @use '../config'; 5 | @use '../data'; 6 | 7 | // Module Start 8 | // ------------ 9 | /// Module start helper 10 | /// @access private 11 | /// @group private-test 12 | /// @param {string} $name - module name 13 | @mixin module-start($name) { 14 | @include data.context('module', $name); 15 | 16 | $modules: module-title(); 17 | 18 | @include config.message($modules, 'comments'); 19 | @include config.message(underline($modules), 'comments'); 20 | } 21 | 22 | // Module Title 23 | // ------------ 24 | /// Generate a title based on current nested module context 25 | /// @access private 26 | /// @group private-test 27 | /// @return {string} - Concatenated names of current modules 28 | @function module-title() { 29 | $module-list: data.context-all('module'); 30 | $modules: list.nth($module-list, 1); 31 | $length: list.length($module-list); 32 | 33 | @if $length > 1 { 34 | @for $i from 2 through $length { 35 | $modules: $modules + ' :: ' + list.nth($module-list, $i); 36 | } 37 | } 38 | 39 | @return '# Module: #{$modules}'; 40 | } 41 | 42 | // Underline 43 | // --------- 44 | /// Generate a title based on current nested module context 45 | /// @access private 46 | /// @group private-test 47 | /// @param {string} $string 48 | /// @return {string} - Underline dashes of the same length as string param 49 | @function underline($string) { 50 | $underline: ''; 51 | 52 | @if (meta.type_of($string) != 'string') { 53 | $string: '#{$string}'; 54 | } 55 | 56 | @for $i from 1 through string.length($string) { 57 | $underline: '#{$underline}-'; 58 | } 59 | 60 | @return $underline; 61 | } 62 | 63 | // Module Stop 64 | // ----------- 65 | /// Module stop helper 66 | /// @access private 67 | /// @group private-test 68 | @mixin module-stop { 69 | @include data.update-stats-count('modules'); 70 | @include data.context-pop; 71 | @include config.message('', 'comments'); 72 | } 73 | -------------------------------------------------------------------------------- /sass/report/_report.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @use '../config'; 3 | @use '../data'; 4 | @use 'utils'; 5 | 6 | /// # Reporting Results 7 | /// @group api-report 8 | 9 | // Report 10 | // ------ 11 | /// Report results summary to CSS 12 | /// and (optionally) the command line 13 | /// @access public 14 | /// @group api-report 15 | /// @param {bool} $terminal [$terminal-output] - 16 | /// Optionally output results to the terminal 17 | /// @param {bool} $fail-on-error [false] - 18 | /// Optionally error out the compiler if tests have failed 19 | /// @param {map} $results [$results] - 20 | /// A map of run, pass, fail, and output-to-css results 21 | /// @param {map} $stats [$stats] - 22 | /// A map of module, test, and assertion statistics 23 | /// @example scss - 24 | /// true.$terminal-output: false; 25 | /// @include true.report; 26 | @mixin report( 27 | $terminal: config.$terminal-output, 28 | $fail-on-error: false, 29 | $results: data.$results, 30 | $stats: data.$stats 31 | ) { 32 | $fail: map.get($results, 'fail'); 33 | $run: map.get($results, 'run'); 34 | $pass: map.get($results, 'pass'); 35 | $fail: map.get($results, 'fail'); 36 | $output: map.get($results, 'output-to-css'); 37 | $total: $pass + $fail + $output; 38 | $tests: map.get($stats, 'tests'); 39 | $comment: utils.report-message('linebreak', $results, $stats); 40 | 41 | @include config.message($comment, 'comments'); 42 | 43 | @if $terminal { 44 | $debug: utils.report-message(not 'linebreak', $results, $stats); 45 | 46 | @include config.message($debug, 'debug'); 47 | } 48 | 49 | @if ($run != $tests) or ($run != $total) { 50 | $error: 'The results don’t add up. Are all your tests properly structured?'; 51 | 52 | @include config.error($error, 'report'); 53 | } 54 | 55 | @if $fail-on-error and ($fail > 0) { 56 | $plural: 'tests'; 57 | 58 | @if ($fail == 1) { 59 | $plural: 'test'; 60 | } 61 | 62 | @include config.error('#{$fail} #{$plural} failed', 'report'); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /test/scss/errors/_context.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/config/terms'; 3 | @use '../../../sass/data/context'; 4 | @use 'sass:map'; 5 | 6 | @include describe('Validate Output Context') { 7 | $valid: map.keys(terms.$output); 8 | 9 | @include it('unknown context name') { 10 | @include assert-equal( 11 | context.validate-output-context('wtf'), 12 | 'ERROR [output-context]: #{'wtf'} is not a valid context for output tests: #{$valid}' 13 | ); 14 | } 15 | 16 | @include it('duplicate assert') { 17 | context.$output-context: 'assert' 'output'; 18 | 19 | @include assert-equal( 20 | context.validate-output-context('assert'), 21 | 'ERROR [output-context]: The `assert()` mixin can not contain another `assert()`' 22 | ); 23 | 24 | context.$output-context: (); 25 | } 26 | 27 | @include it('duplicate output/expect') { 28 | context.$output-context: 'assert' 'output' 'expect'; 29 | 30 | @include assert-equal( 31 | context.validate-output-context('output'), 32 | 'ERROR [output-context]: `output()` cannot be used multiple times in the same assertion' 33 | ); 34 | 35 | @include assert-equal( 36 | context.validate-output-context('expect'), 37 | 'ERROR [output-context]: `expect()` cannot be used multiple times in the same assertion' 38 | ); 39 | 40 | context.$output-context: (); 41 | } 42 | 43 | @include it('missing assert') { 44 | @include assert-equal( 45 | context.validate-output-context('output'), 46 | 'ERROR [output-context]: `output()` is only allowed inside `assert()`' 47 | ); 48 | 49 | @include assert-equal( 50 | context.validate-output-context('expect'), 51 | 'ERROR [output-context]: `expect()` is only allowed inside `assert()`' 52 | ); 53 | } 54 | 55 | @include it('wrong length') { 56 | context.$output-context: 'assert' 'wtf' 'output' 'expect' 'contains'; 57 | 58 | @include assert-equal( 59 | context.validate-output-context(null), 60 | 'ERROR [output-context]: Unexpected assertion stack: assert wtf output expect contains' 61 | ); 62 | 63 | context.$output-context: (); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /sass/data/_stats.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:map'; 2 | @use 'utilities'; 3 | 4 | // Stats Count 5 | // ----------- 6 | /// Global stats count of how many modules, tests, and assertions are found 7 | /// @access private 8 | /// @group private-context 9 | /// @type Map 10 | $stats: ( 11 | 'modules': 0, 12 | 'tests': 0, 13 | 'assertions': 0, 14 | ); 15 | 16 | // Update stats count 17 | // ------------------ 18 | /// Add to a stats count type count by 1 19 | /// @param {String} $type - The stats type to add to 20 | /// @access private 21 | /// @group private-context 22 | @mixin update-stats-count($type) { 23 | $update: ( 24 | $type: 1, 25 | ); 26 | $stats: utilities.map-increment($stats, $update) !global; 27 | } 28 | 29 | // Stats Message 30 | // ------------- 31 | /// Stats message 32 | /// @access private 33 | /// @group private-message 34 | /// @param {Bool} $linebreak [false] - 35 | /// Return message either as a single line or in multiple lines 36 | /// @param {Map} $stats [$stats] - 37 | /// Map that contains the stats counts for modules, tests, and assertions found 38 | /// @return {String} - Stats count message 39 | @function stats-message($linebreak: false, $stats: $stats) { 40 | // Get Results 41 | $modules: map.get($stats, 'modules'); 42 | $tests: map.get($stats, 'tests'); 43 | $assertions: map.get($stats, 'assertions'); 44 | 45 | // Pluralize Labels 46 | $modules-label: 'Modules'; 47 | 48 | @if ($modules == 1) { 49 | $modules-label: 'Module'; 50 | } 51 | 52 | $tests-label: 'Tests'; 53 | 54 | @if ($tests == 1) { 55 | $tests-label: 'Test'; 56 | } 57 | 58 | $assertions-label: 'Assertions'; 59 | 60 | @if ($assertions == 1) { 61 | $assertions-label: 'Assertion'; 62 | } 63 | 64 | // Combine Results with Labels 65 | $modules: '#{$modules} #{$modules-label}'; 66 | $tests: '#{$tests} #{$tests-label}'; 67 | $assertions: '#{$assertions} #{$assertions-label}'; 68 | 69 | // Linebreaks 70 | @if $linebreak { 71 | $message: ('Stats:', '- #{$modules}', '- #{$tests}', '- #{$assertions}'); 72 | 73 | @return $message; 74 | } 75 | 76 | // No Linebreaks 77 | $message: '#{$modules}, #{$tests}, #{$assertions}'; 78 | 79 | @return $message; 80 | } 81 | -------------------------------------------------------------------------------- /sass/module/_modules.scss: -------------------------------------------------------------------------------- 1 | @use 'utils'; 2 | 3 | /// # Describing Tests 4 | /// @group api-test 5 | 6 | // Test Module 7 | // ----------- 8 | /// Test Modules are optional, 9 | /// and can be used to group tests and other modules 10 | /// for organizational purposes. 11 | /// Modules can be nested for additional organization. 12 | /// @access public 13 | /// @group api-test 14 | /// @param {string} $name - module name 15 | /// @content Include all the tests & modules that are part of this module 16 | /// @example scss - 17 | /// @use 'sass:list'; 18 | /// // Module Group 19 | /// @include true.test-module('zip [function]') { 20 | /// // Test 1 21 | /// @include true.test('Returns two lists zipped together') { 22 | /// @include true.assert-equal( 23 | /// list.zip(a b c, 1 2 3), 24 | /// (a 1, b 2, c 3)); 25 | /// } 26 | /// 27 | /// // Test 2 28 | /// @include true.test('Returns three zipped lists') { 29 | /// @include true.assert-equal( 30 | /// list.zip(1px 1px 3px, solid dashed solid, red green blue), 31 | /// (1px solid red, 1px dashed green, 3px solid blue)); 32 | /// } 33 | /// } 34 | @mixin test-module($name) { 35 | @include utils.module-start($name); 36 | @content; 37 | @include utils.module-stop; 38 | } 39 | 40 | // Describe 41 | // -------- 42 | /// Describe the unit to be tested. 43 | /// This works just like a test module, 44 | /// allowing you to group one or more related tests. 45 | /// @alias test-module 46 | /// @group api-test 47 | /// @param {string} $name - module name 48 | /// @content Include all the tests that are part of this module 49 | /// @example scss - 50 | /// @use 'sass:list'; 51 | /// @include true.describe('zip [function]') { 52 | /// // Test 1 53 | /// @include true.it('Returns two lists zipped together') { 54 | /// @include true.assert-equal( 55 | /// list.zip(a b c, 1 2 3), 56 | /// (a 1, b 2, c 3)); 57 | /// } 58 | /// 59 | /// // Test 2 60 | /// @include true.it('Returns three zipped lists') { 61 | /// @include true.assert-equal( 62 | /// list.zip(1px 1px 3px, solid dashed solid, red green blue), 63 | /// (1px solid red, 1px dashed green, 3px solid blue)); 64 | /// } 65 | /// } 66 | @mixin describe($name) { 67 | @include test-module($name) { 68 | @content; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/scss/assert/_values.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use 'sass:color'; 3 | @use 'sass:string'; 4 | @use 'sass:math'; 5 | 6 | // Assert True 7 | @include describe('Assert True') { 8 | @include it('Non-false properties return true') { 9 | @include assert-true('this string'); 10 | } 11 | 12 | @include it('Supports is-truthy alias') { 13 | @include is-truthy(5); 14 | } 15 | } 16 | 17 | // Assert False 18 | @include describe('Assert False') { 19 | @include it('Falsiness') { 20 | @include assert-false(not 'anything', 'Negated properties return false.'); 21 | } 22 | 23 | @include it('null') { 24 | @include assert-false(null, 'Null properties return false.'); 25 | } 26 | 27 | @include it('Empty string') { 28 | @include assert-false('', 'Empty string return false.'); 29 | } 30 | 31 | @include it('empty list') { 32 | $empty: (); 33 | 34 | @include assert-false($empty, 'Empty lists return false.'); 35 | } 36 | 37 | @include it('Supports is-falsy alias') { 38 | @include is-falsy(()); 39 | } 40 | } 41 | 42 | // Assert Equal 43 | @include describe('Assert Equal') { 44 | @include it('Equality') { 45 | @include assert-equal(2 - 1, 1, '2 - 1 should equal 1.'); 46 | } 47 | 48 | @include it('Empty description') { 49 | @include assert-equal(1, 1); 50 | } 51 | 52 | @include it('Adding floats') { 53 | @include assert-equal(0.1 + 0.2, 0.3); 54 | } 55 | 56 | @include it('Rounded numbers') { 57 | @include assert-equal(math.div(1, 3), 0.3333333333333333); 58 | } 59 | 60 | @include it('Rounded colors') { 61 | $origin: #246; 62 | $expected: rgb(53.125, 106.25, 159.375); 63 | 64 | @include assert-equal(color.adjust($origin, $lightness: 15%), $expected); 65 | } 66 | 67 | @include it('Supports is-equal alias') { 68 | @include is-equal(5, math.div(10, 2)); 69 | } 70 | } 71 | 72 | // Assert UnEqual 73 | @include describe('Assert UnEqual') { 74 | @include it('Inequality') { 75 | @include assert-unequal(3 - 1, 3, '3 - 1 is not equal to 3.'); 76 | } 77 | 78 | @include it('Mismatched types') { 79 | @include assert-unequal(string.unquote('1rem'), 1rem); 80 | } 81 | 82 | @include it('Mismatched units') { 83 | @include assert-unequal(1, 1rem); 84 | } 85 | 86 | @include it('Supports not-equal alias') { 87 | @include not-equal(5, 10 * 2); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | 3 | const babelParser = require('@babel/eslint-parser'); 4 | const eslint = require('@eslint/js'); 5 | const vitest = require('@vitest/eslint-plugin'); 6 | const { defineConfig } = require('eslint/config'); 7 | const prettier = require('eslint-config-prettier'); 8 | const importPlugin = require('eslint-plugin-import'); 9 | const simpleImportSort = require('eslint-plugin-simple-import-sort'); 10 | const globals = require('globals'); 11 | const tseslint = require('typescript-eslint'); 12 | 13 | module.exports = defineConfig( 14 | { 15 | ignores: [ 16 | '.git/*', 17 | '.nyc_output/*', 18 | '.vscode/*', 19 | '.yarn/*', 20 | '.yarnrc.yml', 21 | 'coverage/*', 22 | 'dist/*', 23 | 'docs/*', 24 | 'node_modules/*', 25 | ], 26 | }, 27 | eslint.configs.recommended, 28 | tseslint.configs.recommended, 29 | importPlugin.flatConfigs.recommended, 30 | prettier, 31 | { 32 | files: ['**/*.{js,mjs,cjs,ts,cts,mts}'], 33 | languageOptions: { 34 | parser: babelParser, 35 | globals: { 36 | ...globals.node, 37 | ...globals.es2022, 38 | }, 39 | parserOptions: { 40 | sourceType: 'script', 41 | }, 42 | }, 43 | settings: { 44 | 'import/resolver': { 45 | typescript: {}, 46 | }, 47 | 'import/external-module-folders': ['node_modules'], 48 | }, 49 | rules: { 50 | 'no-console': 1, 51 | 'no-warning-comments': ['warn', { terms: ['todo', 'fixme', '@@@'] }], 52 | 'import/first': 'warn', 53 | 'import/newline-after-import': 'warn', 54 | 'import/no-duplicates': ['error', { 'prefer-inline': true }], 55 | 'import/order': [ 56 | 'warn', 57 | { 'newlines-between': 'always', alphabetize: { order: 'asc' } }, 58 | ], 59 | 'import/named': 'warn', 60 | }, 61 | }, 62 | { 63 | files: ['src/**/*.{js,mjs,cjs,ts,cts,mts}'], 64 | languageOptions: { 65 | parser: tseslint.parser, 66 | globals: { 67 | ...globals.browser, 68 | ...globals.es2022, 69 | }, 70 | parserOptions: { 71 | sourceType: 'module', 72 | }, 73 | }, 74 | plugins: { 75 | 'simple-import-sort': simpleImportSort, 76 | }, 77 | rules: { 78 | 'simple-import-sort/imports': 'warn', 79 | 'simple-import-sort/exports': 'warn', 80 | 'import/order': 'off', 81 | }, 82 | }, 83 | { 84 | files: ['test/**/*.{js,ts}'], 85 | languageOptions: { 86 | parser: babelParser, 87 | globals: { 88 | ...vitest.environments.env.globals, 89 | ...globals.mocha, 90 | ...globals.es2022, 91 | }, 92 | parserOptions: { 93 | sourceType: 'script', 94 | }, 95 | }, 96 | plugins: { 97 | vitest, 98 | 'simple-import-sort': simpleImportSort, 99 | }, 100 | rules: { 101 | ...vitest.configs.recommended.rules, 102 | }, 103 | }, 104 | ); 105 | -------------------------------------------------------------------------------- /sass/data/_results.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:list'; 2 | @use 'sass:map'; 3 | @use 'utilities'; 4 | 5 | // Get Result 6 | // ---------- 7 | /// Compare two values, and return a `pass` or `fail` result. 8 | /// @access private 9 | /// @group private-context 10 | /// @param {*} $assert - Value to consider 11 | /// @param {*} $expected - Expected match 12 | /// @param {bool} $unequal [false] - 13 | /// Set to `true` if the comparison is expected to fail 14 | /// @return {'pass' | 'fail'} The result of a test 15 | @function get-result($assert, $expected, $unequal: false) { 16 | $is-equal: $assert == $expected; 17 | $assert-equal: not $unequal; 18 | 19 | @if ($assert-equal != $is-equal) { 20 | @return 'fail'; 21 | } 22 | 23 | @return 'pass'; 24 | } 25 | 26 | // Results 27 | // ------- 28 | /// Global test-results map 29 | /// @access private 30 | /// @group private-context 31 | /// @type Map 32 | $results: ( 33 | 'run': 0, 34 | 'pass': 0, 35 | 'fail': 0, 36 | 'output-to-css': 0, 37 | ); 38 | 39 | // Update 40 | // ------ 41 | /// Update global results data 42 | /// @access private 43 | /// @group private-context 44 | /// @param {'pass' | 'fail' | 'output-to-css'} $result - 45 | @mixin update($result) { 46 | $update: ( 47 | 'run': 1, 48 | $result: 1, 49 | ); 50 | $results: utilities.map-increment($results, $update) !global; 51 | $test-result: null !global; 52 | } 53 | 54 | // Test Results 55 | // ------------ 56 | // Local flags for tracking assertion results in a test 57 | /// @access private 58 | /// @group private-context 59 | /// @type String 60 | $test-result: null; 61 | 62 | // Update Test 63 | // ----------- 64 | /// Update test result flag with new data 65 | /// @access private 66 | /// @group private-context 67 | /// @param {'pass' | 'fail' | 'output-to-css'} $result - 68 | @mixin update-test($result) { 69 | @if ($result == 'fail') or ($test-result == 'fail') { 70 | $test-result: 'fail' !global; 71 | } @else if ($test-result != 'output-to-css') { 72 | $test-result: $result !global; 73 | } 74 | } 75 | 76 | // Results Message 77 | // --------------- 78 | /// Report message 79 | /// @access private 80 | /// @group private-message 81 | /// @param {Bool} $linebreak [false] - 82 | /// Return message either as a single line or in multiple lines 83 | /// @param {Map} $results [$results] - 84 | /// A map of run, pass, fail, and output-to-css results 85 | /// @return {String} - 86 | /// Single or multi-line message for reporting 87 | @function results-message($linebreak: false, $results: $results) { 88 | $run: map.get($results, 'run'); 89 | $items-label: 'Tests'; 90 | 91 | @if ($run == 1) { 92 | $items-label: 'Test'; 93 | } 94 | 95 | $pass: map.get($results, 'pass'); 96 | $fail: map.get($results, 'fail'); 97 | $output-to-css: map.get($results, 'output-to-css'); 98 | $items: '#{$run} #{$items-label}'; 99 | $passed: '#{$pass} Passed'; 100 | $failed: '#{$fail} Failed'; 101 | $compiled: null; 102 | 103 | @if ($output-to-css > 0) { 104 | $compiled: '#{$output-to-css} Output to CSS'; 105 | } 106 | 107 | // Linebreaks 108 | @if $linebreak { 109 | $message: ('#{$items}:', '- #{$passed}', '- #{$failed}'); 110 | 111 | @if $compiled { 112 | $message: list.append($message, '- #{$compiled}'); 113 | } 114 | 115 | @return $message; 116 | } 117 | 118 | // No Linebreaks 119 | $message: '#{$items}, #{$passed}, #{$failed}'; 120 | 121 | @if $compiled { 122 | $message: '#{$message}, #{$compiled}'; 123 | } 124 | 125 | @return $message; 126 | } 127 | -------------------------------------------------------------------------------- /test/scss/data/_details.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/data/context'; 3 | @use '../../../sass/data/details'; 4 | @use 'sass:color'; 5 | @use 'sass:list'; 6 | @use 'sass:meta'; 7 | @use 'sass:string'; 8 | @use 'sass:math'; 9 | 10 | // Pass Details 11 | @include describe('Pass Details') { 12 | @include it('Properly output a passing assertion result') { 13 | @include assert('passing test') { 14 | @include output { 15 | @include details.pass-details; 16 | } 17 | 18 | @include expect { 19 | /* ✔ [output] passing test */ 20 | } 21 | } 22 | } 23 | } 24 | 25 | // Fail Details 26 | @include describe('Fail Details') { 27 | @include it('Compiles full failure details') { 28 | @include assert { 29 | @include output(false) { 30 | @include context.context('assert', '[assert-equal] Test Assertion'); 31 | @include details.fail-details(math.div(1, 3), 0.333, not 'terminal'); 32 | @include context.context-pop; 33 | } 34 | 35 | @include expect(false) { 36 | /* ✖ FAILED: [assert-equal] Test Assertion */ 37 | /* - Output: [number] 0.3333333333333333 */ 38 | /* - Expected: [number] 0.333 */ 39 | /* - Details: numbers may need to be rounded before comparison */ 40 | /* - Module: Fail Details */ 41 | /* - Test: Compiles full failure details */ 42 | } 43 | } 44 | } 45 | } 46 | 47 | // Variable Details 48 | @include describe('Variable Details') { 49 | @include it('Number') { 50 | @include assert-equal(details.variable-details(1em), '[number] 1em'); 51 | } 52 | 53 | @include it('Color') { 54 | @include assert-equal(details.variable-details(#ccc), '[color] #ccc'); 55 | } 56 | 57 | @include it('Map') { 58 | $map: ( 59 | 'key': 'value', 60 | ); 61 | 62 | @include assert-equal( 63 | details.variable-details($map), 64 | '[map] ("key": "value")' 65 | ); 66 | } 67 | 68 | @include it('Bracketed List') { 69 | $list: [ 'one' 'two']; 70 | 71 | @include assert-equal( 72 | details.variable-details($list), 73 | '[list] ["one" "two"]' 74 | ); 75 | } 76 | } 77 | 78 | // EdgeFail Notes 79 | @include describe('Edgefail Notes') { 80 | @include it('Type mismatch') { 81 | $message: '- Details: variable types do not match'; 82 | 83 | @include assert-equal(details.edgefail-notes(1em, '1em'), $message); 84 | 85 | $list: ('one', 'two'); 86 | $string: meta.inspect($list); 87 | 88 | @include assert-equal(details.edgefail-notes($list, $string), $message); 89 | } 90 | 91 | $string-one: string.unquote('hello world'); 92 | $string-two: string.quote('hello world'); 93 | 94 | @if ($string-one != $string-two) { 95 | @include it('String quotes') { 96 | $message: '- Details: string quotations do not match'; 97 | 98 | @include assert-equal( 99 | details.edgefail-notes($string-one, $string-two), 100 | $message 101 | ); 102 | } 103 | } 104 | 105 | @include it('Number Rounding') { 106 | $message: '- Details: numbers may need to be rounded before comparison'; 107 | 108 | @include assert-equal( 109 | details.edgefail-notes(math.div(1, 3), 0.3), 110 | $message 111 | ); 112 | } 113 | 114 | @include it('List Separators') { 115 | $message: '- Details: list-separators do not match'; 116 | 117 | $space: list.join((), 'one', space); 118 | $comma: list.join((), 'one', comma); 119 | 120 | @include assert-equal(details.edgefail-notes($space, $comma), $message); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /test/scss/data/_context.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/data/context'; 3 | @use 'sass:list'; 4 | 5 | // Adding and removing context 6 | @include describe('Context [mixin] & Context-Pop') { 7 | @include it('Adds scope and name to context') { 8 | @each $entry-scope, $entry-name in context.$context { 9 | @include assert-unequal( 10 | $entry-scope, 11 | 'fake', 12 | 'Confirm that there is currently no "fake" scope' 13 | ); 14 | } 15 | 16 | @include context.context('fake', 'this scope is not real'); 17 | 18 | @include assert-equal( 19 | context.context('fake'), 20 | 'this scope is not real', 21 | 'Sets the value of scope "fake" to "this scope is not real"' 22 | ); 23 | 24 | @include context.context-pop; 25 | 26 | @each $entry-scope, $entry-name in context.$context { 27 | @include assert-unequal( 28 | $entry-scope, 29 | 'fake', 30 | 'Confirm that "fake" scope has been removed' 31 | ); 32 | } 33 | } 34 | } 35 | 36 | @include describe('Output Context') { 37 | $empty-list: (); 38 | $before: context.$output-context; 39 | @include context.output-context('assert'); 40 | $assert: context.$output-context; 41 | @include context.output-context('expect'); 42 | $expect: context.$output-context; 43 | @include context.output-context('output'); 44 | $output: context.$output-context; 45 | @include context.output-context(null); 46 | $reset: context.$output-context; 47 | 48 | @include it('Appends new context') { 49 | @include assert-equal($before, $empty-list, 'Check initial value'); 50 | @include assert-equal($assert, list.join((), 'assert')); 51 | @include assert-equal($expect, ('assert' 'expect')); 52 | @include assert-equal($output, ('assert' 'expect' 'output')); 53 | } 54 | 55 | @include it('Resets context') { 56 | @include assert-equal($reset, $before); 57 | } 58 | } 59 | 60 | // valid output context 61 | @include describe('Validate Output Context') { 62 | @include it('allows multiple contains-string') { 63 | context.$output-context: 'assert' 'output' 'contains-string'; 64 | 65 | @include assert-equal( 66 | context.validate-output-context('contains-string'), 67 | context.$output-context 68 | ); 69 | 70 | context.$output-context: (); 71 | } 72 | 73 | @include it('allows multiple contains') { 74 | context.$output-context: 'assert' 'output' 'contains'; 75 | 76 | @include assert-equal( 77 | context.validate-output-context('contains'), 78 | context.$output-context 79 | ); 80 | 81 | context.$output-context: (); 82 | } 83 | } 84 | 85 | // Inspecting context 86 | @include describe('Context [function] & Context All') { 87 | @include it('Returns current module context') { 88 | @include assert-equal( 89 | context.context('module'), 90 | 'Context [function] & Context All' 91 | ); 92 | } 93 | 94 | @include it('Returns current test context') { 95 | @include assert-equal( 96 | context.context('test'), 97 | 'Returns current test context' 98 | ); 99 | } 100 | 101 | @include describe('Context [Nested]') { 102 | @include it('Returns the innermost module name') { 103 | @include assert-equal(context.context('module'), 'Context [Nested]'); 104 | } 105 | } 106 | 107 | @include describe('Context All [Nested]') { 108 | @include it('Returns the current stack of module names') { 109 | @include assert-equal( 110 | context.context-all('module'), 111 | 'Context [function] & Context All' 'Context All [Nested]' 112 | ); 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/css/test-errors.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | /* # Module: Validate Output Context */ 3 | /* --------------------------------- */ 4 | /* Test: unknown context name */ 5 | /* ✔ [assert-equal] unknown context name */ 6 | /* */ 7 | /* Test: duplicate assert */ 8 | /* ✔ [assert-equal] duplicate assert */ 9 | /* */ 10 | /* Test: duplicate output/expect */ 11 | /* ✔ [assert-equal] duplicate output/expect */ 12 | /* ✔ [assert-equal] duplicate output/expect */ 13 | /* */ 14 | /* Test: missing assert */ 15 | /* ✔ [assert-equal] missing assert */ 16 | /* ✔ [assert-equal] missing assert */ 17 | /* */ 18 | /* Test: wrong length */ 19 | /* ✔ [assert-equal] wrong length */ 20 | /* */ 21 | /* */ 22 | /* # Module: Report */ 23 | /* ---------------- */ 24 | /* Test: Fail on Error */ 25 | /* ASSERT: */ 26 | /* OUTPUT */ 27 | .test-output { 28 | /* # SUMMARY ---------- */ 29 | /* 6 Tests: */ 30 | /* - 5 Passed */ 31 | /* - 1 Failed */ 32 | /* Stats: */ 33 | /* - 4 Modules */ 34 | /* - 6 Tests */ 35 | /* - 25 Assertions */ 36 | /* -------------------- */ 37 | /* ERROR [report]: */ 38 | /* 1 test failed */ 39 | } 40 | 41 | /* END_OUTPUT */ 42 | /* EXPECTED */ 43 | .test-output { 44 | /* # SUMMARY ---------- */ 45 | /* 6 Tests: */ 46 | /* - 5 Passed */ 47 | /* - 1 Failed */ 48 | /* Stats: */ 49 | /* - 4 Modules */ 50 | /* - 6 Tests */ 51 | /* - 25 Assertions */ 52 | /* -------------------- */ 53 | /* ERROR [report]: */ 54 | /* 1 test failed */ 55 | } 56 | 57 | /* END_EXPECTED */ 58 | /* END_ASSERT */ 59 | /* */ 60 | /* Test: Bad results */ 61 | /* ASSERT: */ 62 | /* OUTPUT */ 63 | .test-output { 64 | /* # SUMMARY ---------- */ 65 | /* 6 Tests: */ 66 | /* - 4 Passed */ 67 | /* - 1 Failed */ 68 | /* Stats: */ 69 | /* - 4 Modules */ 70 | /* - 6 Tests */ 71 | /* - 25 Assertions */ 72 | /* -------------------- */ 73 | /* ERROR [report]: */ 74 | /* The results don’t add up. Are all your tests properly structured? */ 75 | } 76 | 77 | /* END_OUTPUT */ 78 | /* EXPECTED */ 79 | .test-output { 80 | /* # SUMMARY ---------- */ 81 | /* 6 Tests: */ 82 | /* - 4 Passed */ 83 | /* - 1 Failed */ 84 | /* Stats: */ 85 | /* - 4 Modules */ 86 | /* - 6 Tests */ 87 | /* - 25 Assertions */ 88 | /* -------------------- */ 89 | /* ERROR [report]: */ 90 | /* The results don’t add up. Are all your tests properly structured? */ 91 | } 92 | 93 | /* END_EXPECTED */ 94 | /* END_ASSERT */ 95 | /* */ 96 | /* */ 97 | /* # Module: True Error [function] */ 98 | /* ------------------------------- */ 99 | /* Test: Allow errors to return without blocking compilation */ 100 | /* ✔ [assert-equal] Allow errors to return without blocking compilation */ 101 | /* */ 102 | /* */ 103 | /* # Module: True Error [mixin] */ 104 | /* ---------------------------- */ 105 | /* Test: Allow errors to output without blocking compilation */ 106 | /* ASSERT: */ 107 | /* OUTPUT */ 108 | .test-output { 109 | /* ERROR [error test]: */ 110 | /* This is a test error message */ 111 | } 112 | 113 | /* END_OUTPUT */ 114 | /* EXPECTED */ 115 | .test-output { 116 | /* ERROR [error test]: */ 117 | /* This is a test error message */ 118 | } 119 | 120 | /* END_EXPECTED */ 121 | /* END_ASSERT */ 122 | /* */ 123 | /* */ 124 | /* # Module: Imported $terminal-output setting */ 125 | /* ------------------------------------------- */ 126 | /* Test: Is prefixed as $true-terminal-output */ 127 | /* ✔ [assert-true] Is prefixed as $true-terminal-output */ 128 | /* */ 129 | /* */ 130 | /* # SUMMARY ---------- */ 131 | /* 10 Tests: */ 132 | /* - 7 Passed */ 133 | /* - 0 Failed */ 134 | /* - 3 Output to CSS */ 135 | /* Stats: */ 136 | /* - 5 Modules */ 137 | /* - 10 Tests */ 138 | /* - 12 Assertions */ 139 | /* -------------------- */ 140 | 141 | /*# sourceMappingURL=test-errors.css.map */ 142 | -------------------------------------------------------------------------------- /sass/assert/_utils.scss: -------------------------------------------------------------------------------- 1 | @use '../config'; 2 | @use '../data'; 3 | @use 'sass:map'; 4 | 5 | // Content 6 | // ------- 7 | /// Wrap output assertions with the proper 8 | /// CSS comments & selectors. 9 | /// 10 | /// @access private 11 | /// @group assert-utils 12 | /// 13 | /// @param {string} $type - 14 | /// The type of content being output (eg `assert`/`expect`) 15 | /// @param {bool} $selector [true] - 16 | /// Optionally wrap the contents in a `.test-output` selector block 17 | /// @param {string} $description [null] - 18 | /// Optional description of the assertion being tested. 19 | /// 20 | /// @output - Content wrapped in appropriate comments & optional selector 21 | @mixin content($type, $selector: true, $description: null) { 22 | @include data.output-context($type); 23 | 24 | $block: map.get(config.$output, $type); 25 | $start: $block; 26 | 27 | @if ($description or ($block == 'ASSERT')) { 28 | $start: '#{$block}: #{$description}'; 29 | } 30 | 31 | @include config.message(' #{$start} ', 'comments'); 32 | 33 | @if $selector { 34 | .test-output { 35 | @content; 36 | } 37 | } @else { 38 | @content; 39 | } 40 | 41 | @include config.message(' END_#{$block} ', 'comments'); 42 | } 43 | 44 | // Setup 45 | // ----- 46 | /// Setup the proper context for value assertions before testing 47 | /// 48 | /// @access private 49 | /// @group assert-utils 50 | /// 51 | /// @param {string} $type - 52 | /// The type of content being output (eg `assert`/`expect`) 53 | /// @param {string} $description [null] - 54 | /// Optional description of the assertion being tested. 55 | @mixin setup($name, $description: null) { 56 | $description: $description or data.context('test'); 57 | 58 | @include data.context('assert', '[#{$name}] #{$description}'); 59 | } 60 | 61 | // Strike 62 | // ------ 63 | /// Report assertion results to the current test, 64 | /// update assertion stats, 65 | /// and remove current assertion from context. 66 | /// 67 | /// @access private 68 | /// @group assert-utils 69 | /// 70 | /// @param {string} $type - 71 | /// The type of content being output (eg `assert`/`expect`) 72 | /// @param {string} $description [null] - 73 | /// Optional description of the assertion being tested. 74 | @mixin strike($result, $output: false) { 75 | @include data.update-test($result); 76 | @include data.update-stats-count('assertions'); 77 | @include data.context-pop; 78 | 79 | @if ($output) { 80 | @include data.output-context(null); 81 | } 82 | } 83 | 84 | // Value Result 85 | // ------------ 86 | /// Get an official result, 87 | /// record it in the database and output, 88 | /// provide details as necessary, 89 | /// and end the assertion. 90 | /// 91 | /// @access private 92 | /// @group assert-utils 93 | /// 94 | /// @param {*} $assert - Value to consider 95 | /// @param {*} $expected - Expected match 96 | /// @param {bool} $unequal [false] - 97 | /// Set to `true` if the comparison is expected to fail 98 | /// 99 | /// @output - Document the passing or failing result of the test 100 | @mixin result( 101 | $assert, 102 | $expected, 103 | $unequal: false, 104 | $terminal: config.$terminal-output 105 | ) { 106 | $result: data.get-result($assert, $expected, $unequal); 107 | 108 | @if $result == 'pass' { 109 | @include data.pass-details; 110 | } @else { 111 | @include data.fail-details($assert, $expected, $terminal); 112 | } 113 | 114 | @include strike($result); 115 | } 116 | 117 | // Is Truthy 118 | // --------- 119 | /// Check that a value is truthy 120 | /// (empty lists and strings return false) 121 | /// 122 | /// @access private 123 | /// @group assert-utils 124 | /// @param {*} $assert - Value to consider 125 | /// @return {bool} - 126 | @function is-truthy($assert) { 127 | $not: (not not $assert); 128 | $list: ($assert != ()); 129 | $string: ($assert != ''); 130 | 131 | @if ($not and $list and $string) { 132 | @return true; 133 | } 134 | 135 | @return false; 136 | } 137 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sass-true", 3 | "title": "True", 4 | "version": "10.1.0", 5 | "description": "Unit testing for Sass.", 6 | "keywords": [ 7 | "unit-test", 8 | "test", 9 | "sass", 10 | "libsass", 11 | "TDD", 12 | "eyeglass-module" 13 | ], 14 | "homepage": "https://www.oddbird.net/true/", 15 | "license": "BSD-3-Clause", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/oddbird/true.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/oddbird/true/issues" 22 | }, 23 | "author": "Miriam Suzanne ", 24 | "contributors": [ 25 | "Scott Davis ", 26 | "Chris Eppstein ", 27 | "Carl Meyer ", 28 | "David Glick ", 29 | "Jonny Gerig Meyer ", 30 | "Ed Rivas " 31 | ], 32 | "files": [ 33 | "lib/**/*", 34 | "sass/**/*", 35 | "_index.scss", 36 | "CHANGELOG.md", 37 | "LICENSE.txt", 38 | "README.md" 39 | ], 40 | "engines": { 41 | "node": ">=20" 42 | }, 43 | "scripts": { 44 | "test:src": "run-s build:js && USE_BUILT=true vitest run", 45 | "test:lib": "USE_BUILT=true mocha", 46 | "test": "run-s build test:src test:lib", 47 | "prettier:js": "prettier --write '**/*.js'", 48 | "prettier:other": "prettier --write '**/*.{json,md,yml,scss}'", 49 | "eslint": "yarn eslint:ci --fix", 50 | "eslint:ci": "eslint src test", 51 | "lint": "run-p lint:js lint:sass prettier:other", 52 | "lint:js": "run-s prettier:js eslint", 53 | "lint:sass": "yarn lint:sass:ci --fix", 54 | "lint:sass:ci": "stylelint '**/*.scss'", 55 | "lint:ci": "run-p eslint:ci lint:sass:ci", 56 | "docs": "sassdoc sass/", 57 | "build:sass": "sass test/scss/test.scss test/css/test.css --load-path ./sass/", 58 | "build:sass:errors": "sass test/scss/test-errors.scss test/css/test-errors.css --load-path ./sass/ --silence-deprecation=import", 59 | "build:js": "tsc", 60 | "build": "run-p build:js build:sass build:sass:errors", 61 | "commit": "run-s lint test", 62 | "release": "run-s commit docs", 63 | "prepack": "yarn run release" 64 | }, 65 | "dependencies": { 66 | "@prettier/sync": "^0.6.1", 67 | "jest-diff": "^30.2.0", 68 | "postcss": "^8.5.6", 69 | "prettier": "^3.7.4" 70 | }, 71 | "peerDependencies": { 72 | "sass": ">=1.45.0", 73 | "sass-embedded": ">=1.45.0" 74 | }, 75 | "peerDependenciesMeta": { 76 | "sass": { 77 | "optional": true 78 | }, 79 | "sass-embedded": { 80 | "optional": true 81 | } 82 | }, 83 | "devDependencies": { 84 | "@babel/core": "^7.28.5", 85 | "@babel/eslint-parser": "^7.28.5", 86 | "@babel/preset-env": "^7.28.5", 87 | "@babel/preset-typescript": "^7.28.5", 88 | "@eslint/js": "^9.39.2", 89 | "@vitest/coverage-v8": "^4.0.15", 90 | "@vitest/eslint-plugin": "^1.5.2", 91 | "chai": "^4.5.0", 92 | "eslint": "^9.39.2", 93 | "eslint-config-prettier": "^10.1.8", 94 | "eslint-import-resolver-typescript": "^4.4.4", 95 | "eslint-plugin-import": "^2.32.0", 96 | "eslint-plugin-simple-import-sort": "^12.1.1", 97 | "globals": "^16.5.0", 98 | "mocha": "^11.7.5", 99 | "npm-run-all": "^4.1.5", 100 | "sass": "^1.96.0", 101 | "sass-embedded": "^1.96.0", 102 | "sassdoc": "^2.7.4", 103 | "sassdoc-theme-herman": "^7.0.0", 104 | "stylelint": "^16.26.1", 105 | "stylelint-config-standard-scss": "^16.0.0", 106 | "typescript": "^5.9.3", 107 | "typescript-eslint": "^8.50.0", 108 | "vitest": "^4.0.15" 109 | }, 110 | "main": "./lib/index.js", 111 | "types": "./lib/index.d.ts", 112 | "exports": { 113 | "types": "./lib/index.d.ts", 114 | "sass": "./_index.scss", 115 | "default": "./lib/index.js" 116 | }, 117 | "eyeglass": { 118 | "needs": "*", 119 | "name": "true", 120 | "sassDir": "./sass/", 121 | "exports": false 122 | }, 123 | "packageManager": "yarn@4.12.0" 124 | } 125 | -------------------------------------------------------------------------------- /test/scss/data/_results.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/data/results'; 3 | @use 'sass:map'; 4 | 5 | @include describe('Get Result') { 6 | @include it('Equal Pass') { 7 | @include assert-equal(results.get-result(1em, 1em), 'pass'); 8 | } 9 | 10 | @include it('Equal Fail') { 11 | @include assert-equal(results.get-result(1em, true), 'fail'); 12 | } 13 | 14 | @include it('Unequal pass') { 15 | @include assert-equal(results.get-result(true, 'hello', 'unequal'), 'pass'); 16 | } 17 | 18 | @include it('Unequal fail') { 19 | @include assert-equal( 20 | results.get-result('hello', 'hello', 'unequal'), 21 | 'fail' 22 | ); 23 | } 24 | } 25 | 26 | @include describe('Update Results') { 27 | $before: results.$results; 28 | @include results.update('pass'); 29 | $actual: results.$results; 30 | results.$results: $before; 31 | 32 | @include it('Add one run') { 33 | @include assert-equal(map.get($actual, 'run'), map.get($before, 'run') + 1); 34 | } 35 | 36 | @include it('Add one pass') { 37 | @include assert-equal( 38 | map.get($actual, 'pass'), 39 | map.get($before, 'pass') + 1 40 | ); 41 | } 42 | 43 | @include it('Fail counts are left as-is') { 44 | @include assert-equal(map.get($actual, 'fail'), map.get($before, 'fail')); 45 | } 46 | 47 | @include it('Output counts are left as-is') { 48 | @include assert-equal( 49 | map.get($actual, 'output-to-css'), 50 | map.get($before, 'output-to-css') 51 | ); 52 | } 53 | } 54 | 55 | @include describe('Update Test') { 56 | $before: results.$test-result; 57 | @include results.update-test('pass'); 58 | $pass: results.$test-result; 59 | @include results.update-test('output-to-css'); 60 | $css: results.$test-result; 61 | @include results.update-test('pass'); 62 | $css2: results.$test-result; 63 | @include results.update-test('fail'); 64 | $fail: results.$test-result; 65 | @include results.update-test('pass'); 66 | @include results.update-test('output-to-css'); 67 | $fail2: results.$test-result; 68 | results.$test-result: $before; 69 | 70 | @include it('Updates global test-result') { 71 | @include assert-equal($before, null, 'confirm the default state'); 72 | @include assert-equal($pass, 'pass', 'confirm updated test-result'); 73 | } 74 | 75 | @include it('Output-to-css overrides pass') { 76 | @include assert-equal($css, 'output-to-css'); 77 | } 78 | 79 | @include it('Pass does not override output-to-css') { 80 | @include assert-equal($css, $css2); 81 | } 82 | 83 | @include it('Fail overrides everything') { 84 | @include assert-equal($fail, 'fail'); 85 | } 86 | 87 | @include it('Nothing overrides fail') { 88 | @include assert-equal($fail, $fail2); 89 | } 90 | } 91 | 92 | @include describe('Results Message') { 93 | $test-map: ( 94 | 'run': 10, 95 | 'pass': 6, 96 | 'fail': 1, 97 | 'output-to-css': 3, 98 | ); 99 | 100 | @include it('Single Line') { 101 | @include assert-equal( 102 | results.results-message($results: $test-map), 103 | '10 Tests, 6 Passed, 1 Failed, 3 Output to CSS' 104 | ); 105 | } 106 | 107 | @include it('Linebreaks') { 108 | $message: ('10 Tests:', '- 6 Passed', '- 1 Failed', '- 3 Output to CSS'); 109 | @include assert-equal( 110 | results.results-message('linebreaks', $test-map), 111 | $message 112 | ); 113 | } 114 | 115 | $test-map: map.merge( 116 | $test-map, 117 | ( 118 | 'output-to-css': 0, 119 | ) 120 | ); 121 | 122 | @include it('No output tests') { 123 | @include assert-equal( 124 | results.results-message($results: $test-map), 125 | '10 Tests, 6 Passed, 1 Failed' 126 | ); 127 | } 128 | 129 | $test-map: map.merge( 130 | $test-map, 131 | ( 132 | 'run': 1, 133 | ) 134 | ); 135 | 136 | @include it('Single test') { 137 | @include assert-equal( 138 | results.results-message($results: $test-map), 139 | '1 Test, 6 Passed, 1 Failed' 140 | ); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /sass/data/_details.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:list'; 2 | @use 'sass:meta'; 3 | @use 'sass:string'; 4 | @use '../config'; 5 | @use 'context' as data; 6 | 7 | // Test Result Output 8 | // ================== 9 | 10 | // Pass Details 11 | // ------------ 12 | /// Ouptut a success message for passing tests 13 | /// @access private 14 | /// @group assert-utils 15 | /// @output - 16 | /// a passing-test comment with the name of the passing assertion 17 | @mixin pass-details { 18 | $assertion: data.context('assert'); 19 | 20 | @include config.message('✔ #{$assertion}', 'comments', 2); 21 | } 22 | 23 | // Fail Details 24 | // ------------ 25 | /// Failure message, with appropriate details 26 | /// to help you debug various common problems. 27 | /// @access private 28 | /// @group assert-utils 29 | /// @param {*} $assert - The assertion value 30 | /// @param {*} $expected - The expected value 31 | /// @param {bool} $terminal [$terminal-output] - 32 | /// whether to use the terminal as an output stream 33 | @mixin fail-details($assert, $expected, $terminal: config.$terminal-output) { 34 | $location: 'comments'; 35 | 36 | @if $terminal { 37 | $location: ('comments', 'debug'); 38 | } 39 | 40 | // Title Messages 41 | $assertion: data.context('assert'); 42 | $title: 'FAILED: #{$assertion}'; 43 | 44 | @include config.message('✖ #{$title}', $location, 2); 45 | 46 | // Details Message 47 | $out: variable-details($assert); 48 | $exp: variable-details($expected); 49 | $details: ('- Output: #{$out}', '- Expected: #{$exp}'); 50 | 51 | @include config.message($details, $location, 4); 52 | 53 | // Edge Failure Notes 54 | @if string.index($assertion, 'assert-equal') { 55 | $notes: edgefail-notes($assert, $expected); 56 | 57 | @if $notes { 58 | @include config.message($notes, $location, 4); 59 | } 60 | } 61 | 62 | // Context Message 63 | $module: data.context('module'); 64 | $test: data.context('test'); 65 | $context: ('- Module: #{$module}', '- Test: #{$test}'); 66 | 67 | @include config.message($context, $location, 4); 68 | 69 | // Terminal Warning 70 | @if $terminal { 71 | @include config.message($assertion, 'warn'); 72 | } 73 | } 74 | 75 | // Variable Details 76 | // ---------------- 77 | /// Provide the details (type, list-separator, quotation) 78 | /// for a given variable - 79 | /// used to provide context in failure reporting 80 | /// @access private 81 | /// @group assert-utils 82 | /// @param {*} $var - 83 | /// Pass in asserted and expected values individually 84 | /// to retrieve comparable details for both 85 | @function variable-details($var) { 86 | $inspect: meta.inspect($var); 87 | $type: meta.type-of($var); 88 | 89 | @return '[#{$type}] #{$inspect}'; 90 | } 91 | 92 | // EdgeFail Notes 93 | // -------------- 94 | /// There are some common test failures that can be confusing, 95 | /// where results look identical in the output, 96 | /// but represent different values internally. 97 | /// This function looks for those edge-case failures 98 | /// and adds a clarifying note in the results. 99 | /// @access private 100 | /// @group assert-utils 101 | /// @param {*} $one - One of the values being compared 102 | /// @param {*} $two - The other calue being compared 103 | /// @return {null | string} - 104 | /// A helpful note related to your edge-case, when appropriate 105 | @function edgefail-notes($one, $two) { 106 | $one-type: meta.type-of($one); 107 | $two-type: meta.type-of($two); 108 | $note: null; 109 | $pre: '- Details: '; 110 | 111 | // Type 112 | @if ($one-type != $two-type) { 113 | $message: 'variable types do not match'; 114 | 115 | @return $pre + $message; 116 | } 117 | 118 | // List Separators 119 | @if $one-type == 'list' { 120 | @if (list.join((), $one, comma) == list.join((), $two, comma)) { 121 | $message: 'list-separators do not match'; 122 | 123 | @return $pre + $message; 124 | } 125 | } 126 | 127 | // String Quotes 128 | @if $one-type == 'string' { 129 | @if (string.unquote($one) == string.unquote($two)) { 130 | $message: 'string quotations do not match'; 131 | 132 | @return $pre + $message; 133 | } 134 | } 135 | 136 | @if list.index(('number', 'color'), $one-type) { 137 | $message: '#{$one-type}s may need to be rounded before comparison'; 138 | 139 | @return $pre + $message; 140 | } 141 | 142 | @return $pre; 143 | } 144 | -------------------------------------------------------------------------------- /sass/assert/_values.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:meta'; 2 | @use 'utils'; 3 | 4 | /// # Testing Return Values 5 | /// @group api-assert-values 6 | 7 | // Assert True 8 | // ----------- 9 | /// Assert that a parameter is truthy. 10 | /// - Empty lists and strings are excluded from default Sass truthyness. 11 | /// Assertions are used inside the `test()` mixin 12 | /// 13 | /// @group api-assert-values 14 | /// 15 | /// @param {*} $assert - 16 | /// Asserted value to test 17 | /// @param {string} $description [null] - 18 | /// Description of the assertion being tested. 19 | /// A `null` of `false` value generates a default description. 20 | /// 21 | /// @example scss - 22 | /// @include true.test('Non-empty strings are truthy') { 23 | /// @include true.assert-true( 24 | /// 'Hello World', 25 | /// 'You can optionally describe the assertion...'); 26 | /// } 27 | /// @example scss - 28 | /// @include true.it('Non-empty strings are truthy') { 29 | /// @include true.is-truthy( 30 | /// 'Hello World', 31 | /// 'You can optionally describe the assertion...'); 32 | /// } 33 | @mixin assert-true($assert, $description: null) { 34 | @include utils.setup('assert-true', $description); 35 | 36 | $truthy: utils.is-truthy($assert); 37 | 38 | @include utils.result($truthy, true); 39 | } 40 | 41 | /// @alias assert-true 42 | @mixin is-truthy($assert, $description: null) { 43 | @include assert-true($assert, $description); 44 | } 45 | 46 | // Assert False 47 | // ------------ 48 | /// Assert that a parameter is falsey. 49 | /// - Empty lists and strings are added to default Sass falseyness. 50 | /// to define the expected results of the test. 51 | /// 52 | /// @group api-assert-values 53 | /// 54 | /// @param {*} $assert - 55 | /// Asserted value to test 56 | /// @param {string} $description [null] - 57 | /// Description of the assertion being tested. 58 | /// A `null` of `false` value generates a default description. 59 | /// 60 | /// @example scss - 61 | /// @include true.test('Empty strings are falsey') { 62 | /// @include true.assert-false( 63 | /// '', 64 | /// 'You can optionally describe the assertion...'); 65 | /// } 66 | @mixin assert-false($assert, $description: null) { 67 | @include utils.setup('assert-false', $description); 68 | 69 | $falsey: not utils.is-truthy($assert); 70 | 71 | @include utils.result($falsey, true); 72 | } 73 | 74 | /// @alias assert-false 75 | @mixin is-falsy($assert, $description: null) { 76 | @include assert-false($assert, $description); 77 | } 78 | 79 | // Assert Equal 80 | // ------------ 81 | /// Assert that two parameters are `equal` 82 | /// Assertions are used inside the `test()` mixin 83 | /// to define the expected results of the test. 84 | /// 85 | /// @group api-assert-values 86 | /// 87 | /// @param {*} $assert - 88 | /// Asserted value to test 89 | /// @param {*} $expected - 90 | /// Expected match 91 | /// @param {string} $description [null] - 92 | /// Description of the assertion being tested 93 | /// (a `null` of `false` value generates a default description) 94 | /// 95 | /// @example scss - 96 | /// @use 'sass:math'; 97 | /// @include true.test('Division works as expected in Sass') { 98 | /// @include true.assert-equal( 99 | /// math.div(8, 2), 4, 100 | /// 'You can optionally describe the assertion...'); 101 | /// } 102 | @mixin assert-equal($assert, $expected, $description: null) { 103 | @include utils.setup('assert-equal', $description); 104 | @include utils.result($assert, $expected); 105 | } 106 | 107 | /// @alias assert-equal 108 | @mixin is-equal($assert, $expected, $description: null) { 109 | @include assert-equal($assert, $expected, $description); 110 | } 111 | 112 | // Assert UnEqual 113 | // -------------- 114 | /// Assert that two parameters are `unequal` 115 | /// Assertions are used inside the `test()` mixin 116 | /// to define the expected results of the test. 117 | /// 118 | /// @group api-assert-values 119 | /// 120 | /// @param {*} $assert - 121 | /// Asserted value to test 122 | /// @param {*} $expected - 123 | /// Expected mismatch 124 | /// @param {string} $description [null] - 125 | /// Description of the assertion being tested. 126 | /// A `null` of `false` value generates a default description. 127 | /// 128 | /// @example scss - 129 | /// @include true.test('Strings and numbers are not the same') { 130 | /// @include true.assert-unequal( 131 | /// 1em, 132 | /// '1em'); 133 | /// } 134 | @mixin assert-unequal($assert, $expected, $description: null) { 135 | @include utils.setup('assert-unequal', $description); 136 | @include utils.result($assert, $expected, 'unequal'); 137 | } 138 | 139 | /// @alias assert-unequal 140 | @mixin not-equal($assert, $expected, $description: null) { 141 | @include assert-unequal($assert, $expected, $description); 142 | } 143 | -------------------------------------------------------------------------------- /sass/config/_throw.scss: -------------------------------------------------------------------------------- 1 | @use 'messages'; 2 | 3 | /// # Catching & Testing Errors 4 | /// Sass doesn't (yet) provide a way to catch errors, 5 | /// but we provide a workaround that works for many use-cases 6 | /// (especially unit-testing error states). 7 | /// @group api-errors 8 | 9 | /// By default, the `error()` function and mixin 10 | /// will simply pass details along to the built-in Sass `@error` declaration. 11 | /// This setting allows you to avoid throwing errors that stop compilation, 12 | /// and return them as values (for functions) 13 | /// or output CSS comments (for mixins) instead. 14 | /// 15 | /// Any "true" value will catch errors, 16 | /// but a value of `warn` will also show warnings in the command-line output. 17 | /// @since v6.0 18 | /// @access public 19 | /// @group api-settings 20 | /// @type bool | 'warn' 21 | /// @see {function} error 22 | $catch-errors: false !default; 23 | 24 | // Format an error prefix, with source if available 25 | @function _prefix($source) { 26 | @if $source { 27 | @return 'ERROR [#{$source}]:'; 28 | } 29 | 30 | @return 'ERROR:'; 31 | } 32 | 33 | /// Use in place of `@error` statements inside functions. 34 | /// When the `$catch` parameter 35 | /// (or global `$catch-errors` setting) is set, 36 | /// the function will return errors without stopping compilation. 37 | /// This can be used to test errors as return values with True, 38 | /// or to "catch" errors and handle them in different ways. 39 | /// @access public 40 | /// @group api-errors 41 | /// @since v6.0 42 | /// @param {string} $message - 43 | /// The error message to report 44 | /// @param {string} $source [null] - 45 | /// The source of the error, for additional context 46 | /// @param {bool | 'warn'} $catch [$catch-errors] - 47 | /// Optionally catch errors, 48 | /// and return them as values without stopping compilation 49 | /// @return {string} 50 | /// A message detailing the source and error, 51 | /// when `$catch` is true 52 | /// @throw 53 | /// A message detailing the source and error, 54 | /// when `$catch` is false 55 | /// @example scss 56 | /// @use 'throw'; 57 | /// @use 'sass:meta'; 58 | /// 59 | /// @function add($a, $b) { 60 | /// @if (meta.type-of($a) != 'number') or (meta.type-of($b) != 'number') { 61 | /// @return throw.error( 62 | /// $message: '$a and $b must both be numbers', 63 | /// $source: 'add()', 64 | /// $catch: true 65 | /// ); 66 | /// } 67 | /// @return $a + $b; 68 | /// } 69 | /// 70 | /// .demo { width: add(3em, 'hello'); } 71 | @function error($message, $source: null, $catch: $catch-errors) { 72 | @if $catch { 73 | @if ($catch == 'warn') { 74 | @warn $message; 75 | } 76 | 77 | @return '#{_prefix($source)} #{$message}'; 78 | } 79 | 80 | @error $message; 81 | } 82 | 83 | /// Use in place of `@error` statements inside mixins 84 | /// or other control structures with CSS output (not functions). 85 | /// When the `$catch` parameter 86 | /// (or global `$catch-errors` setting) is set, 87 | /// the function will output errors as comments without stopping compilation. 88 | /// This can be used to test errors as return values with True, 89 | /// or to "catch" errors and handle them in different ways. 90 | /// 91 | /// Since True results rely on completing compilation, 92 | /// we do not have a way to "error out" of the code being tested. 93 | /// If there is code that needs to be skipped after an error, 94 | /// we recommend using explicit Sass conditional (if/else) statements 95 | /// to avoid compounding the problem: 96 | /// 97 | /// ```scss 98 | /// @mixin width ($length) { 99 | /// @if (meta.type-of($length) != number) { 100 | /// @include true.error("$length must be a number", "width", true); 101 | /// @else { 102 | /// // The @else block hides any remaining output 103 | /// width: $length; 104 | /// } 105 | /// } 106 | /// ``` 107 | /// 108 | /// @access public 109 | /// @group api-errors 110 | /// @since v6.0 111 | /// @param {string | list} $message - 112 | /// The error message to report 113 | /// @param {string} $source [null] - 114 | /// The source of the error, for additional context 115 | /// @param {bool | 'warn'} $catch [$catch-errors] - 116 | /// Optionally catch errors, 117 | /// and output them as CSS comments without stopping compilation 118 | /// @output 119 | /// A message detailing the source and error, 120 | /// when `$catch` is true 121 | /// @throw 122 | /// A message detailing the source and error, 123 | /// when `$catch` is false 124 | /// @example scss 125 | /// @use 'throw'; 126 | /// $run: 5; $total: 6; 127 | /// 128 | /// @if ($run != $total) { 129 | /// @include throw.error( 130 | /// $message: 'The results don’t add up.', 131 | /// $source: 'report', 132 | /// $catch: true 133 | /// ); 134 | /// } 135 | @mixin error($message, $source: null, $catch: $catch-errors) { 136 | @if $catch { 137 | @if ($catch == 'warn') { 138 | @warn $message; 139 | } 140 | 141 | @include messages.message(_prefix($source), 'comments'); 142 | @include messages.message($message, 'comments', $comment-padding: 2); 143 | } @else { 144 | @error $message; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /sass/data/_context.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:list'; 2 | @use 'sass:map'; 3 | @use '../config/terms'; 4 | @use '../config/throw'; 5 | 6 | // Context [variable] 7 | // ------------------ 8 | /// Stores the current module/test/assertion context stack 9 | /// @access private 10 | /// @group private-context 11 | /// @type list 12 | $context: (); 13 | 14 | // Context [mixin] 15 | // --------------- 16 | /// Update the current context for a given scope 17 | /// @access private 18 | /// @group private-context 19 | /// @param {string} $scope - 20 | /// Either `module`, `test` or `assert` 21 | /// @param {string} $name - 22 | /// Name or description of the current scope 23 | @mixin context($scope, $name) { 24 | $context: list.append($context, ($scope, $name)) !global; 25 | } 26 | 27 | // Context Pop [mixin] 28 | // ------------------- 29 | /// Remove the deepest context layer from `$context` 30 | /// @access private 31 | /// @group private-context 32 | @mixin context-pop { 33 | $new: (); 34 | 35 | @for $i from 1 to list.length($context) { 36 | $new: list.append($new, list.nth($context, $i)); 37 | } 38 | 39 | $context: $new !global; 40 | } 41 | 42 | // Output-context [variable] 43 | // ------------------------- 44 | /// Make sure every output test 45 | /// includes an `assert`, `output`, and `expect`/`contains` 46 | /// @access private 47 | /// @group private-context 48 | /// @type list 49 | $output-context: (); 50 | 51 | // Output-context [mixin] 52 | // ---------------------- 53 | /// Add `assert`, `output`, `expect`, or `contains` context to an output test, 54 | /// or check to make sure they all exist before resetting the context. 55 | /// @access private 56 | /// @group private-context 57 | /// @param {'assert' | 'output' | 'expect' | 'contains' | null} $new - 58 | /// Add a new `assert`, `output`, `expect`, or `contains` layer 59 | /// to the context of an output-test, 60 | /// or use `null` to check that all context is properly formed 61 | /// and then reset it at the end of a test 62 | /// @param {list} $context [$output-context] - 63 | /// The current output context 64 | @mixin output-context($new, $context: $output-context) { 65 | $output-context: validate-output-context($new, $context) !global; 66 | } 67 | 68 | // Validate Output-context [function] 69 | // ---------------------------------- 70 | /// Validate the new context, and return an updated context value 71 | /// @access private 72 | /// @group private-context 73 | /// @param {'assert' | 'output' | 'expect' | 'contains' | null} $new - 74 | /// Add a new `assert`, `output`, `expect`, or `contains` layer 75 | /// to the context of an output-test, 76 | /// or use `null` to check that all context is properly formed 77 | /// and then reset it at the end of a test 78 | /// @param {list} $context [$output-context] - 79 | /// The current output context 80 | /// @return {list} Updated output context 81 | /// @throw When adding unknown context 82 | /// @throw When trying to add context that already exists 83 | /// @throw When `assert()` is missing before `expect`, `output`, or `contains` 84 | /// @throw When context is missing before a reset 85 | @function validate-output-context($new, $context: $output-context) { 86 | $valid: map.keys(terms.$output); 87 | $solo-types: ('expect', 'output'); 88 | $expect-types: ('expect', 'contains', 'contains-string'); 89 | $sub-types: list.append($expect-types, 'output'); 90 | 91 | @if $new and not (list.index($valid, $new)) { 92 | @return throw.error( 93 | '#{$new} is not a valid context for output tests: #{$valid}', 94 | 'output-context' 95 | ); 96 | } 97 | 98 | @if list.index($context, $new) { 99 | @if ($new == 'assert') { 100 | @return throw.error( 101 | 'The `assert()` mixin can not contain another `assert()`', 102 | 'output-context' 103 | ); 104 | } 105 | 106 | @if list.index($solo-types, $new) { 107 | @return throw.error( 108 | '`#{$new}()` cannot be used multiple times in the same assertion', 109 | 'output-context' 110 | ); 111 | } 112 | 113 | @return $context; 114 | } 115 | 116 | @if list.index($sub-types, $new) and not list.index($context, 'assert') { 117 | @return throw.error( 118 | '`#{$new}()` is only allowed inside `assert()`', 119 | 'output-context' 120 | ); 121 | } 122 | 123 | @if list.index($expect-types, $new) { 124 | @each $step in $context { 125 | @if list.index($expect-types, $step) and list.index($solo-types, $step) { 126 | @return throw.error( 127 | '`#{$new}()` cannot be used in the same assertion as `#{$step}()`', 128 | 'output-context' 129 | ); 130 | } 131 | } 132 | } 133 | 134 | @if $new { 135 | @return list.append($context, $new); 136 | } 137 | 138 | // checking and re-setting the context 139 | @if not list.index($context, 'output') { 140 | @return throw.error( 141 | 'The `assert()` mixin requires nested `output()`', 142 | 'output-context' 143 | ); 144 | } 145 | 146 | $has-expect: false; 147 | 148 | @each $expect in $expect-types { 149 | $has-expect: $has-expect or list.index($context, $expect); 150 | } 151 | 152 | @if not $has-expect { 153 | @return throw.error( 154 | 'The `assert()` mixin requires at least one expectation', 155 | 'output-context' 156 | ); 157 | } 158 | 159 | @if (list.length($context) != 3) { 160 | @return throw.error( 161 | 'Unexpected assertion stack: #{$context}', 162 | 'output-context' 163 | ); 164 | } 165 | 166 | @return (); 167 | } 168 | 169 | // Context [function] 170 | // ------------------ 171 | /// Get information on current context for a given scope 172 | /// @group private-context 173 | /// @param {string} $scope 174 | /// @return {string} 175 | /// @access private 176 | @function context($scope) { 177 | $value: null; 178 | 179 | @each $entry-scope, $entry-value in $context { 180 | @if $entry-scope == $scope { 181 | $value: $entry-value; 182 | } 183 | } 184 | 185 | @return $value; 186 | } 187 | 188 | // Context All [function] 189 | // ---------------------- 190 | /// Get list of context names for a given scope 191 | /// @group private-context 192 | /// @param {string} $scope 193 | /// @return {list} 194 | /// @access private 195 | @function context-all($scope) { 196 | $list: (); 197 | 198 | @each $entry-scope, $entry-value in $context { 199 | @if $entry-scope == $scope { 200 | $list: list.append($list, $entry-value); 201 | } 202 | } 203 | 204 | @return $list; 205 | } 206 | -------------------------------------------------------------------------------- /test/scss/assert/_output.scss: -------------------------------------------------------------------------------- 1 | @use '../../../index' as *; 2 | @use '../../../sass/data'; 3 | @use 'sass:math'; 4 | 5 | // Assert [output] 6 | @include describe('Output Expect') { 7 | @include it('Compares math output properly') { 8 | @include assert { 9 | @include output { 10 | -property1: 0.1 + 0.2; 11 | -property2: math.div(1, 3); 12 | } 13 | 14 | @include expect { 15 | -property1: 0.3; 16 | -property2: 0.3333333333; 17 | } 18 | } 19 | } 20 | 21 | @include it('Matches output and expected selector patterns') { 22 | @include assert { 23 | @include output { 24 | -property: value; 25 | 26 | @media (min-width: 30em) { 27 | -prop: val; 28 | 29 | @at-root { 30 | .selector { 31 | -prop: val; 32 | } 33 | } 34 | } 35 | } 36 | 37 | @include expect { 38 | -property: value; 39 | @media (min-width: 30em) { 40 | -prop: val; 41 | @at-root { 42 | .selector { 43 | -prop: val; 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | 52 | @include describe('Output Contains-string') { 53 | @include it('Contains sub-strings') { 54 | @include assert { 55 | @include output { 56 | height: 10px; 57 | width: 20px; 58 | } 59 | 60 | @include contains-string('height'); 61 | } 62 | } 63 | 64 | @include it('Contains properties') { 65 | @include assert { 66 | @include output { 67 | --my-custom-property: 3rem; 68 | } 69 | 70 | @include contains-string('--my-custom-property'); 71 | } 72 | } 73 | 74 | @include it('Contains values') { 75 | @include assert { 76 | @include output { 77 | font-family: Helvetica; 78 | } 79 | 80 | @include contains-string('Helvetica'); 81 | } 82 | } 83 | 84 | @include it('Can assert multiple and overlapping strings') { 85 | @include assert { 86 | @include output { 87 | height: 10px; 88 | width: 20px; 89 | border: thin solid currentColor; 90 | } 91 | 92 | @include contains-string('height'); 93 | @include contains-string('solid'); 94 | @include contains-string('20px'); 95 | @include contains-string('thin solid currentColor'); 96 | } 97 | } 98 | } 99 | 100 | @include describe('Output Contains') { 101 | @include it('Contains sub-string') { 102 | @include assert { 103 | @include output { 104 | height: 10px; 105 | width: 20px; 106 | } 107 | 108 | @include contains { 109 | height: 10px; 110 | } 111 | } 112 | } 113 | 114 | @include it('Contains nested selector block') { 115 | @include assert { 116 | @include output { 117 | height: 20px; 118 | 119 | .class { 120 | height: 10px; 121 | } 122 | .other-class { 123 | height: 10px; 124 | } 125 | } 126 | 127 | @include contains { 128 | .class { 129 | height: 10px; 130 | } 131 | } 132 | } 133 | } 134 | 135 | @include it('Can be used with nested classes') { 136 | @include assert { 137 | @include output { 138 | height: 20px; 139 | .class { 140 | height: 10px; 141 | .other-class { 142 | height: 10px; 143 | } 144 | } 145 | } 146 | 147 | @include contains { 148 | .class { 149 | .other-class { 150 | height: 10px; 151 | } 152 | } 153 | } 154 | } 155 | } 156 | 157 | @include it('Can be used with nested @media queries') { 158 | @include assert { 159 | @include output { 160 | .class { 161 | height: 20px; 162 | } 163 | @media (min-width: 30em) { 164 | @media (min-width: 40em) { 165 | .selector { 166 | height: 10px; 167 | } 168 | .selector2 { 169 | height: 10px; 170 | } 171 | } 172 | .selector3 { 173 | height: 10px; 174 | } 175 | } 176 | } 177 | 178 | @include contains { 179 | @media (min-width: 30em) { 180 | @media (min-width: 40em) { 181 | .selector2 { 182 | height: 10px; 183 | } 184 | } 185 | } 186 | } 187 | } 188 | } 189 | 190 | @include it('Can contain multiple/overlapping assertions') { 191 | @include assert { 192 | @include output { 193 | .example { 194 | width: 12em; 195 | height: 1lh; 196 | border: thin solid; 197 | } 198 | } 199 | 200 | @include contains { 201 | .example { 202 | width: 12em; 203 | } 204 | } 205 | @include contains { 206 | .example { 207 | height: 1lh; 208 | } 209 | } 210 | @include contains { 211 | .example { 212 | height: 1lh; 213 | border: thin solid; 214 | } 215 | } 216 | } 217 | } 218 | 219 | @include it('Can be used with @at-root') { 220 | @include assert { 221 | @include output { 222 | @at-root { 223 | .selector { 224 | height: 10px; 225 | } 226 | } 227 | } 228 | 229 | @include contains { 230 | @at-root { 231 | .selector { 232 | height: 10px; 233 | } 234 | } 235 | } 236 | } 237 | } 238 | 239 | @include it('Can be used for all selector types') { 240 | @include assert { 241 | @include output { 242 | .selector { 243 | -prop: val; 244 | } 245 | #id { 246 | -prop: value1; 247 | } 248 | div { 249 | -prop: value2; 250 | min-height: 20px; 251 | max-height: 30px; 252 | } 253 | 254 | input[type='text'] { 255 | color: rebeccapurple; 256 | } 257 | 258 | * + * { 259 | color: red; 260 | display: none; 261 | } 262 | 263 | body > h1 { 264 | font-weight: bold; 265 | } 266 | 267 | i::before { 268 | -prop: value3; 269 | } 270 | 271 | div ~ p { 272 | color: green; 273 | } 274 | 275 | i:not(.italic) { 276 | text-decoration: underline; 277 | --custom: val; 278 | } 279 | } 280 | 281 | @include contains { 282 | .selector { 283 | -prop: val; 284 | } 285 | 286 | div { 287 | max-height: 30px; 288 | } 289 | 290 | body > h1 { 291 | font-weight: bold; 292 | } 293 | 294 | * + * { 295 | display: none; 296 | } 297 | 298 | i:not(.italic) { 299 | text-decoration: underline; 300 | } 301 | } 302 | } 303 | } 304 | 305 | @include it('Can assert multiple properties within a selector') { 306 | @include assert { 307 | @include output { 308 | .selector { 309 | width: 10px; 310 | min-height: 5px; 311 | max-height: 20px; 312 | } 313 | } 314 | 315 | @include contains { 316 | .selector { 317 | width: 10px; 318 | max-height: 20px; 319 | } 320 | } 321 | } 322 | } 323 | 324 | @include it('Can assert with multiple matching selector') { 325 | @include assert { 326 | @include output { 327 | .selector { 328 | width: 10px; 329 | } 330 | .selector { 331 | min-height: 5px; 332 | max-height: 20px; 333 | } 334 | } 335 | 336 | @include contains { 337 | .selector { 338 | width: 10px; 339 | min-height: 5px; 340 | } 341 | } 342 | } 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /sass/assert/_output.scss: -------------------------------------------------------------------------------- 1 | @use '../config'; 2 | @use 'utils'; 3 | 4 | /// # Testing CSS Output 5 | /// 6 | /// Unlike value assertions (which are evaluated during Sass compilation), CSS 7 | /// output assertions generate compiled CSS that must be compared to verify 8 | /// correctness. The `assert()`, `output()`, and `expect()` mixins work together 9 | /// to create this testable output. 10 | /// 11 | /// **Important:** CSS output tests require comparison after Sass compilation. 12 | /// You have two options for verifying output: 13 | /// 14 | /// 1. **Manual comparison** – Use `git diff` or similar tools to review changes 15 | /// in the compiled CSS output. True generates structured CSS comments 16 | /// containing the test results, which you can inspect manually. 17 | /// 18 | /// 2. **JavaScript test runner** – Integrate with Mocha, Jest, Vitest, or 19 | /// similar test runners to automate the comparison. The JS integration 20 | /// parses the CSS output and reports differences automatically. See: 21 | /// https://www.oddbird.net/true/docs/#javascript-test-runner-integration 22 | /// 23 | /// @group api-assert-output 24 | 25 | // Assert [output] 26 | // --------------- 27 | /// Define a CSS-output assertion. 28 | /// Assertions are used inside the `test()` mixin 29 | /// to define the expected results of the test. 30 | /// - The `assert()` mixin is a wrapper, 31 | /// and should contain one `output()` block and one `expect()` block 32 | /// as nested contents. 33 | /// - These three mixins together describe a single 34 | /// `assert-equal` comparison on output CSS. 35 | /// The compiled CSS-results of the `output()` mixin 36 | /// will be compared against the results of the `expect()` mixin. 37 | /// - When using Mocha/Jest integration, the output comparison is automated – 38 | /// otherwise you will have to compare the output manually. 39 | /// Using `git diff` is a great way to watch for changes in output. 40 | /// 41 | /// @group api-assert-output 42 | /// 43 | /// @param {string} $description [null] - 44 | /// Description of the assertion being tested. 45 | /// A `null` of `false` value generates a default description. 46 | /// 47 | /// @content Use `output()` and `expect()` mixins 48 | /// to define blocks for comparison 49 | /// 50 | /// @example scss - 51 | /// @include true.test('Sass math compiles before output') { 52 | /// @include true.assert('You can also describe the assertion...') { 53 | /// @include true.output { 54 | /// width: 14em + 2; 55 | /// } 56 | /// @include true.expect { 57 | /// width: 16em; 58 | /// } 59 | /// } 60 | /// } 61 | @mixin assert($description: null) { 62 | @include utils.setup('output', $description); 63 | 64 | @include utils.content('assert', false, $description) { 65 | @content; 66 | } 67 | 68 | @include utils.strike('output-to-css', $output: true); 69 | } 70 | 71 | // Output 72 | // ------ 73 | /// Describe the test content to be evaluated 74 | /// against the paired `expect()` block. 75 | /// Assertions are used inside the `test()` mixin 76 | /// to define the expected results of the test. 77 | /// - The `output()` mixin requires a content block, 78 | /// and should be nested inside the `assert()` mixin 79 | /// along with a single `expect()` block. 80 | /// - These three mixins together describe a single 81 | /// `assert-equal` comparison on output CSS. 82 | /// The compiled CSS-results of the `output()` mixin 83 | /// will be compared against the results of the `expect()` mixin. 84 | /// - When using Mocha/Jest integration, the output comparison is automated – 85 | /// otherwise you will have to compare the output manually. 86 | /// Using `git diff` is a great way to watch for changes in output. 87 | /// 88 | /// @group api-assert-output 89 | /// 90 | /// @param {bool} $selector [true] - 91 | /// Optionally wrap the contents in a `.test-output` selector block, 92 | /// so you can test property-value output directly. 93 | /// 94 | /// @content Define the test content to be checked 95 | /// 96 | /// @example scss - 97 | /// @include true.test('Sass math compiles before output') { 98 | /// @include true.assert { 99 | /// @include true.output { 100 | /// width: 14em + 2; 101 | /// } 102 | /// @include true.expect { 103 | /// width: 16em; 104 | /// } 105 | /// } 106 | /// } 107 | @mixin output($selector: true) { 108 | @include utils.content('output', $selector) { 109 | @content; 110 | } 111 | } 112 | 113 | // Expect 114 | // ------ 115 | /// Describe the expected results of the paired `output()` block. 116 | /// The `expect()` mixin requires a content block, 117 | /// and should be nested inside the `assert()` mixin, 118 | /// along with a single `output()` block. 119 | /// Assertions are used inside the `test()` mixin 120 | /// to define the expected results of the test. 121 | /// - These three mixins together describe a single 122 | /// `assert-equal` comparison on output CSS. 123 | /// The compiled CSS-results of the `output()` mixin 124 | /// will be compared against the results of the `expect()` mixin. 125 | /// - When using Mocha/Jest integration, the output comparison is automated – 126 | /// otherwise you will have to compare the output manually. 127 | /// Using `git diff` is a great way to watch for changes in output. 128 | /// 129 | /// @group api-assert-output 130 | /// 131 | /// @param {bool} $selector [true] - 132 | /// Optionally wrap the contents in a `.test-output` selector block, 133 | /// so you can test property-value output directly. 134 | /// 135 | /// @content Define the expected results of a sibling `output()` mixin 136 | /// 137 | /// @example scss - 138 | /// @include true.test('Sass math compiles before output') { 139 | /// @include true.assert { 140 | /// @include true.output { 141 | /// width: 14em + 2; 142 | /// } 143 | /// @include true.expect { 144 | /// width: 16em; 145 | /// } 146 | /// } 147 | /// } 148 | @mixin expect($selector: true) { 149 | @include utils.content('expect', $selector) { 150 | @content; 151 | } 152 | } 153 | 154 | // Contains 155 | // -------- 156 | /// Describe the expected results of the paired `output()` block. 157 | /// The `contains()` mixin requires a content block, 158 | /// and should be nested inside the `assert()` mixin, 159 | /// along with a single `output()` block. 160 | /// Assertions are used inside the `test()` mixin 161 | /// to define the expected results of the test. 162 | /// - These three mixins together describe a single 163 | /// comparison on output CSS. 164 | /// The compiled CSS-results of the `contains()` mixin 165 | /// will be compared against the results of the `output()` mixin 166 | /// to see if all of the `contains` CSS is within the `output` CSS. 167 | /// - When using Mocha/Jest integration, the output comparison is automated – 168 | /// otherwise you will have to compare the output manually. 169 | /// Using `git diff` is a great way to watch for changes in output. 170 | /// 171 | /// @group api-assert-output 172 | /// 173 | /// @param {bool} $selector [true] - 174 | /// Optionally wrap the contents in a `.test-output` selector block, 175 | /// so you can test property-value output directly. 176 | /// 177 | /// @content Define the expected results of a sibling `output()` mixin 178 | /// 179 | /// @example scss - 180 | /// @include true.test('Sass math compiles before output') { 181 | /// @include true.assert { 182 | /// @include true.output { 183 | /// height: 100%; 184 | /// width: 14em + 2; 185 | /// } 186 | /// @include true.contains { 187 | /// width: 16em; 188 | /// } 189 | /// } 190 | /// } 191 | @mixin contains($selector: true) { 192 | @include utils.content('contains', $selector) { 193 | @content; 194 | } 195 | } 196 | 197 | // Contains String 198 | // --------------- 199 | /// Describe a case-sensitive substring 200 | /// expected to be found within the paired `output()` block. 201 | /// The `contains-string()` mixin requires a string argument, 202 | /// and should be nested inside the `assert()` mixin 203 | /// along with a single `output()` block. 204 | /// Assertions are used inside the `test()` mixin 205 | /// to define the expected results of the test. 206 | /// - These mixins together describe a comparison on output CSS, 207 | /// checking if the compiled CSS-results of the `output()` mixin 208 | /// contain the specified `$string-to-find`. 209 | /// - When using Mocha/Jest integration, the output comparison is automated – 210 | /// otherwise you will have to compare the output manually. 211 | /// Using `git diff` is a great way to watch for changes in output. 212 | /// 213 | /// @group api-assert-output 214 | /// 215 | /// @param {string} $string-to-find - 216 | /// The substring to search for within the compiled CSS output. 217 | /// 218 | /// @example scss - 219 | /// @include true.test('Can find partial strings') { 220 | /// @include true.assert { 221 | /// @include true.output { 222 | /// font-size: 1em; 223 | /// line-height: 1.5; 224 | /// } 225 | /// @include true.contains-string('font-size'); 226 | /// @include true.contains-string('line'); 227 | /// } 228 | /// } 229 | @mixin contains-string($string-to-find) { 230 | @include utils.content('contains-string', $selector: false) { 231 | @include config.message('#{$string-to-find}', 'comments'); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # True 2 | 3 | [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) 4 | 5 | > **true** (_verb_): To make even, accurate, or precise. 6 | > _"True the wheels of a bicycle. True up the sides of a door. True your Sass 7 | > code before you deploy."_ 8 | 9 | **True is a unit-testing tool for [Sass](https://sass-lang.com/) code.** 10 | 11 | - Write tests in plain Sass 12 | - Compile with Dart Sass 13 | - Optional [JavaScript test runner integration][js-runner] (e.g. 14 | [Mocha](https://mochajs.org/), [Jest](https://jestjs.io/), or 15 | [Vitest](https://vitest.dev/)) 16 | 17 | ## Installation 18 | 19 | ### 1. Install via npm 20 | 21 | ```bash 22 | npm install --save-dev sass-true 23 | ``` 24 | 25 | ### 2. Install Dart Sass (if needed) 26 | 27 | True requires **Dart Sass v1.45.0 or higher**: 28 | 29 | ```bash 30 | npm install --save-dev sass-embedded # or `sass` 31 | ``` 32 | 33 | ### 3. Import in your Sass tests 34 | 35 | **With [Node.js package importer][pkg-importer]**: 36 | 37 | ```scss 38 | @use 'pkg:sass-true' as *; 39 | ``` 40 | 41 | **With [JavaScript test runner][js-runner]:** 42 | 43 | ```scss 44 | @use 'true' as *; 45 | ``` 46 | 47 | **Without package importer:** 48 | 49 | ```scss 50 | // Path may vary based on your project structure 51 | @use '../node_modules/sass-true' as *; 52 | ``` 53 | 54 | [pkg-importer]: https://sass-lang.com/documentation/js-api/classes/nodepackageimporter/ 55 | [js-runner]: #javascript-test-runner-integration 56 | 57 | ## Configuration 58 | 59 | True has one configuration variable: **`$terminal-output`** (boolean, defaults 60 | to `true`) 61 | 62 | | Value | Behavior | 63 | | ---------------- | --------------------------------------------------------------------------------------------------------------- | 64 | | `true` (default) | Shows detailed terminal output for debugging and results. Best for standalone Sass compilation. | 65 | | `false` | Disables Sass terminal output. Use with [JavaScript test runners][js-runner] (they handle their own reporting). | 66 | 67 | ### Legacy `@import` Support 68 | 69 | If you're still using `@import` instead of `@use`, use the legacy import path 70 | with the prefixed variable name: 71 | 72 | ```scss 73 | // Path may vary 74 | @import '../node_modules/sass-true/sass/true'; 75 | // Variable is named $true-terminal-output 76 | ``` 77 | 78 | ## Usage 79 | 80 | True uses familiar testing syntax inspired by JavaScript test frameworks: 81 | 82 | ### Testing Values (Functions & Variables) 83 | 84 | True can compare Sass values during compilation: 85 | 86 | ```scss 87 | @include describe('Zip [function]') { 88 | @include it('Zips multiple lists into a single multi-dimensional list') { 89 | // Assert the expected results 90 | @include assert-equal(zip(a b c, 1 2 3), (a 1, b 2, c 3)); 91 | } 92 | } 93 | ``` 94 | 95 | **Alternative syntax** using `test-module` and `test`: 96 | 97 | ```scss 98 | @include test-module('Zip [function]') { 99 | @include test('Zips multiple lists into a single multi-dimensional list') { 100 | // Assert the expected results 101 | @include assert-equal(zip(a b c, 1 2 3), (a 1, b 2, c 3)); 102 | } 103 | } 104 | ``` 105 | 106 | ### Testing CSS Output (Mixins) 107 | 108 | CSS output tests require a different assertion structure, with an outer `assert` 109 | mixin, and a matching pair of `output` and `expect` to contain the 110 | output-values: 111 | 112 | ```scss 113 | // Test CSS output from mixins 114 | @include it('Outputs a font size and line height based on keyword') { 115 | @include assert { 116 | @include output { 117 | @include font-size('large'); 118 | } 119 | 120 | @include expect { 121 | font-size: 2rem; 122 | line-height: 3rem; 123 | } 124 | } 125 | } 126 | ``` 127 | 128 | > **Note:** CSS output is compared after compilation. You can review changes 129 | > manually with `git diff` or use a [JavaScript test runner][js-runner] for 130 | > automated comparison. 131 | 132 | ### Optional Summary Report 133 | 134 | Display a test summary in CSS output and/or terminal: 135 | 136 | ```scss 137 | @include report; 138 | ``` 139 | 140 | ### Documentation & Changelog 141 | 142 | - **[Full Documentation](https://www.oddbird.net/true/docs/)** – Complete API 143 | reference and guides 144 | - **[CHANGELOG.md](https://github.com/oddbird/true/blob/main/CHANGELOG.md)** – 145 | Migration notes for upgrading 146 | 147 | ## JavaScript Test Runner Integration 148 | 149 | Integrate True with your existing JS test runner for enhanced reporting and 150 | automated CSS output comparison. 151 | 152 | ### Quick Start 153 | 154 | #### 1. Install dependencies 155 | 156 | ```bash 157 | npm install --save-dev sass-true 158 | npm install --save-dev sass-embedded # or `sass` (if not already installed) 159 | ``` 160 | 161 | #### 2. Write Sass tests 162 | 163 | Create your Sass test file (e.g., `test/test.scss`) using True's syntax (see 164 | [Usage](#usage)). 165 | 166 | #### 3. Create JS test file 167 | 168 | Create a JavaScript shim to run your Sass tests (e.g., `test/sass.test.js`): 169 | 170 | ```js 171 | import path from 'node:path'; 172 | import { fileURLToPath } from 'node:url'; 173 | import sassTrue from 'sass-true'; 174 | 175 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 176 | 177 | const sassFile = path.join(__dirname, 'test.scss'); 178 | sassTrue.runSass({ describe, it }, sassFile); 179 | ``` 180 | 181 | #### 4. Run your tests 182 | 183 | Run Mocha, Jest, Vitest, or your test runner. Sass tests will appear in the 184 | terminal output. 185 | 186 | ### Enable Watch Mode for Sass Files 187 | 188 | By default, `vitest --watch` and `jest --watch` don't detect Sass file changes. 189 | 190 | **Vitest solution:** Add Sass files to `forceRerunTriggers`: 191 | 192 | ```js 193 | // vitest.config.js 194 | module.exports = defineConfig({ 195 | test: { 196 | forceRerunTriggers: ['**/*.scss'], 197 | }, 198 | }); 199 | ``` 200 | 201 | See [Vitest documentation](https://vitest.dev/config/forcereruntriggers.html#forcereruntriggers) for details. 202 | 203 | **Jest solution:** Add `"scss"` to `moduleFileExtensions`: 204 | 205 | ```js 206 | // jest.config.js 207 | module.exports = { 208 | moduleFileExtensions: ['js', 'json', 'scss'], 209 | }; 210 | ``` 211 | 212 | See [Jest documentation](https://jestjs.io/docs/configuration#modulefileextensions-arraystring) for details. 213 | 214 | ### Advanced Configuration 215 | 216 | #### `runSass()` API 217 | 218 | ```js 219 | sassTrue.runSass(testRunnerConfig, sassPathOrSource, sassOptions); 220 | ``` 221 | 222 | **Arguments:** 223 | 224 | 1. **`testRunnerConfig`** (object, required) 225 | 226 | | Option | Type | Required | Description | 227 | | -------------- | ---------------------- | -------- | ---------------------------------------------------------------------------------------------------- | 228 | | `describe` | function | Yes | Your test runner's `describe` function | 229 | | `it` | function | Yes | Your test runner's `it` function | 230 | | `sass` | string or object | No | Sass implementation name (`'sass'` or `'sass-embedded'`) or instance. Auto-detected if not provided. | 231 | | `sourceType` | `'string'` or `'path'` | No | Set to `'string'` to compile inline Sass source instead of file path (default: `'path'`) | 232 | | `contextLines` | number | No | Number of CSS context lines to show in parse errors (default: `10`) | 233 | 234 | 2. **`sassPathOrSource`** (`'string'` or `'path'`, required) 235 | - File path to Sass test file, or 236 | - Inline Sass source code (if `sourceType: 'string'`) 237 | 238 | 3. **`sassOptions`** (object, optional) 239 | - Standard [Sass compile options](https://sass-lang.com/documentation/js-api/interfaces/Options) 240 | (`importers`, `loadPaths`, `style`, etc.) 241 | - **Default modifications by True:** 242 | - `loadPaths`: True's sass directory is automatically added (allowing `@use 'true';`) 243 | - `importers`: [Node.js package importer][pkg-importer] added if 244 | `importers` is not defined and Dart Sass ≥ v1.71 (allowing `@use 'pkg:sass-true' as *;`) 245 | - ⚠️ **Warning:** Must use `style: 'expanded'` (default). 246 | `style: 'compressed'` is not supported. 247 | 248 | #### Multiple Test Files 249 | 250 | Call `runSass()` multiple times to run separate test files: 251 | 252 | ```js 253 | sassTrue.runSass({ describe, it }, path.join(__dirname, 'functions.test.scss')); 254 | sassTrue.runSass({ describe, it }, path.join(__dirname, 'mixins.test.scss')); 255 | ``` 256 | 257 | #### Other Test Runners 258 | 259 | Any test runner with `describe`/`it` functions (or equivalents) works with True: 260 | 261 | ```js 262 | // Example with custom test runner 263 | import { suite, test } from 'my-test-runner'; 264 | 265 | sassTrue.runSass( 266 | { describe: suite, it: test }, 267 | path.join(__dirname, 'test.scss'), 268 | ); 269 | ``` 270 | 271 | #### Custom Importers 272 | 273 | If you use custom import syntax (e.g., tilde notation 274 | `@use '~accoutrement/sass/tools'`), you'll need to provide a 275 | [custom importer](https://sass-lang.com/documentation/js-api/interfaces/FileImporter): 276 | 277 | ```js 278 | import path from 'node:path'; 279 | import { fileURLToPath, pathToFileURL } from 'node:url'; 280 | import sassTrue from 'sass-true'; 281 | 282 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 283 | 284 | const importers = [ 285 | { 286 | findFileUrl(url) { 287 | if (!url.startsWith('~')) { 288 | return null; 289 | } 290 | return new URL( 291 | pathToFileURL(path.resolve('node_modules', url.substring(1))), 292 | ); 293 | }, 294 | }, 295 | ]; 296 | 297 | const sassFile = path.join(__dirname, 'test.scss'); 298 | sassTrue.runSass({ describe, it }, sassFile, { importers }); 299 | ``` 300 | 301 | --- 302 | 303 | ## Sponsor OddBird's Open Source Work 304 | 305 | At OddBird, we love contributing to the languages and tools developers rely on. 306 | We're currently working on: 307 | 308 | - Polyfills for new Popover & Anchor Positioning functionality 309 | - CSS specifications for functions, mixins, and responsive typography 310 | - Sass testing tools like True 311 | 312 | **Help us keep this work sustainable!** Sponsor logos and avatars are featured 313 | on our [website](https://www.oddbird.net/true/#open-source-sponsors). 314 | 315 | **[→ Sponsor OddBird on Open Collective](https://opencollective.com/oddbird-open-source)** 316 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # True Changelog 2 | 3 | ## 10.1.0 (12/15/25) 4 | 5 | - Replace uses of [deprecated](https://sass-lang.com/documentation/breaking-changes/if-function/) Sass `if()` function 6 | [340](https://github.com/oddbird/true/pull/340) 7 | - INTERNAL: Update dependencies 8 | 9 | ## 10.0.1 (12/04/25) 10 | 11 | - DOCUMENTATION: Rewrite README documentation 12 | - INTERNAL: Update dependencies 13 | 14 | ## 10.0.0 (11/18/25) 15 | 16 | - BREAKING: Remove the `$inspect` option from assertions, 17 | since Sass has improved comparisons and changed inspection. 18 | [#332](https://github.com/oddbird/true/pull/332) 19 | - FEATURE: Multiple `contains()` and `contains-string()` expectations 20 | can be used in a single assertion. 21 | [#333](https://github.com/oddbird/true/pull/333) 22 | - FEATURE: Do not fail on non-standard at-rules (switch to postcss for css 23 | parsing). [#314](https://github.com/oddbird/true/pull/314) 24 | - INTERNAL: Update dependencies 25 | 26 | **Migration**: 27 | It seems that most tests can be updated 28 | by simply removing the `$inspect` argument entirely, 29 | with no other changes. 30 | In some cases, you may still need one of the following: 31 | 32 | - Numeric tests with long decimals should use 33 | `math.round()` to explicitly compare values 34 | at the desired level of precision, 35 | rather than relying on the unreliable precision 36 | of the `meta.inspect()` function. 37 | - Tests that rely on map output might throw an error 38 | since 'maps' are not a defined CSS syntax. 39 | In this case you can use `meta.inspect()` 40 | to compare the string representations of maps. 41 | 42 | ## 10.0.0-alpha.1 (11/18/25) 43 | 44 | - FEATURE: Multiple `contains()` and `contains-string()` expectations 45 | can be used in a single assertion. 46 | [#333](https://github.com/oddbird/true/pull/333) 47 | 48 | ## 10.0.0-alpha.0 (11/10/25) 49 | 50 | - BREAKING: Remove the `$inspect` option from assertions, 51 | since Sass has improved comparisons and changed inspection. 52 | [#332](https://github.com/oddbird/true/pull/332) 53 | - INTERNAL: Update dependencies 54 | 55 | ## 9.1.0-alpha.0 (07/01/25) 56 | 57 | - FEATURE: Do not fail on non-standard at-rules (switch to postcss for css 58 | parsing). [#314](https://github.com/oddbird/true/pull/314) 59 | - INTERNAL: Update dependencies 60 | 61 | ## 9.0.0 (06/23/25) 62 | 63 | - FEATURE: `contains-string()` supports substring matching. 64 | [#311](https://github.com/oddbird/true/pull/311) 65 | - BREAKING: Drop support for node < 20 66 | - INTERNAL: Update dependencies 67 | 68 | ## 8.1.0 (10/02/24) 69 | 70 | - FEATURE: If True `sass` option is not specified, True will automatically 71 | attempt to use `embedded-sass`, then `sass`. 72 | [#290](https://github.com/oddbird/true/issues/290) 73 | - INTERNAL: Add `sass` and `sass-embedded` as optional peer-dependencies. 74 | - INTERNAL: Update dependencies 75 | 76 | ## 8.0.0 (02/23/24) 77 | 78 | - FEATURE: Add True `sass` option (`string` or Sass implementation instance, 79 | defaults to `'sass'`) to allow using either `sass` or `embedded-sass`. 80 | - FEATURE: Add the 81 | [Node.js package importer](https://sass-lang.com/documentation/js-api/classes/nodepackageimporter/) 82 | to the Sass `importers` option by default, if Dart Sass v1.71 or later is 83 | available. Users can opt out by providing their own `importers` option, e.g. 84 | `{ importers: [] }`. 85 | - BREAKING: Drop support for node < 18 86 | - INTERNAL: Remove `sass` as a peer-dependency. 87 | - INTERNAL: Update dependencies 88 | 89 | ## 7.0.1 (01/04/24) 90 | 91 | - FEATURE: Validate `runSass` arguments and warn if using v6 API. 92 | - DOCUMENTATION: Add note that `{ style: 'compressed' }` is not supported. 93 | - DOCUMENTATION: Add note about possible Jest error and workaround. 94 | - INTERNAL: Update dependencies 95 | 96 | ## 7.0.0 (12/14/22) 97 | 98 | - FEATURE: `contains()` checks multiple block with matching selectors. 99 | [#243](https://github.com/oddbird/true/pull/243) 100 | - BREAKING: Upgrade to newer [Sass API](https://sass-lang.com/documentation/js-api) 101 | - Add True `sourceType` option (`path` [default] or `string`) 102 | - Reverse order of expected arguments to `runSass`: 1) True options, 2) source 103 | path (or string), 3) optional Sass options 104 | - **Note that some of the Sass options have changed.** For example, 105 | `includePaths` is now `loadPaths`, `outputStyle` is now `style`, `importer` 106 | is now `importers`, etc. See the [Dart Sass 107 | documentation](https://sass-lang.com/documentation/js-api/interfaces/Options) 108 | for more details. 109 | - BREAKING: Require `sass` (`>=1.45.0`) as a peer-dependency, removing True 110 | `sass` option 111 | - BREAKING: Drop support for node < 14.15.0 112 | - INTERNAL: Use both Jest and Mocha for internal testing 113 | - INTERNAL: Remove documentation from npm package 114 | - INTERNAL: Update dependencies 115 | 116 | ### Migrating from v6 117 | 118 | - `runSass` arguments have changed: 119 | 120 | v6: 121 | 122 | ```js 123 | const path = require('path'); 124 | const sass = require('node-sass'); 125 | const sassTrue = require('sass-true'); 126 | 127 | const sassFile = path.join(__dirname, 'test.scss'); 128 | sassTrue.runSass( 129 | // Sass options [required] 130 | { file: sassFile, includePaths: ['node_modules'] }, 131 | // True options [required] 132 | { describe, it, sass }, 133 | ); 134 | 135 | const sassString = ` 136 | h1 { 137 | font-size: 40px; 138 | }`; 139 | sassTrue.runSass( 140 | // Sass options [required] 141 | { 142 | data: sassString, 143 | includePaths: ['node_modules'], 144 | }, 145 | // True options [required] 146 | { describe, it, sass }, 147 | ); 148 | ``` 149 | 150 | v7: 151 | 152 | ```js 153 | const path = require('path'); 154 | const sassTrue = require('sass-true'); 155 | 156 | const sassFile = path.join(__dirname, 'test.scss'); 157 | sassTrue.runSass( 158 | // True options [required] 159 | { describe, it }, 160 | // Sass source (path) [required] 161 | sassFile, 162 | // Sass options [optional] 163 | { loadPaths: ['node_modules'] }, 164 | ); 165 | 166 | const sassString = ` 167 | h1 { 168 | font-size: 40px; 169 | }`; 170 | sassTrue.runSass( 171 | // True options [required] 172 | { describe, it, sourceType: 'string' }, 173 | // Sass source (string) [required] 174 | sassString, 175 | // Sass options [optional] 176 | { loadPaths: ['node_modules'] }, 177 | ); 178 | ``` 179 | 180 | ## 7.0.0-beta.0 (09/16/22) 181 | 182 | - BREAKING: Upgrade to newer [Sass API](https://sass-lang.com/documentation/js-api) 183 | - Add True `sourceType` option (`path` [default] or `string`) 184 | - Reverse order of expected arguments to `runSass`: 1) True options, 2) source 185 | path (or string), 3) optional Sass options 186 | - BREAKING: Require `sass` as a peer-dependency, removing True `sass` option 187 | - BREAKING: Drop support for node < 14.15.0 188 | - INTERNAL: Use both Jest and Mocha for internal testing 189 | - INTERNAL: Update dependencies 190 | 191 | ## 6.1.0 (03/02/22) 192 | 193 | - No changes since v6.1.0-beta.1 194 | 195 | ## 6.1.0-beta.1 (02/24/22) 196 | 197 | - FEATURE: Clearer formatting of failing test diffs 198 | [#210](https://github.com/oddbird/true/issues/210) 199 | - INTERNAL: Limit files included in npm package 200 | [#189](https://github.com/oddbird/true/issues/189) 201 | - INTERNAL: Convert JS to TypeScript and bundle type definitions 202 | [#212](https://github.com/oddbird/true/issues/212) -- 203 | thanks to [@robertmaier](https://github.com/robertmaier) for the initial PR 204 | [#206](https://github.com/oddbird/true/pull/206) 205 | - INTERNAL: Remove documentation static-site from True repository 206 | - INTERNAL: Use Jest for internal testing (replaces Mocha) 207 | - INTERNAL: Switch from Travis CI to GitHub Actions for CI 208 | - INTERNAL: Update dependencies 209 | 210 | ## 6.0.1 (10/16/20) 211 | 212 | - Remove eyeglass specific-version requirement. 213 | - Update documentation 214 | 215 | ## 6.0.0 (07/22/20) 216 | 217 | - BREAKING: Switch to [Dart Sass](https://sass-lang.com/dart-sass) with [Sass 218 | module system](https://sass-lang.com/blog/the-module-system-is-launched), 219 | dropping support for [Node Sass](https://github.com/sass/node-sass). 220 | - BREAKING: Drop support for node < 10 221 | - BREAKING: Rename `$true-terminal-output` setting to `$terminal-output` 222 | when importing as a module (with `@use`). 223 | Projects not using Sass modules can still 224 | `@import '/sass-true/sass/true'` 225 | and access the setting as `$true-terminal-output` 226 | - FEATURE: Added `_index.scss` at the project root, 227 | for simpler import path: `@use '/sass-true'` 228 | - FEATURE: New `sass/_throw.scss` module provides: 229 | - `error()` function & mixin for establishing "catchable" errors 230 | - global `$catch-errors` toggles how `error()` output is handled 231 | - FEATURE: Support testing `content` properties which include a curly brace. 232 | - Update dependencies 233 | 234 | ## 5.0.0 (06/03/19) 235 | 236 | - BREAKING: Update API for `runSass`, which now accepts two arguments: a 237 | `sassOptions` object and a `trueOptions` object. 238 | - BREAKING: Drop support for node < 8 239 | - Add docs and testing for usage with Jest 240 | [#135](https://github.com/oddbird/true/issues/135) 241 | - Add `sass` option to `runSass` for passing a different Sass implementation 242 | than `node-sass` [#137](https://github.com/oddbird/true/issues/137) 243 | - Remove `node-sass` from `peerDependencies` 244 | - Fix deprecated use of `assert.fail` 245 | [#138](https://github.com/oddbird/true/issues/138) 246 | - Update dev dependencies 247 | 248 | ## 4.0.0 (04/09/18) 249 | 250 | - BREAKING: Move `node-sass` to `peerDependencies` 251 | - Update dependencies 252 | - Add JS coverage reporting 253 | 254 | ## 3.1.0 (03/06/18) 255 | 256 | - NEW: Add `contains()` mixin for more minute output comparisons. 257 | Works the same as `expect()`, but doesn't require a complete match. 258 | - Update docs 259 | 260 | ## 3.0.2 (10/6/17) 261 | 262 | - Dependency updates 263 | 264 | ## 3.0.1 (9/13/17) 265 | 266 | - Update docs 267 | 268 | ## 3.0.0 (8/26/17) 269 | 270 | - Update dependencies & release 271 | 272 | ## 3.0.0-beta.1 (6/1/17) 273 | 274 | - Added `describe` and `it` mixins, 275 | as alias for `test-module` and `test` respectively. 276 | - Added `$inspect` argument to `assert-equal` and `assert-unequal` mixins, 277 | for comparing `inspect($assert) == inspect($expected)` 278 | instead of `$assert == $expected`. 279 | This helps with several of the equality edge-cases listed below 280 | (rounding and units). 281 | - BREAKING: No more Ruby gem or Ruby CLI 282 | - BREAKING: No more bower package 283 | - BREAKING: Removes special-handling of equality, 284 | in favor of allowing Sass to determine the best comparisons. 285 | There are a few edge-cases to be aware of: 286 | - In some versions of Sass, 287 | manipulated numbers and colors are compared without rounding, 288 | so `1/3 != 0.333333` and `lighten(#246, 15%) != #356a9f`. 289 | Use the `$inspect` argument to compare rounded output values. 290 | - In all versions of Sass, 291 | unitless numbers are considered comparable to all units, 292 | so `1 == 1x` where `x` represents any unit. 293 | Use the `$inspect` argument to compare output values with units. 294 | - Lists compare both values and delimiter, 295 | so `(one two three) != (one, two, three)`. 296 | This can be particularly confusing for single-item lists, 297 | which still have a delimiter assigned, 298 | even though it is not used. 299 | 300 | ## 2.2.2 (4/11/17) 301 | 302 | - `assert-true` returns false on empty strings and lists 303 | - `assert-false` returns true on empty strings and lists 304 | - Module/Test/Assertion stats are included in reports 305 | 306 | ## 2.2.1 (2/7/17) 307 | 308 | - Output CSS context around Mocha parsing errors. 309 | - Added `$fail-on-error` argument to `report()` mixin. 310 | Set to `true` if you need the Sass compiler to fail 311 | on broken tests. 312 | - Fix bug with `assert-false` causing it to fail on `null` values. 313 | - Allow unquoted descriptions and test/module names. 314 | - Fix bug throwing off test-count and reporting. 315 | 316 | ## 2.1.4 (12/22/16) 317 | 318 | - Fix default assertion messages 319 | - Upgrade dependencies 320 | 321 | ## 2.0.2 (5/13/15) 322 | 323 | - Fixes debug inspector. 324 | 325 | ## 2.0.1 (5/9/15) 326 | 327 | - Improve internal logic, and namespace private functions behind `_true-*`. 328 | - Add `assert()`, `input`, and `expect` mixins for testing CSS output. 329 | - Support for LibSass. 330 | - Add Mocha JS integration. 331 | - Create NPM package. 332 | - Simplify output options down to single `$true-terminal-output` setting. 333 | - Add eyeglass support. 334 | 335 | ## 1.0.1 (10/18/14) 336 | 337 | - LibSass 3.0 compatability. 338 | 339 | ## 1.0.0 (10/3/14) 340 | 341 | - Add command-line interface: `true-cli ` 342 | - Use `-s` flag for silent output 343 | - Check for unit differences between numbers. 344 | - Add assertion-failure details to css output. 345 | 346 | ## 0.2.0 (7/15/14) 347 | 348 | - Simplified reporting in both terminal and CSS. 349 | - Remove `default-module-output`, `$default-test-output` and `$default-final-output`. 350 | Replace them with `$true` settings map: `(output: css, summary: terminal css)`. 351 | `output` handles test/module output, `summary` handles final output. 352 | Assertions are always output to the terminal if they fail. 353 | - Update to use Sass map variables. 354 | - Add `report` function and `report` mixin, for reporting final results. 355 | - Only register as a compass extension if compass is present. 356 | Compass is no longer an explicit dependency. 357 | - Adjust the output styles to work with Sass 3.4 358 | and have more visual consistency. 359 | 360 | ## 0.1.5 (6/10/13) 361 | 362 | - Append actual results to custom failure messages. 363 | 364 | ## 0.1.4 (6/9/13) 365 | 366 | - Null result is considered a failure. 367 | - Allow output to be turned off for certain modules/tests/assertions. 368 | 369 | ## 0.1.3 (6/7/13) 370 | 371 | - Nest assertions within `test() {}` named tests. 372 | - Cleaner css output. 373 | 374 | ## 0.1.2 (6/7/13) 375 | 376 | - Use nesting for modules with `test-module() {}` 377 | - Added failure message argument to all assertions. 378 | 379 | ## 0.1.1 (6/6/13) 380 | 381 | - Fix bug in `lib/true.rb` compass plugin registration. 382 | 383 | ## 0.1.0 (6/6/13) 384 | 385 | - `assert-true()`, `assert-false()`, `assert-equal()`, and `assert-unequal()`. 386 | - `pass()` and `fail()` for tracking and reporting individual results. 387 | - `start-test-module()` and `report-test-result()` for module results. 388 | - Includes tests of the testing tools! 389 | -------------------------------------------------------------------------------- /test/css/test.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | /* # Module: Message */ 3 | /* ----------------- */ 4 | /* Test: Renders messages as CSS comments */ 5 | /* ASSERT: */ 6 | /* OUTPUT */ 7 | /* This is a simple message */ 8 | /* END_OUTPUT */ 9 | /* EXPECTED */ 10 | /* This is a simple message */ 11 | /* END_EXPECTED */ 12 | /* END_ASSERT */ 13 | /* */ 14 | /* Test: Renders lists as multiple CSS comments */ 15 | /* ASSERT: */ 16 | /* OUTPUT */ 17 | /* This is a */ 18 | /* multiline message */ 19 | /* END_OUTPUT */ 20 | /* EXPECTED */ 21 | /* This is a */ 22 | /* multiline message */ 23 | /* END_EXPECTED */ 24 | /* END_ASSERT */ 25 | /* */ 26 | /* */ 27 | /* # Module: Map Increment */ 28 | /* ----------------------- */ 29 | /* Test: Returns a map with the sum-values of two numeric maps */ 30 | /* ✔ [assert-equal] Returns a map with the sum-values of two numeric maps */ 31 | /* */ 32 | /* */ 33 | /* # Module: Join Multiple */ 34 | /* ----------------------- */ 35 | /* Test: Combines multiple lists */ 36 | /* ✔ [assert-equal] Combines multiple lists */ 37 | /* */ 38 | /* Test: Sets new list-separator */ 39 | /* ✔ [assert-equal] Sets new list-separator */ 40 | /* */ 41 | /* */ 42 | /* # Module: Context [mixin] & Context-Pop */ 43 | /* --------------------------------------- */ 44 | /* Test: Adds scope and name to context */ 45 | /* ✔ [assert-unequal] Confirm that there is currently no "fake" scope */ 46 | /* ✔ [assert-unequal] Confirm that there is currently no "fake" scope */ 47 | /* ✔ [assert-equal] Sets the value of scope "fake" to "this scope is not real" */ 48 | /* ✔ [assert-unequal] Confirm that "fake" scope has been removed */ 49 | /* ✔ [assert-unequal] Confirm that "fake" scope has been removed */ 50 | /* */ 51 | /* */ 52 | /* # Module: Output Context */ 53 | /* ------------------------ */ 54 | /* Test: Appends new context */ 55 | /* ✔ [assert-equal] Check initial value */ 56 | /* ✔ [assert-equal] Appends new context */ 57 | /* ✔ [assert-equal] Appends new context */ 58 | /* ✔ [assert-equal] Appends new context */ 59 | /* */ 60 | /* Test: Resets context */ 61 | /* ✔ [assert-equal] Resets context */ 62 | /* */ 63 | /* */ 64 | /* # Module: Validate Output Context */ 65 | /* --------------------------------- */ 66 | /* Test: allows multiple contains-string */ 67 | /* ✔ [assert-equal] allows multiple contains-string */ 68 | /* */ 69 | /* Test: allows multiple contains */ 70 | /* ✔ [assert-equal] allows multiple contains */ 71 | /* */ 72 | /* */ 73 | /* # Module: Context [function] & Context All */ 74 | /* ------------------------------------------ */ 75 | /* Test: Returns current module context */ 76 | /* ✔ [assert-equal] Returns current module context */ 77 | /* */ 78 | /* Test: Returns current test context */ 79 | /* ✔ [assert-equal] Returns current test context */ 80 | /* */ 81 | /* # Module: Context [function] & Context All :: Context [Nested] */ 82 | /* -------------------------------------------------------------- */ 83 | /* Test: Returns the innermost module name */ 84 | /* ✔ [assert-equal] Returns the innermost module name */ 85 | /* */ 86 | /* */ 87 | /* # Module: Context [function] & Context All :: Context All [Nested] */ 88 | /* ------------------------------------------------------------------ */ 89 | /* Test: Returns the current stack of module names */ 90 | /* ✔ [assert-equal] Returns the current stack of module names */ 91 | /* */ 92 | /* */ 93 | /* */ 94 | /* # Module: Pass Details */ 95 | /* ---------------------- */ 96 | /* Test: Properly output a passing assertion result */ 97 | /* ASSERT: passing test */ 98 | /* OUTPUT */ 99 | .test-output { 100 | /* ✔ [output] passing test */ 101 | } 102 | 103 | /* END_OUTPUT */ 104 | /* EXPECTED */ 105 | .test-output { 106 | /* ✔ [output] passing test */ 107 | } 108 | 109 | /* END_EXPECTED */ 110 | /* END_ASSERT */ 111 | /* */ 112 | /* */ 113 | /* # Module: Fail Details */ 114 | /* ---------------------- */ 115 | /* Test: Compiles full failure details */ 116 | /* ASSERT: */ 117 | /* OUTPUT */ 118 | /* ✖ FAILED: [assert-equal] Test Assertion */ 119 | /* - Output: [number] 0.3333333333333333 */ 120 | /* - Expected: [number] 0.333 */ 121 | /* - Details: numbers may need to be rounded before comparison */ 122 | /* - Module: Fail Details */ 123 | /* - Test: Compiles full failure details */ 124 | /* END_OUTPUT */ 125 | /* EXPECTED */ 126 | /* ✖ FAILED: [assert-equal] Test Assertion */ 127 | /* - Output: [number] 0.3333333333333333 */ 128 | /* - Expected: [number] 0.333 */ 129 | /* - Details: numbers may need to be rounded before comparison */ 130 | /* - Module: Fail Details */ 131 | /* - Test: Compiles full failure details */ 132 | /* END_EXPECTED */ 133 | /* END_ASSERT */ 134 | /* */ 135 | /* */ 136 | /* # Module: Variable Details */ 137 | /* -------------------------- */ 138 | /* Test: Number */ 139 | /* ✔ [assert-equal] Number */ 140 | /* */ 141 | /* Test: Color */ 142 | /* ✔ [assert-equal] Color */ 143 | /* */ 144 | /* Test: Map */ 145 | /* ✔ [assert-equal] Map */ 146 | /* */ 147 | /* Test: Bracketed List */ 148 | /* ✔ [assert-equal] Bracketed List */ 149 | /* */ 150 | /* */ 151 | /* # Module: Edgefail Notes */ 152 | /* ------------------------ */ 153 | /* Test: Type mismatch */ 154 | /* ✔ [assert-equal] Type mismatch */ 155 | /* ✔ [assert-equal] Type mismatch */ 156 | /* */ 157 | /* Test: Number Rounding */ 158 | /* ✔ [assert-equal] Number Rounding */ 159 | /* */ 160 | /* Test: List Separators */ 161 | /* ✔ [assert-equal] List Separators */ 162 | /* */ 163 | /* */ 164 | /* # Module: Get Result */ 165 | /* -------------------- */ 166 | /* Test: Equal Pass */ 167 | /* ✔ [assert-equal] Equal Pass */ 168 | /* */ 169 | /* Test: Equal Fail */ 170 | /* ✔ [assert-equal] Equal Fail */ 171 | /* */ 172 | /* Test: Unequal pass */ 173 | /* ✔ [assert-equal] Unequal pass */ 174 | /* */ 175 | /* Test: Unequal fail */ 176 | /* ✔ [assert-equal] Unequal fail */ 177 | /* */ 178 | /* */ 179 | /* # Module: Update Results */ 180 | /* ------------------------ */ 181 | /* Test: Add one run */ 182 | /* ✔ [assert-equal] Add one run */ 183 | /* */ 184 | /* Test: Add one pass */ 185 | /* ✔ [assert-equal] Add one pass */ 186 | /* */ 187 | /* Test: Fail counts are left as-is */ 188 | /* ✔ [assert-equal] Fail counts are left as-is */ 189 | /* */ 190 | /* Test: Output counts are left as-is */ 191 | /* ✔ [assert-equal] Output counts are left as-is */ 192 | /* */ 193 | /* */ 194 | /* # Module: Update Test */ 195 | /* --------------------- */ 196 | /* Test: Updates global test-result */ 197 | /* ✔ [assert-equal] confirm the default state */ 198 | /* ✔ [assert-equal] confirm updated test-result */ 199 | /* */ 200 | /* Test: Output-to-css overrides pass */ 201 | /* ✔ [assert-equal] Output-to-css overrides pass */ 202 | /* */ 203 | /* Test: Pass does not override output-to-css */ 204 | /* ✔ [assert-equal] Pass does not override output-to-css */ 205 | /* */ 206 | /* Test: Fail overrides everything */ 207 | /* ✔ [assert-equal] Fail overrides everything */ 208 | /* */ 209 | /* Test: Nothing overrides fail */ 210 | /* ✔ [assert-equal] Nothing overrides fail */ 211 | /* */ 212 | /* */ 213 | /* # Module: Results Message */ 214 | /* ------------------------- */ 215 | /* Test: Single Line */ 216 | /* ✔ [assert-equal] Single Line */ 217 | /* */ 218 | /* Test: Linebreaks */ 219 | /* ✔ [assert-equal] Linebreaks */ 220 | /* */ 221 | /* Test: No output tests */ 222 | /* ✔ [assert-equal] No output tests */ 223 | /* */ 224 | /* Test: Single test */ 225 | /* ✔ [assert-equal] Single test */ 226 | /* */ 227 | /* */ 228 | /* # Module: Update Stats Count */ 229 | /* ---------------------------- */ 230 | /* Test: Assertions counts are updated */ 231 | /* ✔ [assert-equal] Assertions counts are updated */ 232 | /* */ 233 | /* Test: Modules counts are left as-is */ 234 | /* ✔ [assert-equal] Modules counts are left as-is */ 235 | /* */ 236 | /* Test: Tests counts are left as-is */ 237 | /* ✔ [assert-equal] Tests counts are left as-is */ 238 | /* */ 239 | /* */ 240 | /* # Module: Stats Message */ 241 | /* ----------------------- */ 242 | /* Test: Single Line */ 243 | /* ✔ [assert-equal] Single Line */ 244 | /* */ 245 | /* Test: Linebreaks */ 246 | /* ✔ [assert-equal] Linebreaks */ 247 | /* */ 248 | /* */ 249 | /* # Module: Setup */ 250 | /* --------------- */ 251 | /* Test: Updates context based on current assertions */ 252 | /* ✔ [assert-equal] Updates context based on current assertions */ 253 | /* */ 254 | /* */ 255 | /* # Module: Is Truthy */ 256 | /* ------------------- */ 257 | /* Test: True is truthy */ 258 | /* ✔ [assert-equal] True is truthy */ 259 | /* */ 260 | /* Test: String is truthy */ 261 | /* ✔ [assert-equal] String is truthy */ 262 | /* */ 263 | /* Test: List is truthy */ 264 | /* ✔ [assert-equal] List is truthy */ 265 | /* */ 266 | /* Test: False is not truthy */ 267 | /* ✔ [assert-equal] False is not truthy */ 268 | /* */ 269 | /* Test: Null is not truthy */ 270 | /* ✔ [assert-equal] Null is not truthy */ 271 | /* */ 272 | /* Test: Empty string is not truthy */ 273 | /* ✔ [assert-equal] Empty string is not truthy */ 274 | /* */ 275 | /* Test: Empty list is not truthy */ 276 | /* ✔ [assert-equal] Empty list is not truthy */ 277 | /* */ 278 | /* */ 279 | /* # Module: Assert True */ 280 | /* --------------------- */ 281 | /* Test: Non-false properties return true */ 282 | /* ✔ [assert-true] Non-false properties return true */ 283 | /* */ 284 | /* Test: Supports is-truthy alias */ 285 | /* ✔ [assert-true] Supports is-truthy alias */ 286 | /* */ 287 | /* */ 288 | /* # Module: Assert False */ 289 | /* ---------------------- */ 290 | /* Test: Falsiness */ 291 | /* ✔ [assert-false] Negated properties return false. */ 292 | /* */ 293 | /* Test: null */ 294 | /* ✔ [assert-false] Null properties return false. */ 295 | /* */ 296 | /* Test: Empty string */ 297 | /* ✔ [assert-false] Empty string return false. */ 298 | /* */ 299 | /* Test: empty list */ 300 | /* ✔ [assert-false] Empty lists return false. */ 301 | /* */ 302 | /* Test: Supports is-falsy alias */ 303 | /* ✔ [assert-false] Supports is-falsy alias */ 304 | /* */ 305 | /* */ 306 | /* # Module: Assert Equal */ 307 | /* ---------------------- */ 308 | /* Test: Equality */ 309 | /* ✔ [assert-equal] 2 - 1 should equal 1. */ 310 | /* */ 311 | /* Test: Empty description */ 312 | /* ✔ [assert-equal] Empty description */ 313 | /* */ 314 | /* Test: Adding floats */ 315 | /* ✔ [assert-equal] Adding floats */ 316 | /* */ 317 | /* Test: Rounded numbers */ 318 | /* ✔ [assert-equal] Rounded numbers */ 319 | /* */ 320 | /* Test: Rounded colors */ 321 | /* ✔ [assert-equal] Rounded colors */ 322 | /* */ 323 | /* Test: Supports is-equal alias */ 324 | /* ✔ [assert-equal] Supports is-equal alias */ 325 | /* */ 326 | /* */ 327 | /* # Module: Assert UnEqual */ 328 | /* ------------------------ */ 329 | /* Test: Inequality */ 330 | /* ✔ [assert-unequal] 3 - 1 is not equal to 3. */ 331 | /* */ 332 | /* Test: Mismatched types */ 333 | /* ✔ [assert-unequal] Mismatched types */ 334 | /* */ 335 | /* Test: Mismatched units */ 336 | /* ✔ [assert-unequal] Mismatched units */ 337 | /* */ 338 | /* Test: Supports not-equal alias */ 339 | /* ✔ [assert-unequal] Supports not-equal alias */ 340 | /* */ 341 | /* */ 342 | /* # Module: Output Expect */ 343 | /* ----------------------- */ 344 | /* Test: Compares math output properly */ 345 | /* ASSERT: */ 346 | /* OUTPUT */ 347 | .test-output { 348 | -property1: 0.3; 349 | -property2: 0.3333333333; 350 | } 351 | 352 | /* END_OUTPUT */ 353 | /* EXPECTED */ 354 | .test-output { 355 | -property1: 0.3; 356 | -property2: 0.3333333333; 357 | } 358 | 359 | /* END_EXPECTED */ 360 | /* END_ASSERT */ 361 | /* */ 362 | /* Test: Matches output and expected selector patterns */ 363 | /* ASSERT: */ 364 | /* OUTPUT */ 365 | .test-output { 366 | -property: value; 367 | } 368 | @media (min-width: 30em) { 369 | .test-output { 370 | -prop: val; 371 | } 372 | .selector { 373 | -prop: val; 374 | } 375 | } 376 | 377 | /* END_OUTPUT */ 378 | /* EXPECTED */ 379 | .test-output { 380 | -property: value; 381 | } 382 | @media (min-width: 30em) { 383 | .test-output { 384 | -prop: val; 385 | } 386 | .selector { 387 | -prop: val; 388 | } 389 | } 390 | 391 | /* END_EXPECTED */ 392 | /* END_ASSERT */ 393 | /* */ 394 | /* */ 395 | /* # Module: Output Contains-string */ 396 | /* -------------------------------- */ 397 | /* Test: Contains sub-strings */ 398 | /* ASSERT: */ 399 | /* OUTPUT */ 400 | .test-output { 401 | height: 10px; 402 | width: 20px; 403 | } 404 | 405 | /* END_OUTPUT */ 406 | /* CONTAINS_STRING */ 407 | /* height */ 408 | /* END_CONTAINS_STRING */ 409 | /* END_ASSERT */ 410 | /* */ 411 | /* Test: Contains properties */ 412 | /* ASSERT: */ 413 | /* OUTPUT */ 414 | .test-output { 415 | --my-custom-property: 3rem; 416 | } 417 | 418 | /* END_OUTPUT */ 419 | /* CONTAINS_STRING */ 420 | /* --my-custom-property */ 421 | /* END_CONTAINS_STRING */ 422 | /* END_ASSERT */ 423 | /* */ 424 | /* Test: Contains values */ 425 | /* ASSERT: */ 426 | /* OUTPUT */ 427 | .test-output { 428 | font-family: Helvetica; 429 | } 430 | 431 | /* END_OUTPUT */ 432 | /* CONTAINS_STRING */ 433 | /* Helvetica */ 434 | /* END_CONTAINS_STRING */ 435 | /* END_ASSERT */ 436 | /* */ 437 | /* Test: Can assert multiple and overlapping strings */ 438 | /* ASSERT: */ 439 | /* OUTPUT */ 440 | .test-output { 441 | height: 10px; 442 | width: 20px; 443 | border: thin solid currentColor; 444 | } 445 | 446 | /* END_OUTPUT */ 447 | /* CONTAINS_STRING */ 448 | /* height */ 449 | /* END_CONTAINS_STRING */ 450 | /* CONTAINS_STRING */ 451 | /* solid */ 452 | /* END_CONTAINS_STRING */ 453 | /* CONTAINS_STRING */ 454 | /* 20px */ 455 | /* END_CONTAINS_STRING */ 456 | /* CONTAINS_STRING */ 457 | /* thin solid currentColor */ 458 | /* END_CONTAINS_STRING */ 459 | /* END_ASSERT */ 460 | /* */ 461 | /* */ 462 | /* # Module: Output Contains */ 463 | /* ------------------------- */ 464 | /* Test: Contains sub-string */ 465 | /* ASSERT: */ 466 | /* OUTPUT */ 467 | .test-output { 468 | height: 10px; 469 | width: 20px; 470 | } 471 | 472 | /* END_OUTPUT */ 473 | /* CONTAINED */ 474 | .test-output { 475 | height: 10px; 476 | } 477 | 478 | /* END_CONTAINED */ 479 | /* END_ASSERT */ 480 | /* */ 481 | /* Test: Contains nested selector block */ 482 | /* ASSERT: */ 483 | /* OUTPUT */ 484 | .test-output { 485 | height: 20px; 486 | } 487 | .test-output .class { 488 | height: 10px; 489 | } 490 | .test-output .other-class { 491 | height: 10px; 492 | } 493 | 494 | /* END_OUTPUT */ 495 | /* CONTAINED */ 496 | .test-output .class { 497 | height: 10px; 498 | } 499 | 500 | /* END_CONTAINED */ 501 | /* END_ASSERT */ 502 | /* */ 503 | /* Test: Can be used with nested classes */ 504 | /* ASSERT: */ 505 | /* OUTPUT */ 506 | .test-output { 507 | height: 20px; 508 | } 509 | .test-output .class { 510 | height: 10px; 511 | } 512 | .test-output .class .other-class { 513 | height: 10px; 514 | } 515 | 516 | /* END_OUTPUT */ 517 | /* CONTAINED */ 518 | .test-output .class .other-class { 519 | height: 10px; 520 | } 521 | 522 | /* END_CONTAINED */ 523 | /* END_ASSERT */ 524 | /* */ 525 | /* Test: Can be used with nested @media queries */ 526 | /* ASSERT: */ 527 | /* OUTPUT */ 528 | .test-output .class { 529 | height: 20px; 530 | } 531 | @media (min-width: 30em) and (min-width: 40em) { 532 | .test-output .selector { 533 | height: 10px; 534 | } 535 | .test-output .selector2 { 536 | height: 10px; 537 | } 538 | } 539 | @media (min-width: 30em) { 540 | .test-output .selector3 { 541 | height: 10px; 542 | } 543 | } 544 | 545 | /* END_OUTPUT */ 546 | /* CONTAINED */ 547 | @media (min-width: 30em) and (min-width: 40em) { 548 | .test-output .selector2 { 549 | height: 10px; 550 | } 551 | } 552 | 553 | /* END_CONTAINED */ 554 | /* END_ASSERT */ 555 | /* */ 556 | /* Test: Can contain multiple/overlapping assertions */ 557 | /* ASSERT: */ 558 | /* OUTPUT */ 559 | .test-output .example { 560 | width: 12em; 561 | height: 1lh; 562 | border: thin solid; 563 | } 564 | 565 | /* END_OUTPUT */ 566 | /* CONTAINED */ 567 | .test-output .example { 568 | width: 12em; 569 | } 570 | 571 | /* END_CONTAINED */ 572 | /* CONTAINED */ 573 | .test-output .example { 574 | height: 1lh; 575 | } 576 | 577 | /* END_CONTAINED */ 578 | /* CONTAINED */ 579 | .test-output .example { 580 | height: 1lh; 581 | border: thin solid; 582 | } 583 | 584 | /* END_CONTAINED */ 585 | /* END_ASSERT */ 586 | /* */ 587 | /* Test: Can be used with @at-root */ 588 | /* ASSERT: */ 589 | /* OUTPUT */ 590 | .selector { 591 | height: 10px; 592 | } 593 | 594 | /* END_OUTPUT */ 595 | /* CONTAINED */ 596 | .selector { 597 | height: 10px; 598 | } 599 | 600 | /* END_CONTAINED */ 601 | /* END_ASSERT */ 602 | /* */ 603 | /* Test: Can be used for all selector types */ 604 | /* ASSERT: */ 605 | /* OUTPUT */ 606 | .test-output .selector { 607 | -prop: val; 608 | } 609 | .test-output #id { 610 | -prop: value1; 611 | } 612 | .test-output div { 613 | -prop: value2; 614 | min-height: 20px; 615 | max-height: 30px; 616 | } 617 | .test-output input[type=text] { 618 | color: rebeccapurple; 619 | } 620 | .test-output * + * { 621 | color: red; 622 | display: none; 623 | } 624 | .test-output body > h1 { 625 | font-weight: bold; 626 | } 627 | .test-output i::before { 628 | -prop: value3; 629 | } 630 | .test-output div ~ p { 631 | color: green; 632 | } 633 | .test-output i:not(.italic) { 634 | text-decoration: underline; 635 | --custom: val; 636 | } 637 | 638 | /* END_OUTPUT */ 639 | /* CONTAINED */ 640 | .test-output .selector { 641 | -prop: val; 642 | } 643 | .test-output div { 644 | max-height: 30px; 645 | } 646 | .test-output body > h1 { 647 | font-weight: bold; 648 | } 649 | .test-output * + * { 650 | display: none; 651 | } 652 | .test-output i:not(.italic) { 653 | text-decoration: underline; 654 | } 655 | 656 | /* END_CONTAINED */ 657 | /* END_ASSERT */ 658 | /* */ 659 | /* Test: Can assert multiple properties within a selector */ 660 | /* ASSERT: */ 661 | /* OUTPUT */ 662 | .test-output .selector { 663 | width: 10px; 664 | min-height: 5px; 665 | max-height: 20px; 666 | } 667 | 668 | /* END_OUTPUT */ 669 | /* CONTAINED */ 670 | .test-output .selector { 671 | width: 10px; 672 | max-height: 20px; 673 | } 674 | 675 | /* END_CONTAINED */ 676 | /* END_ASSERT */ 677 | /* */ 678 | /* Test: Can assert with multiple matching selector */ 679 | /* ASSERT: */ 680 | /* OUTPUT */ 681 | .test-output .selector { 682 | width: 10px; 683 | } 684 | .test-output .selector { 685 | min-height: 5px; 686 | max-height: 20px; 687 | } 688 | 689 | /* END_OUTPUT */ 690 | /* CONTAINED */ 691 | .test-output .selector { 692 | width: 10px; 693 | min-height: 5px; 694 | } 695 | 696 | /* END_CONTAINED */ 697 | /* END_ASSERT */ 698 | /* */ 699 | /* */ 700 | /* # Module: Module Title */ 701 | /* ---------------------- */ 702 | /* Test: Returns the current module name, prefixed */ 703 | /* ✔ [assert-equal] Returns the current module name, prefixed */ 704 | /* */ 705 | /* # Module: Module Title :: Module Title [Nested] */ 706 | /* ----------------------------------------------- */ 707 | /* Test: Returns a concatenated title of current modules */ 708 | /* ✔ [assert-equal] Returns a concatenated title of current modules */ 709 | /* */ 710 | /* */ 711 | /* */ 712 | /* # Module: Underline */ 713 | /* ------------------- */ 714 | /* Test: Returns a string of dashes, the same length as the input */ 715 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 716 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 717 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 718 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 719 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 720 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 721 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 722 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 723 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 724 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 725 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 726 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 727 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 728 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 729 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 730 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 731 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 732 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 733 | /* ✔ [assert-equal] Returns a string of dashes, the same length as the input */ 734 | /* */ 735 | /* */ 736 | /* # Module: Test Module */ 737 | /* --------------------- */ 738 | /* Test: Changes the current module context */ 739 | /* ✔ [assert-equal] Changes the current module context */ 740 | /* */ 741 | /* # Module: Test Module :: Nested Unquoted Module Name */ 742 | /* ---------------------------------------------------- */ 743 | /* Test: Don’t barf on unquoted names */ 744 | /* ✔ [assert-true] Please don’t barf on me */ 745 | /* */ 746 | /* */ 747 | /* */ 748 | /* # Module: Describe */ 749 | /* ------------------ */ 750 | /* Test: Changes the current module context */ 751 | /* ✔ [assert-equal] Changes the current module context */ 752 | /* */ 753 | /* */ 754 | /* # Module: Tests */ 755 | /* --------------- */ 756 | /* Test: Test */ 757 | /* ✔ [assert-equal] Changes the current test context */ 758 | /* */ 759 | /* Test: It [alias] */ 760 | /* ✔ [assert-equal] Changes the current test context */ 761 | /* */ 762 | /* */ 763 | /* # Module: Report */ 764 | /* ---------------- */ 765 | /* Test: Output Message */ 766 | /* ASSERT: */ 767 | /* OUTPUT */ 768 | .test-output { 769 | /* # SUMMARY ---------- */ 770 | /* 6 Tests: */ 771 | /* - 5 Passed */ 772 | /* - 1 Failed */ 773 | /* Stats: */ 774 | /* - 4 Modules */ 775 | /* - 6 Tests */ 776 | /* - 25 Assertions */ 777 | /* -------------------- */ 778 | } 779 | 780 | /* END_OUTPUT */ 781 | /* EXPECTED */ 782 | .test-output { 783 | /* # SUMMARY ---------- */ 784 | /* 6 Tests: */ 785 | /* - 5 Passed */ 786 | /* - 1 Failed */ 787 | /* Stats: */ 788 | /* - 4 Modules */ 789 | /* - 6 Tests */ 790 | /* - 25 Assertions */ 791 | /* -------------------- */ 792 | } 793 | 794 | /* END_EXPECTED */ 795 | /* END_ASSERT */ 796 | /* */ 797 | /* */ 798 | /* # Module: Report Message */ 799 | /* ------------------------ */ 800 | /* Test: Single Line */ 801 | /* ✔ [assert-equal] Single Line */ 802 | /* */ 803 | /* Test: Linebreaks */ 804 | /* ✔ [assert-equal] Linebreaks */ 805 | /* */ 806 | /* */ 807 | .not-a-test { 808 | break: please-no; 809 | } 810 | 811 | /* # SUMMARY ---------- */ 812 | /* 96 Tests: */ 813 | /* - 76 Passed */ 814 | /* - 0 Failed */ 815 | /* - 20 Output to CSS */ 816 | /* Stats: */ 817 | /* - 37 Modules */ 818 | /* - 96 Tests */ 819 | /* - 123 Assertions */ 820 | /* -------------------- */ 821 | 822 | /*# sourceMappingURL=test.css.map */ 823 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import path from 'node:path'; 3 | 4 | import { diffStringsUnified } from 'jest-diff'; 5 | import { 6 | type AtRule as CssAtRule, 7 | type Comment as CssComment, 8 | parse as cssParse, 9 | type Position as NodePosition, 10 | type Rule as CssRule, 11 | } from 'postcss'; 12 | 13 | import * as constants from './constants'; 14 | import { 15 | cssStringToArrayOfRules, 16 | generateCss, 17 | isCommentNode, 18 | removeNewLines, 19 | splitSelectorAndProperties, 20 | truthyValues, 21 | } from './utils'; 22 | 23 | export interface TrueOptions { 24 | describe: (description: string, fn: () => void) => void; 25 | it: (description: string, fn: () => void) => void; 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | sass?: any; 28 | sourceType?: 'path' | 'string'; 29 | contextLines?: number; 30 | } 31 | 32 | export interface Assertion { 33 | description: string; 34 | assertionType?: string; 35 | output?: string; 36 | expected?: string; 37 | details?: string; 38 | passed?: boolean; 39 | [key: string]: boolean | string | undefined; 40 | } 41 | 42 | export interface Test { 43 | test: string; 44 | assertions: Assertion[]; 45 | } 46 | 47 | export interface Module { 48 | module: string; 49 | tests?: Test[]; 50 | modules?: Module[]; 51 | } 52 | 53 | export type Context = { 54 | modules: Module[]; 55 | currentModule?: Module; 56 | currentTest?: Test; 57 | currentAssertion?: Assertion; 58 | currentOutputRules?: Rule[]; 59 | currentExpectedRules?: Rule[]; 60 | currentExpectedStrings?: string[]; 61 | currentExpectedContained?: string[]; 62 | }; 63 | 64 | export type Rule = CssComment | CssRule | CssAtRule; 65 | 66 | export type Parser = (rule: Rule, ctx: Context) => Parser; 67 | 68 | const loadSass = function (sassPkg: string) { 69 | try { 70 | // eslint-disable-next-line @typescript-eslint/no-require-imports 71 | return require(sassPkg); 72 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 73 | } catch (err) { 74 | throw new Error(`Cannot find Dart Sass (\`${sassPkg}\`) dependency.`); 75 | } 76 | }; 77 | 78 | export const runSass = function ( 79 | trueOptions: TrueOptions, 80 | src: string, 81 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 82 | sassOptions?: any, 83 | ) { 84 | const trueOpts = Object.assign({}, trueOptions); 85 | const sassOpts = Object.assign({}, sassOptions); 86 | 87 | // Add True's sass to `loadPaths` 88 | const sassPath = path.join(__dirname, '..', 'sass'); 89 | if (sassOpts.loadPaths) { 90 | sassOpts.loadPaths.push(sassPath); 91 | } else { 92 | sassOpts.loadPaths = [sassPath]; 93 | } 94 | 95 | // Error if arguments match v6 API 96 | if (typeof src !== 'string' || !trueOptions.describe || !trueOptions.it) { 97 | throw new Error( 98 | 'The arguments provided to `runSass` do not match the new API ' + 99 | 'introduced in True v7. Refer to the v7 release notes ' + 100 | 'for migration documentation: ' + 101 | 'https://github.com/oddbird/true/releases/tag/v7.0.0', 102 | ); 103 | } 104 | 105 | // Error if `style: "compressed"` is used 106 | if (sassOpts.style === 'compressed') { 107 | throw new Error( 108 | 'True requires the default Sass `expanded` output style, ' + 109 | 'but `style: "compressed"` was used.', 110 | ); 111 | } 112 | 113 | let compiler; 114 | if (trueOpts.sass && typeof trueOpts.sass !== 'string') { 115 | compiler = trueOpts.sass; 116 | } else if (typeof trueOpts.sass === 'string') { 117 | compiler = loadSass(trueOpts.sass); 118 | } else { 119 | try { 120 | // try sass-embedded before sass 121 | compiler = loadSass('sass-embedded'); 122 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 123 | } catch (e1) { 124 | /* v8 ignore next */ 125 | try { 126 | compiler = loadSass('sass'); 127 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 128 | } catch (e2) { 129 | throw new Error( 130 | 'Cannot find Dart Sass (`sass-embedded` or `sass`) dependency.', 131 | ); 132 | } 133 | } 134 | } 135 | 136 | // Add the Sass Node.js package importer, if available 137 | if (!sassOpts.importers && compiler.NodePackageImporter) { 138 | sassOpts.importers = [new compiler.NodePackageImporter()]; 139 | } 140 | 141 | const compilerFn = 142 | trueOpts.sourceType === 'string' ? 'compileString' : 'compile'; 143 | const parsedCss = compiler[compilerFn](src, sassOpts).css; 144 | const modules = parse(parsedCss, trueOpts.contextLines); 145 | 146 | modules.forEach((module) => { 147 | describeModule(module, trueOpts.describe, trueOpts.it); 148 | }); 149 | }; 150 | 151 | export const formatFailureMessage = function (assertion: Assertion) { 152 | let msg = `${assertion.description} `; 153 | msg = `${msg}[type: ${assertion.assertionType}]`; 154 | if (assertion.details) { 155 | msg = `${msg} -- ${assertion.details}`; 156 | } 157 | 158 | // For contains-string assertions with multiple strings, show which ones are missing 159 | if (assertion.assertionType === 'contains-string' && assertion.expected) { 160 | const expectedStrings = assertion.expected.split('\n'); 161 | /* v8 ignore else */ 162 | if (expectedStrings.length > 1) { 163 | const output = assertion.output || ''; 164 | const missing = expectedStrings.filter((str) => !output.includes(str)); 165 | /* v8 ignore else */ 166 | if (missing.length > 0) { 167 | msg = `${msg}\n\nExpected output to contain all of the following strings:\n`; 168 | expectedStrings.forEach((str) => { 169 | const found = output.includes(str); 170 | msg = `${msg} ${found ? '✓' : '✗'} "${str}"\n`; 171 | }); 172 | msg = `${msg}\nActual output:\n${output}\n`; 173 | return msg; 174 | } 175 | } 176 | } 177 | 178 | // For contains assertions with multiple blocks, show which ones are missing 179 | if (assertion.assertionType === 'contains' && assertion.expected) { 180 | const expectedBlocks = assertion.expected.split('\n---\n'); 181 | /* v8 ignore else */ 182 | if (expectedBlocks.length > 1) { 183 | const output = assertion.output || ''; 184 | const missing = expectedBlocks.filter( 185 | (block) => !contains(output, block), 186 | ); 187 | /* v8 ignore else */ 188 | if (missing.length > 0) { 189 | msg = `${msg}\n\nExpected output to contain all of the following CSS blocks:\n`; 190 | expectedBlocks.forEach((block, index) => { 191 | const found = contains(output, block); 192 | msg = `${msg} ${found ? '✓' : '✗'} Block ${index + 1}:\n`; 193 | msg = `${msg}${block 194 | .split('\n') 195 | .map((line) => ` ${line}`) 196 | .join('\n')}\n`; 197 | }); 198 | msg = `${msg}\nActual output:\n${output}\n`; 199 | return msg; 200 | } 201 | } 202 | } 203 | 204 | msg = `${msg}\n\n${diffStringsUnified( 205 | assertion.expected || '', 206 | assertion.output || '', 207 | )}\n`; 208 | return msg; 209 | }; 210 | 211 | const describeModule = function ( 212 | module: Module, 213 | describe: TrueOptions['describe'], 214 | it: TrueOptions['it'], 215 | ) { 216 | describe(module.module, () => { 217 | module.modules?.forEach((submodule) => { 218 | describeModule(submodule, describe, it); 219 | }); 220 | module.tests?.forEach((test) => { 221 | it(test.test, () => { 222 | test.assertions?.forEach((assertion) => { 223 | if (!assertion.passed) { 224 | assert.fail(formatFailureMessage(assertion)); 225 | } 226 | }); 227 | }); 228 | }); 229 | }); 230 | }; 231 | 232 | const finishCurrentModule = function (ctx: Context) { 233 | finishCurrentTest(ctx); 234 | if (ctx.currentModule) { 235 | const paths = ctx.currentModule.module.split( 236 | constants.MODULE_NESTING_TOKEN, 237 | ); 238 | ctx.currentModule.module = paths[paths.length - 1] || ''; 239 | insertModule(paths, ctx.currentModule, ctx); 240 | delete ctx.currentModule; 241 | } 242 | }; 243 | 244 | const finishCurrentTest = function (ctx: Context) { 245 | finishCurrentAssertion(ctx); 246 | if (ctx.currentTest) { 247 | ctx.currentModule?.tests?.push(ctx.currentTest); 248 | delete ctx.currentTest; 249 | } 250 | }; 251 | 252 | const finishCurrentAssertion = function (ctx: Context) { 253 | if (ctx.currentAssertion) { 254 | ctx.currentTest?.assertions.push(ctx.currentAssertion); 255 | delete ctx.currentAssertion; 256 | } 257 | }; 258 | 259 | const insertModule = function (paths: string[], module: Module, ctx: Context) { 260 | if (!ctx.modules) { 261 | ctx.modules = []; 262 | } 263 | 264 | if (paths.length > 1) { 265 | let newCtx = ctx.modules.find((submod) => submod.module === paths[0]); 266 | if (!newCtx) { 267 | newCtx = { module: paths[0] }; 268 | ctx.modules.push(newCtx); 269 | } 270 | insertModule(paths.slice(1), module, newCtx as Context); 271 | } else { 272 | ctx.modules.push(module); 273 | } 274 | }; 275 | 276 | const dealWithAnnoyingMediaQueries = function (rawCSS: string) { 277 | const matchMediaQuery = /(@[a-zA-Z0-9:()\s-]+)/g; 278 | const matchCSSWithinMediaQueryBlock = 279 | /@[a-zA-Z0-9:()\s-]+{([a-zA-Z0-9:()\s-;._\\n{}]+)(?!}\\n})/g; 280 | 281 | const mediaqueries = rawCSS.match(matchMediaQuery); 282 | const rawCSSSansMediaQueries = rawCSS 283 | .replace(matchMediaQuery, '') 284 | .replace(matchCSSWithinMediaQueryBlock, '') 285 | .replace(/^{/, ''); 286 | let matches = matchCSSWithinMediaQueryBlock.exec(rawCSS); 287 | let i = 0; 288 | let mediaQueryBasedSelectors: string[] = []; 289 | const mediaqueryRule = (rule: string) => (mediaqueries?.[i] || '') + rule; 290 | while (matches !== null) { 291 | // This is necessary to avoid infinite loops with zero-width matches 292 | /* v8 ignore next */ 293 | if (matches.index === matchCSSWithinMediaQueryBlock.lastIndex) { 294 | matchCSSWithinMediaQueryBlock.lastIndex++; 295 | } 296 | 297 | const cssWithinMediaQuery = removeNewLines(matches[1]); 298 | const cssRules = cssStringToArrayOfRules(cssWithinMediaQuery); 299 | 300 | mediaQueryBasedSelectors = mediaQueryBasedSelectors.concat( 301 | cssRules.map(mediaqueryRule), 302 | ); 303 | 304 | i++; 305 | matches = matchCSSWithinMediaQueryBlock.exec(rawCSS); 306 | } 307 | 308 | return { 309 | mediaQueryBasedSelectors, 310 | rawCSSSansMediaQueries, 311 | }; 312 | }; 313 | 314 | const createSelectorsRulesPairs = function (cssString: string) { 315 | const processedMediaQueries = dealWithAnnoyingMediaQueries(cssString); 316 | const mediaQueries = splitSelectorAndProperties( 317 | processedMediaQueries.mediaQueryBasedSelectors, 318 | ); 319 | const nonMediaQueries = processedMediaQueries.rawCSSSansMediaQueries; 320 | 321 | const blocks = cssStringToArrayOfRules(nonMediaQueries); 322 | 323 | const splitBlocks = splitSelectorAndProperties(blocks); 324 | 325 | return splitBlocks.concat(mediaQueries).filter(truthyValues); 326 | }; 327 | 328 | const contains = function (output: string, expected: string) { 329 | const outputBlocks = createSelectorsRulesPairs(output); 330 | const expectedBlocks = createSelectorsRulesPairs(expected); 331 | 332 | const results = expectedBlocks.map((block) => { 333 | const matchingOutputBlocks = outputBlocks.filter( 334 | (element) => element.selector === block.selector, 335 | ); 336 | if (matchingOutputBlocks.length) { 337 | // Turns a css string into an array of property-value pairs. 338 | const expectedProperties = block.output 339 | .split(';') 340 | .map((propertyValuePair) => propertyValuePair.trim()) 341 | .filter( 342 | (innerBlock) => 343 | innerBlock && innerBlock !== ' {' && innerBlock !== '}', 344 | ); 345 | 346 | // This is the assertion itself! 347 | return expectedProperties.every((property) => 348 | matchingOutputBlocks.some((outputBlock) => 349 | outputBlock.output.includes(property), 350 | ), 351 | ); 352 | } 353 | return false; 354 | }); 355 | 356 | return results.every((result) => result === true); 357 | }; 358 | 359 | export const parse = function ( 360 | rawCss: Readonly, 361 | ctxLines?: Readonly, 362 | ): Module[] { 363 | const contextLines = typeof ctxLines === 'undefined' ? 10 : ctxLines; 364 | const lines = rawCss.split(/\r?\n/); 365 | 366 | const parseCss = function () { 367 | const ctx: Context = { modules: [] }; 368 | let handler = parseModule; 369 | cssParse(rawCss).each((node) => { 370 | /* v8 ignore else */ 371 | if (['comment', 'rule', 'atrule'].includes(node.type)) { 372 | handler = handler(node as Rule, ctx); 373 | } 374 | }); 375 | 376 | finishCurrentModule(ctx); 377 | 378 | return ctx.modules; 379 | }; 380 | 381 | const parseError = function ( 382 | msg: string, 383 | seeking: string, 384 | start?: NodePosition | undefined, 385 | ) { 386 | const unknown = ''; 387 | let errorMsg = 388 | `Line ${start?.line ?? unknown}, ` + 389 | `column ${start?.column ?? unknown}: ${msg}; ` + 390 | `looking for ${seeking || unknown}.`; 391 | /* v8 ignore else */ 392 | if (start?.line && start?.column) { 393 | errorMsg = 394 | `${errorMsg}\n` + 395 | `-- Context --\n${lines 396 | .slice(Math.max(0, start.line - contextLines), start.line) 397 | .join('\n')}\n${' '.repeat(start.column - 1)}^\n`; 398 | } 399 | return new Error(errorMsg); 400 | }; 401 | 402 | const parseModule: Parser = function (rule, ctx) { 403 | if (isCommentNode(rule)) { 404 | const text = rule.text.trim(); 405 | /* v8 ignore else */ 406 | if (!text) { 407 | return parseModule; 408 | } 409 | if (text.startsWith(constants.MODULE_TOKEN)) { 410 | finishCurrentModule(ctx); 411 | ctx.currentModule = { 412 | module: text.substring(constants.MODULE_TOKEN.length), 413 | tests: [], 414 | }; 415 | return parseTest; 416 | } 417 | if (text.startsWith(constants.SUMMARY_TOKEN)) { 418 | return ignoreUntilEndSummary; 419 | } 420 | // ignore un-recognized comments, keep looking for module header. 421 | return parseModule; 422 | } 423 | // ignore other rule types 424 | return parseModule; 425 | }; 426 | 427 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 428 | const ignoreUntilEndSummary: Parser = function (rule, ctx) { 429 | if (isCommentNode(rule)) { 430 | const text = rule.text.trim(); 431 | if (text.startsWith(constants.END_SUMMARY_TOKEN)) { 432 | return parseModule; 433 | } 434 | return ignoreUntilEndSummary; 435 | } 436 | throw parseError( 437 | `Unexpected rule type "${rule.type}"`, 438 | 'end summary', 439 | rule.source?.start, 440 | ); 441 | }; 442 | 443 | const parseTest: Parser = function (rule, ctx) { 444 | if (isCommentNode(rule)) { 445 | const text = rule.text.trim(); 446 | /* v8 ignore else */ 447 | if (!text) { 448 | return parseTest; 449 | } 450 | if (text.match(/^-+$/)) { 451 | return parseTest; 452 | } 453 | if (text.startsWith(constants.TEST_TOKEN)) { 454 | finishCurrentTest(ctx); 455 | ctx.currentTest = { 456 | test: text.substring(constants.TEST_TOKEN.length), 457 | assertions: [], 458 | }; 459 | return parseAssertion; 460 | } 461 | return parseModule(rule, ctx); 462 | } 463 | // ignore other rule types 464 | return parseModule; 465 | }; 466 | 467 | const parseAssertion: Parser = function (rule, ctx) { 468 | if (isCommentNode(rule)) { 469 | const text = rule.text.trim(); 470 | /* v8 ignore else */ 471 | if (!text) { 472 | return parseAssertion; 473 | } 474 | if (text.startsWith(constants.PASS_TOKEN)) { 475 | finishCurrentAssertion(ctx); 476 | ctx.currentAssertion = { 477 | description: 478 | text.substring(constants.PASS_TOKEN.length).trim() || 479 | '', 480 | passed: true, 481 | }; 482 | return parseAssertion; 483 | } else if (text.startsWith(constants.FAIL_TOKEN)) { 484 | finishCurrentAssertion(ctx); 485 | const endAssertionType = text.indexOf(constants.END_FAIL_TOKEN); 486 | ctx.currentAssertion = { 487 | description: text.substring(endAssertionType + 2).trim(), 488 | passed: false, 489 | assertionType: text 490 | .substring(constants.FAIL_TOKEN.length, endAssertionType) 491 | .trim(), 492 | }; 493 | return parseFailureDetail; 494 | } else if (text.startsWith(constants.ASSERT_TOKEN)) { 495 | finishCurrentAssertion(ctx); 496 | ctx.currentAssertion = { 497 | description: text.substring(constants.ASSERT_TOKEN.length).trim(), 498 | assertionType: 'equal', 499 | }; 500 | return parseAssertionOutputStart; 501 | } 502 | return parseTest(rule, ctx); 503 | } 504 | // ignore other rule types 505 | return parseModule; 506 | }; 507 | 508 | const parseFailureDetail: Parser = function (rule, ctx) { 509 | if (isCommentNode(rule)) { 510 | const text = rule.text.trim(); 511 | if (text.startsWith(constants.FAILURE_DETAIL_TOKEN)) { 512 | const detail = text.substring(constants.FAILURE_DETAIL_TOKEN.length); 513 | const isOutput = detail.startsWith(constants.OUTPUT_TOKEN); 514 | const isExpected = detail.startsWith(constants.EXPECTED_TOKEN); 515 | let outputOrExpected: 'output' | 'expected' | undefined; 516 | if (isOutput) { 517 | outputOrExpected = 'output'; 518 | } else if (isExpected) { 519 | outputOrExpected = 'expected'; 520 | } 521 | /* v8 ignore else */ 522 | if (outputOrExpected) { 523 | /* v8 ignore else */ 524 | if (ctx.currentAssertion) { 525 | const startType = text.indexOf(constants.FAILURE_TYPE_START_TOKEN); 526 | const endType = text.indexOf(constants.FAILURE_TYPE_END_TOKEN); 527 | const type = text.substring(startType, endType + 1); 528 | const content = text.substring(endType + 2); 529 | ctx.currentAssertion[outputOrExpected] = `${type} ${content}`; 530 | } 531 | return parseFailureDetail; 532 | } 533 | const splitAt = detail.indexOf(constants.DETAILS_SEPARATOR_TOKEN); 534 | /* v8 ignore else */ 535 | if (splitAt !== -1) { 536 | /* v8 ignore else */ 537 | if (ctx.currentAssertion) { 538 | const key = detail.substring(0, splitAt); 539 | ctx.currentAssertion[key.toLowerCase()] = detail.substring( 540 | splitAt + constants.DETAILS_SEPARATOR_TOKEN.length, 541 | ); 542 | } 543 | return parseFailureDetail; 544 | } 545 | } 546 | return parseAssertion(rule, ctx); 547 | } 548 | throw parseError( 549 | `Unexpected rule type "${rule.type}"`, 550 | 'output/expected', 551 | rule.source?.start, 552 | ); 553 | }; 554 | 555 | const parseAssertionOutputStart: Parser = function (rule, ctx) { 556 | if (isCommentNode(rule)) { 557 | const text = rule.text.trim(); 558 | /* v8 ignore else */ 559 | if (!text) { 560 | return parseAssertionOutputStart; 561 | } 562 | if (text === constants.OUTPUT_START_TOKEN) { 563 | ctx.currentOutputRules = []; 564 | return parseAssertionOutput; 565 | } 566 | throw parseError( 567 | `Unexpected comment "${text}"`, 568 | 'OUTPUT', 569 | rule.source?.start, 570 | ); 571 | } 572 | throw parseError( 573 | `Unexpected rule type "${rule.type}"`, 574 | 'OUTPUT', 575 | rule.source?.start, 576 | ); 577 | }; 578 | 579 | const parseAssertionOutput: Parser = function (rule, ctx) { 580 | if (isCommentNode(rule)) { 581 | if (rule.text.trim() === constants.OUTPUT_END_TOKEN) { 582 | /* v8 ignore else */ 583 | if (ctx.currentAssertion) { 584 | ctx.currentAssertion.output = generateCss( 585 | ctx.currentOutputRules || [], 586 | ); 587 | } 588 | delete ctx.currentOutputRules; 589 | return parseAssertionExpectedStart; 590 | } 591 | } 592 | ctx.currentOutputRules?.push(rule); 593 | return parseAssertionOutput; 594 | }; 595 | 596 | const parseAssertionExpectedStart: Parser = function (rule, ctx) { 597 | /* v8 ignore else */ 598 | if (isCommentNode(rule)) { 599 | const text = rule.text.trim(); 600 | /* v8 ignore else */ 601 | if (!text) { 602 | return parseAssertionExpectedStart; 603 | } 604 | if (text === constants.EXPECTED_START_TOKEN) { 605 | ctx.currentExpectedRules = []; 606 | return parseAssertionExpected; 607 | } 608 | 609 | if (text === constants.CONTAINED_START_TOKEN) { 610 | ctx.currentExpectedRules = []; 611 | // Initialize array for multiple contains assertions 612 | /* v8 ignore else */ 613 | if (!ctx.currentExpectedContained) { 614 | ctx.currentExpectedContained = []; 615 | } 616 | return parseAssertionContained; 617 | } 618 | if (text === constants.CONTAINS_STRING_START_TOKEN) { 619 | ctx.currentExpectedRules = []; 620 | // Initialize array for multiple contains-string assertions 621 | /* v8 ignore else */ 622 | if (!ctx.currentExpectedStrings) { 623 | ctx.currentExpectedStrings = []; 624 | } 625 | return parseAssertionContainsString; 626 | } 627 | throw parseError( 628 | `Unexpected comment "${text}"`, 629 | 'EXPECTED', 630 | rule.source?.start, 631 | ); 632 | } 633 | throw parseError( 634 | `Unexpected rule type "${rule.type}"`, 635 | 'EXPECTED', 636 | rule.source?.start, 637 | ); 638 | }; 639 | 640 | const parseAssertionExpected: Parser = function (rule, ctx) { 641 | /* v8 ignore else */ 642 | if (isCommentNode(rule)) { 643 | if (rule.text.trim() === constants.EXPECTED_END_TOKEN) { 644 | /* v8 ignore else */ 645 | if (ctx.currentAssertion) { 646 | ctx.currentAssertion.expected = generateCss( 647 | ctx.currentExpectedRules || [], 648 | ); 649 | ctx.currentAssertion.passed = 650 | ctx.currentAssertion.output === ctx.currentAssertion.expected; 651 | } 652 | delete ctx.currentExpectedRules; 653 | return parseEndAssertion; 654 | } 655 | } 656 | ctx.currentExpectedRules?.push(rule); 657 | return parseAssertionExpected; 658 | }; 659 | 660 | const parseEndAssertion: Parser = function (rule, ctx) { 661 | /* v8 ignore else */ 662 | if (isCommentNode(rule)) { 663 | const text = rule.text.trim(); 664 | /* v8 ignore else */ 665 | if (!text) { 666 | return parseEndAssertion; 667 | } 668 | /* v8 ignore else */ 669 | if (text === constants.ASSERT_END_TOKEN) { 670 | finishCurrentAssertion(ctx); 671 | return parseAssertion; 672 | } 673 | throw parseError( 674 | `Unexpected comment "${text}"`, 675 | 'END_ASSERT', 676 | rule.source?.start, 677 | ); 678 | } 679 | throw parseError( 680 | `Unexpected rule type "${rule.type}"`, 681 | 'END_ASSERT', 682 | rule.source?.start, 683 | ); 684 | }; 685 | 686 | const parseAssertionContained: Parser = function (rule, ctx) { 687 | if ( 688 | isCommentNode(rule) && 689 | rule.text.trim() === constants.CONTAINED_END_TOKEN 690 | ) { 691 | const expectedCss = generateCss(ctx.currentExpectedRules || []); 692 | 693 | // Add this expected CSS block to the array 694 | ctx.currentExpectedContained?.push(expectedCss); 695 | 696 | delete ctx.currentExpectedRules; 697 | return parseAssertionContainedEnd; 698 | } 699 | ctx.currentExpectedRules?.push(rule); 700 | return parseAssertionContained; 701 | }; 702 | 703 | const parseAssertionContainedEnd: Parser = function (rule, ctx) { 704 | /* v8 ignore else */ 705 | if (isCommentNode(rule)) { 706 | const text = rule.text.trim(); 707 | /* v8 ignore else */ 708 | if (!text) { 709 | return parseAssertionContainedEnd; 710 | } 711 | // Check for another CONTAINED block 712 | /* v8 ignore else */ 713 | if (text === constants.CONTAINED_START_TOKEN) { 714 | ctx.currentExpectedRules = []; 715 | return parseAssertionContained; 716 | } 717 | // Check for END_ASSERT - finalize the assertion 718 | /* v8 ignore else */ 719 | if (text === constants.ASSERT_END_TOKEN) { 720 | /* v8 ignore else */ 721 | if (ctx.currentAssertion && ctx.currentExpectedContained) { 722 | // Check if all expected CSS blocks are found in the output 723 | const allFound = ctx.currentExpectedContained.every((expectedCss) => 724 | contains(ctx.currentAssertion?.output || '', expectedCss), 725 | ); 726 | ctx.currentAssertion.passed = allFound; 727 | ctx.currentAssertion.assertionType = 'contains'; 728 | // Store all expected CSS blocks joined with newlines for display 729 | ctx.currentAssertion.expected = 730 | ctx.currentExpectedContained.join('\n---\n'); 731 | } 732 | delete ctx.currentExpectedContained; 733 | finishCurrentAssertion(ctx); 734 | return parseAssertion; 735 | } 736 | throw parseError( 737 | `Unexpected comment "${text}"`, 738 | 'CONTAINED or END_ASSERT', 739 | rule.source?.start, 740 | ); 741 | } 742 | throw parseError( 743 | `Unexpected rule type "${rule.type}"`, 744 | 'CONTAINED or END_ASSERT', 745 | rule.source?.start, 746 | ); 747 | }; 748 | 749 | const parseAssertionContainsString: Parser = function (rule, ctx) { 750 | if ( 751 | isCommentNode(rule) && 752 | rule.text.trim() === constants.CONTAINS_STRING_END_TOKEN 753 | ) { 754 | // The string to find is wrapped in a Sass comment because it might not 755 | // always be a complete, valid CSS block on its own. These replace calls 756 | // are necessary to strip the leading `/*` and trailing `*/` characters 757 | // that enclose the string, so we're left with just the raw string to 758 | // find for accurate comparison. 759 | const expectedString = generateCss(ctx.currentExpectedRules || []) 760 | .replace(new RegExp('^/\\*'), '') 761 | .replace(new RegExp('\\*/$'), '') 762 | .trim(); 763 | 764 | // Add this string to the array of expected strings 765 | ctx.currentExpectedStrings?.push(expectedString); 766 | 767 | delete ctx.currentExpectedRules; 768 | return parseAssertionContainsStringEnd; 769 | } 770 | ctx.currentExpectedRules?.push(rule); 771 | return parseAssertionContainsString; 772 | }; 773 | 774 | const parseAssertionContainsStringEnd: Parser = function (rule, ctx) { 775 | /* v8 ignore else */ 776 | if (isCommentNode(rule)) { 777 | const text = rule.text.trim(); 778 | /* v8 ignore else */ 779 | if (!text) { 780 | return parseAssertionContainsStringEnd; 781 | } 782 | // Check for another CONTAINS_STRING block 783 | /* v8 ignore else */ 784 | if (text === constants.CONTAINS_STRING_START_TOKEN) { 785 | ctx.currentExpectedRules = []; 786 | return parseAssertionContainsString; 787 | } 788 | // Check for END_ASSERT - finalize the assertion 789 | /* v8 ignore else */ 790 | if (text === constants.ASSERT_END_TOKEN) { 791 | /* v8 ignore else */ 792 | if (ctx.currentAssertion && ctx.currentExpectedStrings) { 793 | // Check if all expected strings are found in the output 794 | const allFound = ctx.currentExpectedStrings.every((str) => 795 | ctx.currentAssertion?.output?.includes(str), 796 | ); 797 | ctx.currentAssertion.passed = allFound; 798 | ctx.currentAssertion.assertionType = 'contains-string'; 799 | // Store all expected strings joined with newlines for display 800 | ctx.currentAssertion.expected = ctx.currentExpectedStrings.join('\n'); 801 | } 802 | delete ctx.currentExpectedStrings; 803 | finishCurrentAssertion(ctx); 804 | return parseAssertion; 805 | } 806 | throw parseError( 807 | `Unexpected comment "${text}"`, 808 | 'CONTAINS_STRING or END_ASSERT', 809 | rule.source?.start, 810 | ); 811 | } 812 | throw parseError( 813 | `Unexpected rule type "${rule.type}"`, 814 | 'CONTAINS_STRING or END_ASSERT', 815 | rule.source?.start, 816 | ); 817 | }; 818 | 819 | return parseCss(); 820 | }; 821 | --------------------------------------------------------------------------------