How to simulate a <select> element in Vitest / vue-test-utils in a Vue.js project - vuejs3

I am trying to simulate choosing an option in a <select> element in my Vitest test. The simulated selection should set the value of the ref that is bound to the <select>'s v-model. As you can see below, I have tried many different ways, based on other questions here and elsewhere on the internet, but I just cannot make it work in my case.
I am using the following:
Vue 3.2.37
Vite 4.0.4
Vitest 0.26.3
vue-test-utils 2.2.7
I have created a very basic Vue component that includes a heading and a <select> element:
<script setup lang="ts">
import { ref } from "vue";
const items = ["item 1", "item 2", "item 3", "item 4", "item 5"];
const selectedItem = ref<string>("");
// this is a placeholder for a more complex function
async function handleSubmit(): Promise<void> {
console.log("in handleSubmit()");
console.log(quotedString(selectedItem.value));
}
// this is just here for a prettier output
function quotedString(s: string): string {
return s === undefined ? s : '"' + s + '"';
}
</script>
<template>
<h1>Foo</h1>
<p>
<select v-model="selectedItem">
<option disabled value="">Please Select</option>
<option v-for="item in items" :value="item" :key="item">
{{ item }}
</option>
</select>
</p>
<p>Selected {{ selectedItem }}</p>
<p><button #click="handleSubmit">Submit</button></p>
</template>
The <select> is bound to selectedItem and when I open that code in the browser everything works fine, meaning that after selecting an element the name of the element is shown on the page and after clicking the submit button, the name of the element is written to the console.
But when I try the following test code, the statement console.log(quotedString(selectedItem.value)); either produces undefined or an empty string, meaning that the simulated selection did not work. Here is the test code including several different attempts.
import { describe, test } from "vitest";
import { shallowMount, VueWrapper } from "#vue/test-utils";
import FooView from "./FooView.vue";
describe("FooView", (): void => {
test("can simulate select selection", async (): Promise<void> => {
const fooViewWrapper = shallowMount(FooView);
const selectFieldWrapper = fooViewWrapper.find("select");
const options = selectFieldWrapper.findAll("option");
// ATTEMP 0
// This is the preferred way according to https://v1.test-utils.vuejs.org/api/wrapper/setvalue.html but still does not work
// await selectFieldWrapper.setValue("item 2");
// expect(selectFieldWrapper.element.value).toBe("item 2");
// alternatively
// selectFieldWrapper.element.value = "item 2";
// selectFieldWrapper.trigger('change')
// expect(selectFieldWrapper.element.value).toBe("item 2");
// ATTEMPT 1
// // eslint-disable-next-line #typescript-eslint/no-non-null-assertion
// await options.at(2)!.trigger("click");
// --> output: empty string
// ATTEMPT 2
// await options.at(2)?.setSelected(); // setSelected() is private :(
// --> output: empty string
// ATTEMPT 3
// // eslint-disable-next-line #typescript-eslint/no-non-null-assertion
// options.at(2)!.element.selected = true;
// // eslint-disable-next-line #typescript-eslint/no-non-null-assertion
// await options.at(2)!.trigger("input");
// --> output: empty string
// ATTEMPT 4
// await selectFieldWrapper.setValue("item 2");
// await selectFieldWrapper.trigger("change");
// --> output: undefined
// ATTEMPT 5
// await selectFieldWrapper.setValue("item 2");
// await selectFieldWrapper.trigger("input");
// --> output: undefined
// ATTEMPT 6
// await selectFieldWrapper.setValue("item 2");
// await selectFieldWrapper.trigger("click");
// --> output: undefined
// ATTEMPT 7
// selectFieldWrapper.element.selectedIndex = 2;
// await selectFieldWrapper.trigger("change");
// --> output: undefined
// ATTEMPT 8
// selectFieldWrapper.element.selectedIndex = 2;
// await selectFieldWrapper.trigger("input");
// --> output: empty string
// ATTEMPT 9
// selectFieldWrapper.element.selectedIndex = 2;
// await selectFieldWrapper.trigger("click");
// --> output: empty string
// ATTEMPT 10
// eslint-disable-next-line #typescript-eslint/no-non-null-assertion
// await options.at(2)!.trigger("click");
// --> output: empty string
// ATTEMPT 10
// await selectFieldWrapper.trigger("click");
// // eslint-disable-next-line #typescript-eslint/no-non-null-assertion
// await options.at(2)!.trigger("click");
// await selectFieldWrapper.trigger("input");
// --> output: empty string
// ATTEMPT 11
// selectFieldWrapper.element.click();
// // eslint-disable-next-line #typescript-eslint/no-non-null-assertion
// options.at(2)!.element.click();
// await nextTick();
// --> output: empty string
// ATTEMPT 12
// // eslint-disable-next-line #typescript-eslint/no-non-null-assertion
// options.at(1)!.trigger('click');
// await nextTick();
// --> output: empty string
// ATTEMPT 13
// await selectFieldWrapper.setValue(2);
// await selectFieldWrapper.trigger("click");
// --> output: undefined
// ATTEMPT 14
// await selectFieldWrapper.setValue(2);
// await selectFieldWrapper.trigger("change");
// --> output: undefined
// selectFieldWrapper.element.selectedIndex = 2;
// await selectFieldWrapper.trigger("input");
// console.log(options.at(2)?.element.selected);
// console.log(selectFieldWrapper.element.selectedIndex);
await clickSubmitButton(fooViewWrapper);
});
});
async function clickSubmitButton(fooViewWrapper: VueWrapper): Promise<void> {
const submitButtonWrapper = fooViewWrapper.find("button");
submitButtonWrapper.element.click();
}
What I find interesting is that I can set the selected option like so:
options.at(2)!.element.selected = true;
console.log(options.at(2)?.element.selected); // prints *true*
But even then I do not get the desired output.

