Skip to content

Commit

Permalink
Merge pull request #24795 from paoloricciuti/next
Browse files Browse the repository at this point in the history
SvelteKit: Add experimental page and navigation mocking
  • Loading branch information
JReinhold committed Nov 22, 2023
2 parents d680a4b + 62e7fbb commit e0509fe
Show file tree
Hide file tree
Showing 29 changed files with 1,061 additions and 18 deletions.
44 changes: 37 additions & 7 deletions code/e2e-tests/framework-svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
/* eslint-disable jest/no-disabled-tests */
import { test, expect } from '@playwright/test';
import process from 'process';
import dedent from 'ts-dedent';
import { SbPage } from './util';

const storybookUrl = process.env.STORYBOOK_URL || 'http://localhost:6006';
const templateName = process.env.STORYBOOK_TEMPLATE_NAME;

test.beforeEach(async ({ page }) => {
await page.goto(storybookUrl);
await new SbPage(page).waitUntilLoaded();
});

test.describe('Svelte', () => {
test.skip(
// eslint-disable-next-line jest/valid-title
!templateName?.includes('svelte'),
'Only run this test on Svelte'
);

test.beforeEach(async ({ page }) => {
await page.goto(storybookUrl);
await new SbPage(page).waitUntilLoaded();
});

test('JS story has auto-generated args table', async ({ page }) => {
const sbPage = new SbPage(page);

Expand Down Expand Up @@ -55,10 +54,41 @@ test.describe('Svelte', () => {
const sbPage = new SbPage(page);
const lines: string[] = [];
page.on('console', (msg) => {
lines.push(msg.text());
const text = msg.text();
if (text === 'decorator called') {
lines.push(text);
}
});

await sbPage.navigateToStory('stories/renderers/svelte/decorators-runs-once', 'default');
expect(lines).toHaveLength(1);
});
});

test.describe('SvelteKit', () => {
test.skip(
// eslint-disable-next-line jest/valid-title
!templateName?.includes('svelte-kit'),
'Only run this test on SvelteKit'
);

test('Links are logged in Actions panel', async ({ page }) => {
const sbPage = new SbPage(page);

await sbPage.navigateToStory('stories/sveltekit/modules/hrefs', 'default-actions');
const root = sbPage.previewRoot();
const link = root.locator('a', { hasText: 'Link to /basic-href' });
await link.click();

await sbPage.viewAddonPanel('Actions');
const basicLogItem = await page.locator('#storybook-panel-root #panel-tab-content', {
hasText: `/basic-href`,
});

await expect(basicLogItem).toBeVisible();
const complexLogItem = await page.locator('#storybook-panel-root #panel-tab-content', {
hasText: `/deep/nested`,
});
await expect(complexLogItem).toBeVisible();
});
});
80 changes: 77 additions & 3 deletions code/frameworks/sveltekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Check out our [Frameworks API](https://storybook.js.org/blog/framework-api/) ann
- [In a project with Storybook](#in-a-project-with-storybook)
- [Automatic migration](#automatic-migration)
- [Manual migration](#manual-migration)
- [How to mock](#how-to-mock)
- [Mocking links](#mocking-links)
- [Troubleshooting](#troubleshooting)
- [Error: `ERR! SyntaxError: Identifier '__esbuild_register_import_meta_url__' has already been declared` when starting Storybook](#error-err-syntaxerror-identifier-__esbuild_register_import_meta_url__-has-already-been-declared-when-starting-storybook)
- [Error: `Cannot read properties of undefined (reading 'disable_scroll_handling')` in preview](#error-cannot-read-properties-of-undefined-reading-disable_scroll_handling-in-preview)
Expand All @@ -26,10 +28,10 @@ However SvelteKit has some [Kit-specific modules](https://kit.svelte.dev/docs/mo
| **Module** | **Status** | **Note** |
| ---------------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| [`$app/environment`](https://kit.svelte.dev/docs/modules#$app-environment) | ✅ Supported | `version` is always empty in Storybook. |
| [`$app/forms`](https://kit.svelte.dev/docs/modules#$app-forms) | ⏳ Future | Will use mocks. Tracked in [#20999](https://github.com/storybookjs/storybook/issues/20999) |
| [`$app/navigation`](https://kit.svelte.dev/docs/modules#$app-navigation) | ⏳ Future | Will use mocks. Tracked in [#20999](https://github.com/storybookjs/storybook/issues/20999) |
| [`$app/forms`](https://kit.svelte.dev/docs/modules#$app-forms) | ✅ Supported | See [How to mock](#how-to-mock) |
| [`$app/navigation`](https://kit.svelte.dev/docs/modules#$app-navigation) | ✅ Supported | See [How to mock](#how-to-mock) |
| [`$app/paths`](https://kit.svelte.dev/docs/modules#$app-paths) | ✅ Supported | Requires SvelteKit 1.4.0 or newer |
| [`$app/stores`](https://kit.svelte.dev/docs/modules#$app-stores) | ✅ Supported | Mocks planned, so you can set different store values per story. |
| [`$app/stores`](https://kit.svelte.dev/docs/modules#$app-stores) | ✅ Supported | See [How to mock](#how-to-mock) |
| [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private) | ⛔ Not supported | They are meant to only be available server-side, and Storybook renders all components on the client. |
| [`$env/dynamic/public`](https://kit.svelte.dev/docs/modules#$env-dynamic-public) | 🚧 Partially supported | Only supported in development mode. Storybook is built as a static app with no server-side API so cannot dynamically serve content. |
| [`$env/static/private`](https://kit.svelte.dev/docs/modules#$env-static-private) | ⛔ Not supported | They are meant to only be available server-side, and Storybook renders all components on the client. |
Expand Down Expand Up @@ -100,6 +102,77 @@ yarn remove storybook-builder-vite
yarn remove @storybook/builder-vite
```

## How to mock

To mock a SvelteKit import you can set it on `parameters.sveltekit_experimental`:

```ts
export const MyStory = {
parameters: {
sveltekit_experimental: {
stores: {
page: {
data: {
test: 'passed',
},
},
navigating: {
route: {
id: '/storybook',
},
},
updated: true,
},
},
},
};
```

You can add the name of the module you want to mock to `parameters.sveltekit_experimental` (in the example above we are mocking the `stores` module which correspond to `$app/stores`) and then pass the following kind of objects:

| Module | Path in parameters | Kind of objects |
| ------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| `import { page } from "$app/stores"` | `parameters.sveltekit_experimental.stores.page` | A Partial of the page store |
| `import { navigating } from "$app/stores"` | `parameters.sveltekit_experimental.stores.navigating` | A Partial of the navigating store |
| `import { updated } from "$app/stores"` | `parameters.sveltekit_experimental.stores.updated` | A boolean representing the value of updated (you can also access `check()` which will be a noop) |
| `import { goto } from "$app/navigation"` | `parameters.sveltekit_experimental.navigation.goto` | A callback that will be called whenever goto is called |
| `import { invalidate } from "$app/navigation"` | `parameters.sveltekit_experimental.navigation.invalidate` | A callback that will be called whenever invalidate is called |
| `import { invalidateAll } from "$app/navigation"` | `parameters.sveltekit_experimental.navigation.invalidateAll` | A callback that will be called whenever invalidateAll is called |
| `import { afterNavigate } from "$app/navigation"` | `parameters.sveltekit_experimental.navigation.afterNavigate` | An object that will be passed to the afterNavigate function (which will be invoked onMount) called |
| `import { enhance } from "$app/forms"` | `parameters.sveltekit_experimental.forms.enhance` | A callback that will called when a form with `use:enhance` is submitted |

All the other functions are still exported as `noop` from the mocked modules so that your application will still work.

### Mocking links

The default link-handling behavior (ie. clicking an `<a />` tag with an `href` attribute) is to log an action to the Actions panel.

You can override this by setting an object on `parameter.sveltekit_experimental.hrefs`, where the keys are strings representing an href and the values are objects typed as `{ callback: (href, event) => void, asRegex?: boolean }`.

If you have an `<a />` tag inside your code with the `href` attribute that matches one or more of the links defined (treated as regex based on the `asRegex` property) the corresponding `callback` will be called.

Example:

```ts
export const MyStory = {
parameters: {
sveltekit_experimental: {
hrefs: {
'/basic-href': (to, event) => {
console.log(to, event);
},
'/root.*': {
callback: (to, event) => {
console.log(to, event);
},
asRegex: true,
},
},
},
},
};
```

## Troubleshooting

### Error: `ERR! SyntaxError: Identifier '__esbuild_register_import_meta_url__' has already been declared` when starting Storybook
Expand All @@ -125,3 +198,4 @@ You'll experience this if anything in your story is importing from `$app/forms`
## Acknowledgements

Integrating with SvelteKit would not have been possible if it weren't for the fantastic efforts by the Svelte core team - especially [Ben McCann](https://twitter.com/benjaminmccann) - to make integrations with the wider ecosystem possible.
A big thank you also goes out to [Paolo Ricciuti](https://twitter.com/PaoloRicciuti) for improving the mocking capabilities.
7 changes: 6 additions & 1 deletion code/frameworks/sveltekit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
"./dist/preview.mjs": {
"import": "./dist/preview.mjs"
},
"./preset": {
"types": "./dist/preset.d.ts",
"require": "./dist/preset.js"
Expand All @@ -43,13 +46,14 @@
"README.md",
"*.js",
"*.d.ts",
"!src/**/*"
"src/mocks/**/*"
],
"scripts": {
"check": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/check.ts",
"prep": "node --loader ../../../scripts/node_modules/esbuild-register/loader.js -r ../../../scripts/node_modules/esbuild-register/register.js ../../../scripts/prepare/bundle.ts"
},
"dependencies": {
"@storybook/addon-actions": "workspace:*",
"@storybook/builder-vite": "workspace:*",
"@storybook/svelte": "workspace:*",
"@storybook/svelte-vite": "workspace:*"
Expand All @@ -72,6 +76,7 @@
"bundler": {
"entries": [
"./src/index.ts",
"./src/preview.ts",
"./src/preset.ts"
],
"platform": "node"
Expand Down
17 changes: 17 additions & 0 deletions code/frameworks/sveltekit/src/mocks/app/forms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export function enhance(form: HTMLFormElement) {
const listener = (e: Event) => {
e.preventDefault();
const event = new CustomEvent('storybook:enhance');
window.dispatchEvent(event);
};
form.addEventListener('submit', listener);
return {
destroy() {
form.removeEventListener('submit', listener);
},
};
}

export function applyAction() {}

export function deserialize() {}
43 changes: 43 additions & 0 deletions code/frameworks/sveltekit/src/mocks/app/navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { getContext, onMount, setContext } from 'svelte';

export async function goto(...args: any[]) {
const event = new CustomEvent('storybook:goto', {
detail: args,
});
window.dispatchEvent(event);
}

export function setAfterNavigateArgument(afterNavigateArgs: any) {
setContext('after-navigate-args', afterNavigateArgs);
}

export function afterNavigate(cb: any) {
const argument = getContext('after-navigate-args');
onMount(() => {
if (cb && cb instanceof Function) {
cb(argument);
}
});
}

export function onNavigate() {}

export function beforeNavigate() {}

export function disableScrollHandling() {}

export async function invalidate(...args: any[]) {
const event = new CustomEvent('storybook:invalidate', {
detail: args,
});
window.dispatchEvent(event);
}

export async function invalidateAll() {
const event = new CustomEvent('storybook:invalidateAll');
window.dispatchEvent(event);
}

export function preloadCode() {}

export function preloadData() {}
32 changes: 32 additions & 0 deletions code/frameworks/sveltekit/src/mocks/app/stores.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { getContext, setContext } from 'svelte';

function createMockedStore(contextName: string) {
return [
{
subscribe(runner: any) {
const page = getContext(contextName);
runner(page);
return () => {};
},
},
(value: unknown) => {
setContext(contextName, value);
},
] as const;
}

export const [page, setPage] = createMockedStore('page-ctx');
export const [navigating, setNavigating] = createMockedStore('navigating-ctx');
const [updated, setUpdated] = createMockedStore('updated-ctx');

(updated as any).check = () => {};

export { updated, setUpdated };

export function getStores() {
return {
page,
navigating,
updated,
};
}
2 changes: 1 addition & 1 deletion code/frameworks/sveltekit/src/plugins/config-overrides.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Plugin } from 'vite';
import { type Plugin } from 'vite';

export function configOverrides() {
return {
Expand Down
17 changes: 17 additions & 0 deletions code/frameworks/sveltekit/src/plugins/mock-sveltekit-stores.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { resolve } from 'node:path';
import { mergeConfig, type Plugin } from 'vite';

export function mockSveltekitStores() {
return {
name: 'storybook:sveltekit-mock-stores',
enforce: 'post',
config: (config) =>
mergeConfig(config, {
resolve: {
alias: {
$app: resolve(__dirname, '../src/mocks/app/'),
},
},
}),
} satisfies Plugin;
}
9 changes: 8 additions & 1 deletion code/frameworks/sveltekit/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { PresetProperty } from '@storybook/types';
import { withoutVitePlugins } from '@storybook/builder-vite';
import { dirname, join } from 'path';
import { configOverrides } from './plugins/config-overrides';
import { mockSveltekitStores } from './plugins/mock-sveltekit-stores';
import { type StorybookConfig } from './types';

const getAbsolutePath = <I extends string>(input: I): I =>
Expand All @@ -13,6 +14,10 @@ export const core: PresetProperty<'core', StorybookConfig> = {
builder: getAbsolutePath('@storybook/builder-vite'),
renderer: getAbsolutePath('@storybook/svelte'),
};
export const previewAnnotations: StorybookConfig['previewAnnotations'] = (entry = []) => [
...entry,
join(dirname(require.resolve('@storybook/sveltekit/package.json')), 'dist/preview.mjs'),
];

export const viteFinal: NonNullable<StorybookConfig['viteFinal']> = async (config, options) => {
const baseConfig = await svelteViteFinal(config, options);
Expand All @@ -25,7 +30,9 @@ export const viteFinal: NonNullable<StorybookConfig['viteFinal']> = async (confi
'vite-plugin-sveltekit-compile',
'vite-plugin-sveltekit-guard',
])
).concat(configOverrides());
)
.concat(configOverrides())
.concat(mockSveltekitStores());

return { ...baseConfig, plugins };
};

0 comments on commit e0509fe

Please sign in to comment.