vue-instantsearch-ssr
Version:
👀 Lightning-fast Algolia search for Vue apps
1,137 lines (1,009 loc) • 28.7 kB
JavaScript
import { mount, createSSRApp } from '../../../test/utils';
import Router from 'vue-router';
import Vuex from 'vuex';
import { createStore } from 'vuex4';
import { createServerRootMixin } from '../createServerRootMixin';
import InstantSearchSsr from '../../components/InstantSearchSsr';
import Configure from '../../components/Configure';
import SearchBox from '../../components/SearchBox.vue';
import { createWidgetMixin } from '../../mixins/widget';
import { createFakeClient } from '../testutils/client';
import { createSerializedState } from '../testutils/helper';
import { isVue3, isVue2, Vue2, renderCompat } from '../vue-compat';
import {
AlgoliaSearchHelper,
SearchParameters,
SearchResults,
} from 'algoliasearch-helper';
jest.unmock('instantsearch.js/es');
function renderToString(app) {
if (isVue3) {
return require('@vue/server-renderer').renderToString(app);
} else {
return new Promise((resolve, reject) => {
require('vue-server-renderer/basic')(app, (err, res) => {
if (err) reject(err);
resolve(res);
});
});
}
}
const forceIsServerMixin = {
beforeCreate() {
Object.setPrototypeOf(
this,
new Proxy(Object.getPrototypeOf(this), {
get: (target, key, receiver) =>
key === '$isServer' ? true : Reflect.get(target, key, receiver),
})
);
},
};
process.env.VUE_ENV = 'server';
describe('createServerRootMixin', () => {
describe('creation', () => {
it('requires searchClient', () => {
expect(() =>
createSSRApp({
mixins: [
createServerRootMixin({
searchClient: undefined,
indexName: 'lol',
}),
],
})
).toThrowErrorMatchingInlineSnapshot(`
"The \`searchClient\` option is required.
See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/"
`);
});
it('requires indexName', () => {
expect(() =>
createSSRApp({
mixins: [
createServerRootMixin({
searchClient: createFakeClient(),
indexName: undefined,
}),
],
})
).toThrowErrorMatchingInlineSnapshot(`
"The \`indexName\` option is required.
See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/"
`);
});
it('creates an instantsearch instance on "data"', () => {
const App = {
mixins: [
createServerRootMixin({
searchClient: createFakeClient(),
indexName: 'lol',
}),
],
render: () => null,
};
const wrapper = mount(App);
expect(wrapper.vm.$data).toEqual({
instantsearch: expect.objectContaining({
start: expect.any(Function),
}),
});
});
it('provides the instantsearch instance', done => {
const App = {
mixins: [
createServerRootMixin({
searchClient: createFakeClient(),
indexName: 'myIndexName',
}),
],
template: `<div><slot /></div>`,
};
const Child = {
mixins: [createWidgetMixin({ connector: true })],
mounted() {
expect(this.instantSearchInstance).toEqual(
expect.objectContaining({
start: expect.any(Function),
dispose: expect.any(Function),
mainIndex: expect.any(Object),
addWidgets: expect.any(Function),
removeWidgets: expect.any(Function),
})
);
done();
},
render() {
return null;
},
};
mount({
components: { App, InstantSearchSsr, Child },
template: `
<App>
<InstantSearchSsr>
<Child />
</InstantSearchSsr>
</App>
`,
});
});
});
describe('findResultsState', () => {
it('provides findResultsState', async done => {
const app = createSSRApp({
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient: createFakeClient(),
indexName: 'hello',
}),
],
render: renderCompat(h => h(InstantSearchSsr, {})),
created() {
expect(typeof this.instantsearch.findResultsState).toBe('function');
done();
},
});
await renderToString(app);
});
it('requires renderToString', async () => {
const searchClient = createFakeClient();
const app = {
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
serverPrefetch() {
expect(() =>
this.instantsearch.findResultsState({ component: this })
).toThrowErrorMatchingInlineSnapshot(
`"findResultsState requires \`renderToString: (component) => Promise<string>\` in the first argument."`
);
},
};
const wrapper = createSSRApp({
mixins: [forceIsServerMixin],
render: renderCompat(h => h(app)),
});
await renderToString(wrapper);
});
it('detects child widgets', async () => {
const searchClient = createFakeClient();
let mainIndex;
const app = {
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
serverPrefetch() {
return this.instantsearch.findResultsState({
component: this,
renderToString,
});
},
created() {
mainIndex = this.instantsearch.mainIndex;
},
};
const wrapper = createSSRApp({
mixins: [forceIsServerMixin],
render: renderCompat(h => h(app)),
});
await renderToString(wrapper);
expect(mainIndex.getWidgetState()).toMatchInlineSnapshot(`
Object {
"hello": Object {
"configure": Object {
"hitsPerPage": 100,
},
},
}
`);
expect(searchClient.search).toHaveBeenCalledTimes(1);
expect(searchClient.search.mock.calls[0][0]).toMatchInlineSnapshot(`
Array [
Object {
"indexName": "hello",
"params": Object {
"facets": Array [],
"hitsPerPage": 100,
"query": "",
"tagFilters": "",
},
},
]
`);
});
it('returns correct results state', async done => {
const searchClient = createFakeClient();
const app = {
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
async serverPrefetch() {
const state = await this.instantsearch.findResultsState({
component: this,
renderToString,
});
expect(state).toEqual({
hello: {
results: [
{
query: '',
},
],
state: {
disjunctiveFacets: [],
disjunctiveFacetsRefinements: {},
facets: [],
facetsExcludes: {},
facetsRefinements: {},
hierarchicalFacets: [],
hierarchicalFacetsRefinements: {},
hitsPerPage: 100,
index: 'hello',
numericRefinements: {},
query: '',
tagRefinements: [],
},
},
});
done();
return state;
},
};
const wrapper = createSSRApp({
mixins: [forceIsServerMixin],
render: renderCompat(h => h(app)),
});
await renderToString(wrapper);
});
it('forwards router', async () => {
const searchClient = createFakeClient();
let router;
if (isVue3) {
const Router4 = require('vue-router4');
router = Router4.createRouter({
history: Router4.createMemoryHistory(),
routes: [{ path: '', component: {} }],
});
} else {
router = new Router({});
}
// there are two renders of App, each with an assertion
expect.assertions(2);
if (isVue2) {
Vue2.use(Router);
}
const App = {
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
data() {
expect(this.$router).toBe(router);
return {};
},
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
serverPrefetch() {
return this.instantsearch.findResultsState({
component: this,
renderToString,
});
},
};
const wrapper = createSSRApp({
mixins: [forceIsServerMixin],
render: renderCompat(h => h(App)),
...(isVue2 ? { router } : {}),
});
if (isVue3) {
wrapper.use(router);
}
await renderToString(wrapper);
});
it('forwards vuex', async () => {
const searchClient = createFakeClient();
if (isVue2) {
Vue2.use(Vuex);
}
const store = isVue3 ? createStore() : new Vuex.Store();
// there are two renders of App, each with an assertion
expect.assertions(2);
const App = {
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
data() {
expect(this.$store).toBe(store);
return {};
},
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
serverPrefetch() {
return this.instantsearch.findResultsState({
component: this,
renderToString,
});
},
};
const wrapper = createSSRApp({
mixins: [forceIsServerMixin],
...(isVue2 ? { store } : {}),
render: renderCompat(h => h(App)),
});
if (isVue3) {
wrapper.use(store);
}
await renderToString(wrapper);
});
if (isVue2) {
it('forwards props', async () => {
const searchClient = createFakeClient();
// there are two renders of App, each with an assertion
expect.assertions(2);
const someProp = { data: Math.random() };
const App = {
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
props: {
someProp: {
required: true,
type: Object,
validator(value) {
expect(value).toBe(someProp);
return value === someProp;
},
},
},
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
serverPrefetch() {
return this.instantsearch.findResultsState({
component: this,
renderToString,
});
},
};
const wrapper = createSSRApp({
mixins: [forceIsServerMixin],
render: renderCompat(h => h(App, { props: { someProp } })),
});
await renderToString(wrapper);
});
it('forwards slots', async () => {
const searchClient = createFakeClient();
let res;
const App = {
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
components: { InstantSearchSsr },
template: `
<InstantSearchSsr>
<slot />
</InstantSearchSsr>
`,
serverPrefetch() {
return this.instantsearch
.findResultsState({ component: this, renderToString })
.then(result => {
res = result;
});
},
};
const wrapper = createSSRApp({
mixins: [forceIsServerMixin],
components: { App, Configure },
template: `
<App>
<Configure :hits-per-page.camel="100" />
</App>
`,
});
await renderToString(wrapper);
expect(res.hello.state.hitsPerPage).toBe(100);
});
// TODO: forwarding of scoped slots doesn't yet work.
it.skip('forwards scoped slots', async done => {
const searchClient = createFakeClient();
expect.assertions(2);
const App = {
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
render: renderCompat(h =>
h(InstantSearchSsr, {}, [this.$scopedSlots.default({ test: true })])
),
serverPrefetch() {
return (
this.instantsearch
.findResultsState({ component: this, renderToString })
.then(res => {
expect(
this.instantsearch.mainIndex.getWidgets().map(w => w.$$type)
).toEqual(['ais.configure']);
expect(res.hello._state.hitsPerPage).toBe(100);
})
// jest throws an error we need to catch, since stuck in the flow
.catch(e => {
done.fail(e);
})
);
},
};
const wrapper = createSSRApp({
mixins: [forceIsServerMixin],
render: renderCompat(h =>
h(App, {
scopedSlots: {
default({ test }) {
if (test) {
return h(Configure, {
hitsPerPage: 100,
});
}
return null;
},
},
})
),
});
await renderToString(wrapper);
done();
});
it('forwards root', async () => {
const searchClient = createFakeClient();
// there are two renders of App, each with an assertion
expect.assertions(2);
const App = {
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
render: renderCompat(function(h) {
expect(this.$root).toBe(wrapper);
return h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
]);
}),
serverPrefetch() {
return this.instantsearch.findResultsState({
component: this,
renderToString,
});
},
};
const wrapper = createSSRApp({
mixins: [forceIsServerMixin],
render: renderCompat(h => h(App)),
});
await renderToString(wrapper);
});
it('forwards nuxt', async () => {
const searchClient = createFakeClient();
let nuxt = 0;
// every time the function gets called, we get a different "nuxt"
// this can be used to assert both "nuxt" objects are equal
const getNuxtCounter = () => ++nuxt;
// there are two renders of App, each with an assertion
expect.assertions(2);
const App = {
mixins: [
{
beforeCreate() {
this.$nuxt = getNuxtCounter();
},
},
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
data() {
expect(this.$nuxt).toEqual(1);
return {};
},
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
serverPrefetch() {
return this.instantsearch.findResultsState({
component: this,
renderToString,
});
},
};
const wrapper = createSSRApp({
mixins: [forceIsServerMixin],
render: renderCompat(h => h(App)),
});
await renderToString(wrapper);
});
it('searches only once', async () => {
const searchClient = createFakeClient();
const app = {
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
serverPrefetch() {
return this.instantsearch.findResultsState({
component: this,
renderToString,
});
},
};
const wrapper = createSSRApp({
mixins: [forceIsServerMixin],
render: renderCompat(h => h(app)),
});
await renderToString(wrapper);
expect(searchClient.search).toHaveBeenCalledTimes(1);
expect(searchClient.search.mock.calls[0][0]).toMatchInlineSnapshot(`
Array [
Object {
"indexName": "hello",
"params": Object {
"facets": Array [],
"hitsPerPage": 100,
"query": "",
"tagFilters": "",
},
},
]
`);
});
it('works when component is at root (and therefore has no $vnode)', async () => {
const searchClient = createFakeClient();
let res;
const app = {
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
};
const wrapper = createSSRApp({
mixins: [
forceIsServerMixin,
createServerRootMixin({
searchClient,
indexName: 'hello',
}),
],
serverPrefetch() {
return this.instantsearch
.findResultsState({
component: this,
renderToString,
})
.then(result => {
res = result;
});
},
render: renderCompat(h => h(app)),
});
await renderToString(wrapper);
expect(res.hello.state.hitsPerPage).toBe(100);
expect(searchClient.search).toHaveBeenCalledTimes(1);
expect(searchClient.search.mock.calls[0][0]).toMatchInlineSnapshot(`
Array [
Object {
"indexName": "hello",
"params": Object {
"facets": Array [],
"hitsPerPage": 100,
"query": "",
"tagFilters": "",
},
},
]
`);
});
}
});
describe('hydrate', () => {
it('sets _initialResults', () => {
const serialized = createSerializedState();
const app = {
mixins: [
createServerRootMixin({
searchClient: createFakeClient(),
indexName: 'hello',
}),
],
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
// in test, beforeCreated doesn't have $data yet, but IRL it does
created() {
this.instantsearch.hydrate({
hello: serialized,
});
},
};
const {
vm: { instantsearch },
} = mount(app);
expect(instantsearch._initialResults).toEqual(
expect.objectContaining({
hello: {
state: expect.any(Object),
results: expect.any(Object),
},
})
);
expect(instantsearch._initialResults.hello).toEqual(
expect.objectContaining(serialized)
);
});
it('inits the main index', () => {
const serialized = createSerializedState();
const app = {
mixins: [
createServerRootMixin({
searchClient: createFakeClient(),
indexName: 'hello',
}),
],
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
};
const {
vm: { instantsearch },
} = mount(app);
expect(instantsearch.mainIndex.getHelper()).toBe(null);
instantsearch.hydrate({
hello: serialized,
});
// TODO: assert that this is expect.any(AlgoliaSearchHelper), but test fails
// even though it's an object with all the right properties (including constructor)
expect(instantsearch.mainIndex.getHelper()).not.toBeNull();
});
it('sets helper & mainHelper', () => {
const serialized = createSerializedState();
const app = {
mixins: [
createServerRootMixin({
searchClient: createFakeClient(),
indexName: 'hello',
}),
],
render: renderCompat(h =>
h(InstantSearchSsr, {}, [
h(Configure, {
attrs: {
hitsPerPage: 100,
},
}),
h(SearchBox),
])
),
};
const {
vm: { instantsearch },
} = mount(app);
expect(instantsearch.helper).toBe(null);
expect(instantsearch.mainHelper).toBe(null);
instantsearch.hydrate({
hello: serialized,
});
expect(instantsearch.helper).toEqual(expect.any(AlgoliaSearchHelper));
expect(instantsearch.mainHelper).toEqual(expect.any(AlgoliaSearchHelper));
});
});
describe('__forceRender', () => {
it('calls render on widget', () => {
let instantSearchInstance;
mount({
mixins: [
createServerRootMixin({
searchClient: createFakeClient(),
indexName: 'lol',
}),
],
created() {
instantSearchInstance = this.instantsearch;
},
render() {},
});
const widget = {
init: jest.fn(),
render: jest.fn(),
};
instantSearchInstance.hydrate({
lol: createSerializedState(),
});
instantSearchInstance.__forceRender(
widget,
instantSearchInstance.mainIndex
);
expect(widget.init).toHaveBeenCalledTimes(0);
expect(widget.render).toHaveBeenCalledTimes(1);
const renderArgs = widget.render.mock.calls[0][0];
expect(renderArgs).toMatchInlineSnapshot(
{
helper: expect.anything(),
results: expect.anything(),
scopedResults: expect.arrayContaining([
expect.objectContaining({
helper: expect.anything(),
indexId: expect.any(String),
results: expect.anything(),
}),
]),
parent: expect.anything(),
state: expect.anything(),
instantSearchInstance: expect.anything(),
},
`
Object {
"createURL": [Function],
"helper": Anything,
"instantSearchInstance": Anything,
"parent": Anything,
"results": Anything,
"scopedResults": ArrayContaining [
ObjectContaining {
"helper": Anything,
"indexId": Any<String>,
"results": Anything,
},
],
"searchMetadata": Object {
"isSearchStalled": false,
},
"state": Anything,
"templatesConfig": Object {},
}
`
);
});
it('uses the results passed to hydrate for rendering', () => {
let instantSearchInstance;
mount({
mixins: [
createServerRootMixin({
searchClient: createFakeClient(),
indexName: 'lol',
}),
],
created() {
instantSearchInstance = this.instantsearch;
},
render() {},
});
const widget = {
init: jest.fn(),
render: jest.fn(),
};
const resultsState = createSerializedState();
const state = new SearchParameters(resultsState.state);
const results = new SearchResults(state, resultsState.results);
instantSearchInstance.hydrate({
lol: resultsState,
});
instantSearchInstance.__forceRender(
widget,
instantSearchInstance.mainIndex
);
expect(widget.init).toHaveBeenCalledTimes(0);
expect(widget.render).toHaveBeenCalledTimes(1);
const renderArgs = widget.render.mock.calls[0][0];
expect(renderArgs).toEqual(
expect.objectContaining({
state,
results,
scopedResults: [
expect.objectContaining({
indexId: 'lol',
results,
}),
],
})
);
});
describe('createURL', () => {
it('returns # if instantsearch has no routing', () => {
let instantSearchInstance;
mount({
mixins: [
createServerRootMixin({
searchClient: createFakeClient(),
indexName: 'lol',
}),
],
created() {
instantSearchInstance = this.instantsearch;
},
render() {},
});
const widget = {
init: jest.fn(),
render: jest.fn(),
};
instantSearchInstance.hydrate({
lol: createSerializedState(),
});
instantSearchInstance.__forceRender(
widget,
instantSearchInstance.mainIndex
);
const renderArgs = widget.render.mock.calls[0][0];
expect(renderArgs.createURL()).toBe('#');
});
it('allows for widgets without getWidgetState', () => {
let instantSearchInstance;
mount({
mixins: [
createServerRootMixin({
searchClient: createFakeClient(),
indexName: 'lol',
}),
],
created() {
instantSearchInstance = this.instantsearch;
},
render() {},
});
const widget = {
init: jest.fn(),
render: jest.fn(),
getWidgetState(uiState) {
return uiState;
},
};
const widgetWithoutGetWidgetState = {
init: jest.fn(),
render: jest.fn(),
};
instantSearchInstance.hydrate({
lol: createSerializedState(),
});
instantSearchInstance.addWidgets([widget, widgetWithoutGetWidgetState]);
instantSearchInstance.__forceRender(
widget,
instantSearchInstance.mainIndex
);
const renderArgs = widget.render.mock.calls[0][0];
expect(renderArgs.createURL()).toBe('#');
});
});
});
});