Skip to content

Commit

Permalink
Merge remote-tracking branch 'refs/remotes/origin/kasper/module-mocki…
Browse files Browse the repository at this point in the history
…ng' into kasper/before-each

# Conflicts:
#	code/lib/preview-api/src/modules/preview-web/render/StoryRender.ts
  • Loading branch information
kasperpeulen committed Apr 11, 2024
2 parents df6c1ad + b69cfdb commit fb121c8
Show file tree
Hide file tree
Showing 35 changed files with 1,536 additions and 376 deletions.
4 changes: 2 additions & 2 deletions code/e2e-tests/framework-nextjs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ test.describe('Next.js', () => {

await sbPage.viewAddonPanel('Actions');
const logItem = await page.locator('#storybook-panel-root #panel-tab-content', {
hasText: `nextNavigation.${action}`,
hasText: `useRouter().${action}`,
});
await expect(logItem).toBeVisible();
});
Expand Down Expand Up @@ -91,7 +91,7 @@ test.describe('Next.js', () => {

await sbPage.viewAddonPanel('Actions');
const logItem = await page.locator('#storybook-panel-root #panel-tab-content', {
hasText: `nextRouter.${action}`,
hasText: `useRouter().${action}`,
});
await expect(logItem).toBeVisible();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { SbPage } from './util';
const storybookUrl = process.env.STORYBOOK_URL || 'http://localhost:8001';
const templateName = process.env.STORYBOOK_TEMPLATE_NAME || '';

test.describe('preview-web', () => {
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test.describe('preview-api', () => {
test.beforeEach(async ({ page }) => {
await page.goto(storybookUrl);

Expand Down Expand Up @@ -50,4 +52,40 @@ test.describe('preview-web', () => {
await sbPage.previewRoot().getByRole('button').getByText('Submit').first().press('Alt+s');
await expect(sbPage.page.locator('.sidebar-container')).not.toBeVisible();
});

// if rerenders were interleaved the button would have rendered "Error: Interleaved loaders. Changed arg"
test('should only render once at a time when rapidly changing args', async ({ page }) => {
const sbPage = new SbPage(page);
await sbPage.navigateToStory('lib/preview-api/rendering', 'slow-loader');

const root = sbPage.previewRoot();

const labelControl = await sbPage.page.locator('#control-label');

await expect(root.getByText('Loaded. Click me')).toBeVisible();
await expect(labelControl).toBeVisible();

await labelControl.fill('');
await labelControl.type('Changed arg', { delay: 50 });
await labelControl.blur();

await expect(root.getByText('Loaded. Changed arg')).toBeVisible();
});

test('should reload plage when remounting while loading', async ({ page }) => {
const sbPage = new SbPage(page);
await sbPage.navigateToStory('lib/preview-api/rendering', 'slow-loader');

const root = sbPage.previewRoot();

await expect(root.getByText('Loaded. Click me')).toBeVisible();

await sbPage.page.getByRole('button', { name: 'Remount component' }).click();
await wait(200);
await sbPage.page.getByRole('button', { name: 'Remount component' }).click();

// the loading spinner indicates the iframe is being fully reloaded
await expect(sbPage.previewIframe().locator('.sb-preparing-story > .sb-loader')).toBeVisible();
await expect(root.getByText('Loaded. Click me')).toBeVisible();
});
});
29 changes: 28 additions & 1 deletion code/frameworks/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@
"require": "./dist/next-image-loader-stub.js",
"import": "./dist/next-image-loader-stub.mjs"
},
"./headers.mock": {
"types": "./dist/headers/index.d.ts",
"require": "./dist/headers/index.js",
"import": "./dist/headers/index.mjs"
},
"./router.mock": {
"types": "./dist/routing/router/index.d.ts",
"require": "./dist/routing/router/index.js",
"import": "./dist/routing/router/index.mjs"
},
"./navigation.mock": {
"types": "./dist/routing/navigation/index.d.ts",
"require": "./dist/routing/navigation/index.js",
"import": "./dist/routing/navigation/index.mjs"
},
"./package.json": "./package.json"
},
"main": "dist/index.js",
Expand All @@ -59,6 +74,15 @@
],
"dist/image-context": [
"dist/image-context.d.ts"
],
"headers.mock": [
"dist/headers/index.d.ts"
],
"router.mock": [
"dist/routing/router/index.d.ts"
],
"navigation.mock": [
"dist/routing/navigation/index.d.ts"
]
}
},
Expand Down Expand Up @@ -89,14 +113,14 @@
"@babel/preset-typescript": "^7.23.2",
"@babel/runtime": "^7.23.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@storybook/addon-actions": "workspace:*",
"@storybook/builder-webpack5": "workspace:*",
"@storybook/core-common": "workspace:*",
"@storybook/core-events": "workspace:*",
"@storybook/node-logger": "workspace:*",
"@storybook/preset-react-webpack": "workspace:*",
"@storybook/preview-api": "workspace:*",
"@storybook/react": "workspace:*",
"@storybook/test": "workspace:*",
"@storybook/types": "workspace:*",
"@types/node": "^18.0.0",
"@types/semver": "^7.3.4",
Expand Down Expand Up @@ -157,7 +181,10 @@
"./src/image-context.ts",
"./src/index.ts",
"./src/preset.ts",
"./src/headers/index.ts",
"./src/preview.tsx",
"./src/routing/router/index.ts",
"./src/routing/navigation/index.ts",
"./src/next-image-loader-stub.ts",
"./src/images/decorator.tsx",
"./src/images/next-legacy-image.tsx",
Expand Down
132 changes: 132 additions & 0 deletions code/frameworks/nextjs/src/headers/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/* 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';
// 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';

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;
}

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((...args: [name: string] | [RequestCookie] | []) => {
const all = Array.from(this._parsed);
if (!args.length) {
return all.map(([_, value]) => value);
}

const name = typeof args[0] === 'string' ? args[0] : args[0]?.name;
return all.filter(([n]) => n === name).map(([_, value]) => value);
}).mockName('cookies().getAll');

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('; ');
}
}

let requestCookiesMock: RequestCookiesMock;

export const cookies = fn(() => {
if (!requestCookiesMock) {
requestCookiesMock = new RequestCookiesMock(headers());
}
return requestCookiesMock;
});

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

// will be called automatically by the test loader
cookies.mockRestore = () => {
originalRestore();
headers.mockRestore();
requestCookiesMock = new RequestCookiesMock(headers());
};
111 changes: 111 additions & 0 deletions code/frameworks/nextjs/src/headers/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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.
export class HeadersAdapterMock extends Headers implements HeadersAdapter {
private headers: IncomingHttpHeaders = {};

/**
* 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;
}

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);

return null;
}).mockName('headers().get');

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

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

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');

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;

yield [name, value];
}
}.bind(this)
).mockName('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');

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();
}
}

let headersAdapterMock: HeadersAdapterMock;

export const headers = () => {
if (!headersAdapterMock) headersAdapterMock = new HeadersAdapterMock();
return headersAdapterMock;
};

// This fn is called by ./cookies to restore the headers in the right order
headers.mockRestore = () => {
headersAdapterMock = new HeadersAdapterMock();
};
2 changes: 2 additions & 0 deletions code/frameworks/nextjs/src/headers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './headers';
export * from './cookies';
Loading

0 comments on commit fb121c8

Please sign in to comment.