`);
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 | 
2 | 
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