Skip to content

Commit

Permalink
Merge branch 'kasper/module-mocking' into module-mocking-docs
Browse files Browse the repository at this point in the history
  • Loading branch information
kylegach authored Apr 19, 2024
2 parents f4914e3 + 32213e8 commit d12b61a
Show file tree
Hide file tree
Showing 12 changed files with 179 additions and 320 deletions.
75 changes: 43 additions & 32 deletions code/addons/actions/src/components/ActionLogger/index.tsx
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit d12b61a

Please sign in to comment.