Skip to content

Commit a09ee70

Browse files
committed
Add Storybook tests
Prevent regressions in our custom components by adding Storybook testing. Copy Storybook tests from the archived `gravitational/docs` repo. Add a rough-and-ready Storybook configuration to enable tests to pass. One significant complication is that Docusaurus generates a Webpack configuration when building a docs site. There are Storybook frameworks and add-ons for Docusaurus that take advantage of Docusaurus's asset-loading logic, but none are currently being maintained. The quickest thing we can do is add a separate Webpack configuration that renders components in Storybook similarly (but not identically) to the Docusaurus site. A separate change can refine this approach by, for example: - Vendoring a Storybook Docusaurus framework - Migrating Storybook tests to `react-testing-library`
1 parent fdd3a6c commit a09ee70

File tree

15 files changed

+2621
-128
lines changed

15 files changed

+2621
-128
lines changed

.github/workflows/test.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,9 @@ jobs:
88
runs-on: ubuntu-22.04-2core-arm64
99
steps:
1010
- uses: actions/checkout@v4
11-
- run: yarn && yarn test
11+
- name: Install deps
12+
run: yarn && yarn playwright install --with-deps
13+
- name: Run unit tests
14+
run: yarn test
15+
- name: Run Storybook tests
16+
run: yarn storybook:test-ci

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# Generated files
88
.docusaurus
99
.cache-loader
10+
storybook-static
1011

1112
# Misc
1213
.env

.storybook/@types/imports.d.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Declare modules so Storybook can import assets as expected. Docusaurus
2+
// handles this on its own, so we need to redeclare these modules here for
3+
// Storybook.
4+
5+
declare module "*.css";
6+
7+
declare module "*.svg";
8+
9+
declare module "*.svg?react" {
10+
const Component: React.StatelessComponent<React.SVGAttributes<SVGElement>>;
11+
12+
export default Component;
13+
}
14+
15+
declare module "*.png" {
16+
const value: string;
17+
export default value;
18+
}
19+
20+
declare module "*.webp" {
21+
const value: string;
22+
export default value;
23+
}
24+
25+
declare module "*.jpg" {
26+
const value: string;
27+
export default value;
28+
}
29+
30+
declare module "*.woff2" {
31+
const value: string;
32+
export default value;
33+
}

.storybook/main.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { StorybookConfig } from "@storybook/react-webpack5";
2+
3+
const config: StorybookConfig = {
4+
framework: "@storybook/react-webpack5",
5+
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
6+
addons: ["@storybook/addon-essentials"],
7+
webpackFinal: async (config) => {
8+
config.module?.rules?.push({
9+
test: /\.css$/,
10+
use: {
11+
loader: "postcss-loader",
12+
},
13+
});
14+
15+
const imageRule = config.module?.rules?.find((rule) => {
16+
const test = (rule as { test: RegExp }).test;
17+
18+
if (!test) {
19+
return false;
20+
}
21+
22+
return test.test(".svg");
23+
}) as { [key: string]: any };
24+
25+
imageRule.exclude = /\.svg$/;
26+
27+
config.module?.rules?.push({
28+
test: /\.svg$/,
29+
use: ["@svgr/webpack"],
30+
});
31+
32+
config.module?.rules?.push({
33+
test: /\.tsx?$/,
34+
use: [
35+
{
36+
loader: "ts-loader",
37+
options: {
38+
logLevel: "INFO",
39+
logInfoToStdOut: true,
40+
configFile: "tsconfig.storybook.json",
41+
// Otherwise, properties added by Storybook trip the TypeScript
42+
// checker.
43+
transpileOnly: true,
44+
},
45+
},
46+
],
47+
exclude: /node_modules/,
48+
});
49+
50+
return config;
51+
},
52+
};
53+
export default config;

.storybook/preview.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Preview } from "@storybook/react-webpack5";
2+
3+
// See the following documentation for how this configuration loads CSS styles
4+
// for Storybook stories:
5+
// https://storybook.js.org/docs/configure/styling-and-css#import-bundled-css-recommended
6+
import "../src/styles/variables.css";
7+
import "../src/styles/fonts-ubuntu.css";
8+
import "../src/styles/global.css";
9+
import "../src/styles/media.css";
10+
import "../src/styles/fonts-lato.css";
11+
12+
const preview: Preview = {
13+
parameters: {},
14+
};
15+
16+
export default preview;

