├── .npmignore ├── .prettierignore ├── .eslintignore ├── tests ├── fixtures │ └── Dom.svelte └── test.js ├── prettier.config.js ├── babel.config.js ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .dependabot └── config.yml ├── .eslintrc.json ├── rollup.config.js ├── package.json ├── .gitignore ├── README.md └── src └── index.js /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules/* 2 | /dist/* -------------------------------------------------------------------------------- /tests/fixtures/Dom.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
{answer}
6 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | trailingComma: 'all', 4 | tabWidth: 4, 5 | semi: false, 6 | singleQuote: true, 7 | } 8 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v1 12 | - run: yarn install 13 | - run: yarn run test 14 | env: 15 | CI: true 16 | -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | update_configs: 4 | - package_manager: 'javascript' 5 | directory: '/' 6 | update_schedule: 'live' 7 | ignored_updates: 8 | - match: 9 | dependency_name: 'svelte' 10 | automerged_updates: 11 | - match: 12 | update_type: 'all' 13 | target_branch: master 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | }, 5 | "parserOptions": { 6 | "ecmaVersion": 2018, 7 | "sourceType": "module" 8 | }, 9 | "extends": ["airbnb-base", "prettier"], 10 | "rules": { 11 | "import/no-extraneous-dependencies": [ 12 | "error", 13 | { "devDependencies": true } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import pkg from './package.json' 2 | 3 | export default { 4 | input: 'src/index.js', 5 | output: [ 6 | { 7 | file: pkg.main, 8 | format: 'cjs', 9 | sourcemap: true, 10 | }, 11 | { 12 | file: pkg.module, 13 | format: 'es', 14 | sourcemap: true, 15 | }, 16 | ], 17 | external: [ 18 | 'path', 19 | 'rollup', 20 | 'rollup-plugin-svelte', 21 | '@rollup/plugin-node-resolve', 22 | 'jsdom', 23 | 'svelte', 24 | 'svelte/compiler', 25 | 'code-red', 26 | ], 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: '12.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | - run: yarn install 19 | - run: yarn run build 20 | - run: yarn run test 21 | env: 22 | CI: true 23 | - run: yarn publish 24 | env: 25 | NODE_AUTH_TOKEN: ${{ secrets.YARN_TOKEN }} 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dainte", 3 | "version": "0.1.5", 4 | "main": "dist/dainte.js", 5 | "module": "dist/dainte.es.js", 6 | "repository": "git@github.com:nathancahill/dainte.git", 7 | "author": "Nathan Cahill ", 8 | "license": "MIT", 9 | "devDependencies": { 10 | "@babel/core": "^7.14.8", 11 | "@babel/preset-env": "^7.14.9", 12 | "babel-jest": "^27.0.6", 13 | "eslint": "^7.32.0", 14 | "eslint-config-airbnb-base": "^14.2.1", 15 | "eslint-config-prettier": "^8.3.0", 16 | "eslint-plugin-import": "^2.23.4", 17 | "jest": "^27.0.6", 18 | "jest-transform-svelte": "^2.1.1", 19 | "prettier": "^2.3.2" 20 | }, 21 | "scripts": { 22 | "build": "rollup -c", 23 | "format": "prettier --write \"**/*.js\" \"**/*.json\"", 24 | "lint": "eslint .", 25 | "prepublishOnly": "yarn run lint", 26 | "preversion": "yarn run test", 27 | "postversion": "git push && git push --tags", 28 | "test": "jest && yarn run lint" 29 | }, 30 | "files": [ 31 | "dist/**/*" 32 | ], 33 | "dependencies": { 34 | "@rollup/plugin-node-resolve": "^13.0.4", 35 | "code-red": "^0.2.1", 36 | "jsdom": "^16.7.0", 37 | "rollup": "^2.55.1", 38 | "rollup-plugin-svelte": "^7.1.0", 39 | "svelte": "^3.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/test.js: -------------------------------------------------------------------------------- 1 | import { tick } from 'svelte' 2 | import { mount, compile, render } from '../src' 3 | 4 | test('compile', async () => { 5 | const { Dom } = await compile('./tests/fixtures/Dom.svelte') 6 | 7 | expect(Dom).toBeInstanceOf(Function) 8 | }) 9 | 10 | test('mount', async () => { 11 | const { document } = await mount('./tests/fixtures/Dom.svelte', { 12 | props: { 13 | answer: 42, 14 | }, 15 | }) 16 | 17 | expect(document.getElementById('answer').textContent).toBe('42') 18 | }) 19 | 20 | test('inspect', async () => { 21 | const { dom } = await mount('./tests/fixtures/Dom.svelte', { 22 | props: { 23 | answer: 42, 24 | }, 25 | inspect: true, 26 | }) 27 | 28 | const { answer } = dom.inspect() 29 | 30 | expect(answer).toBe(42) 31 | }) 32 | 33 | test('update', async () => { 34 | const { dom, document } = await mount('./tests/fixtures/Dom.svelte', { 35 | props: { 36 | answer: 42, 37 | }, 38 | accessors: true, 39 | }) 40 | 41 | expect(document.getElementById('answer').textContent).toBe('42') 42 | 43 | dom.$set({ answer: 40 }) 44 | await tick() 45 | 46 | expect(document.getElementById('answer').textContent).toBe('40') 47 | }) 48 | 49 | test('render', async () => { 50 | const { head, html, css } = await render('./tests/fixtures/Dom.svelte', { 51 | props: { 52 | answer: 42, 53 | }, 54 | }) 55 | 56 | expect(head).toBe('') 57 | expect(html).toBe('
42
') 58 | expect(css).toMatchObject({ code: '', map: null }) 59 | }) 60 | -------------------------------------------------------------------------------- /.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 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dainte ![CI](https://github.com/nathancahill/dainte/workflows/CI/badge.svg) 2 | 3 | Painless testing for Svelte components, inspired by Enzyme. 4 | 5 | 🥂  Test Svelte runtime and SSR simultanously
6 | 🎭  Zero-config compatible with Jest
7 | 🤖  Low-level compile options to ensure that tests match prod
8 | 🔎  Component "state" introspection
9 | 10 | ## Usage 11 | 12 | ### `dainte.compile` 13 | 14 | ```js 15 | result: { 16 | // Compiled Svelte component class 17 | Component, 18 | 19 | // Alias to component as specified or inferred name 20 | [name], 21 | } = await dainte.compile(source: string, options?: {...}) 22 | ``` 23 | 24 | Creates a compiled Svelte component class from a source file path. 25 | 26 | The following options can be passed to `compile`, including [svelte.compile options](https://svelte.dev/docs#svelte_compile). 27 | The `dev` option defaults to `true` for testing. None are required: 28 | 29 | | option | default | description | 30 | |:-------------|:------------------------|:-----------------------------------------------------------------| 31 | | `name` | `'Component'` | Name of the component class, inferred from filename | 32 | | `dev` | `true` | Perform runtime checks and provide debugging information | 33 | | `immutable` | `false` | You promise not to mutate any objects | 34 | | `hydratable` | `false` | Enables the hydrate: true runtime option | 35 | | `legacy` | `false` | Generates code that will work in IE9 and IE10 | 36 | | `accessors` | `false` | Getters and setters will be created for the component's props | 37 | | `css` | `true` | Include CSS styles in JS class | 38 | | `generate` | `'dom'` | Create JS DOM class or object with `.render()` | 39 | | `inspect` | `false` | Include `instance.inspect()` accessor | 40 | | `plugins` | `[svelte(), resolve()]` | Advanced option to manually specify Rollup plugins for bundling. | 41 | 42 | #### Example 43 | 44 | ```js 45 | import { compile } from 'dainte' 46 | 47 | const { App } = await compile('./App.svelte') 48 | 49 | const app = new App({ 50 | target: document.body, 51 | }) 52 | ``` 53 | 54 | ### `dainte.mount` 55 | 56 | ```js 57 | result: { 58 | // Svelte component instance 59 | instance, 60 | 61 | // Compiled JS component class 62 | Component, 63 | 64 | // JSDom window and document where component is mounted. 65 | window, 66 | document, 67 | 68 | // Alias to the Component with specified or inferred name 69 | [name], 70 | 71 | // Alias to the instance with lowercase specified or inferred name 72 | [lowercase(name)], 73 | } = await mount(source: string, options?: {...}) 74 | ``` 75 | 76 | Creates an instance of a component from a source file path. Mounts the instance 77 | in a JSDom. 78 | 79 | All `compile` options can also be passed to `mount`. Additionally, these options, including the [component initialisation options](https://svelte.dev/docs#Creating_a_component), can be provided: 80 | 81 | | option | default | description | 82 | |:-------------|:------------------------|:-----------------------------------------------------------------| 83 | | `html` | `''` | HTML to initiate the JSDom instance with | 84 | | `target` | `'body'` | Render target (as a query selector, *not a DOM element* as in Svelte initialisation) | 85 | | `anchor` | `null` | Render anchor (as a query selector, *not a DOM element* as in Svelte initialisation) | 86 | | `props` | `{}` | An object of properties to supply to the component | 87 | | `hydrate` | `false` | Upgrade existing DOM instead of replacing it | 88 | | `intro` | `false` | Play transitions on initial render | 89 | 90 | A `svelte.tick` is awaited between mounting the instance and resolving the `mount` promise so 91 | that the DOM is full initialized. An additional `svelte.tick` should be awaited 92 | between updating the component and reading from the DOM. 93 | 94 | #### Example 95 | 96 | ```js 97 | import { mount } from 'dainte' 98 | import { tick } from 'svelte' 99 | 100 | const { app, document } = await mount('./App.svelte') 101 | app.$set({ answer: 42 }) 102 | await tick() 103 | 104 | expect(document.querySelector('#answer').textContent).toBe('42') 105 | ``` 106 | 107 | ### `dainte.render` 108 | 109 | ```js 110 | result: { 111 | head, 112 | html, 113 | css, 114 | } = await dainte.render(source: string, options?: {...}) 115 | ``` 116 | 117 | Wraps Svelte's server-side `Component.render` API for rendering a component 118 | to HTML. 119 | 120 | The following options can be passed to `render`, including [svelte.compile options](https://svelte.dev/docs#svelte_compile). 121 | The `dev` option defaults to `true` for testing. None are required: 122 | 123 | | option | default | description | 124 | |:-------------|:------------------------|:-----------------------------------------------------------------| 125 | | `dev` | `true` | Perform runtime checks and provide debugging information | 126 | | `immutable` | `false` | You promise not to mutate any objects | 127 | | `hydratable` | `false` | Enables the hydrate: true runtime option | 128 | | `css` | `true` | Include CSS styles in JS class | 129 | | `preserveComments` | `false` | HTML comments will be preserved | 130 | | `preserveWhitespace` | `false` | Keep whitespace inside and between elements as you typed it | 131 | | `plugins` | `[svelte(), resolve()]` | Advanced option to manually specify Rollup plugins for bundling. | 132 | 133 | #### Example 134 | 135 | ```js 136 | import { render } from 'dainte' 137 | 138 | const { html } = await render('./App.svelte') 139 | // '
42
' 140 | ``` 141 | 142 | ### `instance.inspect` 143 | 144 | ```js 145 | variables: { 146 | // Snapshot of all top-level variables and imports 147 | } = instance.inspect() 148 | ``` 149 | 150 | Compiling with `inspect: true` adds a `inspect()` function to the component instance. 151 | Calling the function returns a snapshot object of all top-level variables and their 152 | current values. Snapshot values are not reactive and `inspect()` must be called 153 | again to retrieve updated values. 154 | 155 | #### Example 156 | 157 | ```js 158 | import { mount } from 'dainte' 159 | 160 | const { app } = await mount('./App.svelte', { inspect: true }) 161 | const { answer } = app.inspect() 162 | // 42 163 | ``` 164 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { rollup } from 'rollup' 3 | import rollupSvelte from 'rollup-plugin-svelte' 4 | import resolve from '@rollup/plugin-node-resolve' 5 | import { JSDOM } from 'jsdom' 6 | import { tick } from 'svelte' 7 | import { compile as svelteCompile } from 'svelte/compiler' 8 | import { print, b } from 'code-red' 9 | 10 | const sanitize = input => 11 | path 12 | .basename(input) 13 | .replace(path.extname(input), '') 14 | .replace(/[^a-zA-Z_$0-9]+/g, '_') 15 | .replace(/^_/, '') 16 | .replace(/_$/, '') 17 | .replace(/^(\d)/, '_$1') 18 | 19 | const capitalize = str => str[0].toUpperCase() + str.slice(1) 20 | const lowercase = str => str[0].toLowerCase() + str.slice(1) 21 | 22 | const roll = async ( 23 | source, 24 | { 25 | name: providedName = null, 26 | plugins = null, 27 | dev = true, 28 | immutable = false, 29 | hydratable = false, 30 | legacy = false, 31 | accessors = false, 32 | css = true, 33 | generate = 'dom', 34 | inspect = false, 35 | } = {}, 36 | ) => { 37 | const bundle = await rollup({ 38 | input: source, 39 | plugins: plugins || [ 40 | rollupSvelte({ 41 | emitCss: false, 42 | compilerOptions: { 43 | dev, 44 | immutable, 45 | hydratable, 46 | legacy, 47 | accessors: accessors || inspect, 48 | css, 49 | generate, 50 | }, 51 | preprocess: { 52 | script: input => { 53 | if (inspect) { 54 | const ast = svelteCompile( 55 | ``, 56 | ) 57 | 58 | const scope = { 59 | type: 'ObjectExpression', 60 | properties: ast.vars.map(variable => ({ 61 | type: 'Property', 62 | key: { 63 | type: 'Identifier', 64 | name: variable.name, 65 | }, 66 | value: { 67 | type: 'Identifier', 68 | name: variable.name, 69 | }, 70 | })), 71 | } 72 | 73 | const func = print( 74 | b`export const inspect = () => (${scope})`, 75 | ) 76 | 77 | return { code: `${input.content}\n${func.code}` } 78 | } 79 | 80 | return { code: input.content } 81 | }, 82 | }, 83 | }), 84 | resolve(), 85 | ], 86 | }) 87 | 88 | const { output } = await bundle.generate({ 89 | format: 'iife', 90 | name: providedName, 91 | }) 92 | 93 | return output[0].code 94 | } 95 | 96 | export const compile = async ( 97 | source, 98 | { 99 | name: providedName = null, 100 | plugins = null, 101 | dev = true, 102 | immutable = false, 103 | hydratable = false, 104 | legacy = false, 105 | accessors = false, 106 | css = true, 107 | generate = 'dom', 108 | inspect = false, 109 | html = '', 110 | } = {}, 111 | ) => { 112 | const name = providedName || capitalize(sanitize(source)) 113 | 114 | const code = await roll(source, { 115 | name, 116 | plugins, 117 | dev, 118 | immutable, 119 | hydratable, 120 | legacy, 121 | accessors, 122 | css, 123 | generate, 124 | inspect, 125 | html, 126 | }) 127 | 128 | // eslint-disable-next-line no-new-func 129 | const Component = new Function(`${code}return ${name};`)() 130 | 131 | return { 132 | Component, 133 | [name]: Component, 134 | } 135 | } 136 | 137 | export const mount = async ( 138 | source, 139 | { 140 | name: providedName, 141 | plugins = null, 142 | dev = true, 143 | immutable = false, 144 | hydratable = false, 145 | legacy = false, 146 | accessors = false, 147 | css = true, 148 | inspect = false, 149 | 150 | html = '', 151 | target = 'body', 152 | anchor = null, 153 | props = {}, 154 | hydrate = false, 155 | intro = false, 156 | } = {}, 157 | ) => { 158 | const name = providedName || capitalize(sanitize(source)) 159 | 160 | const code = await roll(source, { 161 | name, 162 | plugins, 163 | dev, 164 | immutable, 165 | hydratable, 166 | legacy, 167 | accessors, 168 | css, 169 | inspect, 170 | html, 171 | }) 172 | 173 | const { window } = new JSDOM(html, { runScripts: 'dangerously' }) 174 | const { document } = window 175 | 176 | const script = document.createElement('script') 177 | script.type = 'text/javascript' 178 | script.text = code 179 | document.body.appendChild(script) 180 | 181 | const Component = window[name] 182 | 183 | const instance = new Component({ 184 | target: document.querySelector(target), 185 | anchor: anchor ? document.querySelector(anchor) : null, 186 | props, 187 | hydrate, 188 | intro, 189 | }) 190 | 191 | await tick() 192 | 193 | return { 194 | window, 195 | document, 196 | Component, 197 | instance, 198 | [name]: Component, 199 | [lowercase(name)]: instance, 200 | } 201 | } 202 | 203 | export const render = async ( 204 | source, 205 | { 206 | dev = true, 207 | immutable = false, 208 | hydratable = false, 209 | css = true, 210 | preserveComments = false, 211 | preserveWhitespace = false, 212 | 213 | plugins = null, 214 | props = {}, 215 | } = {}, 216 | ) => { 217 | const bundle = await rollup({ 218 | input: source, 219 | plugins: plugins || [ 220 | rollupSvelte({ 221 | emitCss: false, 222 | compilerOptions: { 223 | generate: 'ssr', 224 | dev, 225 | immutable, 226 | hydratable, 227 | css, 228 | preserveComments, 229 | preserveWhitespace, 230 | }, 231 | }), 232 | resolve(), 233 | ], 234 | }) 235 | 236 | const name = 'Component' 237 | 238 | const { output } = await bundle.generate({ 239 | format: 'iife', 240 | name, 241 | }) 242 | 243 | // eslint-disable-next-line no-new-func 244 | const Component = new Function(`${output[0].code}return ${name};`)() 245 | 246 | return Component.render(props) 247 | } 248 | --------------------------------------------------------------------------------