Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,17 @@
"import/extensions": ["error", "never", { "svg": "always", "png": "always" }],
"import/no-cycle": "warn",
"import/order": "off",
"import/prefer-default-export": "warn",
"import/prefer-default-export": "off",
"import/no-extraneous-dependencies": ["error", {
"devDependencies": [
// these globs cannot be combined
".storybook/**",
"**/*.stories.*",
"**/*.test.*",
"**/test.*",
"vite.config.*"
]
}],
"max-len": [
"off",
{
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/npm-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run check-types
- run: npx playwright install chromium
- run: npm run test -- --reporter=default --reporter=junit --outputFile=coverage/junit.xml --coverage
- name: Publish Unit Test Results
uses: EnricoMi/publish-unit-test-result-action@v1.6
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,6 @@ Thumbs.db
/stats.json
/dist
/dist\ copy
.env.*.local
.env.*.local
*storybook.log
storybook-static
14 changes: 14 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineMain } from '@storybook/react-vite/node';

export default defineMain({
stories: [
'../src/**/*.stories.tsx'
],
addons: [
'@storybook/addon-vitest',
],
framework: '@storybook/react-vite',
features: {
experimentalTestSyntax: true,
},
});
134 changes: 134 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import React from 'react';
import ThemeProvider from '../src/theme';
import { initialize, mswLoader, getWorker } from 'msw-storybook-addon'
import { QueryClient, QueryClientProvider } from 'react-query';
import '../src/static/graphkb-env-config';
import { HttpResponse, ResponseResolverInfo, http as httpBase, isCommonAssetRequest } from 'msw';
import { GeneralRecordType, QueryBody } from '../src/components/types';
import { Decorator, definePreview, StoryObj } from '@storybook/react-vite';
import addonTest from '@storybook/addon-vitest';
import { MswParameters } from 'msw-storybook-addon'
import { PreviewAddon } from 'storybook/internal/csf';
import { MemoryRouter } from 'react-router-dom';
import { SnackbarProvider } from 'notistack';
import { AuthContext, AuthContextState } from '../src/components/Auth';
import { expect, waitFor } from 'storybook/test';

function wrapAsResponse<T extends Record<string, any> | any[]>(maybeResponse: T | HttpResponse<T>): HttpResponse<T> {
if (maybeResponse instanceof Response) {
return maybeResponse;
}
return HttpResponse.json(maybeResponse);
}

type AllowedResponse = Record<string, any> | any[] | HttpResponse<any>;

export const http = {
...httpBase,
gkb: {
get: (url: `/api/${string}`, resolver: (info: ResponseResolverInfo<any>) => AllowedResponse) => {
return httpBase.get(`${window._env_.API_BASE_URL}${url}`, (info) => {
return wrapAsResponse(resolver(info));
})
},
post: (url: `/api/${string}`, resolver: (body: any, info: ResponseResolverInfo<any>) => AllowedResponse) => {
return httpBase.post(`${window._env_.API_BASE_URL}${url}`, async (info) => {
const body = await info.request.clone().json();
return wrapAsResponse(resolver(body, info));
})
},
query: (resolver: (body: QueryBody) => (HttpResponse<Partial<GeneralRecordType>[]> | Partial<GeneralRecordType>[])) => http.gkb.post('/api/query', resolver)
}
}

// see https://github.com/mswjs/msw-storybook-addon/issues/180
try {
getWorker();
} catch (_) {
initialize({
onUnhandledRequest: (request, print) => {
if (request.url.startsWith(window._env_.API_BASE_URL) && !isCommonAssetRequest(request)) {
print.error();
}
},
// @ts-expect-error
quiet: import.meta.env.CI === 'true',
})
}

const snapshotAddon = () => ({}) as PreviewAddon<{ parameters: { snapshot?: boolean } }>;

const addonMsw = () => ({}) as PreviewAddon<{
parameters: MswParameters
}>;

const preview = definePreview({
addons: [addonTest(), addonMsw(), snapshotAddon()],
parameters: {
snapshot: true,
},
decorators: [
(Story) => <ThemeProvider><Story /></ThemeProvider>,
(Story, context) => {
return <QueryClientProvider client={context.loaded.queryClient}><Story /></QueryClientProvider>
}
],
loaders: [mswLoader, () => ({ queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }) })],
});

export async function hasFinishedLoading({ step, canvas }: Pick<Parameters<NonNullable<StoryObj['play']>>[0], 'step' | 'canvas'>) {
await step('Finished Loading', async () => {
await waitFor(async () => {
await expect(canvas.queryAllByRole('progressbar')).toHaveLength(0);
await expect(canvas.queryAllByText(/loading\.\.\./i)).toHaveLength(0);
}, { timeout: 10000 });
});
}

export const withSnackbar: Decorator = (Story) => <SnackbarProvider anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}><Story /></SnackbarProvider>;

interface WithAuthArgs {
auth?: Omit<Partial<AuthContextState>, 'user'> & { user?: Partial<AuthContextState['user']> };
}

