kit icon indicating copy to clipboard operation
kit copied to clipboard

Shim SvelteKit runtime import aliases / Importing `$app/*` fails

Open madeleineostoja opened this issue 4 years ago • 25 comments

Describe the bug As far as I can tell there's no way to use sveltekit runtime imports (eg: $app/navigation) outside of sveltekit dev/build. Which makes testing virtually impossible. If there is a way to shim these imports outside of the main sveltekit context I haven't found it, so perhaps documentation is needed.

My particular use-case is with Storybook, where UI components that rely on any sveltekit modules break the whole setup. I tried aliasing them with webpack (pointing to .svelte-kit/dev/...) but that didn't work either.

Another use-case is publishing components for sveltekit that would need to rely on those imports.

To Reproduce

  1. Setup storybook with Sveltekit
  2. Create a component that imports a runtime module (eg: $app/env)
  3. Run storybook and see if fail (cannot resolve module $app/env)

Severity Not blocking, but makes building a component library with Storybook or other development/testing frameworks impossible. So, severe annoyance?

madeleineostoja avatar May 18 '21 03:05 madeleineostoja

This sounds loosely related to allowing SvelteKit to build components as suggested in https://github.com/sveltejs/kit/issues/518

benmccann avatar May 18 '21 15:05 benmccann

Yep that would solve the second use case (distributing components/actions that rely on sveltekit), but I don’t think it would address the first? Testing etc components of a sveltekit app outside the main sveltekit dev/build runtime? Storybook being a very common use case

madeleineostoja avatar May 18 '21 22:05 madeleineostoja

TBH I am hitting this issue trying to just do basic testing with uvu and typescript. I have ts files that import $app/env and uvu fails to resolve this using ts-node but I know you guys have set testing as a post 1.0. But any hacks or workarounds would be greatly appreciated. https://github.com/sveltejs/kit/issues/19

alexkornitzer avatar May 19 '21 10:05 alexkornitzer

Are we able to use something like https://github.com/eirslett/storybook-builder-vite to help with this - or is this a problem outside of Vite?

josephspurrier avatar May 19 '21 16:05 josephspurrier

@madeleineostoja for Storybook, in the .storybook/main.js/cjs, can you try using preprocess.replace like this for each of your aliases (https://github.com/sveltejs/svelte-preprocess#replace-values):

const path = require('path');
const sveltePreprocess = require('svelte-preprocess');

module.exports = {
	stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx|svelte)'],
	addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-svelte-csf'],
	svelteOptions: {
		preprocess: [
			sveltePreprocess({
				replace: [['~', path.resolve('./src')]],
			}),
		],
	},
};

josephspurrier avatar May 19 '21 16:05 josephspurrier

Ah, I see the challenge - the modules like $app/env are the problem, not just aliases. My workaround won't help there.

josephspurrier avatar May 19 '21 17:05 josephspurrier

Yeah this isn't to do with your own custom aliases, but the runtime modules of sveltekit. There needs to be a way to consume or shim them in non-sveltekit contexts like testing. Updated issue title to be clearer on the issue

madeleineostoja avatar May 19 '21 23:05 madeleineostoja

I'd like to voice my support for a feature that would allow libraries to neatly get access to the $app/navigation, $app/stores, and $app/env modules.

As the creator and maintainer of SvelteKitAuth we're trying to provide a way for users to augment the SvelteKit session with authentication data using the getSession() hook, and upon changes in the session it would be nice to reset it internally instead of expecting users to do something like signOut().then(session.set), and a similar story for routing. Since signIn() either generates a redirect URL or sends a direct fetch() request depending on the payload provided, currently we're returning the URL and expect users to route themselves with goto() as such:

signIn().then(goto);

Letting libraries handle these things internally means less boilerplate for our users, so getting access to the SvelteKit router in a global module, instead of a scoped one would be useful. This is how other frameworks and libraries such as the Vue Router and React Router handle this, as they make use of the global React instance SvelteKit might have to work around the fact that Svelte doesn't provide such a thing, but create its own global context or a singleton.

Dan6erbond avatar May 31 '21 08:05 Dan6erbond

I've fixed this in storybook by using their new vite-builder and adding manual aliases to sveltekit's $app runtime module