package.json

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
"lint": "yarn base:eslint --fix && yarn base:prettier --write -l",
2424
"lint-check": "yarn base:eslint && yarn base:prettier --check",
2525
"markdown-lint": "yarn build-remark && remark --rc-path .remarkrc.mjs 'content/**/docs/pages/**/*.mdx' --quiet --frail --ignore-pattern '**/includes/**' --silently-ignore",
26+
"storybook": "storybook dev -p 6006",
27+
"storybook:build": "storybook build",
28+
"storybook:test-ci": "yarn storybook:build --quiet && npx concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:6006 && yarn storybook:test\"",
29+
"storybook:test": "test-storybook",
2630
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --trace-warnings --experimental-vm-modules\" jest --config ./jest.server.config.mjs server/*.test.ts"
2731
},
2832
"lint-staged": {
@@ -57,32 +61,42 @@
5761
"nanoid": "^5.0.9",
5862
"postcss-preset-env": "^9.5.14",
5963
"prism-react-renderer": "^2.3.0",
64+
"react": "^18.3.1",
6065
"react-dom": "^18.3.1",
6166
"react-loadable": "^5.5.0",
6267
"react-use": "^17.5.0",
63-
"react": "^18.3.1",
6468
"rehype-highlight": "^7.0.2",
6569
"remark-mdx": "^2.1.1",
6670
"vite-node": "^3.0.5"
6771
},
6872
"devDependencies": {
6973
"@docusaurus/module-type-aliases": "^3.6.3",
7074
"@docusaurus/types": "^3.6.3",
75+
"@storybook/addon-essentials": "^8.5.2",
76+
"@storybook/addon-webpack5-compiler-swc": "^2.0.0",
77+
"@storybook/react": "^8.5.2",
78+
"@storybook/react-webpack5": "^8.5.2",
79+
"@storybook/test": "^8.5.2",
80+
"@storybook/test-runner": "^0.21.0",
81+
"@svgr/webpack": "^8.1.0",
7182
"@types/react": "^18.3.3",
7283
"ajv": "^8.16.0",
84+
"concurrently": "9.1.2",
7385
"hast": "^1.0.0",
86+
"http-server": "14.1.1",
7487
"jest": "^29.7.0",
7588
"js-yaml": "^4.1.0",
7689
"loadr": "^0.1.1",
90+
"mdast": "^3.0.0",
7791
"mdast-util-from-markdown": "^2.0.1",
7892
"mdast-util-frontmatter": "^2.0.1",
7993
"mdast-util-gfm": "^3.0.0",
8094
"mdast-util-mdx": "^3.0.0",
81-
"mdast": "^3.0.0",
8295
"micromark-extension-frontmatter": "^2.0.0",
8396
"micromark-extension-gfm": "^3.0.0",
8497
"micromark-extension-mdxjs": "^3.0.0",
8598
"postcss": "^8.4.38",
99+
"postcss-loader": "^8.1.1",
86100
"rehype-stringify": "^10.0.1",
87101
"remark-cli": "10.0.1",
88102
"remark-copy-linked-files": "^1.5.0",
@@ -92,16 +106,18 @@
92106
"remark-preset-lint-markdown-style-guide": "^5.1.2",
93107
"remark-rehype": "^10.1.0",
94108
"remark-validate-links": "^11.0.2",
109+
"storybook": "^8.5.2",
95110
"to-vfile": "^8.0.0",
96111
"ts-jest": "^29.2.5",
112+
"ts-loader": "^9.5.2",
97113
"tsc-esm-fix": "^3.1.0",
98114
"tsm": "^2.3.0",
99115
"typescript": "~5.5.2",
100-
"unified-lint-rule": "^3.0.0",
101116
"unified": "^11.0.5",
117+
"unified-lint-rule": "^3.0.0",
118+
"unist": "^0.0.1",
102119
"unist-util-find": "^3.0.0",
103120
"unist-util-visit-parents": "^6.0.1",
104-
"unist": "^0.0.1",
105121
"vfile": "^6.0.1"
106122
},
107123
"browserslist": {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { userEvent, within } from "@storybook/test";
3+
import { replaceClipboardWithCopyBuffer } from "/src/utils/clipboard";
4+
5+
import Command from "./Command";
6+
7+
const commandText = "yarn install";
8+
9+
const meta: Meta<typeof Command> = {
10+
title: "components/Command",
11+
component: Command,
12+
args: {
13+
children: <span>{commandText}</span>,
14+
},
15+
};
16+
export default meta;
17+
type Story = StoryObj<typeof Command>;
18+
19+
export const SimpleCommand: Story = {
20+
args: {
21+
children: <span>{commandText}</span>,
22+
},
23+
};
24+
25+
export const CopyButton: Story = {
26+
play: async ({ canvasElement, step }) => {
27+
replaceClipboardWithCopyBuffer();
28+
const canvas = within(canvasElement);
29+
await step("Hover and click on copy button", async () => {
30+
await userEvent.hover(canvas.getByTestId("copy-button"));
31+
await userEvent.click(canvas.getByTestId("copy-button"));
32+
});
33+
},
34+
};
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { userEvent, within } from "@storybook/test";
3+
import { expect } from "@storybook/test";
4+
5+
import { Var } from "../Variables/Var";
6+
import { default as Snippet } from "./Snippet";
7+
import Command, { CommandLine, CommandComment } from "../Command/Command";
8+
import { CodeLine } from "/src/theme/MDXComponents/Code";
9+
import { replaceClipboardWithCopyBuffer } from "/src/utils/clipboard";
10+
11+
export const SimpleCommand = () => (
12+
<Snippet>
13+
<Command>
14+
<CommandLine data-content="$ ">echo Hello world!</CommandLine>
15+
</Command>
16+
</Snippet>
17+
);
18+
19+
const meta: Meta<typeof Snippet> = {
20+
title: "components/Snippet",
21+
component: SimpleCommand,
22+
};
23+
export default meta;
24+
type Story = StoryObj<typeof Snippet>;
25+
26+
export const CopyCommandVar: Story = {
27+
render: () => {
28+
return (
29+
<Snippet>
30+
<Command>
31+
<CommandLine data-content="$ ">
32+
curl https://
33+
<Var name="example.com" isGlobal={false} description="" />
34+
/v1/webapi/saml/acs/azure-saml
35+
</CommandLine>
36+
</Command>
37+
</Snippet>
38+
);
39+
},
40+
play: async ({ canvasElement, step }) => {
41+
replaceClipboardWithCopyBuffer();
42+
const canvas = within(canvasElement);
43+
44+
await step("Copy the content", async () => {
45+
await userEvent.hover(canvas.getByText("example.com"));
46+
await userEvent.click(canvas.getByTestId("copy-button"));
47+
expect(navigator.clipboard.readText()).toEqual(
48+
"curl https://example.com/v1/webapi/saml/acs/azure-saml",
49+
);
50+
await userEvent.click(canvas.getByTestId("copy-button-all"));
51+
expect(navigator.clipboard.readText()).toEqual(
52+
"curl https://example.com/v1/webapi/saml/acs/azure-saml",
53+
);
54+
});
55+
},
56+
};
57+
58+
// A code snippet with commands should only copy the commands.
59+
export const CopyCommandVarWithOutput: Story = {
60+
render: () => {
61+
return (
62+
<Snippet>
63+
<Command>
64+
<CommandLine data-content="$ ">
65+
curl https://
66+
<Var name="example.com" isGlobal={false} description="" />
67+
/v1/webapi/saml/acs/azure-saml
68+
</CommandLine>
69+
</Command>
70+
<CodeLine>
71+
The output of curling <Var name="example.com" />
72+
</CodeLine>
73+
</Snippet>
74+
);
75+
},
76+
play: async ({ canvasElement, step }) => {
77+
replaceClipboardWithCopyBuffer();
78+
const canvas = within(canvasElement);
79+
80+
await step("Copy the content", async () => {
81+
await userEvent.click(canvas.getByTestId("copy-button-all"));
82+
expect(navigator.clipboard.readText()).toEqual(
83+
"curl https://example.com/v1/webapi/saml/acs/azure-saml",
84+
);
85+
});
86+
},
87+
};
88+
89+
// A code snippet with no commands should copy all content within the snippet.
90+
export const CopyCodeLineVar: Story = {
91+
render: () => {
92+
return (
93+
<Snippet>
94+
<CodeLine>
95+
curl https://
96+
<Var name="example.com" isGlobal={false} description="" />
97+
/v1/webapi/saml/acs/azure-saml
98+
</CodeLine>
99+
</Snippet>
100+
);
101+
},
102+
play: async ({ canvasElement, step }) => {
103+
replaceClipboardWithCopyBuffer();
104+
105+
const canvas = within(canvasElement);
106+
107+
await step("Copy the content", async () => {
108+
await userEvent.hover(canvas.getByText("example.com"));
109+
await userEvent.click(canvas.getByTestId("copy-button-all"));
110+
expect(navigator.clipboard.readText()).toEqual(
111+
"curl https://example.com/v1/webapi/saml/acs/azure-saml",
112+
);
113+
});
114+
},
115+
};

0 commit comments

Comments
 (0)