diff --git a/.circleci/config.yml b/.circleci/config.yml index cff98bd..4290be4 100755 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ jobs: - attach-project - run: name: E2E SSR Tests - command: yarn build && yarn test:e2e-ssr + command: yarn build && yarn test:e2e-ssr --coverage && yarn coverage - persist_to_workspace: root: ~/project paths: diff --git a/src/client/updaters/attribute.js b/src/client/updaters/attribute.js index 19fa3b4..c4b14cb 100644 --- a/src/client/updaters/attribute.js +++ b/src/client/updaters/attribute.js @@ -43,8 +43,6 @@ export default function updateAttribute (appId, { attribute } = {}, type, attrs, if (attrs[attr]) { data[attr] = data[attr] || {} data[attr][appId] = attrs[attr] - } else { - delete data[attr][appId] } } } diff --git a/src/index.js b/src/index.js index 14c3393..16528d8 100644 --- a/src/index.js +++ b/src/index.js @@ -28,6 +28,6 @@ function install (Vue, options = {}) { export default { version, install, - generate: process.server ? generate : () => showWarningNotSupportedInBrowserBundle('generate'), + generate: metaInfo => process.server ? generate(metaInfo) : showWarningNotSupportedInBrowserBundle('generate'), hasMetaInfo } diff --git a/src/server/generateServerInjector.js b/src/server/generateServerInjector.js index 43368ff..a00818d 100644 --- a/src/server/generateServerInjector.js +++ b/src/server/generateServerInjector.js @@ -58,13 +58,15 @@ export default function generateServerInjector (options, metaInfo) { } } - for (const appId in serverInjector.extraData) { - const data = serverInjector.extraData[appId][type] - if (data) { - for (const attr in data) { - attributeData[attr] = { - ...attributeData[attr], - [appId]: data[attr] + if (serverInjector.extraData) { + for (const appId in serverInjector.extraData) { + const data = serverInjector.extraData[appId][type] + if (data) { + for (const attr in data) { + attributeData[attr] = { + ...attributeData[attr], + [appId]: data[attr] + } } } } diff --git a/src/shared/mixin.js b/src/shared/mixin.js index cf75ea0..fd2ec17 100644 --- a/src/shared/mixin.js +++ b/src/shared/mixin.js @@ -155,6 +155,7 @@ export default function createMixin (Vue, options) { // Wait that element is hidden before refreshing meta tags (to support animations) const interval = setInterval(() => { if (this.$el && this.$el.offsetParent !== null) { + /* istanbul ignore next line */ return } diff --git a/test/components/hello-world.vue b/test/components/hello-world.vue index 9b3721c..88bc599 100644 --- a/test/components/hello-world.vue +++ b/test/components/hello-world.vue @@ -6,12 +6,7 @@ export default { metaInfo() { return { - title: this.title - } - }, - data() { - return { - title: 'Hello World', + title: this.title, htmlAttrs: { lang: 'en' }, @@ -19,6 +14,11 @@ export default { { charset: 'utf-8' } ] } + }, + data() { + return { + title: 'Hello World', + } } } diff --git a/test/unit/components.test.js b/test/unit/components.test.js index aa49fee..d155ba6 100644 --- a/test/unit/components.test.js +++ b/test/unit/components.test.js @@ -14,9 +14,9 @@ jest.mock('../../src/utils/window', () => ({ hasGlobalWindow: false })) -describe('client', () => { +describe('components', () => { let Vue - let html + let elements beforeAll(() => { Vue = loadVueMetaPlugin() @@ -25,15 +25,36 @@ describe('client', () => { delete window.requestAnimationFrame delete window.cancelAnimationFrame - html = document.createElement('html') + elements = { + html: document.createElement('html'), + head: document.createElement('head'), + body: document.createElement('body') + } + + elements.html.appendChild(elements.head) + elements.html.appendChild(elements.body) + document._getElementsByTagName = document.getElementsByTagName jest.spyOn(document, 'getElementsByTagName').mockImplementation((tag) => { - if (tag === 'html') { - return [html] + if (elements[tag]) { + return [elements[tag]] } return document._getElementsByTagName(tag) }) + jest.spyOn(document, 'querySelectorAll').mockImplementation((query) => { + return elements.html.querySelectorAll(query) + }) + }) + + afterEach(() => { + elements.html.getAttributeNames().forEach(name => elements.html.removeAttribute(name)) + elements.head.childNodes.forEach(child => child.remove()) + elements.head.getAttributeNames().forEach(name => elements.head.removeAttribute(name)) + elements.body.childNodes.forEach(child => child.remove()) + elements.body.getAttributeNames().forEach(name => elements.body.removeAttribute(name)) + + clearClientAttributeMap() }) test('meta-info refreshed on component\'s data change', () => { @@ -94,6 +115,22 @@ describe('client', () => { expect(metaInfo.title).toEqual(undefined) }) + test('warns when component doesnt has metaInfo', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const metaInfo = HelloWorld.metaInfo + delete HelloWorld.metaInfo + + const wrapper = mount(HelloWorld, { localVue: Vue }) + wrapper.vm.$meta().inject() + + HelloWorld.metaInfo = metaInfo + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith('This vue app/component has no vue-meta configuration') + + warn.mockRestore() + }) + test('meta-info can be rendered with inject', () => { const wrapper = mount(HelloWorld, { localVue: Vue }) @@ -101,8 +138,54 @@ describe('client', () => { expect(metaInfo.title.text()).toEqual('Hello World') }) + test('inject also renders additional app info', () => { + HelloWorld.created = function () { + const { set } = this.$meta().addApp('inject-test-app') + set({ + htmlAttrs: { lang: 'nl' }, + meta: [{ name: 'description', content: 'test-description' }] + }) + } + + const wrapper = mount(HelloWorld, { localVue: Vue }) + + const metaInfo = wrapper.vm.$meta().inject() + expect(metaInfo.title.text()).toEqual('Hello World') + + expect(metaInfo.htmlAttrs.text()).toEqual('lang="en nl" data-vue-meta="%7B%22lang%22:%7B%22ssr%22:%22en%22,%22inject-test-app%22:%22nl%22%7D%7D"') + expect(metaInfo.meta.text()).toEqual('') + + delete HelloWorld.created + }) + + test('attributes with special meaning or functioning correct with inject', () => { + HelloWorld.created = function () { + const { set } = this.$meta().addApp('inject-test-app') + set({ + meta: [{ skip: true, name: 'description', content: 'test-description' }], + script: [{ + once: true, + callback: true, + async: false, + json: { + a: 1 + } + }] + }) + } + + const wrapper = mount(HelloWorld, { localVue: Vue }) + + const metaInfo = wrapper.vm.$meta().inject() + + expect(metaInfo.meta.text()).toEqual('') + expect(metaInfo.script.text()).toEqual('') + + delete HelloWorld.created + }) + test('doesnt update when ssr attribute is set', () => { - html.setAttribute(defaultOptions.ssrAttribute, 'true') + elements.html.setAttribute(defaultOptions.ssrAttribute, 'true') const el = document.createElement('div') el.setAttribute('id', 'app') @@ -216,6 +299,7 @@ describe('client', () => { }) test('changes before hydration initialization trigger an update', async () => { + const { html } = elements html.setAttribute(defaultOptions.ssrAttribute, 'true') const el = document.createElement('div') @@ -257,14 +341,11 @@ describe('client', () => { jest.runAllTimers() expect(html.getAttribute('theme')).toBe('dark') - html.removeAttribute('theme') - wrapper.destroy() }) test('changes during hydration initialization trigger an update', async () => { - clearClientAttributeMap() - + const { html } = elements html.setAttribute(defaultOptions.ssrAttribute, 'true') const el = document.createElement('div') @@ -304,8 +385,122 @@ describe('client', () => { jest.runAllTimers() expect(html.getAttribute('theme')).toBe('dark') - html.removeAttribute('theme') + wrapper.destroy() + }) + + test('can add/remove meta info from additional app ', () => { + const { html } = elements + let app + + HelloWorld.created = function () { + // make sure that app's which set data but are removed before mounting + // are really removed + const { set, remove } = this.$meta().addApp('my-bogus-app') + set({ + meta: [{ name: 'og:description', content: 'test-description' }] + }) + remove() + + app = this.$meta().addApp('my-test-app') + app.set({ + htmlAttrs: { lang: 'nl' }, + meta: [{ name: 'description', content: 'test-description' }], + script: [{ innerHTML: 'var test = true;' }] + }) + } + + const wrapper = mount(HelloWorld, { + localVue: Vue + }) + + wrapper.vm.$meta().refresh() + + expect(html.getAttribute('lang')).toEqual('en nl') + expect(Array.from(html.querySelectorAll('meta')).length).toBe(2) + expect(Array.from(html.querySelectorAll('script')).length).toBe(1) + expect(Array.from(html.querySelectorAll('[data-vue-meta="my-test-app"]')).length).toBe(2) + + app.remove() + + // add another app to make sure on client data is immediately added + const anotherApp = wrapper.vm.$meta().addApp('another-test-app') + anotherApp.set({ + meta: [{ name: 'og:description', content: 'test-description' }] + }) + + expect(html.getAttribute('lang')).toEqual('en') + expect(Array.from(html.querySelectorAll('meta')).length).toBe(2) + expect(Array.from(html.querySelectorAll('script')).length).toBe(0) + expect(Array.from(html.querySelectorAll('[data-vue-meta="my-test-app"]')).length).toBe(0) + expect(Array.from(html.querySelectorAll('[data-vue-meta="another-test-app"]')).length).toBe(1) + + wrapper.destroy() + delete HelloWorld.created + }) + + test('retrieves ssr app config from attribute', () => { + const { html, body } = elements + html.setAttribute(defaultOptions.ssrAttribute, 'true') + + body.setAttribute('foo', 'bar') + body.setAttribute('data-vue-meta', '%7B%22foo%22:%7B%22ssr%22:%22bar%22%7D%7D') + + const el = document.createElement('div') + el.setAttribute('id', 'app') + el.setAttribute('data-server-rendered', true) + document.body.appendChild(el) + + const Component = Vue.extend({ + metaInfo: { + title: 'Test', + bodyAttrs: { + foo: 'bar' + } + }, + render: h => h('div', null, 'Test') + }) + + const vm = new Component().$mount(el) + + const wrapper = createWrapper(vm) + + wrapper.vm.$meta().refresh() + expect(body.getAttribute('foo')).toBe('bar') + expect(body.getAttribute('data-vue-meta')).toBe('%7B%22foo%22:%7B%22ssr%22:%22bar%22%7D%7D') + + wrapper.vm.$meta().refresh() + expect(body.getAttribute('foo')).toBe('bar') + expect(body.getAttribute('data-vue-meta')).toBeNull() + + wrapper.vm.$meta().refresh() + expect(body.getAttribute('foo')).toBe('bar') + expect(body.getAttribute('data-vue-meta')).toBeNull() wrapper.destroy() }) + + test('can toggle refreshOnceOnNavigation runtime', () => { + const guards = {} + const wrapper = mount(HelloWorld, { + localVue: Vue, + mocks: { + $router: { + beforeEach (fn) { + guards.before = fn + }, + afterEach (fn) { + guards.after = fn + } + } + } + }) + + expect(guards.before).toBeUndefined() + expect(guards.after).toBeUndefined() + + wrapper.vm.$meta().setOptions({ refreshOnceOnNavigation: true }) + + expect(guards.before).not.toBeUndefined() + expect(guards.after).not.toBeUndefined() + }) }) diff --git a/test/unit/generators.test.js b/test/unit/generators.test.js index cd4d4f9..84de215 100644 --- a/test/unit/generators.test.js +++ b/test/unit/generators.test.js @@ -61,7 +61,7 @@ describe('generators', () => { } }) -describe('extra tests', () => { +describe.only('extra tests', () => { test('empty config doesnt generate a tag', () => { const { meta } = generateServerInjector({ meta: [] }) @@ -109,4 +109,33 @@ describe('extra tests', () => { expect(scriptTags.text({ body: true })).toBe('') expect(scriptTags.text({ pbody: true })).toBe('') }) + + test('add additional app and test head/body injector helpers', () => { + const baseInfo = { + title: 'hello', + htmlAttrs: { lang: 'en' }, + bodyAttrs: { class: 'base-class' }, + script: [{ src: '/script.js', body: true }] + } + const extraInfo = { + bodyAttrs: { class: 'extra-class' }, + script: [{ src: '/script.js', pbody: true }] + } + + const serverInjector = _generateServerInjector(defaultOptions, baseInfo) + serverInjector.addInfo('test-app', extraInfo) + + const meta = serverInjector.injectors + + expect(meta.script.text()).toBe('') + expect(meta.script.text({ body: true })).toBe('') + expect(meta.script.text({ pbody: true })).toBe('') + + expect(meta.head(true)).toBe('hello\n') + expect(meta.bodyPrepend(true)).toBe('\n') + expect(meta.bodyAppend()).toBe('') + + expect(meta.htmlAttrs.text()).toBe('lang="en" data-vue-meta="%7B%22lang%22:%7B%22ssr%22:%22en%22%7D%7D"') + expect(meta.bodyAttrs.text()).toBe('class="base-class extra-class" data-vue-meta="%7B%22class%22:%7B%22ssr%22:%22base-class%22,%22test-app%22:%22extra-class%22%7D%7D"') + }) }) diff --git a/test/unit/plugin.test.js b/test/unit/plugin.test.js index ba519b5..65a143d 100644 --- a/test/unit/plugin.test.js +++ b/test/unit/plugin.test.js @@ -120,6 +120,7 @@ describe('plugin', () => { }) test('can use generate export', () => { + process.server = true const rawInfo = { meta: [{ charset: 'utf-8' }] } @@ -131,6 +132,22 @@ describe('plugin', () => { expect(metaInfo.script.text()).toBe('') }) + test('warning when calling generate in browser build', () => { + process.server = false + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + + const rawInfo = { + meta: [{ charset: 'utf-8' }] + } + + const metaInfo = VueMetaPlugin.generate(rawInfo) + expect(metaInfo).toBeUndefined() + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith('generate is not supported in browser builds') + + warn.mockRestore() + }) + test('updates can be paused and resumed', async () => { const { batchUpdate: _batchUpdate } = jest.requireActual('../../src/client/update') const batchUpdateSpy = batchUpdate.mockImplementation(_batchUpdate) diff --git a/test/utils/index.js b/test/utils/index.js index d6d58a4..d0449e2 100644 --- a/test/utils/index.js +++ b/test/utils/index.js @@ -1,6 +1,6 @@ import { JSDOM } from 'jsdom' import { mount, shallowMount, createWrapper, createLocalVue } from '@vue/test-utils' -import { renderToString } from '@vue/server-test-utils' +import { render, renderToString } from '@vue/server-test-utils' import { attributeMap } from '../../src/client/updaters/attribute' import { defaultOptions } from '../../src/shared/constants' import VueMetaPlugin from '../../src' @@ -9,8 +9,10 @@ export { mount, shallowMount, createWrapper, + render, renderToString, - VueMetaPlugin + VueMetaPlugin, + attributeMap } export function getVue () {