async viteFinal(config) {
    config.resolve.alias = {
        $app: path.resolve('./.svelte-kit/dev/runtime/app')
    }

    return config;
}

With the caveat being that you have to have run sveltekit dev first to generate those runtime modules. I think this is worth documenting (the path to the alias if nothing else) for others that need to shim these modules until Svelte comes up with an official workaround

madeleineostoja avatar Jun 16 '21 05:06 madeleineostoja

Here's an example of someone having to mock this stuff out for testing: https://github.com/rossyman/svelte-add-jest/issues/14#issuecomment-891387235

benmccann avatar Aug 02 '21 23:08 benmccann

Author of comment of the said person who had to mock this stuff. I don't mind mocking - fine as a workaround for unit testing anyway. It wasn't exactly simple to figure out the correct mocks though.

patrickleet avatar Aug 02 '21 23:08 patrickleet

It looks like import.meta.env also might need to be mocked: https://github.com/sveltejs/kit/pull/2210#discussion_r691627049

benmccann avatar Aug 20 '21 17:08 benmccann

Here's the test mock we've been using to handle $app/stores. It doesn't handle things like import.meta.env.

<script>
  import {setContext} from 'svelte';
  import {writable} from 'svelte/store';

  export let Component;

  export let stores = {
    page: writable('/'),
    navigating: writable(null),
    session: writable(null),
  };

  setContext('__svelte__', stores);
</script>

<svelte:component this={Component} {...$$restProps} />

So you just pass it the actual component to be tested and (optionally) the stores you want to use for $app/stores.

EDIT: Added spreading of other props onto the component to be tested.

wallw-teal avatar Aug 20 '21 21:08 wallw-teal

I think there's a couple ways to do test setup thus far:

  • There's the TestHarness.svelte option mentioned above (https://github.com/sveltejs/kit/issues/1485#issuecomment-902965385) along with babel-plugin-transform-vite-meta-env
  • Or do mocking. For ESM, Jest has a brand new, experimental, and undocumented unstable_mockModule

I'm not sure what the tradeoffs are and which is the better approach.

Also, if there's anything SvelteKit can do to make testing easier I'd be happy to support changes there.

Related, there's a request to mock fetch (https://github.com/sveltejs/kit/issues/19#issuecomment-914178415), which I haven't seen anyone do yet.

benmccann avatar Sep 13 '21 21:09 benmccann

