Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Docs: Module mocking, and more #26858

Merged
merged 48 commits into from May 6, 2024
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
1efccbe
WIP
kylegach Apr 15, 2024
0242371
Split into 3 pages
kylegach Apr 16, 2024
ff3f2e9
Add Modules section to Next.js docs
JReinhold Apr 16, 2024
92e1382
More mocking content
kylegach Apr 16, 2024
75b0820
Updates for Next.js
kylegach Apr 16, 2024
299a2c2
Simplify Building pages with Storybook guide
kylegach Apr 16, 2024
ec6742b
Update Decorators and Interaction tests pages
kylegach Apr 17, 2024
abc9b7c
Add missing TODOs
kylegach Apr 17, 2024
194a9bc
Add vite example
kylegach Apr 17, 2024
f2f4d13
clarify mock->original import limitations.
JReinhold Apr 17, 2024
e7c0676
add paragraph on mocking external modules.
JReinhold Apr 17, 2024
5d8ee28
add docs on `beforeEach`, `cleanup`, `parameters.test`
JReinhold Apr 17, 2024
4faf1ae
simplify subpath example in nextjs
JReinhold Apr 17, 2024
2ac03c4
Address feedback
kylegach Apr 18, 2024
b1da9c9
add getPackageAliases example
JReinhold Apr 18, 2024
71a4cea
add examples to next mock docs
JReinhold Apr 18, 2024
5bab73a
improve documentation for parameters.test.dangerouslyIgnoreUnhandledE…
JReinhold Apr 18, 2024
46c31eb
remove docs on default router and navigation contexts
JReinhold Apr 18, 2024
eef3290
add reasons for aliases in nextjs+jest
JReinhold Apr 18, 2024
f88bea5
Add back nextjs router/navigation override instructions
kylegach Apr 19, 2024
3f7e44c
Improve MSW snippets
kylegach Apr 19, 2024
8eaeb94
Address MSW snippets feedback
kylegach Apr 19, 2024
f4914e3
Prettify snippets
kylegach Apr 19, 2024
cf2be73
Add new actions to the bottom of the panel and auto scroll
kasperpeulen Apr 17, 2024
42773e3
Address review
kasperpeulen Apr 17, 2024
e850d36
add cache.mock entrypoint to nextjs
JReinhold Apr 17, 2024
401fd26
Try another mock naming convention
kasperpeulen Apr 16, 2024
9920353
Bind this
kasperpeulen Apr 16, 2024
b55673f
Update snapshot
kasperpeulen Apr 16, 2024
a033167
Fix name
kasperpeulen Apr 16, 2024
537fbb2
Hide some junk of next for nwo
kasperpeulen Apr 16, 2024
7cacdfa
Update code/frameworks/nextjs/src/export-mocks/navigation/index.ts
kasperpeulen Apr 16, 2024
af31a1f
Update code/frameworks/nextjs/src/export-mocks/navigation/index.ts
kasperpeulen Apr 16, 2024
789991f
Update code/frameworks/nextjs/src/export-mocks/navigation/index.ts
kasperpeulen Apr 16, 2024
17c5152
Update code/frameworks/nextjs/src/export-mocks/navigation/index.ts
kasperpeulen Apr 16, 2024
fc7e664
Hide some junk of next for now
kasperpeulen Apr 16, 2024
5fafb46
Show redirect and next/cache spies
kasperpeulen Apr 16, 2024
a9ce1d4
Apply suggestions from code review
kylegach Apr 30, 2024
01c6509
Address feedback
kylegach Apr 30, 2024
39689ac
Merge branch 'kasper/module-mocking' into module-mocking-docs
kylegach Apr 30, 2024
1dca308
Snippetize examples
kylegach May 1, 2024
6bf05a1
Remove unnecessary snippets
kylegach May 1, 2024
c715248
Prettify snippets
kylegach May 1, 2024
7be08e3
Address feedback
kylegach May 1, 2024
a9d9bfa
Update decorator snippets
kylegach May 3, 2024
d2f7fe8
Address feedback
kylegach May 3, 2024
dfa82ab
Address feedback
kylegach May 6, 2024
2a0d245
Merge branch 'next' into module-mocking-docs
kylegach May 6, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
75 changes: 43 additions & 32 deletions code/addons/actions/src/components/ActionLogger/index.tsx
@@ -1,22 +1,23 @@
import type { FC, PropsWithChildren } from 'react';
import React, { Fragment } from 'react';
import { styled, withTheme } from '@storybook/theming';
import type { ElementRef, ReactNode } from 'react';
import React, { forwardRef, Fragment, useEffect, useRef } from 'react';
import type { Theme } from '@storybook/theming';
import { styled, withTheme } from '@storybook/theming';

