Skip to content

Commit

Permalink
Merge pull request #26853 from storybookjs/kasper/mock-names
Browse files Browse the repository at this point in the history
Next: Use `<<package>>::<<import>>` naming convention for mock names
  • Loading branch information
kasperpeulen committed Apr 19, 2024
2 parents 24683a3 + 8dbd04c commit 32213e8
Show file tree
Hide file tree
Showing 8 changed files with 92 additions and 248 deletions.
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
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
98 changes: 13 additions & 85 deletions code/frameworks/nextjs/src/export-mocks/headers/headers.ts
Original file line number Diff line number Diff line change
@@ -1,101 +1,29 @@
import { fn } from '@storybook/test';
import type { IncomingHttpHeaders } from 'http';
import type { HeadersAdapter } from 'next/dist/server/web/spec-extension/adapters/headers';

// Mostly copied from https://github.com/vercel/next.js/blob/763b9a660433ec5278a10e59d7ae89d4010ba212/packages/next/src/server/web/spec-extension/adapters/headers.ts#L20
// @ts-expect-error unfortunately the headers property is private (and not protected) in HeadersAdapter
// and we can't access it so we need to redefine it, but that clashes with the type, hence the ts-expect-error comment.
class HeadersAdapterMock extends Headers implements HeadersAdapter {
private headers: IncomingHttpHeaders = {};
import { HeadersAdapter } from 'next/dist/server/web/spec-extension/adapters/headers';

/**
* Merges a header value into a string. This stores multiple values as an
* array, so we need to merge them into a string.
*
* @param value a header value
* @returns a merged header value (a string)
*/
private merge(value: string | string[]): string {
if (Array.isArray(value)) return value.join(', ');

return value;
class HeadersAdapterMock extends HeadersAdapter {
constructor() {
super({});
}

public append = fn((name: string, value: string): void => {
const existing = this.headers[name];
if (typeof existing === 'string') {
this.headers[name] = [existing, value];
} else if (Array.isArray(existing)) {
existing.push(value);
} else {
this.headers[name] = value;
}
}).mockName('headers().append');

public delete = fn((name: string) => {
delete this.headers[name];
}).mockName('headers().delete');

public get = fn((name: string): string | null => {
const value = this.headers[name];
if (typeof value !== 'undefined') return this.merge(value);
append = fn(super.append.bind(this)).mockName('next/headers::headers().append');

return null;
}).mockName('headers().get');
delete = fn(super.delete.bind(this)).mockName('next/headers::headers().delete');

public has = fn((name: string): boolean => {
return typeof this.headers[name] !== 'undefined';
}).mockName('headers().has');
get = fn(super.get.bind(this)).mockName('next/headers::headers().get');

public set = fn((name: string, value: string): void => {
this.headers[name] = value;
}).mockName('headers().set');
has = fn(super.has.bind(this)).mockName('next/headers::headers().has');

public forEach = fn(
(callbackfn: (value: string, name: string, parent: Headers) => void, thisArg?: any): void => {
for (const [name, value] of this.entries()) {
callbackfn.call(thisArg, value, name, this);
}
}
).mockName('headers().forEach');
set = fn(super.set.bind(this)).mockName('next/headers::headers().set');

public entries = fn(
function* (this: HeadersAdapterMock): IterableIterator<[string, string]> {
for (const key of Object.keys(this.headers)) {
const name = key.toLowerCase();
// We assert here that this is a string because we got it from the
// Object.keys() call above.
const value = this.get(name) as string;
forEach = fn(super.forEach.bind(this)).mockName('next/headers::headers().forEach');

yield [name, value];
}
}.bind(this)
).mockName('headers().entries');
entries = fn(super.entries.bind(this)).mockName('next/headers::headers().entries');

public keys = fn(
function* (this: HeadersAdapterMock): IterableIterator<string> {
for (const key of Object.keys(this.headers)) {
const name = key.toLowerCase();
yield name;
}
}.bind(this)
).mockName('headers().keys');
keys = fn(super.keys.bind(this)).mockName('next/headers::headers().keys');

public values = fn(
function* (this: HeadersAdapterMock): IterableIterator<string> {
for (const key of Object.keys(this.headers)) {
// We assert here that this is a string because we got it from the
// Object.keys() call above.
const value = this.get(key) as string;

yield value;
}
}.bind(this)
).mockName('headers().values');

public [Symbol.iterator](): IterableIterator<[string, string]> {
return this.entries();
}
values = fn(super.values.bind(this)).mockName('next/headers::headers().values');
}

let headersAdapterMock: HeadersAdapterMock;
Expand Down
65 changes: 29 additions & 36 deletions code/frameworks/nextjs/src/export-mocks/navigation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ let navigationAPI: {
* @ignore
* @internal
* */
const createNavigation = (overrides: any) => {
export const createNavigation = (overrides: any) => {
const navigationActions = {
push: fn().mockName('useRouter().push'),
replace: fn().mockName('useRouter().replace'),
forward: fn().mockName('useRouter().forward'),
back: fn().mockName('useRouter().back'),
prefetch: fn().mockName('useRouter().prefetch'),
refresh: fn().mockName('useRouter().refresh'),
push: fn().mockName('next/navigation::useRouter().push'),
replace: fn().mockName('next/navigation::useRouter().replace'),
forward: fn().mockName('next/navigation::useRouter().forward'),
back: fn().mockName('next/navigation::useRouter().back'),
prefetch: fn().mockName('next/navigation::useRouter().prefetch'),
refresh: fn().mockName('next/navigation::useRouter().refresh'),
};

if (overrides) {
Expand All @@ -42,7 +42,7 @@ const createNavigation = (overrides: any) => {
return navigationAPI;
};

const getRouter = () => {
export const getRouter = () => {
if (!navigationAPI) {
throw new NextjsRouterMocksNotAvailable({
importType: 'next/navigation',
Expand All @@ -56,41 +56,34 @@ const getRouter = () => {
export * from 'next/dist/client/components/navigation';

// mock utilities/overrides (as of Next v14.2.0)
const redirect = fn().mockName('redirect');
export const redirect = fn().mockName('next/navigation::redirect');

// passthrough mocks - keep original implementation but allow for spying
const useSearchParams = fn(originalNavigation.useSearchParams).mockName('useSearchParams');
const usePathname = fn(originalNavigation.usePathname).mockName('usePathname');
const useSelectedLayoutSegment = fn(originalNavigation.useSelectedLayoutSegment).mockName(
'useSelectedLayoutSegment'
export const useSearchParams = fn(originalNavigation.useSearchParams).mockName(
'next/navigation::useSearchParams'
);
const useSelectedLayoutSegments = fn(originalNavigation.useSelectedLayoutSegments).mockName(
'useSelectedLayoutSegments'
export const usePathname = fn(originalNavigation.usePathname).mockName(
'next/navigation::usePathname'
);
const useRouter = fn(originalNavigation.useRouter).mockName('useRouter');
const useServerInsertedHTML = fn(originalNavigation.useServerInsertedHTML).mockName(
'useServerInsertedHTML'
export const useSelectedLayoutSegment = fn(originalNavigation.useSelectedLayoutSegment).mockName(
'next/navigation::useSelectedLayoutSegment'
);
export const useSelectedLayoutSegments = fn(originalNavigation.useSelectedLayoutSegments).mockName(
'next/navigation::useSelectedLayoutSegments'
);
export const useRouter = fn(originalNavigation.useRouter).mockName('next/navigation::useRouter');
export const useServerInsertedHTML = fn(originalNavigation.useServerInsertedHTML).mockName(
'next/navigation::useServerInsertedHTML'
);
export const notFound = fn(originalNavigation.notFound).mockName('next/navigation::notFound');
export const permanentRedirect = fn(originalNavigation.permanentRedirect).mockName(
'next/navigation::permanentRedirect'
);
const notFound = fn(originalNavigation.notFound).mockName('notFound');
const permanentRedirect = fn(originalNavigation.permanentRedirect).mockName('permanentRedirect');

// Params, not exported by Next.js, is manually declared to avoid inference issues.
interface Params {
[key: string]: string | string[];
}
const useParams = fn<[], Params>(originalNavigation.useParams).mockName('useParams');

export {
createNavigation,
getRouter,
redirect,
useSearchParams,
usePathname,
useSelectedLayoutSegment,
useSelectedLayoutSegments,
useParams,
useRouter,
useServerInsertedHTML,
notFound,
permanentRedirect,
};
export const useParams = fn<[], Params>(originalNavigation.useParams).mockName(
'next/navigation::useParams'
);
Loading

0 comments on commit 32213e8

Please sign in to comment.