Skip to content

Commit

Permalink
Add Storybook tests
Browse files Browse the repository at this point in the history
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`
  • Loading branch information
ptgott committed Feb 27, 2025
1 parent fdd3a6c commit 65a3581
Show file tree
Hide file tree
Showing 15 changed files with 2,623 additions and 128 deletions.
9 changes: 8 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@ jobs:
runs-on: ubuntu-22.04-2core-arm64
steps:
- uses: actions/checkout@v4
- run: yarn && yarn test
- name: Install deps
run: yarn && yarn playwright install --with-deps
- name: Run unit tests
run: yarn test
- name: Build Storybook
run: yarn storybook:build
- name: Run Storybook tests
run: yarn storybook:test-ci
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# Generated files
.docusaurus
.cache-loader
storybook-static

# Misc
.env
Expand Down
33 changes: 33 additions & 0 deletions .storybook/@types/imports.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Declare modules so Storybook can import assets as expected. Docusaurus
// handles this on its own, so we need to redeclare these modules here for
// Storybook.

declare module "*.css";

declare module "*.svg";

declare module "*.svg?react" {
const Component: React.StatelessComponent<React.SVGAttributes<SVGElement>>;

export default Component;
}

declare module "*.png" {
const value: string;
export default value;
}

declare module "*.webp" {
const value: string;
export default value;
}

declare module "*.jpg" {
const value: string;
export default value;
}

declare module "*.woff2" {
const value: string;
export default value;
}
53 changes: 53 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { StorybookConfig } from "@storybook/react-webpack5";

const config: StorybookConfig = {
framework: "@storybook/react-webpack5",
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: ["@storybook/addon-essentials"],
webpackFinal: async (config) => {
config.module?.rules?.push({
test: /\.css$/,
use: {
loader: "postcss-loader",
},
});

const imageRule = config.module?.rules?.find((rule) => {
const test = (rule as { test: RegExp }).test;

if (!test) {
return false;
}

return test.test(".svg");
}) as { [key: string]: any };

imageRule.exclude = /\.svg$/;

config.module?.rules?.push({
test: /\.svg$/,
use: ["@svgr/webpack"],
});

config.module?.rules?.push({
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: {
logLevel: "INFO",
logInfoToStdOut: true,
configFile: "tsconfig.storybook.json",
// Otherwise, properties added by Storybook trip the TypeScript
// checker.
transpileOnly: true,
},
},
],
exclude: /node_modules/,
});

return config;
},
};
export default config;
16 changes: 16 additions & 0 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Preview } from "@storybook/react-webpack5";

// See the following documentation for how this configuration loads CSS styles
// for Storybook stories:
// https://storybook.js.org/docs/configure/styling-and-css#import-bundled-css-recommended
import "../src/styles/variables.css";
import "../src/styles/fonts-ubuntu.css";
import "../src/styles/global.css";
import "../src/styles/media.css";
import "../src/styles/fonts-lato.css";

const preview: Preview = {
parameters: {},
};

export default preview;
24 changes: 20 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
"lint": "yarn base:eslint --fix && yarn base:prettier --write -l",
"lint-check": "yarn base:eslint && yarn base:prettier --check",
"markdown-lint": "yarn build-remark && remark --rc-path .remarkrc.mjs 'content/**/docs/pages/**/*.mdx' --quiet --frail --ignore-pattern '**/includes/**' --silently-ignore",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build",
"storybook:test-ci": "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\"",
"storybook:test": "test-storybook",
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --trace-warnings --experimental-vm-modules\" jest --config ./jest.server.config.mjs server/*.test.ts"
},
"lint-staged": {
Expand Down Expand Up @@ -57,32 +61,42 @@
"nanoid": "^5.0.9",
"postcss-preset-env": "^9.5.14",
"prism-react-renderer": "^2.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-loadable": "^5.5.0",
"react-use": "^17.5.0",
"react": "^18.3.1",
"rehype-highlight": "^7.0.2",
"remark-mdx": "^2.1.1",
"vite-node": "^3.0.5"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.6.3",
"@docusaurus/types": "^3.6.3",
"@storybook/addon-essentials": "^8.5.2",
"@storybook/addon-webpack5-compiler-swc": "^2.0.0",
"@storybook/react": "^8.5.2",
"@storybook/react-webpack5": "^8.5.2",
"@storybook/test": "^8.5.2",
"@storybook/test-runner": "^0.21.0",
"@svgr/webpack": "^8.1.0",
"@types/react": "^18.3.3",
"ajv": "^8.16.0",
"concurrently": "9.1.2",
"hast": "^1.0.0",
"http-server": "14.1.1",
"jest": "^29.7.0",
"js-yaml": "^4.1.0",
"loadr": "^0.1.1",
"mdast": "^3.0.0",
"mdast-util-from-markdown": "^2.0.1",
"mdast-util-frontmatter": "^2.0.1",
"mdast-util-gfm": "^3.0.0",
"mdast-util-mdx": "^3.0.0",
"mdast": "^3.0.0",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"micromark-extension-mdxjs": "^3.0.0",
"postcss": "^8.4.38",
"postcss-loader": "^8.1.1",
"rehype-stringify": "^10.0.1",
"remark-cli": "10.0.1",
"remark-copy-linked-files": "^1.5.0",
Expand All @@ -92,16 +106,18 @@
"remark-preset-lint-markdown-style-guide": "^5.1.2",
"remark-rehype": "^10.1.0",
"remark-validate-links": "^11.0.2",
"storybook": "^8.5.2",
"to-vfile": "^8.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"tsc-esm-fix": "^3.1.0",
"tsm": "^2.3.0",
"typescript": "~5.5.2",
"unified-lint-rule": "^3.0.0",
"unified": "^11.0.5",
"unified-lint-rule": "^3.0.0",
"unist": "^0.0.1",
"unist-util-find": "^3.0.0",
"unist-util-visit-parents": "^6.0.1",
"unist": "^0.0.1",
"vfile": "^6.0.1"
},
"browserslist": {
Expand Down
34 changes: 34 additions & 0 deletions src/components/Command/Command.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within } from "@storybook/test";
import { replaceClipboardWithCopyBuffer } from "/src/utils/clipboard";

import Command from "./Command";

const commandText = "yarn install";

const meta: Meta<typeof Command> = {
title: "components/Command",
component: Command,
args: {
children: <span>{commandText}</span>,
},
};
export default meta;
type Story = StoryObj<typeof Command>;

export const SimpleCommand: Story = {
args: {
children: <span>{commandText}</span>,
},
};

export const CopyButton: Story = {
play: async ({ canvasElement, step }) => {
replaceClipboardWithCopyBuffer();
const canvas = within(canvasElement);
await step("Hover and click on copy button", async () => {
await userEvent.hover(canvas.getByTestId("copy-button"));
await userEvent.click(canvas.getByTestId("copy-button"));
});
},
};
115 changes: 115 additions & 0 deletions src/components/Snippet/Snippet.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within } from "@storybook/test";
import { expect } from "@storybook/test";

import { Var } from "../Variables/Var";
import { default as Snippet } from "./Snippet";
import Command, { CommandLine, CommandComment } from "../Command/Command";
import { CodeLine } from "/src/theme/MDXComponents/Code";
import { replaceClipboardWithCopyBuffer } from "/src/utils/clipboard";

export const SimpleCommand = () => (
<Snippet>
<Command>
<CommandLine data-content="$ ">echo Hello world!</CommandLine>
</Command>
</Snippet>
);

const meta: Meta<typeof Snippet> = {
title: "components/Snippet",
component: SimpleCommand,
};
export default meta;
type Story = StoryObj<typeof Snippet>;

export const CopyCommandVar: Story = {
render: () => {
return (
<Snippet>
<Command>
<CommandLine data-content="$ ">
curl https://
<Var name="example.com" isGlobal={false} description="" />
/v1/webapi/saml/acs/azure-saml
</CommandLine>
</Command>
</Snippet>
);
},
play: async ({ canvasElement, step }) => {
replaceClipboardWithCopyBuffer();
const canvas = within(canvasElement);

await step("Copy the content", async () => {
await userEvent.hover(canvas.getByText("example.com"));
await userEvent.click(canvas.getByTestId("copy-button"));
expect(navigator.clipboard.readText()).toEqual(
"curl https://example.com/v1/webapi/saml/acs/azure-saml",
);
await userEvent.click(canvas.getByTestId("copy-button-all"));
expect(navigator.clipboard.readText()).toEqual(
"curl https://example.com/v1/webapi/saml/acs/azure-saml",
);
});
},
};

// A code snippet with commands should only copy the commands.
export const CopyCommandVarWithOutput: Story = {
render: () => {
return (
<Snippet>
<Command>
<CommandLine data-content="$ ">
curl https://
<Var name="example.com" isGlobal={false} description="" />
/v1/webapi/saml/acs/azure-saml
</CommandLine>
</Command>
<CodeLine>
The output of curling <Var name="example.com" />
</CodeLine>
</Snippet>
);
},
play: async ({ canvasElement, step }) => {
replaceClipboardWithCopyBuffer();
const canvas = within(canvasElement);

await step("Copy the content", async () => {
await userEvent.click(canvas.getByTestId("copy-button-all"));
expect(navigator.clipboard.readText()).toEqual(
"curl https://example.com/v1/webapi/saml/acs/azure-saml",
);
});
},
};

// A code snippet with no commands should copy all content within the snippet.
export const CopyCodeLineVar: Story = {
render: () => {
return (
<Snippet>
<CodeLine>
curl https://
<Var name="example.com" isGlobal={false} description="" />
/v1/webapi/saml/acs/azure-saml
</CodeLine>
</Snippet>
);
},
play: async ({ canvasElement, step }) => {
replaceClipboardWithCopyBuffer();

const canvas = within(canvasElement);

await step("Copy the content", async () => {
await userEvent.hover(canvas.getByText("example.com"));
await userEvent.click(canvas.getByTestId("copy-button-all"));
expect(navigator.clipboard.readText()).toEqual(
"curl https://example.com/v1/webapi/saml/acs/azure-saml",
);
});
},
};
Loading

0 comments on commit 65a3581

Please sign in to comment.