As the one who made that request to mock fetch, I thought I'd give an update on what I've found so far:

  1. When testing components with svelte-jester (via https://github.com/rossyman/svelte-add-jest), I'm outside the Svelte-Kit environment, so load() functions are never run, and I can just pass props to the component to provide it with test data. This reduces the need to provide a mock fetch in unit testing scenarios.
  2. When doing E2E tests with Playwright, I can use Playwright's page.route feature to intercept browser requests to specific URLs and return my test data instead of what that URL would have returned (and the URL is never hit). This works only in the browser that Playwright is driving, and does not work server-side. But I can add kit: { ssr: !(process.env.NODE_ENV === 'test') } to my svelte.config.js to turn off server-side rendering, so that load() is only ever run on the client where Playwright can intercept it.
  3. If I want to run E2E tests in an environment as close to production as possible, though, I'd want to run them with server-side rendering turned on. I could, in theory, use externalFetch to rewrite URLs from loaded pages to return test data instead. But that doesn't help me with internal URLs like /api/blogpost/3, where externalFetch is not called.

So without the ability to mock fetch, I can run almost all the test scenarios I need. I can unit-test my components, and I can run E2E tests with SSR turned off and intercept the fetch requests in the E2E browser. The only thing I can't do is run E2E tests with server-side rendering turned on. For that scenario, I believe I would need to be able to mock the fetch function passed into load().

rmunn avatar Sep 15 '21 03:09 rmunn

@rmunn

so load() functions are never run

In at least pre-esm jest@26 and svelte-jester@1, you can mock $app/env.js to set browser to true or false, which will cause the svelte component to server side render or client side render - the load function is called when ssr'd.

Theoretically this should now also be possible in ESM jest@27 and svelte-jester@2, via the new unstable_mockModule mocking method, but I have not tried that yet.

jest.mock('$app/env.js', () => ({
  amp: false,
  browser: false,
  dev: true,
  mode: 'test'
}))

In my tests directory, I find it simplest to have two test files index-client.js and index-server.js, with the browser set to true and false, respectively.

image

Here's an image of a coverage report showing lines of the load function being hit:

image

Maybe I'm missing something about mocking fetch, but you should be able to just mock fetch in the same way. jest.mock('fetch', ...)?

In the same code this is pulled from, my load function is loading graphql over HTTP, however I am using a graphql library for that, not fetch directly, and thus, just mock the graphql library to return the responses I would get in good, bad, and other important cases. Similarly, if you were using axios instead of fetch, you could mock the response of the .get or whatever. You don't want to rely on networks in unit tests as that is outside of the scope of the unit. Things like that are best left for integration tests.

patrickleet avatar Sep 15 '21 13:09 patrickleet

Other sveltekit mocking tips:

import.meta.env

  1. Put all import.meta.env usage in one file, I have been naming that file lib/env.js, so I can reference it via $lib/env.js. This way in the majority of situations you can just mock that one file to set the envs you want to use for the test context:
lib/env.js
export const VITE_HASURA_GRAPHQL_URL = import.meta.env.VITE_HASURA_GRAPHQL_URL
export const VITE_HASURA_GRAPHQL_WS_URL = import.meta.env.VITE_HASURA_GRAPHQL_WS_URL
test.js
// ENV mocks
jest.mock('$lib/env', () => ({
  VITE_HASURA_GRAPHQL_URL:
    'http://fakeendpoint.example.com/v1/graphql',
  VITE_HASURA_GRAPHQL_WS_URL:
    'ws://fakeendpoint.example.com/v1/graphql'
}))

$app/navigation.js

jest.mock('$app/navigation.js', () => ({
  goto: jest.fn()
}))

patrickleet avatar Sep 15 '21 13:09 patrickleet

@patrickleet wrote:

Here's an image of a coverage report showing lines of the load function being hit:

What's the setup you're using for those tests? I've looked at https://github.com/CloudNativeEntrepreneur/sveltekit-eventsourced-funnel, but there you're using render() from @testing-library/svelte, which is the same thing that I'm using that doesn't call load(). (That is, it doesn't call the load() function from the module context the way Svelte-Kit does; it's just using Svelte to compile the component). I haven't yet found an example of running Jest tests in a Svelte-Kit context. Your coverage report shows that that's what you're doing, so I'd be interested to see how you've set that up. Would you be able to share your Jest config and/or a couple of your unit tests that are running Svelte-Kit's load() function, so I have an example of how to do that?

rmunn avatar Sep 16 '21 03:09 rmunn

@rmunn

This is from a different repo for a client, so it's private, unfortunately - it's still on jest@26 / svelte-jester@1, and the "funnel" example was more of an example of event sourcing with svelte than a unit test demonstration - just happened to be public and relevant to some of the recent testing changes.

That said - let me see about pulling out relevant pieces...

Ok - looked ... Looks like I just exported/imported load in the server test.

companies/index-server.js

/**
 * @jest-environment jsdom
 */
import '@testing-library/jest-dom/extend-expect'
import { render } from '@testing-library/svelte'
import companiesIndex, { load } from '$routes/companies/index.svelte'
import debug from 'debug'

const log = debug('tests')

log('starting suite routes/companies/index.svelte')

// Sveltekit Mocks
jest.mock('$app/env.js', () => ({
  amp: false,
  browser: false,
  dev: true,
  mode: 'test'
}))

jest.mock('$app/navigation.js', () => ({
  goto: jest.fn()
}))

// In more recent tests I've started using the "TestHarness" instead of this `svelte` mock with a fake getContext
jest.mock('svelte', () => {
  const { writable } = require('svelte/store')
  const actualSvelte = jest.requireActual('svelte')
  const fakeGetContext = jest.fn((name) => {
    if (name === '__svelte__') {
      return fakeSvelteKitContext
    }
  })
  const fakeSvelteKitContext = {
    page: writable({
      path: '/',
      query: new URLSearchParams({
        offset: 0,
        limit: 5
      })
    }),
    navigating: writable(false)
  }

  const mockedSvelteKit = {
    ...actualSvelte,
    getContext: fakeGetContext
  }
  return mockedSvelteKit
})
// End Sveltekit mocks

// ENV mocks
jest.mock('$lib/env', () => ({
  VITE_PRESIDIO_HASURA_GRAPHQL_URL:
    'http://fakeendpoint.example.com/v1/graphql',
  VITE_PRESIDIO_HASURA_GRAPHQL_INTERNAL_URL:
    'http://fakeendpoint.example.com/v1/graphql',
  VITE_PRESIDIO_HASURA_GRAPHQL_WS_URL:
    'ws://fakeendpoint.example.com/v1/graphql'
}))

// Network mocks
jest.mock('$lib/data/urql', () => ({
  client: {
    query: jest.fn(() => {
      const result = {
        data: {
          companies: [
            {
              id: 'test-1',
              name: 'Test 1',
              logo_url:
                'https://res.cloudinary.com/crunchbase-production/image/upload/v1418896144/nzn3gfio6p8lupehf6nv.jpg',
              __typename: 'companies'
            },
            {
              id: 'test-2',
              name: 'Test 2',
              logo_url:
                'https://res.cloudinary.com/crunchbase-production/image/upload/v1397199104/34852c1debc24e028c4082caa0efb427.jpg',
              __typename: 'companies'
            },
            {
              id: 'test-3',
              name: 'Test 3',
              logo_url:
                'https://res.cloudinary.com/crunchbase-production/image/upload/rswshbdwsa7bg39kjtjk',
              __typename: 'companies'
            }
          ],
          companies_aggregate: {
            aggregate: {
              count: 3
            }
          }
        }
      }

      return {
        toPromise: jest.fn(() => Promise.resolve(result))
      }
    })
  }
}))

// mock store that uses URL query string
jest.mock('$lib/stores/queryStore')

const ctx = {
  page: {
    query: {
      get: jest.fn((key) => {
        const params = {
          limit: 50,
          isClientFilter: true,
          order: 'asc',
          offset: 0
        }

        return params[key]
      })
    }
  }
}

describe('routes/companiesIndex.svelte - server', () => {
  // browser false is default mock

  describe('server side rendering', () => {
    it('should server render empty', async () => {
      const { getByText } = render(companiesIndex)
      expect(getByText('Companies')).toBeInTheDocument()
      expect(getByText('No companies')).toBeInTheDocument()
    })

    it('should server render empty with data but empty companies result', async () => {
      const props = await load(ctx)
      props.companies = []
      const { getByText } = render(companiesIndex, { props })
      expect(getByText('Companies')).toBeInTheDocument()
      expect(getByText('No companies')).toBeInTheDocument()
    })

    it('should server render with data', async () => {
      const { getByText } = render(companiesIndex, await load(ctx))
      expect(getByText('Companies')).toBeInTheDocument()
      expect(getByText('Test 1')).toBeInTheDocument()
    })
  })

  describe('#load', () => {
    it('should query graphql endpoint and return found companies', async () => {
      const { client } = require('$lib/data/urql')
      let result = await load(ctx)
      expect(client.query).toBeCalled()
      expect(result.props.companies.length).toBe(3)
    })
  })
})

And the load function from that component:

  export async function load({ page }) {
    const variables = {
      limit: parseInt(page.query.get('limit'), 10) || defaults.limit,
      isClientFilter: page.query.get('isClientFilter') !== 'false',
      nameFilter: `%${page.query.get('nameFilter') || ''}%`,
      order: page.query.get('order') || 'asc',
      offset: parseInt(page.query.get('offset'), 10) || defaults.offset
    }

    const result = await client.query(QUERY, variables).toPromise()
    const { data } = result
    const { companies } = data

    return {
      props: {
        companies,
        count: data.companies_aggregate.aggregate.count
      }
    }
  }

.babelrc

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "current"
        }
      }
    ]
  ]
}