export const withAuth: Decorator<WithAuthArgs> = (Story, { args }) => {
const auth = args.auth;
const authState: AuthContextState = {
login: () => { },
logout: () => { },
isAuthenticating: false,
isAuthenticated: true,
isAdmin: false,
hasWriteAccess: false,
error: undefined,
...auth,
user: auth?.user === null ? undefined : {
signedLicenseAt: new Date(2020, 0, 1).getTime(),
name: 'bmonkeys',
groups: [],
'@rid': '#1:1',
...auth?.user
},
}
return (
<AuthContext.Provider value={authState}>
<Story />
</AuthContext.Provider>
)
};

interface WithRouterArgs {
path?: string;
}

export const withRouter: Decorator<WithRouterArgs> = (Story, { args }) => {
const { path = '/' } = args;
return (
<MemoryRouter initialEntries={[path]}>
<Story />
</MemoryRouter>
)
}


export default preview;
16 changes: 16 additions & 0 deletions .storybook/tests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { waitFor } from '@testing-library/react';
import { expect, test } from 'vitest';

export function createSnapshotTest(testName: string, story: { run: () => Promise<void>; composed: { parameters: { snapshot: boolean; }}}) {
if (!story.composed.parameters?.snapshot) return;
return test(testName, async () => {
await story.run();
// make sure done loading
await waitFor(() => {
expect(document.querySelectorAll('.MuiTouchRipple-childLeaving')).toHaveLength(0);
});
const children = Array.from(document.body.childNodes).filter((node) => (node.nodeName !== '#text' || node.textContent?.trim() != '') && !(node as Element).classList?.contains('sb-wrapper'));
const child = children.length <= 1 ? children[0] : children;
expect(child).toMatchSnapshot();
});
}
73 changes: 67 additions & 6 deletions config/jest/windowEnvMock.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,72 @@
import { expect, afterEach } from 'vitest';
import { setProjectAnnotations } from '@storybook/react-vite';
import * as matchers from '@testing-library/jest-dom/matchers';
import { cleanup } from '@testing-library/react';
// necessary for extend-expect to work until upgrade to v6
global.expect = expect;
window.expect = expect;
import { plugins } from '@vitest/pretty-format'; // dependency of vitest
import { format } from 'date-fns';
import { afterEach, expect, beforeAll } from 'vitest';

import * as previewAnnotations from '#.storybook/preview';
process.env.REACT_APP_VERSION = '1.0.0'

const today = format(new Date(), 'yyyy-MM-dd');

/**
* used to normalize snapshots
*
* using snapshots for styles is too finicky to maintain
* and should swap to image snapshots if want that level
* of surity.
* currently just used as a safeguard that the content is there
* for cases where it would be much too combersome to avoid having to write asserts
* for every piece of content that should or should be be on the page
*
* for that reason this helper will replace any generated classnames, ids, uuids,
* relative dates, current dates etc. and omit style attributes entirely
*/
function replaceNonDeterministicValues(value) {
let fixed = value;
// uuids
fixed = fixed.replace(/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/g, ':uuid:');
// react useId
fixed = fixed.replace(/"\_r\_[0-9a-z]+_/g, '"_r_XXXX_')

fixed = fixed.replaceAll(/class="([^"]+)"/g, (_, classnames) => {
const classes = Array.from(new Set(classnames.trim().split(' '))).map((name) => name.replace(/css\-[0-9a-zA-Z]+/g, 'css-XXXX')).sort().join(' ');
return `class="${classes}"`;
});

fixed = fixed.replaceAll(/[ ]*style="([^"]+)"\n?/g, '');

fixed = fixed.replaceAll(new RegExp(`"${today}"`, 'g'), '":today:"');
fixed = fixed.replace(/page last updated \d+ [a-z]+ ago/g, 'page last updated XXXX ago');

// remove html comments
fixed = fixed.replace(/<!--[ a-zA-Z0-9_-]+-->/g, '');
// remove extra newlines
fixed = fixed.replace(/[ \n]*\n+/g, '\n');

return fixed;
}

expect.extend(matchers);
expect.addSnapshotSerializer({
serialize: (...args) => replaceNonDeterministicValues(plugins.DOMElement.serialize(...args)),
test: plugins.DOMElement.test,
});
expect.addSnapshotSerializer({
serialize: (...args) => replaceNonDeterministicValues(plugins.DOMCollection.serialize(...args)),
test: plugins.DOMCollection.test,
});
// omit <style> nodes
expect.addSnapshotSerializer({
serialize: () => '',
test: (val) => plugins.DOMElement.test(val) && val?.tagName === 'STYLE',
});

const annotations = setProjectAnnotations([previewAnnotations]);

beforeAll(annotations.beforeAll);

// Automatically clean up the DOM after each test run
afterEach(() => {
cleanup(); // from @testing-library/react
});
Expand All @@ -18,4 +80,3 @@ window._env_ = {
CONTACT_EMAIL: process.env.CONTACT_EMAIL || "graphkb@bcgsc.ca",
CONTACT_TICKET_URL: process.env.CONTACT_TICKET_URL || "https://www.bcgsc.ca/jira/projects/KBDEV",
};

Loading
Loading