vitest icon indicating copy to clipboard operation
vitest copied to clipboard

Browser mode runs test twice in case of a self-importing svelte component

Open paoloricciuti opened this issue 1 year ago • 15 comments

Describe the bug

So, i know this is a bit of a weird case but please bear with me.

To test components with snippets in svelte 5 i can create a snippet programmatically with createRawSnippet but that's an intentionally low level api which makes things a bit annoying to test. But today i realized that i could do something like this which is very clean and elegant.

Button.test.svelte

<script module lang="ts">
	import { expect, it } from 'vitest';
	import { render as r } from 'vitest-browser-svelte';
	import Test from './Button.test.svelte';

	it('should render a snippet in the button', async () => {
		const instance = r(Test, {
			children: rendering_this
		});

		const button = instance.getByRole('button');
		expect(button.getByText('Pretty cool eh?').query()).toBeDefined();
	});
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';
	import Button from './Button.svelte';

	let { children }: { children: Snippet } = $props();
</script>

{#snippet rendering_this()}
	<b>Pretty cool eh?</b>
{/snippet}

<Button>
	{@render children()}
</Button>

so basically i can create a svelte file, name it Something.test.svelte include it in the test file and then write a test bed in the test component itself, self import it in the module and write my tests there. This works absolutely fine with @testing-library/svelte.

But i start to use vitest browser testing i have the inconvenient side effect that my tests run twice. I suspect this is a bug with vitest and it would be cool if it was fixed because i really like this pattern and i want to spread it.

Reproduction

  1. Go to this stackblitz
  2. run pnpm test (i think vitest browser mode is a bit glitchy on stackblitz but sometimes it works and you'll see two tests being run)

System Info

System:
    OS: Linux 5.0 undefined
    CPU: (8) x64 Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
    Memory: 0 Bytes / 0 Bytes
    Shell: 1.0 - /bin/jsh
  Binaries:
    Node: 18.20.3 - /usr/local/bin/node
    Yarn: 1.22.19 - /usr/local/bin/yarn
    npm: 10.2.3 - /usr/local/bin/npm
    pnpm: 8.15.6 - /usr/local/bin/pnpm
  npmPackages:
    @vitest/browser: ^2.1.8 => 2.1.8 
    vite: ^5.4.11 => 5.4.11 
    vitest: ^2.0.4 => 2.1.8

Used Package Manager

pnpm

Validations

paoloricciuti avatar Jan 14 '25 17:01 paoloricciuti

As browser network devtool shows, Button.test.svelte module is loaded twice. One is /full-path-to/src/lib/Button.test.svelte?import&browserv=xxx and the other is /src/lib/Button.test.svelte. I don't know about the purpose of ?browserv, but it's coming from here. @sheremet-va Do you know why we need this?

https://github.com/vitest-dev/vitest/blob/f9a628438a5462436b59dd9bdeffddada19a9e81/packages/browser/src/client/tester/runner.ts#L138-L150

(But eliminating ?browserv=xxx query is not enough since there are also ?import and /full-path-to/... parts, which is inconsistent with how Vite normalizes normal import.)


I came up with a rough hack to avoid the issue, which is to transform/load /full-path-to/src/lib/Button.test.svelte?import&browserv=xxx in a following way:

// response of /full-path-to/src/lib/Button.test.svelte?import&browserv=xxx
import "/src/lib/Button.test.svelte"

// normally `Button.test.svelte` content is returned in this module,
// but instead, it's now replaced with `/src/lib/Button.test.svelte` import.
// in this way, browser only evaluates `Button.test.svelte` content only once.

https://stackblitz.com/edit/stackblitz-starters-pwcfznwc?file=vite.config.ts

    {
      name: 'fix-duplicate-module',
      resolveId: {
        order: 'pre',
        handler(source) {
          if (source.includes('/Button.test.svelte?browserv=')) {
            return '\0virtual:fix-duplicate-module' + source;
          }
        },
      },
      load(id) {
        if (id.startsWith('\0virtual:fix-duplicate-module')) {
          id = id.slice('\0virtual:fix-duplicate-module'.length);
          id = id.split('?')[0];
          return `import ${JSON.stringify(id)}`;
        }
      },
    }

Btw, the same can happen in plain js tests too, for example:

// repro.test.js
import { it } from 'vitest';
import './repro.test.js';

it("repro", () => {})

hi-ogawa avatar Jan 15 '25 09:01 hi-ogawa

Thanks for the exploration, for the hack and for the even more minimal reproduction. I wonder if the self import tricks vite into not rewriting the inner module but that's just a random guess.

Anyway i can be more helpful?

paoloricciuti avatar Jan 15 '25 12:01 paoloricciuti

There is an easier workaround to do await import(import.meta.url)

sheremet-va avatar Jan 15 '25 12:01 sheremet-va

There is an easier workaround to do await import(import.meta.url)

In the hack plugin? Because it doesn't seem to work in the file itself

paoloricciuti avatar Jan 15 '25 16:01 paoloricciuti

There is an easier workaround to do await import(import.meta.url)

In the hack plugin? Because it doesn't seem to work in the file itself

In the test file. You need to use it inside the test or a describe callback though.

sheremet-va avatar Jan 15 '25 17:01 sheremet-va

In the test file. You need to use it inside the test or a describe callback though.

Doesn't seems to work because when you import it it also run the test function and it's within the test function leading to an error.

paoloricciuti avatar Jan 15 '25 18:01 paoloricciuti

Yeah, I tried this too and it should technically work, but Vitest's dynamic import transform drops /* @vite-ignore*/, so Vite import analysis logs a warning

	it('should render a snippet in the button', async () => {
		const { default: Test } = await import(/* @vite-ignore*/ import.meta.url);
		const instance = r(Test, {
			children: rendering_this
		});
10:44:07 AM [vite] warning: 
/home/hiroshi/Downloads/stackblitz-starters-om7pgwvq/src/lib/Button.test.svelte
17 |  // import Test from './Button.test.svelte';
18 |  it('should render a snippet in the button', async () => {
19 |    const { default: Test } = await globalThis["__vitest_browser_runner__"].wrapDynamicImport(() => import(import.meta.url));
   |                                                                                                          ^^^^^^^^^^^^^^^
20 |    const instance = r(Test, { children: rendering_this });
21 |    const button = instance.getByRole('button');
The above dynamic import cannot be analyzed by Vite.
See https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations for supported dynamic import formats. If this is intended to be left as-is, you can use the /* @vite-ignore */ comment inside the import() call to suppress this warning.

In my opinion, I'm not sure if we can support your self-import test pattern robustly even if we deal with ?browserv hash.

hi-ogawa avatar Jan 16 '25 01:01 hi-ogawa

How does this pattern work in production? Doesn't it mean you also bundle Vitest?

sheremet-va avatar Jan 22 '25 15:01 sheremet-va

How does this pattern work in production? Doesn't it mean you also bundle Vitest?

The .svelte is only used to render the actual component easily, it's never imported in application code so it will be treeshaken

paoloricciuti avatar Jan 22 '25 15:01 paoloricciuti

In my opinion, I'm not sure if we can support your self-import test pattern robustly even if we deal with ?browserv hash.

Can you explain a bit why is that the case?

paoloricciuti avatar Jan 22 '25 15:01 paoloricciuti

In my opinion, I'm not sure if we can support your self-import test pattern robustly even if we deal with ?browserv hash.

Can you explain a bit why is that the case?

The point is that, other than ?browserv, we need to copy how Vite's import analysis normalizes import specifier to not duplicate the module. Okay, it looks like it's now contained in this function and maybe fine. https://github.com/vitejs/vite/blob/3aa10b7d618b178aec0f027b1f5fcd3353d2b166/packages/vite/src/node/plugins/importAnalysis.ts#L103-L140 Also if we assume test files are under root, what's need is basically testFullePath.slice(config.root.length).

hi-ogawa avatar Jan 22 '25 23:01 hi-ogawa

In case this helps: I've also found Playwright Locators to "act twice" in a way that non-browser testing doesn't. In my case, a click handler added child elements, and the Locator.click on the parent also triggered a click on the child, but only with Playwright. I worked around that with using DOM click method directly, asHTMLElement(button.element()).click();

tv42 avatar Aug 04 '25 18:08 tv42

I don't think we need browserv anymore since we create separate iframes for tests and during watch rerun it will be recreated.

sheremet-va avatar Nov 27 '25 13:11 sheremet-va

So this might be solved? 👀 Should I try?

paoloricciuti avatar Nov 27 '25 13:11 paoloricciuti

So this might be solved? 👀 Should I try?

I still think that import(import.meta.url) should work for your case because that is what Storybook does for their integration and it works.

But I also think that we don't need the browserv query anymore, and using a static import provides better ergonomics. You can try to investigate it, some good points to start are:

  • https://github.com/vitest-dev/vitest/blob/075ab35207ce7e6ad6bd3abb02ba6d14d5108c7b/packages/browser/src/client/tester/runner.ts#L272
  • https://github.com/vitest-dev/vitest/blob/075ab35207ce7e6ad6bd3abb02ba6d14d5108c7b/packages/browser/src/node/plugin.ts#L340

sheremet-va avatar Nov 27 '25 14:11 sheremet-va