├── .github ├── FUNDING.yml └── workflows │ └── nodejs.yml ├── tests ├── fixtures │ ├── single-component.html │ ├── template.html │ ├── template.php │ ├── multiple-components.html │ └── version.html ├── ava │ ├── example.test.js │ └── use-cases.test.js ├── unit │ ├── wait-for.js │ ├── version.js │ ├── render.js │ └── load.js ├── jest │ ├── example.test.js │ ├── data.test.js │ └── use-cases.test.js ├── tape │ ├── example.test.js │ └── use-cases.test.js └── uvu │ ├── example.test.js │ └── use-cases.test.js ├── .editorconfig ├── src ├── version-mismatch.js ├── config.js └── main.js ├── LICENSE ├── .gitignore ├── package.json ├── types.d.ts ├── USE_CASES.md └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: HugoDF 2 | -------------------------------------------------------------------------------- /tests/fixtures/single-component.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /tests/fixtures/template.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /tests/fixtures/template.php: -------------------------------------------------------------------------------- 1 |
"> 2 | 3 |
4 | -------------------------------------------------------------------------------- /tests/fixtures/multiple-components.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | -------------------------------------------------------------------------------- /tests/fixtures/version.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | # The JSON files contain newlines inconsistently 13 | [*.json] 14 | insert_final_newline = ignore 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | 19 | -------------------------------------------------------------------------------- /tests/ava/example.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {render} from '../../src/main.js'; 3 | 4 | test('render - example', (t) => { 5 | const componentHtml = `
6 | 7 |
`; 8 | const component = render(componentHtml); 9 | t.is(component.querySelector('span').textContent, 'bar'); 10 | }); 11 | test('render - override sanity check', (t) => { 12 | const componentHtml = `
13 | 14 |
`; 15 | const component = render(componentHtml, {foo: 'baz'}); 16 | t.is(component.querySelector('span').textContent, 'baz'); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/unit/wait-for.js: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {render, waitFor} from '../../src/main.js'; 4 | 5 | test('waitFor - x-show toggles style.display', async () => { 6 | const component = render(`
7 | 8 | 9 |
`); 10 | 11 | assert.is(component.querySelector('span').style.display, 'none'); 12 | component.querySelector('button').click(); 13 | await waitFor(() => { 14 | assert.is(component.querySelector('span').style.display, ''); 15 | }); 16 | }); 17 | 18 | test.run(); 19 | -------------------------------------------------------------------------------- /tests/jest/example.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const {render} = require('../../src/main.js'); 3 | 4 | test('render - example', () => { 5 | const componentHtml = `
6 | 7 |
`; 8 | const component = render(componentHtml); 9 | expect(component.querySelector('span').textContent).toEqual('bar'); 10 | }); 11 | 12 | test('render - override sanity check', () => { 13 | const componentHtml = `
14 | 15 |
`; 16 | const component = render(componentHtml, {foo: 'baz'}); 17 | expect(component.querySelector('span').textContent).toEqual('baz'); 18 | }); 19 | -------------------------------------------------------------------------------- /tests/tape/example.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import {render} from '../../src/main.js'; 3 | 4 | test('render - example', (t) => { 5 | t.plan(1); 6 | const componentHtml = `
7 | 8 |
`; 9 | const component = render(componentHtml); 10 | t.is(component.querySelector('span').textContent, 'bar'); 11 | }); 12 | test('render - override sanity check', (t) => { 13 | t.plan(1); 14 | const componentHtml = `
15 | 16 |
`; 17 | const component = render(componentHtml, {foo: 'baz'}); 18 | t.is(component.querySelector('span').textContent, 'baz'); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/jest/data.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const {render} = require('../../src/main.js'); 3 | 4 | test('render - sets $data properties on the component', () => { 5 | const component = render( 6 | `
7 | 8 |
` 9 | ); 10 | expect(component.$data.foo).toEqual('bar'); 11 | }); 12 | 13 | test('render - updating $data works', async () => { 14 | const component = render( 15 | `
16 | 17 |
` 18 | ); 19 | 20 | component.$data.foo = 'baz'; 21 | 22 | await component.$nextTick(); 23 | expect(component.$data.foo).toEqual('baz'); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/uvu/example.test.js: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {render} from '../../src/main.js'; 4 | 5 | test('render - example', () => { 6 | const componentHtml = `
7 | 8 |
`; 9 | const component = render(componentHtml); 10 | assert.is(component.querySelector('span').textContent, 'bar'); 11 | }); 12 | test('render - override sanity check', () => { 13 | const componentHtml = `
14 | 15 |
`; 16 | const component = render(componentHtml, {foo: 'baz'}); 17 | assert.is(component.querySelector('span').textContent, 'baz'); 18 | }); 19 | 20 | test.run(); 21 | -------------------------------------------------------------------------------- /tests/unit/version.js: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import path from 'path'; 4 | import {load} from '../../src/main.js'; 5 | import Alpine from 'alpinejs'; 6 | 7 | const stub = (fn) => { 8 | const calls = []; 9 | const callable = (...args) => { 10 | calls.push(args); 11 | fn(...args); 12 | }; 13 | 14 | callable.calls = calls; 15 | callable.firstCall = () => calls[0]; 16 | return callable; 17 | }; 18 | 19 | test('load - Alpine.js version mismatch', async () => { 20 | console.warn = stub(() => {}); 21 | const component = await load( 22 | path.join(__dirname, '../fixtures/version.html') 23 | ); 24 | assert.is(component, `
`); 25 | assert.is( 26 | console.warn.firstCall()[0], 27 | `alpine-test-utils: Alpine.js version is different to CDN one, requested "1.x.x", testing with "${Alpine.version}"` 28 | ); 29 | }); 30 | 31 | test.run(); 32 | -------------------------------------------------------------------------------- /src/version-mismatch.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {Document} document - document from which Alpine components are being loaded from 4 | * @param {string} alpineVersion - Alpine.js version from NPM 5 | * @returns {void} 6 | */ 7 | function checkVersionMismatch(document, alpineVersion) { 8 | if (document.scripts.length === 0) return; 9 | const alpineScript = [...document.scripts].find( 10 | (s) => s.src.includes('dist/alpine') || s.src.includes('alpinejs/alpine') 11 | ); 12 | if (!alpineScript) return; 13 | // Match v1.x.x, v2.x.x etc (the bit between @ and /)from 14 | // `https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js` 15 | const [jsDelivrVersion] = alpineScript.src.match(/(?<=@v)[a-z|\d.]*(?=\/)/gm); 16 | const cdnMajorVersion = jsDelivrVersion[0]; 17 | if (!alpineVersion.startsWith(cdnMajorVersion)) { 18 | console.warn( 19 | `alpine-test-utils: Alpine.js version is different to CDN one, requested "${jsDelivrVersion}", testing with "${alpineVersion}"` 20 | ); 21 | } 22 | } 23 | 24 | module.exports = {checkVersionMismatch}; 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2019-2020 Hugo Di Francesco 3 | 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 19 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 20 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 21 | OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const {JSDOM} = require('jsdom'); 3 | 4 | /** 5 | * Override Node.js `global` using passed `override` object. 6 | * 7 | * @param {object} override - Override object 8 | * @returns {void} 9 | */ 10 | function setGlobal(override) { 11 | Object.assign(global, override); 12 | } 13 | 14 | /** 15 | * Set `navigator` global. 16 | * 17 | * @param {Navigator} navigator 18 | * @returns {void} 19 | */ 20 | function setNavigator(navigator) { 21 | setGlobal({ 22 | navigator 23 | }); 24 | } 25 | 26 | /** 27 | * Set `MutationObserver` global. 28 | * 29 | * @param {Function} mutationObserver 30 | * @returns {void} 31 | */ 32 | function setMutationObserver(mutationObserver) { 33 | setGlobal({ 34 | MutationObserver: mutationObserver 35 | }); 36 | } 37 | 38 | /** 39 | * Pre-Alpine.start() setup work. 40 | * 41 | * @returns {void} 42 | */ 43 | function config() { 44 | // These need to happen before Alpine.js loads 45 | // otherwise it tries to start() itself, 46 | // since "isTesting" returns false. 47 | setNavigator(new JSDOM().window.navigator); 48 | setMutationObserver( 49 | class { 50 | observe() {} 51 | } 52 | ); 53 | } 54 | 55 | module.exports = { 56 | setGlobal, 57 | setNavigator, 58 | setMutationObserver, 59 | config 60 | }; 61 | -------------------------------------------------------------------------------- /tests/jest/use-cases.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | const {render, waitFor, setGlobal} = require('../../src/main.js'); 3 | 4 | test('use-case - clicking a button to toggle visibility', async () => { 5 | const component = render(`
6 | 7 | 8 |
`); 9 | 10 | expect(component.querySelector('span').style.display).toEqual('none'); 11 | component.querySelector('button').click(); 12 | await waitFor(() => { 13 | expect(component.querySelector('span').style.display).toEqual(''); 14 | }); 15 | }); 16 | 17 | test('use-case - intercepting fetch calls - waitFor', async () => { 18 | setGlobal({ 19 | fetch: () => 20 | Promise.resolve({ 21 | json: () => Promise.resolve(['data-1', 'data-2']) 22 | }) 23 | }); 24 | const component = render(`
30 | 33 |
`); 34 | await waitFor(() => { 35 | expect(component.$data.data).toEqual(['data-1', 'data-2']); 36 | }); 37 | await waitFor(() => { 38 | const textNodes = component.querySelectorAll('[data-testid=text-el]'); 39 | expect(textNodes).toHaveLength(2); 40 | expect(textNodes[0].textContent).toEqual('data-1'); 41 | expect(textNodes[1].textContent).toEqual('data-2'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint_unit: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [12.x] 11 | env: 12 | CI: true 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Cache 20 | uses: actions/cache@v2 21 | with: 22 | path: | 23 | **/node_modules 24 | key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} 25 | - name: yarn install 26 | run: yarn 27 | - name: lint 28 | run: yarn lint 29 | - name: unit test 30 | run: yarn test:unit 31 | 32 | test: 33 | runs-on: ubuntu-latest 34 | 35 | strategy: 36 | matrix: 37 | node-version: [12.x] 38 | command: ['yarn test:uvu && yarn test:jest', 'yarn test:tape && yarn test:ava'] 39 | 40 | env: 41 | CI: true 42 | 43 | steps: 44 | - uses: actions/checkout@v1 45 | - name: Use Node.js ${{ matrix.node-version }} 46 | uses: actions/setup-node@v1 47 | with: 48 | node-version: ${{ matrix.node-version }} 49 | - name: Cache 50 | uses: actions/cache@v2 51 | with: 52 | path: | 53 | **/node_modules 54 | key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} 55 | - name: yarn install --frozen-lockfile 56 | run: yarn 57 | - name: test section 58 | run: ${{ matrix.command }} 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | .env.test 68 | 69 | # parcel-bundler cache (https://parceljs.org/) 70 | .cache 71 | 72 | # next.js build output 73 | .next 74 | 75 | # nuxt.js build output 76 | .nuxt 77 | 78 | # vuepress build output 79 | .vuepress/dist 80 | 81 | # Serverless directories 82 | .serverless/ 83 | 84 | # FuseBox cache 85 | .fusebox/ 86 | 87 | # DynamoDB Local files 88 | .dynamodb/ 89 | dist 90 | .rts2_cache_* 91 | schema.json 92 | _gen 93 | -------------------------------------------------------------------------------- /tests/unit/render.js: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import {render} from '../../src/main.js'; 4 | 5 | test('render - sanity check', () => { 6 | const component = render(`
7 | 8 |
`); 9 | assert.is(component.querySelector('span').textContent, 'bar'); 10 | }); 11 | 12 | test('render - can override x-data if data option is passed - string', () => { 13 | const component = render( 14 | `
15 | 16 |
`, 17 | '{ "foo": "baz" }' 18 | ); 19 | assert.is(component.querySelector('span').textContent, 'baz'); 20 | }); 21 | 22 | test('render - can override x-data if data option is passed - object', () => { 23 | const component = render( 24 | `
25 | 26 |
`, 27 | {foo: 'baz'} 28 | ); 29 | assert.is(component.querySelector('span').textContent, 'baz'); 30 | }); 31 | 32 | test('render - sets $data properties on the component', () => { 33 | const component = render( 34 | `
35 | 36 |
` 37 | ); 38 | assert.is(component.$data.foo, 'bar'); 39 | }); 40 | 41 | test('render - updating $data works', async () => { 42 | const component = render( 43 | `
44 | 45 |
` 46 | ); 47 | 48 | component.$data.foo = 'baz'; 49 | 50 | await component.$nextTick(); 51 | assert.is(component.querySelector('span').textContent, 'baz'); 52 | }); 53 | 54 | test('render - sets $el on the component to itself', () => { 55 | const component = render( 56 | `
57 | 58 |
` 59 | ); 60 | assert.is(component.$el, component); 61 | }); 62 | 63 | test('render throws if passed a Promise (eg. forgot to await load())', async () => { 64 | assert.throws(() => { 65 | render(Promise.resolve('
')); 66 | }, 'alpine-test-utils render(): "markup" should be a string'); 67 | }); 68 | 69 | test.run(); 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alpine-test-utils", 3 | "description": "Utilities for testing Alpine.js components", 4 | "version": "1.0.0", 5 | "source": "src/main.js", 6 | "main": "src/main.js", 7 | "types": "types.d.ts", 8 | "keywords": [ 9 | "alpinejs", 10 | "testing", 11 | "jsdom" 12 | ], 13 | "files": [ 14 | "src/**.js*" 15 | ], 16 | "scripts": { 17 | "test:unit": "uvu tests/unit -r esm", 18 | "test:ava": "ava 'tests/ava/*.js'", 19 | "test:uvu": "uvu tests/uvu -r esm", 20 | "test:tape": "tape tests/tape/*.js -r esm", 21 | "test:jest": "jest tests/jest", 22 | "test": "yarn test:unit && yarn test:uvu && yarn test:tape && yarn test:ava && yarn test:jest", 23 | "build": "jsdoc -t node_modules/tsd-jsdoc/dist -r src -d .", 24 | "lint": "xo src tests", 25 | "fmt": "xo src tests --fix", 26 | "format": "yarn fmt", 27 | "release": "yarn build && np" 28 | }, 29 | "peerDependencies": { 30 | "alpinejs": "^2.3.1" 31 | }, 32 | "dependencies": { 33 | "jsdom": "^19.0.0", 34 | "wait-for-expect": "^3.0.2" 35 | }, 36 | "devDependencies": { 37 | "alpinejs": "^2.3.1", 38 | "ava": "^3.7.0", 39 | "esm": "^3.2.25", 40 | "jest": "^27.0.1", 41 | "jsdoc": "^3.6.4", 42 | "np": "^7.0.0", 43 | "tape": "^5.0.1", 44 | "tsd-jsdoc": "^2.5.0", 45 | "uvu": "^0.5.1", 46 | "xo": "^0.37.1" 47 | }, 48 | "xo": { 49 | "prettier": true, 50 | "space": true, 51 | "globals": [ 52 | "jest", 53 | "document" 54 | ], 55 | "rules": { 56 | "unicorn/prefer-text-content": 0 57 | } 58 | }, 59 | "ava": { 60 | "require": [ 61 | "esm" 62 | ] 63 | }, 64 | "license": "MIT", 65 | "publishConfig": { 66 | "registry": "https://registry.npmjs.org" 67 | }, 68 | "directories": { 69 | "test": "tests" 70 | }, 71 | "repository": { 72 | "type": "git", 73 | "url": "git+https://github.com/HugoDF/alpine-test-utils.git" 74 | }, 75 | "author": "Hugo", 76 | "bugs": { 77 | "url": "https://github.com/HugoDF/alpine-test-utils/issues" 78 | }, 79 | "homepage": "https://github.com/HugoDF/alpine-test-utils#readme" 80 | } 81 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Override Node.js `global` using passed `override` object. 3 | * @param override - Override object 4 | */ 5 | declare function setGlobal(override: any): void; 6 | 7 | /** 8 | * Set `navigator` global. 9 | */ 10 | declare function setNavigator(navigator: Navigator): void; 11 | 12 | /** 13 | * Set `MutationObserver` global. 14 | */ 15 | declare function setMutationObserver(mutationObserver: (...params: any[]) => any): void; 16 | 17 | /** 18 | * Pre-Alpine.start() setup work. 19 | */ 20 | declare function config(): void; 21 | 22 | /** 23 | * Get x-data (Alpine) component(s) from markup 24 | * @param markup - markup to load 25 | */ 26 | declare function getComponents(markup: string): string[] | string; 27 | 28 | /** 29 | * Load markup from a file asynchronously using a path. 30 | * @param filePath - Path to the HTML/template file to load components from 31 | */ 32 | declare function load(filePath: string): Promise; 33 | 34 | /** 35 | * Load markup from a file **synchronously** using a path. 36 | * @param filePath - Path to the HTML/template file to load components from 37 | */ 38 | declare function loadSync(filePath: string): string[] | string; 39 | 40 | /** 41 | * @property $data - Alpine.js data reference 42 | * @property $el - Root element reference 43 | * @property $nextTick - Wait for a render/async operation to complete 44 | */ 45 | declare type AlpineElement = { 46 | $data: any; 47 | $el: Element; 48 | $nextTick: (...params: any[]) => any; 49 | }; 50 | 51 | /** 52 | * Render Alpine.js Component Markup to JSDOM & initialise Alpine.js. 53 | * @param markup - Component HTML content 54 | * @param [data] - Override x-data for component 55 | */ 56 | declare function render(markup: string, data?: any | string): AlpineElement; 57 | 58 | /** 59 | * Function to wait until a render/async operation complete 60 | */ 61 | declare function $nextTick(): Promise; 62 | 63 | /** 64 | * @param document - document from which Alpine components are being loaded from 65 | * @param alpineVersion - Alpine.js version from NPM 66 | */ 67 | declare function checkVersionMismatch(document: Document, alpineVersion: string): void; 68 | 69 | -------------------------------------------------------------------------------- /tests/unit/load.js: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import path from 'path'; 4 | import {load, loadSync} from '../../src/main.js'; 5 | 6 | test('load - file with multiple components', async () => { 7 | const components = await load( 8 | path.join(__dirname, '../fixtures/multiple-components.html') 9 | ); 10 | assert.is(components.length, 3); 11 | assert.is(components[0], `
`); 12 | assert.is(components[1], `
`); 13 | assert.is(components[2], `
`); 14 | }); 15 | 16 | test('load - file with single component', async () => { 17 | const component = await load( 18 | path.join(__dirname, '../fixtures/single-component.html') 19 | ); 20 | assert.is(component, `
`); 21 | }); 22 | 23 | test('loadSync - file with multiple components', () => { 24 | const calls = []; 25 | const realWarn = console.warn; 26 | console.warn = (...args) => { 27 | calls.push(args); 28 | }; 29 | 30 | const components = loadSync( 31 | path.join(__dirname, '../fixtures/multiple-components.html') 32 | ); 33 | assert.is(components.length, 3); 34 | assert.is(components[0], `
`); 35 | assert.is(components[1], `
`); 36 | assert.is(components[2], `
`); 37 | // Check warning 38 | assert.equal(calls, [ 39 | [ 40 | 'alpine-test-utils: loadSync() can cause performance issues, prefer async "load()"' 41 | ] 42 | ]); 43 | console.warn = realWarn; 44 | }); 45 | 46 | test('loadSync - file with single component', () => { 47 | const calls = []; 48 | const realWarn = console.warn; 49 | console.warn = (...args) => { 50 | calls.push(args); 51 | }; 52 | 53 | const component = loadSync( 54 | path.join(__dirname, '../fixtures/single-component.html') 55 | ); 56 | assert.is(component, `
`); 57 | // Check warning 58 | assert.equal(calls, [ 59 | [ 60 | 'alpine-test-utils: loadSync() can cause performance issues, prefer async "load()"' 61 | ] 62 | ]); 63 | console.warn = realWarn; 64 | }); 65 | 66 | test.run(); 67 | -------------------------------------------------------------------------------- /tests/uvu/use-cases.test.js: -------------------------------------------------------------------------------- 1 | import {test} from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import path from 'path'; 4 | import {load, loadSync, render, setGlobal, waitFor} from '../../src/main.js'; 5 | 6 | console.warn = () => {}; 7 | 8 | test('use-case - clicking a button to toggle visibility', async () => { 9 | const component = render(`
10 | 11 | 12 |
`); 13 | assert.is(component.querySelector('span').style.display, 'none'); 14 | component.querySelector('button').click(); 15 | await waitFor(() => { 16 | assert.is(component.querySelector('span').style.display, ''); 17 | }); 18 | }); 19 | 20 | test('use-case - intercepting fetch calls', async () => { 21 | setGlobal({ 22 | fetch: () => 23 | Promise.resolve({ 24 | json: () => Promise.resolve(['data-1', 'data-2']) 25 | }) 26 | }); 27 | const component = render(`
33 | 36 |
`); 37 | await waitFor(() => { 38 | assert.equal(component.$data.data, ['data-1', 'data-2']); 39 | }); 40 | await waitFor(() => { 41 | const textNodes = component.querySelectorAll('[data-testid=text-el]'); 42 | assert.is(textNodes.length, 2); 43 | assert.is(textNodes[0].textContent, 'data-1'); 44 | assert.is(textNodes[1].textContent, 'data-2'); 45 | }); 46 | }); 47 | 48 | test('use-case - PHP template - async', async () => { 49 | const markup = await load(path.join(__dirname, '../fixtures/template.php')); 50 | // Overwrite `x-data` since it's set by a PHP expression 51 | const component = render(markup, { 52 | foo: 'baz' 53 | }); 54 | assert.is(component.querySelector('span').textContent, 'baz'); 55 | }); 56 | 57 | test('use-case - PHP template - sync', () => { 58 | const markup = loadSync(path.join(__dirname, '../fixtures/template.php')); 59 | // Overwrite `x-data` since it's set by a PHP expression 60 | const component = render(markup, { 61 | foo: 'baz' 62 | }); 63 | assert.is(component.querySelector('span').textContent, 'baz'); 64 | }); 65 | 66 | test('use-case - load from HTML file - async', async () => { 67 | const markup = await load(path.join(__dirname, '../fixtures/template.html')); 68 | const component = render(markup); 69 | assert.is(component.querySelector('span').textContent, 'bar'); 70 | }); 71 | 72 | test('use-case - load from HTML file - sync', () => { 73 | const markup = loadSync(path.join(__dirname, '../fixtures/template.html')); 74 | const component = render(markup); 75 | assert.is(component.querySelector('span').textContent, 'bar'); 76 | }); 77 | 78 | test.run(); 79 | -------------------------------------------------------------------------------- /USE_CASES.md: -------------------------------------------------------------------------------- 1 | This documentation provides examples for specific use cases in Node.js. Please [open an issue](https://github.com/HugoDF/alpine-test-utils/issues) or make a pull request for any other use cases you would like us to document here. Thank you! 2 | 3 | 4 | ## Table of contents 5 | 6 | - [Clicking a button to toggle visibility](#clicking-a-button-to-toggle-visibility) 7 | - [Intercepting `fetch` calls & waiting for re-renders](#intercepting-fetch-calls--waiting-for-re-renders) 8 | - [Loading & rendering a PHP template that injects into x-data](#loading--rendering-a-php-template-that-injects-into-x-data) 9 | - [Loading & rendering an HTML file and running it](#loading--rendering-an-html-file-and-running-it) 10 | 11 | ### Clicking a button to toggle visibility 12 | 13 | ```js 14 | import test from 'ava'; 15 | import {render, waitFor} from 'alpine-test-utils'; 16 | 17 | test('use-case - clicking a button to toggle visibility', async (t) => { 18 | const component = render(`
19 | 20 | 21 |
`); 22 | 23 | t.is(component.querySelector('span').style.display, 'none'); 24 | component.querySelector('button').click(); 25 | await waitFor(() => { 26 | t.is(component.querySelector('span').style.display, ''); 27 | }); 28 | }); 29 | ``` 30 | 31 | ### Intercepting `fetch` calls & waiting for re-renders 32 | 33 | ```js 34 | import test from 'ava'; 35 | import {render, setGlobal, waitFor} from 'alpine-test-utils'; 36 | 37 | test('use-case - intercepting fetch calls', async (t) => { 38 | setGlobal({ 39 | fetch: () => 40 | Promise.resolve({ 41 | json: () => Promise.resolve(['data-1', 'data-2']) 42 | }) 43 | }); 44 | const component = render(`
50 | 53 |
`); 54 | // Flushes the Promises 55 | await waitFor(() => { 56 | t.deepEqual(component.$data.data, ['data-1', 'data-2']); 57 | }) 58 | await waitFor(() => { 59 | const textNodes = component.querySelectorAll('[data-testid=text-el]'); 60 | t.is(textNodes.length, 2); 61 | t.is(textNodes[0].textContent, 'data-1'); 62 | t.is(textNodes[1].textContent, 'data-2'); 63 | }); 64 | }); 65 | ``` 66 | 67 | ### Loading & rendering a PHP template that injects into x-data 68 | 69 | ```js 70 | import test from 'ava'; 71 | import path from 'path'; 72 | import {load, loadSync, render} from 'alpine-test-utils'; 73 | 74 | test('use-case - PHP template - async', async (t) => { 75 | const markup = await load(path.join(__dirname, '../fixtures/template.php')); 76 | // Overwrite `x-data` since it's set by a PHP expression 77 | const component = render(markup, { 78 | foo: 'baz' 79 | }); 80 | t.is(component.querySelector('span').textContent, 'baz'); 81 | }); 82 | 83 | test('use-case - PHP template - sync', (t) => { 84 | const markup = loadSync(path.join(__dirname, '../fixtures/template.php')); 85 | // Overwrite `x-data` since it's set by a PHP expression 86 | const component = render(markup, { 87 | foo: 'baz' 88 | }); 89 | t.is(component.querySelector('span').textContent, 'baz'); 90 | }); 91 | ``` 92 | 93 | ### Loading & rendering an HTML file and running it 94 | 95 | ```js 96 | import test from 'ava'; 97 | import path from 'path'; 98 | import {load, loadSync, render} from 'alpine-test-utils'; 99 | 100 | test('use-case - load from HTML file - async', async (t) => { 101 | const markup = await load(path.join(__dirname, '../fixtures/template.html')); 102 | const component = render(markup); 103 | t.is(component.querySelector('span').textContent, 'bar'); 104 | }); 105 | 106 | test('use-case - load from HTML file - sync', (t) => { 107 | const markup = loadSync(path.join(__dirname, '../fixtures/template.html')); 108 | const component = render(markup); 109 | t.is(component.querySelector('span').textContent, 'bar'); 110 | }); 111 | ``` 112 | -------------------------------------------------------------------------------- /tests/ava/use-cases.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import path from 'path'; 3 | import {load, loadSync, render, setGlobal, waitFor} from '../../src/main.js'; 4 | console.warn = () => {}; 5 | 6 | test('[deprecated] use-case - clicking a button to toggle visibility', async (t) => { 7 | const component = render(`
8 | 9 | 10 |
`); 11 | 12 | t.is(component.querySelector('span').style.display, 'none'); 13 | component.querySelector('button').click(); 14 | await component.$nextTick(); 15 | t.is(component.querySelector('span').style.display, ''); 16 | }); 17 | 18 | test('use-case - clicking a button to toggle visibility', async (t) => { 19 | const component = render(`
20 | 21 | 22 |
`); 23 | 24 | t.is(component.querySelector('span').style.display, 'none'); 25 | component.querySelector('button').click(); 26 | await waitFor(() => { 27 | t.is(component.querySelector('span').style.display, ''); 28 | }); 29 | }); 30 | 31 | test('[deprecated] use-case - intercepting fetch calls - $nextTick', async (t) => { 32 | setGlobal({ 33 | fetch: () => 34 | Promise.resolve({ 35 | json: () => Promise.resolve(['data-1', 'data-2']) 36 | }) 37 | }); 38 | const component = render(`
44 | 47 |
`); 48 | // Flushes the Promises 49 | await component.$nextTick(); 50 | t.deepEqual(component.$data.data, ['data-1', 'data-2']); 51 | // Lets the re-render run 52 | await component.$nextTick(); 53 | const textNodes = component.querySelectorAll('[data-testid=text-el]'); 54 | t.is(textNodes.length, 2); 55 | t.is(textNodes[0].textContent, 'data-1'); 56 | t.is(textNodes[1].textContent, 'data-2'); 57 | }); 58 | 59 | test('use-case - intercepting fetch calls - waitFor', async (t) => { 60 | setGlobal({ 61 | fetch: () => 62 | Promise.resolve({ 63 | json: () => Promise.resolve(['data-1', 'data-2']) 64 | }) 65 | }); 66 | const component = render(`
72 | 75 |
`); 76 | await waitFor(() => { 77 | t.deepEqual(component.$data.data, ['data-1', 'data-2']); 78 | }); 79 | await waitFor(() => { 80 | const textNodes = component.querySelectorAll('[data-testid=text-el]'); 81 | t.is(textNodes.length, 2); 82 | t.is(textNodes[0].textContent, 'data-1'); 83 | t.is(textNodes[1].textContent, 'data-2'); 84 | }); 85 | }); 86 | 87 | test('use-case - PHP template - async', async (t) => { 88 | const markup = await load(path.join(__dirname, '../fixtures/template.php')); 89 | // Overwrite `x-data` since it's set by a PHP expression 90 | const component = render(markup, { 91 | foo: 'baz' 92 | }); 93 | t.is(component.querySelector('span').textContent, 'baz'); 94 | }); 95 | 96 | test('use-case - PHP template - sync', (t) => { 97 | const markup = loadSync(path.join(__dirname, '../fixtures/template.php')); 98 | // Overwrite `x-data` since it's set by a PHP expression 99 | const component = render(markup, { 100 | foo: 'baz' 101 | }); 102 | t.is(component.querySelector('span').textContent, 'baz'); 103 | }); 104 | 105 | test('use-case - load from HTML file - async', async (t) => { 106 | const markup = await load(path.join(__dirname, '../fixtures/template.html')); 107 | const component = render(markup); 108 | t.is(component.querySelector('span').textContent, 'bar'); 109 | }); 110 | 111 | test('use-case - load from HTML file - sync', (t) => { 112 | const markup = loadSync(path.join(__dirname, '../fixtures/template.html')); 113 | const component = render(markup); 114 | t.is(component.querySelector('span').textContent, 'bar'); 115 | }); 116 | -------------------------------------------------------------------------------- /tests/tape/use-cases.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import path from 'path'; 3 | import {load, loadSync, render, setGlobal, waitFor} from '../../src/main.js'; 4 | console.warn = () => {}; 5 | 6 | test('[deprecated] use-case - clicking a button to toggle visibility', async (t) => { 7 | t.plan(2); 8 | const component = render(`
9 | 10 | 11 |
`); 12 | 13 | t.is(component.querySelector('span').style.display, 'none'); 14 | component.querySelector('button').click(); 15 | await component.$nextTick(); 16 | t.is(component.querySelector('span').style.display, ''); 17 | }); 18 | 19 | test('use-case - clicking a button to toggle visibility', async (t) => { 20 | t.plan(2); 21 | const component = render(`
22 | 23 | 24 |
`); 25 | 26 | t.is(component.querySelector('span').style.display, 'none'); 27 | component.querySelector('button').click(); 28 | await waitFor(() => { 29 | t.is(component.querySelector('span').style.display, ''); 30 | }); 31 | }); 32 | 33 | test('[deprecated] use-case - intercepting fetch calls - $nextTick', async (t) => { 34 | t.plan(4); 35 | setGlobal({ 36 | fetch: () => 37 | Promise.resolve({ 38 | json: () => Promise.resolve(['data-1', 'data-2']) 39 | }) 40 | }); 41 | const component = render(`
47 | 50 |
`); 51 | // Flushes the Promises 52 | await component.$nextTick(); 53 | t.deepEqual(component.$data.data, ['data-1', 'data-2']); 54 | // Lets the re-render run 55 | await component.$nextTick(); 56 | const textNodes = component.querySelectorAll('[data-testid=text-el]'); 57 | t.is(textNodes.length, 2); 58 | t.is(textNodes[0].textContent, 'data-1'); 59 | t.is(textNodes[1].textContent, 'data-2'); 60 | }); 61 | 62 | test('use-case - intercepting fetch calls - waitFor', async (t) => { 63 | t.plan(4); 64 | setGlobal({ 65 | fetch: () => 66 | Promise.resolve({ 67 | json: () => Promise.resolve(['data-1', 'data-2']) 68 | }) 69 | }); 70 | const component = render(`
76 | 79 |
`); 80 | await waitFor(() => { 81 | t.deepEqual(component.$data.data, ['data-1', 'data-2']); 82 | }); 83 | await waitFor(() => { 84 | const textNodes = component.querySelectorAll('[data-testid=text-el]'); 85 | t.is(textNodes.length, 2); 86 | t.is(textNodes[0].textContent, 'data-1'); 87 | t.is(textNodes[1].textContent, 'data-2'); 88 | }); 89 | }); 90 | 91 | test('use-case - PHP template - async', async (t) => { 92 | t.plan(1); 93 | const markup = await load(path.join(__dirname, '../fixtures/template.php')); 94 | // Overwrite `x-data` since it's set by a PHP expression 95 | const component = render(markup, { 96 | foo: 'baz' 97 | }); 98 | t.is(component.querySelector('span').textContent, 'baz'); 99 | }); 100 | 101 | test('use-case - PHP template - sync', (t) => { 102 | t.plan(1); 103 | const markup = loadSync(path.join(__dirname, '../fixtures/template.php')); 104 | // Overwrite `x-data` since it's set by a PHP expression 105 | const component = render(markup, { 106 | foo: 'baz' 107 | }); 108 | t.is(component.querySelector('span').textContent, 'baz'); 109 | }); 110 | 111 | test('use-case - load from HTML file - async', async (t) => { 112 | t.plan(1); 113 | const markup = await load(path.join(__dirname, '../fixtures/template.html')); 114 | const component = render(markup); 115 | t.is(component.querySelector('span').textContent, 'bar'); 116 | }); 117 | 118 | test('use-case - load from HTML file - sync', (t) => { 119 | t.plan(1); 120 | const markup = loadSync(path.join(__dirname, '../fixtures/template.html')); 121 | const component = render(markup); 122 | t.is(component.querySelector('span').textContent, 'bar'); 123 | }); 124 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const fs = require('fs'); 3 | const waitFor = require('wait-for-expect'); 4 | const {promisify} = require('util'); 5 | const readFile = promisify(fs.readFile); 6 | const {JSDOM} = require('jsdom'); 7 | const {config, setGlobal, setMutationObserver} = require('./config'); 8 | const {checkVersionMismatch} = require('./version-mismatch'); 9 | 10 | // Needs to happen before loading Alpine 11 | config(); 12 | 13 | let Alpine; 14 | try { 15 | Alpine = require('alpinejs'); 16 | } catch { 17 | throw new Error( 18 | "Alpine.js npm module ('alpinejs') not found - try installing it with `npm install --save-dev alpinejs`" 19 | ); 20 | } 21 | 22 | // Not great, but makes sure we know the version of Alpine.js loaded from NPM. 23 | // Safe to do here because if Alpine.js wasn't in node_modules 24 | // we would have already thrown (see above). 25 | const {version: AlpineVersion} = require('alpinejs/package.json'); 26 | 27 | /** 28 | * Get x-data (Alpine) component(s) from markup 29 | * @param {string} markup - markup to load 30 | * @returns {Array|string} 31 | */ 32 | const getComponents = (markup) => { 33 | const {document} = new JSDOM(markup).window; 34 | 35 | checkVersionMismatch(document, AlpineVersion); 36 | 37 | const components = [...document.querySelectorAll('[x-data]')].map( 38 | (element) => element.outerHTML 39 | ); 40 | return components.length === 1 ? components[0] : components; 41 | }; 42 | 43 | /** 44 | * Load markup from a file asynchronously using a path. 45 | * 46 | * @param {string} filePath - Path to the HTML/template file to load components from 47 | * @returns {Promise|string>} 48 | */ 49 | async function load(filePath) { 50 | const markup = await readFile(filePath, 'utf-8'); 51 | return getComponents(markup); 52 | } 53 | 54 | /** 55 | * Load markup from a file **synchronously** using a path. 56 | * 57 | * @param {string} filePath - Path to the HTML/template file to load components from 58 | * @returns {Array|string} 59 | */ 60 | function loadSync(filePath) { 61 | console.warn( 62 | 'alpine-test-utils: loadSync() can cause performance issues, prefer async "load()"' 63 | ); 64 | const markup = fs.readFileSync(filePath, 'utf-8'); 65 | return getComponents(markup); 66 | } 67 | 68 | /** 69 | * @typedef AlpineProps 70 | * @type {object} 71 | * @property {object} $data - Alpine.js data reference 72 | * @property {Element} $el - Root element reference 73 | * @property {Function} $nextTick - Wait for a render/async operation to complete 74 | * 75 | * @typedef {Element|AlpineProps} AlpineElement 76 | */ 77 | 78 | /** 79 | * Render Alpine.js Component Markup to JSDOM & initialise Alpine.js. 80 | * 81 | * @param {string} markup - Component HTML content 82 | * @param {object|string} [data] - Override x-data for component 83 | * @returns {AlpineElement} 84 | */ 85 | function render(markup, data) { 86 | if (typeof markup !== 'string') { 87 | throw new TypeError( 88 | 'alpine-test-utils render(): "markup" should be a string' 89 | ); 90 | } 91 | 92 | // Create new window/document from html 93 | const {window} = new JSDOM(markup); 94 | const {document: _document} = window; 95 | 96 | const isJestWithJSDOM = 97 | // @ts-ignore 98 | typeof jest !== 'undefined' && typeof document !== 'undefined'; 99 | 100 | // Alpine.start looks at `document` 101 | // set and unset current document before/after respectively 102 | setGlobal({ 103 | window, 104 | document: _document 105 | }); 106 | 107 | let component = _document.querySelector('[x-data]'); 108 | if (data) { 109 | component.setAttribute( 110 | 'x-data', 111 | typeof data === 'string' ? data : JSON.stringify(data) 112 | ); 113 | } 114 | 115 | if (isJestWithJSDOM) { 116 | document.body.innerHTML = component.outerHTML; 117 | component = document.body.querySelector('[x-data]'); 118 | } 119 | 120 | Alpine.start(); 121 | 122 | // @ts-ignore 123 | return Object.assign(component, component.__x, {$nextTick}); 124 | } 125 | 126 | /** 127 | * Function to wait until a render/async operation complete 128 | * @returns {Promise} 129 | */ 130 | async function $nextTick() { 131 | // eslint-disable-next-line no-unused-vars 132 | await new Promise((resolve, reject) => { 133 | setTimeout(resolve, 0); 134 | }); 135 | } 136 | 137 | module.exports = { 138 | setMutationObserver, 139 | // I don't like exporting this, but it's a good escape hatch 140 | setGlobal, 141 | load, 142 | loadSync, 143 | render, 144 | waitFor 145 | }; 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![test](https://github.com/HugoDF/alpine-test-utils/workflows/test/badge.svg) 2 | ![npm version](https://img.shields.io/npm/v/alpine-test-utils) 3 | # Alpine.js Test Utils 4 | 5 | Utilities for testing Alpine.js components. 6 | 7 | **This library allows you to quickly and easily write tests for Alpine.js applications via Node.js using _any testing library_.** 8 | 9 | That means you can use AVA, Tape, Mocha, Jest or whatever other testing library you enjoy using. 10 | 11 | This project is not officially affiliated with Alpine.js, it's maintained by community members. For any feedback, questions or issues, please create [issues](https://github.com/HugoDF/alpine-test-utils/issues) and [pull requests](https://github.com/HugoDF/alpine-test-utils/blob/master/README.md#contributing) or merely upvote or comment on existing issues or pull requests. 12 | 13 | # Table of Contents 14 | 15 | - [Installation](#installation) 16 | - [Prerequisites](#prerequisites) 17 | - [Install Package](#install-package) 18 | - [Peer Dependencies](#peer-dependencies) 19 | - [Quick Start, Write your first test](#quick-start-write-your-first-test) 20 | - [API](#api) 21 | - [Roadmap](#roadmap) 22 | - [Contributing](#contributing) 23 | - [Requirements](#requirements) 24 | - [Setup](#setup) 25 | - [npm scripts](#npm-scripts) 26 | - [About](#about) 27 | - [Acknowledgments](#acknowledgments) 28 | - [LICENSE](#license) 29 | 30 | # Installation 31 | 32 | ## Prerequisites 33 | 34 | - Node.js version 10, 12 or 14 35 | 36 | ## Install Package 37 | 38 | The following recommended installation requires [npm](https://npmjs.org/). If you are unfamiliar with npm, see the [npm docs](https://npmjs.org/doc/). Npm comes installed with Node.js since node version 0.8.x, therefore, you likely already have it. 39 | 40 | ```sh 41 | npm install --save-dev alpine-test-utils 42 | ``` 43 | 44 | You may also use [yarn](https://yarnpkg.com/en/) to install. 45 | 46 | ```sh 47 | yarn add --dev alpine-test-utils 48 | ``` 49 | 50 | ## Peer Dependencies 51 | 52 | **IMPORTANT**: If you're loading Alpine.js from CDN (using a `script` tag) you'll need to install `alpinejs` in order to use `alpine-test-utils`. It should be the same version as the version of Alpine.js you are loading from CDN. using. 53 | 54 | ```sh 55 | npm install --save-dev alpinejs 56 | # or for Yarn users 57 | yarn add --dev alpinejs 58 | ``` 59 | 60 | 61 | # Quick Start, Write your first test 62 | 63 | Here's an example to render a simple Alpine.js component using Jest/Jasmine syntax: 64 | 65 | ```js 66 | import {render} from 'alpine-test-utils'; 67 | 68 | test('test foo component', () => { 69 | const componentHtml = `
70 | 71 |
` 72 | const component = render(componentHtml); 73 | expect(component.querySelector('span').textContent).toEqual('bar'); 74 | }); 75 | ``` 76 | 77 | For more complex use cases, please see [USE_CASES.md](./USE_CASES.md) or for the full API, see the following section. 78 | 79 | # API 80 | 81 | | Method | Description | 82 | | --- | --- | 83 | | [render](#render) | Render & run Alpine.js component markup | 84 | | [load](#loadloadsync) | Extract Alpine.js component markup from files | 85 | | [loadSync](#loadloadsync) | Synchronous variant of `load` | 86 | | [waitFor](#waitfor) | Wait for an assertion to pass | 87 | | [$nextTick](#nexttick) | Wait for a re-render or async work to happen | 88 | | [setGlobal](#setglobal) | Override globals using an object | 89 | | [setMutationObserver](#setmutationobserver) | Set a custom MutationObserver implementation | 90 | 91 | ## render 92 | 93 | Render Alpine.js Component Markup to JSDOM & initialise Alpine.js. 94 | 95 | Parameters: 96 | 97 | - markup - string - the Alpine.js markup to render 98 | - data - (Optional) object or string - data to use to override contents of x-data 99 | 100 | Returns: 101 | 102 | - an AlpineElement - an Element with added Alpine.js `$data` and `$el` properties and Alpine Test Utils `$nextTick` function. 103 | 104 | Usage Example: render a component and check text is displayed as per x-data. 105 | 106 | ```js 107 | test('component renders content of "foo" in span', () => { 108 | const component = render(`
109 | 110 |
`); 111 | expect(component.querySelector('span').textContent).toEqual('bar'); 112 | }); 113 | ``` 114 | 115 | For a more advanced example see [Clicking a button to toggle visibility](./USE_CASES.md#clicking-a-button-to-toggle-visibility). 116 | 117 | ## load/loadSync 118 | 119 | Load markup from a file asynchronously using a path. 120 | 121 | > Note: when a single `x-data` Alpine.js component is found in the file, it is returned. If multiple components are found, all are returned in an Array. 122 | 123 | Parameters: 124 | 125 | - filePath - Path to the HTML/template file to load components from 126 | 127 | Returns: 128 | 129 | - in the async case, a `Promise` (a Promise that resolves to a string or an array of strings) 130 | - in the sync case, a `string[]|string`. 131 | 132 | Usage Example: load a PHP template, see [the full use-case](./USE_CASES.md##loading--rendering-a-php-template-that-injects-into-x-data). 133 | 134 | ```ts 135 | test('my test', async () => { 136 | const markupAsync = await load(path.join(__dirname, '../fixtures/template.php')); 137 | const markupSync = loadSync(path.join(__dirname, '../fixtures/template.php')); 138 | }); 139 | ``` 140 | 141 | ## waitFor 142 | 143 | Wait until assertions pass, wrapper for [wait-for-expect](https://github.com/TheBrainFamily/wait-for-expect). 144 | 145 | Parameters: 146 | 147 | - callback containing the assertions. "predicate" that has to complete without throwing 148 | - timeout - Optional, Number - Maximum wait interval, 4500ms by default 149 | - interval - Optional, Number - Wait interval, 50ms by default 150 | 151 | Returns: Promise that resolves/rejects based on whether the assertions eventually pass. 152 | 153 | 154 | Usage example: for more advanced use-cases see [Clicking a button to toggle visibility](./USE_CASES.md#clicking-a-button-to-toggle-visibility) and [Intercepting `fetch` calls & waiting for re-renders](./USE_CASES.md#intercepting-fetch-calls--waiting-for-re-renders) 155 | 156 | ```js 157 | test('clicking a button to toggle visibility', async () => { 158 | const component = render(`
159 | 160 | 161 |
`); 162 | 163 | expect(component.querySelector('span').style.display).toEqual('none'); 164 | component.querySelector('button').click(); 165 | await waitFor(() => { 166 | expect(component.querySelector('span').style.display).toEqual(''); 167 | }); 168 | }); 169 | ``` 170 | 171 | 172 | ## $nextTick 173 | 174 | > Note: prefer [`waitFor`](#waitfor) it's more flexible and accurate. 175 | 176 | Function to wait until a render/async operation happens. 177 | 178 | Parameters: none. 179 | 180 | Returns: 181 | 182 | - a Promise that resolves after the next async operation has completed (ie. on the next tick of the event loop) 183 | 184 | > Note this exported as a global from the Alpine Test Utils module _and_ is attached to components during `render`, see [render](#render). 185 | 186 | ```js 187 | test('clicking a button to toggle visibility', async () => { 188 | const component = render(`
189 | 190 | 191 |
`); 192 | 193 | expect(component.querySelector('span').style.display).toEqual('none'); 194 | component.querySelector('button').click(); 195 | await component.$nextTick(); 196 | expect(component.querySelector('span').style.display).toEqual(''); 197 | }); 198 | ``` 199 | 200 | 201 | ## setGlobal 202 | 203 | Override Node.js `global` using passed `override` object. 204 | 205 | The implementation is literally `Object.assign(global, override)`. 206 | 207 | Parameters: 208 | 209 | - an object with keys to override on the `global` object 210 | 211 | Returns: none. 212 | 213 | Usage example: overring `global.fetch`, see the full use case [Intercepting `fetch` calls & waiting for re-renders](./USE_CASES.md#intercepting-fetch-calls--waiting-for-re-renders). 214 | 215 | ```js 216 | test('intercepting fetch calls', async () => { 217 | setGlobal({ 218 | fetch: () => 219 | Promise.resolve({ 220 | json: () => Promise.resolve(['data-1', 'data-2']) 221 | }) 222 | }); 223 | }); 224 | ``` 225 | 226 | 227 | # Roadmap 228 | 229 | If you are interested in the future direction of this project, please take a look at the open [issues](https://github.com/HugoDF/alpine-test-utils/issues) and [pull requests](https://github.com/HugoDF/alpine-test-utils/pulls). I would love to hear your feedback! 230 | 231 | # Contributing 232 | 233 | ## Requirements 234 | 235 | - Node 10 236 | - Yarn 1.x or npm 237 | 238 | ## Setup 239 | 240 | 1. Clone the repository 241 | 2. Run `yarn` or `npm install` installs all required dependencies. 242 | 3. Run `yarn test` to run all tests :D. 243 | 244 | ## npm scripts 245 | 246 | > Equivalent `npm run