[BUG] "Cannot read properties of null (reading '$options')" in Vue 2 Component Tests
Hey there,
I've got an issue since the 1.32. Currently we have the 1.30.0 and there everything works fine. I wanted to upgrade and now the component.update logic fails in vue 2 component tests.
System info
- Playwright Version: v1.32.3
- Previous working Playwright Version: v1.30.0
- Operating System: macOS 13.3
- Browser: All
- Other info:
- @playwright/experimental-ct-vue2: v1.32.3 (upgraded due to the playwright v1.32.3 upgraded)
Config file
// playwright.config.ts
import { devices, defineConfig } from '@playwright/experimental-ct-vue2';
import viteConfig from "./vite.config";
export default defineConfig({
testDir: 'src',
testMatch: /.*\.spec\.[t|j]sx/,
expect: {
timeout: 15 * 1000,
},
timeout: 30 * 1000,
globalTimeout: 30 * 60 * 1000,
forbidOnly: true,
workers: 1,
use: {
viewport: { width: 1920, height: 1080 },
trace: 'on',
video: {
mode: 'on',
size: { width: 1920, height: 1080 },
},
screenshot: 'on',
ignoreHTTPSErrors: true,
ctPort: 10443,
ctViteConfig: viteConfig,
},
globalSetup: './playwright/setup.js',
globalTeardown: './playwright/teardown.js',
reporter: [['html', { open: 'never' }]],
fullyParallel: false,
projects: process.env.CI
? [
{
name: 'component-chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'component-firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'component-webkit',
use: {
...devices['Desktop Safari'],
},
},
]
: undefined,
});
Test file (self-contained)
test('quick test', async ({ page, mount }) => {
const component = await mount(<div id="test" />);
await component.update(<div id="test2" />);
});
Steps
- Run the test (ran it with the --ui flag)
- Errors while updating the component
Expected
Test should pass without any issues.
Actual
Crashes with the error: TypeError: Cannot read properties of null (reading '$options') while testing and is also emitting a warning, which may be the root of the issue: [Vue warn]: globally imported h() can only be invoked when there is an active component instance, e.g. synchronously in a component's render or setup function.
Hi @oliver-freudrich-packwise, the update() works indeed only on framework components at the moment. Any reason why you want to update native HTML elements?
I think we should throw if its not a framework component, wdyt @sand4rt?
@mxschmitt yeah, agree. Think it's possible to add but if a native HTML element is being updated you probably did it by mistake and don't want that in the first place. If it turns out to be useful afterwards, it can still be added, right?
But then it's weird because in my case I tried also using a component (or better multiple components) from Vue 2 and the exact same error occurs for all update calls in the tests. The Wrapper is needed to prepare some stuff the Component uses before it's created. Didn't get it working in the beforeMounted hook, because a method is async and necessary to be blocking before mounting the component and I can't access the template to add a v-if. Is this may the issue (which would be weird because in the 1.30.0 it's working fine)? Or can there only be single components in the update in the new version?
import Wrapper from '../Wrapper.vue';
import Component from '../Component.vue';
test('Should update the component and ensure an component event is only called once', async ({ mount }) => {
const comp = await mount(<Wrapper><Component variables={{test: 'test'}} /></Wrapper>);
let promise;
let resolver;
let count = 0;
const wasCalled = () => {
count++;
if (!resolver) {
return;
}
resolver();
resolver = null;
};
const updateComponent = async (vars, expectedCount) => {
promise = new Promise(resolve => {
resolver = resolve;
});
await comp.update(
<Wrapper>
<Component
variables={vars}
v-on:update={() => wasCalled()}
/>
</Wrapper>
);
await promise;
expect(count).toBe(expectedCount);
};
});
await updateComponent({ hello: 'world' }, 1);
await updateComponent({ another: 'change' }, 2);
@sand4rt Updating html was just a test with the exact same error, so no in general there is no reason, it was just an easier example to write down.
@oliver-freudrich-packwise Thank you for clarifying. Throwing a custom error when updating a native HTML element would still be useful. That's one thing that should be fixed.
It seems that updating a child component its props/events never worked before. Are you sure this worked in 1.30.0? Could you also share a real world example including implementation of the components?
@sand4rt Yeah I am pretty sure because we are using the apollo cache and in my case I am observing the network requests weather it's requesting from network or the cache and that is working in both cases like it should.
Is it somehow possible to add props to the root template? Then I wouldn't need the wrapper component to be wrapped around.
Like this but I can't access the template to assign the show prop with a v-if:
index.js
beforeMount(({ Vue }) => {
// register stuff for Vue
return {
data() {
return {
show: false,
};
},
async created() {
// do some async stuff here....
this.show = true;
},
};
});
Here is a "simpler" async test setup which is working. I tried to reduce those as much as possible to keep the logic but avoid big references to other files:
Wrapper.vue (to load user data and other async stuff which is necessary for a "user session")
<template lang="pug">
div(
v-if="show"
)#test-wrapper
v-app
slot
</template>
<script>
import { mapState } from 'pinia';
import { vuetify, router } from '@/register';
import authHelpers from '@/mixins/authHelpers';
import { authStore } from '@/store';
export default {
name: 'TestWrapper',
vuetify,
router,
mixins: [authHelpers],
props: {
needsAuth: {
type: Boolean,
default: true,
},
},
data() {
return {
show: false,
};
},
computed: {
...mapState(authStore, ['user']),
},
async created() {
// here is happening some more async stuff but would be too much references
if (this.needsAuth) {
const isAuthenticated = await this.isAuthenticated();
if (!isAuthenticated) {
throw new Error(
'No cookie context found. Load a user before the test.'
);
}
if (!this.user) {
// is saving the user to the store
await this.getUserByApiAndToken();
}
}
this.show = true;
},
};
</script>
Container.vue
<template lang="pug">
div.autocomplete
v-autocomplete(
ref="autocomplete"
v-bind="$attrs"
v-on="$listeners"
:items="computedList"
)
</template>
<script>
export default {
name: 'ContainerAutocomplete',
props: {
excludeIds: {
type: Array,
},
includeIds: {
type: Array,
},
valueProperty: {
type: [String, Function],
default: '_id',
},
},
data() {
return {
items: [],
};
},
computed: {
computedList() {
let list = this.items ?? [];
if (this.includeIds?.length) {
list = list.filter(
item => ~this.includeIds.indexOf(this.getValueProperty(item))
);
}
if (this.excludeIds?.length) {
list = list.filter(
item => !~this.excludeIds.indexOf(this.getValueProperty(item))
);
}
return list;
},
},
methods: {
getValueProperty(item) {
return typeof this.valueProperty === 'function'
? this.valueProperty(item)
: item[this.valueProperty];
},
},
apollo: {
getContainers: {
// here is a gql query and variable updates but you won't be able to use it anyways so only for the logic purpose here is the assignment of the response. Test should also work by ignoring this.
result(data) {
this.items = data;
}
}
}
};
</script>
container.spec.jsx
import {
loadCompanyUser,
test,
expect,
generateComponentTestName,
} from '@/../playwright/componentUtils';
import Wrapper from '@/../playwright/componentUtils/Wrapper.vue';
import Container from './container.vue';
test.describe(generateComponentTestName('ContainerAutocomplete'), () => {
// load the auth cookies for the this.isAuthenticated() check in the wrapper
loadCompanyUser();
test(`Should disable and enable the autocomplete`, async ({
mount,
page,
}) => {
const component = await mount(
<Wrapper>
<Container disabled />
</Wrapper>
);
await expect(page.locator('#test-wrapper')).toHaveCount(1);
await expect(page.locator('.autocomplete')).toHaveCount(1);
await expect(
page.locator('.autocomplete input[type="text"]')
).toBeDisabled();
await component.update(
<Wrapper>
<Container disabled={false} />
</Wrapper>
);
await expect(
page.locator('.autocomplete input[type="text"]')
).toBeEnabled();
});
});
@sand4rt do you think it can be fixed next week or so, or we should move it to the next release (1.35) ?
Hey @yury-s, hopefully next release. I'm a bit buried in work ATM.
Hey any updates on that topic? Still getting the same error on the latest version.
However I think I could get rid of this issue, if I can get rid of my "middle layer" wrapper, which is doing async stuff, like fetching a user and adding it to the store, before mounting the component.
Is it somehow possible to add this functionality to the root wrapper of playwright?
Like preparing async stuff in the test root component, but it needs to be in the vue instance scope, not in the beforeMount hook, because I would need like the store to save the user. That way I would not need to add the Wrapper in each test and each mount or component.update.
Also some frameworks like vuetify need a v-app wrapped around for all the components to function properly and to do so something like:
await mount(
<v-app>
<my-component />
</v-app>
);
could result in the same error as I currently have.
I've found a similar topic in https://github.com/microsoft/playwright/issues/14345. Is this somehow available for vue 2? I tried to overwrite/extend the mount function to always mount a custom wrapper around, but doing this resulted in 2 errors: The custom wrapper was not bundled via vite as it is not in the test but in a fixture overwrite in another file and the component.update resulted in an empty DOM.