To answer my own question: The issue was not with the test itself but with the vitest configuration. I was using happy-dom as its environment, which apparently does not work in this particular case. Switching the environment to jsdom was the solution. With this my Attempt 0 was successful, so this works:
await selectFieldWrapper.setValue("item 2");
expect(selectFieldWrapper.element.value).toBe("item 2");

Related

usage of ref in vue3

I wrote a vue3 component which uses the VirtualScroller from PrimeVue and I would like to scroll to the end of the scroller each time I'm adding new elements. For that, there is scrollInView method which is defined on the component and documented here
My code looks like this (it's typescript with vue-class-component and single file syntax):
<template>
...
<VirtualScroller :items="content" :itemSize="50" class="streamscroller" ref="streamscroller">
<template v-slot:item="{ item }">
<pre>{{ item }}</pre>
</template>
</VirtualScroller>
...
</template>
<script lang="ts">
...
import { ref, ComponentPublicInstance } from "vue";
import VirtualScroller from "primevue/virtualscroller";
...
#Options({
components: {
VirtualScroller,
...
},
})
export default class StreamResultViewer extends Vue {
streamscroller = ref<ComponentPublicInstance<VirtualScroller>>();
content: string [] = [ "No output" ];
...
mounted(): void {
...
console.debug("scroller mounted: ", this.streamscroller.value); // <=== here, already the value is indefined
}
onData(msg: string): void {
const lines = msg.split('\n');
const content = [...this.content, ...lines];
this.content = content;
console.debug("scroller: ", this.streamscroller.value); // <== always undefined
this.streamscroller.value?.scrollInView(this.content.length, 'to-end', 'smooth'); // <== so never called
}
...
The virtual scroller works well (I can add lines each time they arrives and the scroll bar moves...) but I can never call the scroll method because the ref is undefined...
I'd be very grateful for any clue...
Thank you
The only workaround I found is too use $refs like this:
onData(msg: string): void {
const lines = msg.split('\n');
const content = [...this.content, ...lines];
this.content = content;
const scroller = this.$refs.streamscroller as VirtualScroller;
scroller.scrollInView(this.content.length, 'to-end', 'smooth');
}
This way, I am able to call the scrolling method and it works fine.
If someone can explain how it should work normally with ref<T>() in the vue-class-component + typescript mode, I'd be glad to hear that.

Typescript transformer, `node.parent` is undefined

I'm currently using a typescript transformer api, and I found that the node.parent is undefined.
My code is:
const transformerFactory: ts.TransformerFactory<ts.Node> = (
context: ts.TransformationContext
) => {
return (rootNode) => {
function visit(node: ts.Node): ts.Node {
node = ts.visitEachChild(node, visit, context);
// HERE node.parent IS UNDEFINED !
return filterFn(node, context);
}
return ts.visitNode(rootNode, visit);
};
};
const transformationResult = ts.transform(
sourceFile, [transformerFactory]
);
How can I find the parent of the node?
You can parse specifying to set the parent nodes:
const sourceFile = ts.createSourceFile(
"fileName.ts",
"class Test {}",
ts.ScriptTarget.Latest,
/* setParentNodes */ true, // specify this as true
);
Or do some operation on the node to get it to set its parent nodes (ex. type check the program... IIRC during binding it ensures the parent nodes are set).
Update based on comment
If you are creating these from a program, then you can do the following:
const options: ts.CompilerOptions = { allowJs: true };
const compilerHost = ts.createCompilerHost(options, /* setParentNodes */ true);
const program = ts.createProgram([this.filePath], options, compilerHost);

Firebase: Why value event gets fired before new child ref gets added

Following code, is a very simple Firebase - VueJS app, (codeSandBox demo)
app.vue
<template>
<div class="container">
<!-- Adding Quote -->
<add-quote/>
<!-- Display Quotes -->
<quote-list/>
</div>
</template>
<script>
import addQuote from "./components/AddQuote.vue";
import quoteList from "./components/QuoteList.vue";
export default {
components: {
addQuote,
quoteList
},
methods: {
get_allQuotes: function() {
// var vm = this;
var localArr = [];
quotesRef
.once("value", function(snapshot) {
snapshot.forEach(function(snap) {
localArr.push({
key: snap.key,
category: snap.val().category,
quoteTxt: snap.val().quoteTxt
});
});
})
.then(data => {
this.$store.commit("set_allQuotes", localArr);
});
}
},
mounted() {
this.get_allQuotes();
console.log("App: mounted fired");
}
};
</script>
store.js(vuex store)
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export const store = new Vuex.Store({
state: {
quotesList: []
},
getters: {
get_quotesList(state) {
return state.quotesList;
}
},
mutations: {
set_allQuotes(state, value) {
state.quotesList = value;
}
}
});
AddQuote.vue
<template>
<div class="row quote-edit-wrapper">
<div class="col-xs-6">
<textarea v-model.lazy="newQuoteTxt"
rows="4"
cols="50"></textarea>
<button #click="addQuote">Add Quote</button>
</div>
</div>
</template>
<script>
export default {
data() {
return {
newQuoteTxt: '',
}
},
computed: {
allQuotes() {
return this.$store.getters.get_quotesList;
},
newQuoteIdx() {
var localArr = [...this.allQuotes]
if(localArr.length > 0) {
var highestKEY, currKEY
localArr.forEach((element, idx) => {
currKEY = parseInt(element.key)
if(idx == 0) {
highestKEY = currKEY
} else {
if(highestKEY < currKEY) {
highestKEY = currKEY
}
}
})
return highestKEY + 1
} else {
return 1
}
}
},
methods: {
// ADD new Quote in DB
addQuote: function() {
var vm = this
var localArr = [...this.allQuotes]
//1. First attach 'value' event listener,
// Snapshot will contain data from that ref
// when any child node is added/updated/delete
quotesRef.on('value', function (snapshot) {
snapshot.forEach(function(snap) {
var itemExists = localArr.some(function (item, idx) {
return item.key == snap.key
})
// If newly added item doesn't yet exists then add to local array
if (!(itemExists)) {
localArr.push({
key: snap.key,
category: snap.val().category,
quoteTxt: snap.val().quoteTxt })
vm.$store.commit('set_allQuotes', localArr)
}
})
})
//2. Second set/create a new quotes in Firebase,
// When this quote gets added in Firebase,
// value event (attached earlier) gets fired
// with
var newQuoteRef = quotesRef.child(this.newQuoteIdx)
newQuoteRef.set({
category: 'motivation',
quoteTxt: this.newQuoteTxt
})
}
}
}
</script>
quoteList.vue
<template>
<div class="row">
<div class="col-xs-12 quotes-list-wrapper">
<template v-for="(quote,idx) in allQuotes">
<!-- Quote block -->
<div class="quote-block-item">
<p class="quote-txt"> {{quote.quoteTxt}} </p>
</div>
</template>
</div>
</div>
</template>
<script>
export default {
computed: {
allQuotes() {
return this.$store.getters.get_quotesList;
}
}
}
</script>
Note: The main code of concern is of addQuote.vue
User enter newQuoteTxt that gets added to Firebase (addQuote()) as a quote item under quotesRef. As soon as quote is added (on firebase), Firebase client side SDK's value event fires, and adds the new quote (via callback) to localArray (allQuotes). VueJS then updates the DOM with newly added Quote.
The addQuote() method works in the following manner:
First, attach a callback/listener to 'value' event on quotesRef
quotesRef.on('value', function (snapshot) {
....
})
Next, A firebase ref (child of quotesRef) is created with a ID this.newQuoteIdx
var newQuoteRef = quotesRef.child(this.newQuoteIdx)
Then set() is called (on this newly created Ref) adding newquote to firebase RealTime DB.
value event gets triggered (attached from step 1) and listener /callback is called.
The callback looks for this new quote's key in existing list of items by matching keys of localArr and snap.key, if not found, adds the newly quote to localArr. localArr commits to a vuex store.
`vm.$store.commit('set_allQuotes', localArr)`
VueX then updates all subscriber component of this array. VueJS then adds the new quote to the existing list of quotes (updates the DOM)
While debugging the addQuote method, the problem I notice, the execution/flow of script (via F8 in chrome debugger) first steps into the listener/callback attached to value event before the code newQuoteRef.set({ ... }) that adds new quote (on firebase), which in turn will cause 'value' event to trigger.
I am not sure why this occurs. Can anybuddy explain why the listener/callback is called before the quotes is created.
Are child nodes (of QuotesRef) are cached at clientside such that 'value' fires even before new quote is added.
Thanks
If I correctly understand your question (Your code is not extremely easy to follow! :-)) it is the normal behaviour. As explained in the documentation:
The value event will trigger once with the initial data stored at
this location, and then trigger again each time the data
changes.
Your sandbox demo does not actually shows how the app works, but normally you should not set-up the listener in the method that saves a new node to the database. These two things should be decoupled.
One common approach is to set the listener in the created hook of a component (see https://v2.vuejs.org/v2/guide/instance.html#Instance-Lifecycle-Hooks and https://v2.vuejs.org/v2/api/#created) and then in your addQuote method you just write to the database. As soon as you write, the listener will be fired.

React Native - Async storage information is extracted after componentWillMount()

I am storing if a checkbox is checked or not using AsyncStorage. When I reload the app, I see from logs that inside the asynchronous variable the correct information is stored. But, it is loaded after componentWillMount. Because of that, the checkbox does not appear checked, as it should be.
I think a good workaround will be to change the checkbox properties inside the asynchronous function. Do you think that would be a good solution? Do you have other suggestions for showing the correct checkbox value?
My code:
constructor(props) {
super(props)
this.state = {isChecked: false}
this.switchStatus = this.switchStatus.bind(this)
}
async getCache(key) {
try {
const status = await AsyncStorage.getItem(key);
if (status == null)
status = false
console.log("my async status is " + status)
return status
} catch(error) {
console.log("error", e)
}
}
componentWillMount(){
// key string depends on the object selected to be checked
const key = "status_" + this.props.data.id.toString()
this.getCache = this.getCache.bind(this)
this.setState({isChecked: (this.getCache(key) == 'true')})
console.log("my state is" + this.state.isChecked)
}
switchStatus () {
const newStatus = this.state.isChecked == false ? true : false
AsyncStorage.setItem("status_" + this.props.data.id.toString(), newStatus.toString());
console.log("hi my status is " + newStatus)
this.setState({isChecked: newStatus})
}
render({ data, onPress} = this.props) {
const {id, title, checked} = data
return (
<ListItem button onPress={onPress}>
<CheckBox
style={{padding: 1}}
onPress={(this.switchStatus}
checked={this.state.isChecked}
/>
<Body>
<Text>{title}</Text>
</Body>
<Right>
<Icon name="arrow-forward" />
</Right>
</ListItem>
)
}
There is no difference if I put everything in componentWillMount in the constructor.
Thank you for your answers. I am pretty sure await will work too, but I solved the problem before getting an answer. What I did was set the state to false in the beginning, and then update it in getCache. This way, it will always be set after getting the information from the local phone storage.
async getCache(key) {
try {
let status = await AsyncStorage.getItem(key);
if (status == null) {
status = false
}
this.setState({ isChecked: (status == 'true') })
} catch(e) {
console.log("error", e);
}
}
you make use of the async - await, but you are not waiting for your method in componentWillMount(). Try this:
componentWillMount(){
// key string depends on the object selected to be checked
const key = "status_" + this.props.data.id.toString()
this.getCache = await this.getCache.bind(this) // <-- Missed await
this.setState({isChecked: (this.getCache(key) == 'true')})
console.log("my state is" + this.state.isChecked)
}
The return value of an async function is a Promise object. So you have to use then to access the resolved value of getCache. Change your code to the following and it should work.
componentWillMount(){
// key string depends on the object selected to be checked
const key = "status_" + this.props.data.id.toString();
this.getCache(key).then(status => {
this.setState({ isChecked: status === true });
})
}

Firebase: Update item in list binding using AngularFire2

According to the angularfire2 documentation the following can be done when you wan't to update a item in a list :
const items = af.database.list('/items');
// to get a key, check the Example app below
items.update('key-of-some-data', { size: newSize });
But is is possible to update an item in the list, without having to specify key:values for the object like this?
items.update('key-of-some-data', item);
In angularfire this is possible to do like this:
<li ng-repeat="item in list">
<input type="text" ng-model="item.title" ng-change="list.$save(item)" />
</li>
Thanks for taking your time to read this question :)
The implementation of update looks like this:
update(item: FirebaseOperation, value: Object): firebase.Promise<void> {
return this._checkOperationCases(item, {
stringCase: () => this.$ref.ref.child(<string>item).update(value),
firebaseCase: () => (<firebase.database.Reference>item).update(value),
snapshotCase: () => (<firebase.database.DataSnapshot>item).ref.update(value),
unwrappedSnapshotCase: () => this.$ref.ref.child((<AFUnwrappedDataSnapshot>item).$key).update(value)
});
}
So it's possible to call update in the following ways:
Using a string key and a value:
const items = af.database.list('/items');
items.update('key-of-some-data', { size: newSize });
Using a Firebase ref and a value:
const items = af.database.list('/items');
const ref = items.$ref.ref;
items.update(ref.child('key-of-some-data'), { size: newSize });
Using a Firebase snapshot and a value:
const items = af.database.list('/items', { preserveSnapshot: true });
items.subscribe(list => {
const snapshot = list[0];
items.update(snapshot, { size: newSize });
});
Using an unwrapped list item and a value:
const items = af.database.list('/items');
items.subscribe(list => {
const item = list[0];
items.update(item, { size: newSize });
});
(The snippets above that call subscribe are only to illustrate that the snapshot and unwrapped items are the list observable's emitted values. Using subscribe like this to perform an update makes no sense.)
AngularFire2 is currently undergoing some refactoring and rearranging in preparation for a release candidate. If you have a use case for which none of the above options is suitable, now is the time to speak up. The discussion is here. However, for something this specific, you should create a new issue.

Resources