jest.json

{
  "roots": ["<rootDir>/src", "<rootDir>/__tests__/unit"],
  "testEnvironment": "node",
  "modulePaths": ["<rootDir>/src"],
  "moduleDirectories": ["node_modules"],
  "transform": {
    "^.+\\.svelte$": "svelte-jester",
    "^.+\\.(ts|tsx|js|jsx)$": ["esbuild-jest"]
  },
  "moduleFileExtensions": ["js", "svelte"],
  "moduleNameMapper": {
    "^\\$app(.*)$": "<rootDir>/.svelte-kit/build/runtime/app$1",
    "^\\$lib(.*)$": "<rootDir>/src/lib$1",
    "^\\$routes(.*)$": "<rootDir>/src/routes$1"
  },
  "setupFilesAfterEnv": ["@testing-library/jest-dom/extend-expect"],
  "coverageThreshold": {
    "global": {
      "branches": 0,
      "functions": 0,
      "lines": 0,
      "statements": 0
    }
  },
  "collectCoverageFrom": ["src/**/*.{js,svelte}"],
  "testTimeout": 30000
}

patrickleet avatar Sep 16 '21 13:09 patrickleet

If you don't mind using experimental features of the latest Node.js, along with esmodules/import/export, you can use an esmodule loader hook to mock SvelteKit's $app/navigation-style import aliases.