import { Inspector } from 'react-inspector';
import { ActionBar, ScrollArea } from '@storybook/components';

import { Action, InspectorContainer, Counter } from './style';
import { Action, Counter, InspectorContainer } from './style';
import type { ActionDisplay } from '../../models';

const UnstyledWrapped: FC<PropsWithChildren<{ className?: string }>> = ({
children,
className,
}) => (
<ScrollArea horizontal vertical className={className}>
{children}
</ScrollArea>
const UnstyledWrapped = forwardRef<HTMLDivElement, { children: ReactNode; className?: string }>(
({ children, className }, ref) => (
<ScrollArea ref={ref} horizontal vertical className={className}>
{children}
</ScrollArea>
)
);
UnstyledWrapped.displayName = 'UnstyledWrapped';

export const Wrapper = styled(UnstyledWrapped)({
margin: 0,
padding: '10px 5px 20px',
Expand All @@ -39,24 +40,34 @@ interface ActionLoggerProps {
onClear: () => void;
}

export const ActionLogger = ({ actions, onClear }: ActionLoggerProps) => (
<Fragment>
<Wrapper>
{actions.map((action: ActionDisplay) => (
<Action key={action.id}>
{action.count > 1 && <Counter>{action.count}</Counter>}
<InspectorContainer>
<ThemedInspector
sortObjectKeys
showNonenumerable={false}
name={action.data.name}
data={action.data.args || action.data}
/>
</InspectorContainer>
</Action>
))}
</Wrapper>

<ActionBar actionItems={[{ title: 'Clear', onClick: onClear }]} />
</Fragment>
);
export const ActionLogger = ({ actions, onClear }: ActionLoggerProps) => {
const wrapperRef = useRef<ElementRef<typeof Wrapper>>(null);
const wrapper = wrapperRef.current;
const wasAtBottom = wrapper && wrapper.scrollHeight - wrapper.scrollTop === wrapper.clientHeight;

useEffect(() => {
// Scroll to bottom, when the action panel was already scrolled down
if (wasAtBottom) wrapperRef.current.scrollTop = wrapperRef.current.scrollHeight;
}, [wasAtBottom, actions.length]);

return (
<Fragment>
<Wrapper ref={wrapperRef}>
{actions.map((action: ActionDisplay) => (
<Action key={action.id}>
{action.count > 1 && <Counter>{action.count}</Counter>}
<InspectorContainer>
<ThemedInspector
sortObjectKeys
showNonenumerable={false}
name={action.data.name}
data={action.data.args || action.data}
/>
</InspectorContainer>
</Action>
))}
</Wrapper>
<ActionBar actionItems={[{ title: 'Clear', onClick: onClear }]} />
</Fragment>
);
};
4 changes: 2 additions & 2 deletions code/addons/actions/src/containers/ActionLogger/index.tsx
Expand Up @@ -63,12 +63,12 @@ export default class ActionLogger extends Component<ActionLoggerProps, ActionLog
addAction = (action: ActionDisplay) => {
this.setState((prevState: ActionLoggerState) => {
const actions = [...prevState.actions];
const previous = actions.length && actions[0];
const previous = actions.length && actions[actions.length - 1];
if (previous && safeDeepEqual(previous.data, action.data)) {
previous.count++;
} else {
action.count = 1;
actions.unshift(action);
actions.push(action);
}
return { actions: actions.slice(0, action.options.limit) };
});
Expand Down
21 changes: 20 additions & 1 deletion code/addons/actions/src/loaders.ts
Expand Up @@ -18,7 +18,26 @@ const logActionsWhenMockCalled: LoaderFunction = (context) => {
typeof global.__STORYBOOK_TEST_ON_MOCK_CALL__ === 'function'
) {
const onMockCall = global.__STORYBOOK_TEST_ON_MOCK_CALL__ as typeof onMockCallType;
onMockCall((mock, args) => action(mock.getMockName())(args));
onMockCall((mock, args) => {
const name = mock.getMockName();

// TODO: Make this a configurable API in 8.2
if (
!/^next\/.*::/.test(name) ||
[
'next/router::useRouter()',
'next/navigation::useRouter()',
'next/navigation::redirect',
'next/cache::',
'next/headers::cookies().set',
'next/headers::cookies().delete',
'next/headers::headers().set',
'next/headers::headers().delete',
].some((prefix) => name.startsWith(prefix))
) {
action(name)(args);
}
});
subscribed = true;
}
};
Expand Down
15 changes: 10 additions & 5 deletions code/frameworks/nextjs/package.json
Expand Up @@ -52,21 +52,26 @@
"require": "./dist/export-mocks/index.js",
"import": "./dist/export-mocks/index.mjs"
},
"./cache.mock": {
"types": "./dist/export-mocks/cache/index.d.ts",
"require": "./dist/export-mocks/cache/index.js",
"import": "./dist/export-mocks/cache/index.mjs"
},
"./headers.mock": {
"types": "./dist/export-mocks/headers/index.d.ts",
"require": "./dist/export-mocks/headers/index.js",
"import": "./dist/export-mocks/headers/index.mjs"
},
"./router.mock": {
"types": "./dist/export-mocks/router/index.d.ts",
"require": "./dist/export-mocks/router/index.js",
"import": "./dist/export-mocks/router/index.mjs"
},
"./navigation.mock": {
"types": "./dist/export-mocks/navigation/index.d.ts",
"require": "./dist/export-mocks/navigation/index.js",
"import": "./dist/export-mocks/navigation/index.mjs"
},
"./router.mock": {
"types": "./dist/export-mocks/router/index.d.ts",
"require": "./dist/export-mocks/router/index.js",
"import": "./dist/export-mocks/router/index.mjs"
},
"./package.json": "./package.json"
},
"main": "dist/index.js",
Expand Down
4 changes: 2 additions & 2 deletions code/frameworks/nextjs/src/export-mocks/cache/index.ts
Expand Up @@ -3,8 +3,8 @@ import { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cac
import { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store';

// mock utilities/overrides (as of Next v14.2.0)
const revalidatePath = fn().mockName('revalidatePath');
const revalidateTag = fn().mockName('revalidateTag');
const revalidatePath = fn().mockName('next/cache::revalidatePath');
const revalidateTag = fn().mockName('next/cache::revalidateTag');

const cacheExports = {
unstable_cache,
Expand Down
112 changes: 9 additions & 103 deletions code/frameworks/nextjs/src/export-mocks/headers/cookies.ts
@@ -1,116 +1,22 @@
/* eslint-disable no-underscore-dangle */
import { fn } from '@storybook/test';
import type { RequestCookies } from 'next/dist/compiled/@edge-runtime/cookies';
import {
parseCookie,
stringifyCookie,
type RequestCookie,
} from 'next/dist/compiled/@edge-runtime/cookies';
import { RequestCookies } from 'next/dist/compiled/@edge-runtime/cookies';
// We need this import to be a singleton, and because it's used in multiple entrypoints
// both in ESM and CJS, importing it via the package name instead of having a local import
// is the only way to achieve it actually being a singleton
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore we must ignore types here as during compilation they are not generated yet
import { headers, type HeadersStore } from '@storybook/nextjs/headers.mock';
import { headers } from '@storybook/nextjs/headers.mock';

const stringifyCookies = (map: Map<string, RequestCookie>) => {
return Array.from(map)
.map(([_, v]) => stringifyCookie(v).replace(/; /, ''))
.join('; ');
};

// Mostly copied from https://github.com/vercel/edge-runtime/blob/c25e2ded39104e2a3be82efc08baf8dc8fb436b3/packages/cookies/src/request-cookies.ts#L7
class RequestCookiesMock implements RequestCookies {
/** @internal */
private readonly _headers: HeadersStore;

_parsed: Map<string, RequestCookie> = new Map();

constructor(requestHeaders: HeadersStore) {
this._headers = requestHeaders;
const header = requestHeaders?.get('cookie');
if (header) {
const parsed = parseCookie(header);
for (const [name, value] of parsed) {
this._parsed.set(name, { name, value });
}
}
}

[Symbol.iterator]() {
return this._parsed[Symbol.iterator]();
}

get size(): number {
return this._parsed.size;
}
class RequestCookiesMock extends RequestCookies {
get = fn(super.get.bind(this)).mockName('next/headers::cookies().get');

get = fn((...args: [name: string] | [RequestCookie]) => {
const name = typeof args[0] === 'string' ? args[0] : args[0].name;
return this._parsed.get(name);
}).mockName('cookies().get');
getAll = fn(super.getAll.bind(this)).mockName('next/headers::cookies().getAll');

getAll = fn((...args: [name: string] | [RequestCookie] | []) => {
const all = Array.from(this._parsed);
if (!args.length) {
return all.map(([_, value]) => value);
}
has = fn(super.has.bind(this)).mockName('next/headers::cookies().has');

const name = typeof args[0] === 'string' ? args[0] : args[0]?.name;
return all.filter(([n]) => n === name).map(([_, value]) => value);
}).mockName('cookies().getAll');
set = fn(super.set.bind(this)).mockName('next/headers::cookies().set');

has = fn((name: string) => {
return this._parsed.has(name);
}).mockName('cookies().has');

set = fn((...args: [key: string, value: string] | [options: RequestCookie]): this => {
const [name, value] = args.length === 1 ? [args[0].name, args[0].value] : args;

const map = this._parsed;
map.set(name, { name, value });

this._headers.set('cookie', stringifyCookies(map));
return this;
}).mockName('cookies().set');

/**
* Delete the cookies matching the passed name or names in the request.
*/
delete = fn(
(
/** Name or names of the cookies to be deleted */
names: string | string[]
): boolean | boolean[] => {
const map = this._parsed;
const result = !Array.isArray(names)
? map.delete(names)
: names.map((name) => map.delete(name));
this._headers.set('cookie', stringifyCookies(map));
return result;
}
).mockName('cookies().delete');

/**
* Delete all the cookies in the cookies in the request.
*/
clear = fn((): this => {
this.delete(Array.from(this._parsed.keys()));
return this;
}).mockName('cookies().clear');

/**
* Format the cookies in the request as a string for logging
*/
[Symbol.for('edge-runtime.inspect.custom')]() {
return `RequestCookies ${JSON.stringify(Object.fromEntries(this._parsed))}`;
}

toString() {
return [...this._parsed.values()]
.map((v) => `${v.name}=${encodeURIComponent(v.value)}`)
.join('; ');
}
delete = fn(super.delete.bind(this)).mockName('next/headers::cookies().delete');
}

let requestCookiesMock: RequestCookiesMock;
Expand All @@ -120,7 +26,7 @@ export const cookies = fn(() => {
requestCookiesMock = new RequestCookiesMock(headers());
}
return requestCookiesMock;
});
}).mockName('next/headers::cookies()');

const originalRestore = cookies.mockRestore.bind(null);

Expand Down