Rough beginner example link follows. One could mock the module into a no-op, or rewrite it to .svelte-kit/dev/runtime/app for real functionality (after svelte-kit dev has run):

  • https://www.npmjs.com/package/create-esm-loader#2-create-directory-aliases

I've built a loader for .svelte files, which can also do pre-processing, and no-op imports of .css, pre-processed assets, and now SvelteKit's import { goto } from '$app/navigation':

  • https://github.com/brev/esm-loader-svelte

This allows me to simply test my .svelte components in node/es6/esm with uvu, like @alexkornitzer but without typescript.

  • Context: https://github.com/lukeed/uvu/issues/122

These loader hooks do not chain well yet, so it's far from a perfect solution, in general.

EDIT: Big update in my next comment below!

brev avatar Sep 17 '21 05:09 brev

As an update to my previous post, I've made a lot of new progress, details are here:

https://github.com/sveltejs/kit/issues/19#issuecomment-1041134457

Now supporting:

  • Handling import.meta.env, etc.
  • Handling aliases like $lib and $app/*.
  • Mocking in general (fetch, etc.) Also, mocking of Stores before component rendering to provide context to components tested in isolation.

brev avatar Feb 21 '22 22:02 brev

Copy-pastable repro here: https://github.com/sveltejs/kit/issues/4432

Rich-Harris avatar Apr 04 '22 17:04 Rich-Harris

https://github.com/michaelwooley/storybook-experimental-vite demonstrates setting up Storybook with the new vite.config.js. We still need to figure out how to handle $navigation and $stores

benmccann avatar Jun 24 '22 02:06 benmccann

There's a great example of Vitest mocks here: https://github.com/sveltejs/kit/issues/5525#issuecomment-1186390654

benmccann avatar Jul 20 '22 15:07 benmccann

I'm facing similar issue in a sveltekit app. The error message is Uncaught TypeError: Cannot read properties of undefined (reading 'disable_scroll_handling') at navigation-15d5f540.js: which causes 500 status error. I've read through issue https://github.com/sveltejs/kit/issues/4432 as well and it doesn't seem applicable. The issue happens randomly but frequently in production mode and in low speed networks.

Any idea?

prakhar-pal avatar Jul 31 '22 12:07 prakhar-pal

Got this issue as well when I use afterNavigate in a +page while using the adapter static and chunking strategy of vite (splitVendorChunkPlugin).

peterpeterparker avatar Oct 26 '22 11:10 peterpeterparker

Sample repo btw. https://github.com/peterpeterparker/my-app-yolo

peterpeterparker avatar Oct 26 '22 12:10 peterpeterparker

@peterpeterparker your issue sounds different than what this one is talking about. Can you file a new issue for it?

benmccann avatar Oct 28 '22 04:10 benmccann

Sure. I just commented here because #4432 was closed and linked with the issue.

So here you go 👉 https://github.com/sveltejs/kit/issues/7415

peterpeterparker avatar Oct 28 '22 05:10 peterpeterparker

The introduction of $env has made the surface of this issue widen from just $app/*. Without the ability to shim these things unit/component testing becomes increasingly difficult (ref: https://github.com/microsoft/playwright/issues/18465) and, in practice, leads to awkward workarounds in order to properly support testing environments such as Playwright component testing.

vhscom avatar Oct 31 '22 21:10 vhscom